hungtran24 6577bb74be refactor: simplify scoring service, AI profile revisions and test suite
Phase 2 and 3 of the platform rework.

- Neutral request/response contract: external_id, metadata, X-AI-Key,
  run_id in the response; client-specific fields removed.
- AI profile is a revision table: saving creates an inactive revision,
  activation is separate, try-run does not store a run. One
  chat-completions gateway serves AIHub and OpenAI-compatible profiles.
- Add between and in rule operators.
- Contracts moved to contracts/lead-scoring/ with a score-response fixture.
- Admin Web updated for revisions, runs and the neutral fixture.
- Consolidate tests; remove rewrite_architect.md and the stale handoff doc;
  rewrite spec, CONTEXT and ARCHITECTURE for the current design.

Not verified: live AIHub/OpenAI behavior, Omni/EMS integration.
2026-10-09 08:23:21 +07:00

99 lines
3.9 KiB
Python

from collections.abc import Awaitable, Callable
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Security
from fastapi.routing import APIRoute
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.responses import JSONResponse
from ai_platform.lead_scoring.application.run_use_cases import (
ScoringRunConflictError,
ScoringUseCases,
)
from ai_platform.platform.application.app_credentials import AppCredentialUseCases
from ai_platform.presentation.http.run_mapping import score_json
from ai_platform.presentation.http.run_responses import ScoreResponse, ScoringError
from ai_platform.presentation.http.schemas import RunRequest
_MAX_RUN_BODY_BYTES = 1_000_000
class BoundedRunRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def bounded(request: Request):
if request.method == "POST" and request.url.path == "/v1/lead-scoring/score":
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > _MAX_RUN_BODY_BYTES:
return JSONResponse({"detail": "Request body too large"}, status_code=413)
request._body = bytes(body)
return await handler(request)
return bounded
def create_scoring_router(
get_app_credentials: Callable[..., Awaitable[AppCredentialUseCases]],
get_scoring_use_cases: Callable[..., Awaitable[ScoringUseCases]],
get_database_readiness: Callable[..., Awaitable[bool]],
) -> APIRouter:
router = APIRouter(route_class=BoundedRunRoute)
async def authenticated_app(
credentials: HTTPAuthorizationCredentials | None = Security(HTTPBearer(auto_error=False)),
use_cases: AppCredentialUseCases = Depends(get_app_credentials),
) -> str:
if credentials is None:
raise HTTPException(401, "App authentication required")
app_id = await use_cases.authenticate(credentials.credentials)
if app_id is None:
raise HTTPException(401, "Invalid app key")
return app_id
@router.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "ok"}
@router.get("/health/ready")
async def ready(available: bool = Depends(get_database_readiness)) -> dict[str, str]:
if not available:
raise HTTPException(503, "Database unavailable")
return {"status": "ready"}
@router.post(
"/v1/lead-scoring/score",
response_model=ScoreResponse,
responses={
401: {
"model": ScoringError,
"description": "Missing, invalid or revoked app credential",
},
413: {"model": ScoringError, "description": "Request body exceeds size limit"},
422: {
"model": ScoringError,
"description": "Invalid input or AI key, or idempotency key reused with another body",
},
503: {"model": ScoringError, "description": "Database unavailable"},
},
)
async def score(
body: RunRequest,
app_id: str = Depends(authenticated_app),
ai_key: str | None = Header(default=None, alias="X-AI-Key"),
idempotency_key: UUID = Header(alias="Idempotency-Key"),
use_cases: ScoringUseCases = Depends(get_scoring_use_cases),
):
if ai_key is not None and (len(ai_key) > 4_096 or "\r" in ai_key or "\n" in ai_key):
raise HTTPException(422, "X-AI-Key is invalid")
request = body.model_dump(mode="json")
try:
run = await use_cases.score(app_id, str(idempotency_key), request, ai_key)
except ScoringRunConflictError as exc:
raise HTTPException(422, "Idempotency-Key already used with a different body") from exc
return score_json(run.id, run.request, run.result)
return router