140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""FastAPI application and lifecycle.
|
|
|
|
``create_app`` accepts an optional ``scorer`` so tests can inject a fake without ever
|
|
constructing a provider client. When one is supplied, no ``AsyncOpenAI`` is created.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
from openai import AsyncOpenAI
|
|
|
|
from app.api.routes import router
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import ATSError, ErrorCode
|
|
from app.core.logging import configure_logging, request_id_var
|
|
from app.models.scoring import ErrorResponse
|
|
from app.services.llm import OpenAIScorer, Scorer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_REQUEST_ID_SAFE = re.compile(r"[^A-Za-z0-9._-]")
|
|
|
|
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
|
|
|
|
def _error_response(status: int, code: str, message: str) -> JSONResponse:
|
|
payload = ErrorResponse(
|
|
request_id=request_id_var.get(),
|
|
error_code=code,
|
|
error_message=message,
|
|
)
|
|
return JSONResponse(status_code=status, content=payload.model_dump())
|
|
|
|
|
|
def create_app(
|
|
settings: Settings | None = None,
|
|
scorer: Scorer | None = None,
|
|
) -> FastAPI:
|
|
resolved = settings or get_settings()
|
|
configure_logging(level=resolved.log_level, fmt=resolved.log_format)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
|
client: AsyncOpenAI | None = None
|
|
if scorer is not None:
|
|
application.state.scorer = scorer
|
|
else:
|
|
# One shared client for the process lifetime. Never per-request.
|
|
client = AsyncOpenAI(
|
|
api_key=resolved.openai_api_key or None,
|
|
timeout=resolved.openai_timeout_seconds,
|
|
max_retries=resolved.openai_max_retries,
|
|
)
|
|
application.state.scorer = OpenAIScorer(
|
|
client,
|
|
model=resolved.openai_model,
|
|
max_output_tokens=resolved.openai_max_output_tokens,
|
|
effort=resolved.openai_effort,
|
|
enable_cache=resolved.openai_enable_prompt_cache,
|
|
)
|
|
logger.info("startup_complete", extra={"model": resolved.openai_model})
|
|
try:
|
|
yield
|
|
finally:
|
|
if client is not None:
|
|
await client.close()
|
|
logger.info("shutdown_complete")
|
|
|
|
app = FastAPI(
|
|
title="Bulk ATS Scoring Engine",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
app.state.settings = resolved
|
|
|
|
@app.middleware("http")
|
|
async def request_context(
|
|
request: Request,
|
|
call_next: Callable[[Request], Awaitable[Response]],
|
|
) -> Response:
|
|
inbound = request.headers.get("x-request-id", "")
|
|
request_id = _REQUEST_ID_SAFE.sub("", inbound)[:64] or str(uuid.uuid4())
|
|
token = request_id_var.set(request_id)
|
|
try:
|
|
response = await call_next(request)
|
|
finally:
|
|
request_id_var.reset(token)
|
|
response.headers["X-Request-ID"] = request_id
|
|
return response
|
|
|
|
@app.exception_handler(ATSError)
|
|
async def handle_ats_error(_: Request, exc: ATSError) -> JSONResponse:
|
|
logger.info(
|
|
"request_rejected",
|
|
extra={"error_code": exc.error_code, "http_status": exc.http_status},
|
|
)
|
|
return _error_response(exc.http_status, exc.error_code, exc.public_message)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
# A missing or malformed multipart field is a bad request, not a field-value
|
|
# problem; 422 is reserved for structurally valid requests (see routes).
|
|
return _error_response(
|
|
400,
|
|
ErrorCode.INVALID_REQUEST,
|
|
"The request is malformed.",
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def handle_unexpected(_: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("unhandled_error")
|
|
return _error_response(
|
|
500,
|
|
ErrorCode.INTERNAL_ERROR,
|
|
"An internal error occurred.",
|
|
)
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def test_ui() -> FileResponse:
|
|
# Manual-testing page only; programmatic clients use /api/v1. Served straight
|
|
# from the package so no static mount or extra dependency is needed.
|
|
return FileResponse(_STATIC_DIR / "index.html", media_type="text/html")
|
|
|
|
app.include_router(router)
|
|
return app
|
|
|
|
|
|
# Run with: uvicorn app.main:create_app --factory
|
|
# No module-level app instance: constructing one at import time would read settings
|
|
# (and fail on a bad OPENAI_MODEL) merely because something imported this module.
|