2026-08-04 15:00:33 +02:00
---
okf: "0.1"
type: phase
visibility: private
status: active
2026-08-06 12:42:25 +02:00
updated: 2026-08-06
2026-08-04 15:00:33 +02:00
links: []
---
2026-07-22 18:15:36 +02:00
# Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN)
2026-08-06 12:42:25 +02:00
> Status (2026-08-06): Kroki 0-5 WYKONANE na żywo (chunker wydzielony, hybrid
> retrieval, Etap A apply na żywej bazie, bramka jakościowa **PASS** — patrz §8).
> **Etap B (Krok 6) ZAMKNIĘTY 2026-08-06**: pełny korpus gmail jest zchunkowany
> i zembedowany (389 012 chunków, zero nie-excluded bez wektora) — patrz §9
docs(recon): przyrostowka IMAP gmail + fastmail — Krok 7 fazy mailowej
Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit).
Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura
48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko
dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID.
Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu):
- koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?"
(build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem
- DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie
z /search, bo galaz summaryless filtruje po source
- etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing
na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1)
- envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach
trafia do bazy raz, z source konta ktore wygralo wyscig
Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU),
indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony:
HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest
kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy).
Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen:
- POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko
nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien.
Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600,
czytany przez systemd przed zrzuceniem uprawnien)
- Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje
"unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego
dokumentu po zatwierdzeniu
Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to
subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat
(kb-m5-faza3.md:620 — "pelna wiki po przyrostowce").
Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu.
Nic nie zaimplementowano, nie zdeployowano ani nie pobrano.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:02:23 +02:00
> „Wynik Etapu B". **Krok 7 (recon przyrostówki) WYKONANY 2026-08-06**:
> `kb/audits/mail-sync-2026-08-06.md` — czeka na decyzje operatora (a)-(g),
> w tym rozstrzygnięcie IMAP vs JMAP dla Fastmaila (§10 niżej mówi JMAP,
> recon rekomenduje wspólny IMAP).
2026-07-22 18:15:36 +02:00
>
> 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ń"**.
2026-08-06 12:42:25 +02:00
> W punkcie wyjścia (2026-07-22) 225 030 kopert gmail miało w bazie tylko
> nagłówki — treści leżały wyłącznie w archiwum .eml na PIHA (stan zamknięty
> Etapem B, §9). Ta faza wprowadza treści maili do `document_chunk`
2026-07-22 18:15:36 +02:00
> 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
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).
- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
(weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).
Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
hybrid dostępny jawnie. *(Wykonane 2026-08-06: default = `hybrid` , patrz
DoD (d) w §12.)* Wyniki gmail w UI już obsłużone (faza 4: subject/from
2026-07-22 18:15:36 +02:00
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 ) . * *
fix(eval): mail_hit@3 criterion 4 + N2 threshold, gate PASS after Etap A
Bug: hit_at_3 returned None for kind=mail_hit rows (expected_envelope is
always null for them -- operator supplies query text, not a Message-ID), so
criterion 4 could never count a hit and read 0/5 despite hybrid distances of
0.25-0.42. Fixed with mail_hit_at_3: hit iff the top-3 distinct hybrid
envelopes include a mail-sourced one (envelope.source lookup via
fetch_envelope_sources, since hybrid_retrieve overwrites source to "hybrid"
on merge) under HIT_THRESHOLD. Also added a per-query no_answer_threshold
override in queries.yaml for criterion 3.
N2 ("piaskownica plastikowa") investigation: after Etap A added ~34k mail
chunks, N2's top-1 neighbor dropped to dist 0.5298 (< the 0.55 bar). Content
check showed it's a ski-school reservation newsletter (Rossignol ski sizes)
-- a semantic false-positive collision, not a real corpus match. M5, which
the operator had added assuming a genuine piaskownica mail existed, itself
misses (dist 0.5585) -- confirming there's no such mail in the corpus. M5
dropped; N2's pass bar lowered to 0.50 with a note documenting the collision.
Gate result on the live DB post-Etap A (Etap A: 13 009 mails scanned -> 33 871
new gmail chunks, 6 398 embedded / 27 473 newsletter-flagged, balanced +
idempotent on rerun; Ollama incident #4 during the run required a compose
force-recreate, not just restart -- root-cause task ollama-solaria-start-race
stays in backlog): all 4 criteria PASS (5/5 flat hits held, cascade hit@3 5/5
vs flat 4/5, negative controls above their bars, mail hit@3 4/4 after
dropping M5). Plan doc updated with the numbers and verdict table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:17:16 +02:00
### 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.
2026-07-22 18:15:36 +02:00
## 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).**
fix(eval): mail_hit@3 criterion 4 + N2 threshold, gate PASS after Etap A
Bug: hit_at_3 returned None for kind=mail_hit rows (expected_envelope is
always null for them -- operator supplies query text, not a Message-ID), so
criterion 4 could never count a hit and read 0/5 despite hybrid distances of
0.25-0.42. Fixed with mail_hit_at_3: hit iff the top-3 distinct hybrid
envelopes include a mail-sourced one (envelope.source lookup via
fetch_envelope_sources, since hybrid_retrieve overwrites source to "hybrid"
on merge) under HIT_THRESHOLD. Also added a per-query no_answer_threshold
override in queries.yaml for criterion 3.
N2 ("piaskownica plastikowa") investigation: after Etap A added ~34k mail
chunks, N2's top-1 neighbor dropped to dist 0.5298 (< the 0.55 bar). Content
check showed it's a ski-school reservation newsletter (Rossignol ski sizes)
-- a semantic false-positive collision, not a real corpus match. M5, which
the operator had added assuming a genuine piaskownica mail existed, itself
misses (dist 0.5585) -- confirming there's no such mail in the corpus. M5
dropped; N2's pass bar lowered to 0.50 with a note documenting the collision.
Gate result on the live DB post-Etap A (Etap A: 13 009 mails scanned -> 33 871
new gmail chunks, 6 398 embedded / 27 473 newsletter-flagged, balanced +
idempotent on rerun; Ollama incident #4 during the run required a compose
force-recreate, not just restart -- root-cause task ollama-solaria-start-race
stays in backlog): all 4 criteria PASS (5/5 flat hits held, cascade hit@3 5/5
vs flat 4/5, negative controls above their bars, mail hit@3 4/4 after
dropping M5). Plan doc updated with the numbers and verdict table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:17:16 +02:00
### 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,
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).
- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
(weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).
Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
mała zmiana w serwisie, nie w `packages/kb-retrieval` ). **Wykonane 2026-08-06**
po regresji na pełnym korpusie (Etap B) — szczegóły przy DoD (d) w §12.
fix(eval): mail_hit@3 criterion 4 + N2 threshold, gate PASS after Etap A
Bug: hit_at_3 returned None for kind=mail_hit rows (expected_envelope is
always null for them -- operator supplies query text, not a Message-ID), so
criterion 4 could never count a hit and read 0/5 despite hybrid distances of
0.25-0.42. Fixed with mail_hit_at_3: hit iff the top-3 distinct hybrid
envelopes include a mail-sourced one (envelope.source lookup via
fetch_envelope_sources, since hybrid_retrieve overwrites source to "hybrid"
on merge) under HIT_THRESHOLD. Also added a per-query no_answer_threshold
override in queries.yaml for criterion 3.
N2 ("piaskownica plastikowa") investigation: after Etap A added ~34k mail
chunks, N2's top-1 neighbor dropped to dist 0.5298 (< the 0.55 bar). Content
check showed it's a ski-school reservation newsletter (Rossignol ski sizes)
-- a semantic false-positive collision, not a real corpus match. M5, which
the operator had added assuming a genuine piaskownica mail existed, itself
misses (dist 0.5585) -- confirming there's no such mail in the corpus. M5
dropped; N2's pass bar lowered to 0.50 with a note documenting the collision.
Gate result on the live DB post-Etap A (Etap A: 13 009 mails scanned -> 33 871
new gmail chunks, 6 398 embedded / 27 473 newsletter-flagged, balanced +
idempotent on rerun; Ollama incident #4 during the run required a compose
force-recreate, not just restart -- root-cause task ollama-solaria-start-race
stays in backlog): all 4 criteria PASS (5/5 flat hits held, cascade hit@3 5/5
vs flat 4/5, negative controls above their bars, mail hit@3 4/4 after
dropping M5). Plan doc updated with the numbers and verdict table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:17:16 +02:00
2026-07-22 18:15:36 +02:00
## 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).**
feat(mail-body-ingest): circuit breaker na martwy backend embed przed Etapem B
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>
2026-08-04 16:43:49 +02:00
### 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
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).
- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
(weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).
Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
Etapu B**, osobny task po PASS regresji. *(Wykonany 2026-08-06 — DoD (d) w §12.)*
feat(mail-body-ingest): circuit breaker na martwy backend embed przed Etapem B
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>
2026-08-04 16:43:49 +02:00
feat(kb-mail-batching): retry + izolacja trujacego chunka w torze embed + benchmark
Batching /api/embed juz istnial (Krok 1 fazy mailowej, batch 64). Recon przed
Etapem B wykazal w torze backfillu blad blokujacy i dwie luki.
BUG (blokujacy dla Etapu B): flush_embed_buffer lapal wylacznie
aiohttp.ClientError, a wyczerpanie ClientTimeout(total=...) rzuca goly
builtins.TimeoutError, ktory NIE jest jego podklasa (zweryfikowane empirycznie
na aiohttp 3.14.3). Zawieszona Ollama — czyli jej udokumentowany failure mode,
"przyjmuje polaczenie i milczy" — wywalala caly run nieobsluzonym wyjatkiem,
bez breakera i bez flushu threadingu. Na plastrze 50k = utrata zarobionej pracy.
Klasy przejsciowe nazwane teraz jawnie w TRANSIENT_EMBED_ERRORS.
kb-retrieval:
- embed_batch(timeout_s=...) — bound per zadanie, skalowalny z batch size
- embed_batch_resilient() — retry z backoffem wykladniczym, a po ich wyczerpaniu
probe /api/tags rozstrzyga: backend zywy -> bisekcja izolujaca trujacy chunk
(jeden zly tekst kosztowal caly batch 64, bo /api/embed jest all-or-nothing);
backend martwy -> natychmiastowe gave_up bez bisekcji, ktora spalilaby 2n-1
zadan i opoznila breaker. EmbeddingDimensionError nigdy nie jest retry'owane.
- failed_indices wyprowadzane z wyniku, nie akumulowane per span — przy gave_up
w srodku bisekcji porzucone poddrzewo nigdy nie dochodzi do liscia.
mail-body-ingest:
- breaker liczy give-upy (backend padl), nie dowolne nieudane batche; porazka
czesciowa przy zywym backendzie nie przesuwa licznika, bo te chunki i tak
zlapie kolejny run przez idempotencje
- wiersze zembedowane w umierajacym batchu sa commitowane przed abortem
- parametryzacja: --batch-size/--embed-retries/--embed-backoff/--embed-timeout,
kazdy z odpowiednikiem env MAIL_INGEST_*; bledna wartosc env = glosny SystemExit
- metryka embed_ms_per_chunk (porownywalna miedzy runami, w odroznieniu od
sredniej per batch) + embed_requests_total/embed_calls jako sygnal zdrowia
mail-body-ingest-bench: nowy entry point, sweep batch size na realnych chunkach.
Read-only (SELECT + inferencja, zero sciezki zapisu), warmup przed pomiarem, ten
sam zbior chunkow dla kazdego rozmiaru. Czyni liczby z planu §1.4 odtwarzalnymi.
Fallback SOLARIA->PIHA dla backfillu SWIADOMIE nie powstaje (potwierdzone przez
operatora): 271k chunkow x 790 ms CPU ~ 60 h na 8 GB PIHA dzielonym z HA i
Paperlessem. Wlasciwa odpowiedzia na martwy backend jest exit 2 i wznowienie
plastra. Tor online (kb-query -> embed_router) zachowuje fallback — rozdzial
torow udokumentowany w docstringu embed.py i w kb/services/.
Testy: 117 zielonych (62 job + 22 klient embed + reszta pakietow), w tym
regresja na TimeoutError, bisekcja, ograniczony koszt przy martwym backendzie
i porazka czesciowa nieprzesuwajaca breakera. Bez uruchamiania backfillu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:02:41 +02:00
### Hardening toru embed przed Etapem B (2026-08-05)
Recon pod kątem batchingu potwierdził, że batch `/api/embed` (Krok 1) działa zgodnie
z §1.4 — ale wykazał w torze backfillu **błąd blokujący dla Etapu B** i dwie luki:
1. **BUG (naprawiony)** : `flush_embed_buffer` łapał wyłącznie `aiohttp.ClientError` , a
wyczerpanie `ClientTimeout(total=...)` rzuca goły `builtins.TimeoutError` , który **nie**
jest jego podklasą (zweryfikowane empirycznie na aiohttp 3.14.3). Zawieszona Ollama —
czyli dokładnie jej udokumentowany failure mode, „przyjmuje połączenie i milczy", nie
„odmawia" — wywalała cały run nieobsłużonym wyjątkiem: **bez breakera i bez flushu
threadingu**. Na plastrze 50k oznaczało to utratę już zarobionej pracy. Klasy przejściowe
nazwane teraz jawnie w `kb_retrieval.embed.TRANSIENT_EMBED_ERRORS` .
2. **Brak retry** — jeden blip sieciowy spisywał na straty cały batch (64 chunki). Dodane:
`--embed-retries` (default 2) z backoffem wykładniczym.
3. **Brak obsługi błędu częściowego** — `/api/embed` jest all-or-nothing, więc jeden trujący
chunk zabijał batch w kółko i mógł wywalić breaker przy **żywym** backendzie. Dodana
bisekcja po nieudanych retry, ale tylko gdy `/api/tags` potwierdza, że backend żyje;
przy martwym batch od razu „gives up" (bisekcja martwego backendu kosztowałaby 2n-1
żądań i opóźniała breaker). Porażka częściowa **nie** przesuwa już breakera.
Decyzja 2 (circuit breaker) obowiązuje w zaostrzonej formie: licznik liczy **give-upy**
(backend padł), nie dowolne nieudane batche. Fallback SOLARIA→PIHA dla backfillu **świadomie
nie powstaje** — 271k chunków × 790 ms CPU ≈ 60 h na 8 GB PIHA dzielonym z HA i Paperlessem;
właściwą odpowiedzią na martwy backend jest exit 2 i wznowienie plastra. Tor online (`kb-query`
→ `embed_router` ) ma fallback i tak zostaje — te dwie ścieżki są rozdzielone celowo.
Doszedł też `mail-body-ingest-bench` — sweep batch size na realnych chunkach (read-only),
żeby liczby z §1.4 dało się odtworzyć po zmianie GPU albo wersji Ollamy.
2026-08-06 12:42:25 +02:00
### Wynik Etapu B (ZAMKNIĘTY, 2026-08-06)
Pełny korpus gmail jest zchunkowany i zembedowany: **389 012 chunków**
`document_chunk` , z czego **0 nie-excluded bez embeddingu** — weryfikacja
przebiegła idempotentnymi plastrami 0-4 (`--offset 0/50k/100k/150k/200k
--limit 50000 --batch-size 64`, wszystkie EXIT 0) plus fix bajtu NUL (`4ec0b78`).
Cross-tab na żywej bazie (kb-postgres@PIHA):
| Miara | Wartość |
|---|---|
| `document_chunk` total | **389 012** |
| nie-excluded **bez** embeddingu | **0** |
| nie-excluded z wektorem | 187 025 |
| `newsletter` -flagged bez wektora | 201 849 (odwracalne, Decyzja 4) |
| excluded **z** wektorem | 138 (artefakt kolejności flagowania, nieszkodliwy) |
**Korpus był w pełni zembedowany jeszcze przed plastrami z 2026-08-06** —
zapamiętany stan „6,4k embedded z Etapu A, ~225k kopert do backfillu" był
nieaktualny, wcześniejsze przebiegi pokryły całość. Dzisiejsze runy to pełna,
idempotentna weryfikacja. Źródło mylącego odczytu: licznik
`chunks_already_embedded` liczy **istnienie wiersza w DB** (w tym chunków
newsletter-flagged bez wektora), a nie obecność wektora.
**Bug NUL (naprawiony, `4ec0b78` )**: bajt `0x00` w treści maili z 2007 (Sony
Ericsson, 3 chunki, plaster offset 50k) wywalał insert
(`asyncpg.CharacterNotInRepertoireError` — PostgreSQL nie przyjmuje `0x00`
w `text` ). Fix: strip `\x00` przed chunkowaniem i embedem + liczniki
`nul_bytes_stripped` / `mails_nul_sanitized` . Re-run plastra 1: EXIT 0,
3 chunki dobrane. Znany follow-up (osobny task): `jobs/gmail-header-backfill`
i `jobs/gmail-bulk-import` mają tę samą latentną podatność na NUL w nagłówkach
zapisywanych do `jsonb` .
Szczegóły runu: `docs/sessions/2026-08-06-kb-etapb-backfill.md` .
feat(mail-body-ingest): circuit breaker na martwy backend embed przed Etapem B
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>
2026-08-04 16:43:49 +02:00
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.
2026-07-22 18:15:36 +02:00
## 10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon)
docs(recon): przyrostowka IMAP gmail + fastmail — Krok 7 fazy mailowej
Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit).
Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura
48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko
dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID.
Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu):
- koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?"
(build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem
- DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie
z /search, bo galaz summaryless filtruje po source
- etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing
na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1)
- envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach
trafia do bazy raz, z source konta ktore wygralo wyscig
Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU),
indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony:
HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest
kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy).
Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen:
- POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko
nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien.
Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600,
czytany przez systemd przed zrzuceniem uprawnien)
- Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje
"unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego
dokumentu po zatwierdzeniu
Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to
subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat
(kb-m5-faza3.md:620 — "pelna wiki po przyrostowce").
Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu.
Nic nie zaimplementowano, nie zdeployowano ani nie pobrano.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:02:23 +02:00
> **Recon wykonany 2026-08-06: `kb/audits/mail-sync-2026-08-06.md`.** Zarys
> poniżej pochodzi z 2026-07-22 i zachowuję go jako zapis intencji. Recon
> rozstrzyga inaczej dwa jego punkty: (1) **Fastmail przez IMAP, nie JMAP**
> (unifikacja adaptera — jeden `jobs/mail-imap-sync` zamiast
> `fastmail-poller` + `gmail-imap-poller`), (2) spoiwem z torem body nie jest
> `--since`, tylko kolejka „koperty bez chunków". Reszta zarysu (poll zamiast
> IDLE, reuse `save_eml`/`insert_envelope`, sekrety w `/opt/homelab/config/`)
> się potwierdziła.
2026-07-22 18:15:36 +02:00
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)
2026-08-04 15:15:35 +02:00
| # | 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` |
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).
- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
(weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).
Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
| 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). Domyślny `mode` przełączony na `hybrid` 2026-08-06 (follow-up z §8, DoD (d) niżej) |
2026-08-04 15:15:35 +02:00
| 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`) |
2026-08-06 12:42:25 +02:00
| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78` ; `docs/sessions/2026-08-06-kb-etapb-backfill.md` |
docs(recon): przyrostowka IMAP gmail + fastmail — Krok 7 fazy mailowej
Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit).
Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura
48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko
dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID.
Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu):
- koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?"
(build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem
- DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie
z /search, bo galaz summaryless filtruje po source
- etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing
na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1)
- envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach
trafia do bazy raz, z source konta ktore wygralo wyscig
Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU),
indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony:
HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest
kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy).
Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen:
- POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko
nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien.
Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600,
czytany przez systemd przed zrzuceniem uprawnien)
- Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje
"unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego
dokumentu po zatwierdzeniu
Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to
subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat
(kb-m5-faza3.md:620 — "pelna wiki po przyrostowce").
Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu.
Nic nie zaimplementowano, nie zdeployowano ani nie pobrano.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:02:23 +02:00
| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **WYKONANE** (2026-08-06) | `kb/audits/mail-sync-2026-08-06.md` . Ustalenia: korpus urywa się 2026-06-19 (dziura 48 dni ≈ 1 800 maili), zero kodu IMAP w repo, brak modelu stanu synca; cały nowy kod to jeden `jobs/mail-imap-sync` + 4 drobne zmiany w istniejącym torze. Do decyzji operatora: (a)-(g) w §4 tamtego dokumentu |
2026-07-22 18:15:36 +02:00
**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]` .
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).
- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
(weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).
Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
**(d) SPEŁNIONE w repo 2026-08-06** — domyślny `mode` w `/search` przełączony
`cascade` → `hybrid` (`services/kb-query/app/main.py`, walidator `Query` ;
frontend przestał wysyłać `mode` przy odznaczonym „tryb flat", więc UI
dziedziczy domyślny tryb API). Podstawa: eval na **pełnym** korpusie
(187 025 zembedowanych chunków mailowych w HNSW, §9) —
kryterium 1 (regresja paperless) **PASS** : żaden istniejący hit nie
zdegradował ani we flat, ani w hybrid; mailowe **hit@3 = 5/5** ; koszt
hybrydy to jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
`eval-http-2026-08-06.json` i `eval-direct-2026-08-06.json`
w `~/kb/mail/ingest-logs` na PIHA (celowo niecommitowane — artefakt runu).
Jawne `?mode=cascade` / `?mode=flat` działają bez zmian.
**Deploy na PIHA robi operator z mastera po mergu** — do tego czasu
`kb.kapala.org` nadal odpowiada kaskadą.
2026-07-22 18:15:36 +02:00
## 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.
2026-08-06 12:42:25 +02:00
**Wykonanie (2026-08-06, §9): 389 012 wierszy — 187 025 z embeddingiem,
201 849 flagowanych `newsletter` .** Mniej niż ekstrapolacja z §1.3, bo
quote-strip (Decyzja 2) realnie ucina objętość, co §1.3 zapowiadał.
2026-07-22 18:15:36 +02:00
- **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
2026-08-06 12:42:25 +02:00
> **Uwaga (2026-08-06):** poniższe to podsumowanie z chwili reconu (2026-07-22),
> zachowane jako zapis intencji. Plan został wykonany — treści 225k maili są
> w bazie i w retrievalu (§9 „Wynik Etapu B"); otwarty jest już tylko Krok 7
> (przyrostówka IMAP/JMAP).
2026-07-22 18:15:36 +02:00
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.