"""kb-query -- module 5 phase 4 (docs/kb/modules/05-faza4-plan.md §4): first user-facing HTTP entry point to the KB. Wraps `kb_retrieval.cascade_query`/`flat_query` (module 5 phase 3, already gated PASS -- docs/sessions/2026-07-21.md) in FastAPI. This is a search API, not chat: no answer synthesis, no LLM call over the results (that is phase 5, out of scope here). Embed path is deliberately simple for this step: calls Ollama on SOLARIA directly, no cache/circuit-breaker/local-PIHA-fallback (plan §2 decision 2, §5) -- that state machine is a later, separate step. A failed embed call (SOLARIA unreachable) surfaces as 503 to the caller rather than a bare 500. """ from __future__ import annotations import os from contextlib import asynccontextmanager import aiohttp from fastapi import FastAPI, HTTPException, Query from kb_retrieval.embed import check_ollama_health from app.db import create_pool from app.search import run_search from app.startup import validate_embed_model KB_DSN = os.environ.get("KB_DSN") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://solaria:11434") EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3") SUMMARY_MODEL = os.environ.get("SUMMARY_MODEL", "claude-haiku-4-5") OLLAMA_HEALTH_TIMEOUT_S = 3.0 @asynccontextmanager async def lifespan(app: FastAPI): if not KB_DSN: raise RuntimeError("KB_DSN is required (see env.example)") pool = await create_pool(KB_DSN) async with pool.acquire() as conn: # Hard invariant (plan §2 decision 2): refuse to start rather than silently serve # queries against a mismatched embedding space. await validate_embed_model(conn, EMBED_MODEL) app.state.pool = pool app.state.http = aiohttp.ClientSession() try: yield finally: await app.state.http.close() await pool.close() app = FastAPI(lifespan=lifespan) @app.get("/healthz") async def healthz() -> dict: sol_up = await check_ollama_health(app.state.http, OLLAMA_URL, OLLAMA_HEALTH_TIMEOUT_S) return {"status": "ok", "sol_status": "up" if sol_up else "down"} @app.get("/search") async def search( q: str = Query(..., min_length=1), mode: str = Query("cascade", pattern="^(cascade|flat)$"), ) -> dict: try: async with app.state.pool.acquire() as conn: return await run_search( conn, app.state.http, OLLAMA_URL, q, mode, EMBED_MODEL, SUMMARY_MODEL ) except aiohttp.ClientError as exc: raise HTTPException(status_code=503, detail=f"embed backend unavailable: {exc}") from exc