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). |
||
|---|---|---|
| .. | ||
| app | ||
| tests | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dockerignore | ||
| env.example | ||
| healthcheck.sh | ||
| pytest.ini | ||
| README.md | ||
| requirements.txt | ||
| service.yaml | ||
kb-query
FastAPI search API in front of the module-5 KB retrieval engine
(packages/kb-retrieval/). Runs on PIHA, bound to PIHA's LAN IP only
(exposure: private, same class as paperless/nextcloud — no public ingress
yet). This is a search API, not chat: no answer synthesis over results,
that's phase 5.
Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/ |
GET | Search UI (Jinja2 shell + /static/app.js, no login yet — plan §8 OIDC is a later step) |
/static/* |
GET | UI assets (app.js, style.css) |
/healthz |
GET | {"status": "ok", "sol_status": "up"|"down"} — sol_status is a live probe of Ollama@SOLARIA, no auth required (monitoring must reach it) |
/search?q=<text>&mode=cascade|flat |
GET | query_text -> embed -> cascade_query/flat_query -> results, mode defaults to cascade |
/search response shape (module 5 phase 4 plan §4, summary/summary_tags
added in Krok 4 for the UI's per-envelope result header — additive, does not
change any field the plan §4 shape already defined):
{
"query": "...", "mode": "cascade", "sol_status": "up",
"results": [
{"envelope_id": "paperless:119", "source": "paperless", "dist": 0.34,
"chunk_index": 2, "text": "...", "link": "https://paper.kapala.org/documents/119/details",
"summary": "...", "summary_tags": ["..."]},
{"envelope_id": "<Message-ID>", "source": "gmail", "dist": 0.44,
"chunk_index": 0, "text": "...", "subject": "...", "from": "...", "date": "...",
"link": null, "mail_ui_url": null, "summary": null, "summary_tags": []}
]
}
dist is never filtered server-side — the 0.45/0.55 colour thresholds (below)
are a frontend concern, not an API contract. summary/summary_tags come
from document_summary for SUMMARY_MODEL; null/[] when the envelope
has no summary yet.
Frontend (Krok 4, plan §7)
One page, served from this same FastAPI process — no separate frontend
container, no node build step (plan §2 decision 4): app/templates/index.html
(Jinja2 shell) + app/static/app.js (vanilla JS, fetch() to /search) +
app/static/style.css. Wszystko po polsku.
- Pole zapytania + submit (Enter lub przycisk), przełącznik trybu kaskada/flat (domyślnie kaskada — checkbox "tryb flat (debug)").
- Wyniki grupowane po
envelope_id(dokument): nagłówek trafienia to streszczenie dokumentu (summary, tor haiku) gdy dostępne, w przeciwnym razieenvelope_id; chunki są rozwijanymi fragmentami (<details>) pod nagłówkiem, posortowane podist. - Kolorowanie progów (fazy 3, zweryfikowane bramką):
dist < 0.45zielony,0.45–0.55żółty (nadal renderowany, z wizualnym ostrzeżeniem),> 0.55nigdy nie renderowany jako pojedynczy wynik. Jeśli po tym filtrze żadna grupa nie zostaje nic do pokazania (wszystkie trafienia > 0.55, albo brak trafień w ogóle), całość zastępuje komunikat "Brak odpowiedzi w KB dla tego zapytania" z najlepszym (najniższym) zaobserwowanymdistw nawiasie. - Źródło: Paperless → link "Otwórz w Paperless" (
link); Gmail → metadane (subject/from/date) + przycisk "Kopiuj Message-ID" (envelope_idjest Message-ID, plan §2 decyzja 3) — nie ma dokąd linkować, więc kopiowalny identyfikator zamiast martwego linku. - Stopka pokazuje
sol_statusdyskretnie (odświeżane z/healthzprzy starcie strony i po każdym wyszukiwaniu).
Embed path (current step)
Calls Ollama on SOLARIA directly per request — no cache, no circuit breaker,
no local-PIHA fallback yet (that state machine, plan §2 decision 2/§5, is a
separate later step). If SOLARIA is unreachable, /search returns 503;
/healthz still answers (sol_status: "down"), same tolerance pattern as
llm-gateway.
Startup invariant (hard-fail)
At startup, kb-query queries document_chunk.model and
document_summary.embedding_model for the set of models behind active
embeddings, and refuses to start (crash-loop, visible via container restarts)
if the configured EMBED_MODEL (default bge-m3) isn't in both sets. This
guards against querying with an embedding space that doesn't match what's
actually indexed — see app/startup.py for why the check reads
document_summary.embedding_model and not .model (the latter is the LLM
that wrote the summary, e.g. claude-haiku-4-5, not the embedder).
Configuration
.env — gitignored, copy from env.example. Required: LAN_BIND_IP,
KB_DSN. Optional: OLLAMA_URL, EMBED_MODEL, SUMMARY_MODEL.
Deploy (PIHA)
git pullon PIHA (~/homelab-codex-ws).cp services/kb-query/env.example services/kb-query/.envand fill in the realKB_DSNpassword.-
docker compose -f services/kb-query/docker-compose.yml \ -f hosts/piha/runtime/kb-query/docker-compose.override.yml up -d --build - Verify:
services/kb-query/healthcheck.sh, then from PIHA:curl "http://192.168.31.5:8230/search?q=test"and openhttp://192.168.31.5:8230/in a browser.
Tests
pip install -e packages/kb-retrieval/
cd services/kb-query && pip install -r requirements.txt pytest pytest-asyncio && pytest
Unit tests mock the DB connection and Ollama HTTP session (no live DB/Ollama
required) — same style as packages/kb-retrieval/tests/. tests/test_frontend.py
drives GET ///static/* through FastAPI's TestClient without entering it
as a context manager, so the DB-requiring lifespan never runs.
Frontend JS has its own pure-function tests (query-URL encoding, threshold
colouring, envelope grouping), run without a browser via Node's built-in
test runner: node --test services/kb-query/tests/frontend/.
Out of scope for this step
- Local-PIHA embed fallback / circuit breaker (plan §2 decision 2, §5).
- npm@PIHA vhost, OIDC login, DNS (plan §8) —
kb.kapala.orgis not wired up yet; reach the API/UI directly over LAN/Tailscale for now, no auth.