"""FastAPI entrypoint.""" from __future__ import annotations import threading from contextlib import asynccontextmanager from typing import AsyncIterator from fastapi import FastAPI from ocr_sprint import __version__ from ocr_sprint.api.errors import register_error_handlers from ocr_sprint.api.metrics import MetricsMiddleware, metrics_endpoint from ocr_sprint.api.routes import documents, ground_truth, health from ocr_sprint.config import get_settings from ocr_sprint.db import models as _models # noqa: F401 (register ORM tables) from ocr_sprint.db.base import Base, get_engine from ocr_sprint.utils.logging import configure_logging, get_logger _startup_logger = get_logger(__name__) def _ensure_schema() -> None: """Create tables if they don't exist. Production deploys should run Alembic migrations explicitly; this is a convenience for local dev / tests so the API works without a manual `alembic upgrade head` step. """ Base.metadata.create_all(bind=get_engine()) def _warmup_models_background() -> None: """Load PaddleOCR and PP-Structure models in a background thread. Running in a thread keeps the lifespan non-blocking so uvicorn can start accepting health-check requests immediately while the heavy models load (~5-15s on CPU). Requests that arrive before warmup completes will wait on the existing _lock in each module rather than racing to load. """ from ocr_sprint.config import get_settings as _gs from ocr_sprint.pipeline import ocr as _ocr from ocr_sprint.pipeline import table as _table s = _gs() try: _ocr.warmup() except Exception as exc: _startup_logger.warning("paddleocr.warmup.failed", error=str(exc)) if s.tables_enabled: try: _table.warmup() except Exception as exc: _startup_logger.warning("pp_structure.warmup.failed", error=str(exc)) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """FastAPI lifespan: warm OCR models on startup in a background thread.""" _startup_logger.info("startup.warmup.begin") t = threading.Thread(target=_warmup_models_background, name="ocr-warmup", daemon=True) t.start() yield # Shutdown: nothing to clean up (models are process-global singletons). _startup_logger.info("shutdown.complete") def create_app() -> FastAPI: """Application factory — keeps top-level state easy to test.""" settings = get_settings() configure_logging(settings.app_log_level) _ensure_schema() # Support for reverse proxy with path prefix (e.g., /ocr) root_path = getattr(settings, "root_path", "") app = FastAPI( lifespan=lifespan, title="OCR Sprint Service", version=__version__, description="OCR + structured extraction for Indonesian police 'surat sprint' documents.", docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", root_path=root_path, ) register_error_handlers(app) app.add_middleware(MetricsMiddleware) app.include_router(health.router, prefix="/api/v1") app.include_router(documents.router, prefix="/api/v1") app.include_router(ground_truth.router, prefix="/api/v1") app.add_api_route("/metrics", metrics_endpoint, methods=["GET"], include_in_schema=False) return app app = create_app() def run() -> None: """Console-script entrypoint (`ocr-sprint-api`).""" import uvicorn s = get_settings() uvicorn.run("ocr_sprint.main:app", host=s.app_host, port=s.app_port, reload=False)