---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-07-23
links: []
---

# Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN)

> Status (2026-07-23): Kroki 0-4 WYKONANE na żywo (chunker wydzielony, hybrid
> retrieval, Etap A apply na żywej bazie), Krok 5 (bramka jakościowa) **PASS**
> — patrz §8 dla liczb i werdyktu. Etap B (pełne archiwum) i Krok 7 (recon
> IMAP/JMAP) wciąż przed nami.
>
> 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/<sanitized_message_id>.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).**

### Wynik Etapu A (WYKONANE na żywo, 2026-07-23)

`--since 2025-07-01 --apply` odpalony ręcznie przez operatora na SOLARII →
PIHA:

| Miara | Wartość |
|---|---|
| `mails_scanned` | 13 009 |
| `document_chunk` (nowe, gmail) | 33 871 |
| — z embeddingiem (bge-m3) | 6 398 |
| — `excluded_reason='newsletter'` (bez embeddingu) | 27 473 |

Bilans domknięty (`run_complete`), drugi przebieg tego samego runu —
idempotentny (zero nowych insertów, wszystko `chunks_already_embedded` /
`chunks_conflict_skipped`). Newsletter-udział w tym wycinku (~81% chunków)
wyższy niż ekstrapolacja z §1.3 (48,5% wolumenu tekstu) — spodziewane, bo
Etap A to najświeższy rok, a §1.3 już to sygnalizował („w dekadzie 202x aż
64% maili ma sygnał newslettera").

**Incydent Ollama #4** (w trakcie runu): kontener Ollama@SOLARIA padł
w trakcie embedowania — ten sam wzorzec co `solaria-ollama-network-incident`
(3 wcześniejsze incydenty w tydzień, §1.4/§1.5) — `docker start`/`restart`
nie przywrócił sieci kontenera, wymagane było `compose down` + `up` (force
recreate). Run wznowiony bez utraty danych dzięki idempotencji
(pre-fetch kluczy + `ON CONFLICT DO NOTHING`) — dokładnie po to ten wzorzec
jest w §1.5 obowiązkowy. Task `ollama-solaria-start-race` (backlog) czeka na
naprawę korzenia — Ollama nie powinna wymagać ręcznej interwencji przy
starcie/restarcie.

## 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: "<message-id>"` (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).**

### Wynik bramki (WYKONANE, 2026-07-23) — na żywej bazie po Etapie A

Operator dopisał 5 zapytań mailowych do `mail_queries` w `queries.yaml`
(M1-M4 zostały, M5 odrzucone — patrz niżej). Pierwszy przebieg bramki ujawnił
bug w `retrieval_eval.py`: `hit_at_3` zwracał `None` dla `kind: mail_hit`,
bo te zapytania mają `expected_envelope: null` (operator dał treść zapytania,
nie Message-ID) — kryterium 4 liczyło to jako brak trafienia zamiast
sprawdzać właściwą semantykę. Naprawa: nowa funkcja `mail_hit_at_3` (hit iff
top-3 hybrid zawiera wynik z `envelope.source` w `summaryless_sources`, czyli
gmail, z `dist < 0.45`), z lookupem `envelope.source` per top-3 envelope
(`fetch_envelope_sources`, bo `hybrid_retrieve` nadpisuje `source` na
`"hybrid"` przy scalaniu i traci pochodzenie chunku).

Po naprawie, werdykt bramki:

| Kryterium | Wynik | Werdykt |
|---|---|---|
| 1: zero regresji flat hitów (cascade + hybrid) | 5/5 istniejących hitów bez degradacji | PASS |
| 2: hit@3 cascade ≥ flat | cascade 5/5, flat 4/5 | PASS |
| 3: kontrole negatywne > 0,55 (N, N2) | N=0,5983; N2=0,5298 (próg N2 obniżony do 0,50, patrz niżej) | PASS |
| 4: hit@3 hybrid dla mail_queries ≥ 4/5 (po odrzuceniu M5: ≥4/4) | M1-M4 wszystkie hit (dist 0,25-0,42) | PASS |

**OVERALL: PASS.**

**N2 ("piaskownica plastikowa") — znalezisko i decyzja.** Po dolaniu 34k
chunków mailowych top-1 sąsiad N2 spadł do dist 0,5298 (< dawny próg 0,55,
pilot: 0,5533). Weryfikacja treści (top-3 hybrid z pełnym tekstem chunka)
pokazała, że to **kolizja semantyczna, nie realne trafienie**: top-1 to
newsletter szkoły narciarskiej (rozmiary nart Rossignol 155-181cm, dane
kontaktowe instruktora) — zero związku z piaskownicą. Operator wstępnie
dodał M5 ("piaskownica plac zabaw wspólnota") zakładając realny mail na
temat, ale M5 samo nie trafia (dist 0,5585, miss) — korpus mailowy Etapu A
nie zawiera nic o piaskownicy. Decyzja operatora: M5 odrzucone (nie testuje
niczego realnego), próg N2 w bramce obniżony do 0,50 z notą o kolizji
ski-newsletter w `queries.yaml` (żeby ten znany, nieszkodliwy przypadek nie
płonił bramki co uruchomienie).

**`kb-query` domyślny `mode`**: przełączenie na `hybrid` jako follow-up (poza
zakresem tego zamknięcia bramki — `kb-query`'s `mode` param zmiana to osobna,
mała zmiana w serwisie, nie w `packages/kb-retrieval`).

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

### Decyzje operatora do Etapu B (2026-08-04) — przed runem

Recon przed Etapem B (mirror archiwum na SOLARII żyje: 225 057 plików / 27 GB; RTT
SOLARIA→PIHA 0,83 ms; PIHA 140 GB wolne, baza 397 MB; M1 — `NODE_TYPE=lte_node`
na node-agencie SOLARII — zdeployowane, więc kontener Ollamy nie zniknie po
zatrzymaniu) wykazał dwie rzeczy do rozstrzygnięcia. Decyzje:

1. **Run w plastrach po 50k** (`--limit 50000 --offset 0/50k/100k/150k/200k`),
   log per plaster, `nice`/`ionice`. Powód: brak checkpointu (restart = ponowny
   parse od początku listy, ~1 h) + nocne wyłączanie SOLARII. Plaster ≈ 25–40 min.
   Tempo kolejnych plastrów po obserwacji PIHA po pierwszym.
2. **Circuit breaker w jobie: TAK** — `--max-embed-failures` (domyślnie 5),
   abort z kodem wyjścia 2 po N kolejnych nieudanych batchach embed. Powód:
   parse jest jednowątkowy i wyprzedza GPU, więc martwa Ollama (4 incydenty)
   zamieniłaby 2-godzinny przebieg w 200k+ `chunks_errors` bez ani jednego
   zapisu. Licznik zeruje się po udanym batchu.
3. **Dry-run całości pomijamy** — idempotencja i odwracalność flag newsletterowych
   wystarczają; ewentualna kalibracja heurystyki na dekadzie 2010–2015 po fakcie,
   na już zapisanych flagach.
4. Przełączenie domyślnego `mode` kb-query na `hybrid` (DoD (d)) — **poza zakresem
   Etapu B**, osobny task po PASS regresji.

Uwaga do czytania wyników: na pełnym korpusie `exit 1` jest spodziewany
(pojedyncze `parse_errors` — §1.5 dokumentuje ~9 maili na fallbacku compat32).
Werdyktem jest bilans i liczniki w linii `summary`, nie kod wyjścia. `exit 2`
oznacza co innego: backend embed padł, trzeba wznowić plaster po naprawie Ollamy.

## 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 | Stan | Dowód (2026-08-04) |
|---|---|---|---|---|---|
| 0 | Chunker → `packages/kb-mail` | — | 0,5 sesji | **WYKONANE** | `348ce10`; `packages/kb-mail/src/kb_mail/chunking.py` + `tests/test_chunking.py` |
| 1 | `embed_batch` w kb-retrieval | — | 0,5 sesji | **WYKONANE** | `51998fd`; `kb_retrieval/embed.py:61` (`embed_batch`) + `tests/test_embed.py` |
| 2 | Job `mail-body-ingest` | 0, 1 | 2 sesje | **WYKONANE** | `ad0ef40` (job), `a95524c` (README), `fc5c698` (fix html_to_text); `jobs/mail-body-ingest/` + `tests/test_ingest.py` |
| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | **WYKONANE** | `a640cf1`; `kb_retrieval/retrieval.py:112` (`hybrid_retrieve`), `:195` (`hybrid_query`), `kb-query/app/main.py:119` (`mode` pattern). Uwaga: domyślny `mode` to nadal `cascade` — przełączenie to follow-up z §8, nie część Kroku 3 |
| 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 |
| 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) |
| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **OTWARTE** | Brak commitu, brak sekcji z wynikiem w tym dokumencie; żywa baza pokazuje wyłącznie wolumen Etapu A (33 871 chunków gmail vs oczekiwane ~496k), więc run bez `--since` nie był wykonany |
| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **OTWARTE** | Brak `jobs/fastmail-poller` / `jobs/gmail-imap-poller`, brak dokumentu reconu; IMAP/JMAP występuje wyłącznie jako zarys w §10 i w `kb-00-overview.md` |

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