Files
bakery-ia/services/production/app/api/ml_insights.py

395 lines
14 KiB
Python
Raw Normal View History

2025-11-05 13:34:56 +01:00
"""
ML Insights API Endpoints for Production Service
Provides endpoints to trigger ML insight generation for:
- Production yield predictions
- Quality optimization
- Process efficiency analysis
"""
2025-12-15 21:14:22 +01:00
from fastapi import APIRouter, Depends, HTTPException, Request
2025-11-05 13:34:56 +01:00
from pydantic import BaseModel, Field
from typing import Optional, List
from uuid import UUID
from datetime import datetime, timedelta
import structlog
import pandas as pd
from app.core.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
router = APIRouter(
prefix="/api/v1/tenants/{tenant_id}/production/ml/insights",
tags=["ML Insights"]
)
# ================================================================
# REQUEST/RESPONSE SCHEMAS
# ================================================================
class YieldPredictionRequest(BaseModel):
"""Request schema for yield prediction"""
recipe_ids: Optional[List[str]] = Field(
None,
description="Specific recipe IDs to analyze. If None, analyzes all recipes"
)
lookback_days: int = Field(
90,
description="Days of historical production to analyze",
ge=30,
le=365
)
min_history_runs: int = Field(
30,
description="Minimum production runs required",
ge=10,
le=100
)
class YieldPredictionResponse(BaseModel):
"""Response schema for yield prediction"""
success: bool
message: str
tenant_id: str
recipes_analyzed: int
total_insights_generated: int
total_insights_posted: int
recipes_with_issues: int
insights_by_recipe: dict
errors: List[str] = []
# ================================================================
# API ENDPOINTS
# ================================================================
@router.post("/predict-yields", response_model=YieldPredictionResponse)
async def trigger_yield_prediction(
tenant_id: str,
request_data: YieldPredictionRequest,
2025-12-15 21:14:22 +01:00
request: Request,
2025-11-05 13:34:56 +01:00
db: AsyncSession = Depends(get_db)
):
"""
Trigger yield prediction for production recipes.
This endpoint:
1. Fetches historical production data for specified recipes
2. Runs the YieldInsightsOrchestrator to predict yields
3. Generates insights about yield optimization opportunities
4. Posts insights to AI Insights Service
2025-12-15 21:14:22 +01:00
5. Publishes recommendation events to RabbitMQ
2025-11-05 13:34:56 +01:00
Args:
tenant_id: Tenant UUID
request_data: Prediction parameters
2025-12-15 21:14:22 +01:00
request: FastAPI request (for app state access)
2025-11-05 13:34:56 +01:00
db: Database session
Returns:
YieldPredictionResponse with prediction results
"""
logger.info(
"ML insights yield prediction requested",
tenant_id=tenant_id,
recipe_ids=request_data.recipe_ids,
lookback_days=request_data.lookback_days
)
try:
# Import ML orchestrator and clients
from app.ml.yield_insights_orchestrator import YieldInsightsOrchestrator
from shared.clients.recipes_client import RecipesServiceClient
from app.core.config import settings
2025-12-15 21:14:22 +01:00
# Get event publisher from app state (if available)
event_publisher = getattr(request.app.state, 'event_publisher', None) if hasattr(request, 'app') else None
2025-11-05 13:34:56 +01:00
# Initialize orchestrator and recipes client
2025-12-15 21:14:22 +01:00
orchestrator = YieldInsightsOrchestrator(
event_publisher=event_publisher
)
2025-11-05 13:34:56 +01:00
recipes_client = RecipesServiceClient(settings)
# Get recipes to analyze from recipes service via API
if request_data.recipe_ids:
# Fetch specific recipes
recipes = []
for recipe_id in request_data.recipe_ids:
recipe = await recipes_client.get_recipe_by_id(
recipe_id=recipe_id,
tenant_id=tenant_id
)
if recipe:
recipes.append(recipe)
else:
# Fetch all recipes for tenant (limit to 10)
all_recipes = await recipes_client.get_all_recipes(tenant_id=tenant_id)
recipes = all_recipes[:10] if all_recipes else [] # Limit to prevent timeout
if not recipes:
return YieldPredictionResponse(
success=False,
message="No recipes found for analysis",
tenant_id=tenant_id,
recipes_analyzed=0,
total_insights_generated=0,
total_insights_posted=0,
recipes_with_issues=0,
insights_by_recipe={},
errors=["No recipes found"]
)
# Calculate date range for production history
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=request_data.lookback_days)
# Process each recipe
total_insights_generated = 0
total_insights_posted = 0
recipes_with_issues = 0
insights_by_recipe = {}
errors = []
for recipe in recipes:
try:
recipe_id = str(recipe['id'])
recipe_name = recipe.get('name', 'Unknown Recipe')
logger.info(f"Analyzing yield for {recipe_name} ({recipe_id})")
# Fetch real production batch history from database
from app.models.production import ProductionBatch, ProductionStatus
from sqlalchemy import select
batch_query = select(ProductionBatch).where(
ProductionBatch.tenant_id == UUID(tenant_id),
ProductionBatch.recipe_id == UUID(recipe_id), # Use the extracted UUID
ProductionBatch.actual_start_time >= start_date,
ProductionBatch.actual_start_time <= end_date,
ProductionBatch.status == ProductionStatus.COMPLETED,
ProductionBatch.actual_quantity.isnot(None)
).order_by(ProductionBatch.actual_start_time)
batch_result = await db.execute(batch_query)
batches = batch_result.scalars().all()
if len(batches) < request_data.min_history_runs:
logger.warning(
f"Insufficient production history for recipe {recipe_id}: "
f"{len(batches)} batches < {request_data.min_history_runs} required"
)
continue
# Create production history DataFrame from real batches
production_data = []
for batch in batches:
# Calculate yield percentage
if batch.planned_quantity and batch.actual_quantity:
yield_pct = (batch.actual_quantity / batch.planned_quantity) * 100
else:
continue # Skip batches without complete data
production_data.append({
2025-12-15 21:14:22 +01:00
'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
2025-11-05 13:34:56 +01:00
'planned_quantity': float(batch.planned_quantity),
'actual_quantity': float(batch.actual_quantity),
'yield_percentage': yield_pct,
2025-12-15 21:14:22 +01:00
'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
2025-11-05 13:34:56 +01:00
})
if not production_data:
logger.warning(
f"No valid production data for recipe {recipe_id}"
)
continue
production_history = pd.DataFrame(production_data)
2025-12-15 21:14:22 +01:00
# 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
)
2025-11-05 13:34:56 +01:00
# Run yield analysis
results = await orchestrator.analyze_and_post_insights(
tenant_id=tenant_id,
recipe_id=recipe_id,
production_history=production_history,
min_history_runs=request_data.min_history_runs
)
# Track results
total_insights_generated += results['insights_generated']
total_insights_posted += results['insights_posted']
baseline_stats = results.get('baseline_stats', {})
mean_yield = baseline_stats.get('mean_yield', 100)
if mean_yield < 90:
recipes_with_issues += 1
insights_by_recipe[recipe_id] = {
'recipe_name': recipe_name,
'insights_posted': results['insights_posted'],
'mean_yield': mean_yield,
'patterns': len(results.get('patterns', []))
}
logger.info(
f"Recipe {recipe_id} analysis complete",
insights_posted=results['insights_posted'],
mean_yield=mean_yield
)
except Exception as e:
error_msg = f"Error analyzing recipe {recipe_id}: {str(e)}"
logger.error(error_msg, exc_info=True)
errors.append(error_msg)
2025-12-13 23:57:54 +01:00
# Close orchestrator
2025-11-05 13:34:56 +01:00
await orchestrator.close()
# Build response
response = YieldPredictionResponse(
success=total_insights_posted > 0,
message=f"Successfully analyzed {len([r for r in recipes if isinstance(r, dict)])} recipes, generated {total_insights_posted} insights",
tenant_id=tenant_id,
recipes_analyzed=len([r for r in recipes if isinstance(r, dict)]),
total_insights_generated=total_insights_generated,
total_insights_posted=total_insights_posted,
recipes_with_issues=recipes_with_issues,
insights_by_recipe=insights_by_recipe,
errors=errors
)
logger.info(
"ML insights yield prediction complete",
tenant_id=tenant_id,
total_insights=total_insights_posted,
recipes_with_issues=recipes_with_issues
)
return response
except Exception as e:
logger.error(
"ML insights yield prediction failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise HTTPException(
status_code=500,
detail=f"Yield prediction failed: {str(e)}"
)
@router.get("/health")
async def ml_insights_health():
"""Health check for ML insights endpoints"""
return {
"status": "healthy",
"service": "production-ml-insights",
"endpoints": [
"POST /ml/insights/predict-yields"
]
}
2025-12-13 23:57:54 +01:00
# ================================================================
# INTERNAL ENDPOINTS (for demo-session service)
# ================================================================
# Create a separate router for internal endpoints to avoid the tenant prefix
internal_router = APIRouter(
tags=["ML Insights - Internal"]
)
@internal_router.post("/api/v1/tenants/{tenant_id}/production/internal/ml/generate-yield-insights")
async def generate_yield_insights_internal(
tenant_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Internal endpoint to trigger yield insights generation for demo sessions.
This endpoint is called by the demo-session service after cloning data.
It uses the same ML logic as the public endpoint but with optimized defaults.
Security: Protected by X-Internal-Service header check.
Args:
tenant_id: The tenant UUID
request: FastAPI request object
db: Database session
Returns:
{
"insights_posted": int,
"tenant_id": str,
"status": str
}
"""
# Verify internal service header
if not request or request.headers.get("X-Internal-Service") not in ["demo-session", "internal"]:
logger.warning("Unauthorized internal API call", tenant_id=tenant_id)
raise HTTPException(
status_code=403,
detail="This endpoint is for internal service use only"
)
logger.info("Internal yield insights generation triggered", tenant_id=tenant_id)
try:
# Use the existing yield prediction logic with sensible defaults
request_data = YieldPredictionRequest(
recipe_ids=None, # Analyze all recipes
lookback_days=90, # 3 months of history
min_history_runs=20 # Minimum 20 production runs required
)
# Call the existing yield prediction endpoint logic
result = await trigger_yield_prediction(
tenant_id=tenant_id,
request_data=request_data,
2025-12-15 21:14:22 +01:00
request=request,
2025-12-13 23:57:54 +01:00
db=db
)
# Return simplified response for internal use
return {
"insights_posted": result.total_insights_posted,
"tenant_id": tenant_id,
"status": "success" if result.success else "failed",
"message": result.message,
"recipes_analyzed": result.recipes_analyzed,
"recipes_with_issues": result.recipes_with_issues
}
except Exception as e:
logger.error(
"Internal yield insights generation failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise HTTPException(
status_code=500,
detail=f"Internal yield insights generation failed: {str(e)}"
)