200 lines
6.7 KiB
Python
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)
|