# 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.