diff --git a/docs/kb/modules/05-faza-mailowa-plan.md b/docs/kb/modules/05-faza-mailowa-plan.md index 74749b7..4a9d20a 100644 --- a/docs/kb/modules/05-faza-mailowa-plan.md +++ b/docs/kb/modules/05-faza-mailowa-plan.md @@ -1,9 +1,9 @@ # Moduł 5, faza mailowa — treść maili w retrievalu (RECON + PLAN) -> Status: RECON ZAKOŃCZONY (2026-07-22), plan DO ZATWIERDZENIA. Zero kodu, zero -> migracji, zero pełnych runów w ramach tego zadania — wyłącznie ten dokument -> (recon obejmował pomiary read-only: próbka 600 .eml na PIHA, benchmark -> `/api/embed` na SOLARII, `\d` + SELECT count na żywej bazie). +> Status (2026-07-23): Kroki 0-4 WYKONANE na żywo (chunker wydzielony, hybrid +> retrieval, Etap A apply na żywej bazie), Krok 5 (bramka jakościowa) **PASS** +> — patrz §8 dla liczb i werdyktu. Etap B (pełne archiwum) i Krok 7 (recon +> IMAP/JMAP) wciąż przed nami. > > 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 @@ -476,6 +476,35 @@ WHERE e.source = 'gmail' GROUP BY 1; **Szacunek: 1 sesja (w tym czas runu <1 h).** +### Wynik Etapu A (WYKONANE na żywo, 2026-07-23) + +`--since 2025-07-01 --apply` odpalony ręcznie przez operatora na SOLARII → +PIHA: + +| Miara | Wartość | +|---|---| +| `mails_scanned` | 13 009 | +| `document_chunk` (nowe, gmail) | 33 871 | +| — z embeddingiem (bge-m3) | 6 398 | +| — `excluded_reason='newsletter'` (bez embeddingu) | 27 473 | + +Bilans domknięty (`run_complete`), drugi przebieg tego samego runu — +idempotentny (zero nowych insertów, wszystko `chunks_already_embedded` / +`chunks_conflict_skipped`). Newsletter-udział w tym wycinku (~81% chunków) +wyższy niż ekstrapolacja z §1.3 (48,5% wolumenu tekstu) — spodziewane, bo +Etap A to najświeższy rok, a §1.3 już to sygnalizował („w dekadzie 202x aż +64% maili ma sygnał newslettera"). + +**Incydent Ollama #4** (w trakcie runu): kontener Ollama@SOLARIA padł +w trakcie embedowania — ten sam wzorzec co `solaria-ollama-network-incident` +(3 wcześniejsze incydenty w tydzień, §1.4/§1.5) — `docker start`/`restart` +nie przywrócił sieci kontenera, wymagane było `compose down` + `up` (force +recreate). Run wznowiony bez utraty danych dzięki idempotencji +(pre-fetch kluczy + `ON CONFLICT DO NOTHING`) — dokładnie po to ten wzorzec +jest w §1.5 obowiązkowy. Task `ollama-solaria-start-race` (backlog) czeka na +naprawę korzenia — Ollama nie powinna wymagać ręcznej interwencji przy +starcie/restarcie. + ## 8. Krok 5 — bramka jakościowa fazy mailowej Rozszerzenie `jobs/documents-ingest/eval/queries.yaml` + `retrieval_eval.py`: @@ -499,6 +528,47 @@ Rozszerzenie `jobs/documents-ingest/eval/queries.yaml` + `retrieval_eval.py`: **Szacunek: 1 sesja (+ wejście od operatora).** +### Wynik bramki (WYKONANE, 2026-07-23) — na żywej bazie po Etapie A + +Operator dopisał 5 zapytań mailowych do `mail_queries` w `queries.yaml` +(M1-M4 zostały, M5 odrzucone — patrz niżej). Pierwszy przebieg bramki ujawnił +bug w `retrieval_eval.py`: `hit_at_3` zwracał `None` dla `kind: mail_hit`, +bo te zapytania mają `expected_envelope: null` (operator dał treść zapytania, +nie Message-ID) — kryterium 4 liczyło to jako brak trafienia zamiast +sprawdzać właściwą semantykę. Naprawa: nowa funkcja `mail_hit_at_3` (hit iff +top-3 hybrid zawiera wynik z `envelope.source` w `summaryless_sources`, czyli +gmail, z `dist < 0.45`), z lookupem `envelope.source` per top-3 envelope +(`fetch_envelope_sources`, bo `hybrid_retrieve` nadpisuje `source` na +`"hybrid"` przy scalaniu i traci pochodzenie chunku). + +Po naprawie, werdykt bramki: + +| Kryterium | Wynik | Werdykt | +|---|---|---| +| 1: zero regresji flat hitów (cascade + hybrid) | 5/5 istniejących hitów bez degradacji | PASS | +| 2: hit@3 cascade ≥ flat | cascade 5/5, flat 4/5 | PASS | +| 3: kontrole negatywne > 0,55 (N, N2) | N=0,5983; N2=0,5298 (próg N2 obniżony do 0,50, patrz niżej) | PASS | +| 4: hit@3 hybrid dla mail_queries ≥ 4/5 (po odrzuceniu M5: ≥4/4) | M1-M4 wszystkie hit (dist 0,25-0,42) | PASS | + +**OVERALL: PASS.** + +**N2 ("piaskownica plastikowa") — znalezisko i decyzja.** Po dolaniu 34k +chunków mailowych top-1 sąsiad N2 spadł do dist 0,5298 (< dawny próg 0,55, +pilot: 0,5533). Weryfikacja treści (top-3 hybrid z pełnym tekstem chunka) +pokazała, że to **kolizja semantyczna, nie realne trafienie**: top-1 to +newsletter szkoły narciarskiej (rozmiary nart Rossignol 155-181cm, dane +kontaktowe instruktora) — zero związku z piaskownicą. Operator wstępnie +dodał M5 ("piaskownica plac zabaw wspólnota") zakładając realny mail na +temat, ale M5 samo nie trafia (dist 0,5585, miss) — korpus mailowy Etapu A +nie zawiera nic o piaskownicy. Decyzja operatora: M5 odrzucone (nie testuje +niczego realnego), próg N2 w bramce obniżony do 0,50 z notą o kolizji +ski-newsletter w `queries.yaml` (żeby ten znany, nieszkodliwy przypadek nie +płonił bramki co uruchomienie). + +**`kb-query` domyślny `mode`**: przełączenie na `hybrid` jako follow-up (poza +zakresem tego zamknięcia bramki — `kb-query`'s `mode` param zmiana to osobna, +mała zmiana w serwisie, nie w `packages/kb-retrieval`). + ## 9. Krok 6 — Etap B: pełne archiwum - Ten sam job bez `--since`, `--apply`, log do pliku, `nice`/`ionice` na I/O. diff --git a/jobs/documents-ingest/eval/queries.yaml b/jobs/documents-ingest/eval/queries.yaml index f4a2e76..be670fb 100644 --- a/jobs/documents-ingest/eval/queries.yaml +++ b/jobs/documents-ingest/eval/queries.yaml @@ -68,22 +68,53 @@ queries: kind: negative_control_borderline expected_envelope: null baseline_top1_dist: 0.5533 + no_answer_threshold: 0.50 note: > Poprawnie na granicy "brak" w pilocie -- semantycznie sąsiednie dokumenty wspólnoty - mieszkaniowej, nie odpowiedź na zapytanie. + mieszkaniowej, nie odpowiedź na zapytanie. Faza mailowa, bramka po Etapie A + (2026-07-23): po dolaniu chunków mailowych top-1 = newsletter szkoły narciarskiej + (Rossignol, rozmiary nart 155-181cm, dist 0.5298) -- zweryfikowana treść pokazuje, że to + kolizja semantyczna krótkich, liczbowych tekstów w przestrzeni wektorowej, NIE realny + mail o piaskownicy -- korpus mailowy takiego trafienia nie zawiera (próbne zapytanie + "piaskownica plac zabaw wspólnota" dało dist 0.5585, miss; odrzucone jako mail_queries + wpis, patrz historia sesji). Próg tej kontroli obniżony do 0.50 (z 0.55) właśnie z powodu + tej znanej kolizji, żeby bramka nie płonęła co uruchomienie na nie-problemie. # Faza mailowa (docs/kb/modules/05-faza-mailowa-plan.md, §8, Krok 5) -- bramka jakościowa dla -# treści mailowej wprowadzonej w Etapie A (ostatnie 12 miesięcy, plan §7 Krok 4). PLACEHOLDER: -# operator ma dostarczyć 3-5 zapytań "wiem że to mam w mailach z ostatniego roku" + -# oczekiwany Message-ID (surowy, bez prefiksu "gmail:" -- envelope.id dla źródła gmail to -# bare Message-ID, np. "abc123@mail.gmail.com", inaczej niż "paperless:N" powyżej). -# Do czasu uzupełnienia ta lista jest pusta i retrieval_eval.py pomija kryterium hit@3 mailowe -# z jawną notatką w raporcie, zamiast fałszywie PASS/FAIL na braku danych. +# treści mailowej wprowadzonej w Etapie A (ostatnie 12 miesięcy, plan §7 Krok 4). Wypełniona +# przez operatora 2026-07-23 (5 zapytań "wiem że to mam w mailach z ostatniego roku"; M5 +# odrzucone po weryfikacji, patrz N2 powyżej i historia sesji). expected_envelope celowo null +# dla wszystkich -- operator dostarczył treść zapytania, nie surowy Message-ID; kind: mail_hit +# ma inną semantykę hit@3 niż `queries:` powyżej: hit iff top-3 hybrid zawiera wynik z gałęzi +# mailowej (envelope.source w summaryless_sources, dziś gmail) z dist < 0.45 +# (retrieval_eval.py::mail_hit_at_3) -- identity-match na expected_envelope nie ma tu +# zastosowania, bo nie ma czego dopasować. # -# Format wpisu (identyczny co do pól z `queries:` powyżej): +# Format wpisu (identyczny co do pól z `queries:` powyżej, minus baseline_top1_dist -- nie było +# pilota mailowego przed tą bramką): # - id: "M1" # text: "..." -# kind: hit -# expected_envelope: "" +# kind: mail_hit +# expected_envelope: "" # note: "..." -mail_queries: [] +mail_queries: + - id: "M1" + text: "szkolenie narciarskie zegrze termin" + kind: mail_hit + expected_envelope: null + note: "Wątek listy klubowej (lasery-zegrze) — celowo na granicy filtra newsletterowego." + - id: "M2" + text: "faktura Play kwota do zapłaty telefon" + kind: mail_hit + expected_envelope: null + note: "Faktury operatora przychodzą mailem co miesiąc." + - id: "M3" + text: "wznowienie polisy OC składka samochód" + kind: mail_hit + expected_envelope: null + note: "Korespondencja ubezpieczeniowa — cross-source z dokumentami OWU." + - id: "M4" + text: "rejestracja drużyny FLL zgłoszenie sezon" + kind: mail_hit + expected_envelope: null + note: "Wątki organizacyjne FLL 25-26." diff --git a/jobs/documents-ingest/eval/retrieval_eval.py b/jobs/documents-ingest/eval/retrieval_eval.py index 9daf3c1..416eab3 100644 --- a/jobs/documents-ingest/eval/retrieval_eval.py +++ b/jobs/documents-ingest/eval/retrieval_eval.py @@ -51,6 +51,7 @@ from kb_retrieval.retrieval import ( # noqa: E402 DEFAULT_K, DEFAULT_N, DEFAULT_SUMMARY_MODEL, + DEFAULT_SUMMARYLESS_SOURCES, cascade_query, flat_query, hybrid_query, @@ -94,6 +95,43 @@ def hit_at_3(chunks: list[dict], expected_envelope: Optional[str]) -> Optional[b return expected_envelope in {eid for eid, _ in top3} +def top3_envelopes(chunks: list[dict]) -> list[tuple[str, float]]: + """Top-3 *distinct* envelopes by best (lowest) distance -- shared by `hit_at_3` (identity + match against `expected_envelope`) and `mail_hit_at_3` (source match, no expected id).""" + best_per_envelope: dict[str, float] = {} + for c in chunks: + prev = best_per_envelope.get(c["envelope_id"]) + if prev is None or c["dist"] < prev: + best_per_envelope[c["envelope_id"]] = c["dist"] + return sorted(best_per_envelope.items(), key=lambda kv: kv[1])[:3] + + +def mail_hit_at_3(chunks: list[dict], envelope_sources: dict[str, str]) -> bool: + """`mail_queries` (plan §8 Krok 5) carry `expected_envelope: null` -- the operator supplied + the query text, not a Message-ID, so `hit_at_3`'s envelope-identity match always returns + None for them (the bug the operator flagged: it prints as '--' and can never count towards + criterion 4). The intended semantics is "hybrid actually surfaces mail content here": hit + iff one of the top-3 distinct hybrid envelopes is mail-sourced (`envelope.source` in + `DEFAULT_SUMMARYLESS_SOURCES`, i.e. gmail today) with dist < HIT_THRESHOLD.""" + return any( + envelope_sources.get(eid) in DEFAULT_SUMMARYLESS_SOURCES and dist < HIT_THRESHOLD + for eid, dist in top3_envelopes(chunks) + ) + + +async def fetch_envelope_sources(conn: asyncpg.Connection, envelope_ids: list[str]) -> dict[str, str]: + """Read-only lookup of `envelope.source` for the envelope ids seen in a result set -- + a hybrid chunk's `source` field is always overwritten to `"hybrid"` on merge + (`hybrid_retrieve`'s docstring), so origin (paperless vs. gmail) has to come from `envelope` + itself, not from the chunk dict.""" + if not envelope_ids: + return {} + rows = await conn.fetch( + "SELECT id, source FROM envelope WHERE id = ANY($1::text[])", envelope_ids + ) + return {r["id"]: r["source"] for r in rows} + + async def run_query_all_tracks( conn: asyncpg.Connection, session: aiohttp.ClientSession, @@ -118,21 +156,34 @@ async def run_query_all_tracks( return {"query": query, "flat": flat, "cascades": cascades, "hybrid": hybrid} -def summarize_query_result(result: dict) -> dict: +def summarize_query_result(result: dict, envelope_sources: Optional[dict[str, str]] = None) -> dict: query = result["query"] expected = query["expected_envelope"] flat_chunks = result["flat"]["chunks"] hybrid_chunks = result["hybrid"]["chunks"] + if query["kind"] == "mail_hit": + # plan §8 Krok 5: no expected_envelope (operator gave query text, not a Message-ID) -- + # graded on mail_hit_at_3's source-match semantics instead of hit_at_3's identity match. + flat_hit3 = None # flat never reaches summaryless (gmail) chunks -- not a meaningful axis + hybrid_hit3 = mail_hit_at_3(hybrid_chunks, envelope_sources or {}) + else: + flat_hit3 = hit_at_3(flat_chunks, expected) + hybrid_hit3 = hit_at_3(hybrid_chunks, expected) + row = { "id": query["id"], "kind": query["kind"], "text": query["text"], "expected_envelope": expected, "flat_top1_dist": top1_dist(flat_chunks), - "flat_hit3": hit_at_3(flat_chunks, expected), + "flat_hit3": flat_hit3, "hybrid_top1_dist": top1_dist(hybrid_chunks), - "hybrid_hit3": hit_at_3(hybrid_chunks, expected), + "hybrid_hit3": hybrid_hit3, + # per-query override (queries.yaml `no_answer_threshold`) for criterion 3 -- defaults + # to the module-wide NO_ANSWER_THRESHOLD; see N2's note (faza mailowa false-positive + # collision with an unrelated ski-newsletter mail, 2026-07-23). + "no_answer_threshold": query.get("no_answer_threshold", NO_ANSWER_THRESHOLD), "cascade": {}, } for n, cascade_result in result["cascades"].items(): @@ -180,22 +231,25 @@ def evaluate_gate(rows: list[dict], gate_n: int, mail_rows: Optional[list[dict]] f"< flat={flat_hit3_count}/{len(scored)} (N={gate_n})" ) - # 3. negative controls stay above 0.55 in flat, cascade, AND hybrid. + # 3. negative controls stay above their pass bar (module default NO_ANSWER_THRESHOLD, + # unless queries.yaml gives this query its own `no_answer_threshold` -- see N2) in flat, + # cascade, AND hybrid. control_failures = [] for row in rows: if row["kind"] not in ("negative_control", "negative_control_borderline"): continue - if row["flat_top1_dist"] is not None and row["flat_top1_dist"] <= NO_ANSWER_THRESHOLD: - control_failures.append(f"{row['id']} flat={row['flat_top1_dist']:.4f}") + threshold = row["no_answer_threshold"] + if row["flat_top1_dist"] is not None and row["flat_top1_dist"] <= threshold: + control_failures.append(f"{row['id']} flat={row['flat_top1_dist']:.4f} (bar {threshold})") cascade_dist = row["cascade"][gate_n]["top1_dist"] - if cascade_dist is not None and cascade_dist <= NO_ANSWER_THRESHOLD: - control_failures.append(f"{row['id']} cascade(N={gate_n})={cascade_dist:.4f}") + if cascade_dist is not None and cascade_dist <= threshold: + control_failures.append(f"{row['id']} cascade(N={gate_n})={cascade_dist:.4f} (bar {threshold})") hybrid_dist = row["hybrid_top1_dist"] - if hybrid_dist is not None and hybrid_dist <= NO_ANSWER_THRESHOLD: - control_failures.append(f"{row['id']} hybrid={hybrid_dist:.4f}") + if hybrid_dist is not None and hybrid_dist <= threshold: + control_failures.append(f"{row['id']} hybrid={hybrid_dist:.4f} (bar {threshold})") criterion_3 = not control_failures if not criterion_3: - reasons.append(f"criterion 3 FAILED: negative control(s) crossed {NO_ANSWER_THRESHOLD}: {control_failures}") + reasons.append(f"criterion 3 FAILED: negative control(s) crossed their pass bar: {control_failures}") # 4. (faza mailowa, plan §8) mail queries' hit@3 in hybrid -- skipped entirely when the # operator hasn't supplied queries yet (mail_rows empty), never PASSed/FAILed on no data. @@ -265,7 +319,7 @@ def print_report( h3 = gate_result["criterion_2_hit3"] print(f" criterion 2 (hit@3 cascade >= flat) : cascade={h3['cascade']}/{h3['total']} flat={h3['flat']}/{h3['total']} " f"-> {'PASS' if gate_result['criterion_1_no_degradation'] and h3['cascade'] >= h3['flat'] else 'FAIL'}") - print(f" criterion 3 (negative controls > {NO_ANSWER_THRESHOLD}, flat+cascade+hybrid): {'PASS' if gate_result['criterion_3_negative_controls'] else 'FAIL'}") + print(f" criterion 3 (negative controls > {NO_ANSWER_THRESHOLD} [per-query override possible], flat+cascade+hybrid): {'PASS' if gate_result['criterion_3_negative_controls'] else 'FAIL'}") c4 = gate_result["criterion_4_mail_hit3"] if c4["skipped"]: print(" criterion 4 (mail hit@3 in hybrid) : SKIPPED (mail_queries empty)") @@ -307,14 +361,22 @@ async def main_async(args: argparse.Namespace) -> dict: ) results.append(summarize_query_result(result)) - mail_results = [] + mail_results_raw = [] for query in mail_queries: result = await run_query_all_tracks( conn, session, args.ollama_url, query, summary_model=args.summary_model, embed_model=args.embed_model, n_values=n_values, k=args.k, ) - mail_results.append(summarize_query_result(result)) + mail_results_raw.append(result) + + mail_envelope_ids = { + c["envelope_id"] for r in mail_results_raw for c in r["hybrid"]["chunks"] + } + envelope_sources = await fetch_envelope_sources(conn, list(mail_envelope_ids)) + mail_results = [ + summarize_query_result(r, envelope_sources) for r in mail_results_raw + ] finally: await conn.close()