HR-ATS-Portal/app/main.py

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.