homelab-codex-ws/services/kb-query/app/main.py
oskar a640cf1455 feat(kb-retrieval,kb-query): add hybrid retrieval mode (faza mailowa Krok 3)
Mail (gmail) envelopes never get a document_summary (Decyzja 6 -- a mail
"summary" would usually be longer than the mail itself), so they're invisible
to the cascade's stage-1 pre-filter. hybrid_retrieve runs the existing
cascade for summarized sources (paperless) and, in parallel, a direct chunk
scan restricted to summaryless_sources (gmail), merging both by dist -- same
embedder/cosine space, so the merge is a plain sort, no re-normalization.
hybrid_query mirrors cascade_query (one shared query embed).

kb-query: mode pattern extended to ^(cascade|flat|hybrid)$, /search routes
"hybrid" to hybrid_query. Default mode stays "cascade" until the quality gate
(plan §8) PASSes on the full mail corpus -- flipping the default, and
deploying this to the running kb-query container, are separate follow-ups
for the operator; this task only adds the code path + tests
(docs/kb/modules/05-faza-mailowa-plan.md, §6, Krok 3).
2026-07-23 17:06:49 +02:00

93 lines
3.6 KiB
Python

"""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.
`GET /` (Krok 4, plan §7) serves the search UI from this same FastAPI process -- one image, one
container (plan §2 decision 4): a Jinja2 shell + a static vanilla-JS file, no node build step.
`/` and `/static/*` need no DB/Ollama, so they stay reachable even while `/search` is 503ing.
`mode=hybrid` (faza mailowa, plan Krok 3, docs/kb/modules/05-faza-mailowa-plan.md §6) is
available explicitly starting here, but the default stays `cascade` until the quality gate
(plan §8) PASSes on the full mail corpus -- flipping the default is a separate, later change.
"""
from __future__ import annotations
import os
import pathlib
from contextlib import asynccontextmanager
import aiohttp
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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
BASE_DIR = pathlib.Path(__file__).resolve().parent
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.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request, "index.html")
@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|hybrid)$"),
) -> 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