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

36 KiB
Raw Permalink Blame History

okf type visibility status updated links
0.1 phase private active 2026-07-22

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.pyflat_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.orgkb/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.pypython-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 logikiretrieval.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/nextcloudexposure: 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.org192.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.pyembed_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.pyflat_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.55nie 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.org192.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.org192.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.