From 75d96956a5ae15ce92191da74d381a17decd93b8 Mon Sep 17 00:00:00 2001 From: oskar Date: Thu, 6 Aug 2026 14:02:23 +0200 Subject: [PATCH] =?UTF-8?q?docs(recon):=20przyrostowka=20IMAP=20gmail=20+?= =?UTF-8?q?=20fastmail=20=E2=80=94=20Krok=207=20fazy=20mailowej?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit). Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura 48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID. Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu): - koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?" (build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem - DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie z /search, bo galaz summaryless filtruje po source - etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1) - envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach trafia do bazy raz, z source konta ktore wygralo wyscig Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU), indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony: HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy). Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen: - POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien. Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600, czytany przez systemd przed zrzuceniem uprawnien) - Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje "unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego dokumentu po zatwierdzeniu Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat (kb-m5-faza3.md:620 — "pelna wiki po przyrostowce"). Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu. Nic nie zaimplementowano, nie zdeployowano ani nie pobrano. Co-Authored-By: Claude Opus 5 (1M context) --- kb/audits/mail-sync-2026-08-06.md | 809 ++++++++++++++++++++++++++++++ kb/phases/kb-m5-faza-mailowa.md | 16 +- 2 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 kb/audits/mail-sync-2026-08-06.md diff --git a/kb/audits/mail-sync-2026-08-06.md b/kb/audits/mail-sync-2026-08-06.md new file mode 100644 index 0000000..fba7f91 --- /dev/null +++ b/kb/audits/mail-sync-2026-08-06.md @@ -0,0 +1,809 @@ +--- +okf: "0.1" +type: audit +visibility: private +status: active +updated: 2026-08-06 +as_of: 2026-08-06 +links: [] +--- + +# Recon — przyrostówka IMAP (gmail + fastmail), 2026-08-06 + +Read-only recon. Realizuje **Krok 7** fazy mailowej +(`kb/phases/kb-m5-faza-mailowa.md` §10, §12 poz. 7 — „OTWARTE — NEXT", +odblokowane zamknięciem Etapu B). Zakres: co trzeba dobudować, żeby korpus +mailowy przestał być fotografią z Takeoutu i sam się dosypywał z dwóch żywych +skrzynek. + +Źródło kodu: `task/mail-sync-recon` @ `b1b6692` (worktree cięty z mastera). +Dowody runtime zebrane 2026-08-06 ~13:50 UTC+2: SOLARIA = host tego reconu +(odczyty lokalne), PIHA po ssh (`docker exec kb-postgres psql`, `systemctl`, +`curl`). **Nic nie zostało zaimplementowane, zdeployowane ani pobrane — wynik +to wyłącznie ten dokument.** Twierdzenia o zewnętrznych API (limity Gmaila, +układ folderów Fastmaila) są oznaczone jako **[do weryfikacji na żywo]** — +nie sprawdzałem ich, bo wymagałoby to logowania na konta. + +--- + +## 1. Jak powstał obecny korpus + +### 1.1 Tor, którym przyszły dane — trzy joby, jeden kierunek + +| # | Job | Co robi | Kiedy | +|---|---|---|---| +| 1 | `jobs/gmail-bulk-import` | Takeout `.mbox` → archiwum `.eml` + wiersze `envelope` (tylko manifest załączników) | jednorazowo, 2026-06/07 | +| 2 | `jobs/gmail-header-backfill` | UPDATE: dopisuje `entities[type=headers]` do istniejących kopert | jednorazowo, 2026-07 | +| 3 | `jobs/mail-body-ingest` | drugi pełny przebieg po archiwum: body → quote-strip → chunk → embed → `document_chunk` + `entities[type=threading]` | Etap A 2026-07-23, Etap B 2026-08-06 | + +Wszystkie trzy są **one-shotami uruchamianymi ręcznie**. Żaden nie ma jednostki +systemd, żaden nie wie o istnieniu skrzynki — punktem wejścia jest plik na dysku +(`.mbox`, potem `.eml`). To jest dokładnie ta luka, którą przyrostówka wypełnia: +**nie ma niczego, co rozmawia z serwerem pocztowym.** + +### 1.2 Archiwum `.eml` — struktura i format + +Kanoniczne archiwum na PIHA, lustro (dla backfillu GPU) na SOLARII: +`/home/oskar/kb/mail/archive/`. + +Układ katalogów wyznacza `kb_mail.archive.save_eml` +(`packages/kb-mail/src/kb_mail/archive.py:29`): + +``` +{archive_root}/{source}/{YYYY}/{MM}/{sanitized_message_id}.eml +``` + +- `sanitized_message_id` = `envelope.id` przepuszczony przez + `str.maketrans({"/": "_", "\\": "_", ":": "_", "<": "", ">": ""})`. +- `YYYY/MM` pochodzą z `ts` (nagłówek `Date`), **nie** z daty pobrania. +- **Append-only twardo**: `save_eml` robi `dest.exists()` → `FileExistsError`. + Nie ma trybu nadpisania. Wołający decyduje, czy to błąd, czy „już mam" + (bulk-import liczy to jako `skipped`). +- Format pliku: surowe bajty wiadomości, zapisane przez + `msg.as_bytes(policy=email.policy.compat32)` — czyli **bez** normalizacji, + z oryginalnymi 8-bitowymi bajtami w nagłówkach włącznie. + +Stan na dziś (SOLARIA, odczyt lokalny): jedyny podkatalog to `gmail/`, roczniki +`1970` (epoch fallback) oraz `2002`–`2026`. **Katalogu `fastmail/` nie ma — +źródło `fastmail` jest greenfieldem, zero plików i zero wierszy w DB.** + +### 1.3 Tabela `envelope` — schemat i klucze dedup + +`services/kb-postgres/init/001_envelope.sql`: + +```sql +CREATE TABLE envelope ( + id TEXT PRIMARY KEY, -- goły Message-ID (bez < >), lub sha256-<32hex> + source TEXT NOT NULL, -- fastmail | gmail | paperless + ts TIMESTAMPTZ NOT NULL, -- data wiadomości, UTC + geo JSONB, -- null dla maili + raw_ref TEXT NOT NULL, -- ścieżka względna do .eml + entities JSONB NOT NULL DEFAULT '[]' +); +CREATE INDEX envelope_source_idx ON envelope (source); +CREATE INDEX envelope_ts_idx ON envelope (ts); +``` + +Kontrakt zamrożony, addytywny (`packages/kb-mail/src/kb_mail/envelope.py`, +`kb/subsystems/kb-mail-pillar.md` §3). Kolumn UID/folder/etykieta **nie ma** +i nie było w planie. + +**Klucz dedup to `id`, czyli goły Message-ID.** Wyprowadza go +`gmail_bulk_import._message_id` (`importer.py:63`): + +```python +mid = _sanitize(str(msg.get("Message-ID", ""))).strip().strip("<>") +return mid or ("sha256-" + hashlib.sha256(msg.as_bytes()).hexdigest()[:32]) +``` + +Cała idempotencja stoi na `INSERT ... ON CONFLICT (id) DO NOTHING` +(`kb_mail.db.insert_envelope`, `importer._insert_batch`). Na żywej bazie: +**9 kopert** ma id z prefiksem `sha256-` (maile bez Message-ID), reszta to +prawdziwe Message-ID; średnia długość id = 60 znaków. + +`entities` to lista otwartych obiektów tagowanych `type`. Dziś w użyciu trzy: + +| `type` | Kto pisze | Zawartość | +|---|---|---| +| `attachment` | gmail-bulk-import | `filename`, `content_type`, `size`, `sha256` | +| `headers` | gmail-header-backfill | `from`, `to`, `cc`, `delivered_to`, `subject`, `date_raw` | +| `threading` | mail-body-ingest | `in_reply_to`, `references[]` | + +Oba appendy (`headers`, `threading`) są idempotentne przez +`WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type'='…')`. + +### 1.4 Stan żywej bazy i archiwum (zmierzone 2026-08-06) + +| Miara | Wartość | Skąd | +|---|---|---| +| `envelope` source='gmail' | 225 030 | `SELECT source, count(*) … GROUP BY source` | +| `envelope` source='paperless' | 191 | j.w. | +| `envelope` source='fastmail' | **0** | j.w. | +| `max(ts)` dla gmail | **2026-06-19 19:13:39+02** | j.w. | +| `document_chunk` (model bge-m3) | 389 012, w tym 201 849 bez wektora (newsletter) | `GROUP BY model` | +| Rozmiar `document_chunk` | 2 971 MB | `pg_total_relation_size` | +| Rozmiar `envelope` | 264 MB | j.w. | +| Rozmiar bazy `kb` | **3 248 MB** | `pg_database_size` | +| PIHA `/home` wolne | 136 GB (66% zajęte) | `df -h` | +| PIHA RAM | 8 GB, `available` 2,4 GB | `free -m` | +| Archiwum gmail 2024 / 2025 / 2026 | 13 207 / 13 575 / 6 213 plików | `find … -name '*.eml' \| wc -l` | +| Ostatni rocznik archiwum | `2026/06` (861 plików) | `ls` | + +**Kluczowa liczba tego reconu: korpus urywa się 2026-06-19, dziś jest 2026-08-06 +— dziura ma 48 dni.** Przy zmierzonym tempie ~37 maili/dobę (13 575 w 2025; +6 213 przez ~170 dni 2026) to **~1 800 kopert**, których w KB nie ma i nigdy nie +będzie, dopóki ktoś nie zrobi drugiego Takeoutu albo nie postawi przyrostówki. +Dziura rośnie o kolejny dzień każdego dnia. + +### 1.5 Skąd `source='gmail'` i czym różni się ścieżka fastmail + +`source` jest **wpisany na sztywno w kod importera** — `Envelope(..., source="gmail", ...)` +(`importer.py:255`) i `save_eml(archive_root, envelope_id, "gmail", ts, raw)` +(`importer.py:248`). Nie ma parametru CLI, nie ma detekcji. Tak samo +`mail_body_ingest.fetch_envelopes` (`ingest.py:372`) ma zaszyte +`WHERE source = 'gmail'`. + +Ścieżka fastmail **nie istnieje w żadnej postaci** — ani kodu, ani wierszy, ani +katalogu w archiwum. Jedyne, co jest, to zarezerwowane miejsce w kontrakcie +(komentarz `-- fastmail | gmail | ...` w DDL i w dataclassie). Różnice, które +realnie wystąpią, gdy się ją zbuduje: + +1. **Brak Takeoutu** — nie ma bulk-importu do wykonania; fastmail zaczyna od + pustego zbioru i albo bierzemy tylko nowe maile, albo robimy jednorazowy + pełny zaciąg IMAP-em (Decyzja **(e)** w §4). +2. **Inny układ folderów** — Gmail ma jeden worek `All Mail`; Fastmail ma + klasyczne IMAP-owe foldery i trzeba je wyliczyć (§2.3). +3. **Ta sama przestrzeń id** — i to jest pułapka, patrz §2.4. + +--- + +## 2. Czego brakuje do przyrostówki + +### 2.1 Klient IMAP — nie ma go nigdzie + +Grep po całym repo (`imaplib|aioimaplib|imapclient|IMAP|JMAP`) daje wyłącznie +**dokumenty**: `kb/subsystems/kb-mail-pillar.md`, `kb/subsystems/kb-overview.md`, +§10 planu fazy mailowej, kilka session-logów. **Zero linii kodu.** +`packages/kb-mail` ma dokładnie pięć modułów — `archive`, `chunking`, `db`, +`envelope`, `text` — i dwie zależności (`asyncpg`, `structlog`). Nie ma warstwy +transportu i nigdy nie było. + +Katalogi `jobs/fastmail-poller/` i `jobs/gmail-imap-poller/`, zapowiadane w +`kb-overview.md` jako etapy 3–4, **nie istnieją**. + +Co jest gotowe do reużycia i jest tego sporo: + +| Element | Gdzie | Uwaga | +|---|---|---| +| `save_eml` | `kb_mail/archive.py` | append-only, `FileExistsError` = „już mam" | +| `insert_envelope` | `kb_mail/db.py` | `ON CONFLICT (id) DO NOTHING` | +| `parse_headers` + `parse_headers_fallback` | `gmail_header_backfill/backfill.py:67,131` | typed parse + compat32, przetestowane na 8-bitowych nagłówkach | +| `_message_id`, `_parse_date`, `_parse_attachments` | `gmail_bulk_import/importer.py:63,78,97` | wyprowadzenie id/ts/manifestu | +| `sanitize_surrogates`, `strip_nul` | `kb_mail/text.py` | oba obowiązkowe przed zapisem do jsonb/text | +| Cały pipeline body | `mail_body_ingest/ingest.py` | wymaga jednej zmiany, §3.2 | +| `embed_batch_resilient`, `check_ollama_health` | `kb_retrieval/embed.py:168,256` | retry + bisekcja + probe backendu | + +`imaplib` jest w stdlib Pythona 3.11 (PIHA ma 3.11.2) — przyrostówka **nie musi +dokładać żadnej zależności**, jeśli zaakceptujemy synchroniczny klient +(uzasadnienie przy Decyzji (a)). + +### 2.2 Model stanu synca — nie ma go, i to jest główna nowa rzecz do zaprojektowania + +W bazie są **trzy tabele**: `envelope`, `document_chunk`, `document_summary` +(sprawdzone `\dt`). Nic nie przechowuje ani UIDVALIDITY, ani UIDNEXT, ani +last-seen-UID, ani daty ostatniego pollu. + +Trzy warianty, uporządkowane: + +**(A) Nowa tabela `mail_sync_state` w kb-postgres** — migracja `005`: + +```sql +CREATE TABLE mail_sync_state ( + account TEXT NOT NULL, -- 'gmail' | 'fastmail' (== envelope.source) + folder TEXT NOT NULL, -- nazwa IMAP-owa, jak zwrócona przez LIST + uidvalidity BIGINT NOT NULL, + last_uid BIGINT NOT NULL, -- najwyższy UID przetworzony do końca + last_sync_ts TIMESTAMPTZ NOT NULL, + PRIMARY KEY (account, folder) +); +``` + +**(B) Plik JSON w `/opt/homelab/state/`** — zgodne z konwencją runtime-state +z CLAUDE.md, ale rozjeżdża stan z danymi: przy odtworzeniu bazy z backupu plik +zostaje w przyszłości i przyrostówka cicho przeskakuje maile. + +**(C) Wyprowadzanie z `max(envelope.ts)`** — bez stanu. Kuszące i **złe**: +`ts` to nagłówek `Date` nadawcy, nie moment dostarczenia. Mail z przekręconym +zegarem albo opóźniony w dostarczeniu wpadnie poniżej znacznika i zniknie na +zawsze. Znamy to z własnego korpusu — 2 559 kopert ma `ts` = epoch 1970. + +**Rekomendacja: (A).** Stan synca to dane, nie konfiguracja — należy do tej +samej jednostki backupu co koperty, którym odpowiada. Kosztuje jedną migrację +(`services/kb-postgres/init/005_mail_sync_state.sql`), a płaci za to +najważniejszą właściwością: **stan i dane odtwarzają się razem albo wcale.** + +Semantyka pętli (standardowa i jedyna poprawna dla IMAP): + +1. `SELECT` folderu → serwer zwraca `UIDVALIDITY` i `UIDNEXT`. +2. Jeśli `UIDVALIDITY` ≠ zapamiętany → **cała numeracja UID jest unieważniona**; + zapamiętane `last_uid` nic nie znaczy. Reakcja: pełne przemiecenie folderu + (`UID SEARCH ALL`) i oparcie się wyłącznie na dedupie po Message-ID, po czym + zapis nowego `uidvalidity`. To zdarza się rzadko, ale gdy się zdarzy i nie + obsłużymy — tracimy maile bez śladu. +3. W przeciwnym razie `UID FETCH {last_uid+1}:*`. +4. Zapis `last_uid` **dopiero po** tym, jak koperta i plik `.eml` są trwale + zapisane — nigdy przed. Przerwanie w środku = powtórka partii przy następnym + ticku, którą dedup wyciszy. + +Uwaga na wyścig: `UID FETCH n:*` zawsze zwraca co najmniej jedną wiadomość +(serwer zwraca ostatnią, gdy przedział jest pusty) — trzeba odfiltrować UID-y +`<= last_uid` po stronie klienta, inaczej licznik „nowych maili" nigdy nie +spadnie do zera i obserwowalność z §3.4 kłamie. + +### 2.3 Mapowanie folderów + +**Gmail.** Model etykietowy: jedna wiadomość, wiele etykiet, każda etykieta +widoczna jako osobny folder IMAP. Synchronizowanie kilku folderów oznacza +pobranie tej samej wiadomości wielokrotnie. Właściwy wybór to **jeden folder +`\All`** (`[Gmail]/All Mail`), który zawiera wszystko poza Spamem i Koszem — +i to jest dokładnie ta sama populacja co Takeout „All Mail", na której stoi +obecny korpus (plan §Decyzja 4: „Takeout »All Mail« nie zawiera folderu Spam"). +Przyrostówka na `\All` jest więc **ciągła merytorycznie** z tym, co już jest +w bazie. + +Nazwy folderów Gmaila są lokalizowane (przy polskim UI `[Gmail]/Wszystkie`), więc +**nie wolno ich zaszywać** — trzeba wybrać folder po atrybucie SPECIAL-USE +`\All` z odpowiedzi `LIST`. To samo dotyczy `\Sent`/`\Trash`. + +**Fastmail.** Model klasyczny: `INBOX`, `Archive`, `Sent`, `Drafts`, `Trash`, +`Spam` + foldery użytkownika. Nie ma jednego worka odpowiadającego Gmailowemu +`\All` **[do weryfikacji na żywo — czy konto wystawia wirtualny folder +obejmujący całość]**. Zakres trzeba więc podać jawnie jako listę; rozsądny +domyślny zestaw to `INBOX` + `Archive` + `Sent` (Decyzja **(e)**). + +Konsekwencja projektowa: konfiguracja folderów musi być **per konto listą**, a +nie pojedynczą nazwą, bo Gmail chce jednego wpisu, a Fastmail trzech. Tabela +z §2.2 jest już kluczowana `(account, folder)`, więc to obsługuje. + +Czego **nie** zapisujemy: etykiet Gmaila. `entities` przyjęłoby +`{"type":"labels", …}` bez migracji, ale nic w retrievalu ich dziś nie czyta, +a `X-GM-LABELS` wymaga rozszerzenia `X-GM-EXT-1`, które przywiązuje kod do +Google — wprost wbrew zasadzie „protokół, nie provider" +(`kb-overview.md` §61). Odkładam. + +### 2.4 Dedup nowych vs istniejące — działa, z jednym ostrym rogiem + +Trzy warstwy, wszystkie już w kodzie: + +1. **Archiwum**: `save_eml` → `FileExistsError` = ten `.eml` już leży. +2. **Koperty**: `ON CONFLICT (id) DO NOTHING` po Message-ID. +3. **Chunki**: `ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING` + + pre-fetch kluczy. + +Dla ponownego pobrania tej samej wiadomości z tego samego konta to jest +komplet i nic nie trzeba dokładać. Ale: + +> **Znalezisko — kolizja Message-ID między kontami.** `envelope.id` to goły +> Message-ID, **globalnie unikalny w całej tabeli**, bez prefiksu źródła +> (inaczej niż paperless, który używa `paperless:N`). Wiadomość obecna i w +> Gmailu, i w Fastmailu (ta sama lista dyskusyjna, przekierowanie, CC na oba +> adresy) ma **ten sam Message-ID**. Kto pierwszy wstawi, ten ustala `source`; +> drugi jest cicho pominięty przez `ON CONFLICT DO NOTHING`. Jednocześnie +> `save_eml` zapisze **dwa** pliki `.eml` (w `gmail/…` i `fastmail/…`, bo ścieżka +> zawiera `source`), a `raw_ref` będzie wskazywał tylko na jeden z nich. + +To nie jest korupcja danych i nie blokuje niczego — treść trafia do indeksu raz, +co jest zachowaniem pożądanym. Ale ma dwa mierzalne skutki, o których trzeba +wiedzieć **zanim** ktoś zacznie się dziwić liczbom: + +- statystyki „nowe maile fastmail" będą systematycznie zaniżone o część wspólną, +- filtrowanie po `source` w retrievalu (dziś: `DEFAULT_SUMMARYLESS_SOURCES`) + przypisze taki mail do konta, które akurat wygrało wyścig. + +**Rekomendacja: zostawić zachowanie, dodać licznik.** Job niech raportuje +`envelopes_conflict_other_source` (konflikt na id, którego istniejący wiersz ma +inny `source`) — jedno zapytanie przy pominiętym wstawieniu, zero zmiany +semantyki, a zjawisko przestaje być niewidzialne. Zmiana klucza na +`(source, message_id)` jest odrzucona: łamie zamrożony kontrakt koperty, +wymagałaby przepisania 225 030 istniejących id i wszystkich `raw_ref`, a kupuje +duplikaty w indeksie — czyli dokładnie to, przed czym dedup chroni. + +### 2.5 Czego jeszcze brakuje, a nie widać na pierwszy rzut oka + +**(i) Nowe koperty muszą mieć `headers` od razu.** `mail_body_ingest.build_prefix` +(`ingest.py:344`) buduje prefiks chunka `Temat: … | Od: … | Data: …` +z `entities[type=headers]`, a przy ich braku podstawia `"(brak tematu)"` i `"?"` +— **cicho, bez błędu**. Historyczne koperty mają headers tylko dlatego, że +przejechał po nich osobny backfill. Gdyby poller wstawiał koperty tak jak +bulk-import (sam manifest załączników), każdy nowy mail dostałby bezużyteczny +prefiks i nikt by tego nie zauważył poza spadkiem jakości retrievalu. +**Poller musi wstawiać `headers` i `attachment` w jednym `entities` przy +INSERCIE**, wołając `gmail_header_backfill.parse_headers` z jego fallbackiem. + +**(ii) `mail_body_ingest` nie umie o źródle innym niż gmail.** +`fetch_envelopes` ma zaszyte `WHERE source = 'gmail'`. Potrzebny parametr +`--source` (lub `--sources` przyjmujące listę) — zmiana jednolinijkowa +w zapytaniu plus flaga CLI. + +**(iii) `fastmail` jest niewidoczny w domyślnym trybie wyszukiwania.** +`packages/kb-retrieval/src/kb_retrieval/retrieval.py:43`: + +```python +DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) +``` + +`kb-query` nie nadpisuje tej wartości (`services/kb-query/app/main.py:128` +nie przekazuje `summaryless_sources`). Ponieważ maile nie mają streszczeń, do +wyniku wchodzą **wyłącznie** przez gałąź „summaryless" trybu hybrid — a ta +filtruje `WHERE e.source = ANY($2)`. **Koperty fastmail wpadłyby do bazy, +zembedowały się i nie pojawiły w żadnym wyniku `/search`.** Poprawka to jedno +słowo (`("gmail", "fastmail")`), ale bez niej cała gałąź fastmail jest niema. +To jest najbardziej „cicha" pułapka w całym przedsięwzięciu. + +**(iv) Pre-fetch kluczy chunków nie skaluje się do trybu cyklicznego.** +`fetch_existing_chunk_keys` (`ingest.py:387`) robi +`SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1` +**bez ograniczenia** — dziś 389 012 wierszy. Zmierzone na żywo: ~1,0 s po +stronie serwera, ~26 MB na drucie (średnia długość id = 60 B), plus set 389 tys. +krotek w Pythonie (rzędu 60–90 MB RSS) — na PIHA, gdzie `free` pokazuje 2,4 GB +`available`. Dla jednorazowego plastra 50k to nic; dla joba odpalanego co godzinę +to marnotrawstwo rosnące razem z korpusem. Poprawka: zawęzić pre-fetch do kopert +z bieżącego zbioru roboczego (`WHERE model = $1 AND envelope_id = ANY($2)`). +Nieblokujące — `ON CONFLICT` i tak stanowi drugą linię obrony — ale należy +zrobić to razem z (ii), bo dotyka tej samej funkcji. + +--- + +## 3. Proponowana architektura + +### 3.1 Gdzie biegnie fetcher: **PIHA**, i to nie jest bliska decyzja + +| Kryterium | PIHA | SOLARIA | +|---|---|---| +| Dostępność | 24/7 | **wyłączana ~16 h/dobę z założenia** (`kb/decisions/architektura-2026-07-28.md`) | +| Archiwum kanoniczne | tak | lustro do backfillu, może dryfować | +| kb-postgres | lokalnie | przez Tailscale | +| GPU / Ollama | nie | tak | +| Precedens (`kb-ingest.timer`) | tak | — | + +Przyrostówka jest zadaniem **sieciowo-, nie obliczeniowo-zależnym**: pobrać +~37 maili, zapisać ~4,6 MB, wstawić 37 wierszy. Umieszczenie jej na hoście +wyłączanym na noc oznaczałoby, że stan skrzynki jest odczytywany tylko wtedy, +gdy ktoś akurat włączył desktop — i że archiwum kanoniczne żyje po drugiej +stronie sieci od procesu, który je zapisuje. Decyzja architektoniczna repo mówi +o SOLARII wprost: „nic wymagającego 24/7 nie może tu mieszkać". + +**Embedding zostaje na SOLARII, ale tylko jako wywołanie HTTP.** Zweryfikowane +na żywo z PIHA (2026-08-06 13:56): `getent hosts solaria` → +`100.100.231.104`, `GET http://solaria:11434/api/tags` → **HTTP 200 w 8,2 ms**, +RTT ICMP 1,26 ms. Przy dobowym przyroście rzędu 60 chunków (37 maili × 1,73 +chunka/mail, zmierzone: 389 012 / 225 030) i ~8–18 ms/chunk to **poniżej sekundy +pracy GPU na dobę**. Argument z planu §Decyzja 8 przeciwko embedowaniu z PIHA +dotyczył 271 tys. chunków backfillu i **nie przenosi się** na tę skalę. + +**Wniosek: rsync archiwum PIHA→SOLARIA nie jest przyrostówce do niczego +potrzebny.** Lustro na SOLARII pozostaje artefaktem backfillu; niech dryfuje +albo zniknie. + +### 3.2 Jak nowe koperty wchodzą w istniejący tor — dwa etapy, nie jeden + +**Odrzucam „tryb `--since`" jako mechanizm spinający.** `--since` filtruje po +`envelope.ts`, czyli po dacie z nagłówka nadawcy. Mail dostarczony dziś z datą +sprzed tygodnia wypadnie poza okno i nie zostanie nigdy zchunkowany — ta sama +wada, co wariant (C) w §2.2, tylko przesunięta o jeden krok dalej w potoku. +`--since` zostaje tym, czym był: narzędziem do etapowania ręcznych runów. + +Właściwym spoiwem jest **kolejka wynikająca z danych**: koperta bez chunków +*jest* elementem kolejki. Zapytanie: + +```sql +SELECT e.id FROM envelope e +WHERE e.source = ANY($1) + AND NOT EXISTS (SELECT 1 FROM document_chunk c WHERE c.envelope_id = e.id) +``` + +Jest samonaprawiające się (przerwany run, koperta pominięta przy padniętej +Ollamie, mail wstawiony z datą wsteczną — wszystko wraca do kolejki samo) +i nie wymaga żadnego dodatkowego stanu. To wprost wzorzec, którym `kb-ingest` +liczy `kb_ingest_embed_backlog`. + +Stąd **dwa niezależne kroki**, celowo rozdzielone po linii „potrzebuje GPU": + +``` +kb-mail-sync.timer (PIHA, co godzinę, bez GPU) + └─ jobs/mail-imap-sync + IMAP LOGIN → SELECT folder → UID FETCH last_uid+1:* + → save_eml() (append-only, FileExistsError = skip) + → insert_envelope() (entities = [headers…, attachment…]) + → UPDATE mail_sync_state.last_uid + → .prom + +kb-ingest.timer (PIHA, istniejący, bramkowany probe'em Ollamy) + └─ + nowy etap: mail_body_ingest.run(sources=[...], only_unchunked=True) + → chunk → embed (http://solaria:11434) → document_chunk + threading +``` + +Zaletą tego podziału jest to, że **pobieranie nigdy nie czeka na SOLARIĘ**. +Maile lądują w archiwum i w kopertach co godzinę niezależnie od tego, czy +desktop jest włączony; indeksowanie dogania, gdy GPU jest dostępne — dokładnie +tak, jak dziś działa paperless. Nowy kod ogranicza się do klienta IMAP; reszta +to wpięcie istniejących funkcji. + +### 3.3 Harmonogram — i pułapka odziedziczona po `kb-ingest.timer` + +Wzorzec jednostek: `jobs/documents-ingest/systemd/` — `.service` (`Type=oneshot`, +`User=oskar`, `EnvironmentFile=/opt/homelab/kb/.env`) + `.timer` +(`Persistent=true`) + cienki `*-run.sh`, którego jedynym zadaniem jest +przekierowanie do `/opt/homelab/logs//run-YYYYMMDD.log` (nigdy sam +journal — lekcja z runu, który stracił 4999 wierszy do zamkniętego tmuxa). + +Proponowany takt: **`OnCalendar=hourly`** dla `kb-mail-sync`. Uzasadnienie: +przy ~37 mailach na dobę godzinny tick pobiera 1–2 wiadomości, czyli jest +praktycznie darmowy, a jednocześnie utrzymuje świeżość KB w granicach godziny — +co ma znaczenie dla przyszłego dyspozytora (§5). `Persistent=true` nadgania po +reboocie PIHA. + +> **Znalezisko — `kb-ingest.timer` chodzi o 03:30, kiedy SOLARIA prawie na pewno +> śpi.** Dowód z żywego systemu (PIHA, 2026-08-06): +> `NEXT Fri 2026-08-07 03:30, LAST Thu 2026-08-06 03:30:03` oraz zawartość +> `/opt/homelab/state/node-exporter/kb-ingest.prom`: +> `kb_ingest_embed_skipped 1`, `kb_ingest_embed_backlog 0`. +> Dzisiejszy tick **pominął oba etapy embedujące**, bo probe Ollamy nie +> odpowiedział. Dziś to nieszkodliwe: paperless nie generuje nowych chunków, więc +> backlog stoi na zerze i `KbEmbedBacklogGrowing` (próg: `> 0` przez `for: 72h`) +> nigdy nie ma czego mierzyć. +> +> **Dopięcie etapu mailowego do tego samego ticku zmienia to jakościowo.** +> Wpłynie ~60 nowych chunków na dobę, embed będzie pomijany każdej nocy, +> backlog zacznie rosnąć monotonicznie i po 72 h alert zapali się **na stałe** — +> nie sygnalizując żadnej awarii, tylko rozjazd harmonogramu z dobowym cyklem +> SOLARII. Alert, który świeci zawsze, przestaje być alertem. + +Do rozstrzygnięcia razem z Decyzją **(d)**. Najtańsza poprawka: przesunąć etap +mailowy (albo cały `kb-ingest`) na godzinę, o której SOLARIA realnie pracuje — +w chwili tego reconu jest włączona od ~10:40, `uptime` 3 h 12 min. Alternatywa +bez zgadywania: uruchamiać etap indeksujący częściej (np. co 2 h) i pozwolić, +by probe Ollamy sam wybrał okno, w którym GPU odpowiada. Ta druga opcja jest +odporna na zmianę nawyków operatora i nie wymaga zgadywania, o której SOLARIA +wstaje — **rekomenduję ją.** + +### 3.4 Obserwowalność — istniejący tor, bez nowych bytów + +Tor jest gotowy i sprawdzony: job pisze plik `.prom` atomowo (tmp + rename) do +`/opt/homelab/state/node-exporter/`, node_exporter@PIHA go zbiera przez +textfile collector (mount `/:/host:ro` już to pokrywa), reguły idą do +`services/fleet-prometheus/rules/`, a dostawę do Telegrama robi brain-watchdog +odpytujący `/api/v1/alerts`. **Bez Alertmanagera** — tak jak `kb-ingest.yml` +i `liveness.yml`. + +Proponowane metryki w `/opt/homelab/state/node-exporter/kb-mail-sync.prom`: + +| Metryka | Typ | Znaczenie | +|---|---|---| +| `kb_mail_sync_last_run_timestamp` | gauge | ostatni tick (sukces lub nie) | +| `kb_mail_sync_last_success_timestamp` | gauge | ostatni tick bez twardego błędu | +| `kb_mail_sync_last_exit_code` | gauge | kod wyjścia | +| `kb_mail_sync_envelopes_inserted{account}` | gauge | nowe koperty w tym ticku | +| `kb_mail_sync_envelopes_skipped_dup{account}` | gauge | dedup po Message-ID | +| `kb_mail_sync_conflict_other_source{account}` | gauge | kolizja międzykontowa z §2.4 | +| `kb_mail_sync_last_message_ts{account}` | gauge | `max(envelope.ts)` dla konta | +| `kb_mail_sync_uidvalidity_resets{account}` | counter | ile razy serwer unieważnił numerację | + +Reguły alertowe — i tu jest realna subtelność: + +- `KbMailSyncStale`: `time() - kb_mail_sync_last_success_timestamp > 21600` + (6 h = 6 nieudanych godzinnych ticków), severity critical. **To jest właściwy + alert.** +- **„Zero nowych maili przez X dni" odradzam jako alert.** Zero nowych maili jest + legalnym stanem skrzynki — urlop, weekend, przeniesienie ruchu na drugie konto. + Alert na ciszę zapali się przy zdrowym systemie i zostanie wyciszony, po czym + przestanie działać wtedy, gdy będzie potrzebny. Ten sam skutek daje sygnał, + który **nie ma fałszywych trafień**: `kb_mail_sync_last_success_timestamp` + odpowiada na pytanie „czy poller w ogóle działa", i to niezależnie od tego, + czy cokolwiek przyszło. +- Jeśli mimo to operator chce ostrzeżenie o „martwej skrzynce", właściwą metryką + jest `kb_mail_sync_last_message_ts` (data najnowszego maila, nie licznik ticku) + z progiem **per konto** i severity `warning` — gmail jest kontem śmieciowym, + więc dla niego kilkudniowa cisza to anomalia, a dla fastmaila niekoniecznie. + Sugerowany próg gmail: 7 dni. Do rozstrzygnięcia po miesiącu obserwacji, nie + z góry. + +--- + +## 4. Decyzje operatora + +### (a) Gmail: IMAP + hasło aplikacji **czy** Gmail API / OAuth2 + +**Rekomendacja: IMAP + hasło aplikacji.** + +- Zasada z `kb-overview.md` §61 mówi wprost: *„Protokół, nie provider. Ingest + pisany przeciw standardom (JMAP, IMAP, WebDAV), nie przeciw firmie → + przenośność"*. `kb-mail-pillar.md` §2 powtarza to dla Gmaila: *„adapter IMAP + (protokół, nie Gmail API → przenośność)*". Ta decyzja jest w repo już podjęta; + recon jej nie zmienia, tylko potwierdza, że nic nowego nie każe jej rewidować. +- Jeden adapter obsłuży oba konta. Gmail API dałby drugi, nieprzenośny tor kodu + dla konta, które sam operator opisuje jako **śmieciowe** (loginy, 2FA, + newslettery) — najgorszy możliwy stosunek nakładu do wartości. +- OAuth2 wymaga projektu w Google Cloud, ekranu zgody, cyklu odświeżania tokenu + i obsługi jego wygaśnięcia w jobie bezobsługowym. Hasło aplikacji to jeden + ciąg znaków w pliku `.env`, który już istnieje i już trzyma inne sekrety. +- **[do weryfikacji na żywo]** Hasła aplikacji wymagają włączonego 2FA na koncie + i bywają wyłączane politykami Workspace; przy koncie prywatnym powinny być + dostępne. Warto sprawdzić przy okazji, czy Google nie ogłosiło wygaszenia tej + metody — mój stan wiedzy sięga maja 2026 i **nie jest wiarygodnym źródłem dla + polityki dostawcy z sierpnia 2026**. Gdyby okazało się, że hasła aplikacji + odpadły, OAuth2 staje się przymusem, nie wyborem — reszta architektury się nie + zmienia, wymianie podlega wyłącznie sposób uwierzytelnienia w kliencie. +- **[do weryfikacji na żywo]** Gmail limituje dobowy transfer IMAP (rzędu + kilku GB). Przy ~1 800 zaległych maili (~225 MB) i ~4,6 MB/dobę bieżąco nie + ma to znaczenia, ale gdyby ktoś kiedyś chciał zaciągnąć IMAP-em pełną + historię — miałby. + +### (b) Fastmail: IMAP + hasło aplikacji + +**Rekomendacja: tak, IMAP + hasło aplikacji — i tym samym zamykamy otwartą +decyzję „unifikacja adaptera".** + +To nie jest formalność, bo **stoi w sprzeczności z tym, co repo dziś zapisuje**: +`kb-mail-pillar.md` §2 i §7 oraz §10 planu fazy mailowej przewidują dla +Fastmaila **JMAP** (`jobs/fastmail-poller`), a `kb-mail-pillar.md` §9 trzyma to +jako decyzję otwartą: *„jeden wspólny IMAP dla Fastmail + Gmail (mniej kodu) vs +JMAP dla Fastmail + IMAP dla Gmail (JMAP bogatszy)?"*. Zlecenie tego reconu +mówi „IMAP (gmail + fastmail)", czyli rozstrzyga tę decyzję na rzecz unifikacji. +Recon się z tym zgadza: + +- JMAP daje synchronizację po `state` — elegancką i **niepotrzebną przy dwóch + ticku na godzinę i 37 mailach na dobę**. UIDVALIDITY/UIDNEXT rozwiązuje ten + sam problem, a jest jedyną rzeczą, którą trzeba i tak zaimplementować dla + Gmaila. +- Jeden adapter = jeden zestaw testów, jedna klasa błędów, jedna ścieżka + hardeningu 8-bitowych nagłówków. Dwa protokoły to dwa razy tyle powierzchni + przy identycznym wyniku w bazie. +- Fastmail wystawia pełny IMAP i hasła aplikacji z ograniczonym zakresem + (można wydać poświadczenie tylko-IMAP, bez dostępu do panelu) — **[do + weryfikacji na żywo]**, ale jeśli tak, jest to *ściślejsze* uprawnienie niż + token JMAP. +- JMAP nie jest zamknięty na zawsze: koperta i archiwum są protokołowo obojętne, + więc wymiana transportu w przyszłości nie dotyka danych. + +**Konsekwencja dokumentacyjna:** po zatwierdzeniu trzeba poprawić +`kb-mail-pillar.md` §2/§7/§9 i §10 planu fazy mailowej, żeby nie zostawiać +w KB dwóch sprzecznych zapisów. Nazwy jobów z tamtych dokumentów +(`jobs/fastmail-poller` + `jobs/gmail-imap-poller`) tracą sens przy jednym +adapterze — proponuję **jeden `jobs/mail-imap-sync`** sparametryzowany kontem. + +### (c) Sekrety — gdzie trzymać hasła + +> **Sprostowanie założenia.** Zlecenie mówi, że `POSTGRES_PASSWORD` leży dziś +> plaintextem w `services/*/service.yaml`. **Tak nie jest.** `service.yaml` +> wymienia wyłącznie *nazwy* zmiennych (`runtime.env_vars: [POSTGRES_PASSWORD]` +> — `services/kb-postgres/service.yaml:22`), pliki `env.example` zawierają +> placeholdery (`POSTGRES_PASSWORD=change-me-strong-password`), a `.gitignore` +> blokuje `.env` i `*.env`. Skan wszystkich śledzonych plików YAML pod kątem +> wartości wyglądających na sekrety (≥12 znaków, po odsianiu placeholderów) +> zwrócił **zero trafień**. Wzorzec repo jest poprawny i to jego należy użyć, +> a nie zastępować. + +**Rekomendacja: rozszerzyć istniejący `/opt/homelab/kb/.env` na PIHA.** + +Zweryfikowane na żywo: plik istnieje, ma uprawnienia **`-rw------- root root`**, +262 bajty, i trzyma już `KB_DSN`, `PAPERLESS_API_TOKEN` oraz +`ANTHROPIC_API_KEY`. `kb-ingest.service` czyta go przez +`EnvironmentFile=/opt/homelab/kb/.env`, mimo że sam biegnie jako `User=oskar` — +systemd wczytuje `EnvironmentFile` **jako root, przed zrzuceniem uprawnień**. +Efekt: sekret jest wstrzykiwany do procesu, ale **nie jest czytelny dla +użytkownika `oskar` w spoczynku**. To jest lepsza własność, niż dałby plik +`600 oskar:oskar`, i warto ją zachować świadomie, a nie przypadkiem. + +Dokładamy cztery klucze: + +``` +MAIL_GMAIL_USER=… +MAIL_GMAIL_APP_PASSWORD=… +MAIL_FASTMAIL_USER=… +MAIL_FASTMAIL_APP_PASSWORD=… +``` + +Repo dostaje **wyłącznie** `jobs/mail-imap-sync/env.example` z placeholderami — +dokładnie jak `services/*/env.example`. + +Zasady wykonawcze, wszystkie wyprowadzone z tego, co już się w tym repo +wydarzyło: + +1. **Nigdy w argv.** `--dsn ` trafia do `ps` i do historii powłoki. + Istniejące joby dopuszczają `--dsn`, ale honorują też `KB_DSN` z env — + nowy job niech przyjmuje poświadczenia **tylko** ze środowiska, bez + odpowiednika `--password`. +2. **Nigdy do transkryptu.** W repo są **dwa** udokumentowane przypadki rotacji + po wycieku do transkryptu sesji: token Paperless (`docs/sessions/2026-07-15.md` + §Krok 3) i klucz Anthropic (`docs/sessions/2026-07-21.md`, incydent 3). + To nie jest hipotetyczne ryzyko — to jedyny sposób, w jaki sekrety w tym + homelabie dotąd wyciekały. Instalacja hasła musi się odbyć bez wypisywania go + na ekran. +3. **Hasła aplikacji, nie hasła kont** — odwoływalne pojedynczo, bez dostępu do + ustawień konta i bez omijania 2FA. +4. **Bez własnego szyfrowania.** SOPS/age/vault to nowy byt operacyjny (klucze, + dystrybucja, odtwarzanie po awarii) dla jednego pliku na jednym hoście. + Nieproporcjonalne, i wprost sprzeczne z „użyj istniejących mechanizmów". + +Odrzucone: sekrety w `hosts//runtime/…` (śledzone w Gicie); +`docker secret` (wymaga Swarma, którego repo świadomie nie używa — +patrz konwencja `mem_limit` w CLAUDE.md); osobny plik per job (rozdrabnia to, +co już jest scentralizowane i poprawnie uprawnione). + +### (d) Host schedulera + +**Rekomendacja: PIHA** — pełne uzasadnienie w §3.1 (24/7, archiwum kanoniczne, +lokalny kb-postgres, istniejący precedens `kb-ingest.timer`, decyzja +architektoniczna zakazująca stawiania na SOLARII czegokolwiek, co wymaga 24/7). + +**Do rozstrzygnięcia razem z tym: takt indeksowania wobec dobowego cyklu +SOLARII** (§3.3). Rekomendacja: pobieranie co godzinę (`kb-mail-sync`, bez GPU), +indeksowanie częściej niż raz na dobę i bramkowane probe'em Ollamy, żeby +któryś tick trafił w okno pracy desktopu. Bez tego backlog embedów rośnie +każdej nocy i `KbEmbedBacklogGrowing` zapala się na stałe. + +### (e) Zakres folderów per konto + +**Rekomendacja:** + +| Konto | Foldery | Uzasadnienie | +|---|---|---| +| gmail | **wyłącznie `\All`** (SPECIAL-USE, nie nazwa) | jedna wiadomość = jeden fetch mimo wielu etykiet; ta sama populacja co Takeout, na którym stoi 225 030 istniejących kopert → ciągłość korpusu | +| fastmail | `INBOX` + `Archive` + `Sent` | brak odpowiednika `\All`; te trzy pokrywają korespondencję prowadzoną i zarchiwizowaną | + +Wyłączone z obu kont: `Spam`, `Trash`, `Drafts`. Spam i Trash to zdefiniowany +szum, a ich włączenie zmieniłoby populację względem tego, co już jest w bazie +(plan §Decyzja 4 odnotowuje, że Takeout „All Mail" nie zawiera Spamu). Drafty +nie są korespondencją — nie mają stabilnego Message-ID i mutują. + +Zakres **musi być konfigurowalny listą per konto** (`MAIL__FOLDERS`), +bo tabela stanu jest kluczowana `(account, folder)`. Rozszerzenie o kolejny +folder jest wtedy zmianą konfiguracji, nie kodu, a dedup po Message-ID +gwarantuje, że dołożenie folderu z częściowo pokrywającą się zawartością niczego +nie zduplikuje. + +**Osobne pytanie, którego nie rozstrzygam za operatora: historia Fastmaila.** +Konto ma zawartość sprzed dziś, a przyrostówka domyślnie zaczyna od +„od teraz" (`last_uid` = `UIDNEXT-1` przy pierwszym `SELECT`). Dwie opcje: + +- **(e1) Tylko nowe.** Pierwszy tick zapisuje `last_uid` i nie pobiera nic + wstecz. Najprostsze, natychmiastowe, historia zostaje poza KB. +- **(e2) Pełny zaciąg.** Pierwszy przebieg z `UID SEARCH ALL` ściąga całą + zawartość wybranych folderów. Koszt zależy od rozmiaru skrzynki, którego + **nie znam** — to jest dokładnie ta „decyzja otwarta: sizing", którą + `kb-mail-pillar.md` §9 trzyma niezamkniętą od czerwca. + +**Rekomendacja: (e2), ale dopiero po pomiarze.** Historia Fastmaila to +prawdopodobnie ta „sensowna poczta", o którą operatorowi chodziło bardziej niż +o gmailowe newslettery — a mechanizm jest ten sam co dla przyrostu, więc nie +kosztuje osobnego kodu. Warunek: najpierw jedno bezpieczne zapytanie +(`SELECT` folderu + `STATUS (MESSAGES)`), które poda liczbę wiadomości, i +dopiero na tej liczbie decyzja. Wykonalne w minutę **po** postawieniu klienta — +nie ma sensu zgadywać teraz. + +### (f) Nowa decyzja, której zlecenie nie wymieniało: model stanu synca + +Wypływa z §2.2 i wymaga zgody, bo dokłada **migrację `005`** do bazy, o której +faza mailowa deklarowała „zero migracji". + +**Rekomendacja: tabela `mail_sync_state` w kb-postgres** (wariant A), nie plik +w `/opt/homelab/state/`. Powód rozstrzygający: stan synca i dane, którym +odpowiada, muszą się odtwarzać razem. Plik stanu przeżywający restore bazy +sprawia, że przyrostówka **cicho przeskakuje** wszystko między odtworzonym +stanem a bieżącym `last_uid` — awaria bez objawów, wykrywalna dopiero przy +zauważeniu brakujących maili miesiące później. + +### (g) Nowa decyzja: `fastmail` w domyślnym trybie wyszukiwania + +Z §2.5 (iii). `DEFAULT_SUMMARYLESS_SOURCES = ("gmail",)` sprawia, że koperty +fastmail byłyby niewidoczne w `/search` mimo poprawnego zembedowania. + +**Rekomendacja: rozszerzyć do `("gmail", "fastmail")` w tym samym commicie, +który wprowadza źródło `fastmail`** — nie później. Rozdzielenie tych zmian tworzy +okno, w którym system wygląda na działający, a wyszukiwarka po cichu gubi całe +źródło. Zmiana jest jednowierszowa i nie ma wpływu na paperless (idzie kaskadą) +ani na inwariant startowy `kb-query` (sprawdza model embeddera, nie źródła). + +--- + +## 5. Zależności z reconem multiagentowym i planem dyspozytora + +Sprawdzony dokument: `docs/architecture/RECON-multiagent-2026-07-27.md` +**już nie istnieje pod tą ścieżką** — migracja OKF przeniosła go commitem +`00de810` do `kb/subsystems/recon-multiagent.md` (`type: subsystem`). +Analogicznie recony-audyty poszły commitem `9f77a72` do `kb/audits/` +(`type: audit`, wymagane pole `as_of`) — dlatego ten dokument leży +w `kb/audits/`, nie w `docs/architecture/`. + +**Wprost: planu dyspozytora w tym reconie nie ma.** Dokument dotyczy w całości +subsystemu A (control-plane, node-agenty, MQTT, event pipeline, topologia). +`kb-query` pojawia się w nim wyłącznie jako wiersz inwentarza („kb-query | piha", +linia 129) i jako rozjazd topologii (linia 463: `topology.yaml` pomija +kb-query, choć `hosts/piha` go ma). Fraza „dyspozytor" nie występuje. + +Dyspozytor jest zdefiniowany gdzie indziej — +`kb/decisions/architektura-2026-07-28.md`: *„B — do-the-work: dyspozytor zadań +(agent) + nogi KB / Home Assistant / homelab-ops. **Osobny wysiłek, osobny +projekt**"*, z PIHA jako *„przyszłym domem dyspozytora subsystemu B"*. + +**Zależności twardych (blokujących) nie ma w żadną stronę.** Przyrostówka pisze +do `envelope`/`document_chunk`; kb-query czyta. Kontrakt między nimi to schemat +bazy, który się nie zmienia. Są natomiast **cztery realne styki**, które warto +mieć zapisane: + +1. **Wybór hosta jest zgodny z docelową architekturą.** Dyspozytor ma zamieszkać + na PIHA; przyrostówka też tam trafia. Świeżość korpusu i jego konsument będą + w tym samym miejscu, bez nowych przeskoków sieciowych. +2. **Świeżość jest warunkiem sensowności dyspozytora.** Agent odpowiadający na + „co pisał X w zeszłym tygodniu" na korpusie urwanym 2026-06-19 zwróci + pewną siebie i nieprawdziwą odpowiedź. Przyrostówka jest tym, co zamienia + KB z archiwum w źródło bieżące — i to jest jej główny związek z subsystemem B. +3. **Wiki-kompilat jest zablokowany wprost.** `kb/phases/kb-m5-faza3.md:620`: + *„Pełna wiki po fazie mailowej (przyrostówka) — wcześniej kompilat byłby + fotografią przeszłości"*. To jedyna znaleziona **jawna** zależność „X czeka na + przyrostówkę" w całym KB. +4. **Nowy timer powiększa „shadow set" z otwartego pytania nr 5 tamtego reconu.** + Recon multiagentowy wymienia `kb-ingest` wśród bytów instalowanych **poza** + GitOps-owym wykrywaniem dryfu (*„shadow-deploy family: stability-agent, + agent-system, frigate, kb-ingest, HA config push"*) i stawia decyzję: co jest + w zakresie detekcji dryfu. `kb-mail-sync.timer` dołoży się do tej samej listy. + Nie blokuje to niczego i nie jest powodem do zmiany planu — ale przy + rozstrzyganiu pytania 5 lista będzie o jedną pozycję dłuższa i lepiej, żeby + trafiła tam świadomie niż przez przeoczenie. + +Dla `kb-query` „API bez UI" przyrostówka nie zmienia **nic** po stronie +kontraktu HTTP: `/search` przyjmuje `q` i `mode`, a źródła są wewnętrznym +szczegółem retrievalu. Jedyny punkt styku to Decyzja **(g)** — bez niej nowe +źródło nie pojawi się w odpowiedziach ani przez UI, ani przez API. + +--- + +## 6. Podsumowanie i zakres pracy + +**Stan wyjściowy.** Korpus mailowy jest kompletny i zaindeksowany — do +**2026-06-19**. Od 48 dni nie przyrasta, i nie przyrośnie, bo w repo nie ma ani +jednej linii kodu rozmawiającej z serwerem pocztowym. Przy zmierzonym tempie +~37 maili/dobę poza KB jest już ~1 800 wiadomości, a licznik bije dalej. + +**Co trzeba zbudować** (szacunek: 2 sesje, w tym testy): + +| # | Element | Rodzaj | +|---|---|---| +| 1 | `jobs/mail-imap-sync` — klient IMAP (stdlib `imaplib`), pętla UIDVALIDITY/UIDNEXT, wybór folderu po SPECIAL-USE | nowy kod | +| 2 | Migracja `005_mail_sync_state.sql` | nowy plik | +| 3 | Wstawianie kopert z `entities = [headers, attachment]` w jednym kroku (reuse `parse_headers` + `parse_headers_fallback`) | reuse | +| 4 | `--source` / `--sources` w `mail_body_ingest.fetch_envelopes` + tryb „koperty bez chunków" | 2 małe zmiany | +| 5 | Zawężenie `fetch_existing_chunk_keys` do zbioru roboczego | 1 mała zmiana | +| 6 | `DEFAULT_SUMMARYLESS_SOURCES` += `"fastmail"` | 1 wiersz | +| 7 | Licznik `envelopes_conflict_other_source` | 1 mała zmiana | +| 8 | `systemd/kb-mail-sync.{service,timer}` + `kb-mail-sync-run.sh` | wzorzec 1:1 z `documents-ingest/systemd/` | +| 9 | Metryki `.prom` + reguła `KbMailSyncStale` w `services/fleet-prometheus/rules/` | wzorzec 1:1 z `kb-ingest.yml` | +| 10 | `env.example` + rozszerzenie `/opt/homelab/kb/.env` na PIHA | placeholdery w repo, wartości poza | + +Punkty 4–7 to cztery drobne zmiany w istniejącym kodzie; **cały właściwy nowy +kod to pozycja 1**. Reszta pipeline'u — archiwum, koperta, parse z hardeningiem +8-bitowym, quote-strip, chunker, batch embed z breakerem i bisekcją, tor +Prometheus — jest zbudowana, przetestowana i przećwiczona na 225 tysiącach +wiadomości. + +**Trzy rzeczy, które łatwo przeoczyć, a każda kosztuje cicho:** + +1. Koperty bez `entities[type=headers]` dostają prefiks + `Temat: (brak tematu) | Od: ?` — **bez błędu**, tylko z gorszym retrievalem + (§2.5 i). +2. `fastmail` nieujęty w `DEFAULT_SUMMARYLESS_SOURCES` znika z wyników mimo + poprawnego zembedowania — **bez błędu** (§2.5 iii). +3. Etap mailowy dopięty do ticku 03:30 zapali `KbEmbedBacklogGrowing` na stałe, + bo o tej porze SOLARIA śpi — potwierdzone dziś odczytem + `kb_ingest_embed_skipped 1` (§3.3). + +**Do decyzji operatora przed startem:** (a) Gmail IMAP+app password, +(b) Fastmail IMAP+app password — co domyka otwartą od czerwca decyzję +„unifikacja adaptera" i **wymaga korekty** zapisów o JMAP w +`kb-mail-pillar.md` i §10 planu, (c) sekrety w istniejącym +`/opt/homelab/kb/.env` (**założenie zlecenia o plaintextowych hasłach w repo +jest nieprawdziwe** — obecny wzorzec jest poprawny), (d) PIHA + korekta taktu +indeksowania, (e) `\All` dla Gmaila i `INBOX`+`Archive`+`Sent` dla Fastmaila, +plus osobno historia Fastmaila po pomiarze rozmiaru skrzynki, (f) stan synca +jako tabela w bazie, (g) `fastmail` w domyślnym trybie wyszukiwania. diff --git a/kb/phases/kb-m5-faza-mailowa.md b/kb/phases/kb-m5-faza-mailowa.md index bb1059b..7e7a6c6 100644 --- a/kb/phases/kb-m5-faza-mailowa.md +++ b/kb/phases/kb-m5-faza-mailowa.md @@ -13,7 +13,10 @@ links: [] > retrieval, Etap A apply na żywej bazie, bramka jakościowa **PASS** — patrz §8). > **Etap B (Krok 6) ZAMKNIĘTY 2026-08-06**: pełny korpus gmail jest zchunkowany > i zembedowany (389 012 chunków, zero nie-excluded bez wektora) — patrz §9 -> „Wynik Etapu B". Następny: Krok 7 (recon przyrostówki IMAP/JMAP). +> „Wynik Etapu B". **Krok 7 (recon przyrostówki) WYKONANY 2026-08-06**: +> `kb/audits/mail-sync-2026-08-06.md` — czeka na decyzje operatora (a)-(g), +> w tym rozstrzygnięcie IMAP vs JMAP dla Fastmaila (§10 niżej mówi JMAP, +> recon rekomenduje wspólny IMAP). > > Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone, > serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez @@ -695,6 +698,15 @@ oznacza co innego: backend embed padł, trzeba wznowić plaster po naprawie Olla ## 10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon) +> **Recon wykonany 2026-08-06: `kb/audits/mail-sync-2026-08-06.md`.** Zarys +> poniżej pochodzi z 2026-07-22 i zachowuję go jako zapis intencji. Recon +> rozstrzyga inaczej dwa jego punkty: (1) **Fastmail przez IMAP, nie JMAP** +> (unifikacja adaptera — jeden `jobs/mail-imap-sync` zamiast +> `fastmail-poller` + `gmail-imap-poller`), (2) spoiwem z torem body nie jest +> `--since`, tylko kolejka „koperty bez chunków". Reszta zarysu (poll zamiast +> IDLE, reuse `save_eml`/`insert_envelope`, sekrety w `/opt/homelab/config/`) +> się potwierdziła. + Zakotwiczone w kb-00 jako etapy 3–4 (`jobs/fastmail-poller`, `jobs/gmail-imap-poller`). Zarys decyzji do tamtego reconu: @@ -734,7 +746,7 @@ Zakotwiczone w kb-00 jako etapy 3–4 (`jobs/fastmail-poller`, | 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 | | 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) | | 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78`; `docs/sessions/2026-08-06-kb-etapb-backfill.md` | -| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **OTWARTE — NEXT** | Odblokowane przez zamknięcie Etapu B. Brak `jobs/fastmail-poller` / `jobs/gmail-imap-poller`, brak dokumentu reconu; IMAP/JMAP występuje wyłącznie jako zarys w §10 i w `kb-00-overview.md` | +| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **WYKONANE** (2026-08-06) | `kb/audits/mail-sync-2026-08-06.md`. Ustalenia: korpus urywa się 2026-06-19 (dziura 48 dni ≈ 1 800 maili), zero kodu IMAP w repo, brak modelu stanu synca; cały nowy kod to jeden `jobs/mail-imap-sync` + 4 drobne zmiany w istniejącym torze. Do decyzji operatora: (a)-(g) w §4 tamtego dokumentu | **Kryterium ukończenia fazy mailowej:** (a) pełny korpus gmail zchunkowany (bilans domknięty, `parse_errors` na poziomie pojedynczych sztuk jak