homelab-codex-ws/kb/phases/kb-m5-faza3.md
oskar e20845ae1b docs(kb): dopisz inwariant 7 (izolacja retrievalu kompilacji od source=wiki) do faza3 §8.1
Decyzja (f) z kb/audits/wiki-kompilat-recon-2026-08-26.md §10, zatwierdzona
przez operatora w całości 2026-08-27. Kompilacja strony wiki nigdy nie czyta
source='wiki' jako dowodu (exclude_sources=('wiki',)); tylko /search
(warstwa użytkownika, po syntezie odpowiedzi) widzi wiki w kaskadzie —
mitygacja self-citation/citogenesis przy źródle retrievalu.

Dopisane jako rozszerzenie po inwariantach 1-6, które pozostają nietknięte
(szkic operatora 2026-07-15 nadal "NIE podlega zmianie").
2026-08-27 15:53:35 +02:00

799 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-08-27
links: []
---
# 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 18 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.450.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:<id>` (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.11.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 (23 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, ~1819 GB w q4 — częściowy offload na CPU przy
1216 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.
**ROZSTRZYGNIĘCIE (Oskar, 2026-07-17, z oceny A/B pilota dwutorowego 186/186 dokumentów):**
- **Tor kompilacyjny = `claude-haiku-4-5`** — 157/157 streszczeń bez ani jednego JSON-faila,
100% dyscypliny słownika tagów, wyraźnie lepsza hierarchia treści na długich dokumentach
(OWU, umowy wielostronicowe) niż `gemma3:12b` (155/157, słabsza struktura na długich
dokumentach). Kaskada retrieval (§6) filtruje `document_summary` po `model =
'claude-haiku-4-5'` z tego powodu.
- **`gemma3:12b` zostaje w odwodzie jako tor lokalny** — dane już w bazie (155 streszczeń +
embeddingi), job (`summarize.py`) wspiera oba backendy bez zmian kodu, więc przełączenie
jest jednym flagowym argumentem, nie migracją.
- **Decyzja o modelu dla fazy mailowej (skala: dziesiątki tysięcy streszczeń) odłożona do
reconu fazy mailowej** — tam ekonomia jest inna (koszt API rzędu setek USD zamiast ~1.5 USD)
i wymaga własnej oceny z flagą prywatności (dane finansowo-tożsamościowe w skali masowej)
i szacunkiem kosztu, nie automatycznego przeniesienia wniosku z pilota 186-dokumentowego.
### 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/<id>/`).
- 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 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 1474 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 13 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 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": "<38 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 23
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: ~3060 s/dok na GPU → **23 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 02 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 `kb/phases/kb-m5-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 = <wybrany>, 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.
### 6.3 Implementacja i wynik bramki (2026-07-17) — **PASS, kaskada = domyślna ścieżka**
**Kod**: `jobs/documents-ingest/src/documents_ingest/retrieval.py` — dwie czyste funkcje
wejściowe pod przyszłe kb-query (faza 4), `query_text → chunki z dist i source`:
`flat_query` (baseline) i `cascade_query` (`summary_model='claude-haiku-4-5'` — decyzja 3
rozstrzygnięta wyżej). Oba dzielą **jedno** wywołanie embeddingu zapytania (bge-m3) — kaskada
dokłada tylko jeden dodatkowy SQL (stage 1), zero dodatkowych wywołań Ollamy. Testy
jednostkowe z mockami: `tests/test_retrieval.py` (stage1→stage2 zawężenie, koperta bez
aktywnych chunków, N większe niż liczba kopert, no-summaries short-circuit) — 166/166 testów
pakietu przechodzi.
**Eval-set utrwalony**: `jobs/documents-ingest/eval/queries.yaml` (7 zapytań z pilota
07-16, 1:1 z `kb/phases/kb-m5-eval-retrieval-pilot.md`, ten plik pozostał nietknięty —
`queries.yaml` to jego wersjonowana kopia robocza). Skrypt bramki (read-only, integracyjny,
**nie wchodzi do pytest**): `jobs/documents-ingest/eval/retrieval_eval.py`.
**Kalibracja N** (sweep na żywej bazie, k=5 stałe): przy N∈{1,2,3} kaskada **degraduje**
zapytania 25 (np. zapytanie 4 spada z 0.448 do 0.532 przy N=1 — trafienie znika); **N=5 jest
najmniejszym progiem bezpiecznym** — od N=5 wzwyż wynik identyczny z płaskim dla wszystkich
7 zapytań. Rekomendacja planu (**N=10, k=5**) ma więc 2× margines nad zmierzonym progiem
minimalnym — potwierdzona bez zmian, nie tylko przyjęta z założenia.
**Wynik bramki przy N=10, k=5** (żywa baza, `claude-haiku-4-5`, 157 streszczeń,
2545 aktywnych chunków z 2683):
| Kryterium | Wynik | Werdykt |
|---|---|---|
| 1. żaden płaski hit nie degraduje | 0 degradacji na 5 zapytań-trafień | PASS |
| 2. hit@3 kaskada ≥ hit@3 płaski | 5/5 vs 5/5 | PASS |
| 3. negatywne kontrole > 0.55 (oba tory) | płaski 0.621/0.553, kaskada 0.644/0.553 | PASS |
**Czy kaskada COŚ poprawia?** Nie przy tej skali (186 dok.) — dystanse top-1 kaskady są
**identyczne co do bita** z płaskim dla wszystkich 5 zapytań-trafień przy N≥5, w tym dla
zapytania 3 (szara strefa "FLL scoring PL", 0.4289 w obu torach — pre-filtr po streszczeniu
ani nie pomaga, ani nie szkodzi). To zgodne z przewidywaniem planu (§6.1): przy 157
skompilowanych kopertach pre-filtr nie ma czego przyspieszać ani czego poprawiać — test
weryfikuje **architekturę** pod skalę mailową (225k kopert), nie jakość na tym korpusie.
Filtr `ocr_junk` z kroku 1 potwierdził się przy okazji: chunk z kodami kreskowymi, który w
pilocie zajmował poz. 3/5 dla zapytania 2, już nie występuje w wynikach (usunięty z indeksu).
**Koszt na zapytanie**: płaski = 1 embed + 1 SQL; kaskada = 1 embed (dzielony) + 2 SQL
(stage 1 top-N streszczeń, stage 2 top-k chunków w ich obrębie) — jeden dodatkowy SQL,
zero dodatkowych wywołań Ollamy.
**Decyzja**: bramka PASS → **kaskada (`cascade_query`, N=10, k=5, `summary_model=
'claude-haiku-4-5'`) jest odtąd domyślną ścieżką retrievalu** dla przyszłego kb-query
(faza 4). Płaski (`flat_query`) zostaje w kodzie jako baseline/fallback do porównań i do
architektur bez warstwy streszczeń.
---
## 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`~~**`OnCalendar=0/2:00:00` (co 2 h) od
2026-08-06**, `Persistent=true` (nadgania po reboocie). Zmiana wynika z Decyzji (d) reconu
przyrostówki (`kb/audits/mail-sync-2026-08-06.md` §3.3): o 03:30 SOLARIA prawie na pewno
śpi (potwierdzone odczytem `kb_ingest_embed_skipped 1`), a od tej daty tick dostaje też
etap mailowy — ~60 nowych chunków na dobę pomijanych każdej nocy zapaliłyby
`KbEmbedBacklogGrowing` na stałe. Co 2 h zamiast stałej godziny dopasowanej do nawyków
operatora: probe Ollamy sam wybiera okno, więc któryś tick w nie trafi niezależnie od tego,
o której SOLARIA wstaje w danym tygodniu.
- **Sekwencja skryptu**: adapter `--apply` → chunk_embed `--apply`
(`OLLAMA_URL=http://solaria:11434`) → **mail_body_ingest `--only-unchunked`** (dodane
2026-08-06 — konsument kolejki, którą wypełnia `jobs/mail-imap-sync`; import miękki, więc
venv bez tego pakietu pomija etap zamiast wywracać wrapper) → summarize → embed-summaries.
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.
**Inwariant 7 (dodany 2026-08-27 — rozszerzenie, nie zmiana inwariantów 16
powyżej, które pozostają NIE podlega zmianie):** decyzja (f),
`kb/audits/wiki-kompilat-recon-2026-08-26.md` §10, zatwierdzona przez operatora
w całości 2026-08-27. Kompilacja strony wiki **nigdy** nie czyta `source='wiki'`
jako dowodu (`exclude_sources=('wiki',)` w `cascade_retrieve`/`hybrid_retrieve`) —
retrieval na potrzeby kompilacji zawsze wyklucza wiki, czyta wyłącznie warstwę
dowodową (mail, paperless). Tylko `/search` (warstwa użytkownika, po zbudowaniu
syntezy odpowiedzi — inwariant 5, faza 5) widzi wiki w kaskadzie. Mitygacja
self-citation/citogenesis przy źródle retrievalu, nie tylko przez lint (inwariant
3) po fakcie — patrz audyt §7 dla pełnego rozumowania.
### 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
`[^<envelope_id>#<chunk_id>]`; 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: 13 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: <strona><envelope_ids>". 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` + **35 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 (`kb/subsystems/kb-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 34 | 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 | 11.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.51 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 | 12 (embed z filtrem) | 1 sesja |
| 10 | Wiki: repo `kb-wiki`, struktura, conventions, 35 stron proof (sesje CC) | 2, 7 | 1 sesja |
Kroki 89 są od siebie niezależne (mogą iść równolegle/w dowolnej kolejności po 67).
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 12.
---
## 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 ~23 h (3060 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 35 stron proof — pomijalna.
- **Czas ludzki/sesyjny**: ~78 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 35 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.