Add AI insights feature

This commit is contained in:
Urtzi Alfaro
2025-12-15 21:14:22 +01:00
parent 5642b5a0c0
commit c566967bea
39 changed files with 17729 additions and 404 deletions

View File

@@ -358,13 +358,66 @@ async def clone_demo_data(
except KeyError:
process_stage_value = None
# Transform foreign key references (product_id, recipe_id, order_id, forecast_id)
transformed_product_id = None
if batch_data.get('product_id'):
try:
transformed_product_id = str(transform_id(batch_data['product_id'], virtual_uuid))
except (ValueError, Exception) as e:
logger.warning("Failed to transform product_id",
product_id=batch_data.get('product_id'),
error=str(e))
transformed_recipe_id = None
if batch_data.get('recipe_id'):
try:
transformed_recipe_id = str(transform_id(batch_data['recipe_id'], virtual_uuid))
except (ValueError, Exception) as e:
logger.warning("Failed to transform recipe_id",
recipe_id=batch_data.get('recipe_id'),
error=str(e))
transformed_order_id = None
if batch_data.get('order_id'):
try:
transformed_order_id = str(transform_id(batch_data['order_id'], virtual_uuid))
except (ValueError, Exception) as e:
logger.warning("Failed to transform order_id",
order_id=batch_data.get('order_id'),
error=str(e))
transformed_forecast_id = None
if batch_data.get('forecast_id'):
try:
transformed_forecast_id = str(transform_id(batch_data['forecast_id'], virtual_uuid))
except (ValueError, Exception) as e:
logger.warning("Failed to transform forecast_id",
forecast_id=batch_data.get('forecast_id'),
error=str(e))
# Transform equipment_used array
transformed_equipment = []
if batch_data.get('equipment_used'):
for equip_id in batch_data['equipment_used']:
try:
transformed_equipment.append(str(transform_id(equip_id, virtual_uuid)))
except (ValueError, Exception) as e:
logger.warning("Failed to transform equipment_id",
equipment_id=equip_id,
error=str(e))
# staff_assigned contains user IDs - these should NOT be transformed
# because they reference actual user accounts which are NOT cloned
# The demo uses the same user accounts across all virtual tenants
staff_assigned = batch_data.get('staff_assigned', [])
new_batch = ProductionBatch(
id=str(transformed_id),
tenant_id=virtual_uuid,
batch_number=f"{session_id[:8]}-{batch_data.get('batch_number', f'BATCH-{uuid.uuid4().hex[:8].upper()}')}",
product_id=batch_data.get('product_id'),
product_id=transformed_product_id,
product_name=batch_data.get('product_name'),
recipe_id=batch_data.get('recipe_id'),
recipe_id=transformed_recipe_id,
planned_start_time=adjusted_planned_start,
planned_end_time=adjusted_planned_end,
planned_quantity=batch_data.get('planned_quantity'),
@@ -389,11 +442,11 @@ async def clone_demo_data(
waste_quantity=batch_data.get('waste_quantity'),
defect_quantity=batch_data.get('defect_quantity'),
waste_defect_type=batch_data.get('waste_defect_type'),
equipment_used=batch_data.get('equipment_used'),
staff_assigned=batch_data.get('staff_assigned'),
equipment_used=transformed_equipment,
staff_assigned=staff_assigned,
station_id=batch_data.get('station_id'),
order_id=batch_data.get('order_id'),
forecast_id=batch_data.get('forecast_id'),
order_id=transformed_order_id,
forecast_id=transformed_forecast_id,
is_rush_order=batch_data.get('is_rush_order', False),
is_special_recipe=batch_data.get('is_special_recipe', False),
is_ai_assisted=batch_data.get('is_ai_assisted', False),

View File

@@ -7,7 +7,7 @@ Provides endpoints to trigger ML insight generation for:
- Process efficiency analysis
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from typing import Optional, List
from uuid import UUID
@@ -71,6 +71,7 @@ class YieldPredictionResponse(BaseModel):
async def trigger_yield_prediction(
tenant_id: str,
request_data: YieldPredictionRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
@@ -81,10 +82,12 @@ async def trigger_yield_prediction(
2. Runs the YieldInsightsOrchestrator to predict yields
3. Generates insights about yield optimization opportunities
4. Posts insights to AI Insights Service
5. Publishes recommendation events to RabbitMQ
Args:
tenant_id: Tenant UUID
request_data: Prediction parameters
request: FastAPI request (for app state access)
db: Database session
Returns:
@@ -103,8 +106,13 @@ async def trigger_yield_prediction(
from shared.clients.recipes_client import RecipesServiceClient
from app.core.config import settings
# Get event publisher from app state (if available)
event_publisher = getattr(request.app.state, 'event_publisher', None) if hasattr(request, 'app') else None
# Initialize orchestrator and recipes client
orchestrator = YieldInsightsOrchestrator()
orchestrator = YieldInsightsOrchestrator(
event_publisher=event_publisher
)
recipes_client = RecipesServiceClient(settings)
# Get recipes to analyze from recipes service via API
@@ -186,12 +194,18 @@ async def trigger_yield_prediction(
continue # Skip batches without complete data
production_data.append({
'production_date': batch.actual_start_time,
'production_run_id': str(batch.id), # Required: unique identifier for each production run
'recipe_id': str(batch.recipe_id), # Required: recipe identifier
'started_at': batch.actual_start_time,
'completed_at': batch.actual_end_time, # Optional but useful for duration analysis
'batch_size': float(batch.planned_quantity), # Use planned_quantity as batch_size
'planned_quantity': float(batch.planned_quantity),
'actual_quantity': float(batch.actual_quantity),
'yield_percentage': yield_pct,
'worker_id': batch.notes or 'unknown', # Use notes field or default
'batch_number': batch.batch_number
'staff_assigned': batch.staff_assigned if batch.staff_assigned else ['unknown'],
'batch_number': batch.batch_number,
'equipment_id': batch.equipment_used[0] if batch.equipment_used and len(batch.equipment_used) > 0 else None,
'notes': batch.quality_notes # Optional quality notes
})
if not production_data:
@@ -202,6 +216,14 @@ async def trigger_yield_prediction(
production_history = pd.DataFrame(production_data)
# Debug: Log DataFrame columns and sample data
logger.debug(
"Production history DataFrame created",
recipe_id=recipe_id,
columns=list(production_history.columns),
sample_data=production_history.head(1).to_dict('records') if len(production_history) > 0 else None
)
# Run yield analysis
results = await orchestrator.analyze_and_post_insights(
tenant_id=tenant_id,
@@ -291,8 +313,6 @@ async def ml_insights_health():
# INTERNAL ENDPOINTS (for demo-session service)
# ================================================================
from fastapi import Request
# Create a separate router for internal endpoints to avoid the tenant prefix
internal_router = APIRouter(
tags=["ML Insights - Internal"]
@@ -347,6 +367,7 @@ async def generate_yield_insights_internal(
result = await trigger_yield_prediction(
tenant_id=tenant_id,
request_data=request_data,
request=request,
db=db
)

View File

@@ -142,6 +142,7 @@ class ProductionService(StandardFastAPIService):
app.state.production_alert_service = self.alert_service # Also store with this name for internal trigger
app.state.notification_service = self.notification_service # Notification service for state change events
app.state.production_scheduler = self.production_scheduler # Store scheduler for manual triggering
app.state.event_publisher = self.event_publisher # Store event publisher for ML insights
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for production service"""

View File

@@ -14,6 +14,7 @@ import os
# Add shared clients to path
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../..'))
from shared.clients.ai_insights_client import AIInsightsClient
from shared.messaging import UnifiedEventPublisher
from app.ml.yield_predictor import YieldPredictor
@@ -28,15 +29,18 @@ class YieldInsightsOrchestrator:
1. Predict yield for upcoming production run or analyze historical performance
2. Generate insights for yield optimization opportunities
3. Post insights to AI Insights Service
4. Provide yield predictions for production planning
4. Publish recommendation events to RabbitMQ
5. Provide yield predictions for production planning
"""
def __init__(
self,
ai_insights_base_url: str = "http://ai-insights-service:8000"
ai_insights_base_url: str = "http://ai-insights-service:8000",
event_publisher: Optional[UnifiedEventPublisher] = None
):
self.predictor = YieldPredictor()
self.ai_insights_client = AIInsightsClient(ai_insights_base_url)
self.event_publisher = event_publisher
async def predict_and_post_insights(
self,
@@ -54,7 +58,7 @@ class YieldInsightsOrchestrator:
recipe_id: Recipe identifier
production_history: Historical production runs
production_context: Upcoming production context:
- worker_id
- staff_assigned (list of staff IDs)
- planned_start_time
- batch_size
- planned_quantity
@@ -109,6 +113,17 @@ class YieldInsightsOrchestrator:
successful=post_results['successful'],
failed=post_results['failed']
)
# Step 4: Publish recommendation events to RabbitMQ
created_insights = post_results.get('created_insights', [])
if created_insights:
recipe_context = production_context.copy() if production_context else {}
recipe_context['recipe_id'] = recipe_id
await self._publish_insight_events(
tenant_id=tenant_id,
insights=created_insights,
recipe_context=recipe_context
)
else:
post_results = {'total': 0, 'successful': 0, 'failed': 0}
logger.info("No insights to post for recipe", recipe_id=recipe_id)
@@ -193,6 +208,15 @@ class YieldInsightsOrchestrator:
total=post_results['total'],
successful=post_results['successful']
)
# Step 4: Publish recommendation events to RabbitMQ
created_insights = post_results.get('created_insights', [])
if created_insights:
await self._publish_insight_events(
tenant_id=tenant_id,
insights=created_insights,
recipe_context={'recipe_id': recipe_id}
)
else:
post_results = {'total': 0, 'successful': 0, 'failed': 0}
@@ -248,6 +272,83 @@ class YieldInsightsOrchestrator:
return enriched
async def _publish_insight_events(
self,
tenant_id: str,
insights: List[Dict[str, Any]],
recipe_context: Optional[Dict[str, Any]] = None
) -> None:
"""
Publish recommendation events to RabbitMQ for each insight.
Args:
tenant_id: Tenant identifier
insights: List of created insights (with insight_id from AI Insights Service)
recipe_context: Optional recipe context (name, id, etc.)
"""
if not self.event_publisher:
logger.warning("Event publisher not configured, skipping event publication")
return
for insight in insights:
try:
# Determine severity based on confidence and priority
confidence = insight.get('confidence', 0)
priority = insight.get('priority', 'medium')
if priority == 'urgent' or confidence >= 90:
severity = 'urgent'
elif priority == 'high' or confidence >= 70:
severity = 'high'
elif priority == 'medium' or confidence >= 50:
severity = 'medium'
else:
severity = 'low'
# Build event metadata
event_metadata = {
'insight_id': insight.get('id'), # From AI Insights Service response
'insight_type': insight.get('insight_type'),
'recipe_id': insight.get('metrics_json', {}).get('recipe_id'),
'recipe_name': recipe_context.get('recipe_name') if recipe_context else None,
'predicted_yield': insight.get('metrics_json', {}).get('predicted_yield'),
'confidence': confidence,
'recommendation': insight.get('recommendation'),
'impact_type': insight.get('impact_type'),
'impact_value': insight.get('impact_value'),
'source_service': 'production',
'source_model': 'yield_predictor'
}
# Remove None values
event_metadata = {k: v for k, v in event_metadata.items() if v is not None}
# Publish recommendation event
await self.event_publisher.publish_recommendation(
event_type='ai_yield_prediction',
tenant_id=tenant_id,
severity=severity,
data=event_metadata
)
logger.info(
"Published yield insight recommendation event",
tenant_id=tenant_id,
insight_id=insight.get('id'),
insight_type=insight.get('insight_type'),
severity=severity
)
except Exception as e:
logger.error(
"Failed to publish insight event",
tenant_id=tenant_id,
insight_id=insight.get('id'),
error=str(e),
exc_info=True
)
# Don't raise - we don't want to fail the whole workflow if event publishing fails
async def analyze_all_recipes(
self,
tenant_id: str,

View File

@@ -62,14 +62,14 @@ class YieldPredictor:
- planned_quantity
- actual_quantity
- yield_percentage
- worker_id
- staff_assigned (list of staff IDs)
- started_at
- completed_at
- batch_size
- equipment_id (optional)
- notes (optional)
production_context: Upcoming production context:
- worker_id
- staff_assigned (list of staff IDs)
- planned_start_time
- batch_size
- equipment_id (optional)
@@ -212,6 +212,9 @@ class YieldPredictor:
df['is_small_batch'] = (df['batch_size'] < df['batch_size'].quantile(0.25)).astype(int)
# Worker experience features (proxy: number of previous runs)
# Extract first worker from staff_assigned list
df['worker_id'] = df['staff_assigned'].apply(lambda x: x[0] if isinstance(x, list) and len(x) > 0 else 'unknown')
df = df.sort_values('started_at')
df['worker_run_count'] = df.groupby('worker_id').cumcount() + 1
df['worker_experience_level'] = pd.cut(
@@ -232,6 +235,10 @@ class YieldPredictor:
factors = {}
# Worker impact
# Extract worker_id from staff_assigned for analysis
if 'worker_id' not in feature_df.columns:
feature_df['worker_id'] = feature_df['staff_assigned'].apply(lambda x: x[0] if isinstance(x, list) and len(x) > 0 else 'unknown')
worker_yields = feature_df.groupby('worker_id')['yield_percentage'].agg(['mean', 'std', 'count'])
worker_yields = worker_yields[worker_yields['count'] >= 3] # Min 3 runs per worker
@@ -339,7 +346,10 @@ class YieldPredictor:
if 'duration_hours' in feature_df.columns:
feature_columns.append('duration_hours')
# Encode worker_id
# Encode worker_id (extracted from staff_assigned)
if 'worker_id' not in feature_df.columns:
feature_df['worker_id'] = feature_df['staff_assigned'].apply(lambda x: x[0] if isinstance(x, list) and len(x) > 0 else 'unknown')
worker_encoding = {worker: idx for idx, worker in enumerate(feature_df['worker_id'].unique())}
feature_df['worker_encoded'] = feature_df['worker_id'].map(worker_encoding)
feature_columns.append('worker_encoded')
@@ -420,11 +430,15 @@ class YieldPredictor:
) -> Dict[str, Any]:
"""Predict yield for upcoming production run."""
# Extract context
worker_id = production_context.get('worker_id')
staff_assigned = production_context.get('staff_assigned', [])
worker_id = staff_assigned[0] if isinstance(staff_assigned, list) and len(staff_assigned) > 0 else 'unknown'
planned_start = pd.to_datetime(production_context.get('planned_start_time'))
batch_size = production_context.get('batch_size')
# Get worker experience
if 'worker_id' not in feature_df.columns:
feature_df['worker_id'] = feature_df['staff_assigned'].apply(lambda x: x[0] if isinstance(x, list) and len(x) > 0 else 'unknown')
worker_runs = feature_df[feature_df['worker_id'] == worker_id]
worker_run_count = len(worker_runs) if len(worker_runs) > 0 else 1
@@ -578,7 +592,7 @@ class YieldPredictor:
'action': 'review_production_factors',
'params': {
'recipe_id': recipe_id,
'worker_id': production_context.get('worker_id')
'worker_id': worker_id
}
}]
})