324 lines
9.3 KiB
Python
324 lines
9.3 KiB
Python
|
|
"""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()
|
||
|
|
|
||
|
|
# TODO: Route to appropriate service based on recommendation_actions
|
||
|
|
# This will be implemented when service clients are added
|
||
|
|
|
||
|
|
return {
|
||
|
|
"message": "Insight application initiated",
|
||
|
|
"insight_id": str(insight_id),
|
||
|
|
"actions": insight.recommendation_actions
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@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"
|
||
|
|
)
|