Tolerancja pojedynczego nieudanego batcha jest słuszna, przeżycie martwej Ollamy już nie. Parse archiwum jest jednowątkowy i wyprzedza GPU, więc przy Etapie B (~212k kopert bez --since) padnięta Ollama przemieliłaby resztę korpusu z prędkością parse'u, oznaczając każdy chunk jako chunks_errors — bez ani jednego zapisu, ale kosztem ~2 h przebiegu do powtórzenia. Znany tryb awarii Ollama@SOLARIA jest totalny (zniknięcie kontenera / network-detach, 4 incydenty, §1.4/§7), nie częściowy, więc próg z kolejnych porażek trafia w niego od razu. --max-embed-failures N (domyślnie 5, 0 wyłącza) → EmbedBackendUnavailableError i exit 2, odrębny od exit 1 (który pełny korpus osiąga legalnie na pojedynczych parse_errors — §1.5). Licznik zeruje się po udanym batchu, więc kryterium jest "kolejnych", nie "łącznie". Przy abortcie dopychane są zaległe wpisy entities[type=threading]: nie zależą od Ollamy, są idempotentne, a ich odtworzenie oznaczałoby ponowny odczyt tych samych 27 GB. Nowy licznik embed_batch_failures jest wyłącznie diagnostyczny — równania bilansu bez zmian. Plan §9: dopisane decyzje operatora do Etapu B (plastry po 50k, breaker, pominięty dry-run całości, hybrid default poza zakresem) + nota jak czytać exit 1 vs exit 2. Testy: 4 nowe (trip po N kolejnych, reset po sukcesie, 0 wyłącza, flush threadingu przy abortcie); 55 passed mail-body-ingest, 25 passed kb-retrieval. Smoke: --limit 5 dry-run na żywym kb-postgres@PIHA — bilans domknięty, zero zapisów, zero wywołań Ollamy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
705 lines
39 KiB
Markdown
705 lines
39 KiB
Markdown
# 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.
|