126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
584 lines
32 KiB
Markdown
584 lines
32 KiB
Markdown
---
|
||
okf: "0.1"
|
||
type: phase
|
||
visibility: private
|
||
status: active
|
||
updated: 2026-07-13
|
||
links: []
|
||
---
|
||
|
||
# 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** (`kb/phases/kb-m5-documents-ingest-fazy.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).
|