homelab-codex-ws/kb/phases/kb-m5-faza-mailowa.md
oskar 0858e25c81 feat(kb): przenosiny type=phase do kb/phases/ (14 plikow, bez SPLIT)
Plany faz KB (modul 5 fazy 2/3/4/mailowa, moduly 0/2/3/4, documents-ingest,
raport fallback-dedup, eval retrieval-pilot) oraz prometheus-cutover-etap2,
subsystem-a-naprawa, okit-cloudflare.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:04 +02:00

714 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-07-23
links: []
---
# Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN)
> Status (2026-07-23): Kroki 0-4 WYKONANE na żywo (chunker wydzielony, hybrid
> retrieval, Etap A apply na żywej bazie), Krok 5 (bramka jakościowa) **PASS**
> — patrz §8 dla liczb i werdyktu. Etap B (pełne archiwum) i Krok 7 (recon
> IMAP/JMAP) wciąż przed nami.
>
> Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone,
> serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez
> HTTP", 2026-07-22, `docs/sessions/2026-07-22.md`). Faza mailowa = odpowiedź na
> feedback operatora z POC wyszukiwarki: **„mało danych, brak połączeń"**.
> 225 030 kopert gmail ma dziś w bazie tylko nagłówki — treści leżą wyłącznie
> w archiwum .eml na PIHA. Ta faza wprowadza treści maili do `document_chunk`
> i udostępnia je w retrievalu. **To nadal wyszukiwarka, nie chat** — synteza,
> Drive Takeout i backfill 70k załączników PDF pozostają poza zakresem (§11).
---
## 1. Stan faktyczny (recon, zweryfikowany w repo i na żywo 2026-07-22)
### 1.1 Baza — co jest, czego nie ma
Live (`docker exec kb-postgres psql` na PIHA):
| Miara | Wartość |
|---|---|
| `envelope` source='gmail' | **225 030** (100% z `entities[type=headers]` po backfillu) |
| `envelope` source='paperless' | 191 |
| `document_chunk` | 2 767 (2 627 aktywnych, 130 `duplicate`, 10 `ocr_junk`; 2 765 z embeddingiem) — **wszystkie paperless** |
| `document_summary` | 317 (162 `claude-haiku-4-5` + 155 `gemma3:12b`) — **wszystkie paperless** |
| Rozmiar bazy `kb` | **250 MB** |
| Dysk PIHA `/home` | 410 GB, wolne **144 GB** |
| RAM PIHA | 8 GB (dostępne ~3,8 GB; obok HA, Paperless, kb-postgres `mem_limit: 1g`) |
**Treści maili NIE ma w bazie.** `gmail-bulk-import` celowo pomijał inline
`text/plain`/`text/html` (`_parse_attachments` robi `continue` na częściach
tekstowych) — do DB trafił tylko manifest załączników, potem backfill dopisał
nagłówki. Ta luka jest dokładnie tym, co faza mailowa wypełnia.
### 1.2 Archiwum .eml — jedyne źródło treści
- `/home/oskar/kb/mail/archive/gmail/YYYY/MM/<sanitized_message_id>.eml`
**225 057 plików, 27 GB** (append-only; 27 plików to duplikaty Message-ID,
które w DB skleiły się przez `ON CONFLICT (id) DO NOTHING`).
- Mapowanie koperta→plik: `envelope.raw_ref` (ścieżka względna) + archive root.
Rejestrem jest sama kolumna — osobnego manifestu nie ma i nie potrzeba.
- Rozkład rozmiarów plików (pełny skan `find -printf '%s'`): p50 = 24 KB,
p90 = 116 KB, p99 = 1,9 MB, średnia 125 KB — ale to rozmiary Z załącznikami
(base64), więc NIE nadają się do szacowania chunków. Stąd pomiar §1.3.
### 1.3 Zmierzony rozkład długości TREŚCI (próbka 600 losowych .eml, parse na PIHA)
Deterministyczna próbka 600 plików, parser stdlib `email` (policy.default z
fallbackiem compat32 — wzorzec z backfillu), HTML→tekst własnym `HTMLParser`,
0 błędów parsowania:
| Miara | Wartość |
|---|---|
| Długość body (znaki): p25 / p50 / p75 / p90 / p99 | 656 / **1 616** / 3 752 / 8 894 / 24 796 |
| Średnia | 3 335 znaków |
| Puste body (attachment-only itp.) | 0,3% |
| HTML-only (bez części text/plain) | **15%** |
| Sygnał newslettera (`List-Unsubscribe` OR `List-Id` OR `Precedence: bulk/list`) | **40% maili, 48,5% wolumenu tekstu** (w dekadzie 202x: 64% maili!) |
| Maile z >30% linii cytowanych (`>`) | **31%** |
**Ekstrapolacja chunków** (600 tok / 150 overlap = 2400/600 znaków, jak
paperless): ~**496k chunków** dla całości, ~**271k** bez newsletterów
(2,02,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`): 20112012 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 **~818 ms/chunk** zamiast 207 ms sekwencyjnie —
**~271k aktywnych chunków ≈ 11,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
~100200 znaków z budżetu 2400 (48%).
- 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 = 818 ms/chunk, ~1122× szybciej niż
obecna ścieżka; 271k chunków ≈ **11,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, ~48 s →
**1121 dni ciągłej pracy GPU**. Nierealne dla całości.
- **Hybryda selektywna** (np. non-newsletter z ostatnich 2 lat, ~2025k maili
≈ 6080 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 → ~2530k chunków,
z czego po filtrze newsletterów ~1012k 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 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
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 35 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/34/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; 12 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 ~57 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 ≈ 2540 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 20102015 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 34 (`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 → ~57 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,51 h (24 rdzenie)
+ embed ~11,5 h (batch 64, zmierzone 818 ms/chunk) + inserty do PIHA.
- **Koszty zewnętrzne: 0 USD** (bez streszczeń Decyzja 6).
- **Czas sesyjny**: ~7 sesji (kroki 06) + 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ść
HTMLtekst 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 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 35 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.