homelab-codex-ws/kb/phases/kb-m5-faza4.md
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

613 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 `kb/audits/piha-slim-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` — `kb/decisions/kb-dokumenty-otwarte.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.52 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, `kb/phases/kb-m2-paperless.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: 11.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, `kb/phases/kb-m5-faza3.md` §1.2,
zweryfikowane bramką): `dist < 0.45` zielony, `0.450.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-31e-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.51 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.51 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.51 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
45 (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**: ~67.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, D3D7 to rekomendacje techniczne do
przyklepnięcia.
Nic nie zostało zaimplementowane, zdeployowane ani pobrane w ramach tego recon —
wynik to wyłącznie ten dokument.