---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-07-22
links: []
---

# Moduł 5, faza 4 — kb-query + UI (RECON + PLAN)

> Status: RECON ZAKOŃCZONY (2026-07-22), plan DO ZATWIERDZENIA. Zero kodu, zero
> deployu, zero nowych kontenerów w ramach tego zadania — wyłącznie ten dokument.
>
> Kontynuacja `05-faza3-plan.md` (faza 3: filtr śmieci, dedup, streszczenia+tagi
> [claude-haiku-4-5], kaskada retrieval [PASS, N=10/k=5], cykliczny ingest [timer
> LIVE na PIHA], wiki-proof-of-concept [5 stron w `kb-wiki`] — **DOMKNIĘTA W CAŁOŚCI**
> 2026-07-21, `docs/sessions/2026-07-21.md`). Faza 4 = pierwszy user-facing punkt
> wejścia do KB: serwis `kb-query` (FastAPI, PIHA) opakowujący `cascade_query` w
> HTTP + minimalny UI wyszukiwania pod `kb.kapala.org`. **To wyszukiwarka, nie
> chat** — synteza odpowiedzi i eskalacja to faza 5, poza zakresem tego planu.

---

## 1. Stan faktyczny (recon, zweryfikowany w repo)

### 1.1 Baza i silnik retrievalu (dziedzictwo fazy 3, gotowe do reużycia)

- `envelope`: **225 221** (gmail + paperless), `document_chunk`: **2765** aktywnych +
  wykluczonych (`excluded_reason` IS NULL vs `'ocr_junk'`/`'duplicate'`),
  `document_summary`: **162** dla `model='claude-haiku-4-5'` (157 z pilota + 5
  dogonionych przez cykliczny ingest, `docs/sessions/2026-07-21.md`).
- Silnik retrievalu **już istnieje i jest przetestowany**:
  `jobs/documents-ingest/src/documents_ingest/retrieval.py` — `flat_query` /
  `cascade_query`, obie `query_text -> chunki z dist i source`, dzielą jeden embed
  zapytania (bge-m3 przez Ollamę). `cascade_query(N=10, k=5,
  summary_model='claude-haiku-4-5')` jest domyślną ścieżką (bramka §6 fazy 3, PASS).
  Zależy wewnętrznie od `embed_chunk` i `_vector_literal` z
  `documents_ingest/chunk_embed.py` (`jobs/documents-ingest/src/documents_ingest/chunk_embed.py:207,233`)
  — `embed_chunk(session, base_url, model, text) -> (embedding, elapsed)`, POST
  `{base_url}/api/embeddings`, **bez wbudowanego timeoutu/retry** (dziedziczy z
  `aiohttp.ClientSession(timeout=...)` wołającego), `raise_for_status()` propaguje
  `aiohttp.ClientError` gdy Ollama nieosiągalna.
- Bramka jakości ma już wersjonowany eval-set (`jobs/documents-ingest/eval/queries.yaml`,
  7 zapytań + 2 kontrole negatywne) i skrypt integracyjny
  (`jobs/documents-ingest/eval/retrieval_eval.py`) wołający `flat_query`/`cascade_query`
  **bezpośrednio przez DB+Ollama** — dziś nie ma trybu HTTP (§9 poniżej).
- `envelope.id` dla `paperless:<id>` = `f"paperless:{doc['id']}"`
  (`paperless_adapter.py:126`); dla gmaila = surowy nagłówek `Message-ID` (bez `<>`)
  lub `sha256-<hash>` gdy brak nagłówka (`gmail_bulk_import/importer.py:63-70`) —
  **Message-ID jest samym `envelope.id`, nie osobną encją**. `entities` gmaila:
  jeden obiekt `{"type":"headers","from":{...},"to":[...],"subject":...,"date_raw":...}`
  (`gmail_header_backfill/backfill.py:120-128`) + `{"type":"attachment",...}` per
  załącznik. `entities` paperless: `content`/`correspondent`/`tag`/`filename`/
  `content_type`, opcjonalnie `source_mail` (link do koperty gmail).

### 1.2 Ollama / embedding

- **SOLARIA** (GPU, `services/ollama/`): `bge-m3`, zmierzone 207 ms/embed GPU.
  `availability_target: medium` — planowe wyłączenia, cykliczny ingest już to toleruje
  (probe `GET /api/tags` + pomiń, nie fail). kb-query musi to samo, ale **w torze
  interaktywnym** (użytkownik czeka), stąd aktywny fallback zamiast "pomiń i nadrób
  w nocy" (§5).
- **PIHA**: kb-postgres, brak Ollamy. Node ma **arm64, 4 rdzenie, brak akceleracji**
  (`hosts/piha/capabilities.yaml`) — słabszy punkt startowy niż SOLARIA-CPU (790 ms/embed
  zmierzone na SOLARII, prawdopodobnie x86 z szerszym SIMD; RPi5 realnie wolniej,
  nieznana dokładna wartość — do zmierzenia, §5.3).

### 1.3 PIHA — budżet RAM (istotne dla decyzji D2 / lokalnego fallbacku)

- Audyt `docs/infra/piha-slim-audit-2026-07-02.md`: po "bezpiecznym usuń"
  (elasticsearch+diskover+stary llm-gateway) available rosło z **2.9Gi do ~3.8Gi**
  (na 8 GB total, +4 GB swap). To jest **jedyna zweryfikowana liczba w repo i ma
  3 tygodnie** — od tego dnia na PIHA doszły (GitOps, z `mem_limit`): paperless+db+broker
  (≤1.9 Gi worst-case, `hosts/piha/runtime/paperless/`), vikunja+db (768m+256m),
  kb-postgres (1g), llm-gateway (256m) — suma nowych ceilingów **≈4.4 Gi**, już dziś
  potencjalnie przy/za budżetem z audytu (ceilingi to worst-case, nie wszystkie
  jednocześnie uderzają, ale to nie jest budżet z dużym zapasem).
