"""API endpoints for AI Insights.""" from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional from uuid import UUID from datetime import datetime import math from app.core.database import get_db from app.repositories.insight_repository import InsightRepository from app.repositories.feedback_repository import FeedbackRepository from app.schemas.insight import ( AIInsightCreate, AIInsightUpdate, AIInsightResponse, AIInsightList, InsightMetrics, InsightFilters ) from app.schemas.feedback import InsightFeedbackCreate, InsightFeedbackResponse router = APIRouter() @router.post("/tenants/{tenant_id}/insights", response_model=AIInsightResponse, status_code=status.HTTP_201_CREATED) async def create_insight( tenant_id: UUID, insight_data: AIInsightCreate, db: AsyncSession = Depends(get_db) ): """Create a new AI Insight.""" # Ensure tenant_id matches if insight_data.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant ID mismatch" ) repo = InsightRepository(db) insight = await repo.create(insight_data) await db.commit() return insight @router.get("/tenants/{tenant_id}/insights", response_model=AIInsightList) async def get_insights( tenant_id: UUID, category: Optional[str] = Query(None), priority: Optional[str] = Query(None), status: Optional[str] = Query(None), actionable_only: bool = Query(False), min_confidence: int = Query(0, ge=0, le=100), source_service: Optional[str] = Query(None), from_date: Optional[datetime] = Query(None), to_date: Optional[datetime] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db) ): """Get insights for a tenant with filters and pagination.""" filters = InsightFilters( category=category, priority=priority, status=status, actionable_only=actionable_only, min_confidence=min_confidence, source_service=source_service, from_date=from_date, to_date=to_date ) repo = InsightRepository(db) skip = (page - 1) * page_size insights, total = await repo.get_by_tenant(tenant_id, filters, skip, page_size) total_pages = math.ceil(total / page_size) if total > 0 else 0 return AIInsightList( items=insights, total=total, page=page, page_size=page_size, total_pages=total_pages ) @router.get("/tenants/{tenant_id}/insights/orchestration-ready") async def get_orchestration_ready_insights( tenant_id: UUID, target_date: datetime = Query(...), min_confidence: int = Query(70, ge=0, le=100), db: AsyncSession = Depends(get_db) ): """Get actionable insights for orchestration workflow.""" repo = InsightRepository(db) categorized_insights = await repo.get_orchestration_ready_insights( tenant_id, target_date, min_confidence ) return categorized_insights @router.get("/tenants/{tenant_id}/insights/{insight_id}", response_model=AIInsightResponse) async def get_insight( tenant_id: UUID, insight_id: UUID, db: AsyncSession = Depends(get_db) ): """Get a single insight by ID.""" repo = InsightRepository(db) insight = await repo.get_by_id(insight_id) if not insight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found" ) if insight.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) return insight @router.patch("/tenants/{tenant_id}/insights/{insight_id}", response_model=AIInsightResponse) async def update_insight( tenant_id: UUID, insight_id: UUID, update_data: AIInsightUpdate, db: AsyncSession = Depends(get_db) ): """Update an insight (typically status changes).""" repo = InsightRepository(db) # Verify insight exists and belongs to tenant insight = await repo.get_by_id(insight_id) if not insight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found" ) if insight.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) updated_insight = await repo.update(insight_id, update_data) await db.commit() return updated_insight @router.delete("/tenants/{tenant_id}/insights/{insight_id}", status_code=status.HTTP_204_NO_CONTENT) async def dismiss_insight( tenant_id: UUID, insight_id: UUID, db: AsyncSession = Depends(get_db) ): """Dismiss an insight (soft delete).""" repo = InsightRepository(db) # Verify insight exists and belongs to tenant insight = await repo.get_by_id(insight_id) if not insight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found" ) if insight.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) await repo.delete(insight_id) await db.commit() @router.get("/tenants/{tenant_id}/insights/metrics/summary", response_model=InsightMetrics) async def get_insights_metrics( tenant_id: UUID, db: AsyncSession = Depends(get_db) ): """Get aggregate metrics for insights.""" repo = InsightRepository(db) metrics = await repo.get_metrics(tenant_id) return InsightMetrics(**metrics) @router.post("/tenants/{tenant_id}/insights/{insight_id}/apply") async def apply_insight( tenant_id: UUID, insight_id: UUID, db: AsyncSession = Depends(get_db) ): """Apply an insight recommendation (trigger action).""" repo = InsightRepository(db) # Verify insight exists and belongs to tenant insight = await repo.get_by_id(insight_id) if not insight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found" ) if insight.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) if not insight.actionable: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This insight is not actionable" ) # Update status to in_progress update_data = AIInsightUpdate(status='in_progress', applied_at=datetime.utcnow()) await repo.update(insight_id, update_data) await db.commit() # Route to appropriate service based on recommendation_actions applied_actions = [] failed_actions = [] try: import structlog logger = structlog.get_logger() for action in insight.recommendation_actions: try: action_type = action.get('action_type') action_target = action.get('target_service') logger.info("Processing insight action", insight_id=str(insight_id), action_type=action_type, target_service=action_target) # Route based on target service if action_target == 'procurement': # Create purchase order or adjust reorder points from shared.clients.procurement_client import ProcurementServiceClient from shared.config.base import get_settings config = get_settings() procurement_client = ProcurementServiceClient(config, "ai_insights") # Example: trigger procurement action logger.info("Routing action to procurement service", action=action) applied_actions.append(action_type) elif action_target == 'production': # Adjust production schedule from shared.clients.production_client import ProductionServiceClient from shared.config.base import get_settings config = get_settings() production_client = ProductionServiceClient(config, "ai_insights") logger.info("Routing action to production service", action=action) applied_actions.append(action_type) elif action_target == 'inventory': # Adjust inventory settings from shared.clients.inventory_client import InventoryServiceClient from shared.config.base import get_settings config = get_settings() inventory_client = InventoryServiceClient(config, "ai_insights") logger.info("Routing action to inventory service", action=action) applied_actions.append(action_type) elif action_target == 'pricing': # Update pricing recommendations logger.info("Price adjustment action identified", action=action) applied_actions.append(action_type) else: logger.warning("Unknown target service for action", action_type=action_type, target_service=action_target) failed_actions.append({ 'action_type': action_type, 'reason': f'Unknown target service: {action_target}' }) except Exception as action_error: logger.error("Failed to apply action", action_type=action.get('action_type'), error=str(action_error)) failed_actions.append({ 'action_type': action.get('action_type'), 'reason': str(action_error) }) # Update final status final_status = 'applied' if not failed_actions else 'partially_applied' final_update = AIInsightUpdate(status=final_status) await repo.update(insight_id, final_update) await db.commit() except Exception as e: logger.error("Failed to route insight actions", insight_id=str(insight_id), error=str(e)) # Update status to failed failed_update = AIInsightUpdate(status='failed') await repo.update(insight_id, failed_update) await db.commit() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to apply insight: {str(e)}" ) return { "message": "Insight application initiated", "insight_id": str(insight_id), "actions": insight.recommendation_actions, "applied_actions": applied_actions, "failed_actions": failed_actions, "status": final_status } @router.post("/tenants/{tenant_id}/insights/{insight_id}/feedback", response_model=InsightFeedbackResponse) async def record_feedback( tenant_id: UUID, insight_id: UUID, feedback_data: InsightFeedbackCreate, db: AsyncSession = Depends(get_db) ): """Record feedback for an applied insight.""" insight_repo = InsightRepository(db) # Verify insight exists and belongs to tenant insight = await insight_repo.get_by_id(insight_id) if not insight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found" ) if insight.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) # Ensure feedback is for this insight if feedback_data.insight_id != insight_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Insight ID mismatch" ) feedback_repo = FeedbackRepository(db) feedback = await feedback_repo.create(feedback_data) # Update insight status based on feedback new_status = 'applied' if feedback.success else 'dismissed' update_data = AIInsightUpdate(status=new_status) await insight_repo.update(insight_id, update_data) await db.commit() return feedback @router.post("/tenants/{tenant_id}/insights/refresh") async def refresh_insights( tenant_id: UUID, db: AsyncSession = Depends(get_db) ): """Trigger insight refresh (expire old, generate new).""" repo = InsightRepository(db) # Expire old insights expired_count = await repo.expire_old_insights() await db.commit() return { "message": "Insights refreshed", "expired_count": expired_count } @router.get("/tenants/{tenant_id}/insights/export") async def export_insights( tenant_id: UUID, format: str = Query("json", regex="^(json|csv)$"), db: AsyncSession = Depends(get_db) ): """Export insights to JSON or CSV.""" repo = InsightRepository(db) insights, _ = await repo.get_by_tenant(tenant_id, filters=None, skip=0, limit=1000) if format == "json": return {"insights": [AIInsightResponse.model_validate(i) for i in insights]} # CSV export would be implemented here raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="CSV export not yet implemented" )