homelab-codex-ws/kb/phases/kb-m5-faza3.md
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

44 KiB
Raw Blame History

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

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 kopertgmail 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-m3same 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 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ą):

-- 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:

{"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:

-- 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):

-- 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": "<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 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 = <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, 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):

---
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: ← <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.