"""FastAPI HTMX app for EPUB search.""" from __future__ import annotations import logging from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Annotated import httpx import typer import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from sqlalchemy.ext.asyncio import AsyncSession from python.common import configure_logger from python.ebook_search.api.bm25_tasks import cancel_bm25_refresh from python.ebook_search.api.routes import admin_router, health_router, page_router, search_router from python.ebook_search.api.web import STATIC_DIR from python.ebook_search.bm25_corpus import ensure_bm25_corpus from python.ebook_search.config import load_config from python.ebook_search.protected_phrases.pool import shutdown_extraction_pool from python.fastapi_tools import ZstdMiddleware from python.orm.common import get_async_postgres_engine if TYPE_CHECKING: from collections.abc import AsyncIterator logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Manage application startup and shutdown resources.""" logger.info("ebook_search_startup") config = load_config() app.state.config = config logger.info( "ebook_search_config_loaded top_k=%s embedding_model=%s embedding_base_url=%s vllm_base_url=%s " "rerank_enabled=%s phrase_matching_enabled=%s answer_enabled=%s library_paths=%s", config.top_k, config.embedding_model, config.embedding_base_url, config.vllm_base_url, config.rerank.enabled, config.phrase_matching_enabled, config.answer_enabled, len(config.library_paths), ) if not config.library_paths: logger.warning("ebook_search_no_library_paths_configured") # Concurrent phrase judging opens one session per book worker on this engine, so size the pool # to cover those plus headroom for ordinary web requests. app.state.engine = get_async_postgres_engine( name="RICHIE", vector_engine=True, pool_size=config.phrase_judge_book_workers + 10, ) app.state.http_client = httpx.AsyncClient() async with AsyncSession(app.state.engine, expire_on_commit=False) as session: await ensure_bm25_corpus(session, config) try: yield finally: logger.info("ebook_search_shutdown") cancel_bm25_refresh(app) shutdown_extraction_pool() await app.state.http_client.aclose() await app.state.engine.dispose() def create_app() -> FastAPI: """Create the EPUB search web app.""" app = FastAPI(title="EPUB Search", lifespan=lifespan) app.add_middleware(ZstdMiddleware) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.include_router(admin_router) app.include_router(health_router) app.include_router(page_router) app.include_router(search_router) return app def serve( host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "127.0.0.1", port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8070, log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO", ) -> None: """Start the EPUB search server.""" configure_logger(log_level) uvicorn.run(create_app(), host=host, port=port) if __name__ == "__main__": typer.run(serve)