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.
99 lines
3.9 KiB
Python
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
|