""" FastAPI routes for AI Quality Metrics and Tests. Endpoints: GET/POST /quality/metrics — list/create metrics PUT/DELETE /quality/metrics/{id} — update/delete metric GET/POST /quality/tests — list/create tests PUT/DELETE /quality/tests/{id} — update/delete test GET /quality/stats — avgScore, metricsAboveThreshold, passed, failed """ import logging from datetime import datetime, timezone from typing import Optional, Any, Dict from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session from classroom_engine.database import get_db from .tenant_utils import get_tenant_id as _get_tenant_id from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/quality", tags=["quality"]) # ============================================================================= # Pydantic Schemas # ============================================================================= class MetricCreate(BaseModel): name: str category: str = "accuracy" score: float = 0.0 threshold: float = 80.0 trend: str = "stable" ai_system: Optional[str] = None last_measured: Optional[datetime] = None class MetricUpdate(BaseModel): name: Optional[str] = None category: Optional[str] = None score: Optional[float] = None threshold: Optional[float] = None trend: Optional[str] = None ai_system: Optional[str] = None last_measured: Optional[datetime] = None class TestCreate(BaseModel): name: str status: str = "pending" duration: Optional[str] = None ai_system: Optional[str] = None details: Optional[str] = None last_run: Optional[datetime] = None class TestUpdate(BaseModel): name: Optional[str] = None status: Optional[str] = None duration: Optional[str] = None ai_system: Optional[str] = None details: Optional[str] = None last_run: Optional[datetime] = None # ============================================================================= # Stats # ============================================================================= @router.get("/stats") async def get_quality_stats( db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Return quality dashboard stats.""" metrics_row = db.execute(text(""" SELECT COUNT(*) AS total_metrics, COALESCE(AVG(score), 0) AS avg_score, COUNT(*) FILTER (WHERE score >= threshold) AS metrics_above_threshold FROM compliance_quality_metrics WHERE tenant_id = :tenant_id """), {"tenant_id": tenant_id}).fetchone() tests_row = db.execute(text(""" SELECT COUNT(*) FILTER (WHERE status = 'passed') AS passed, COUNT(*) FILTER (WHERE status = 'failed') AS failed, COUNT(*) FILTER (WHERE status = 'warning') AS warning, COUNT(*) AS total FROM compliance_quality_tests WHERE tenant_id = :tenant_id """), {"tenant_id": tenant_id}).fetchone() return { "total_metrics": int(metrics_row.total_metrics or 0), "avg_score": round(float(metrics_row.avg_score or 0), 1), "metrics_above_threshold": int(metrics_row.metrics_above_threshold or 0), "passed": int(tests_row.passed or 0), "failed": int(tests_row.failed or 0), "warning": int(tests_row.warning or 0), "total_tests": int(tests_row.total or 0), } # ============================================================================= # Metrics # ============================================================================= @router.get("/metrics") async def list_metrics( category: Optional[str] = Query(None), ai_system: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """List quality metrics.""" where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} if category: where_clauses.append("category = :category") params["category"] = category if ai_system: where_clauses.append("ai_system ILIKE :ai_system") params["ai_system"] = f"%{ai_system}%" where_sql = " AND ".join(where_clauses) total_row = db.execute( text(f"SELECT COUNT(*) FROM compliance_quality_metrics WHERE {where_sql}"), params ).fetchone() total = total_row[0] if total_row else 0 rows = db.execute( text(f""" SELECT * FROM compliance_quality_metrics WHERE {where_sql} ORDER BY category, name LIMIT :limit OFFSET :offset """), params, ).fetchall() return {"metrics": [_row_to_dict(r) for r in rows], "total": total} @router.post("/metrics", status_code=201) async def create_metric( payload: MetricCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Create a new quality metric.""" row = db.execute(text(""" INSERT INTO compliance_quality_metrics (tenant_id, name, category, score, threshold, trend, ai_system, last_measured) VALUES (:tenant_id, :name, :category, :score, :threshold, :trend, :ai_system, :last_measured) RETURNING * """), { "tenant_id": tenant_id, "name": payload.name, "category": payload.category, "score": payload.score, "threshold": payload.threshold, "trend": payload.trend, "ai_system": payload.ai_system, "last_measured": payload.last_measured or datetime.now(timezone.utc), }).fetchone() db.commit() return _row_to_dict(row) @router.put("/metrics/{metric_id}") async def update_metric( metric_id: str, payload: MetricUpdate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Update a quality metric.""" updates: Dict[str, Any] = {"id": metric_id, "tenant_id": tenant_id, "updated_at": datetime.now(timezone.utc)} set_clauses = ["updated_at = :updated_at"] for field, value in payload.model_dump(exclude_unset=True).items(): updates[field] = value set_clauses.append(f"{field} = :{field}") if len(set_clauses) == 1: raise HTTPException(status_code=400, detail="No fields to update") row = db.execute(text(f""" UPDATE compliance_quality_metrics SET {', '.join(set_clauses)} WHERE id = :id AND tenant_id = :tenant_id RETURNING * """), updates).fetchone() db.commit() if not row: raise HTTPException(status_code=404, detail="Metric not found") return _row_to_dict(row) @router.delete("/metrics/{metric_id}", status_code=204) async def delete_metric( metric_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): result = db.execute(text(""" DELETE FROM compliance_quality_metrics WHERE id = :id AND tenant_id = :tenant_id """), {"id": metric_id, "tenant_id": tenant_id}) db.commit() if result.rowcount == 0: raise HTTPException(status_code=404, detail="Metric not found") # ============================================================================= # Tests # ============================================================================= @router.get("/tests") async def list_tests( status: Optional[str] = Query(None), ai_system: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """List quality tests.""" where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} if status: where_clauses.append("status = :status") params["status"] = status if ai_system: where_clauses.append("ai_system ILIKE :ai_system") params["ai_system"] = f"%{ai_system}%" where_sql = " AND ".join(where_clauses) total_row = db.execute( text(f"SELECT COUNT(*) FROM compliance_quality_tests WHERE {where_sql}"), params ).fetchone() total = total_row[0] if total_row else 0 rows = db.execute( text(f""" SELECT * FROM compliance_quality_tests WHERE {where_sql} ORDER BY last_run DESC NULLS LAST, created_at DESC LIMIT :limit OFFSET :offset """), params, ).fetchall() return {"tests": [_row_to_dict(r) for r in rows], "total": total} @router.post("/tests", status_code=201) async def create_test( payload: TestCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Create a new quality test entry.""" row = db.execute(text(""" INSERT INTO compliance_quality_tests (tenant_id, name, status, duration, ai_system, details, last_run) VALUES (:tenant_id, :name, :status, :duration, :ai_system, :details, :last_run) RETURNING * """), { "tenant_id": tenant_id, "name": payload.name, "status": payload.status, "duration": payload.duration, "ai_system": payload.ai_system, "details": payload.details, "last_run": payload.last_run or datetime.now(timezone.utc), }).fetchone() db.commit() return _row_to_dict(row) @router.put("/tests/{test_id}") async def update_test( test_id: str, payload: TestUpdate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Update a quality test.""" updates: Dict[str, Any] = {"id": test_id, "tenant_id": tenant_id, "updated_at": datetime.now(timezone.utc)} set_clauses = ["updated_at = :updated_at"] for field, value in payload.model_dump(exclude_unset=True).items(): updates[field] = value set_clauses.append(f"{field} = :{field}") if len(set_clauses) == 1: raise HTTPException(status_code=400, detail="No fields to update") row = db.execute(text(f""" UPDATE compliance_quality_tests SET {', '.join(set_clauses)} WHERE id = :id AND tenant_id = :tenant_id RETURNING * """), updates).fetchone() db.commit() if not row: raise HTTPException(status_code=404, detail="Test not found") return _row_to_dict(row) @router.delete("/tests/{test_id}", status_code=204) async def delete_test( test_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): result = db.execute(text(""" DELETE FROM compliance_quality_tests WHERE id = :id AND tenant_id = :tenant_id """), {"id": test_id, "tenant_id": tenant_id}) db.commit() if result.rowcount == 0: raise HTTPException(status_code=404, detail="Test not found")