"""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.