- PIHA hostuje też **poza GitOps** (shadow, host-level): Home Assistant (`localhost:8123`,
  cel `ha-diag-agent`), Immich ("zostaje na PIHA na stałe" — decyzja audytu), Forgejo
  (origin repo + IdP OIDC), host-owy mosquitto, prometheus, **oraz najprawdopodobniej
  NPM** (`npm@PIHA` — patrz §1.4) — żadne z nich nie ma zapisanego w GitOps `mem_limit`,
  więc ich realny ślad dziś jest nieznany z samego repo.
- **Wniosek**: liczba "ile RAM naprawdę wolne na PIHA dziś" nie jest wiarygodnie
  wyprowadzalna z repo — wymaga pomiaru na żywym hoście (`free -h`, `docker stats`)
  **przed**, nie po, decyzji o domyślnym włączeniu lokalnego fallbacku embed (§5.3).

### 1.4 Wzorce serwisów webowych (npm ingress + OIDC) — z istniejących serwisów

Trzy żywe precedensy o identycznym kształcie (`services/paperless/`, `services/vikunja/`,
`services/nextcloud/`), wszystkie **`exposure: private`** mimo publicznej-brzmiącej
domeny `*.kapala.org`:

- **NPM**: nie ten z `services/npm/` (ten jest `owner_node: vps`, publiczny ingres dla
  outline/joplin/agents — "drugi npm", `docs/sessions/2026-07-07-okit-wildcard.md`
  sekcja "NIE ruszone"). Istnieje **osobna, dziś poza-GitOps instancja `npm@PIHA`**
  (cert #51 *.okit.pl, cert osobny dla *.kapala.org — sesje ją tylko konfigurują
  ręcznie/SQL-em, nigdy nie deployują z repo). kb-query dogania się do tego samego
  wzorca: nowy vhost w `npm@PIHA`, TLS z **już istniejącego** wildcard `*.kapala.org`
  (pokrywa `paper.`/`cloud.`/`vikunja.kapala.org` — `docs/kb/modules/DECYZJE-do-podjecia.md`
  #6) — **żaden nowy certyfikat nie jest potrzebny**.
- **DNS — dwie warstwy, obie trzeba dotknąć** (lekcja `okit-cloudflare-migracja.md`
  §"WAZNE: split-horizon DNS"): (a) Cloudflare rekord A → Tailscale IP PIHA
  (`100.108.208.3`), DNS Only (nie proxied) — to jest "mesh, prywatne" (poza LAN
  trzeba być na tailnecie); (b) **Pi-hole Local DNS** na PIHA (`192.168.31.5:8888`,
  poza GitOps) nadpisuje tę samą nazwę na **LAN IP** (`192.168.31.5`) dla klientów
  w domu — bez tego LAN-owy klient robi zbędny hairpin przez Tailscale zamiast iść
  bezpośrednio po LAN. Oba kroki są ręczne (UI Pi-hole / Cloudflare), nie ma ich w
  repo — to zawsze było poza automatem, kb-query nie zmienia tego wzorca.
- **OIDC — wbudowane w apkę, nigdy forward-auth.** Paperless: `django-allauth
  openid_connect` (env `PAPERLESS_SOCIALACCOUNT_PROVIDERS`). Nextcloud: appka
  `user_oidc` (occ). Vikunja: natywny `openid` provider w `config.yml`. **Nie ma w
  repo żadnego wzorca forward-auth/reverse-proxy-level auth** (NPM community edition
  go zresztą nie ma) — logowanie zawsze robi sama aplikacja. Wspólny trik trzech
  precedensów: `extra_hosts: forgejo.kapala.org:192.168.31.5` (albo `.okit.pl` dla
  starszej Vikunji) w compose, żeby discovery OIDC szło po LAN zamiast przez
  potencjalnie kapryśny publiczny DNS. kb-query jest **pierwszą usługą FastAPI w
  repo z własnym loginem** — nie ma gotowego kodu do skopiowania, tylko wzorzec
  architektoniczny do powtórzenia w nowym frameworku (§8).
- **Kontrprzykład**: `llm-gateway` (jedyny inny FastAPI na PIHA) jest
  Tailscale-only, bez OIDC, bez npm — bo to API maszyna-maszyna, nie UI dla
  człowieka. kb-query jest bliżej paperless (człowiek w przeglądarce) niż
  llm-gateway (agent po API) — stąd pełny wzorzec npm+OIDC, nie skrót.

### 1.5 Telegram (dla bonusu §10)

Istnieje już bot: `services/agent-system/telegram-bot/bot.py` — `python-telegram-bot`,
**polling** (nie webhook), token w `TELEGRAM_BOT_TOKEN`, autoryzacja przez
`TELEGRAM_ALLOWED_USER_IDS` (self-registration przez `/start`, bez hardkodowanego
chat_id). Komendy dziś: `/status /summary /nodes /services /unhealthy /incidents
/actions /help` + stub free-text fallback (`ENABLE_LLM_FALLBACK`, dziś no-op). Bot
mówi do control-plane HTTP API (`CONTROL_PLANE_URL`), nie bezpośrednio do żadnej bazy.

### 1.6 packages/ vs jobs/ — konwencja z CLAUDE.md

`packages/<lib>/` = reużywalne biblioteki bez `docker-compose.yml`/`service.yaml`,
instalowane w serwisach przez `COPY packages/<lib>/ ...; RUN pip install`
(pierwszy przykład: `packages/kb-mail/`, już zależność `documents-ingest`). `jobs/`
= joby wsadowe instalowane **na hoście** (venv, nie Docker) — `jobs/documents-ingest`
ma w `pyproject.toml` zależność na `anthropic` (streszczenia) i skrypty CLI
(`documents-ingest-*`), z których kb-query nie potrzebuje ani jednego.

---

## 2. Otwarte decyzje dla Oskara (z rekomendacjami)

### Decyzja 1 — Reużycie `retrieval.py`: import z `documents-ingest` vs wydzielenie do `packages/`

**Rekomendacja: wydzielić do nowego `packages/kb-retrieval/`** (`retrieval.py` +
`embed.py` przeniesione z `chunk_embed.py`: `embed_chunk`, `_vector_literal`,
stałe `DEFAULT_MODEL`/`DEFAULT_OLLAMA_URL`), reużywane przez **oba** konsumentów:
`jobs/documents-ingest` (dodaje `kb-retrieval` do zależności, tak jak dziś `kb-mail`)
i `services/kb-query` (Dockerfile: `COPY packages/kb-retrieval/ ...`).

Uzasadnienie:
- **kb-query to długożyjący serwis Docker, `documents-ingest` to joby venv-owe na
  hoście** (§1.6) — nie ma naturalnego sposobu, żeby Dockerfile serwisu "zaimportował"
  kod z `jobs/` bez kopiowania całego drzewa jobu do obrazu, co ciągnie za sobą
  `anthropic` (streszczenia) i CLI-skrypty, których kb-query nigdy nie użyje —
  niepotrzebnie większy obraz i węższa granica odpowiedzialności.
- **Konwencja już istnieje i jest jednoznaczna**: `packages/kb-mail` robi dokładnie
  to samo (reużywalna biblioteka bez `service.yaml`, instalowana `pip install` do
  obrazu) — `kb-retrieval` to drugi taki pakiet, nie nowy wzorzec.
- **Kod przenosi się 1:1, bez zmian logiki** — `retrieval.py` już jest napisany jako
  "dwie czyste funkcje wejściowe pod przyszłe kb-query" (docstring modułu, cytowany
  w §6.3 fazy 3) — to wydzielenie było zaplanowane, nie jest nowym pomysłem.
- Odrzucona alternatywa: zostawić w `jobs/documents-ingest` i importować przez
  `sys.path`/instalację `-e` w obrazie kb-query. Działa technicznie (eval-skrypt tak
  dziś robi, `sys.path.insert` w `retrieval_eval.py:37`), ale ciągnie `anthropic` +
  CLI do obrazu serwisu i zaciera granicę jobs/vs services z CLAUDE.md.

**Zakres migracji (krok 0, przed resztą fazy)**: `chunk_embed.py` i `summarize.py`
(via `embed_chunk`) oraz `retrieval_eval.py` przechodzą na import z `kb_retrieval`;
`documents_ingest.retrieval` zostaje jako cienki re-export **albo** usuwa się
całkiem i `pyproject.toml` dostaje zależność `kb-retrieval` — do rozstrzygnięcia
technicznie przy implementacji (re-export bezpieczniejszy, zero ryzyka złamania
importów gdzie indziej w repo; usunięcie czystsze). Definition of Done z CLAUDE.md
(build/smoke + pytest) obowiązuje dla obu paczek po migracji.

### Decyzja 2 — Aktywny fallback embed: health-check + circuit breaker + lokalny embed PIHA

Architektura (health-check → przełącznik → fallback) jest **wiążąca z roadmapy**,
rekon rozstrzyga wyłącznie *jak* zaimplementować nogę "lokalny embed na PIHA" i
konkretne wartości parametrów.

**Maszyna stanów (w pamięci procesu kb-query, jeden globalny stan `sol_status`):**

| Krok | Zachowanie | Parametr (proponowany, do kalibracji) |
|---|---|---|
| 1. Cache ważny | Użyj zapamiętanego stanu (`up`/`down`) bez sondowania | TTL **30 s** |
| 2. Cache wygasł | `GET http://solaria:11434/api/tags` | timeout **500 ms** |
| 2a. Sukces | `sol_status = up`, cache na 30 s | — |
| 2b. Timeout/błąd | `sol_status = down`, cache na 30 s (ten sam TTL — brak dowodu na
    potrzebę osobnego backoffu, uprościć aż flapping pokaże inaczej) | — |
| 3. `sol_status = up` → embed | `POST solaria:11434/api/embeddings`, twardy timeout | **3 s** |
| 3a. Sukces | Zwróć wynik | — |
| 3b. Timeout/błąd **w trakcie realnego zapytania** (nie tylko probe'a) | **Jednorazowe przełączenie**: `sol_status = down` (cache 30 s) natychmiast, **to samo zapytanie** leci do kroku 4 zamiast zwracać błąd userowi | — |
| 4. `sol_status = down` → embed | Lokalnie na PIHA, `model='bge-m3'` (ten sam co §invariant) | — |

Efekt: pojedyncze zapytanie użytkownika **nigdy nie widzi błędu z powodu SOLARII**
poza pierwszym niefortunnym trafieniem w krok 3b (jeden dodatkowy 3 s timeout raz na
30 s okno, potem cache trzyma `down` i idzie prosto do lokalnego embedu).

**Twardy inwariant (embedding query = model document_chunk.model), egzekwowany dwa razy:**
- **Przy starcie** kb-query odpytuje `SELECT DISTINCT model FROM document_chunk WHERE
  excluded_reason IS NULL AND embedding IS NOT NULL` (i analogicznie
  `document_summary`); jeśli skonfigurowany `EMBED_MODEL` (env, domyślnie `bge-m3`)
  nie jest w tym zbiorze (albo zbiór jest pusty) — **serwis odmawia startu**
  (crash-loop, widoczny przez restart w monitoringu — świadomie głośno, nie cicho).
- **Per request**: zarówno ścieżka SOLARIA jak i PIHA-fallback wołają
  `embed_chunk(..., model=EMBED_MODEL)` z **tej samej stałej konfiguracyjnej** — nie
  ma dziś (i nie planujemy w fazie 4) per-request wyboru modelu przez usera, więc
  ryzyko dryfu jest wyłącznie konfiguracyjne (ktoś zmieni `EMBED_MODEL` bez migracji
  danych) — pokryte przez check startowy. Jeśli Ollama kiedyś zacznie zwracać w
  odpowiedzi faktycznie użyty model (dziś `/api/embeddings` tego nie robi), dodać
  asercję per-response — zanotowane jako przyszłe wzmocnienie, nie blokuje fazy 4.

**Lokalny embed na PIHA — feasibility (§1.3 RAM + §1.2 CPU):**

- `bge-m3` w Ollama: model ~1.2 GB na dysku (nieznana dokładna wartość dla tego
  builda — zweryfikować `ollama show bge-m3` na SOLARII przed pull na PIHA), rezydentny
  ślad RAM podczas inferencji szacunkowo **1.5–2 GB** w trakcie burst.
  `OLLAMA_KEEP_ALIVE=0` (wymagane w zadaniu) zwalnia model z RAM **natychmiast po
  każdym zapytaniu** — to nie jest stały koszt rezydentny, tylko krótki spike
  (idle Ollama binarnie to ~100 MB), co realnie zmienia bilans na lepszy niż "drugi
  stały kontener 2 GB".
- **Ale**: spike nakłada się w czasie dokładnie wtedy, gdy inne serwisy na PIHA są
  pod obciążeniem z tego samego powodu (np. paperless fallback-OCR też się budzi,
  gdy SOLARIA śpi — ten sam trigger). Przy nieznanym dziś realnym wolnym RAM
  (§1.3) nie da się z samego repo rozstrzygnąć "czy się zmieści" z pewnością.
- **CPU**: RPi5 (arm64, brak akceleracji) vs 790 ms zmierzone na SOLARIA-CPU (prawdopodobnie
  x86 z lepszym SIMD) — realny czas na PIHA jest niezmierzony, mógłby być
  kilkukrotnie wyższy (sekundy, nie setki ms). Dla ścieżki fallback (rzadkiej,
  awaryjnej) to akceptowalne, o ile nie jest to droga domyślna.

**Rekomendacja: budować mechanizm w pełni (health-check + circuit breaker + kontener
lokalnej Ollamy na PIHA), ale gate'ować go kalibracją na żywym hoście przed
włączeniem jako cichy domyślny fallback** — dokładnie ten sam wzorzec co "kalibracja
przed backfillem" z fazy 3 §3.1 (zmierz, obejrzyj, dopiero wtedy zaufaj progowi):
1. Zbudować i zdeployować kontener `ollama-piha` (nowy, `hosts/piha/runtime/`) +
   pull `bge-m3` + zmierzyć realny czas embedu i szczyt RAM na żywym PIHA (`docker
   stats` w trakcie, kilka powtórzeń).
2. Jeśli szczyt RAM + istniejące ceilingi mieszczą się z rozsądnym zapasem (≥500 MB
   wolnego po szczycie, analogicznie do marginesu z kb-postgres/paperless) i czas
   embedu jest w pojedynczych sekundach (nie dziesiątkach) → włączyć jako domyślny
   fallback z `mem_limit` na kontenerze `ollama-piha` jako twardy ceiling (np. 2.5g,
   do potwierdzenia pomiarem) — **cgroup OOM restartuje `ollama-piha`, nie zabiera
   RAM sąsiadom** (ten sam mechanizm co reszta PIHA).
3. Jeśli nie mieści się bezpiecznie → **jedyna sensowna alternatywa to jawna
   degradacja**: kb-query zwraca `503 {"error": "wyszukiwanie chwilowo
   niedostępne — SOLARIA offline, brak zapasowego embedu"}` zamiast cicho ryzykować
   OOM na współdzielonym hoście z Home Assistant. Inny model lokalny (mniejszy,
   szybszy) **nie jest opcją** — złamałby twardy inwariant (inny model = wektory
   nieporównywalne z istniejącym indeksem). Kolejkowanie/retry też odpada — search
   jest synchroniczne, użytkownik czeka przy przeglądarce, nie ma gdzie odłożyć
   zapytania na "SOLARIA wstanie za parę godzin".
- Krok kalibracji wchodzi do planu implementacji jako osobna pozycja (§11, krok 5) —
  **werdykt (włączyć domyślnie / zostawić za flagą / zrezygnować) jest wyjściem tego
  kroku, nie założeniem tego planu.**

### Decyzja 3 — Linki do źródeł: paperless vs gmail

**Paperless**: URL do dokumentu — **do zweryfikowania na żywym Paperless przed
implementacją** (repo nie ma zapisanego przykładu, `docs/kb/modules/02-paperless-service.md`
dokumentuje tylko subdomenę, nie ścieżkę). Kandydat wg konwencji paperless-ngx UI:
`https://paper.kapala.org/documents/<id>/details` (Angular routing) — `envelope_id`
`paperless:<id>` już niesie surowy `<id>` do wstawienia. Krok implementacji: jeden
ręczny `curl -I`/otwarcie w przeglądarce na żywym paperless przed zakodowaniem
formatu linku na stałe.

**Gmail — rekomendacja: skopiowalny Message-ID + metadane koperty, zero linku
(bo nie ma dokąd linkować).** `envelope_id` **jest** Message-ID (§1.1) — nic nie
trzeba dodatkowo wyciągać. Odpowiedź `/search` dla trafienia źródła gmail niesie:
`envelope_id` (message-id), `subject`/`from`/`date` z `entities[type=headers]`
(join do `envelope.entities` przy budowaniu odpowiedzi), **oraz pole
`mail_ui_url: null`** zarezerwowane celowo (nie usunięte, nie wypełnione) — gdy
powstanie przyszły mail-UI (kb-00 etap 6, poza zakresem tej fazy), wypełnienie tego
pola to zmiana w jednym miejscu, nie nowy kontrakt API. UI: przycisk "kopiuj
Message-ID" zamiast linku (§7).

### Decyzja 4 — Frontend: osobny build czy serwowany z FastAPI

**Rekomendacja: jeden serwis, zero osobnego node-builda.** Serwer renderuje szkielet
strony (Jinja2, wbudowane w FastAPI/Starlette) + jeden statyczny plik JS (vanilla,
`fetch()` do `/search`) + jeden CSS. Żadna inna usługa webowa w tym repo nie ma
frontendowego build stepu (paperless/nextcloud/vikunja to gotowe obrazy z własnym
frontendem; `llm-gateway` to czyste API bez UI) — kb-query jest pierwszym własnym UI
w repo, więc "najprostsze co działa" bije "jak zrobiliby to profesjonalnie
zewnętrzni autorzy frameworków". Wyszukiwarka (pole + lista wyników), nie chat —
brak stanu rozmowy do zarządzania, więc vanilla JS bez frameworka jest wystarczające,
nie tymczasowe uproszczenie do wymiany później.

Odrzucona alternatywa: SPA (React/Vue) + osobny kontener/build. Dokłada node
toolchain, Dockerfile multi-stage, i najbliższy precedens w repo (agent-system webui,
`services/agent-system/`) już pokazuje że osobny frontend-kontener to więcej
ruchomych części niż potrzeba dla jednego pola wyszukiwania.

### Decyzja 5 — Ingress: potwierdzenie wzorca z §1.4

**Rekomendacja: dokładnie wzorzec paperless/nextcloud** — `exposure: private` w
`service.yaml` (mimo domeny `kb.kapala.org` — to ta sama klasa co `paper.`/`cloud.`,
patrz §1.4), LAN bind (`LAN_BIND_IP`, nigdy `0.0.0.0`), vhost w `npm@PIHA` (ręczny
krok, poza GitOps, jak zawsze), TLS z istniejącego wildcard `*.kapala.org` (zero
nowych certów), OIDC wbudowane w apkę przez `authlib` (Starlette-natywna biblioteka
OIDC-client — pierwszy raz w repo dla FastAPI, ale ten sam wzorzec co
django-allauth/user_oidc/Vikunja-natywny: apka robi login sama, nie proxy),
`extra_hosts: forgejo.kapala.org:192.168.31.5` (trik z paperless/nextcloud — nowsza
domena `kapala.org`, nie `okit.pl` jak stara Vikunja). Rejestracja OAuth2 app w
Forgejo Settings > Applications — krok wykonawczy przy deployu, jak w pozostałych
trzech serwisach.

**DNS (§1.4, dwie warstwy — obie kroki implementacji, nie automat):**
1. Cloudflare: potwierdzić że `*.kapala.org` wildcard już pokrywa `kb.kapala.org`
   (powinien — to ten sam mechanizm co `paper.`/`cloud.`/`vikunja.kapala.org`) —
   jeśli tak, **zero nowego rekordu**; jeśli z jakiegoś powodu nie, dodać A →
   `100.108.208.3` (Tailscale PIHA), DNS Only.
2. Pi-hole Local DNS (`192.168.31.5:8888`, ręcznie w UI): `kb.kapala.org` →
   `192.168.31.5` (LAN IP PIHA) — LAN klienci idą bezpośrednio, nie przez Tailscale
   hairpin. Ten sam ręczny krok co dla `paper.`/`cloud.kapala.org`.

### Decyzja 6 — Bramka jakościowa fazy 4

**Rekomendacja: rozszerzyć `retrieval_eval.py` o tryb transportu HTTP, nie pisać
nowego skryptu.** Dodać `--transport {direct,http}` (domyślnie `direct`, zachowuje
dzisiejsze zachowanie) + `--base-url` dla trybu `http`, który woła `GET
kb-query/search?q=...&mode=cascade` zamiast bezpośrednio `cascade_query`. Kryterium:
**identyczne** (nie ±epsilon) `dist` dla trybu HTTP vs `direct` na tym samym żywym
SOLARII — to ta sama baza i ten sam kod, HTTP to tylko opakowanie, więc różnica
oznacza bug w serializacji/handlerze, nie w retrievalu.

**Test fallbacku (osobny, nowy przypadek — nie rozszerzenie powyższego):**
symulacja sol-down (np. tymczasowo błędny `OLLAMA_URL` w konfiguracji testowej kb-query,
albo zablokowanie portu 11434 do SOLARII na czas testu) → potwierdzenie że kb-query
przełącza się na lokalny embed PIHA i zwraca wyniki. Tu **±epsilon jest uzasadnione**
(nie identyczność) — SOLARIA-GPU i PIHA-CPU to dwa różne uruchomienia tego samego
modelu `bge-m3`, drobne różnice numeryczne w zmiennoprzecinkowej arytmetyce między
backendami są oczekiwane i nieszkodliwe (kolejność wyników top-k powinna być
identyczna, `dist` może różnić się w czwartym-piątym miejscu po przecinku).

### Decyzja 7 — Bonus: bot telegramowy

**Rekomendacja: doczepić `/kb <pytanie>` do istniejącego procesu
`services/agent-system/telegram-bot/`, nie nowy bot.** Reużywa: token bota, listę
`TELEGRAM_ALLOWED_USER_IDS`, pętlę pollingu — nowa komenda to jeden
`CommandHandler` wołający `GET kb-query/search` (HTTP, jak §"Decyzja 6" wyżej) i
formatujący top-3 z progami dystansu jako emoji (🟢/🟡/🔴, ten sam schemat co UI §7)
+ `envelope_id`/link źródła. **Wymaga**: kb-query osiągalny z hosta, na którym stoi
`agent-system` (dziś nieustalone z repo, gdzie dokładnie ten serwis biega — do
sprawdzenia przy implementacji; jeśli nie na PIHA, potrzebny dostęp po Tailscale,
analogicznie do `llm-gateway` osiągalnego jako `http://piha:8080`). Koszt: niski —
głównie okablowanie (jeden handler + jeden HTTP call), zero nowej infrastruktury,
zero nowego tokena. Szacunek: **0.5 sesji**, warunkowane tym że kb-query już stoi
i ma HTTP endpoint gotowy do wołania — nie robić równolegle z rdzeniem fazy 4.

---

## 3. Krok implementacji 0 — pakiet `packages/kb-retrieval`

Poprzedza wszystko inne (§2 Decyzja 1). Zakres:
- `packages/kb-retrieval/src/kb_retrieval/embed.py` — `embed_chunk`, `_vector_literal`,
  `DEFAULT_MODEL`, `DEFAULT_OLLAMA_URL` przeniesione z `chunk_embed.py` bez zmiany
  logiki. Dodać (nowe, potrzebne kb-query): `async def check_ollama_health(session,
  base_url, timeout_s) -> bool` (probe `/api/tags`) — generyczna, żeby health-check
  z §2 Decyzji 2 nie duplikował logiki HTTP.
- `packages/kb-retrieval/src/kb_retrieval/retrieval.py` — `flat_retrieve`,
  `cascade_retrieve`, `flat_query`, `cascade_query` przeniesione 1:1.
- `jobs/documents-ingest`: `pyproject.toml` dostaje zależność `kb-retrieval` (wzorem
  `kb-mail`); `chunk_embed.py`, `summarize.py`, `documents_ingest/retrieval.py`
  (re-export albo usunięcie — decyzja techniczna przy implementacji) przechodzą na
  import z `kb_retrieval`.
- `jobs/documents-ingest/eval/retrieval_eval.py`: import z `kb_retrieval` zamiast
  `documents_ingest.retrieval`.
- Testy: `packages/kb-retrieval/tests/` przejmuje istniejące testy z
  `jobs/documents-ingest/tests/test_retrieval.py` (mocki, bez żywej bazy). Definition
  of Done z CLAUDE.md: build/smoke + pytest dla obu paczek po migracji, żywy przebieg
  `retrieval_eval.py` na końcu jako regression check (te same wyniki co przed
  migracją — to czysty refactor, zero zmiany zachowania).

**Szacunek: 0.5 sesji.**

---

## 4. Krok 1 — serwis `services/kb-query` (szkielet)

Layout wg CLAUDE.md (`docker-compose.yml`, `service.yaml`, `README.md`,
`env.example`, `healthcheck.sh`) + `Dockerfile` (wzorem `llm-gateway`:
`python:3.12-slim`, `COPY packages/kb-mail/ packages/kb-retrieval/ ...; pip install`,
`uvicorn app.main:app`).

`service.yaml`: `owner_node: piha`, `exposure: private`, `dependencies: [kb-postgres,
forgejo]` (`ollama` na SOLARII jest zewnętrzny/opcjonalny — health-checkowany w
runtime, nie hard dependency, zgodnie z tolerancją na SOLARIA-offline z §1.2).

Endpointy:
- `GET /healthz` — `{"status": "ok"}` + (opcjonalnie) `sol_status` z cache (§2
  Decyzja 2) dla widoczności operacyjnej, wzorem `llm-gateway` `/`.
- `GET /search?q=<text>&mode=cascade|flat` (domyślnie `cascade`) — `query_text ->
  embed -> cascade_query/flat_query -> odpowiedź`:
  ```
  {
    "query": "...", "mode": "cascade", "sol_status": "up",
    "results": [
      {
        "envelope_id": "paperless:119", "source": "paperless",
        "dist": 0.3418, "chunk_index": 2, "text": "...",
        "link": "https://paper.kapala.org/documents/119/details"
      },
      {
        "envelope_id": "<Message-ID>", "source": "gmail",
        "dist": 0.44, "chunk_index": 0, "text": "...",
        "subject": "...", "from": "...", "date": "...",
        "link": null, "mail_ui_url": null
      }
    ]
  }
  ```
  `source` wyprowadzone z `envelope_id`/`envelope.source` (join do `envelope` po
  `chunks[].envelope_id` — potrzebny w kb-query, `retrieval.py` dziś zwraca tylko
  `document_chunk`, nie robi joina do `envelope`; to jest nowy kod w warstwie
  HTTP-handlera kb-query, nie zmiana w `kb_retrieval`).

`mem_limit`: FastAPI+uvicorn, ślad podobny do `llm-gateway` (~256m) — kontener
kb-query sam jest lekki; ciężar (jeśli włączony) jest w osobnym kontenerze
`ollama-piha` (§5), nie tutaj.

**Szacunek: 1 sesja** (endpoint + join do envelope + response shape + testy z mockiem DB).

---

## 5. Krok 2 — aktywny fallback embed

Implementacja maszyny stanów i inwariantu z §2 Decyzja 2. Podkroki:

1. Health-check + cache + circuit breaker w kb-query (używa `check_ollama_health`
   z `kb_retrieval.embed`, §3).
2. Walidacja startowa modelu (`SELECT DISTINCT model FROM document_chunk/document_summary`).
3. Nowy kontener `ollama-piha` (`hosts/piha/runtime/ollama-piha/` **lub** nowy wpis w
   `services/` jeśli traktowany jako pełny serwis GitOps — do rozstrzygnięcia: to
   jest infrastrukturalny dodatek specyficzny dla PIHA, prawdopodobnie bliżej
   `services/ollama/` sklonowanego z `owner_node: piha` + `OLLAMA_KEEP_ALIVE=0` niż
   czegoś nowego). `ollama pull bge-m3` na PIHA.
4. **Kalibracja na żywym PIHA** (gate, nie formalność — analogicznie do sweep N z
   fazy 3 §6.3): kilka pomiarów czasu embedu + `docker stats` w trakcie, przy
   normalnym obciążeniu PIHA (nie w oknie ciszy nocnej) — realistyczny worst case.
5. **Werdykt** (wyjście kalibracji, wejście do decyzji Oskara): włączyć jako
   domyślny fallback / zostawić za flagą `KB_QUERY_LOCAL_FALLBACK_ENABLED` (domyślnie
   off, włączany po dodatkowej obserwacji) / zrezygnować na rzecz jawnej degradacji
   503 (§2 Decyzja 2, pkt 3).

**Szacunek: 1–1.5 sesji** (zależnie od werdyktu kalibracji — degradacja 503 jest
prostsza niż domyślnie-włączony fallback z pełnym mem_limit tuningiem).

---

## 6. Krok 3 — linki do źródeł

Paperless: weryfikacja formatu URL na żywym hoście (§2 Decyzja 3) → stała w kodzie
z komentarzem skąd pochodzi (wzorzec z fazy 3 dla progów kalibrowanych). Gmail:
pola `subject`/`from`/`date`/`mail_ui_url: null` w response shape (§4). Zero nowego
schematu bazy — czyste odczyty z `envelope.entities`.

**Szacunek: 0.5 sesji** (część kroku 1, wydzielona tu tylko dla czytelności planu).

---

## 7. Krok 4 — frontend minimalny

Jedna strona (Jinja2 template + vanilla JS + CSS, serwowane z tego samego FastAPI,
§2 Decyzja 4):

- Pole zapytania + submit (Enter albo przycisk).
- Wyniki grupowane po `envelope_id` (dokument), w obrębie dokumentu chunki
  posortowane po `dist`.
- Kolorowanie progów (progi z fazy 3, `docs/kb/modules/05-faza3-plan.md` §1.2,
  zweryfikowane bramką): `dist < 0.45` zielony, `0.45–0.55` żółty, `> 0.55` —
  **nie renderować wyniku**, tylko komunikat "brak odpowiedzi w KB" (żółta/czerwona
  strefa nadal renderuje wynik z ostrzeżeniem wizualnym; czerwona = brak sensownego
  trafienia, więc nic konkretnego do pokazania poza samym komunikatem).
- Link źródła (paperless) / przycisk "kopiuj Message-ID" (gmail) — §6.
- Przełącznik kaskada/flat (debug) — parametr `mode` do `/search`, checkbox/select
  w UI, domyślnie `cascade`.

**Szacunek: 1 sesja.**

---

## 8. Krok 5 — ingress (npm@PIHA, OIDC, DNS)

Wg §2 Decyzja 5:

1. `authlib` w zależnościach kb-query; endpointy `/login`, `/auth/callback`, sesja
   przez cookie (podpisany, `SessionMiddleware` Starlette) — chroni `/search` i UI,
   `/healthz` zostaje bez auth (musi odpowiadać monitoringowi bez loginu).
2. Rejestracja OAuth2 app w Forgejo (Settings > Applications) — client_id/secret do
   `.env` kb-query (gitignored, jak wszędzie).
3. `extra_hosts: forgejo.kapala.org:192.168.31.5` w compose.
4. Deploy kontenera, LAN bind (`LAN_BIND_IP`).
5. Nowy vhost w `npm@PIHA` (ręcznie, UI) → `kb.kapala.org` → `192.168.31.5:<port>`,
   certyfikat = istniejący wildcard `*.kapala.org`.
6. DNS: potwierdzić pokrycie wildcardem Cloudflare (prawdopodobnie zero akcji) +
   Pi-hole Local DNS override `kb.kapala.org` → `192.168.31.5` (ręcznie, UI).
7. Weryfikacja end-to-end: z LAN i z poza LAN (przez Tailscale), login przez
   Forgejo, sesja trzyma.

**Szacunek: 1 sesja** (głównie integracja OIDC od zera w nowym frameworku — jedyny
krok bez bezpośredniego kodu-precedensu do skopiowania, tylko wzorca architektury).

---

## 9. Krok 6 — bramka jakościowa fazy 4

Wg §2 Decyzja 6:
1. `retrieval_eval.py --transport http --base-url http://piha:<port>` → identyczne
   `dist` vs `--transport direct` na tych samych 7+2 zapytaniach z `queries.yaml`.
2. Nowy test fallbacku (symulacja sol-down) → wyniki z PIHA-embedu, `dist` w
   granicach epsilon (do ustalenia empirycznie z kalibracji §5, prawdopodobnie
   rzędu 1e-3–1e-2) względem baseline z SOLARII, **kolejność top-k identyczna**.
3. **Kryterium ukończenia fazy**: oba testy PASS + UI ręcznie zweryfikowany na
   żywo (Definition of Done z CLAUDE.md wymaga też testu UI w przeglądarce dla
   zmian frontendowych, nie tylko pytest).

**Szacunek: 0.5–1 sesja** (zależnie od tego, ile iteracji wymaga dostrojenie
epsilon w teście fallbacku).

---

## 10. Krok 7 (bonus, opcjonalny) — bot telegramowy

Wg §2 Decyzja 7. **Nie wchodzi do kryterium ukończenia fazy 4** — osobna decyzja
Oskara po zobaczeniu działającego kb-query, czy warto. Zaimplementować dopiero po
§9 (wymaga stabilnego, przetestowanego `/search`).

**Szacunek: 0.5 sesji**, warunkowo.

---

## 11. Poza zakresem fazy 4

| Temat | Gdzie zakotwiczone | Kiedy |
|---|---|---|
| Synteza odpowiedzi (LLM nad wynikami retrievalu) + polityka eskalacji | kb-00, `05-faza3-plan.md` §8.1 inwariant 2 | faza 5 |
| Wiki (`kb-wiki`) jako trzeci poziom kaskady w wynikach wyszukiwania | `05-faza3-plan.md` §8.1 inwariant 5 | po automatyzacji kompilacji (faza 5+) |
| Batching wywołań Ollamy (embed) | `05-faza3-plan.md` §9 backlog | faza mailowa |
| Mail-UI (klikalny link dla trafień gmail, `mail_ui_url`) | kb-00 etap 6 (mail-indexer) | osobny moduł, poza kb-query |
| Multi-model retrieval UI (przełącznik modelu streszczeń, gdyby powstał drugi tor kompilacji) | `05-faza3-plan.md` §2 Decyzja 3 | zależne od decyzji o modelu fazy mailowej |
| Writeback / edycja danych z poziomu UI kb-query (to wyszukiwarka read-only) | — | nie planowane |
| Aktualizacja `hosts/solaria/capabilities.yaml` (model GPU) | finding recon fazy 3 §1.3 | drobny osobny task, nietknięty |

---

## 12. Plan implementacji (kolejność = zależności)

| # | Krok | Zależy od | Szacunek |
|---|---|---|---|
| 0 | `packages/kb-retrieval` — wydzielenie z `documents-ingest`, migracja importów, testy | — | 0.5 sesji |
| 1 | Szkielet `services/kb-query` (Dockerfile, `/healthz`, `/search` bez fallbacku, join do `envelope`) | 0 | 1 sesja |
| 2 | Linki do źródeł (paperless URL zweryfikowany, gmail metadata) | 1 | 0.5 sesji |
| 3 | Frontend minimalny (jedna strona, kolorowanie progów, toggle cascade/flat) | 1 | 1 sesja |
| 4 | Aktywny fallback embed: health-check + circuit breaker + inwariant modelu | 1 | 0.5 sesji (mechanizm) |
| 5 | Kalibracja lokalnego embedu na żywym PIHA → werdykt (włącz/flaga/degradacja) | 4 | 0.5–1 sesja + pomiar na żywo |
| 6 | Ingress: OIDC (`authlib`), npm@PIHA vhost, DNS (Cloudflare confirm + Pi-hole) | 1, 3 | 1 sesja |
| 7 | Bramka jakości: HTTP-equivalence test + fallback test (epsilon) | 2, 4, 5 | 0.5–1 sesja |
| 8 | Bonus: `/kb` w telegram-bot | 7 (opcjonalny, po ukończeniu fazy) | 0.5 sesji |

Kroki 2 i 3 mogą iść równolegle po kroku 1. Krok 6 (ingress) niezależny od kroków
4–5 (fallback) — może iść równolegle, o ile krok 3 (frontend) jest gotowy do
podpięcia za auth.

**Kryterium ukończenia fazy 4**: (a) `kb-query` odpowiada na `/search` przez
`kb.kapala.org` z działającym OIDC, (b) bramka §9 PASS (HTTP-equivalence +
fallback), (c) UI ręcznie zweryfikowany w przeglądarce (golden path + brak
wyniku + toggle cascade/flat), (d) werdykt kalibracji fallbacku udokumentowany
(nawet jeśli werdykt to "degradacja 503, nie lokalny embed" — to jest ważny,
udokumentowany wynik, nie porażka planu).

---

## 13. Szacunki zbiorcze

- **Czas sesyjny**: ~6–7.5 sesji roboczych (tabela §12, bez bonusu telegram) +
  0.5 sesji jeśli bonus wchodzi.
- **Nowa infrastruktura**: 1 nowy serwis (`kb-query`, PIHA), opcjonalnie 1 nowy
  kontener (`ollama-piha`, warunkowo po kalibracji), 1 nowy vhost npm (ręczny), 1
  nowa OAuth2 app w Forgejo, 0 nowych certów TLS, 0 nowych domen do rejestracji
  (wildcard już pokrywa).
- **Koszty zewnętrzne**: brak nowych kosztów API (kb-query nie woła Anthropic —
  to jest granica z fazą 5, synteza nie retrieval).
- **Ryzyko RAM na PIHA**: jedyny istotny nieznany parametr tego planu (§1.3) —
  wymaga pomiaru na żywym hoście przed krokiem 5, nie da się rozstrzygnąć z repo.

---

## 14. Podsumowanie dla Oskara

Silnik retrievalu (kaskada, bramka, progi) jest gotowy od fazy 3 — faza 4 to
**warstwa dostępu**: HTTP + UI + auth + fallback, żadnej nowej logiki wyszukiwania.
Największa realna niewiadoma to nie retrieval, tylko **RAM na PIHA** dla lokalnego
fallbacku embedu — plan świadomie nie przesądza "włączyć czy nie", tylko buduje
mechanizm i każe go zmierzyć na żywo przed zaufaniem mu jako domyślnej ścieżce
(ten sam odruch co kalibracja filtra śmieci w fazie 3). Druga realna nowość to
OIDC we własnym kodzie FastAPI — wzorzec (apka robi login sama, nie proxy) jest w
repo trzykrotnie potwierdzony, ale kb-query jest pierwszym Pythonowym/FastAPI
przypadkiem, więc to pierwsza taka integracja, nie kopiuj-wklej.

**7 decyzji w §2, wszystkie z rekomendacjami**; jedyna naprawdę otwarta (zmienia
przebieg planu w zależności od wyniku) to D2 — werdykt kalibracji lokalnego
fallbacku (krok 5) decyduje, czy faza kończy się z pełnym aktywnym fallbackiem czy
z udokumentowaną jawną degradacją. D1, D3–D7 to rekomendacje techniczne do
przyklepnięcia.

Nic nie zostało zaimplementowane, zdeployowane ani pobrane w ramach tego recon —
wynik to wyłącznie ten dokument.
