"""Liveness and readiness routes for the EPUB search service.""" from __future__ import annotations import logging from http import HTTPStatus from typing import TYPE_CHECKING from fastapi import APIRouter from fastapi.responses import JSONResponse from sqlalchemy import literal, select from sqlalchemy.exc import SQLAlchemyError from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime AppConfig, AppHttpClient, ) from python.ebook_search.bm25_corpus import bm25_index_exists, bm25_index_path, read_bm25_manifest from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime if TYPE_CHECKING: import httpx from sqlalchemy.ext.asyncio import AsyncSession from python.ebook_search.config import EbookSearchConfig logger = logging.getLogger(__name__) router = APIRouter() @router.get("/health") async def health() -> dict[str, str]: """Liveness probe that returns ok without touching dependencies.""" return {"status": "ok"} @router.get("/ready") async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClient) -> JSONResponse: """Readiness probe reporting database, embedding endpoint, and BM25 index status.""" database_ok = await check_database(session) embedding_ok = await check_embedding_endpoint(client, config) chat_status = await chat_endpoint_status(client, config) bm25_status = check_bm25_status(config) checks = { "database": "ok" if database_ok else "fail", "embedding": "ok" if embedding_ok else "fail", "chat": chat_status, "bm25": bm25_status, } if not database_ok: status = "unavailable" status_code = HTTPStatus.SERVICE_UNAVAILABLE elif not embedding_ok or chat_status == "fail" or bm25_status == "missing": status = "degraded" status_code = HTTPStatus.OK else: status = "ready" status_code = HTTPStatus.OK logger.info(f"ebook_ready_check {status=} {database_ok=} {embedding_ok=} {chat_status=} {bm25_status=}") return JSONResponse(content={"status": status, "checks": checks}, status_code=status_code) async def chat_endpoint_status(client: httpx.AsyncClient, config: EbookSearchConfig) -> str: """Return the answering chat endpoint status, or disabled when answers are off.""" if not config.answer_enabled: return "disabled" return "ok" if await check_chat_endpoint(client, config) else "fail" async def check_database(session: AsyncSession) -> bool: """Return whether the database answers a trivial query.""" try: await session.execute(select(literal(1))) except SQLAlchemyError as error: logger.warning(f"ebook_ready_database_unavailable {error=}") return False return True def check_bm25_status(config: EbookSearchConfig) -> str: """Return the persisted BM25 index status without loading it into memory.""" index_path = bm25_index_path(config) manifest = read_bm25_manifest(index_path) if manifest is None or not bm25_index_exists(index_path, manifest): return "missing" if manifest.chunk_count == 0: return "empty" return "ok"