From 8ae2c2481a0b10274588bcca01351f00ea32d019 Mon Sep 17 00:00:00 2001 From: oskar Date: Mon, 13 Jul 2026 21:09:55 +0200 Subject: [PATCH] =?UTF-8?q?docs(kb):=20plan=20fazy=202=20modu=C5=82u=205?= =?UTF-8?q?=20=E2=80=94=20koperta=20dokument=C3=B3w=20(recon=20+=20plan=20?= =?UTF-8?q?v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/kb/modules/05-faza2-plan.md | 574 +++++++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 docs/kb/modules/05-faza2-plan.md diff --git a/docs/kb/modules/05-faza2-plan.md b/docs/kb/modules/05-faza2-plan.md new file mode 100644 index 0000000..40b15ba --- /dev/null +++ b/docs/kb/modules/05-faza2-plan.md @@ -0,0 +1,574 @@ +# 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).