diff --git a/docs/kb/modules/05-faza-mailowa-plan.md b/docs/kb/modules/05-faza-mailowa-plan.md new file mode 100644 index 0000000..74749b7 --- /dev/null +++ b/docs/kb/modules/05-faza-mailowa-plan.md @@ -0,0 +1,607 @@ +# Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN) + +> Status: RECON ZAKOŃCZONY (2026-07-22), plan DO ZATWIERDZENIA. Zero kodu, zero +> migracji, zero pełnych runów w ramach tego zadania — wyłącznie ten dokument +> (recon obejmował pomiary read-only: próbka 600 .eml na PIHA, benchmark +> `/api/embed` na SOLARII, `\d` + SELECT count na żywej bazie). +> +> Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone, +> serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez +> HTTP", 2026-07-22, `docs/sessions/2026-07-22.md`). Faza mailowa = odpowiedź na +> feedback operatora z POC wyszukiwarki: **„mało danych, brak połączeń"**. +> 225 030 kopert gmail ma dziś w bazie tylko nagłówki — treści leżą wyłącznie +> w archiwum .eml na PIHA. Ta faza wprowadza treści maili do `document_chunk` +> i udostępnia je w retrievalu. **To nadal wyszukiwarka, nie chat** — synteza, +> Drive Takeout i backfill 70k załączników PDF pozostają poza zakresem (§11). + +--- + +## 1. Stan faktyczny (recon, zweryfikowany w repo i na żywo 2026-07-22) + +### 1.1 Baza — co jest, czego nie ma + +Live (`docker exec kb-postgres psql` na PIHA): + +| Miara | Wartość | +|---|---| +| `envelope` source='gmail' | **225 030** (100% z `entities[type=headers]` po backfillu) | +| `envelope` source='paperless' | 191 | +| `document_chunk` | 2 767 (2 627 aktywnych, 130 `duplicate`, 10 `ocr_junk`; 2 765 z embeddingiem) — **wszystkie paperless** | +| `document_summary` | 317 (162 `claude-haiku-4-5` + 155 `gemma3:12b`) — **wszystkie paperless** | +| Rozmiar bazy `kb` | **250 MB** | +| Dysk PIHA `/home` | 410 GB, wolne **144 GB** | +| RAM PIHA | 8 GB (dostępne ~3,8 GB; obok HA, Paperless, kb-postgres `mem_limit: 1g`) | + +**Treści maili NIE ma w bazie.** `gmail-bulk-import` celowo pomijał inline +`text/plain`/`text/html` (`_parse_attachments` robi `continue` na częściach +tekstowych) — do DB trafił tylko manifest załączników, potem backfill dopisał +nagłówki. Ta luka jest dokładnie tym, co faza mailowa wypełnia. + +### 1.2 Archiwum .eml — jedyne źródło treści + +- `/home/oskar/kb/mail/archive/gmail/YYYY/MM/.eml` — + **225 057 plików, 27 GB** (append-only; 27 plików to duplikaty Message-ID, + które w DB skleiły się przez `ON CONFLICT (id) DO NOTHING`). +- Mapowanie koperta→plik: `envelope.raw_ref` (ścieżka względna) + archive root. + Rejestrem jest sama kolumna — osobnego manifestu nie ma i nie potrzeba. +- Rozkład rozmiarów plików (pełny skan `find -printf '%s'`): p50 = 24 KB, + p90 = 116 KB, p99 = 1,9 MB, średnia 125 KB — ale to rozmiary Z załącznikami + (base64), więc NIE nadają się do szacowania chunków. Stąd pomiar §1.3. + +### 1.3 Zmierzony rozkład długości TREŚCI (próbka 600 losowych .eml, parse na PIHA) + +Deterministyczna próbka 600 plików, parser stdlib `email` (policy.default z +fallbackiem compat32 — wzorzec z backfillu), HTML→tekst własnym `HTMLParser`, +0 błędów parsowania: + +| Miara | Wartość | +|---|---| +| Długość body (znaki): p25 / p50 / p75 / p90 / p99 | 656 / **1 616** / 3 752 / 8 894 / 24 796 | +| Średnia | 3 335 znaków | +| Puste body (attachment-only itp.) | 0,3% | +| HTML-only (bez części text/plain) | **15%** | +| Sygnał newslettera (`List-Unsubscribe` OR `List-Id` OR `Precedence: bulk/list`) | **40% maili, 48,5% wolumenu tekstu** (w dekadzie 202x: 64% maili!) | +| Maile z >30% linii cytowanych (`>`) | **31%** | + +**Ekstrapolacja chunków** (600 tok / 150 overlap = 2400/600 znaków, jak +paperless): ~**496k chunków** dla całości, ~**271k** bez newsletterów +(2,0–2,5 chunka/mail). Bez obcinania cytowań — po obcięciu (Decyzja 2) +realnie mniej. Mediana maila (1,6k znaków) = **1 chunk**. + +Rozkład kopert po latach (SQL, `envelope.ts`): 2011–2012 szczyt (~23k/rok), +ostatnie 12 miesięcy = **13 712 maili**, `epoch_fallback` (1970) = 2 559. + +### 1.4 Ollama SOLARIA — batch embed ZMIERZONY, działa + +- Ollama **0.32.0**, RTX 4070 Ti SUPER 16 GB (driver 595.71.05, GPU żywe). +- `/api/embed` z `input` jako **listą** działa. Benchmark (bge-m3, na żywo): + +| Długość tekstu | batch=1 | batch=32 | batch=64 | batch=128 | +|---|---|---|---|---| +| ~900 znaków | 148 ms | 9,8 ms/szt | 8,5 ms/szt | 6,6 ms/szt | +| ~3500 znaków | 160 ms | 26,8 ms/szt | 18,1 ms/szt | — | + +Wniosek: batch 64 daje **~8–18 ms/chunk** zamiast 207 ms sekwencyjnie — +**~271k aktywnych chunków ≈ 1–1,5 h GPU** (vs ~16 h sekwencyjnie). Batching +przestaje być ryzykiem, jest zmierzonym faktem. + +- Obecny klient (`packages/kb-retrieval/src/kb_retrieval/embed.py::embed_chunk`) + używa starego endpointu `/api/embeddings` z pojedynczym `prompt` — wymaga + rozszerzenia o `embed_batch` (Krok 1). +- Kontekst: bge-m3 ma limit 8192 tok; chunk 600 tok — bez ryzyka obcięcia. +- Ollama@SOLARIA ma udokumentowaną niestabilność (3 incydenty w tydzień: + zniknięcie kontenera, network-detach, bind-race przy starcie) — run musi być + wznawialny i logowany do pliku (inwarianty §1.5). + +### 1.5 Wzorce jobów — dziedzictwo obowiązkowe + +Z `gmail-bulk-import` / `gmail-header-backfill` / `chunk_embed` (rodzina jobów, +faza 3 §1.4): + +- dry-run domyślny, `--apply` jawnie; `--limit`/`--offset` po stabilnym + `ORDER BY id`; idempotencja = pre-fetch istniejących kluczy + + `ON CONFLICT DO NOTHING` (z parsowaniem command taga); **bilans statystyk jako + inwariant** (suma wyników = scanned, `stats_mismatch` → niezerowy exit); + izolacja błędów per wiersz (lekcja −4999 wierszy: `json.dumps` i wszystko + per-row W try); zawsze `> run.log 2>&1`, nigdy goły tmux. +- Hardening 8-bitowych nagłówków: typed parse (`policy.default`) z fallbackiem + compat32 + `kb_mail.text.sanitize_surrogates` — korpus **udowodnił**, że + zawiera surowe bajty 8-bit w nagłówkach (9 maili wymaga fallbacku). +- Znany wart do naprawy w nowym jobie: pre-fetch kluczy w `chunk_embed` pomija + `model` (README to dokumentuje) — job mailowy od początku kluczuje + `(envelope_id, chunk_index, model)`. + +### 1.6 Schema `document_chunk` — GOTOWA, migracje NIEPOTRZEBNE + +Zweryfikowane `\d` na żywej bazie: `UNIQUE (envelope_id, chunk_index, model)` +(migracja 003 zaaplikowana), `excluded_reason TEXT` obecne, FK +`envelope(id) ON DELETE CASCADE`, HNSW `vector_cosine_ops` (domyślne m=16, +ef_construction=64). `excluded_reason` nie ma CHECK-a — nowa wartość +`'newsletter'` (Decyzja 4) nie wymaga ALTER-a. **Zero migracji w tej fazie.** + +### 1.7 Retrieval — koperty bez summary są dziś NIEWIDOCZNE w kaskadzie + +`cascade_retrieve` (`packages/kb-retrieval/src/kb_retrieval/retrieval.py:57-100`): +stage 2 rankuje wyłącznie chunki kopert wyłonionych w stage 1 z +`document_summary WHERE model='claude-haiku-4-5'`. Koperta bez streszczenia +**nigdy nie wejdzie do wyniku kaskady** (docstring mówi to wprost). `flat` +je widzi, ale w UI to tryb debug. `kb-query` domyślnie `mode=cascade`. +Inwariant startowy `startup.py` (bge-m3 w `document_chunk.model` I +`document_summary.embedding_model`) pozostaje spełniony przez paperless — +dolanie chunków mailowych bez summaries go nie łamie. + +--- + +## 2. Otwarte decyzje dla Oskara (z rekomendacjami) + +### Decyzja 1 — Skąd treść: ponowny MIME-walk archiwum .eml, ekstrakcja stdlib + +**Rekomendacja: nowy przebieg po archiwum .eml (nie po mbox), parser stdlib +z dziedziczonym hardeningiem, HTML→tekst własnym `html.parser.HTMLParser`.** + +Uzasadnienie: + +- Archiwum jest źródłem kanonicznym z gotowym mapowaniem `raw_ref`; re-run + mboxa to pełny rebuild indeksu 27 GB przy każdym otwarciu (lekcja z sesji + importu) i brak związku z id kopert. +- Ekstrakcja body = dokładnie te części, które `_parse_attachments` dziś + pomija: inline `text/plain` preferowane; gdy mail jest HTML-only (15%), + HTML→tekst. Charset: `get_content_charset()` z `errors="replace"` + + `sanitize_surrogates` (korpus ma łamane kodowania — udowodnione). +- HTML→tekst: własny `HTMLParser` (pomija `style/script/head`, skleja tekst, + redukuje whitespace) — **zero nowych zależności**, zweryfikowany na próbce + 600 (0 błędów). Newslettery HTML dają po konwersji czysty tekst nawigacyjny, + ale te i tak podlegają Decyzji 4. +- Typed parse `policy.default` (dekoduje RFC 2047) z fallbackiem compat32 — + wzorzec 1:1 z `gmail-header-backfill`, razem z jego testami regresyjnymi. + +Odrzucona alternatywa: biblioteki `html2text`/`beautifulsoup`/`talon` — nowe +zależności w venv na dwóch nodach dla problemu, który stdlib rozwiązuje +wystarczająco dobrze na tym korpusie. + +### Decyzja 2 — Cytowania-łańcuszki: TAK, obcinać + +**Rekomendacja: obcinać quoted reply chains przed chunkowaniem, własną +heurystyką (bez zależności), z licznikiem w bilansie.** + +Uzasadnienie: + +- 31% maili ma >30% linii cytowanych. Bez obcinania każda odpowiedź w wątku + dubluje treść poprzedników — chunki zdominowane powtórzeniami, retrieval + zwraca N kopii tego samego akapitu z różnych kopert. +- Heurystyka: (a) linie `^\s*>`; (b) wszystko od markera odpowiedzi w dół: + `On ... wrote:`, `Dnia ... napisał(a):`, `W dniu ... pisze:`, + `-----Original Message-----`, `________________________________` (Outlook); + (c) w HTML: poddrzewa `div.gmail_quote` / `blockquote` przed konwersją. +- Odwracalne: archiwum nietknięte; zmiana heurystyki = re-run joba (idempotencja + po kluczu z `model` — nowa wersja chunkera może iść pod nowym `model`-tagiem + lub po `DELETE` starych chunków gmail — decyzja operacyjna przy re-runie). +- Bilans: `quoted_chars_stripped` sumarycznie + per-mail flaga w logu, żeby + bramka jakościowa mogła wykryć nadgorliwe cięcie. + +Odrzucona alternatywa: `talon` (Mailgun) — cięższy, nieutrzymywany, uczony na +korpusie EN; nasza heurystyka musi znać polskie markery. + +### Decyzja 3 — Nagłówki jako kontekst chunka: TAK, prefiks w każdym chunku + +**Rekomendacja: każdy chunk maila zaczyna się od jednej linii kontekstu +`Temat: … | Od: … | Data: YYYY-MM-DD`, budowanej z `entities[type=headers]` +(już w DB — zero ponownego parsowania nagłówków).** + +Uzasadnienie: + +- bge-m3 embeduje chunk w izolacji; środkowy chunk długiego maila bez tematu + i nadawcy traci sens zapytań typu „mail od X o Y". Prefiks kosztuje + ~100–200 znaków z budżetu 2400 (4–8%). +- Prefiks w `document_chunk.text` (nie osobna kolumna) — trafia też do + wyników `kb-query`, co od razu poprawia czytelność UI dla maili. + +Odrzucona alternatywa: goły body (tańsze o 5% tokenów, gubi kontekst); prefiks +tylko w chunk_index=0 (niespójne — retrieval zwraca pojedyncze chunki). + +### Decyzja 4 — Newslettery: chunkować, NIE embedować (`excluded_reason='newsletter'`) + +**Rekomendacja: tanie kryterium nagłówkowe — `List-Unsubscribe` OR `List-Id` +OR `Precedence: bulk|list` ⇒ chunki zapisane z `excluded_reason='newsletter'` +i `embedding=NULL` (bez kosztu GPU, poza HNSW i retrievalem). Reszta korpusu +embedowana w całości.** + +Uzasadnienie: + +- Kryterium jest darmowe (nagłówki czytamy i tak), deterministyczne + i zmierzone: łapie 40% maili niosących 48,5% wolumenu tekstu — w tym + praktycznie cały szum komercyjny, który zatapiałby retrieval + (feedback „mało danych" nie znaczy „chcę promocji z 2019"). +- **Odwracalne w obie strony**: tekst chunków newsletterów JEST w bazie — + jeśli operator zechce ich szukać, wystarczy `UPDATE … SET excluded_reason=NULL + WHERE excluded_reason='newsletter'` + doembedowanie (backlog-query cyclic + ingest ich nie widzi, bo filtruje `excluded_reason IS NULL` — nie zawyżą + metryki `kb_ingest_embed_backlog`). +- Koszt magazynowy flagowanych chunków: ~225k wierszy × ~1,5 KB tekstu ≈ + 0,5 GB — akceptowalny za odwracalność. +- Spam: Takeout „All Mail" nie zawiera folderu Spam — osobna kategoria nie + jest potrzebna; `epoch_fallback` (2 559 maili z ts=1970) chunkujemy + normalnie (data w prefiksie z `date_raw`, jeśli jest). + +Odrzucona alternatywa: embedować wszystko (2× GPU, szum w wynikach — a i tak +odwracalne tylko przez `excluded_reason`); pomijać newslettery całkiem +(nieodwracalne bez re-parse 27 GB). + +### Decyzja 5 — Batching embeddingów: `/api/embed`, batch 64, sekwencyjnie + +**Rekomendacja: nowa funkcja `embed_batch(session, base_url, model, texts) -> +list[vector]` w `packages/kb-retrieval/embed.py` na endpoint `/api/embed` +(`input` jako lista), batch 64, batche sekwencyjnie (bez równoległości HTTP).** + +Uzasadnienie: + +- Zmierzone na żywo (§1.4): batch 64 = 8–18 ms/chunk, ~11–22× szybciej niż + obecna ścieżka; 271k chunków ≈ **1–1,5 h**. Równoległość pojedynczych + requestów nie jest potrzebna — GPU i tak saturuje się batchem, a sekwencyjny + pętla = prostszy bilans i wznawialność. +- `embed_chunk` (pojedynczy) zostaje bez zmian dla kb-query (zapytanie użytkownika + = 1 tekst) i dla cyclic ingest paperless. +- Plan benchmarku przed pełnym runem (wzorzec „zmierz, obejrzyj, dopiero wtedy + zaufaj progowi"): pierwszy run Etapu A (§8) loguje `avg_embed_ms_per_chunk` + per batch; jeśli >30 ms/chunk — stop i diagnoza (CPU fallback? model + zewisiony?) zanim ruszy Etap B. Dodatkowo sanity-check wymiaru 1024 na + KAŻDYM elemencie odpowiedzi batcha (guard `EXPECTED_DIM` jak w chunk_embed). + +Odrzucona alternatywa: równoległe requesty na `/api/embeddings` (HTTP overhead, +nieprzewidywalna kolejność błędów w bilansie); zewnętrzny serwis embeddingów +(koszt, prywatność korpusu mailowego). + +### Decyzja 6 — Streszczenia maili: NIE. Kaskada dostaje tryb hybrydowy + +**Rekomendacja: maile NIE dostają `document_summary`. Zamiast tego kaskada +w `kb-retrieval` zyskuje tryb hybrydowy: stage 1 po streszczeniach dla źródeł, +które je mają (paperless), plus równoległy bezpośredni HNSW po chunkach źródeł +bez streszczeń (gmail), scalenie po `dist` (ta sama metryka: cosine na bge-m3).** + +Uzasadnienie architektoniczne: + +- Kaskada powstała, by prefiltrować duże dokumenty OCR przez ich streszczenia. + Mail to inny kształt danych: mediana 1 chunk/mail — „streszczenie" maila + byłoby zwykle dłuższe od niego samego. Prefiltr nic nie wnosi, a HNSW po + 271k chunków to wciąż milisekundy (log-scale). +- Scalanie jest uczciwe: oba tory zwracają `dist` z tego samego embeddera + i tej samej przestrzeni — merge top-k po min-dist bez normalizacji. +- Zmiana w jednym miejscu: `packages/kb-retrieval/retrieval.py` (nowa funkcja + `hybrid_retrieve` / `hybrid_query`; stage 2 obecnej kaskady nietknięty) + + `kb-query` `mode` rozszerzony o `hybrid` (docelowo domyślny PO przejściu + bramki, §8). Inwariant startowy `startup.py` bez zmian. + +Ekonomia streszczeń, gdyby jednak (dla porządku, liczby do decyzji późniejszej): + +- **Haiku 4.5 na 225k maili**: ~1,7k tok input + ~150 tok output/mail → + ~380M input + ~34M output ≈ **~550 USD** (i zderzenie z capem $200/mies.). +- **gemma3:12b lokalnie**: pilot paperless ~10 s/dok; maile krótsze, ~4–8 s → + **11–21 dni ciągłej pracy GPU**. Nierealne dla całości. +- **Hybryda selektywna** (np. non-newsletter z ostatnich 2 lat, ~20–25k maili + ≈ 60–80 USD Haiku) — sensowna dopiero, jeśli bramka pokaże, że tryb hybrydowy + nie wystarcza. Odłożona, nie odrzucona. + +Odrzucona alternatywa: streszczenia całego korpusu (koszt/czas j.w.); +wpuszczenie maili do obecnej kaskady bez zmian (są w niej niewidoczne — §1.7); +przełączenie kb-query na `flat` (regresja jakości dla paperless, po to była +faza 3). + +### Decyzja 7 — Nowy job `jobs/mail-body-ingest/`, chunker wydzielony do pakietu + +**Rekomendacja: nowy job `jobs/mail-body-ingest/` (MIME-walk → quote-strip → +chunk → klasyfikacja newsletter → batch embed → insert). Wspólny chunker +(`chunk_text`, `hard_split`) wydzielony do `packages/kb-mail` +(`kb_mail/chunking.py`); `documents-ingest` importuje go z pakietu.** + +Uzasadnienie: + +- `chunk_embed` jest spleciony z założeniem `source='paperless'` + + `entities[type=content]`; adapter treści mailowej to inne źródło (pliki), + inny preprocessing (quote-strip, HTML), inna pętla embed (batch). Wspólna + jest tylko logika chunkowania — i ją wydzielamy (dokładnie wzorzec fazy 4: + `retrieval.py` → `packages/kb-retrieval`). +- `is_ocr_junk` zostaje w documents-ingest (progi kalibrowane na OCR, nie na + mailach); mail-body-ingest ma własne, prostsze wykluczenie: `body_empty`. +- Job czyta nagłówki z `entities[type=headers]` (prefiks, Decyzja 3) + i klasyfikuje newsletter z surowego .eml (nagłówki List-* nie są w entities — + są tanie do odczytu w trakcie i tak wykonywanego parse'u). +- Rodzina jobów: pełny zestaw inwariantów §1.5 (dry-run domyślny, `--apply`, + `--limit/--offset` po `ORDER BY id`, `--since DATE` dla etapowania po + `envelope.ts`, bilans, log do pliku, testy na fixture'ach .eml z 8-bit + nagłówkami / HTML-only / quoted-chain). + +Odrzucona alternatywa: rozszerzanie `documents-ingest` o adapter mailowy +(rozrost joba o dwóch tożsamościach); copy-paste chunkera (dryf dwóch +implementacji — dokładnie to, czego faza 4 zabroniła dla retrievalu). + +### Decyzja 8 — Wykonanie: job na SOLARII, archiwum rsync-owane, zapis do PIHA + +**Rekomendacja: jednorazowy rsync archiwum PIHA→SOLARIA (27 GB po LAN ~5 min, +SOLARIA ma 2 TB NVMe), job biegnie na SOLARII: parse na 24 rdzeniach, Ollama +na localhost, zapis do kb-postgres@PIHA (`--dsn …@piha:5433`, batched +`executemany` po 500 — wzorzec `_insert_batch`).** + +Uzasadnienie: + +- PIHA (Pi-klasa, 8 GB RAM, dźwiga HA + Paperless + kb-postgres) nie jest + miejscem na godziny parsowania MIME 225k plików; SOLARIA i tak musi być + włączona (GPU). Embed z localhost eliminuje 271k×(round-trip Tailscale). +- Archiwum jest append-only → kopia jest spójnym snapshotem; lista roboczą + i tak wyznacza DB (`SELECT … FROM envelope WHERE source='gmail'`), nie + filesystem. `missing_file` w bilansie łapie ewentualny dryf kopii. +- Zapis do PIHA przez Tailscale batchami — dokładnie tak dziś działa + `chunk_embed` na SOLARII (kierunek przećwiczony). + +Odrzucona alternatywa: NFS PIHA→SOLARIA (kruche przy 225k małych plików, +nic nie daje vs snapshot); run na PIHA z batch embedem przez Tailscale +(wykonalne — batch amortyzuje sieć — ale obciąża najbardziej krytyczny node +floty na godziny). + +### Decyzja 9 — Etapowanie: najpierw ostatnie 12 miesięcy, bramka, potem reszta + +**Rekomendacja: Etap A = `--since 2025-07-01` (13 712 maili → ~25–30k chunków, +z czego po filtrze newsletterów ~10–12k aktywnych; embed <15 min) → bramka +jakościowa (§8) + ręczna ocena operatora w kb-query → Etap B = pełne archiwum.** + +Uzasadnienie: + +- Ostatni rok to dane, o które operator realnie pyta; tanio weryfikuje + cały łańcuch (quote-strip, prefiks, hybrydowy retrieval) zanim spalimy + godziny GPU i ~5 GB bazy na dekady archiwum. +- Uwaga kalibracyjna: w dekadzie 202x aż 64% maili ma sygnał newslettera — + Etap A od razu pokaże, czy heurystyka nie tnie za szeroko (bilans per + kategoria + przegląd próbki flagowanych). +- Idempotencja sprawia, że Etap B to po prostu ten sam run bez `--since` — + chunki Etapu A zostaną policzone jako `already_embedded`. + +Odrzucona alternatywa: pełny run od razu (ryzyko odkrycia złej heurystyki po +496k chunkach); etapowanie po `--offset` (kolejność po id ≠ kolejność +merytoryczna; `ts` jest indeksowane i czytelne). + +### Decyzja 10 — Threading przy okazji (odpowiedź na „brak połączeń"): TAK, tanio + +**Rekomendacja: podczas i tak wykonywanego MIME-walku dopisać do kopert +`entities[type=threading]` z `{in_reply_to, references[]}` — wzorzec +idempotentnego appendu 1:1 z `gmail-header-backfill` (`WHERE NOT EXISTS +type='threading'`).** + +Uzasadnienie: dziś NIC nie łączy maili w wątki (In-Reply-To/References nie są +nigdzie zapisane — zweryfikowane grepem). To najtańszy krok w kierunku +„połączeń" z feedbacku operatora: drugi odczyt 27 GB tylko po to byłby +marnotrawstwem. Sama nawigacja po wątkach / graf to osobna przyszła praca +(§11) — tu tylko zapisujemy surowiec. + +Odrzucona alternatywa: osobny backfill później (drugi pełny odczyt archiwum); +pominięcie (strata jedynej okazji taniego zbioru danych). + +--- + +## 3. Krok 0 — wydzielenie chunkera do `packages/kb-mail` + +- `packages/kb-mail/src/kb_mail/chunking.py`: przeniesione 1:1 `chunk_text`, + `hard_split` + stałe (2400/600) z `documents_ingest/chunk_embed.py`; + chunk_embed importuje z pakietu (zero zmian zachowania, testy chunkera + przeniesione + smoke że documents-ingest nadal przechodzi pytest). +- Przy okazji NIE ruszamy `is_ocr_junk` ani klienta embed (Krok 1 osobno). + +**Szacunek: 0,5 sesji.** + +## 4. Krok 1 — `embed_batch` w `packages/kb-retrieval` + +- `embed.py`: `embed_batch(session, base_url, model, texts: list[str])` na + `POST /api/embed` (`{"model": …, "input": [...]}`), zwraca listę wektorów + + czas; walidacja: `len(embeddings) == len(texts)`, każdy wymiar == 1024 + (w przeciwnym razie wyjątek klasy abort-run, jak `EmbeddingDimensionError`). +- `embed_chunk` (pojedynczy, `/api/embeddings`) zostaje nietknięty — kb-query + i cyclic ingest bez zmian. +- Testy: mock aiohttp (kształt odpowiedzi `/api/embed`), mismatch długości, + zły wymiar. + +**Szacunek: 0,5 sesji.** + +## 5. Krok 2 — job `jobs/mail-body-ingest/` + +Pipeline per koperta (`SELECT id, ts, raw_ref, entities FROM envelope WHERE +source='gmail' [AND ts >= --since] ORDER BY id`, `--limit/--offset`): + +1. **Read**: `archive_root / raw_ref` (`--archive-root`, default snapshotu na + SOLARII); `missing_file` / `read_error` jak w backfillu. +2. **Parse**: typed parse + fallback compat32 (wzorzec i testy z backfillu); + body = inline text/plain, a gdy brak — HTML→tekst (`HTMLParser` stdlib); + charset `errors="replace"` + `sanitize_surrogates`. +3. **Quote-strip** (Decyzja 2) z licznikiem `quoted_chars_stripped`. +4. **Klasyfikacja**: `newsletter` (nagłówki List-*/Precedence z tego samego + parse'u); `body_empty` (po strippingu) → koperta liczona, zero chunków. +5. **Prefiks kontekstu** (Decyzja 3) z `entities[type=headers]`. +6. **Chunk**: `kb_mail.chunking.chunk_text` (2400/600). +7. **Embed**: tylko chunki nie-newsletter, `embed_batch` po 64 (batch może + sklejać chunki wielu kopert — pętla buforuje do 64 i flushuje). +8. **Insert**: `INSERT … ON CONFLICT (envelope_id, chunk_index, model) DO + NOTHING`, batched po 500; newsletter → `excluded_reason='newsletter'`, + `embedding=NULL`; model = `bge-m3`. Pre-fetch kluczy Z modelem (naprawa + warta z §1.5). +9. **Threading append** (Decyzja 10): `entities || [{"type":"threading",…}]` + z podwójną idempotencją (client-side check + `WHERE NOT EXISTS`). + +Bilans (inwariant, exit 1 przy niedomknięciu): + +``` +mails_scanned = parse_errors + read_errors + missing_file + body_empty + mails_chunked +chunks_total = chunks_inserted + chunks_newsletter_flagged + + chunks_already_embedded + chunks_conflict_skipped + chunks_errors +``` + +CLI: `--dsn/KB_DSN`, `--archive-root`, `--ollama-url`, `--model`, `--since`, +`--limit`, `--offset`, `--batch-size` (64), `--apply` (dry-run domyślnie: +parse+chunk+count, zero Ollamy, zero writes). Log ZAWSZE do pliku. + +Testy (`jobs/mail-body-ingest/tests/`): fixture'y .eml — 8-bit nagłówki, +HTML-only, quoted-chain (gmail/outlook/polskie markery), newsletter +(List-Unsubscribe), pusty body, multipart z załącznikiem; testy bilansu +i idempotencji (drugi przebieg = zero insertów). Definition of Done z CLAUDE.md +(build/smoke + pytest przed commitem). + +**Szacunek: 2 sesje.** + +## 6. Krok 3 — tryb hybrydowy w `kb-retrieval` + `kb-query` + +- `retrieval.py`: `hybrid_retrieve(conn, query_vec, n, k, summary_model, + summaryless_sources: list[str])` — stage 1+2 jak w kaskadzie, PLUS + równoległe zapytanie: chunki kopert źródeł z `summaryless_sources` + (`JOIN envelope … WHERE source = ANY($…)`), merge po `dist`, top-k; + `hybrid_query` analogicznie do `cascade_query` (jeden embed zapytania). +- `kb-query`: `mode` pattern `^(cascade|flat|hybrid)$`; **domyślny `mode` + przełączany na `hybrid` dopiero po PASS bramki (§8)** — do tego czasu + hybrid dostępny jawnie. Wyniki gmail w UI już obsłużone (faza 4: subject/from + z headers + „Kopiuj Message-ID"). +- Testy jednostkowe na mockach (merge, pusta gałąź summary, pusta gałąź mail). + +**Szacunek: 1 sesja.** + +## 7. Krok 4 — przygotowanie SOLARII + Etap A (ostatnie 12 miesięcy) + +- rsync archiwum PIHA→SOLARIA (`rsync -a --info=stats` po LAN; ~27 GB). +- Run Etapu A: `--since 2025-07-01 --apply` z logiem do pliku; weryfikacja + bilansu; kalibracja: przegląd ~20 flagowanych newsletterów i ~20 maili po + quote-strip (czy heurystyki nie tną za szeroko), `avg_embed_ms_per_chunk` + vs benchmark (§ Decyzja 5). +- Weryfikacyjne SQL po runie (przykład): + +```sql +-- ile kopert gmail ma chunki, w podziale na status +SELECT c.excluded_reason, count(*) chunks, count(DISTINCT c.envelope_id) mails +FROM document_chunk c JOIN envelope e ON e.id = c.envelope_id +WHERE e.source = 'gmail' GROUP BY 1; +``` + +**Szacunek: 1 sesja (w tym czas runu <1 h).** + +## 8. Krok 5 — bramka jakościowa fazy mailowej + +Rozszerzenie `jobs/documents-ingest/eval/queries.yaml` + `retrieval_eval.py`: + +- **Operator dostarcza 3–5 zapytań mailowych** (rzeczy, o których wie, że ma je + w mailach z ostatniego roku — Etap A) → nowe wpisy `kind: hit` z + `expected_envelope: ""` (uwaga: envelope_id gmail = surowy + Message-ID bez prefiksu source — inaczej niż `paperless:N`). +- `retrieval_eval.py` uczy się trybu `hybrid` (trzecia ścieżka obok flat/cascade). +- Kryteria PASS (wszystkie trzy tory na żywej bazie po Etapie A): + 1. **Regresja zero**: istniejące 7 zapytań paperless — żaden hit (<0,45) + nie degraduje we flat ani w hybrid po dolaniu chunków mailowych + (to mierzy realne ryzyko tej fazy: nowa masa wektorów konkuruje w HNSW). + 2. Zapytania mailowe: hit@3 w trybie hybrid dla ≥ 4/5 (lub 3/3–4/4 przy + mniejszej liczbie), top1 dist < 0,45 dla większości. + 3. Kontrole negatywne (sernik, piaskownica) > 0,55 we wszystkich trybach. +- PASS ⇒ `kb-query` przełącza domyślny `mode` na `hybrid` + zapis wyników + w tym dokumencie (tabela `| Kryterium | Wynik | Werdykt |`). +- FAIL ⇒ diagnoza przed Etapem B (podejrzani wg kolejności: zbyt szeroki + quote-strip, brak prefiksu w praktyce, `hnsw.ef_search` do podbicia). + +**Szacunek: 1 sesja (+ wejście od operatora).** + +## 9. Krok 6 — Etap B: pełne archiwum + +- Ten sam job bez `--since`, `--apply`, log do pliku, `nice`/`ionice` na I/O. + Oczekiwane: ~496k chunków total (~271k embedowanych; 1–2 h GPU + parse), + Etap A policzony jako `already_embedded`. +- Po runie: weryfikacyjne SQL (j.w.), `VACUUM ANALYZE document_chunk`, + ponowny przebieg `retrieval_eval.py` (pełna regresja na 100% korpusu — + dystrybucja starych dekad może przesunąć sąsiedztwa HNSW). +- Obserwacja PIHA: rozmiar bazy (oczekiwane ~5–7 GB, dysk 144 GB — zapas + >20×), latencja `/search` w kb-query, RAM kb-postgres (`mem_limit: 1g`, + `shared_buffers=256MB`). **Świadomie bez prewencyjnego podnoszenia limitów** + — HNSW jest log-scale i 271k wektorów to wciąż mało; jeśli latencja + zapytań zauważalnie wzrośnie, osobna mikro-decyzja o `mem_limit` 1g→1,5g + (PIHA ma ~3,8 GB luzu, ale dzieli go z HA). REINDEX HNSW nie jest w planie + (inserty inkrementalne); gdyby kiedyś był potrzebny — wymaga sesyjnego + podbicia `maintenance_work_mem` (64 MB nie pomieści grafu ~1,1 GB) i godzin, + co odnotowuję jako znany koszt odroczony. + +**Szacunek: 1 sesja (run w tle).** + +## 10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon) + +Zakotwiczone w kb-00 jako etapy 3–4 (`jobs/fastmail-poller`, +`jobs/gmail-imap-poller`). Zarys decyzji do tamtego reconu: + +- **Poll, nie IDLE**: wzorzec systemd-timer jak `kb-ingest` (offline-tolerancja, + brak długotrwałych połączeń); świeżość „raz na godzinę/dobę" wystarcza KB. +- **Fastmail przez JMAP** (natywne, stronicowanie po `state`), `source='fastmail'`; + Gmail przez IMAP (OAuth2 lub app-password — do rozstrzygnięcia tam). +- Reuse wprost: `kb_mail.save_eml` (append-only, `FileExistsError`=skip) + + `insert_envelope` (`ON CONFLICT DO NOTHING`, dedup po Message-ID jak + w bulk-imporcie) + hardened parse + **pipeline body z Kroku 2 jako biblioteka** + (przyrostówka od pierwszego dnia pisze chunki, nie tylko koperty). +- Otwarte tam: sekrety (konta IMAP) w `/opt/homelab/config/`, częstotliwość, + koperty fastmail sprzed epoki gmaila. + +**Szacunek: sam recon 1 sesja; poza kryterium ukończenia tej fazy.** + +## 11. Poza zakresem fazy mailowej + +| Temat | Gdzie zakotwiczone | Kiedy | +|---|---|---| +| Drive Takeout | backlog KB | osobny moduł | +| Backfill 70k załączników PDF → Paperless OCR → ingest | `05-documents-ingest.md`, DECYZJE #9 | osobny pakiet (sizing OCR workera) | +| Synteza odpowiedzi / chat | faza 5 | po fazie mailowej | +| `mail_ui_url` (klikalny link do maila w UI) | kb-00 etap 6, pole zarezerwowane w kb-query | z modułem mail-UI | +| Graf wątków / entity_link z `entities[type=threading]` | ta faza tylko zapisuje surowiec (Decyzja 10) | przyszła faza „połączenia" | +| Streszczenia selektywne maili (hybryda Haiku) | Decyzja 6 — odłożona | po ocenie trybu hybrid w praktyce | +| IMAP/JMAP przyrostówka — implementacja | Krok 7 (zarys) | osobny recon + pakiet | + +## 12. Plan implementacji (kolejność = zależności) + +| # | Krok | Zależy od | Szacunek | +|---|---|---|---| +| 0 | Chunker → `packages/kb-mail` | — | 0,5 sesji | +| 1 | `embed_batch` w kb-retrieval | — | 0,5 sesji | +| 2 | Job `mail-body-ingest` | 0, 1 | 2 sesje | +| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | +| 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | +| 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | +| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | +| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | + +**Kryterium ukończenia fazy mailowej:** (a) pełny korpus gmail zchunkowany +(bilans domknięty, `parse_errors` na poziomie pojedynczych sztuk jak +w backfillu), (b) chunki nie-newsletter zembedowane bge-m3 i widoczne w trybie +hybrid, (c) bramka §8 PASS wraz z regresją po Etapie B, (d) `kb-query` +domyślnie odpowiada trybem hybrid na `kb.kapala.org`, (e) koperty gmail mają +`entities[type=threading]`. + +## 13. Szacunki zbiorcze + +- **Dane**: +~496k wierszy `document_chunk` (~271k z embeddingiem, ~225k + flagowanych `newsletter`); baza 250 MB → ~5–7 GB (dysk PIHA: 144 GB wolne, + zapas >20×). HNSW rośnie inkrementalnie przy insertach — bez rebuildu. +- **GPU/czas runów**: Etap A <1 h e2e; Etap B: parse ~0,5–1 h (24 rdzenie) + + embed ~1–1,5 h (batch 64, zmierzone 8–18 ms/chunk) + inserty do PIHA. +- **Koszty zewnętrzne: 0 USD** (bez streszczeń — Decyzja 6). +- **Czas sesyjny**: ~7 sesji (kroki 0–6) + 1 recon przyrostówki. +- **Ryzyka**: (1) regresja istniejących 7 zapytań po dolaniu ćwierci miliona + wektorów — mierzona bramką na Etapie A ZANIM spalimy pełny run; (2) jakość + HTML→tekst i quote-strip — kalibracja na próbce w Kroku 4; (3) niestabilność + Ollama@SOLARIA (3 incydenty/tydzień) — idempotencja + bilans + log do pliku + czynią każdy run wznawialnym; (4) latencja kb-postgres przy 1 GB mem_limit — + obserwacja po Etapie B, decyzja o limicie osobno. + +## 14. Podsumowanie dla Oskara + +Treści Twoich 225 tysięcy maili leżą dziś martwe w 27 GB archiwum na PIHA — +w bazie są tylko nagłówki, a wyszukiwarka z fazy 4 słusznie skarży się „mało +danych". Ten plan wprowadza je do retrievalu w ~7 sesji i za 0 USD: ponowny +przebieg po archiwum sprawdzonym parserem z backfillu, obcięcie łańcuszków +cytowań (co trzeci mail jest nimi zdominowany), tani filtr newsletterów +(40% korpusu, odwracalny — tekst zostaje w bazie), chunki z prefiksem +Temat/Od/Data i batchowy embedding na GPU SOLARII, który zmierzyłem na żywo: +zamiast 16 godzin sekwencyjnie — nieco ponad godzinę batchami po 64. +Streszczeń NIE robimy (Haiku ≈ 550 USD, gemma lokalnie ≈ 2 tygodnie GPU) — +zamiast tego kaskada dostaje tryb hybrydowy, w którym maile konkurują +bezpośrednio chunkami. Etapowanie chroni jakość: najpierw ostatni rok +i bramka (Twoje 3–5 pytań „wiem, że to mam w mailach" + zero regresji na +7 istniejących zapytaniach), dopiero potem dekady archiwum. Po drodze, przy +okazji jedynego pełnego odczytu archiwum, zapisujemy In-Reply-To/References — +surowiec pod „połączenia", których brakowało Ci w POC. Schema jest gotowa +(zweryfikowane na żywej bazie — zero migracji), dysk na PIHA ma zapas +dwudziestokrotny. Nic nie zostało zaimplementowane, zdeployowane ani pobrane +w ramach tego recon — wynik to wyłącznie ten dokument.