diff --git a/docs/kb/modules/05-faza3-plan.md b/docs/kb/modules/05-faza3-plan.md new file mode 100644 index 0000000..dfcc00a --- /dev/null +++ b/docs/kb/modules/05-faza3-plan.md @@ -0,0 +1,705 @@ +# Moduł 5, faza 3 — warstwa kompilacji (RECON + PLAN) + +> Status: RECON ZAKOŃCZONY (2026-07-16), plan DO ZATWIERDZENIA. Zero kodu, zero migracji, +> zero pobranych modeli w ramach tego zadania — wyłącznie ten dokument. +> +> Kontynuacja `05-faza2-plan.md` (faza 2: koperta dokumentów + embeddingi + cross-source — +> DOMKNIĘTA 2026-07-16, kroki 1–8 komplet). Faza 3 = pierwsza warstwa **kompilacji**: +> porządki po pilocie retrieval, streszczenia+tagi jako półprodukt kompilacji, kaskada +> retrieval, cykliczny ingest, przygotowanie wiki-kompilatu (wzorzec Karpathy llm-wiki). + +--- + +## 1. Stan faktyczny (po fazie 2, zweryfikowany w repo i sesjach) + +### 1.1 Baza danych (kb-postgres@PIHA) + +- `envelope`: **225 216 kopert** — `gmail` 225 030 (wszystkie z headers entities po + backfillu), `paperless` 186. Cross-source join `source_mail`: **180/186** (6 dokumentów + bez linku — spoza pipeline'u faktury-1, stan poprawny). +- `document_chunk`: **2683 chunki** ze 160 dokumentów (26 pustych contentów pominiętych, + 1 patologiczny dot-leader chunk odrzucony przez context window Ollamy). Model `bge-m3`, + `VECTOR(1024)`, indeks HNSW cosine. Chunking 600/150 tok (≈2400/600 znaków) + **zatwierdzony bez zmian** w kroku 7. +- Constraint: `UNIQUE (envelope_id, chunk_index)` — **bez `model`**. Znany dług z review + kroku 6 (docstring `chunk_embed.py` flaguje to wprost): drugi model embeddingów cicho + no-opuje się na `ON CONFLICT DO NOTHING`. Naprawiane w tej fazie (§3.3). + +### 1.2 Retrieval (pilot, krok 7 fazy 2) + +- Progi skalibrowane (cosine distance `<=>`): **<0.45 trafienie, 0.45–0.55 szara strefa, + >0.55 brak odpowiedzi**. Negatywna kontrola ("sernik") = 0.62 — czysta separacja. +- Cross-lingual działa: angielskie zapytanie o FLL scoring wyciąga scoresheet **mimo + mojibake** — wniosek dla filtra śmieci: mojibake ≠ automatycznie śmieć (§3.1). +- Findings nieblokujące fazy 2, ale **blokujące kompilację wiki**: chunki z binarnym + OCR-szumem (zmielone kody kreskowe `\x01...`, zmielone fonty w paperless:119), + duplikaty dokumentów (paperless:14 ≡ paperless:74 — identyczne chunki w wynikach). +- Zestaw zapytań pilotowych żyje dziś tylko w sesji/transkrypcie — **nie jest utrwalony + jako plik**. Bramka kaskady (§6) wymaga powtarzalnego zestawu; utrwalenie to pierwszy + krok tamtego etapu. + +### 1.3 Ollama@SOLARIA + +- Deklaratywnie na GPU (compose z sekcją `deploy`, przywrócone 2026-07-16). Benchmark: + **207 ms/embed GPU vs 790 ms CPU** (~3.8× sekwencyjnie); overhead HTTP dominuje przy + pojedynczych requestach — batching pozostaje w backlogu (przed fazą mailową, poza + zakresem tej fazy). +- Dostępne modele: `qwen2.5-coder:14b`, `qwen3-coder:30b`, `deepseek-coder:latest`, + `deepcoder:14b`, `bge-m3` — **same modele coder + embedding, zero ogólnego instructa**. + Pilot streszczeń wymaga pobrania modelu instruct (§5). +- Uwaga recon: `hosts/solaria/capabilities.yaml` mówi „NVIDIA RTX 4070", sesje 07-15/16 + mówią RTX 4070 Ti SUPER (16 GB) — manifest jest nieaktualny. Follow-up poza tym taskiem + (raportowane, nie ruszane — dyscyplina worktree). +- SOLARIA ma `availability_target: medium` (planowe wyłączenia) — cykliczny embed musi + tolerować Ollamę offline (§7). + +### 1.4 Wzorce jobów (do reużycia 1:1) + +`jobs/documents-ingest/` (`paperless_adapter.py`, `chunk_embed.py`) i +`jobs/gmail-header-backfill/` ustaliły rodzinę wzorców, którą każdy nowy job tej fazy +**musi** powielić: + +- dry-run domyślny, `--apply` jawnie; `--limit`/`--offset` po stabilnym `ORDER BY id`; +- idempotencja: pre-fetch zbioru istniejących kluczy + `ON CONFLICT DO NOTHING` jako + druga linia obrony, z parsowaniem command tagu (cichy no-op ≠ insert); +- **bilans statystyk jako inwariant** (`fetched = suma wyników`), `stats_mismatch` → + niezerowy exit; izolacja błędów per wiersz (nigdy nie ubijać całego slice'a); +- wykonanie na PIHA wg utrwalonego wzorca: rsync `src/` do `/tmp/`, `~/kb/venv` + (Python 3.11), DSN budowany na hoście z `docker inspect kb-postgres`, **zawsze log do + pliku** (`> run.log 2>&1`) — lekcja z utraty 4999 wierszy w tmuxie. + +### 1.5 Infrastruktura pod cykliczny ingest i alertowanie (§7) + +- `node_exporter` na węzłach: mount `/:/host:ro,rslave` już jest, ale **bez flagi + `--collector.textfile.directory`** — włączenie textfile collectora to jedna linijka + w compose, zero nowych mountów (katalog czytany przez istniejący `/host`). +- `fleet-prometheus` (VPS): reguły w `services/fleet-prometheus/rules/`, **celowo bez + Alertmanagera** — alerty FIRING zbiera `brain-watchdog`@PIHA i forwarduje na Telegram. + Nowy alert = nowy plik/wpis w `rules/`, dostawa za darmo istniejącym torem. +- W repo nie ma dziś żadnego wzorca systemd-timer/cron dla jobów — §7 go ustanawia. + +--- + +## 2. Otwarte decyzje dla Oskara (z rekomendacjami) + +### Decyzja 1 — Filtr śmieci: nie tworzyć chunków vs tworzyć i flagować? + +**Rekomendacja: tworzyć i flagować** — kolumna `excluded_reason TEXT NULL` w +`document_chunk` (NULL = aktywny; `'ocr_junk'`, `'duplicate'`), nowe śmieciowe chunki +INSERT-owane z flagą i **bez embeddingu** (`embedding = NULL` — schemat to już dopuszcza). + +Uzasadnienie: +- Heurystyka będzie się mylić. Flaga jest **odwracalna** (UPDATE + doembedowanie po + korekcie progu); nieutworzony chunk jest niewidoczny — nie da się audytować, co filtr + odrzucił, bez ponownego chunkowania całego korpusu. +- `embedding = NULL` wyrzuca śmieć z indeksu HNSW automatycznie (pgvector pomija NULL-e), + więc retrieval nie potrzebuje nawet filtra w zapytaniu dla *nowych* śmieci; dla już + zembedowanych 2683 chunków wystarczy `WHERE excluded_reason IS NULL` (§3.1 — embeddingi + istniejących śmieci zostawiamy, tylko flagujemy; zerowanie to opcjonalny porządek). +- Jedna kolumna obsługuje oba findingi pilota (szum OCR **i** duplikaty) — §3.1 i §3.2. + +Odrzucona alternatywa: filtr wyłącznie przy chunkingu (nie tworzyć). Mniej wierszy, ale +niewidoczne odrzuty + retrofit przy każdej zmianie progu wymaga pełnego re-chunkingu. + +### Decyzja 2 — Polityka dedup dokumentów + +**Rekomendacja: duplikat zostaje w bazie, jego chunki dostają `excluded_reason='duplicate'`, +koperta dostaje addytywny wpis `entities[type=duplicate_of]`. Nic nie kasujemy.** + +- Kanoniczny egzemplarz: ten z linkiem `source_mail` (bogatszy provenance); przy remisie + niższy `paperless:` (starszy). +- `envelope` jest append-only i referencyjna (źródłem prawdy jest Paperless) — DELETE + łamałby zasady kb-00. Wpis `duplicate_of` w entities = audytowalna, odwracalna decyzja + w tej samej konwencji co `source_mail`. +- Usunięcie duplikatu **w Paperlessie** (żeby nie wracał przy cyklicznym ingest) to + osobna, ręczna decyzja operatora per przypadek — poza automatem; automat musi jedynie + być odporny (re-ingest duplikatu z Paperlessa → ponowne oflagowanie, nie błąd). +- Streszczenia (§5) i wiki (§8) generowane **tylko dla kanonicznych**. + +### Decyzja 3 — Model do pilota streszczeń: lokalny GPU vs CC vs API + +**Rekomendacja: pilot dwutorowy na pełnych 186 dokumentach — (A) zewnętrzne API +(Claude Haiku 4.5 lub Sonnet) jako przebieg referencyjny ORAZ (B) lokalny model instruct +na GPU na tej samej populacji. Oba komplety współistnieją w `document_summary` dzięki +`UNIQUE (envelope_id, model)` — schemat natywnie wspiera A/B.** + +Uzasadnienie i kryteria: +- **Koszt API pomijalny**: 186 dok × śr. ~22k znaków ≈ 1.1–1.3M tok wejścia + ~40k tok + wyjścia → Haiku 4.5 ≈ **~1.5 USD**, Sonnet ≈ ~4 USD. To nie jest oś decyzji przy 186 dok. +- **Prywatność**: dokumenty finansowo-tożsamościowe wychodzą na zewnątrz — ale to jest + **zgodne z już podjętą decyzją architektoniczną** (szkic wiki, inwariant 2: kompilację + robi CC/zewnętrzne API; polityka eskalacji fazy 5). Pilot 186 dok to dokładnie + „wyselekcjonowany podzbiór", nie surowy korpus. +- **Po co mimo to lokalny przebieg**: decyzja o **skali mailowej** (docelowo dziesiątki + tysięcy streszczeń) ma zupełnie inną ekonomię — tam lokalny model może być jedyną + rozsądną opcją. Pilot musi zmierzyć, ile jakości tracimy lokalnie, na pełnej populacji, + póki jest tania (2–3 h GPU). +- **CC jako trzeci tor**: nie jako batch (interaktywna sesja nie jest powtarzalnym jobem), + ale jako **oceniający** — porównanie próbki ~25 par streszczeń (API vs lokal) rubryką + z §5.4. +- Kandydat lokalny: **żaden z obecnych modeli na SOLARII nie nadaje się** (same codery; + `qwen3-coder:30b` to MoE coder, ~18–19 GB w q4 — częściowy offload na CPU przy + 12–16 GB VRAM, i nie po to trenowany). Do pobrania jeden z: **`gemma3:12b`** + (~8 GB q4, mocny multilingual/PL, kontekst 128k → większość dokumentów bez map-reduce — + **rekomendowany start**) lub `qwen3:14b` (~9 GB, mocny PL, kontekst 32k). Pilot + rozstrzyga empirycznie, nie przesądzamy w planie. + +### Decyzja 4 — Tagi: słownik kontrolowany vs free-form + +**Rekomendacja: hybryda — startowy słownik kontrolowany w repo + max 3 tagi free-form +per dokument, znormalizowane (lowercase, kebab-case, NFC).** + +- Słownik startowy (plik `jobs/documents-ingest/tags-vocab.yaml`, wersjonowany): + domeny życiowe widoczne już w pilocie — `ubezpieczenie`, `bank`, `kredyt`, `faktura`, + `umowa`, `urzad`, `auto`, `nieruchomosc`, `zdrowie`, `szkola`, `fll`, `praca`, + `subskrypcja`, `regulamin`. Prompt wymusza wybór z listy; free-form tylko jako + uzupełnienie. +- Po pilocie: przegląd free-form tagów (SQL `jsonb_array_elements` + count) → awans + częstych do słownika. Czysty słownik od początku = spójne fasety dla kaskady i wiki; + czysty free-form = eksplozja synonimów (`polisa`/`ubezpieczenie`/`insurance`), którą + potem trzeba sprzątać migracją danych. + +### Decyzja 5 — Writeback tagów do Paperless + +**Rekomendacja: NIE w fazie 3 — odłożyć do oceny po pilocie.** (Zadanie: „oceń, nie +przesądzaj" — ocena poniżej, decyzja na później.) + +- Za: tagi widoczne w UI Paperlessa (jedyne miejsce, gdzie Oskar przegląda dokumenty); + Paperless ma API (`POST /api/tags/`, `PATCH /api/documents//`). +- Przeciw: cały pipeline KB→Paperless jest dziś **ściśle read-only** (docstring adaptera + gwarantuje „GET only") — writeback łamie tę granicę i tworzy pętlę: cykliczny ingest + (§7) wczyta nasze własne tagi z powrotem do `entities[type=tag]`. Pętla jest niegroźna + (idempotentna), ale zaciera pochodzenie tagu (LLM vs człowiek) — wymagałaby konwencji + (np. prefiks `kb:` w nazwie tagu). +- Jeśli po pilocie tagi okażą się dobre: osobny mały job `--writeback` za jawną flagą, + z prefiksem `kb:`, nigdy w domyślnym przebiegu. + +### Decyzja 6 — Cykliczny ingest: gdzie timer i jak alertować + +**Rekomendacja: systemd-timer na PIHA (always-on, ma LAN do Paperlessa i lokalny DB) + +metryki do node_exporter textfile collector + reguła w fleet-prometheus (dostawa +istniejącym torem brain-watchdog→Telegram).** Szczegóły §7. + +Odrzucone alternatywy: (a) cron — brak `Persistent=true` (nadganianie po reboocie PIHA) +i gorsza obserwowalność; (b) emisja eventów przez `scripts/lib/events.sh` → supervisor — +tablica routingu supervisora jest dziś zamknięta na eventy stability-agent/ha-diag, +nowy typ eventu = zmiana w supervisorze; tor Prometheus jest tańszy i już zwalidowany +bojowo (Etap 0 cutoveru). + +### Decyzja 7 — Repozytorium wiki: osobne repo vs katalog w homelab-codex-ws + +**Rekomendacja: osobne repo `kb-wiki` (Forgejo, prywatne), klonowane na PIHA do +`/opt/homelab/data/kb-wiki/`.** (Szkic operatora zostawia to jawnie „do rozstrzygnięcia +w planie" — rozstrzygamy tutaj.) + +- Inna klasa wrażliwości: wiki to **destylat danych osobistych** (zdrowie, finanse, + umowy), a homelab-codex-ws to kod infry — rozdzielenie minimalizuje blast radius + każdego przyszłego udostępnienia/klonu repo infry. +- Inny cykl commitów: kompilator LLM będzie commitował często i maszynowo — w repo infry + zaśmiecałoby to historię i gryzło się z dyscypliną worktree (każda zmiana przez task + branch); wiki potrzebuje trybu „executor commituje do master za zgodą operatora". +- Git history = darmowy audit trail kompilacji (inwariant 6 szkicu) — czystszy, gdy + w historii są wyłącznie strony wiki. +- Koszt: jedno repo więcej w Forgejo + klon na PIHA. Akceptowalny. + +--- + +## 3. Krok 1 — porządki z pilota retrieval (OBOWIĄZKOWE przed kompilacją) + +Śmieć wkompilowany w stronę wiki propaguje się — filtr (3.1) i dedup (3.2) są twardym +warunkiem wstępnym dla §5 (streszczenia czytają chunki) i §8 (wiki czyta wyniki +retrievalu). Zmiana schematu (3.3) idzie w tej samej migracji. + +### 3.1 Filtr śmieciowych chunków (`excluded_reason='ocr_junk'`) + +**Co pilot ujawnił:** chunki będące binarnym szumem OCR — zmielone kody kreskowe +(sekwencje `\x01...`), zmielone glify ze złych fontów (paperless:119). Jednocześnie: +chunki z *częściowym* mojibake (`integraln¹ czêœæ`) **są użyteczne** — retrieval +wyciągnął scoresheet mimo mojibake. Filtr celuje w szum, nie w brzydki tekst. +Decyzja per **chunk**, nigdy per dokument (dokument z 1 stroną kodów kreskowych i 10 +stronami tekstu traci 1 chunk, nie 11). + +**Heurystyka (trzy sygnały, od najtańszego):** + +1. **Znaki kontrolne** — jakikolwiek znak z C0 poza `\t\n\r` (`\x00–\x08`, `\x0b–\x1f`) + → `ocr_junk` bezwarunkowo. Legalny tekst z OCR ich nie zawiera. +2. **Udział znaków słownych** — odsetek znaków spoza klasy + `[a-zA-Z0-9ąćęłńóśźż...interpunkcja...whitespace]` powyżej progu (start: >30%) → junk. + Łapie zmielone kody kreskowe i glifowy szum. +3. **Udział tokenów słowo-podobnych** — odsetek tokenów (split po whitespace) pasujących + do `^[[:alpha:]ąćęłńóśźż-]{2,30}$` poniżej progu (start: <25%) → junk. Łapie „tekst" + złożony z pseudolosowych zbitek liter, którego sygnał 2 nie widzi. + +Entropia Shannona świadomie pominięta — sygnały 2+3 pokrywają te same przypadki taniej +i są objaśnialne (łatwo powiedzieć *dlaczego* chunk wypadł). + +**Kalibracja przed backfillem (read-only):** policzyć sygnały 2 i 3 dla wszystkich 2683 +chunków (skrypt kalibracyjny, wynik do CSV), posortować, ręcznie obejrzeć okolice +kandydackich progów (spodziewana wyraźna bimodalność: normalny tekst ≪10% nie-słownych +znaków, szum ≫50%). Progi zapisane jako stałe w kodzie **z komentarzem skąd pochodzą**. +Oczekiwana skala: pojedyncze dziesiątki chunków (pilot widział je punktowo), nie setki — +jeśli kalibracja pokaże setki, zatrzymać się i obejrzeć próbkę zamiast ufać progowi. + +**Wpięcie (obie warstwy, zgodnie z decyzją 1):** + +- **Backfill istniejących 2683**: one-shot `UPDATE document_chunk SET excluded_reason='ocr_junk' + WHERE id = ANY(...)` na podstawie kalibracji. Embeddingi oflagowanych zostają (odwracalność); + ich wyzerowanie to opcjonalny porządek później. +- **Nowe chunki** (`chunk_embed.py`): ocena heurystyką przed embedem; junk → INSERT + z `excluded_reason='ocr_junk'`, `embedding=NULL`, **bez wywołania Ollamy** (oszczędza + GPU i nie zaśmieca HNSW). Nowe liczniki w bilansie: `chunks_junk_flagged` — bilans + chunków rozszerzony i nadal domknięty. +- **Retrieval i wszyscy konsumenci** (kaskada §6, streszczenia §5, wiki §8): zawsze + `WHERE excluded_reason IS NULL`. + +### 3.2 Dedup dokumentów (`excluded_reason='duplicate'` + `entities[duplicate_of]`) + +**Zbadanie skali (read-only, przed polityką):** + +```sql +-- Kandydaci: identyczna pełna treść chunków per koperta +WITH doc_hash AS ( + SELECT envelope_id, + md5(string_agg(text, E'\n' ORDER BY chunk_index)) AS content_hash, + count(*) AS n_chunks + FROM document_chunk + GROUP BY envelope_id +) +SELECT content_hash, count(*) AS copies, + array_agg(envelope_id ORDER BY envelope_id) AS envelopes +FROM doc_hash +GROUP BY content_hash +HAVING count(*) > 1; +``` + +Do skonfrontowania z Paperlessem: `documents_document.checksum` (MD5 oryginalnego pliku) +— identyczny checksum = duplikat binarny (jak 14≡74 najpewniej: ten sam PDF wszedł raz +przez consume faktury-1, raz inną drogą); różny checksum przy identycznych chunkach = +duplikat treściowy (np. re-OCR tego samego skanu). Paperless ma wbudowaną detekcję +duplikatów **przy consume** — nie działa wstecz i nie widzi par binarnie różnych. + +**Polityka:** wg decyzji 2 — kanoniczny zostaje aktywny, chunki duplikatu dostają +`excluded_reason='duplicate'`, koperta duplikatu dostaje addytywnie: + +```json +{"type": "duplicate_of", "envelope_id": "paperless:14", "detected": "2026-07-XX", + "method": "chunk_content_hash"} +``` + +One-shot skrypt (wzorce §1.4: dry-run, bilans, log), skala dziś to najpewniej 1–3 pary — +ale skrypt zostaje, bo cykliczny ingest (§7) może wprowadzać nowe duplikaty. + +### 3.3 Migracja 003 — `UNIQUE(envelope_id, chunk_index, model)` + `excluded_reason` + +Obie zmiany `document_chunk` w jednej addytywnej migracji: + +```sql +-- services/kb-postgres/init/003_chunk_model_key.sql +-- (a) UNIQUE rozszerzony o model — bez tego drugi model embeddingów cicho się no-opuje +-- na ON CONFLICT DO NOTHING (finding z review kroku 6 fazy 2). +-- (b) excluded_reason — flaga wykluczenia chunka z retrievalu i kompilacji +-- (NULL = aktywny; 'ocr_junk' | 'duplicate'). + +ALTER TABLE document_chunk + DROP CONSTRAINT IF EXISTS document_chunk_envelope_id_chunk_index_key; +ALTER TABLE document_chunk + ADD CONSTRAINT document_chunk_envelope_chunk_model_key + UNIQUE (envelope_id, chunk_index, model); + +ALTER TABLE document_chunk + ADD COLUMN IF NOT EXISTS excluded_reason TEXT; -- NULL = aktywny +``` + +Uwagi wykonawcze: + +- Pliki w `init/` odpalają się tylko przy świeżej inicjalizacji wolumenu + (docker-entrypoint-initdb.d); na żywej bazie migrację stosuje się ręcznie przez `psql` + — ten sam tryb co przy 002. Sekwencja plików pozostaje spójna dla świeżego odtworzenia + (001 → 002 → 003 → 004). +- Nazwa starego constraintu to autogenerowana + `document_chunk_envelope_id_chunk_index_key` — zweryfikować na żywej bazie + (`\d document_chunk`) przed puszczeniem, `IF EXISTS` chroni świeżą bazę. +- **Zmiany w `chunk_embed.py` w tym samym kroku**: `ON CONFLICT (envelope_id, + chunk_index, model)` w `_INSERT_SQL`, aktualizacja docstringów (usunąć zastrzeżenie + o no-opie — przestaje być prawdziwe), heurystyka junk z 3.1, licznik + `chunks_junk_flagged` w bilansie, testy (junk-detekcja: kody kreskowe, mojibake + częściowy NIE-junk, tekst czysty; konflikt z innym modelem wstawia się zamiast no-opa). + +--- + +## 4. Krok 2 — migracja 004: `document_summary` + +Wzorzec 002_chunks, z lekcją z 3.3 wbudowaną od razu (`model` w kluczu unikalności): + +```sql +-- services/kb-postgres/init/004_summaries.sql +-- Streszczenie + tagi per dokument — półprodukt kompilacji (faza 3 modułu 5). +-- 1:N do envelope po model: ta sama koperta może mieć streszczenia z wielu modeli +-- (pilot A/B: API vs lokalny GPU); retrieval wybiera model konfiguracją. + +CREATE TABLE IF NOT EXISTS document_summary ( + id BIGSERIAL PRIMARY KEY, + envelope_id TEXT NOT NULL REFERENCES envelope(id) ON DELETE CASCADE, + summary TEXT NOT NULL, + tags JSONB NOT NULL DEFAULT '[]', + model TEXT NOT NULL, -- model, który NAPISAŁ streszczenie + embedding VECTOR(1024), -- NULL dopóki nie zembedowane + embedding_model TEXT, -- model embeddingu (bge-m3); NULL z embedding + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (envelope_id, model) +); + +CREATE INDEX IF NOT EXISTS document_summary_envelope_idx + ON document_summary (envelope_id); +CREATE INDEX IF NOT EXISTS document_summary_embedding_hnsw_idx + ON document_summary USING hnsw (embedding vector_cosine_ops); +``` + +Uwagi projektowe: + +- **Dwa pola modelu są konieczne**, bo w tej tabeli działają dwa różne modele: instruct + pisze `summary`, bge-m3 robi `embedding`. `model` (streszczający) jest częścią klucza — + to on definiuje tożsamość treści; `embedding_model` to metadana re-indeksu, dokładnie + jak `model` w `document_chunk`. +- `tags` jako JSONB-tablica stringów znormalizowanych (decyzja 4). Zapytania po tagach: + `WHERE tags ? 'ubezpieczenie'` — wystarczające bez dodatkowego indeksu przy tej skali; + GIN na `tags` dopiero gdyby fasety weszły do ścieżki zapytań na skali mailowej. +- Brak `UNIQUE` po samym `envelope_id` jest **celowy** (A/B pilota). Konsument (kaskada + §6) zawsze filtruje po jednym, skonfigurowanym `model`. + +--- + +## 5. Krok 3 — pilot streszczeń + tagów (186 dokumentów) + +### 5.1 Job: `documents-ingest-summarize` (w `jobs/documents-ingest/`) + +Nowy moduł `summarize.py` obok `chunk_embed.py`, ta sama rodzina wzorców (§1.4): + +- **Wejście**: koperty `source='paperless'` bez wpisu `duplicate_of`; treść dokumentu = + konkatenacja `document_chunk.text` `WHERE excluded_reason IS NULL ORDER BY chunk_index` + (nie surowy `entities[content]`) — filtr śmieci i dedup propagują się za darmo, a + lekka redundancja z overlapu chunków streszczeniu nie szkodzi. +- **Wyjście LLM**: wymuszony JSON `{"summary": "<3–8 zdań PL>", "tags": ["...", ...]}`; + walidacja: parsowalny JSON, tagi znormalizowane i zweryfikowane vs słownik (spoza + słownika max 3, reszta ucinana z logiem `tag_truncated`). Jedna próba ponowienia przy + niepoprawnym JSON, potem `llm_errors` i dalej. +- **Backend przez flagę `--backend ollama|anthropic`**: jeden job, dwa przebiegi pilota + (decyzja 3). Ollama: `POST /api/chat`, `format: json`, `OLLAMA_URL` jak w chunk_embed. + Anthropic: klucz z env (`ANTHROPIC_API_KEY`), nigdy w repo/logu; model z `--model`. +- **Długie dokumenty**: dokumenty > limitu kontekstu backendu (przy gemma3 128k realnie + tylko outlier 360k znaków) → map-reduce: streszczenia częściowe po ~20 chunków → + synteza z częściowych; licznik `documents_mapreduce`. +- **Idempotencja**: pre-fetch istniejących `(envelope_id, model)`; re-run pomija gotowe. +- **Bilans**: `documents_fetched = duplicates_skipped + no_active_chunks + already_summarized + + summarized + llm_errors`; niezerowy exit przy rozjeździe lub `llm_errors > 0`. +- **Embedding streszczeń**: osobny, drugi przebieg (`--embed-summaries`, backend bge-m3, + wzorzec chunk_embed 1:1) — rozdzielenie pisania od embedowania pozwala re-embedować + bez ponownych kosztów LLM i działa, gdy SOLARIA śpi a API nie (i odwrotnie). + +### 5.2 Prompt (szkielet do iteracji w pilocie) + +System: „Jesteś archiwistą domowej bazy wiedzy. Streszczasz dokumenty po polsku, zwięźle +i faktograficznie: kto, co, kiedy, kwoty, numery umów/polis, terminy. Nie zgadujesz — +czego nie ma w tekście, tego nie piszesz. Zwracasz wyłącznie JSON." +User: słownik tagów + treść dokumentu. Kwoty/daty/numery **muszą** trafić do streszczenia +— to one niosą sygnał dla kaskady retrieval (zapytania pilotowe były punktowe). + +### 5.3 Wykonanie pilota + +1. `ollama pull gemma3:12b` na SOLARII (albo `qwen3:14b` — decyzja 3); smoke na 2–3 + dokumentach, inspekcja ręczna JSON-ów. +2. Przebieg lokalny: całe 186 (`--backend ollama --model gemma3:12b --apply`), na PIHA + lub SOLARII, log do pliku. Szacunek: ~30–60 s/dok na GPU → **2–3 h**. +3. Przebieg API: całe 186 (`--backend anthropic --model claude-haiku-4-5 --apply`). + Szacunek kosztu: **~1.5 USD** (1.2M tok in / 40k tok out). Czas: minuty. +4. Embedding obu kompletów streszczeń (bge-m3, 372 embeddingi ≈ 2 min na GPU). + +### 5.4 Ocena jakości (wejście do decyzji o skali mailowej) + +Próbka 25 dokumentów (stratyfikowana: polisy, faktury, umowy, urzędowe, FLL/szkolne, +≥3 z mojibake). Sesja CC porównuje pary streszczeń (lokal vs API) przeciw chunkom +źródłowym, rubryka 0–2 na wymiar: + +- **wierność** (0 = halucynacja/przekręcona kwota; 2 = wszystko potwierdzone w źródle), +- **kompletność faktów kluczowych** (kwoty, daty, strony, numery), +- **jakość tagów** (trafność + zgodność ze słownikiem). + +Wynik do `docs/kb/modules/05-faza3-pilot-streszczen.md`: tabela per dokument + wnioski. +**Kryterium „lokalny wystarcza na skalę mailową"**: mediana wierności = 2 (zero tolerancji +dla przekręconych kwot — to trafia do wiki) i kompletność ≥ 80% punktów API. Jeśli lokalny +nie daje rady → decyzja o skali mailowej rozważa API z polityką eskalacji fazy 5 (koszt +na 225k kopert: rząd setek USD — wtedy selektywność, nie wszystko). + +--- + +## 6. Krok 4 — kaskada retrieval: summary → chunk + +### 6.1 Mechanizm + +``` +zapytanie → embed (bge-m3) +→ top-N document_summary (model = , embedding <=> q) -- pre-filtr +→ top-k document_chunk WHERE envelope_id IN (top-N dokumentów) + AND excluded_reason IS NULL + AND embedding IS NOT NULL +``` + +Start: N=10, k=5 (sweep N ∈ {5, 10, 20} w ewaluacji). Przy 186 dokumentach pre-filtr nic +nie przyspiesza — **to test architektury pod skalę mailową** (225k kopert, gdzie płaski +skan chunków przestanie być tani), nie optymalizacja pilota. Bramka mierzy wyłącznie +jakość. + +### 6.2 Bramka jakościowa (wzorzec kroku 7 fazy 2) + +- **Utrwalić zestaw ewaluacyjny**: `jobs/documents-ingest/eval/queries.yaml` — zapytania + pilotowe z kroku 7 (odtworzone z sesji 2026-07-16) + oczekiwany dokument per zapytanie + + negatywne kontrole („sernik"). Od teraz każda zmiana retrievalu przechodzi przez ten + plik — koniec z zapytaniami żyjącymi w transkryptach. +- **Skrypt ewaluacyjny** (read-only, `eval/retrieval_eval.py`): każde zapytanie puszcza + torem płaskim i kaskadowym, raportuje hit@3 (oczekiwany dokument w top-3 po dystansie), + najlepszy dystans, wynik negatywnych kontroli. +- **Kryterium przejścia (kaskada MUSI być nie gorsza):** + 1. każde zapytanie trafione płasko (dystans < 0.45) jest trafione kaskadą — żaden hit + nie degraduje do szarej strefy ani braku; + 2. hit@3 kaskady ≥ hit@3 płaskiego na całym zestawie; + 3. negatywne kontrole pozostają > 0.55 w obu torach. +- **Gdy kaskada wypada gorzej**: diagnoza w kolejności — (a) N za małe (dokument wypada + w pre-filtrze → sweep N), (b) streszczenie nie niesie faktu, o który pyta zapytanie + (→ iteracja promptu §5.2 — to bramka na jakość streszczeń, nie tylko retrievalu), + (c) dopiero potem wniosek o architekturze. Kaskada nieprzechodząca bramki **nie + zastępuje** płaskiego retrievalu — płaski zostaje domyślny do skutku. + +--- + +## 7. Krok 5 — cykliczny ingest (adapter + embed jako timer) + +Adapter i embed są już idempotentne — nowość to wyłącznie orkiestracja i alarm o failu. + +### 7.1 Wykonanie (PIHA, systemd-timer — decyzja 6) + +- **Instalacja stała** (koniec z rsync do `/tmp` dla tego przypadku — to był wzorzec dla + one-shotów): dedykowany venv `/opt/homelab/kb/venv`, `pip install -e packages/kb-mail + jobs/documents-ingest` z checkoutu deploy-only na PIHA przy deployu (checkout służy tu + jako źródło instalacji, praca deweloperska nadal poza nim). Sekrety: istniejący + `/opt/homelab/kb/.env` (token Paperless już tam mieszka, 600). +- **Jednostki** w repo: `jobs/documents-ingest/systemd/kb-ingest.{service,timer}` + + skrypt `kb-ingest-run.sh`; instalacja udokumentowana w README (symlink/copy do + `/etc/systemd/system/`, `systemctl enable --now kb-ingest.timer`). Pierwszy + systemd-timer w repo — świadomie host-level, nie kontener (joby potrzebują jednocześnie + LAN, DB i plików hosta; konteneryzacja nic tu nie daje). +- **Harmonogram**: `OnCalendar=*-*-* 03:30`, `Persistent=true` (nadgania po reboocie). +- **Sekwencja skryptu**: adapter `--apply` → chunk_embed `--apply` + (`OLLAMA_URL=http://solaria:11434`) → (po decyzji z pilota, rozszerzenie później: + summarize nowych dokumentów). Log do `/opt/homelab/logs/kb-ingest/run-YYYYMMDD.log`. +- **Tolerancja na SOLARIĘ offline** (`availability_target: medium`): wrapper odróżnia + „Ollama nieosiągalna" (probe `GET /api/tags` przed embedem; brak → pomiń embed, + odnotuj, **to nie jest fail** — nadrobi następny run, bo embed jest idempotentny) od + realnych błędów (exit ≠ 0 adaptera, `stats_mismatch`, błędy embedu przy żywej Ollamie). + +### 7.2 Obserwowalność i alarm (istniejący tor Prometheus) + +- **Metryki** (wrapper pisze plik `.prom` atomowo — tmp + rename — do + `/opt/homelab/state/node-exporter/kb-ingest.prom`): + `kb_ingest_last_run_timestamp`, `kb_ingest_last_success_timestamp`, + `kb_ingest_documents_inserted`, `kb_ingest_chunks_inserted`, + `kb_ingest_embed_skipped` (0/1 — SOLARIA spała), `kb_ingest_embed_backlog` + (chunki aktywne bez embeddingu — rosnący backlog = SOLARIA śpi za długo). +- **node_exporter@PIHA**: dodać `--collector.textfile.directory=/host/opt/homelab/state/node-exporter` + w compose (mount `/:/host:ro` już to pokrywa — zero nowych wolumenów). +- **Reguły** w `services/fleet-prometheus/rules/` (nowy plik `kb-ingest.yml`, konwencja + liveness.yml — tylko FIRING, dostawę robi brain-watchdog): + - `KbIngestStale`: `time() - kb_ingest_last_success_timestamp{node="piha"} > 172800` + (2 doby = 2 nieudane runy) — severity critical; + - `KbEmbedBacklogGrowing`: `kb_ingest_embed_backlog > 0` przez `for: 72h` — severity + warning (SOLARIA nie wstała od 3 dni albo embed systematycznie pada). + +--- + +## 8. Krok 6 — Wiki-kompilat (docelowy moduł syntezy) + +### 8.1 Szkic operatora (decyzja architektoniczna 2026-07-15 — NIE podlega zmianie) + +> Wzorzec: Karpathy llm-wiki (gist karpathy/442a6bf555914893e9891c11519de94f). +> Architektura dwuwarstwowa: +> - **Warstwa dowodowa** (jest): RAG/pgvector na chunkach — skala 225k+ kopert, źródło +> prawdy, zawsze lokalna. +> - **Warstwa pamięci** (faza 3+): wiki markdownów utrzymywana przez LLM — strony-encje +> (firma, umowa, sprawa, temat: „PZU", „kredyt", „FLL 25-26"), kompilowane +> **Z WYNIKÓW RETRIEVALU**, nie z surowców. +> +> Inwarianty: +> 1. Każda strona wiki linkuje envelope_id + chunk ids, z których powstała +> (audytowalność, mitygacja propagacji błędów kompilacji). +> 2. Kompilację i lint robi CC/zewnętrzne API — lokalny model za słaby na +> wielostronicowe operacje; spina się z polityką eskalacji (faza 5): dane wychodzące +> = wyselekcjonowane chunki, nie surowy korpus. +> 3. Lint okresowy: sprzeczności między stronami, strony-sieroty, brakujące pojęcia +> (linkowane a nieistniejące), spot-check stron vs źródłowe chunki. +> 4. Pętla zwrotna: dobre odpowiedzi z zapytań (faza 5) wracają do wiki jako nowe +> strony/aktualizacje. +> 5. Wiki wchodzi do retrievalu jako dodatkowe źródło (embedding stron obok summaries) +> — kaskada: wiki → summary → chunk. +> 6. Repozytorium wiki: git (osobne repo albo katalog w homelab-codex-ws — do +> rozstrzygnięcia w planie), historia zmian = darmowy audit trail kompilacji. +> +> Kolejność: pierwsze strony wiki dopiero PO pilocie streszczeń (streszczenia to +> półprodukt kompilacji) i PO filtrze śmieciowych chunków. Pełna wiki po fazie mailowej +> (przyrostówka) — wcześniej kompilat byłby fotografią przeszłości. + +### 8.2 Rozwinięcie wykonawcze (szczegóły, decyzje architektoniczne bez zmian) + +**Repozytorium** (rozstrzygnięcie punktu 6 szkicu): osobne repo `kb-wiki` — decyzja 7, +uzasadnienie w §2. Klon roboczy na PIHA: `/opt/homelab/data/kb-wiki/`. + +**Struktura katalogów** (typy stron = katalogi; płasko wewnątrz, linki `[[...]]` robią +graf, nie hierarchia plików): + +``` +kb-wiki/ +├── INDEX.md # spis stron per typ, utrzymywany przy kompilacji +├── podmioty/ # firmy i instytucje: pzu.md, mbank.md, urzad-skarbowy.md +├── osoby/ # tożsamości/aliasy (spina się z backfillem headers) +├── sprawy/ # procesy w czasie: kredyt-hipoteczny.md, fll-2025-26.md +├── umowy/ # aktywne kontrakty/polisy: polisa-oc-auto.md +├── tematy/ # przekrojowe: ubezpieczenia.md, subskrypcje.md +└── _meta/ + ├── conventions.md # ten format, słownik typów, zasady linkowania + └── lint-reports/ # raporty lintu, datowane +``` + +**Frontmatter** (inwariant 1 — audytowalność — realizowany przez blok `sources`): + +```markdown +--- +title: PZU +type: podmiot # podmiot | osoba | sprawa | umowa | temat +status: active # active | archived +tags: [ubezpieczenie] # słownik z §5 (decyzja 4) — wspólny z document_summary +sources: + - envelope_id: paperless:119 + chunks: [4211, 4213] # document_chunk.id + - envelope_id: "CABtrY-...@mail.gmail.com" + chunks: [] # koperta bez chunków (np. sam manifest/headers) +compiled_by: claude-sonnet-5 # albo cc-session +compiled_at: 2026-07-20 +updated_at: 2026-07-20 +--- + +# PZU + +Treść strony... Fakty niosące kwoty/daty z przypisem źródłowym w miejscu użycia: +składka 1 234 zł/rok [^paperless:119#4212]. Linki do innych stron: [[polisa-oc-auto]]. +``` + +Konwencja: każdy fakt liczbowy (kwota, data, numer umowy) ma przypis inline +`[^#]`; blok `sources` agreguje wszystkie źródła strony. Lint +(inwariant 3) weryfikuje oba poziomy. + +**Integracja z retrievalem** (inwariant 5) — strony wiki wchodzą do bazy **istniejącą +architekturą kopert, bez nowego schematu**: + +- strona = koperta `source='wiki'`, `id='wiki:podmioty/pzu'`, `raw_ref=<ścieżka w repo>`, + `ts=updated_at`, `entities=[{type: content, ...}, {type: wiki_meta, ...}]`; +- chunki strony → `document_chunk` normalnie (strony są krótkie: 1–3 chunki), embed + bge-m3 tym samym jobem; +- pełna kaskada docelowa: **wiki → summary → chunk** — zapytanie najpierw trafia strony + (skompilowana wiedza), potem streszczenia, potem chunki (dowód); implementacyjnie to + rozszerzenie eval-skryptu z §6 o trzeci poziom, po pierwszych stronach. + +**Kompilacja i commit** (inwariant 2): sesja CC/API dostaje wyniki retrievalu dla encji +(top chunki + streszczenia, `WHERE excluded_reason IS NULL`), pisze/aktualizuje stronę, +commituje do `kb-wiki` z opisem „compile: ". Dane wychodzące = +wyselekcjonowane chunki — zgodnie z polityką eskalacji. + +**Lint** (inwariant 3): okresowa sesja CC (w fazie 3 ręcznie wyzwalana, nie timer): +linki `[[...]]` do nieistniejących stron, strony bez linków przychodzących, spot-check +N losowych przypisów `[^...]` vs treść chunka w bazie, sprzeczności między stronami +(fakty o tej samej encji). Raport do `_meta/lint-reports/YYYY-MM-DD.md`. + +**Zakres wiki w fazie 3** (kolejność ze szkicu jest wiążąca): utworzenie repo, struktura, +`_meta/conventions.md` + **3–5 stron proof-of-concept** kompilowanych ręcznie sesją CC +z wyników retrievalu — dopiero **po** kroku 1 (filtr śmieci) i kroku 3 (pilot +streszczeń). Kandydaci na pierwsze strony (encje z pilota): PZU/WARTA (polisy), FLL +2025-26, bank/kredyt. Pełna kompilacja korpusu — po fazie mailowej, poza tą fazą. + +--- + +## 9. Poza zakresem fazy 3 + +Granice planu — wszystko poniżej jest świadomie odłożone, z istniejącym miejscem w +roadmapie (`docs/kb/kb-00-overview.md` „Stan etapów/Backlog", sesja +`docs/sessions/2026-07-16.md`, backlog operatora): + +| Temat | Gdzie zakotwiczone | Kiedy | +|---|---|---| +| Batching wywołań Ollamy (embed) | backlog sesji 07-16 („batching pozostaje dźwignią") | przed fazą mailową | +| Backfill embeddingów 225k kopert mailowych | kb-00 etap 6 (mail-indexer) | faza mailowa | +| Streszczenia korpusu mailowego | decyzja po pilocie §5.4 | faza mailowa | +| IMAP/JMAP przyrostówka (Gmail/Fastmail live) | kb-00 etapy 3–4 | osobne etapy | +| Google Drive / pełny Takeout jako źródło | kb-00 backlog | osobny moduł | +| kb-query / UI zapytań | kb-00 backlog | po warstwie kompilacji | +| Synteza odpowiedzi + polityka eskalacji | faza 5 | po fazie 3/4 | +| Pełna kompilacja wiki (poza proof-of-concept) | §8.2 | po fazie mailowej | +| `maintenance_work_mem` HNSW przy milionach chunków | 05-faza2-plan §1.5 | checkpoint przy fazie mailowej | +| Aktualizacja `hosts/solaria/capabilities.yaml` (model GPU) | finding recon §1.3 | drobny osobny task | + +--- + +## 10. Plan implementacji (kolejność = zależności) + +| # | Krok | Zależy od | Szacunek | +|---|---|---|---| +| 1 | Migracja 003 (`UNIQUE+model`, `excluded_reason`) + dostosowanie `chunk_embed.py` + testy | — | 0.5 sesji | +| 2 | Kalibracja filtra śmieci na 2683 chunkach (read-only) → progi → backfill flag + heurystyka w `chunk_embed.py` | 1 | 1 sesja | +| 3 | Dedup: pomiar skali (SQL §3.2) → flagi `duplicate` + `entities[duplicate_of]` | 1 | 0.5 sesji | +| 4 | Migracja 004 (`document_summary`) | 1 | z krokiem 5 | +| 5 | Job `summarize.py` (+ słownik tagów, testy, smoke) | 2, 3, 4 | 1–1.5 sesji | +| 6 | `ollama pull gemma3:12b` + pilot dwutorowy: lokalny 186 + API 186 + embed streszczeń | 5 | 0.5 sesji + ~3 h GPU + ~1.5 USD API | +| 7 | Ocena jakości (rubryka §5.4, próbka 25) → werdykt lokalny-vs-API do skali mailowej | 6 | 0.5–1 sesja | +| 8 | Utrwalenie eval-setu (`queries.yaml`) + `retrieval_eval.py` + bramka kaskady | 6 | 1 sesja | +| 9 | Cykliczny ingest: venv na PIHA, unit+timer, metryki textfile, flaga node_exporter, reguły Prometheus | 1–2 (embed z filtrem) | 1 sesja | +| 10 | Wiki: repo `kb-wiki`, struktura, conventions, 3–5 stron proof (sesje CC) | 2, 7 | 1 sesja | + +Kroki 8–9 są od siebie niezależne (mogą iść równolegle/w dowolnej kolejności po 6–7). +Definition of Done per krok wg CLAUDE.md: build/smoke + pytest przed commitem; joby +odpalane na nodach zawsze z logiem do pliku. + +**Kryterium ukończenia fazy 3:** (a) zero chunków śmieciowych/duplikatów w retrievalu +(`excluded_reason` działa end-to-end), (b) 186 dokumentów ma streszczenia+tagi z co +najmniej jednego modelu + werdykt jakościowy lokal-vs-API, (c) kaskada przechodzi bramkę +§6.2 albo udokumentowana diagnoza czemu nie, (d) timer cyklicznego ingestu chodzi ≥ tydzień +z metrykami w Prometheus, (e) repo kb-wiki istnieje z ≥3 stronami proof spełniającymi +inwarianty 1–2. + +--- + +## 11. Szacunki zbiorcze + +- **Dane**: 186 streszczeń × 2 modele + embeddingi — pojedyncze MB; dziesiątki flag + `excluded_reason`. Pomijalne dla kb-postgres (1 GB limit, 202 MB użyte). +- **GPU**: pilot lokalny ~2–3 h (30–60 s/dok); embeddingi streszczeń ~2 min; brak wpływu + na inne workloady poza oknem pilota. +- **Koszty zewnętrzne**: przebieg API ~1.5 USD (Haiku) / ~4 USD (Sonnet); ocena CC + w ramach subskrypcji; kompilacja 3–5 stron proof — pomijalna. +- **Czas ludzki/sesyjny**: ~7–8 sesji roboczych łącznie (tabela §10), z czego bramki + wymagające udziału Oskara: zatwierdzenie tego planu, werdykt pilota (krok 7), review + stron proof (krok 10). + +--- + +## 12. Podsumowanie dla Oskara + +**Fundament jest**: 225 216 kopert, 2683 chunki, retrieval z zaufanymi progami, GPU +działa, wzorce jobów (bilans/idempotencja/dry-run) sprawdzone bojowo. Faza 3 nie dodaje +żadnej nowej „infrastruktury" — dodaje **jakość** (filtr, dedup, poprawny klucz +unikalności), **pierwszy półprodukt kompilacji** (streszczenia+tagi z pomiarem +lokal-vs-API), **kaskadę** z twardą bramką nie-gorszości, **automat** (timer + alert +istniejącym torem Prometheus→Telegram) i **szkielet wiki** z 3–5 stronami proof. + +**7 decyzji w §2, wszystkie z rekomendacjami**; realnie otwarte (zmieniają przebieg) są +trzy: model pilota streszczeń (D3 — rekomendacja: dwutorowo API+lokalny), writeback tagów +(D5 — rekomendacja: nie teraz), repo wiki (D7 — rekomendacja: osobne `kb-wiki`). D1, D2, +D4, D6 to rekomendacje techniczne do przyklepnięcia. + +Nic nie zostało zaimplementowane, zdeployowane ani pobrane w ramach tego recon — wynik +to wyłącznie ten dokument.