RECON only, zero kodu/deployu. Silnik (cascade_query/flat_query) gotowy z fazy 3; plan pokrywa warstwę dostępu: wydzielenie packages/kb-retrieval, serwis kb-query (FastAPI@PIHA), aktywny fallback embed (health-check + circuit breaker + lokalny bge-m3 na PIHA, gate'owany kalibracją RAM/latencji na żywym hoście), linki źródeł, minimalny frontend, ingress kb.kapala.org (npm@PIHA + OIDC Forgejo + Pi-hole split-horizon), bramka HTTP-equivalence + fallback, bonus telegram opcjonalny. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
36 KiB
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 wkb-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: serwiskb-query(FastAPI, PIHA) opakowującycascade_queryw HTTP + minimalny UI wyszukiwania podkb.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_reasonIS NULL vs'ocr_junk'/'duplicate'),document_summary: 162 dlamodel='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, obiequery_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 odembed_chunki_vector_literalzdocuments_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 zaiohttp.ClientSession(timeout=...)wołającego),raise_for_status()propagujeaiohttp.ClientErrorgdy 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ącyflat_query/cascade_querybezpośrednio przez DB+Ollama — dziś nie ma trybu HTTP (§9 poniżej). envelope.iddlapaperless:<id>=f"paperless:{doc['id']}"(paperless_adapter.py:126); dla gmaila = surowy nagłówekMessage-ID(bez<>) lubsha256-<hash>gdy brak nagłówka (gmail_bulk_import/importer.py:63-70) — Message-ID jest samymenvelope.id, nie osobną encją.entitiesgmaila: jeden obiekt{"type":"headers","from":{...},"to":[...],"subject":...,"date_raw":...}(gmail_header_backfill/backfill.py:120-128) +{"type":"attachment",...}per załącznik.entitiespaperless:content/correspondent/tag/filename/content_type, opcjonalniesource_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 (probeGET /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, zmem_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, celha-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 GitOpsmem_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 jestowner_node: vps, publiczny ingres dla outline/joplin/agents — "drugi npm",docs/sessions/2026-07-07-okit-wildcard.mdsekcja "NIE ruszone"). Istnieje osobna, dziś poza-GitOps instancjanpm@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 wnpm@PIHA, TLS z już istniejącego wildcard*.kapala.org(pokrywapaper./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(envPAPERLESS_SOCIALACCOUNT_PROVIDERS). Nextcloud: appkauser_oidc(occ). Vikunja: natywnyopenidprovider wconfig.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.pldla starszej Vikunji) w compose, żeby discovery OIDC szło po LAN zamiast przez potencjalnie kapryśny publiczny DNS. kb-query jest pierwszą usługą FastAPI w repo z własnym loginem — nie ma gotowego kodu do skopiowania, tylko wzorzec architektoniczny do powtórzenia w nowym frameworku (§8). - Kontrprzykład:
llm-gateway(jedyny inny FastAPI na PIHA) jest Tailscale-only, bez OIDC, bez npm — bo to API maszyna-maszyna, nie UI dla człowieka. kb-query jest bliżej paperless (człowiek w przeglądarce) niż llm-gateway (agent po API) — stąd pełny wzorzec npm+OIDC, nie skrót.
1.5 Telegram (dla bonusu §10)
Istnieje już bot: services/agent-system/telegram-bot/bot.py — python-telegram-bot,
polling (nie webhook), token w TELEGRAM_BOT_TOKEN, autoryzacja przez
TELEGRAM_ALLOWED_USER_IDS (self-registration przez /start, bez hardkodowanego
chat_id). Komendy dziś: /status /summary /nodes /services /unhealthy /incidents /actions /help + stub free-text fallback (ENABLE_LLM_FALLBACK, dziś no-op). Bot
mówi do control-plane HTTP API (CONTROL_PLANE_URL), nie bezpośrednio do żadnej bazy.
1.6 packages/ vs jobs/ — konwencja z CLAUDE.md
packages/<lib>/ = reużywalne biblioteki bez docker-compose.yml/service.yaml,
instalowane w serwisach przez COPY packages/<lib>/ ...; RUN pip install
(pierwszy przykład: packages/kb-mail/, już zależność documents-ingest). jobs/
= joby wsadowe instalowane na hoście (venv, nie Docker) — jobs/documents-ingest
ma w pyproject.toml zależność na anthropic (streszczenia) i skrypty CLI
(documents-ingest-*), z których kb-query nie potrzebuje ani jednego.
2. Otwarte decyzje dla Oskara (z rekomendacjami)
Decyzja 1 — Reużycie retrieval.py: import z documents-ingest vs wydzielenie do packages/
Rekomendacja: wydzielić do nowego packages/kb-retrieval/ (retrieval.py +
embed.py przeniesione z chunk_embed.py: embed_chunk, _vector_literal,
stałe DEFAULT_MODEL/DEFAULT_OLLAMA_URL), reużywane przez oba konsumentów:
jobs/documents-ingest (dodaje kb-retrieval do zależności, tak jak dziś kb-mail)
i services/kb-query (Dockerfile: COPY packages/kb-retrieval/ ...).
Uzasadnienie:
- kb-query to długożyjący serwis Docker,
documents-ingestto joby venv-owe na hoście (§1.6) — nie ma naturalnego sposobu, żeby Dockerfile serwisu "zaimportował" kod zjobs/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-mailrobi dokładnie to samo (reużywalna biblioteka bezservice.yaml, instalowanapip installdo obrazu) —kb-retrievalto drugi taki pakiet, nie nowy wzorzec. - Kod przenosi się 1:1, bez zmian logiki —
retrieval.pyjuż 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-ingesti importować przezsys.path/instalację-ew obrazie kb-query. Działa technicznie (eval-skrypt tak dziś robi,sys.path.insertwretrieval_eval.py:37), ale ciągnieanthropic+ 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 analogiczniedocument_summary); jeśli skonfigurowanyEMBED_MODEL(env, domyślniebge-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ś zmieniEMBED_MODELbez migracji danych) — pokryte przez check startowy. Jeśli Ollama kiedyś zacznie zwracać w odpowiedzi faktycznie użyty model (dziś/api/embeddingstego 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-m3w Ollama: model ~1.2 GB na dysku (nieznana dokładna wartość dla tego builda — zweryfikowaćollama show bge-m3na 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):
- Zbudować i zdeployować kontener
ollama-piha(nowy,hosts/piha/runtime/) + pullbge-m3+ zmierzyć realny czas embedu i szczyt RAM na żywym PIHA (docker statsw trakcie, kilka powtórzeń). - 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_limitna kontenerzeollama-pihajako twardy ceiling (np. 2.5g, do potwierdzenia pomiarem) — cgroup OOM restartujeollama-piha, nie zabiera RAM sąsiadom (ten sam mechanizm co reszta PIHA). - Jeśli nie mieści się bezpiecznie → jedyna sensowna alternatywa to jawna
degradacja: kb-query zwraca
503 {"error": "wyszukiwanie chwilowo niedostępne — SOLARIA offline, brak zapasowego embedu"}zamiast cicho ryzykować OOM na współdzielonym hoście z Home Assistant. Inny model lokalny (mniejszy, szybszy) nie jest opcją — złamałby twardy inwariant (inny model = wektory nieporównywalne z istniejącym indeksem). Kolejkowanie/retry też odpada — search jest synchroniczne, użytkownik czeka przy przeglądarce, nie ma gdzie odłożyć zapytania na "SOLARIA wstanie za parę godzin".
- Krok kalibracji wchodzi do planu implementacji jako osobna pozycja (§11, krok 5) — werdykt (włączyć domyślnie / zostawić za flagą / zrezygnować) jest wyjściem tego kroku, nie założeniem tego planu.
Decyzja 3 — Linki do źródeł: paperless vs gmail
Paperless: URL do dokumentu — do zweryfikowania na żywym Paperless przed
implementacją (repo nie ma zapisanego przykładu, docs/kb/modules/02-paperless-service.md
dokumentuje tylko subdomenę, nie ścieżkę). Kandydat wg konwencji paperless-ngx UI:
https://paper.kapala.org/documents/<id>/details (Angular routing) — envelope_id
paperless:<id> już niesie surowy <id> do wstawienia. Krok implementacji: jeden
ręczny curl -I/otwarcie w przeglądarce na żywym paperless przed zakodowaniem
formatu linku na stałe.
Gmail — rekomendacja: skopiowalny Message-ID + metadane koperty, zero linku
(bo nie ma dokąd linkować). envelope_id jest Message-ID (§1.1) — nic nie
trzeba dodatkowo wyciągać. Odpowiedź /search dla trafienia źródła gmail niesie:
envelope_id (message-id), subject/from/date z entities[type=headers]
(join do envelope.entities przy budowaniu odpowiedzi), oraz pole
mail_ui_url: null zarezerwowane celowo (nie usunięte, nie wypełnione) — gdy
powstanie przyszły mail-UI (kb-00 etap 6, poza zakresem tej fazy), wypełnienie tego
pola to zmiana w jednym miejscu, nie nowy kontrakt API. UI: przycisk "kopiuj
Message-ID" zamiast linku (§7).
Decyzja 4 — Frontend: osobny build czy serwowany z FastAPI
Rekomendacja: jeden serwis, zero osobnego node-builda. Serwer renderuje szkielet
strony (Jinja2, wbudowane w FastAPI/Starlette) + jeden statyczny plik JS (vanilla,
fetch() do /search) + jeden CSS. Żadna inna usługa webowa w tym repo nie ma
frontendowego build stepu (paperless/nextcloud/vikunja to gotowe obrazy z własnym
frontendem; llm-gateway to czyste API bez UI) — kb-query jest pierwszym własnym UI
w repo, więc "najprostsze co działa" bije "jak zrobiliby to profesjonalnie
zewnętrzni autorzy frameworków". Wyszukiwarka (pole + lista wyników), nie chat —
brak stanu rozmowy do zarządzania, więc vanilla JS bez frameworka jest wystarczające,
nie tymczasowe uproszczenie do wymiany później.
Odrzucona alternatywa: SPA (React/Vue) + osobny kontener/build. Dokłada node
toolchain, Dockerfile multi-stage, i najbliższy precedens w repo (agent-system webui,
services/agent-system/) już pokazuje że osobny frontend-kontener to więcej
ruchomych części niż potrzeba dla jednego pola wyszukiwania.
Decyzja 5 — Ingress: potwierdzenie wzorca z §1.4
Rekomendacja: dokładnie wzorzec paperless/nextcloud — exposure: private w
service.yaml (mimo domeny kb.kapala.org — to ta sama klasa co paper./cloud.,
patrz §1.4), LAN bind (LAN_BIND_IP, nigdy 0.0.0.0), vhost w npm@PIHA (ręczny
krok, poza GitOps, jak zawsze), TLS z istniejącego wildcard *.kapala.org (zero
nowych certów), OIDC wbudowane w apkę przez authlib (Starlette-natywna biblioteka
OIDC-client — pierwszy raz w repo dla FastAPI, ale ten sam wzorzec co
django-allauth/user_oidc/Vikunja-natywny: apka robi login sama, nie proxy),
extra_hosts: forgejo.kapala.org:192.168.31.5 (trik z paperless/nextcloud — nowsza
domena kapala.org, nie okit.pl jak stara Vikunja). Rejestracja OAuth2 app w
Forgejo Settings > Applications — krok wykonawczy przy deployu, jak w pozostałych
trzech serwisach.
DNS (§1.4, dwie warstwy — obie kroki implementacji, nie automat):
- Cloudflare: potwierdzić że
*.kapala.orgwildcard już pokrywakb.kapala.org(powinien — to ten sam mechanizm copaper./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. - 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 dlapaper./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 stoiagent-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 dollm-gatewayosiągalnego jakohttp://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_URLprzeniesione zchunk_embed.pybez 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_queryprzeniesione 1:1.jobs/documents-ingest:pyproject.tomldostaje zależnośćkb-retrieval(wzoremkb-mail);chunk_embed.py,summarize.py,documents_ingest/retrieval.py(re-export albo usunięcie — decyzja techniczna przy implementacji) przechodzą na import zkb_retrieval.jobs/documents-ingest/eval/retrieval_eval.py: import zkb_retrievalzamiastdocuments_ingest.retrieval.- Testy:
packages/kb-retrieval/tests/przejmuje istniejące testy zjobs/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 przebiegretrieval_eval.pyna 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_statusz cache (§2 Decyzja 2) dla widoczności operacyjnej, wzoremllm-gateway/.GET /search?q=<text>&mode=cascade|flat(domyślniecascade) —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 } ] }sourcewyprowadzone zenvelope_id/envelope.source(join doenvelopepochunks[].envelope_id— potrzebny w kb-query,retrieval.pydziś zwraca tylkodocument_chunk, nie robi joina doenvelope; to jest nowy kod w warstwie HTTP-handlera kb-query, nie zmiana wkb_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:
- Health-check + cache + circuit breaker w kb-query (używa
check_ollama_healthzkb_retrieval.embed, §3). - Walidacja startowa modelu (
SELECT DISTINCT model FROM document_chunk/document_summary). - Nowy kontener
ollama-piha(hosts/piha/runtime/ollama-piha/lub nowy wpis wservices/jeśli traktowany jako pełny serwis GitOps — do rozstrzygnięcia: to jest infrastrukturalny dodatek specyficzny dla PIHA, prawdopodobnie bliżejservices/ollama/sklonowanego zowner_node: piha+OLLAMA_KEEP_ALIVE=0niż czegoś nowego).ollama pull bge-m3na PIHA. - Kalibracja na żywym PIHA (gate, nie formalność — analogicznie do sweep N z
fazy 3 §6.3): kilka pomiarów czasu embedu +
docker statsw trakcie, przy normalnym obciążeniu PIHA (nie w oknie ciszy nocnej) — realistyczny worst case. - 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 podist. - Kolorowanie progów (progi z fazy 3,
docs/kb/modules/05-faza3-plan.md§1.2, zweryfikowane bramką):dist < 0.45zielony,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
modedo/search, checkbox/select w UI, domyślniecascade.
Szacunek: 1 sesja.
8. Krok 5 — ingress (npm@PIHA, OIDC, DNS)
Wg §2 Decyzja 5:
authlibw zależnościach kb-query; endpointy/login,/auth/callback, sesja przez cookie (podpisany,SessionMiddlewareStarlette) — chroni/searchi UI,/healthzzostaje bez auth (musi odpowiadać monitoringowi bez loginu).- Rejestracja OAuth2 app w Forgejo (Settings > Applications) — client_id/secret do
.envkb-query (gitignored, jak wszędzie). extra_hosts: forgejo.kapala.org:192.168.31.5w compose.- Deploy kontenera, LAN bind (
LAN_BIND_IP). - Nowy vhost w
npm@PIHA(ręcznie, UI) →kb.kapala.org→192.168.31.5:<port>, certyfikat = istniejący wildcard*.kapala.org. - DNS: potwierdzić pokrycie wildcardem Cloudflare (prawdopodobnie zero akcji) +
Pi-hole Local DNS override
kb.kapala.org→192.168.31.5(ręcznie, UI). - 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:
retrieval_eval.py --transport http --base-url http://piha:<port>→ identycznedistvs--transport directna tych samych 7+2 zapytaniach zqueries.yaml.- Nowy test fallbacku (symulacja sol-down) → wyniki z PIHA-embedu,
distw granicach epsilon (do ustalenia empirycznie z kalibracji §5, prawdopodobnie rzędu 1e-3–1e-2) względem baseline z SOLARII, kolejność top-k identyczna. - 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.