homelab-codex-ws/kb/phases/kb-m5-faza2.md
oskar 01db57ab82 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
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>
2026-08-04 16:53:57 +02:00

32 KiB
Raw Blame History

okf type visibility status updated links
0.1 phase private active 2026-07-13

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 sourcejeden 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 authGET /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: ~500800 tokenow/chunk (≈20003200 znakow polskiego tekstu), overlap ~100150 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.56k 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.

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].sha256registry.json[sha256]{envelope_id, consume_name} → JOIN documents_document.original_filename = consume_namepaperless 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)

-- 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.

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:

-- 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

[
  {"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')

[
  {"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_mailobecny 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):

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:

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.24.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 ~20003000 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 (23k 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 23k 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).