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).
93 lines
3.6 KiB
Python
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
|