fix(eval): mail_hit@3 criterion 4 + N2 threshold, gate PASS after Etap A
Bug: hit_at_3 returned None for kind=mail_hit rows (expected_envelope is
always null for them -- operator supplies query text, not a Message-ID), so
criterion 4 could never count a hit and read 0/5 despite hybrid distances of
0.25-0.42. Fixed with mail_hit_at_3: hit iff the top-3 distinct hybrid
envelopes include a mail-sourced one (envelope.source lookup via
fetch_envelope_sources, since hybrid_retrieve overwrites source to "hybrid"
on merge) under HIT_THRESHOLD. Also added a per-query no_answer_threshold
override in queries.yaml for criterion 3.
N2 ("piaskownica plastikowa") investigation: after Etap A added ~34k mail
chunks, N2's top-1 neighbor dropped to dist 0.5298 (< the 0.55 bar). Content
check showed it's a ski-school reservation newsletter (Rossignol ski sizes)
-- a semantic false-positive collision, not a real corpus match. M5, which
the operator had added assuming a genuine piaskownica mail existed, itself
misses (dist 0.5585) -- confirming there's no such mail in the corpus. M5
dropped; N2's pass bar lowered to 0.50 with a note documenting the collision.
Gate result on the live DB post-Etap A (Etap A: 13 009 mails scanned -> 33 871
new gmail chunks, 6 398 embedded / 27 473 newsletter-flagged, balanced +
idempotent on rerun; Ollama incident #4 during the run required a compose
force-recreate, not just restart -- root-cause task ollama-solaria-start-race
stays in backlog): all 4 criteria PASS (5/5 flat hits held, cascade hit@3 5/5
vs flat 4/5, negative controls above their bars, mail hit@3 4/4 after
dropping M5). Plan doc updated with the numbers and verdict table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fc5c698898
commit
bce635c270
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: "<raw gmail Message-ID>"
|
||||
# kind: mail_hit
|
||||
# expected_envelope: "<raw gmail Message-ID lub null>"
|
||||
# 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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue