--- okf: "0.1" type: service visibility: private status: active updated: 2026-08-06 links: - ../runbooks/kb-query-deploy.md --- # 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", "fallback_status": "up"\|"down"\|"unconfigured"}` — `sol_status` comes from the embed router's ~30 s-cached SOLARIA probe (same world view `/search` routes by), `fallback_status` is a live cheap probe of ollama-piha; no auth required (monitoring must reach it) | | `/search?q=&mode=hybrid\|cascade\|flat` | GET | `query_text -> embed -> hybrid/cascade/flat retrieval -> results`, `mode` **defaults to `hybrid`** (od 2026-08-06 — patrz „Tryby wyszukiwania" niżej) | ### Tryby wyszukiwania (`mode`) | `mode` | Co robi | Kiedy | |---|---|---| | `hybrid` (**domyślny** od 2026-08-06) | kaskada (prefiltr po `document_summary`) **+** równoległe skanowanie chunków kopert ze źródeł bez streszczeń (`gmail`), merge po `dist` | normalne zapytania — jedyny tryb, w którym widać treść maili | | `cascade` | tylko tor dokumentowy (prefiltr po streszczeniach) — poprzedni domyślny | porównania/debug toru dokumentowego | | `flat` | pojedynczy skan `document_chunk` bez prefiltru | debug jakości prefiltru | Domyślny `mode` przełączono na `hybrid` **2026-08-06** po ponownym przejściu bramki jakościowej (`kb/phases/kb-m5-faza-mailowa.md` §8, DoD (d)) na pełnym korpusie mailowym (187 025 zembedowanych chunków w HNSW): kryterium regresji paperless PASS (żaden istniejący hit nie zdegradował), mailowe hit@3 5/5. Koszt: jedno dodatkowe zapytanie SQL na wyszukiwanie (skan chunków źródeł summaryless). Jawne `?mode=cascade` / `?mode=flat` działają bez zmian; nieznana wartość `mode` to 422 (pattern `^(cascade|flat|hybrid)$`). `/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): ```json { "query": "...", "mode": "hybrid", "sol_status": "up", "embed_backend": "solaria", "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": "", "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 (checkbox „tryb flat (debug)"). Odznaczony — czyli normalny przypadek — **nie wysyła parametru `mode` w ogóle**, więc UI dziedziczy domyślny tryb API (`hybrid` od 2026-08-06); zaznaczony wysyła jawne `mode=flat`. Domyślny tryb jest zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS. - Wyniki grupowane po `envelope_id` (dokument): nagłówek trafienia to streszczenie dokumentu (`summary`, tor haiku) gdy dostępne, w przeciwnym razie `envelope_id`; chunki są rozwijanymi fragmentami (`
`) pod nagłówkiem, posortowane po `dist`. - Kolorowanie progów (fazy 3, zweryfikowane bramką): `dist < 0.45` zielony, `0.45–0.55` żółty (nadal renderowany, z wizualnym ostrzeżeniem), `> 0.55` nigdy 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) zaobserwowanym `dist` w nawiasie. - Źródło: Paperless → link "Otwórz w Paperless" (`link`); Gmail → metadane (`subject`/`from`/`date`) + przycisk "Kopiuj Message-ID" (`envelope_id` **jest** Message-ID, plan §2 decyzja 3) — nie ma dokąd linkować, więc kopiowalny identyfikator zamiast martwego linku. - Stopka pokazuje `sol_status` dyskretnie (odświeżane z `/healthz` przy starcie strony i po każdym wyszukiwaniu). ## Embed path — active SOLARIA→PIHA fallback (Krok 2, plan §2 D2/§5) Query embeddings go through `app/embed_router.py` (`EmbedRouter`, one instance per process): 1. SOLARIA's health verdict (`GET /api/tags`, 1.5 s timeout) is **cached for 30 s** — no per-request probing. 2. Verdict `up` → embed on `EMBED_PRIMARY_URL` (SOLARIA GPU, ~207 ms) under a hard 3 s timeout. 3. A failure **during a real embed** flips the verdict to `down` for one TTL window and the **same request** is served from `EMBED_FALLBACK_URL` (`ollama-piha`, CPU, `OLLAMA_KEEP_ALIVE=0` — slower, single seconds, but alive) — the user never sees a SOLARIA error while a fallback exists. 4. Verdict `down` → straight to the fallback until the TTL expires; when SOLARIA wakes up, traffic returns to the GPU within ≤30 s. Every `/search` response and log line says which backend embedded the query (`embed_backend: "solaria"|"piha"`, log `backend=…` in `kb-query.embed`) — needed to debug result quality per backend. `sol_status` in the response is the router's world view; the UI footer renders `down` as „SOLARIA: offline (fallback embed)". **Model invariant, both halves:** at startup kb-query pins `EMBED_MODEL` (bge-m3) to `document_chunk.model`/`document_summary.embedding_model` (below); additionally each backend is verified once, at its first use, that its `/api/tags` actually lists `EMBED_MODEL`. A backend serving the wrong model is a **loud ERROR + 500** — never a silent distance computation across two different embedding spaces. Verification is lazy because SOLARIA may be asleep at boot and must not block startup. `/search` returns **503** only when BOTH backends are unreachable (or `EMBED_FALLBACK_URL` is unset — then the pre-fallback behaviour applies); `/healthz` still answers, 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`. On PIHA also set `EMBED_FALLBACK_URL=http://192.168.31.5:11434` (ollama-piha). Optional (defaults in parentheses): `EMBED_PRIMARY_URL` (`http://solaria:11434`), `EMBED_PRIMARY_NAME`/`EMBED_FALLBACK_NAME` (`solaria`/`piha`), `EMBED_HEALTH_TTL_S` (30), `EMBED_HEALTH_TIMEOUT_S` (1.5), `EMBED_PRIMARY_TIMEOUT_S` (3), `EMBED_MODEL` (`bge-m3`), `SUMMARY_MODEL` (`claude-haiku-4-5`). `OLLAMA_URL` was **renamed** to `EMBED_PRIMARY_URL` in Krok 2 — if an old `.env` sets `OLLAMA_URL`, it is ignored. ## Ingress (`kb.kapala.org`, plan §8) Wired up 2026-07-23 (`docs/sessions/2026-07-23-kb-f4-ingress.md`), **no code change in this service** — pure infra step: - npm@PIHA proxy host #35: `kb.kapala.org` → `http://192.168.31.5:8230`, cert #49 (`*.kapala.org` wildcard, DNS-01 via Cloudflare, expires 2026-09-28) — same pattern as `paper.`/`vikunja.`/`ha.kapala.org`. - Pi-hole Local DNS (`/etc/pihole/custom.list` on PIHA, runtime, not in Git): `kb.kapala.org` → `192.168.31.5`. **This is the first `kapala.org` entry in that file** — every other `kapala.org` vhost (paper/ha/immich/vikunja/forgejo) has no LAN override and resolves via the public Cloudflare record (Tailscale IP) even from LAN, a hairpin the plan assumed was already avoided for those too. Not fixed here (out of this task's scope — no other vhosts touched); worth a follow-up if it matters for those services. - Cloudflare A record `kb.kapala.org` → `100.108.208.3` (Tailscale PIHA, DNS only): added manually by the operator (no CF API token available in the environment that did the rest of this step), verified against Cloudflare's own authoritative NS and 8.8.8.8/1.1.1.1 — resolves everywhere now, both LAN (via Pi-hole override) and Tailscale/public (via this record). **No auth.** OIDC (plan §8: `authlib`, `/login`, `/auth/callback`) is explicitly **not implemented** — confirmed no forward-auth/reverse-proxy-level auth pattern exists anywhere in this repo (NPM community edition doesn't support it either); the three precedents (paperless/nextcloud/vikunja) all do OIDC inside the app. Building that is real service code (`authlib` dependency, session middleware, Forgejo OAuth2 app registration) — deliberately deferred to a separate session, decision confirmed with the operator 2026-07-23. Until then `kb.kapala.org` is reachable by anyone on the LAN/tailnet with no login, same as before this vhost existed. ## Out of scope for this step - OIDC login (see above) — separate session, needs `authlib` + Forgejo OAuth2 app. - Calibration verdict for the fallback (plan §5 steps 4–5: live PIHA measurements → keep/tune/degrade decision) — the mechanism is built and default-on when `EMBED_FALLBACK_URL` is set; the measurements are the operator's post-deploy step (see `kb/services/ollama-piha.md`).