# 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:` = `f"paperless:{doc['id']}"` (`paperless_adapter.py:126`); dla gmaila = surowy nagłówek `Message-ID` (bez `<>`) lub `sha256-` 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//` = reużywalne biblioteki bez `docker-compose.yml`/`service.yaml`, instalowane w serwisach przez `COPY packages// ...; 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//details` (Angular routing) — `envelope_id` `paperless:` już niesie surowy `` 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 ` 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=&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": "", "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:`, 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:` → 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.