2026-09-25 09:55:25 +07:00

200 lines
6.7 KiB
Python

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)