import asyncio import hashlib import json import secrets from datetime import UTC, datetime from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey from ai_platform.infrastructure.scoring import execute_run from ai_platform.infrastructure.security import digest from ai_platform.lead_scoring.domain.scoring import derive_metrics from ai_platform.presentation.http.dependencies import db_session from ai_platform.presentation.http.run_responses import RunDetail, RunSummary from ai_platform.presentation.http.schemas import RunRequest router = APIRouter() def canonical(value: object) -> bytes: return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() def to_json(row: ScoringRun) -> dict: return { "run_id": row.id, "request_id": row.request_id, "tenant_id": row.tenant_id, "status": row.status, "profile_version": row.profile_version, "rule_score": row.rule_score, "ai_score": row.ai_score, "final_score": row.final_score, "degraded": row.degraded, "result_mode": row.result_mode, "error_code": row.error_code, "breakdown": row.breakdown, "created_at": row.created_at.isoformat() if row.created_at else None, "completed_at": row.completed_at.isoformat() if row.completed_at else None, } def to_detail_json(row: ScoringRun) -> dict: return { **to_json(row), "snapshot": row.request["snapshot"], "metrics": derive_metrics(row.request["snapshot"]), "ai_confidence": row.ai_confidence, "ai_reasoning": row.ai_reasoning, "ai_evidence": row.ai_evidence, } async def tenant_id( authorization: str | None = Header(default=None), db: AsyncSession = Depends(db_session) ) -> str: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "Tenant authentication required") key = authorization[7:] row = ( await db.execute( select(TenantKey).where(TenantKey.verifier == digest(key), TenantKey.active.is_(True)) ) ).scalar_one_or_none() if row is None: raise HTTPException(401, "Invalid tenant key") return row.tenant_id @router.get("/health/live") async def live(): return {"status": "ok"} @router.get("/health/ready") async def ready(db: AsyncSession = Depends(db_session)): try: await db.execute(select(1)) except Exception as exc: raise HTTPException(503, "Database unavailable") from exc return {"status": "ready"} @router.post("/v1/platform/tenant-keys", status_code=201) async def provision_key( request: Request, body: dict, authorization: str | None = Header(default=None), db: AsyncSession = Depends(db_session), ): secret = request.app.state.settings.tenant_provisioning_secret if ( secret is None or not authorization or not secrets.compare_digest(authorization, "Bearer " + secret.get_secret_value()) ): raise HTTPException(401, "Provisioner authentication required") tenant = body.get("tenant_id") if not isinstance(tenant, str) or not tenant.strip() or len(tenant) > 128: raise HTTPException(422, "tenant_id must be a non-empty string up to 128 characters") if await db.get(TenantKey, tenant): raise HTTPException(409, "Tenant key already provisioned") raw = "tnt_" + secrets.token_urlsafe(32) db.add(TenantKey(tenant_id=tenant, verifier=digest(raw))) try: await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(409, "Tenant key already provisioned") from exc return {"tenant_id": tenant, "tenant_key": raw} @router.post( "/v1/lead-scoring/runs", status_code=202, response_model=RunSummary, responses={200: {"model": RunSummary}}, ) async def create_run( body: RunRequest, request: Request, response: Response, tenant: str = Depends(tenant_id), raw_vkey: str | None = Header(default=None, alias="X-Tenant-VKey"), db: AsyncSession = Depends(db_session), ): payload = body.model_dump(mode="json") canonical_body = canonical(payload) if raw_vkey and raw_vkey.encode() in canonical_body: raise HTTPException(422, "V-Key must not appear in the scoring payload") fingerprint = hashlib.sha256(canonical_body).hexdigest() request_key = str(body.request_id) old = ( await db.execute( select(ScoringRun).where( ScoringRun.tenant_id == tenant, ScoringRun.request_id == request_key ) ) ).scalar_one_or_none() if old: if old.fingerprint != fingerprint: raise HTTPException(409, "Request UUID already used with a different request") response.status_code = 200 return to_json(old) profile = ( await db.execute(select(AIProfile).where(AIProfile.active.is_(True))) ).scalar_one_or_none() row = ScoringRun( tenant_id=tenant, request_id=request_key, fingerprint=fingerprint, request=payload, profile_version=profile.version if profile else "unconfigured", ) db.add(row) try: await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(409, "Request UUID conflict") from exc try: task = asyncio.create_task( execute_run( request.app.state.sessions, row.id, raw_vkey, request.app.state.aihub_client, ) ) except RuntimeError as exc: row.status, row.completed_at, row.error_code = ( "failed", datetime.now(UTC), "execution_failed", ) await db.commit() raise HTTPException(503, "Run could not be scheduled") from exc request.app.state.tasks.add(task) task.add_done_callback(request.app.state.tasks.discard) response.headers["Location"] = f"/v1/lead-scoring/runs/{row.id}" return to_json(row) @router.get("/v1/lead-scoring/runs/{run_id}", response_model=RunDetail) async def get_run( run_id: UUID, tenant: str = Depends(tenant_id), db: AsyncSession = Depends(db_session) ): row = ( await db.execute( select(ScoringRun).where(ScoringRun.id == str(run_id), ScoringRun.tenant_id == tenant) ) ).scalar_one_or_none() if row is None: raise HTTPException(404, "Run not found") return to_detail_json(row)