diff --git a/docs/kb/modules/05-faza4-plan.md b/docs/kb/modules/05-faza4-plan.md new file mode 100644 index 0000000..79dd35d --- /dev/null +++ b/docs/kb/modules/05-faza4-plan.md @@ -0,0 +1,603 @@ +# 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.