# Modul 5, faza 2 — koperta dokumentow + embeddingi + cross-source (RECON + PLAN) > Status: RECON ZAKONCZONY (2026-07-13), architektura DO ZATWIERDZENIA. Zaden kod nie > zostal napisany, zadna migracja nie zostala odpalona, zaden model nie zostal pobrany. > Uzupelnia `05-documents-ingest.md` (faza 1 — ekstrakcja PDF->Paperless, JUZ DZIALA). > > **v2 (2026-07-13, ta sama sesja):** korekta po decyzji Oskara — `correspondent` z > Paperlessa PORZUCONY jako zrodlo cross-source (byl pusty, i tak by byl tylko zgadywany). > Zamiast tego: deterministyczny link mail->zalacznik->dokument (sha256/nazwa pliku), juz > obecny w danych faktury-1. Dodano tez krytyczne odkrycie: koperty mailowe NIE MAJA > naglowkow (from/to/cc/subject) — tylko manifest zalacznikow. Plan rozszerzony o backfill. --- ## 1. Stan faktyczny (zweryfikowany na zywo, read-only) ### 1.1 Filar maili — CZY MA EMBEDDINGI? **NIE.** Sprawdzone bezposrednio w bazie i w kodzie: - `envelope` na kb-postgres@PIHA ma **wylacznie 6 zamrozonych kolumn** (`id, source, ts, geo, raw_ref, entities`) — zero kolumny wektorowej. - `SELECT source, count(*) FROM envelope GROUP BY source` → **jeden wiersz: `gmail`, 225030**. Zaden inny `source` nie istnieje jeszcze w bazie. - `pg_extension` potwierdza `vector 0.8.3` **zainstalowane, ale NIEUZYWANE** — brak jakiejkolwiek tabeli/kolumny typu `vector` w calej bazie. - `grep -r "embed|vector|bge|chunk"` w `jobs/gmail-bulk-import/`, `packages/kb-mail/`, `jobs/documents-ingest/` → **zero trafien**. Indexer opisany w `kb-01-email-design.md` §5 (`parse -> chunk -> embed -> pgvector`) jest **wylacznie speca, nie kodem**. **Wniosek:** to nie jest "reuzyj wzorzec maili" — to jest **pierwszy embedding w calym KB**. Projektujemy schemat/chunking/pipeline od zera, dla dokumentow, ale w sposob ktory mail-indexer (przyszly etap 6 z `kb-01-email-design.md`) bedzie mogl reuzyc bez zmian (ta sama tabela chunkow, ten sam model, ten sam wzorzec joba). ### 1.2 Paperless — dane - **189 dokumentow** (nie 186 — przybylo od czasu briefu; Paperless zyje, liczba rosnie). - `SUM(length(content))` = 4 202 184 znakow. `AVG` = 22 234, `MIN` = 0, `MAX` = 360 466 (114 stron). Co najmniej jeden dokument ma **pusty content** (OCR nie wyprodukowal tekstu — pewnie czysty obraz bez warstwy tekstowej lub blad OCR) — trzeba to obslugiwac (pomijac embed, nie crashowac). - **`documents_correspondent` ma 0 wierszy. `documents_tag` ma 0 wierszy.** Wszystkie 189 dokumentow maja `correspondent_id = NULL`. Paperless auto-detekcja korespondenta/tagow **nie jest skonfigurowana ani wytrenowana** — nie ma dzisiaj ZADNYCH danych do cross-source linku poprzez pole `correspondent`. To odkrycie doprowadzilo do korekty cross-source w v2 — patrz decyzja 4 i §1.9. - Jakosc OCR/tekstu: **niejednolita**. Dokument #1 (polisa WARTA z 2020) ma mojibake w polskich znakach (`integraln¹ czêœæ` zamiast `integralną część`) — najpewniej zle zdekodowana warstwa tekstowa oryginalnego PDF, nie problem samego OCR. Dokument #191 (faktura proforma 2026) ma czyste polskie znaki (`Świdnica`, `wyłącznie`). Nie jest to systemowy problem calego korpusu, ale chunking/embed pipeline musi to tolerowac (nie failowac na dziwnych bajtach) i warto to logowac jako jakosciowy sygnal per-dokument. - **Brak page-break markerow** w polu `content` (sprawdzone: `chr(12)` — form feed — nie wystepuje w tekscie wielostronicowych dokumentow). Nie da sie dzielic po stronach z samego tekstu; `page_count` istnieje jako osobna metadana, ale nie jest wyrownana do offsetow w tekscie. - Losowa probka tytulow ujawnila **szum z fazy-1** (`documents-ingest` sample z zalacznikow maili): `cryptocurrency_terms_conditions`, `crypto_exchange_terms_and_conditions`, dokumenty konkursu robotycznego FLL dziecka. To nie sa dokumenty finansowe/tozsamosciowe — to przypadkowe duze PDFy ktore przeszly filtr rozmiaru (>50KB) w probce fazy-1. Potwierdza potrzebe filtra selektywnosci PRZED embedowaniem (zasada kb-00: archiwizuj/referuj wszystko, **indeksuj selektywnie**). - Paperless OCR: `PAPERLESS_OCR_LANGUAGE=pol+eng`, `PAPERLESS_OCR_LANGUAGES=pol` — polski potwierdzony jako jezyk OCR. ### 1.3 Paperless API - REST/DRF pod `http://192.168.31.5:8210/api/` (LAN, PIHA). Root bez auth zwraca mape endpointow: `documents, correspondents, document_types, tags, storage_paths, custom_fields, mail_accounts, mail_rules, workflows, tasks, users, groups, logs, share_links, config, saved_views`. - **Chronione endpointy wymagaja auth** — `GET /api/documents/` bez tokenu → `401`. Standardowy mechanizm Paperless: `Authorization: Token ` (DRF TokenAuth), token generowany w UI (My Profile) albo `manage.py drf_create_token `. - **Nie ma dzis zadnego tokenu API.** Haslo z `PAPERLESS_ADMIN_PASSWORD` (env, wartosc bootstrapowa) **juz nie dziala** — wedlug `docs/sessions/2026-07-10-paperless-deploy.md` haslo konta `oskar` zostalo zmienione recznie przez `manage.py shell` po skonfigurowaniu OIDC. Nie probowalem zgadywac/resetowac hasla (poza zakresem read-only recon) — **generowanie tokenu API to pierwszy krok implementacji**, nie recon (patrz decyzja #5). - Paginacja: standardowy DRF pattern (`count/next/previous/results`, `?page_size=`) — wedlug dokumentacji Paperless-ngx; nie zweryfikowane bezposrednio (401 zablokowal body), ale to utrwalony, udokumentowany kontrakt API Paperless-ngx, nie zgadywanie. ### 1.4 Model embeddingowy — bge-m3 Decyzja "bge-m3" jest juz **zamknieta w `kb-00-overview.md`** ("multilingual, dlugi kontekst, lepszy pod polski niz multilingual-e5"). Zweryfikowalem, ze to nadal trafny wybor (nie zgadywanie): | Cecha | Wartosc | Zrodlo | |---|---|---| | Parametry | 568M (XLM-RoBERTa base) | ollama.com/library/bge-m3, HF BAAI/bge-m3 | | Rozmiar na dysku (Ollama) | 1.2 GB | ollama.com/library/bge-m3 | | Wymiar wektora (dense) | **1024** | HF BAAI/bge-m3 model card | | Max kontekst | **8192 tokenow** | ollama.com/library/bge-m3, HF | | Jezyki | 100+ working languages, w tym polski | ollama.com/library/bge-m3 | | Tryby | dense + sparse (lexical) + multi-vector (ColBERT) w jednym modelu | HF BAAI/bge-m3 | **Nie jest pobrany na SOLARII.** `curl http://solaria:11434/api/tags` (przez Tailscale z PIHA) pokazuje wylacznie: `qwen2.5-coder:14b, qwen3-coder:30b, deepseek-coder:latest, deepcoder:14b` — same modele coder, zero embeddingowych. `ollama pull bge-m3` (1.2GB) nie zostal wykonany (poza zakresem recon — nic nie pobieram). SOLARIA: RTX 4070, **12GB VRAM** (`hosts/solaria/capabilities.yaml`) — 1.2GB modelu mieści się bez trudu obok/zamiast modeli coder; nie jest to wąskie gardło. ### 1.5 kb-postgres@PIHA — zasoby - Kontener ograniczony do `mem_limit: 1g`, `shared_buffers=256MB`, `maintenance_work_mem=64MB`, `max_connections=30` — celowo ciasno (Pi 5 dzieli RAM z HA/Immich). - Dane obecnie: 202 MB (`du -sh` na wolumenie) dla 225k kopert maili. Zapas na PIHA: `df -h /` → 29 GB wolne z 58 GB. Nie jest to problem dla pilota (189 dok × kilkanascie chunkow ≈ kilka tysiecy wektorow × 4KB/wektor ≈ pojedyncze MB), ale `maintenance_work_mem=64MB` (budowa indeksu HNSW) bedzie trzeba zrewidowac przy docelowej skali (miliony chunkow po dolozeniu maili) — nie w tej fazie. ### 1.6 `packages/kb-mail` — czy da sie reuzyc? Tak, **bezposrednio, bez nowego pakietu**: - `envelope.py` (`Envelope` dataclass) i `db.py` (`insert_envelope`/`get_envelope`) sa juz **w pelni zrodlo-agnostyczne** — zero logiki specyficznej dla maila. Dzialaja identycznie dla `source='paperless'`. - `archive.py` (`save_eml`) jest **specyficzny dla .eml** i **niepotrzebny** dla adaptera Paperless — Paperless jest REFERENCJA (`raw_ref` = doc-id, nie kopiujemy bajtow), wiec nie ma czego archiwizowac lokalnie. - Wniosek: **nie tworzymy `packages/kb-documents`**. Adapter Paperless w `jobs/documents-ingest/` robi `pip install /packages/kb-mail/` i importuje `Envelope`/`insert_envelope` wprost. Gdy przyjdzie faza Nextcloud (KOPIA, nie referencja), wtedy `archive.py` bedzie potrzebny do rozszerzenia — ale to inny adapter, nie ten. ### 1.7 Graf encji (warstwa 3) — stan `grep -r "entity_link|entity_graph|warstwa.3|entity_resolution"` w calym repo → trafienia wylacznie w dokumentacji (kb-00, kb-02, modul 5) i w kolumnie `entities` samej koperty. **Zero kodu, zero tabeli.** Warstwa 3 zaczyna sie od zera w tej fazie — zgodnie z zasada kb-00 #7 ("kontrakt encji definiujemy wczesnie, przy 2 zrodlach; cienka warstwa 4 po drugim zrodle). ### 1.8 Koperty mailowe — brak naglowkow (potwierdzone) Sprawdzone bezposrednio w `jobs/gmail-bulk-import/src/gmail_bulk_import/importer.py`: `_parse_attachments()` (L84) buduje jedyne obiekty ktore trafiaja do `entities[]` — wylacznie `{"type": "attachment", filename, content_type, size, sha256}`. Funkcja `_parse_date()` (L70) parsuje `Date:` wylacznie po to, by wypelnic `envelope.ts` — wartosc naglowka NIE trafia do `entities`. **Zero from/to/cc/delivered_to/subject gdziekolwiek w bazie.** Te dane istnieja wylacznie wewnatrz zarchiwizowanych plikow `.eml` (27 GB, 225 030 plikow) — nigdy nie zostaly wyciagniete do struktury zapytywalnej. To jest realny problem semantyczny, nie tylko brak wygody: Oskar ma wiele adresow/aliasow na tym samym koncie Gmail. Bez `to`/`delivered_to` nie da sie odpowiedziec na pytania typu "czy ta polisa jest na mnie czy na zone" ani odroznic poczty firmowej od prywatnej, gdy oba konteksty ladowaly na rozne aliasy tej samej skrzynki. Naprawiane w §5 (backfill). ### 1.9 Cross-source: dowod juz istnieje w danych (odkrycie, nie projekt od zera) `jobs/documents-ingest/` (faza 1, JUZ DZIALA) zostawia po sobie `/opt/homelab/data/documents-ingest/registry.json`, klucz = **sha256 zalacznika** (z manifestu mailowego `entities[]`), wartosc zawiera m.in. `envelope_id` (Message-ID maila zrodlowego) i `consume_name` (nazwa pliku wrzuconego do `consume/`). Zweryfikowane na zywo: - Registry ma dzis **185 wpisow**, wszystkie `consume_name` **unikalne** (185/185) — bezpieczny klucz join. - **KRYTYCZNE odkrycie:** `documents_document.checksum` / `.archive_checksum` w Paperlessie to **MD5** (32 znaki hex — sprawdzone bezposrednio w bazie), a registry.json jest kluczowany **SHA256** (64 znaki) tego samego pliku. Rozne algorytmy — **nie da sie ich bezposrednio porownac**. To by unieważniło plan "dopasuj po checksumie", gdyby ktos zaproponowal go bez sprawdzenia. - Za to `documents_document.original_filename` (nazwa pliku jaki wszedl przez `consume/`) **dokladnie odpowiada** `registry[sha256].consume_name` — zweryfikowane join na 8 losowych przykladach (np. `2026-06-03_Regulamin_PL-ENG.pdf`). To jest **deterministyczny, bezstratny klucz** miedzy Paperless a rejestrem faktury-1, bez zadnej heurystyki tekstowej. - Dokumenty spoza tej sciezki (np. `polisa 920008969228.pdf`, `fll-challenge-...` — bez prefiksu daty charakterystycznego dla `documents-ingest`) nie maja wpisu w registry — to sa dokumenty dodane recznie/wczesniej, poprawnie NIE beda mialy cross-source linku. **Wniosek:** cross-source link mail↔dokument nie wymaga zadnej nowej infrastruktury ani entity-resolution — dane juz sa, trzeba je tylko odczytac i zapisac w kopercie dokumentu przy jego tworzeniu (§4). --- ## 2. Otwarte decyzje dla Oskara ### Decyzja 1 — Gdzie wektory: kolumna w `envelope` czy osobna tabela? **Rekomendacja: osobna tabela `document_chunk` (1:N do envelope).** Uzasadnienie: sredni dokument (~22k znakow) jest blisko/ponad granice sensownego pojedynczego embeda, a najwieksze (360k znakow / 114 stron) drastycznie ja przekraczaja — **chunking jest obowiazkowy**, nie opcjonalny. Koperta jest zamrozona i ma byc addytywna (kb-00 zasada #2) — N-wartosciowa relacja nie pasuje do plaskiej kolumny. Dodatkowo: mail-indexer (przyszly etap) bedzie mial dokladnie ten sam problem (dlugie watki mailowe) — projektujemy jedna tabele chunkow reuzywalna dla obu zrodel, zamiast osobnych rozwiazan. ### Decyzja 2 — Model embeddingowy **bge-m3 pozostaje trafny wybor** (patrz §1.4) — nie ma potrzeby zmiany zamknietej decyzji z kb-00. Jedyna otwarta czynnosc: `ollama pull bge-m3` na SOLARII — to akcja implementacyjna, nie recon, wiec jej nie wykonalem. ### Decyzja 3 — Chunking **Rekomendacja: ~500–800 tokenow/chunk (≈2000–3200 znakow polskiego tekstu), overlap ~100–150 tokenow, split preferujacy granice akapitow/pustych linii, twardy fallback znakowy gdy akapit przekracza limit.** Uzasadnienie: - bge-m3 (limit 8192 tok) technicznie zmiesci wiekszosc dokumentow (sredni ~22k znakow ≈ 5.5–6k tok) w JEDNYM embedzie — ale maly chunk daje lepsza precyzje retrievalu dla pytan punktowych ("jaka suma ubezpieczenia w polisie GA431JA") niz jeden wektor uśredniajacy caly dokument. - Najwieksze dokumenty (360k znakow / 114 stron) i tak **wymagaja** twardego chunkingu — nie ma opcji embedowania calosci. - Brak page-break markerow w tekscie (§1.2) wyklucza czysty split po stronach — dzielimy po strukturze tekstu (akapity/puste linie), nie po metadanej `page_count`. ### Decyzja 4 — Cross-source link — **ROZSTRZYGNIETA przez Oskara (v2)** ~~Pierwotna rekomendacja (v1) opierala sie o `correspondent` z Paperlessa — Oskar to odrzucil, slusznie: pole jest puste (recon to wykazalo), a nawet gdyby nie bylo, to tylko ZGADYWANIE Paperlessa "kto wystawil dokument", nie fakt.~~ **Decyzja: link mail↔dokument idzie po deterministycznej sciezce danych, nie po polu `correspondent`.** Sciezka (opisana w §1.9): `envelope(gmail).entities[attachment].sha256` → `registry.json[sha256]` → `{envelope_id, consume_name}` → JOIN `documents_document.original_filename = consume_name` → `paperless doc_id`. Wynik zapisany w kopercie DOKUMENTU jako `entities[type=source_mail]` (ksztalt w §4.2). `correspondent`/`tag` z Paperlessa **zostaja w entities jako metadane informacyjne** (moga sie kiedys wypelnic — auto-matcher albo reczne tagowanie), ale **przestaja byc mechanizmem cross-source** — nic w pipeline nie polega juz na tym, ze sa niepuste. **Konsekwencja dla schematu (§3 zaktualizowane):** tabele `entity`/`entity_link` (graf encji person/organizacja) **nie sa wymagane do domkniecia kryterium ukonczenia modulu 5** ("min. jeden cross-source link zademonstrowany") — ten dowod daje wprost pole `source_mail` w kopercie dokumentu, bez potrzeby modelowania tozsamosci. Tabele zostaja w planie jako **przyszla praca, odlozona** (prawdziwa identity-resolution: gdy `correspondent` kiedys sie wypelni, gdy dojda nadawcy mailowi jako encje, gdy dojda twarze ze zdjec z Immich) — nie blokuja pilota. To realne uproszczenie zakresu tej fazy, nie kompromis. ### Decyzja 5 — Konto serwisowe dla API Paperless Trzeba wygenerowac token API — **to mutuje baze Paperless (jeden rekord `authtoken_token`)**, wiec nie zrobilem tego w ramach read-only recon. Do decyzji: token na istniejacym koncie `oskar` (prosciej) czy dedykowany user tylko-do-odczytu `kb-ingest` (czystsze rozdzielenie uprawnien, ale trzeba sprawdzic czy Paperless ma granularne read-only role). **Rekomendacja: dedykowany user readonly jesli Paperless to latwo wspiera** — do zweryfikowania w pierwszym kroku implementacji, nie blokuje planu. ### Decyzja 6 — Filtr selektywnosci przed embedowaniem §1.2 pokazal, ze aktualna zawartosc Paperless zawiera szum niezwiazany z celem KB (crypto T&C, PDFy z konkursu robotycznego) — pozostalosc po size-only filtrze fazy-1. **Rekomendacja: na pilot embedowac WSZYSTKO** (189 dok to tani eksperyment, latwo przebudowac indeks — kb-00 zasada #1: indeks jest odtwarzalny/wyrzucalny), **ale zaprojektowac filtr jako parametr adaptera od razu** (np. blacklist po `document_type`/tagach gdy beda ustawione, lub po korespondencie), zeby nie trzeba bylo tego retrofitowac przy skalowaniu do tysiecy dokumentow z faktycznego batcha 70k zalacznikow. ### Decyzja 7 — Backfill naglowkow: osobny job czy rozszerzenie `gmail-bulk-import`? (NOWA, v2) **Rekomendacja: osobny jednorazowy job**, np. `jobs/gmail-header-backfill/`, nie rozszerzenie `gmail-bulk-import`. Uzasadnienie: - `gmail-bulk-import` ma semantyke **INSERT** (nowe koperty z mbox/Takeout) — juz wykonany, historyczny, uzasadniony przez wlasny modul/etap. Backfill ma semantyke **UPDATE** istniejacych 225 030 wierszy — inna operacja, inne ryzyko (dotyka danych juz w produkcyjnej bazie), inny cykl zycia. - Precedens w repo juz istnieje: `jobs/documents-ingest/` zostal swiadomie wydzielony jako NOWY job mimo ze czyta ten sam `.eml`-archiwum co `gmail-bulk-import` — bo robi cos innego (ekstrakcja, nie import). Ta sama logika stosuje sie tu. - Osobny job = latwiej o dry-run/--apply, `--limit`, wznawialnosc i testy jednostkowe bez ryzyka regresji w kodzie ktory juz odpowiada za 225k-wierszowy bulk import. Alternatywa (odrzucona): dopisanie `--backfill-headers` do `gmail-bulk-import` — mniej kodu, ale miesza dwie odrebne operacje w jednym CLI i utrudnia code review/testy. Do przyklepania przez Oskara, ale rekomendacja jest jasna. --- ## 3. Proponowany schemat SQL (addytywny, nowe migracje) ```sql -- services/kb-postgres/init/002_chunks.sql -- Chunk-level embeddings, 1:N do envelope. Addytywne — envelope (001) nietkniete. CREATE TABLE IF NOT EXISTS document_chunk ( id BIGSERIAL PRIMARY KEY, envelope_id TEXT NOT NULL REFERENCES envelope(id) ON DELETE CASCADE, chunk_index INT NOT NULL, -- kolejnosc w obrebie envelope, 0-based text TEXT NOT NULL, embedding VECTOR(1024), -- bge-m3 dense; NULL dopoki nie zembedowany model TEXT NOT NULL, -- np. 'bge-m3' — sledzenie re-indexu przy zmianie modelu created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (envelope_id, chunk_index) ); CREATE INDEX IF NOT EXISTS document_chunk_envelope_idx ON document_chunk (envelope_id); CREATE INDEX IF NOT EXISTS document_chunk_embedding_hnsw_idx ON document_chunk USING hnsw (embedding vector_cosine_ops); ``` `model` na `document_chunk` istnieje wylacznie po to, by re-indeks przy lepszym modelu (kb-00 zasada #1) byl trywialny: nowy embed = nowy wiersz z innym `model`, stary do skasowania po weryfikacji, bez migracji schematu. ### ODLOZONE (v2): `entity` / `entity_link` W v1 tego planu byla tu tabela grafu encji (`entity` + `entity_link`) jako mechanizm cross-source. **Po decyzji 4 (v2) nie jest juz potrzebna do domkniecia modulu 5** — dowod cross-source daje bezposrednio `entities[type=source_mail]` w kopercie dokumentu (§4.2), bez modelowania tozsamosci osoby/firmy. Schemat zostaje udokumentowany tutaj jako **przyszla praca** (prawdziwa identity-resolution — gdy `correspondent` w Paperless kiedys sie wypelni, gdy dojda nadawcy mailowi jako encje, gdy dojda twarze z Immich), nie jest czescia planu implementacji tej fazy: ```sql -- ODLOZONE — nie czesc migracji fazy 2, tylko szkic na przyszlosc CREATE TABLE IF NOT EXISTS entity ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL, -- 'person' | 'organization' | ... canonical_name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS entity_link ( id BIGSERIAL PRIMARY KEY, entity_id BIGINT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, envelope_id TEXT NOT NULL REFERENCES envelope(id) ON DELETE CASCADE, role TEXT NOT NULL, -- 'correspondent' | 'sender' | ... raw_value TEXT, -- surowa wartosc jak wystapila w zrodle UNIQUE (entity_id, envelope_id, role) ); ``` --- ## 4. Ksztalt `entities[]` — ujednolicony miedzy zrodlami Oba zrodla uzywaja **tej samej konwencji**: `entities` to plaska lista obiektow roznotypowych, kazdy z kluczem `"type"` (wzorzec juz ustalony przez `gmail-bulk-import`'s `{"type": "attachment", ...}`). Nowosc w v2: wspolne slownictwo typow rozszerzone o naglowki (mail) i link zrodlowy (dokument), oba dodane **addytywnie obok** istniejacych wpisow — zaden istniejacy element listy nie jest ruszany ani restrukturyzowany. ### 4.1 Koperta MAILOWA (`source='gmail'`) — po backfillu ```json [ {"type": "attachment", "filename": "...", "content_type": "application/pdf", "size": 143514, "sha256": "00866aa3..."}, ... { "type": "headers", "from": {"name": "WARTA", "address": "no-reply@warta.pl"}, "to": [{"name": "...", "address": "oskar@gmail.com"}], "cc": [], "delivered_to": ["oskar+alias@gmail.com"], "subject": "Twoja polisa OC/AC", "date_raw": "Mon, 9 Jun 2026 12:34:56 +0200" } ] ``` Uwagi projektowe: - `from`/`to`/`cc` **parsowane** (`email.utils.getaddresses`) do `{name, address}`, nie surowy string — ulatwia przyszla entity-resolution (§ decyzja 4) bez re-parsowania. `to`/`cc` to listy (wielu adresatow mozliwych); `from` pojedynczy obiekt (RFC dopuszcza wiele, w praktyce prawie zawsze jeden — jesli wielu, bierzemy pierwszy i logujemy). - `delivered_to` to **lista surowych stringow**, NIE parsowana — naglowek `Delivered-To` moze wystapic wielokrotnie (hop-by-hop), a to wlasnie te wartosci mowia "ktory alias Oskara odebral wiadomosc" (motywacja z §1.8). Zachowujemy wszystkie wystapienia. - `date_raw` to surowy string naglowka `Date:` (dla porownania/debug) — `envelope.ts` juz ma sparsowana wersje UTC z importu (`_parse_date`), wiec to nie jest duplikacja zrodla prawdy, tylko provenance. - Parsowanie przez `email.policy.default` (dekoduje RFC 2047 encoded-words do realnego Unicode) — ta sama decyzja co `documents-ingest` juz podjal dla MIME-parts (patrz jego README, sekcja o rozbieznosci compat32 vs default). ### 4.2 Koperta DOKUMENTU (`source='paperless'`) ```json [ {"type": "content", "text": ""}, {"type": "correspondent", "name": null}, {"type": "tag", "name": "..."}, {"type": "filename", "value": "..."}, {"type": "content_type", "value": "application/pdf"}, { "type": "source_mail", "envelope_id": "CABtrY-KrCCZ0EgSDkg8xf-2pvkaEyxo1Y12Pfi=p=A3R9NFQKw@mail.gmail.com", "attachment_sha256": "00866aa304e65f90ef3538fd9994a6e9d8af6a76480543698c42dd7ea17e016d", "attachment_filename": "31657539_13049.pdf" } ] ``` `source_mail` — **obecny tylko gdy jest deterministyczny match** (§1.9: join `documents_document.original_filename` = `registry.json[sha256].consume_name`). Dla dokumentow dodanych recznie/spoza pipeline'u faktury-1 (np. `polisa 920008969228.pdf`, `fll-challenge-...`) ten wpis **nie wystepuje** — brak linku jest poprawnym stanem, nie bledem. `correspondent`/`tag` zostaja jako metadane informacyjne (§ decyzja 4) — nie sa juz mechanizmem cross-source, ale nie ma powodu ich wyrzucac (tanie, Paperless i tak je daje). Przykladowe zapytanie po dowod cross-source (mail -> dokument): ```sql SELECT e_mail.id AS mail_id, e_mail.entities, e_doc.id AS doc_id FROM envelope e_doc JOIN LATERAL jsonb_array_elements(e_doc.entities) AS link ON link->>'type' = 'source_mail' JOIN envelope e_mail ON e_mail.id = link->>'envelope_id' WHERE e_doc.source = 'paperless'; ``` ### 4.3 Adapter Paperless -> koperta (pelny mapping) ``` source = 'paperless' id = f"paperless:{document_id}" -- PREFIX wymagany: PK envelope.id jest globalny -- plaski TEXT bez kolumny source w kluczu; Paperless -- doc-id to male sekwencyjne inty (1,2,3...) — -- bez prefiksu koliduja z kazdym przyszlym zrodlem -- uzywajacym prostych ID (np. Nextcloud fileid). ts = documents_document.created -- Paperless wykrywa date z tresci/nazwy pliku, -- lepsze niz plikowy mtime (potwierdzone: dok #1 -- created=2020-04-21 zgadza sie z data polisy w OCR) geo = NULL -- jak w specyfikacji modulu 5 raw_ref = str(document_id) -- REFERENCJA, zrodlem prawdy jest Paperless entities = jak w §4.2, w tym `source_mail` gdy join z registry.json sie powiedzie ``` Rozwiazanie `source_mail`: adapter wczytuje `registry.json` raz do pamieci, buduje indeks `consume_name -> {envelope_id, sha256, filename}`, dla kazdego dokumentu z Paperless API sprawdza `original_filename` w tym indeksie. O(1) per dokument, zero dodatkowych zapytan do Paperless czy do bazy maili. --- ## 5. Backfill naglowkow mailowych (225 030 kopert) ### 5.1 Zakres i podejscie Cel: dopisac `{"type": "headers", ...}` (§4.1) do kazdej z 225 030 istniejacych kopert `source='gmail'`, bez ruszania istniejacych elementow `entities` (manifesty zalacznikow). **Zrodlo danych: zarchiwizowane pliki `.eml`**, nie oryginalny plik Takeout/mbox. Uzasadnienie: archiwum `.eml` (`/home/oskar/kb/mail/archive` na PIHA, 27 GB, 225 030 plikow) jest juz warstwa "surowa/niezmienna" (kb-00 zasada #1) i kazdy `.eml` niesie pelne oryginalne naglowki — nie trzeba wracac do zrodlowego mboxa (ktory moze juz nie byc dostepny/aktualny na dysku roboczym). To tez dokladnie ten sam wzorzec dostepu co `documents-ingest` juz uzywa (`archive_root / row["raw_ref"]` -> `read_bytes()` -> `email.message_from_bytes(raw, policy=email.policy.default)`). ### 5.2 Idempotencja i wznawialnosc `entities` jest JSONB **tablica** — dopisanie nowego elementu to konkatenacja, nie nadpisanie: ```sql UPDATE envelope SET entities = entities || $2::jsonb -- $2 = '[{"type": "headers", ...}]'::jsonb WHERE id = $1 AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type' = 'headers' ); ``` Warunek `NOT EXISTS` w `WHERE` czyni operacje **bezpiecznie powtarzalna**: przerwany backfill (crash, restart) po prostu pomija wiersze juz zaktualizowane przy ponownym uruchomieniu — dokladnie ten sam wzorzec `ON CONFLICT DO NOTHING` co reszta pipeline'u, tylko wyrazony przez `WHERE NOT EXISTS` (bo to `UPDATE`, nie `INSERT`). ### 5.3 Wydajnosc (szacunek, nie pomiar) 225 030 plikow, srednio ~124 KB kazdy (27 GB / 225 030 — z faktycznych liczb importu w `kb-00-overview.md`). Operacja per wiersz: `open + read + parse headers (tanie, tylko naglowki, nie cale MIME-walk po zalacznikach) + UPDATE`. Pelny bulk-import (parsowanie calego mboxa **wlacznie z** MIME-walk zalacznikow i insertami) zajal ~29 min (`kb-00-overview.md`, zmierzone). Backfill robi mniej pracy per-wiadomosc (tylko naglowki, nie MIME-walk zalacznikow) ale placi za **osobne otwarcie 225k malych plikow** zamiast strumieniowego czytania jednego mboxa — te dwa efekty czesciowo sie znosza. **Szacunek: tego samego rzedu wielkosci, prawdopodobnie kilkadziesiat minut**, nie pomierzone bezposrednio (nie odpalilem tego joba — nie istnieje jeszcze). Rekomendacja: batch UPDATE (multi-row, nie jeden `UPDATE` na transakcje), `--limit`/`--offset` jak inne joby w repo, zeby dalo sie uruchomic partiami i zweryfikowac progres bez czekania na cale 225k na raz. --- ## 6. Plan implementacji (kolejnosc) 1. **Migracja** `002_chunks.sql` na kb-postgres@PIHA (addytywna, nie rusza `001_envelope.sql`). `003_entities.sql` **odlozona** (§3) — nie jest czescia tego kroku. 2. **`ollama pull bge-m3`** na SOLARII — zweryfikowac czas pobrania i realne zuzycie VRAM przy pierwszym `ollama run`/embed call. 3. **Token API Paperless** — decyzja #5 wyzej (konto dedykowane vs `oskar`), wygenerowac przez `manage.py drf_create_token` lub UI. 4. **`jobs/gmail-header-backfill/`** (decyzja #7) — nowy one-shot job: parsuje naglowki z 225 030 zarchiwizowanych `.eml`, dopisuje `{"type": "headers", ...}` (§4.1) do `envelope.entities` przez idempotentny `UPDATE ... WHERE NOT EXISTS` (§5.2). Wykonac PRZED adapterem Paperless, bo krok 6 (dowod cross-source) czyta te naglowki. 5. **Rozbudowa `jobs/documents-ingest/`** o adapter Paperless→koperta: fetch (paginowany `GET /api/documents/`), zaladowanie `registry.json` do indeksu `consume_name -> wpis`, mapping wg §4.2–4.3 (w tym `source_mail` gdy join sie powiedzie), `insert_envelope` (reuzyte z `packages/kb-mail`). Idempotentnie — `ON CONFLICT (id) DO NOTHING` juz jest w `kb_mail.db.insert_envelope`. 6. **Chunking + embed job** — nowy modul (rozwazyc czy zyje w `jobs/documents-ingest/` czy jako osobny `jobs/kb-indexer/` reuzywalny tez dla maili pozniej — rekomendacja: zaczac w `documents-ingest`, wydzielic gdy mail-indexer bedzie realny, nie przed czasem). Pipeline: `entities[content] -> chunk (§ decyzja 3) -> POST /api/embeddings (ollama, model=bge-m3) -> INSERT document_chunk`. 7. **Pilot na 189 dok** — uruchomic, zweryfikowac jakosciowo retrieval (recznie, kilka zapytan przez SQL `ORDER BY embedding <=> query_vector`). 8. **Weryfikacja dowodu cross-source** — po kroku 5, policzyc ile dokumentow dostalo `source_mail` (oczekiwane: ~185, wedlug dzisiejszego stanu registry.json), zapytaniem z §4.2 pokazac jeden konkretny przyklad mail↔dokument koniec-do-konca. To jest dowod wymagany przez kryterium ukonczenia modulu 5 — bez recznego tagowania w UI. 9. **Testy jednostkowe** (wzorzec `jobs/gmail-bulk-import/tests/`) — dla kazdego nowego/ zmienionego joba: mapping Paperless→koperta, parsowanie naglowkow (w tym multi-`Delivered-To`, RFC 2047 encoded-words), join `source_mail`, chunking (granice, overlap, dokument pusty/za dlugi), idempotencja UPDATE-backfillu. 10. **Definition of Done** (CLAUDE.md): `docker build` + smoke run + `pytest` przed jakimkolwiek deployem/commitem. --- ## 7. Wydajnosc - **189 dokumentow, ~22k znakow sredniej** → przy chunkach ~600 tok / ~2400 znakow + overlap 150 tok: srednio ~10 chunkow/dok dla typowych dokumentow, ~150 chunkow dla najwiekszego (360k znakow/114 stron). Szacunkowo **~2000–3000 chunkow lacznie** dla calego pilota. - **bge-m3 na RTX 4070**: maly model (568M, 1.2GB) — inferencja embeddingu w batchu to rzedu dziesiatek-set chunkow/s. Caly pilot (2–3k chunkow) → **rzedu minut**, nie wymaga specjalnego batchowania/partii. - **Skala docelowa (70k zalacznikow z maili)**: modul 5 faza-1 to swiadomie **probka, nie bulk** (`jobs/documents-ingest/README.md` — decyzja architektoniczna). Realny wolumen ktory trafi do embeddingu zalezy od (a) throughput OCR-workera na SOLARII (modul 3) — **to jest waskie gardlo skalowania, nie embedding** — oraz (b) filtra selektywnosci (decyzja #6). Sam embedding bge-m3 nie bedzie bottleneckiem nawet przy tysiacach dokumentow. - **kb-postgres@PIHA**: budzet 1GB / `maintenance_work_mem=64MB` wystarcza na budowe HNSW dla pilotowych 2–3k wektorow. Przy docelowej skali (miliony chunkow po dolozeniu maili) ten limit trzeba bedzie zrewidowac — **poza zakresem tej fazy**, ale warto zanotowac w backlogu jako przyszly checkpoint operacyjny. - **Backfill naglowkow (225 030 kopert)** — szacunek jest osobny, patrz §5.3: rzedu dziesiatek minut, dominowane przez I/O otwarcia 225k malych plikow, nie przez sam parsing naglowkow (tani). Nie mierzone bezposrednio (job nie istnieje jeszcze). --- ## 8. Podsumowanie dla Oskara **Co jest:** Paperless dziala (189 dok, 4.2M znakow OCR), koperta+pgvector-extension stoi, `packages/kb-mail` reuzywalny bez zmian dla adaptera Paperless, **dowod cross-source juz lezy w danych** (registry.json z faktury-1 + `original_filename` w Paperless) — nie trzeba go budowac, tylko odczytac i zapisac. **Czego brakuje (do zrobienia w tej fazie):** tabela `document_chunk` (nie istnieje nigdzie w KB — mail pillar tez jej nie ma), model bge-m3 (nie pobrany), token API Paperless (nie wygenerowany), naglowki w 225 030 kopertach mailowych (dzis tylko manifest zalacznikow — **krytyczna luka odkryta przez Oskara**, naprawiana backfillem w §5), jakikolwiek kod chunking/embed/backfill (nie istnieje). **7 otwartych decyzji** wypisanych w §2 (byla 6, doszla #7 — backfill: osobny job czy rozszerzenie `gmail-bulk-import`). Decyzja #4 (cross-source) jest juz **rozstrzygnieta** przez Oskara w tej korekcie — mechanizm to deterministyczny sha256/nazwa-pliku join, nie `correspondent`. To jednoczesnie **upraszcza zakres**: tabele `entity`/`entity_link` (graf encji) sa teraz odlozone, nie blokuja domkniecia modulu 5. Nic nie zostalo zaimplementowane, zdeployowane, pobrane ani zacommitowane w ramach tego recon — wlacznie z ta korekta (v2).