# 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 <token>` (DRF TokenAuth),
  token generowany w UI (My Profile) albo `manage.py drf_create_token <user>`.
- **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": "<pelny OCR-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).
