feat(mail-body-ingest): new job to chunk+embed gmail body content (faza mailowa Krok 2)
Second full pass over the gmail .eml archive (gmail-bulk-import's first pass skipped inline text/plain and text/html on purpose). Per envelope: typed parse with compat32 fallback -> body extraction (inline text/plain preferred, HTML->text via a small stdlib HTMLParser otherwise) -> quote-strip (reply markers + `>`-quoted lines, EN/PL/Outlook patterns) -> newsletter classification (List-Unsubscribe/List-Id/Precedence, chunked but not embedded, excluded_reason='newsletter') -> Temat/Od/Data prefix from the already-backfilled entities[type=headers] (zero header re-parse) -> chunk via kb_mail.chunking -> batched embed_batch (64) -> INSERT document_chunk. In-Reply-To/References are appended as entities[type=threading] during the same read (idempotent WHERE NOT EXISTS append, 1:1 with gmail-header-backfill) -- the only DB writes are document_chunk INSERTs and an additive envelope.entities UPDATE; the .eml archive stays read-only. --dsn/KB_DSN, --archive-root, --since/--limit/--offset, --batch-size, --apply (dry-run default). Idempotency keys on (envelope_id, chunk_index) pre-fetched scoped to --model, built correctly from the start per the plan's flagged chunk_embed.py precedent. A failed embed batch is isolated (chunks_errors, no abort) for Ollama's documented instability; a wrong embedding dimension aborts the whole run. 48 tests, DoD smoke run against live kb-postgres@PIHA confirmed wiring (archive not yet rsync'd to SOLARIA, so all 5 rows correctly reported missing_file). docs/kb/modules/05-faza-mailowa-plan.md, §5.
This commit is contained in:
parent
51998fd891
commit
ad0ef408f9
31
jobs/mail-body-ingest/pyproject.toml
Normal file
31
jobs/mail-body-ingest/pyproject.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mail-body-ingest"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"asyncpg>=0.29",
|
||||
"aiohttp>=3.9",
|
||||
"structlog>=24.1",
|
||||
"kb-mail",
|
||||
"kb-retrieval",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.1",
|
||||
"pytest-asyncio>=0.23",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mail-body-ingest = "mail_body_ingest.ingest:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
627
jobs/mail-body-ingest/src/mail_body_ingest/ingest.py
Normal file
627
jobs/mail-body-ingest/src/mail_body_ingest/ingest.py
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
"""Mail body ingest job — module 5, faza mailowa, plan Krok 2
|
||||
(docs/kb/modules/05-faza-mailowa-plan.md, §5). Second full pass over the gmail .eml archive
|
||||
(the first was gmail-bulk-import's manifest-only import): extracts inline body text that
|
||||
`_parse_attachments` deliberately skipped, chunks it, embeds it (batched), and appends
|
||||
`entities[type=threading]` — the only new writes are `document_chunk` INSERTs and an additive
|
||||
`envelope.entities` UPDATE. The archive itself is read-only; `envelope.id`/`raw_ref`/`ts` are
|
||||
never touched.
|
||||
|
||||
Runs on SOLARIA (needs Ollama on localhost) against kb-postgres@PIHA over Tailscale — the .eml
|
||||
archive is rsync'd PIHA -> SOLARIA once (plan §7 Krok 4), not read live over the network.
|
||||
|
||||
Install (from repo root):
|
||||
pip install -e packages/kb-mail/
|
||||
pip install -e packages/kb-retrieval/
|
||||
pip install -e jobs/mail-body-ingest/
|
||||
|
||||
Usage:
|
||||
# Dry run (default) — parse, quote-strip, classify, chunk, count. Zero Ollama calls, zero
|
||||
# DB writes (including the threading UPDATE):
|
||||
mail-body-ingest --dsn postgresql://kb:<pw>@piha:5433/kb --archive-root /path/to/archive
|
||||
|
||||
# Etap A pilot — last 12 months only:
|
||||
mail-body-ingest --dsn ... --since 2025-07-01 --apply
|
||||
|
||||
# Smoke-test slice:
|
||||
mail-body-ingest --dsn ... --apply --limit 10
|
||||
|
||||
DSN can come from KB_DSN, Ollama URL from OLLAMA_URL (default http://localhost:11434 — this job
|
||||
is meant to run where Ollama lives, not call it over Tailscale per-chunk).
|
||||
|
||||
Idempotency: a pre-fetched set of existing (envelope_id, chunk_index) pairs for this `model`
|
||||
skips chunks already inserted (the fetch itself filters `WHERE model = $1`, so the pair is
|
||||
already scoped to the model without needing it redundantly in the tuple — same pattern as
|
||||
`documents_ingest.chunk_embed`, built correctly from the start here per plan §1.5). The
|
||||
`ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING` insert is the second line of defense;
|
||||
its command tag is checked so a silently-skipped row counts as `chunks_conflict_skipped`, never
|
||||
`chunks_inserted`. Threading is separately idempotent via `WHERE NOT EXISTS (... type=threading)`
|
||||
— the same 1:1 pattern as `gmail_header_backfill`.
|
||||
|
||||
Newsletter chunks (`List-Unsubscribe`/`List-Id`/`Precedence: bulk|list`) are inserted with
|
||||
`excluded_reason='newsletter'`, `embedding=NULL` — no Ollama call, no HNSW entry, but the text
|
||||
stays in the DB (reversible: `UPDATE ... SET excluded_reason=NULL WHERE excluded_reason=
|
||||
'newsletter'` + a re-embed run un-flags them later, per plan Decyzja 4).
|
||||
|
||||
Ollama-offline tolerance: a failed `embed_batch()` call is caught per-batch (`chunks_errors +=
|
||||
len(batch)`), never aborting the run — those chunks never enter the idempotency set, so a later
|
||||
re-run naturally retries them. Only a wrong embedding dimension aborts the whole run
|
||||
(`EmbeddingDimensionError`) — never silently indexes a mismatched vector.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import email
|
||||
import email.policy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
import structlog
|
||||
from kb_mail.chunking import OVERLAP_CHARS, TARGET_CHARS, chunk_text
|
||||
from kb_mail.text import sanitize_surrogates as _sanitize
|
||||
from kb_retrieval.embed import (
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_OLLAMA_URL,
|
||||
EmbeddingDimensionError,
|
||||
_vector_literal,
|
||||
check_ollama_health,
|
||||
embed_batch,
|
||||
)
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_ARCHIVE_ROOT = Path("/home/oskar/kb/mail/archive")
|
||||
DEFAULT_BATCH_SIZE = 64
|
||||
THREADING_UPDATE_BATCH_SIZE = 500
|
||||
|
||||
_CHUNK_INSERT_SQL = """
|
||||
INSERT INTO document_chunk (envelope_id, chunk_index, text, embedding, model, excluded_reason)
|
||||
VALUES ($1, $2, $3, $4::vector, $5, $6)
|
||||
ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING
|
||||
"""
|
||||
|
||||
_THREADING_UPDATE_SQL = """
|
||||
UPDATE envelope
|
||||
SET entities = entities || $2::jsonb
|
||||
WHERE id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type' = 'threading'
|
||||
)
|
||||
"""
|
||||
|
||||
# Decision 2b: reply markers -- earliest match of any of these truncates the rest of the body.
|
||||
# `.` doesn't cross newlines (no re.DOTALL) -- these are meant to match a single header line,
|
||||
# same shape gmail/outlook/pl clients actually emit.
|
||||
_REPLY_MARKER_PATTERNS = [
|
||||
re.compile(r"^On .+ wrote:\s*$", re.IGNORECASE | re.MULTILINE),
|
||||
re.compile(r"^Dnia .+ napisał\(a\):\s*$", re.IGNORECASE | re.MULTILINE),
|
||||
re.compile(r"^W dniu .+ pisze:\s*$", re.IGNORECASE | re.MULTILINE),
|
||||
re.compile(r"^-{5,}\s*Original Message\s*-{5,}\s*$", re.IGNORECASE | re.MULTILINE),
|
||||
re.compile(r"^_{10,}\s*$", re.MULTILINE), # Outlook's horizontal-rule separator
|
||||
]
|
||||
# Decision 2a: quoted lines -- applied to whatever remains after marker truncation.
|
||||
_QUOTE_LINE_RE = re.compile(r"^\s*>")
|
||||
|
||||
_ID_TOKEN_RE = re.compile(r"<[^<>]+>")
|
||||
|
||||
|
||||
class _MailHTMLTextExtractor(HTMLParser):
|
||||
"""HTML -> text: skips `style`/`script`/`head` content and `blockquote`/`div.gmail_quote`
|
||||
subtrees (Decision 2c — quoted reply chains in HTML mail), inserts newlines at block-level
|
||||
boundaries so `kb_mail.chunking.chunk_text`'s paragraph splitter has something to work with.
|
||||
|
||||
LIFO tag-stack assumption (no tag-name matching on close): correct for well-nested HTML,
|
||||
which is what mail clients emit in practice; a truly malformed document may under/over-skip
|
||||
at the margins -- acceptable for a best-effort heuristic with zero new dependencies.
|
||||
"""
|
||||
|
||||
_SKIP_CONTENT_TAGS = {"style", "script", "head"}
|
||||
_BLOCK_TAGS = {"p", "div", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._tag_stack: list[tuple[bool, bool]] = []
|
||||
self._skip_depth = 0
|
||||
self._quote_depth = 0
|
||||
self._parts: list[str] = []
|
||||
|
||||
def _visible(self) -> bool:
|
||||
return self._skip_depth == 0 and self._quote_depth == 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
was_visible = self._visible()
|
||||
starts_skip = tag in self._SKIP_CONTENT_TAGS
|
||||
starts_quote = tag == "blockquote" or (
|
||||
tag == "div" and "gmail_quote" in (dict(attrs).get("class") or "").split()
|
||||
)
|
||||
if starts_skip:
|
||||
self._skip_depth += 1
|
||||
if starts_quote:
|
||||
self._quote_depth += 1
|
||||
self._tag_stack.append((starts_skip, starts_quote))
|
||||
if was_visible and tag in self._BLOCK_TAGS:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if not self._tag_stack:
|
||||
return
|
||||
starts_skip, starts_quote = self._tag_stack.pop()
|
||||
if starts_skip and self._skip_depth > 0:
|
||||
self._skip_depth -= 1
|
||||
if starts_quote and self._quote_depth > 0:
|
||||
self._quote_depth -= 1
|
||||
if self._visible() and tag in self._BLOCK_TAGS:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._visible():
|
||||
self._parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
raw = "".join(self._parts)
|
||||
raw = re.sub(r"[ \t]+", " ", raw)
|
||||
raw = re.sub(r"\n[ \t]*\n+", "\n\n", raw)
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def html_to_text(html: str) -> str:
|
||||
parser = _MailHTMLTextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return parser.text()
|
||||
|
||||
|
||||
def strip_quotes(text: str) -> tuple[str, int]:
|
||||
"""Decision 2: truncate at the earliest reply marker, then drop remaining `>`-quoted lines.
|
||||
Returns (clean_text, chars_stripped)."""
|
||||
original_len = len(text)
|
||||
|
||||
earliest: Optional[int] = None
|
||||
for pattern in _REPLY_MARKER_PATTERNS:
|
||||
m = pattern.search(text)
|
||||
if m and (earliest is None or m.start() < earliest):
|
||||
earliest = m.start()
|
||||
if earliest is not None:
|
||||
text = text[:earliest]
|
||||
|
||||
lines = [line for line in text.split("\n") if not _QUOTE_LINE_RE.match(line)]
|
||||
text = "\n".join(lines).strip()
|
||||
return text, original_len - len(text)
|
||||
|
||||
|
||||
def parse_message(raw: bytes) -> email.message.Message:
|
||||
"""Typed parse (RFC 2047 decoding) with a compat32 fallback for the ~9/225030 messages the
|
||||
typed parser rejects (same failure mode `gmail_header_backfill.parse_headers_fallback`
|
||||
documents) — both give a usable `Message`/`EmailMessage` for body walking and header access."""
|
||||
try:
|
||||
return email.message_from_bytes(raw, policy=email.policy.default)
|
||||
except Exception:
|
||||
return email.message_from_bytes(raw, policy=email.policy.compat32)
|
||||
|
||||
|
||||
def _inline_text_parts(msg: email.message.Message, content_type: str):
|
||||
for part in msg.walk():
|
||||
if part.get_content_maintype() == "multipart":
|
||||
continue
|
||||
if part.get_content_type() != content_type:
|
||||
continue
|
||||
disposition = part.get_content_disposition() or ""
|
||||
if disposition == "attachment" or part.get_filename():
|
||||
continue
|
||||
yield part
|
||||
|
||||
|
||||
def _decode_payload(part: email.message.Message) -> Optional[str]:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
return None
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
text = payload.decode(charset, errors="replace")
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
text = payload.decode("utf-8", errors="replace")
|
||||
return _sanitize(text)
|
||||
|
||||
|
||||
def extract_body(msg: email.message.Message) -> str:
|
||||
"""Inline text/plain preferred; HTML->text fallback when the mail is HTML-only (plan §1.3:
|
||||
15% of the corpus). Neither present (attachment-only mail) -> ''."""
|
||||
for part in _inline_text_parts(msg, "text/plain"):
|
||||
text = _decode_payload(part)
|
||||
if text is not None:
|
||||
return text
|
||||
for part in _inline_text_parts(msg, "text/html"):
|
||||
html = _decode_payload(part)
|
||||
if html is not None:
|
||||
return html_to_text(html)
|
||||
return ""
|
||||
|
||||
|
||||
def is_newsletter(msg: email.message.Message) -> bool:
|
||||
"""Decision 4: List-Unsubscribe or List-Id present, or Precedence: bulk|list."""
|
||||
if msg.get("List-Unsubscribe") is not None or msg.get("List-Id") is not None:
|
||||
return True
|
||||
precedence = msg.get("Precedence")
|
||||
if precedence is not None and str(precedence).strip().lower() in ("bulk", "list"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_threading(msg: email.message.Message) -> dict:
|
||||
"""Decision 10: raw In-Reply-To / References, angle brackets stripped (matches
|
||||
`envelope.id`'s bare-Message-ID convention from `gmail_bulk_import._message_id`)."""
|
||||
in_reply_to_raw = msg.get("In-Reply-To")
|
||||
references_raw = msg.get("References")
|
||||
|
||||
in_reply_to = None
|
||||
if in_reply_to_raw:
|
||||
m = _ID_TOKEN_RE.search(_sanitize(str(in_reply_to_raw)) or "")
|
||||
if m:
|
||||
in_reply_to = m.group(0)[1:-1]
|
||||
|
||||
references: list[str] = []
|
||||
if references_raw:
|
||||
for m in _ID_TOKEN_RE.finditer(_sanitize(str(references_raw)) or ""):
|
||||
references.append(m.group(0)[1:-1])
|
||||
|
||||
return {"type": "threading", "in_reply_to": in_reply_to, "references": references}
|
||||
|
||||
|
||||
def build_prefix(entities: list, ts: datetime) -> str:
|
||||
"""Decision 3: 'Temat: ... | Od: ... | Data: YYYY-MM-DD' from entities[type=headers]
|
||||
(already in DB — zero header re-parse) + envelope.ts for the date."""
|
||||
headers = next(
|
||||
(e for e in (entities or []) if isinstance(e, dict) and e.get("type") == "headers"), {}
|
||||
)
|
||||
subject = headers.get("subject") or "(brak tematu)"
|
||||
from_obj = headers.get("from")
|
||||
from_display = (from_obj.get("name") or from_obj.get("address") or "?") if from_obj else "?"
|
||||
return f"Temat: {subject} | Od: {from_display} | Data: {ts:%Y-%m-%d}"
|
||||
|
||||
|
||||
def _decode_jsonb(value: object) -> object:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
def _has_threading(entities: list) -> bool:
|
||||
return any(isinstance(e, dict) and e.get("type") == "threading" for e in entities or [])
|
||||
|
||||
|
||||
async def fetch_envelopes(
|
||||
conn: asyncpg.Connection, since: Optional[datetime], limit: Optional[int], offset: Optional[int]
|
||||
) -> list:
|
||||
"""`source='gmail'` envelopes, ordered by id for stable --limit/--offset slicing."""
|
||||
query = "SELECT id, ts, raw_ref, entities FROM envelope WHERE source = 'gmail'"
|
||||
params: list = []
|
||||
if since is not None:
|
||||
params.append(since)
|
||||
query += f" AND ts >= ${len(params)}"
|
||||
query += " ORDER BY id"
|
||||
if limit is not None:
|
||||
params.append(limit)
|
||||
query += f" LIMIT ${len(params)}"
|
||||
if offset:
|
||||
params.append(offset)
|
||||
query += f" OFFSET ${len(params)}"
|
||||
return await conn.fetch(query, *params)
|
||||
|
||||
|
||||
async def fetch_existing_chunk_keys(conn: asyncpg.Connection, model: str) -> set[tuple[str, int]]:
|
||||
"""(envelope_id, chunk_index) pairs already inserted with this model — idempotency + dry-run
|
||||
preview. The query filters `WHERE model = $1`, so the pair need not redundantly carry model."""
|
||||
rows = await conn.fetch(
|
||||
"SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1", model
|
||||
)
|
||||
return {(r["envelope_id"], r["chunk_index"]) for r in rows}
|
||||
|
||||
|
||||
async def insert_chunk(
|
||||
conn: asyncpg.Connection, envelope_id: str, chunk_index: int, text: str,
|
||||
embedding: Optional[list[float]], model: str, excluded_reason: Optional[str] = None,
|
||||
) -> str:
|
||||
vector_literal = _vector_literal(embedding) if embedding is not None else None
|
||||
return await conn.execute(
|
||||
_CHUNK_INSERT_SQL, envelope_id, chunk_index, text, vector_literal, model, excluded_reason
|
||||
)
|
||||
|
||||
|
||||
def _rows_affected(command_tag: str) -> int:
|
||||
return int(command_tag.rsplit(" ", 1)[-1])
|
||||
|
||||
|
||||
def _new_stats() -> dict:
|
||||
return {
|
||||
"mails_scanned": 0,
|
||||
"missing_file": 0,
|
||||
"read_errors": 0,
|
||||
"parse_errors": 0,
|
||||
"body_empty": 0,
|
||||
"mails_chunked": 0,
|
||||
"chunks_total": 0,
|
||||
"chunks_already_embedded": 0,
|
||||
"chunks_inserted": 0,
|
||||
"chunks_newsletter_flagged": 0,
|
||||
"chunks_conflict_skipped": 0,
|
||||
"chunks_errors": 0,
|
||||
"quoted_chars_stripped_total": 0,
|
||||
"embed_calls": 0,
|
||||
"embed_seconds_total": 0.0,
|
||||
"threading_updated": 0,
|
||||
"threading_already_present": 0,
|
||||
}
|
||||
|
||||
|
||||
async def run(
|
||||
dsn: str,
|
||||
archive_root: Path = DEFAULT_ARCHIVE_ROOT,
|
||||
ollama_url: str = DEFAULT_OLLAMA_URL,
|
||||
model: str = DEFAULT_MODEL,
|
||||
since: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
apply: bool = False,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> dict:
|
||||
"""Process one --limit/--offset (optionally --since-filtered) slice of `source='gmail'`
|
||||
envelopes. dry-run (apply=False): parse + quote-strip + classify + chunk + count, zero
|
||||
Ollama calls, zero writes (including the threading UPDATE). Stats must always balance:
|
||||
|
||||
mails_scanned = missing_file + read_errors + parse_errors + body_empty + mails_chunked
|
||||
chunks_total = chunks_inserted + chunks_newsletter_flagged + chunks_already_embedded
|
||||
+ chunks_conflict_skipped + chunks_errors
|
||||
"""
|
||||
stats = _new_stats()
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
envelopes = await fetch_envelopes(conn, since, limit, offset)
|
||||
existing = await fetch_existing_chunk_keys(conn, model)
|
||||
|
||||
session: Optional[aiohttp.ClientSession] = None
|
||||
if apply:
|
||||
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
||||
if not await check_ollama_health(session, ollama_url, 3.0):
|
||||
_log.warning("ollama_down_at_start", ollama_url=ollama_url)
|
||||
|
||||
embed_buffer: list[tuple[str, int, str]] = []
|
||||
threading_pending: list[tuple[str, str]] = []
|
||||
|
||||
async def flush_embed_buffer() -> None:
|
||||
if not embed_buffer:
|
||||
return
|
||||
texts = [t for (_eid, _idx, t) in embed_buffer]
|
||||
try:
|
||||
embeddings, elapsed = await embed_batch(session, ollama_url, model, texts)
|
||||
except EmbeddingDimensionError:
|
||||
raise
|
||||
except aiohttp.ClientError:
|
||||
_log.warning("skip.embed_batch_error", count=len(embed_buffer), exc_info=True)
|
||||
stats["chunks_errors"] += len(embed_buffer)
|
||||
embed_buffer.clear()
|
||||
return
|
||||
|
||||
stats["embed_calls"] += 1
|
||||
stats["embed_seconds_total"] += elapsed
|
||||
for (eid, idx, chunk), embedding in zip(embed_buffer, embeddings):
|
||||
try:
|
||||
command_tag = await insert_chunk(conn, eid, idx, chunk, embedding, model)
|
||||
except Exception:
|
||||
_log.warning("skip.insert_error", envelope_id=eid, chunk_index=idx, exc_info=True)
|
||||
stats["chunks_errors"] += 1
|
||||
continue
|
||||
existing.add((eid, idx))
|
||||
if _rows_affected(command_tag) == 0:
|
||||
_log.warning("chunk.conflict_skipped", envelope_id=eid, chunk_index=idx, model=model)
|
||||
stats["chunks_conflict_skipped"] += 1
|
||||
else:
|
||||
stats["chunks_inserted"] += 1
|
||||
embed_buffer.clear()
|
||||
|
||||
async def flush_threading() -> None:
|
||||
if apply and threading_pending:
|
||||
await conn.executemany(_THREADING_UPDATE_SQL, threading_pending)
|
||||
threading_pending.clear()
|
||||
|
||||
try:
|
||||
for row in envelopes:
|
||||
stats["mails_scanned"] += 1
|
||||
envelope_id = row["id"]
|
||||
ts = row["ts"]
|
||||
entities = _decode_jsonb(row["entities"]) or []
|
||||
|
||||
eml_path = archive_root / row["raw_ref"]
|
||||
try:
|
||||
raw = eml_path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
_log.info("skip.missing_file", envelope_id=envelope_id, expected_path=str(eml_path))
|
||||
stats["missing_file"] += 1
|
||||
continue
|
||||
except OSError:
|
||||
_log.warning("skip.read_error", envelope_id=envelope_id, path=str(eml_path))
|
||||
stats["read_errors"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = parse_message(raw)
|
||||
body = extract_body(msg)
|
||||
clean_body, stripped = strip_quotes(body)
|
||||
stats["quoted_chars_stripped_total"] += stripped
|
||||
newsletter = is_newsletter(msg)
|
||||
threading_patch = extract_threading(msg)
|
||||
except Exception:
|
||||
_log.warning("skip.parse_error", envelope_id=envelope_id, exc_info=True)
|
||||
stats["parse_errors"] += 1
|
||||
continue
|
||||
|
||||
if not _has_threading(entities):
|
||||
stats["threading_updated"] += 1
|
||||
if apply:
|
||||
threading_pending.append((envelope_id, json.dumps([threading_patch])))
|
||||
if len(threading_pending) >= THREADING_UPDATE_BATCH_SIZE:
|
||||
await flush_threading()
|
||||
else:
|
||||
stats["threading_already_present"] += 1
|
||||
|
||||
if not clean_body.strip():
|
||||
stats["body_empty"] += 1
|
||||
continue
|
||||
|
||||
stats["mails_chunked"] += 1
|
||||
prefix = build_prefix(entities, ts)
|
||||
chunks = chunk_text(f"{prefix}\n\n{clean_body}", TARGET_CHARS, OVERLAP_CHARS)
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
stats["chunks_total"] += 1
|
||||
key = (envelope_id, idx)
|
||||
if key in existing:
|
||||
stats["chunks_already_embedded"] += 1
|
||||
continue
|
||||
|
||||
if newsletter:
|
||||
if not apply:
|
||||
stats["chunks_newsletter_flagged"] += 1
|
||||
continue
|
||||
try:
|
||||
command_tag = await insert_chunk(
|
||||
conn, envelope_id, idx, chunk, None, model, excluded_reason="newsletter"
|
||||
)
|
||||
except Exception:
|
||||
_log.warning(
|
||||
"skip.insert_error", envelope_id=envelope_id, chunk_index=idx, exc_info=True
|
||||
)
|
||||
stats["chunks_errors"] += 1
|
||||
continue
|
||||
existing.add(key)
|
||||
if _rows_affected(command_tag) == 0:
|
||||
stats["chunks_conflict_skipped"] += 1
|
||||
else:
|
||||
stats["chunks_newsletter_flagged"] += 1
|
||||
continue
|
||||
|
||||
if not apply:
|
||||
stats["chunks_inserted"] += 1
|
||||
continue
|
||||
|
||||
embed_buffer.append((envelope_id, idx, chunk))
|
||||
if len(embed_buffer) >= batch_size:
|
||||
await flush_embed_buffer()
|
||||
|
||||
if stats["mails_scanned"] % 500 == 0:
|
||||
_log.info("progress", **stats)
|
||||
|
||||
if apply:
|
||||
await flush_embed_buffer()
|
||||
await flush_threading()
|
||||
finally:
|
||||
if session is not None:
|
||||
await session.close()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
balance_mails = (
|
||||
stats["missing_file"] + stats["read_errors"] + stats["parse_errors"]
|
||||
+ stats["body_empty"] + stats["mails_chunked"]
|
||||
)
|
||||
balance_chunks = (
|
||||
stats["chunks_inserted"] + stats["chunks_newsletter_flagged"] + stats["chunks_already_embedded"]
|
||||
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
|
||||
)
|
||||
if balance_mails != stats["mails_scanned"] or balance_chunks != stats["chunks_total"]:
|
||||
_log.error("stats_mismatch", **stats)
|
||||
|
||||
_log.info("run_complete", apply=apply, model=model, **stats)
|
||||
return stats
|
||||
|
||||
|
||||
def _parse_since(value: str) -> datetime:
|
||||
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Chunk + embed source='gmail' envelope body content into document_chunk "
|
||||
"(module 5, faza mailowa — plan §5, Krok 2)."
|
||||
)
|
||||
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
|
||||
help="asyncpg DSN for kb-postgres (or set KB_DSN env var)")
|
||||
parser.add_argument("--archive-root", type=Path, default=DEFAULT_ARCHIVE_ROOT,
|
||||
help=f"Mail .eml archive root (default: {DEFAULT_ARCHIVE_ROOT})")
|
||||
parser.add_argument("--ollama-url", default=os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL),
|
||||
help=f"Ollama base URL (default: {DEFAULT_OLLAMA_URL}, or set OLLAMA_URL)")
|
||||
parser.add_argument("--model", default=os.environ.get("OLLAMA_EMBED_MODEL", DEFAULT_MODEL),
|
||||
help=f"Ollama embedding model (default: {DEFAULT_MODEL})")
|
||||
parser.add_argument("--since", type=_parse_since, default=None, metavar="YYYY-MM-DD",
|
||||
help="Only envelopes with ts >= this date (for staged runs, plan Decyzja 9)")
|
||||
parser.add_argument("--limit", type=int, default=None,
|
||||
help="Max envelopes to process (default: all)")
|
||||
parser.add_argument("--offset", type=int, default=0,
|
||||
help="Slice offset, ordered by envelope id (default: 0)")
|
||||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
|
||||
help=f"Ollama /api/embed batch size (default: {DEFAULT_BATCH_SIZE})")
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="Actually call Ollama, insert chunks, and update threading entities. "
|
||||
"Default is dry-run (parse + classify + chunk + count only).")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dsn:
|
||||
_log.error("missing_dsn", hint="pass --dsn or set KB_DSN")
|
||||
sys.exit(1)
|
||||
if not args.archive_root.is_dir():
|
||||
_log.error("archive_root_not_found", path=str(args.archive_root))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
stats = asyncio.run(
|
||||
run(
|
||||
dsn=args.dsn,
|
||||
archive_root=args.archive_root,
|
||||
ollama_url=args.ollama_url,
|
||||
model=args.model,
|
||||
since=args.since,
|
||||
limit=args.limit,
|
||||
offset=args.offset,
|
||||
apply=args.apply,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
)
|
||||
except EmbeddingDimensionError as exc:
|
||||
_log.error("dim_mismatch_abort", error=str(exc))
|
||||
sys.exit(1)
|
||||
|
||||
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||
avg_embed_ms = (
|
||||
1000 * stats["embed_seconds_total"] / stats["embed_calls"] if stats["embed_calls"] else 0.0
|
||||
)
|
||||
_log.info("summary", mode=mode, avg_embed_ms_per_batch=round(avg_embed_ms, 2), **stats)
|
||||
|
||||
balanced = (
|
||||
stats["mails_scanned"] == (
|
||||
stats["missing_file"] + stats["read_errors"] + stats["parse_errors"]
|
||||
+ stats["body_empty"] + stats["mails_chunked"]
|
||||
)
|
||||
and stats["chunks_total"] == (
|
||||
stats["chunks_inserted"] + stats["chunks_newsletter_flagged"] + stats["chunks_already_embedded"]
|
||||
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
|
||||
)
|
||||
)
|
||||
failed = (
|
||||
stats["read_errors"] > 0 or stats["parse_errors"] > 0 or stats["missing_file"] > 0
|
||||
or stats["chunks_errors"] > 0 or stats["chunks_conflict_skipped"] > 0
|
||||
or not balanced
|
||||
)
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
577
jobs/mail-body-ingest/tests/test_ingest.py
Normal file
577
jobs/mail-body-ingest/tests/test_ingest.py
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
"""Unit tests for the mail body ingest job — no DB, no real HTTP, no real Ollama.
|
||||
|
||||
Fixture .eml bytes are built inline (email.mime helpers / raw byte literals), matching the
|
||||
convention in jobs/gmail-header-backfill/tests/test_backfill.py rather than separate fixture
|
||||
files -- easier to see exactly what each test is asserting against."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from kb_retrieval.embed import EmbeddingDimensionError
|
||||
from mail_body_ingest.ingest import (
|
||||
_CHUNK_INSERT_SQL,
|
||||
_decode_jsonb,
|
||||
_has_threading,
|
||||
build_prefix,
|
||||
extract_body,
|
||||
extract_threading,
|
||||
fetch_envelopes,
|
||||
fetch_existing_chunk_keys,
|
||||
html_to_text,
|
||||
insert_chunk,
|
||||
is_newsletter,
|
||||
parse_message,
|
||||
run,
|
||||
strip_quotes,
|
||||
)
|
||||
|
||||
|
||||
def _plain_eml(headers: dict, body: str) -> bytes:
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
for k, v in headers.items():
|
||||
msg[k] = v
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def _html_eml(headers: dict, html_body: str) -> bytes:
|
||||
msg = MIMEText(html_body, "html", "utf-8")
|
||||
for k, v in headers.items():
|
||||
msg[k] = v
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def _multipart_with_attachment_eml(headers: dict, body: str) -> bytes:
|
||||
msg = MIMEMultipart()
|
||||
for k, v in headers.items():
|
||||
msg[k] = v
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
attachment = MIMEApplication(b"%PDF-fake", Name="doc.pdf")
|
||||
attachment["Content-Disposition"] = 'attachment; filename="doc.pdf"'
|
||||
msg.attach(attachment)
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
# Pattern 1 from the gmail-header-backfill diagnosis (2026-07-14): RFC 2047 encoded-word whose
|
||||
# decoded text contains a newline in a display name -- policy.default raises ValueError.
|
||||
_CRLF_ENCODED_WORD_EML = (
|
||||
b"From: Rekrutacja z =?utf-8?Q?ExampleCorp=0A?= <mailing@example.pl>\r\n"
|
||||
b"To: Jan Kowalski <jan@example.com>\r\n"
|
||||
b"Subject: Nowe oferty\r\n"
|
||||
b"Date: Mon, 15 Sep 2014 13:24:18 +0000\r\n"
|
||||
b"\r\nbody"
|
||||
)
|
||||
|
||||
|
||||
class TestStripQuotes:
|
||||
def test_no_marker_no_quotes_returns_unchanged(self):
|
||||
clean, stripped = strip_quotes("Hello,\n\nJust checking in.\n\nRegards,\nA")
|
||||
assert clean == "Hello,\n\nJust checking in.\n\nRegards,\nA"
|
||||
assert stripped == 0
|
||||
|
||||
def test_gmail_wrote_marker_truncates_everything_after(self):
|
||||
text = "Sure, sounds good.\n\nOn Tue, 10 Jun 2025 at 12:00, Alice <a@b.com> wrote:\n> old text\n> more old"
|
||||
clean, stripped = strip_quotes(text)
|
||||
assert clean == "Sure, sounds good."
|
||||
assert stripped > 0
|
||||
|
||||
def test_polish_dnia_napisal_marker(self):
|
||||
text = "OK, dzieki.\n\nDnia 10 czerwca 2025 Jan napisał(a):\n> stara tresc"
|
||||
clean, _ = strip_quotes(text)
|
||||
assert clean == "OK, dzieki."
|
||||
|
||||
def test_polish_w_dniu_pisze_marker(self):
|
||||
text = "Dobrze.\n\nW dniu 10.06.2025 Anna pisze:\n> cos tam"
|
||||
clean, _ = strip_quotes(text)
|
||||
assert clean == "Dobrze."
|
||||
|
||||
def test_outlook_original_message_marker(self):
|
||||
text = "Ok, dzieki za info.\n\n-----Original Message-----\nFrom: bob@example.com\nold content"
|
||||
clean, _ = strip_quotes(text)
|
||||
assert clean == "Ok, dzieki za info."
|
||||
|
||||
def test_outlook_underscore_separator(self):
|
||||
text = "Widziane, dzieki.\n\n________________________________\nFrom: c@example.com\nstuff"
|
||||
clean, _ = strip_quotes(text)
|
||||
assert clean == "Widziane, dzieki."
|
||||
|
||||
def test_bare_quote_lines_without_marker_are_dropped(self):
|
||||
text = "My reply here.\n> quoted line one\n> quoted line two\nNot quoted trailing line"
|
||||
clean, stripped = strip_quotes(text)
|
||||
assert ">" not in clean
|
||||
assert "My reply here." in clean
|
||||
assert stripped > 0
|
||||
|
||||
def test_chars_stripped_counts_removed_characters(self):
|
||||
text = "short reply\n\nOn 1 Jan 2020 X wrote:\n> a very long quoted paragraph indeed"
|
||||
_clean, stripped = strip_quotes(text)
|
||||
assert stripped == len(text) - len("short reply")
|
||||
|
||||
|
||||
class TestHtmlToText:
|
||||
def test_skips_style_and_script(self):
|
||||
html = "<html><head><style>.x{color:red}</style></head><body><script>evil()</script><p>Real text</p></body></html>"
|
||||
assert "color:red" not in html_to_text(html)
|
||||
assert "evil()" not in html_to_text(html)
|
||||
assert "Real text" in html_to_text(html)
|
||||
|
||||
def test_skips_blockquote_subtree(self):
|
||||
html = "<div>Reply text</div><blockquote><div>quoted old text</div></blockquote>"
|
||||
text = html_to_text(html)
|
||||
assert "Reply text" in text
|
||||
assert "quoted old text" not in text
|
||||
|
||||
def test_skips_gmail_quote_div(self):
|
||||
html = (
|
||||
'<div>My reply</div>'
|
||||
'<div class="gmail_quote">On Tue, X wrote:<div>nested quoted content</div></div>'
|
||||
)
|
||||
text = html_to_text(html)
|
||||
assert "My reply" in text
|
||||
assert "nested quoted content" not in text
|
||||
|
||||
def test_plain_div_without_gmail_quote_class_is_not_skipped(self):
|
||||
html = '<div class="not_a_quote">Still visible</div>'
|
||||
assert "Still visible" in html_to_text(html)
|
||||
|
||||
|
||||
class TestIsNewsletter:
|
||||
def test_list_unsubscribe_present(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "List-Unsubscribe": "<mailto:x@y.com>"}, "hi"))
|
||||
assert is_newsletter(msg) is True
|
||||
|
||||
def test_list_id_present(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "List-Id": "newsletter.example.com"}, "hi"))
|
||||
assert is_newsletter(msg) is True
|
||||
|
||||
def test_precedence_bulk(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "Precedence": "bulk"}, "hi"))
|
||||
assert is_newsletter(msg) is True
|
||||
|
||||
def test_precedence_list_case_insensitive(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "Precedence": "LIST"}, "hi"))
|
||||
assert is_newsletter(msg) is True
|
||||
|
||||
def test_ordinary_mail_is_not_newsletter(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "Subject": "hi"}, "hi"))
|
||||
assert is_newsletter(msg) is False
|
||||
|
||||
|
||||
class TestExtractThreading:
|
||||
def test_in_reply_to_and_references_stripped_of_brackets(self):
|
||||
msg = parse_message(_plain_eml(
|
||||
{"From": "a@b.com", "In-Reply-To": "<msg1@x.com>",
|
||||
"References": "<msg0@x.com> <msg1@x.com>"}, "hi",
|
||||
))
|
||||
threading = extract_threading(msg)
|
||||
assert threading == {
|
||||
"type": "threading", "in_reply_to": "msg1@x.com",
|
||||
"references": ["msg0@x.com", "msg1@x.com"],
|
||||
}
|
||||
|
||||
def test_missing_headers_yield_none_and_empty_list(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com"}, "hi"))
|
||||
threading = extract_threading(msg)
|
||||
assert threading == {"type": "threading", "in_reply_to": None, "references": []}
|
||||
|
||||
|
||||
class TestBuildPrefix:
|
||||
def test_full_headers(self):
|
||||
entities = [{"type": "headers", "subject": "Twoja polisa", "from": {"name": "PZU", "address": "x@pzu.pl"}}]
|
||||
ts = datetime(2025, 8, 3, tzinfo=timezone.utc)
|
||||
assert build_prefix(entities, ts) == "Temat: Twoja polisa | Od: PZU | Data: 2025-08-03"
|
||||
|
||||
def test_missing_subject_and_from_fallback(self):
|
||||
entities = [{"type": "headers", "subject": None, "from": None}]
|
||||
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
prefix = build_prefix(entities, ts)
|
||||
assert "(brak tematu)" in prefix
|
||||
assert "Od: ?" in prefix
|
||||
|
||||
def test_from_without_name_uses_address(self):
|
||||
entities = [{"type": "headers", "subject": "s", "from": {"name": None, "address": "a@b.com"}}]
|
||||
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
assert "Od: a@b.com" in build_prefix(entities, ts)
|
||||
|
||||
def test_no_headers_entity_at_all(self):
|
||||
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
prefix = build_prefix([], ts)
|
||||
assert "(brak tematu)" in prefix
|
||||
assert "Od: ?" in prefix
|
||||
|
||||
|
||||
class TestExtractBody:
|
||||
def test_plain_text_preferred(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com"}, "hello plain body"))
|
||||
assert extract_body(msg) == "hello plain body"
|
||||
|
||||
def test_html_only_falls_back_to_html_to_text(self):
|
||||
msg = parse_message(_html_eml({"From": "a@b.com"}, "<p>hello <b>html</b> body</p>"))
|
||||
body = extract_body(msg)
|
||||
assert "hello" in body and "html" in body and "body" in body
|
||||
assert "<" not in body
|
||||
|
||||
def test_attachment_only_yields_empty_body(self):
|
||||
msg = MIMEApplication(b"%PDF-fake", Name="doc.pdf")
|
||||
msg["From"] = "a@b.com"
|
||||
assert extract_body(msg) == ""
|
||||
|
||||
def test_multipart_with_attachment_extracts_only_the_text_part(self):
|
||||
msg = parse_message(_multipart_with_attachment_eml({"From": "a@b.com"}, "the actual message"))
|
||||
assert extract_body(msg) == "the actual message"
|
||||
|
||||
|
||||
class TestParseMessage:
|
||||
def test_typed_parse_succeeds_for_ordinary_mail(self):
|
||||
msg = parse_message(_plain_eml({"From": "a@b.com", "Subject": "hi"}, "body"))
|
||||
assert msg.get("Subject") == "hi"
|
||||
|
||||
def test_falls_back_to_compat32_on_typed_parse_failure(self):
|
||||
# This exact byte pattern raises ValueError under policy.default (see backfill's
|
||||
# diagnosis) -- parse_message must still return a usable Message via the fallback.
|
||||
msg = parse_message(_CRLF_ENCODED_WORD_EML)
|
||||
assert msg.get("Subject") is not None
|
||||
|
||||
|
||||
class TestDecodeJsonbAndThreadingHelpers:
|
||||
def test_decode_jsonb_handles_str_and_object(self):
|
||||
assert _decode_jsonb('[{"type": "headers"}]') == [{"type": "headers"}]
|
||||
assert _decode_jsonb([{"type": "headers"}]) == [{"type": "headers"}]
|
||||
assert _decode_jsonb(None) is None
|
||||
|
||||
def test_has_threading(self):
|
||||
assert _has_threading([{"type": "threading"}]) is True
|
||||
assert _has_threading([{"type": "headers"}]) is False
|
||||
assert _has_threading([]) is False
|
||||
|
||||
|
||||
class TestInsertSql:
|
||||
def test_on_conflict_target_includes_model(self):
|
||||
assert "ON CONFLICT (envelope_id, chunk_index, model)" in _CHUNK_INSERT_SQL
|
||||
|
||||
|
||||
class TestInsertChunk:
|
||||
async def test_newsletter_chunk_inserts_null_embedding_with_reason(self):
|
||||
conn = _FakeConn()
|
||||
await insert_chunk(conn, "msgid@x", 0, "junk text", None, "bge-m3", excluded_reason="newsletter")
|
||||
assert conn.execute_calls == [("msgid@x", 0, "junk text", None, "bge-m3", "newsletter")]
|
||||
|
||||
async def test_normal_chunk_inserts_vector_with_no_reason(self):
|
||||
conn = _FakeConn()
|
||||
await insert_chunk(conn, "msgid@x", 0, "real text", [0.1, 0.2], "bge-m3")
|
||||
assert conn.execute_calls == [("msgid@x", 0, "real text", "[0.1,0.2]", "bge-m3", None)]
|
||||
|
||||
|
||||
class TestFetchHelpers:
|
||||
async def test_fetch_envelopes_no_filters(self):
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
rows = await fetch_envelopes(conn, since=None, limit=None, offset=None)
|
||||
assert rows == [_env_row("m1@x", "hi")]
|
||||
query, params = conn.queries[-1]
|
||||
assert "ORDER BY id" in query
|
||||
assert "LIMIT" not in query and "OFFSET" not in query
|
||||
|
||||
async def test_fetch_envelopes_with_since_limit_offset(self):
|
||||
conn = _FakeConn(envelopes=[])
|
||||
await fetch_envelopes(conn, since=datetime(2025, 7, 1, tzinfo=timezone.utc), limit=10, offset=5)
|
||||
query, params = conn.queries[-1]
|
||||
assert "ts >= $1" in query
|
||||
assert "LIMIT $2" in query
|
||||
assert "OFFSET $3" in query
|
||||
assert params == (datetime(2025, 7, 1, tzinfo=timezone.utc), 10, 5)
|
||||
|
||||
async def test_fetch_existing_chunk_keys(self):
|
||||
conn = _FakeConn(existing_keys=[("m1@x", 0), ("m1@x", 1)])
|
||||
keys = await fetch_existing_chunk_keys(conn, model="bge-m3")
|
||||
assert keys == {("m1@x", 0), ("m1@x", 1)}
|
||||
|
||||
|
||||
def _env_row(envelope_id: str, subject: str, ts: datetime = datetime(2025, 8, 1, tzinfo=timezone.utc)):
|
||||
entities = [{"type": "headers", "subject": subject, "from": {"name": "A", "address": "a@b.com"}}]
|
||||
return {"id": envelope_id, "ts": ts, "raw_ref": f"gmail/2025/08/{envelope_id}.eml", "entities": json.dumps(entities)}
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
"""Mirrors documents_ingest's/backfill's `_FakeConn` test style. `existing_keys` were
|
||||
"written" under `existing_model` -- a fetch for a different model must not see them,
|
||||
same as the real `WHERE model = $1` filter."""
|
||||
|
||||
def __init__(self, envelopes=None, existing_keys=None, existing_model="bge-m3", execute_results=None):
|
||||
self._envelopes = envelopes or []
|
||||
self._existing_keys = list(existing_keys or [])
|
||||
self._existing_model = existing_model
|
||||
self._execute_results = list(execute_results) if execute_results is not None else None
|
||||
self.execute_calls: list[tuple] = []
|
||||
self.executemany_calls: list[tuple] = []
|
||||
self.queries: list[tuple] = []
|
||||
|
||||
async def fetch(self, query, *params):
|
||||
self.queries.append((query, params))
|
||||
if "FROM document_chunk" in query:
|
||||
if params and params[0] != self._existing_model:
|
||||
return []
|
||||
return [{"envelope_id": eid, "chunk_index": idx} for eid, idx in self._existing_keys]
|
||||
if "FROM envelope" in query:
|
||||
return self._envelopes
|
||||
raise AssertionError(f"unexpected query: {query}")
|
||||
|
||||
async def execute(self, query, *params):
|
||||
self.execute_calls.append(params)
|
||||
if self._execute_results is not None:
|
||||
return self._execute_results.pop(0)
|
||||
return "INSERT 0 1"
|
||||
|
||||
async def executemany(self, query, rows):
|
||||
self.executemany_calls.append((query, list(rows)))
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeEmbedResponse:
|
||||
def __init__(self, payload, status=200):
|
||||
self._payload = payload
|
||||
self._status = status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self._status >= 400:
|
||||
raise aiohttp.ClientConnectionError(f"simulated HTTP {self._status}")
|
||||
|
||||
|
||||
class _FakeTagsResponse:
|
||||
def __init__(self, status=200):
|
||||
self.status = status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeOllamaSession:
|
||||
def __init__(self, dim=1024, fail_batches=False, health_up=True):
|
||||
self._dim = dim
|
||||
self._fail_batches = fail_batches
|
||||
self._health_up = health_up
|
||||
self.requests: list[dict] = []
|
||||
self.closed = False
|
||||
|
||||
def post(self, url, json):
|
||||
assert url.endswith("/api/embed")
|
||||
self.requests.append({"url": url, "json": json})
|
||||
if self._fail_batches:
|
||||
return _FakeEmbedResponse({}, status=500)
|
||||
n = len(json["input"])
|
||||
return _FakeEmbedResponse({"embeddings": [[0.01] * self._dim for _ in range(n)]})
|
||||
|
||||
def get(self, url, timeout=None):
|
||||
return _FakeTagsResponse(200 if self._health_up else 500)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _write_eml(archive_root, rel_path: str, raw: bytes) -> None:
|
||||
dest = archive_root / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(raw)
|
||||
|
||||
|
||||
class TestRun:
|
||||
def _patch(self, monkeypatch, conn, ollama_session):
|
||||
async def _fake_connect(dsn):
|
||||
return conn
|
||||
monkeypatch.setattr("mail_body_ingest.ingest.asyncpg.connect", _fake_connect)
|
||||
|
||||
def _fake_session_factory(*args, **kwargs):
|
||||
return ollama_session
|
||||
monkeypatch.setattr("mail_body_ingest.ingest.aiohttp.ClientSession", _fake_session_factory)
|
||||
|
||||
async def test_dry_run_counts_without_calling_ollama_or_db(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
ollama = _FakeOllamaSession()
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=False)
|
||||
|
||||
assert stats["mails_scanned"] == 1
|
||||
assert stats["mails_chunked"] == 1
|
||||
assert stats["chunks_total"] == 1
|
||||
assert stats["chunks_inserted"] == 1
|
||||
assert stats["threading_updated"] == 1 # would-update, not applied
|
||||
assert ollama.requests == []
|
||||
assert conn.execute_calls == []
|
||||
assert conn.executemany_calls == []
|
||||
|
||||
async def test_apply_embeds_inserts_and_updates_threading(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["chunks_inserted"] == 1
|
||||
assert stats["embed_calls"] == 1
|
||||
assert len(conn.execute_calls) == 1
|
||||
assert len(ollama.requests) == 1
|
||||
assert len(conn.executemany_calls) == 1 # threading update batch flushed
|
||||
|
||||
async def test_idempotent_skips_already_inserted_chunks(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")], existing_keys=[("m1@x", 0)])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["chunks_already_embedded"] == 1
|
||||
assert stats["chunks_inserted"] == 0
|
||||
assert ollama.requests == []
|
||||
|
||||
async def test_rerun_after_apply_inserts_nothing_new(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn1 = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
ollama1 = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn1, ollama1)
|
||||
first = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
assert first["chunks_inserted"] == 1
|
||||
|
||||
conn2 = _FakeConn(envelopes=[_env_row("m1@x", "hi")], existing_keys=[("m1@x", 0)])
|
||||
ollama2 = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn2, ollama2)
|
||||
second = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert second["chunks_inserted"] == 0
|
||||
assert second["chunks_already_embedded"] == 1
|
||||
assert ollama2.requests == []
|
||||
|
||||
async def test_newsletter_chunk_flagged_and_not_embedded(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/nl@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "Promo", "List-Unsubscribe": "<mailto:x@y.com>",
|
||||
"Date": "Fri, 01 Aug 2025 10:00:00 +0000"},
|
||||
"Kup teraz z rabatem!",
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("nl@x", "Promo")])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["chunks_newsletter_flagged"] == 1
|
||||
assert stats["chunks_inserted"] == 0
|
||||
assert ollama.requests == [] # no Ollama call for newsletter chunks
|
||||
assert conn.execute_calls[0][3] is None # embedding column is NULL
|
||||
assert conn.execute_calls[0][5] == "newsletter"
|
||||
|
||||
async def test_body_empty_after_quote_strip_still_updates_threading(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/empty@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "Re: x", "In-Reply-To": "<orig@x.com>",
|
||||
"Date": "Fri, 01 Aug 2025 10:00:00 +0000"},
|
||||
"On Tue wrote:\n> everything is quoted",
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("empty@x", "Re: x")])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["body_empty"] == 1
|
||||
assert stats["chunks_total"] == 0
|
||||
assert stats["threading_updated"] == 1
|
||||
assert len(conn.executemany_calls) == 1
|
||||
patch = json.loads(conn.executemany_calls[0][1][0][1])
|
||||
assert patch[0]["in_reply_to"] == "orig@x.com"
|
||||
|
||||
async def test_missing_file_counted(self, tmp_path, monkeypatch):
|
||||
conn = _FakeConn(envelopes=[_env_row("missing@x", "hi")])
|
||||
ollama = _FakeOllamaSession()
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=False)
|
||||
|
||||
assert stats["missing_file"] == 1
|
||||
assert stats["mails_scanned"] == 1
|
||||
|
||||
async def test_ollama_offline_batch_error_isolated_not_aborting(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
ollama = _FakeOllamaSession(dim=1024, fail_batches=True)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["chunks_errors"] == 1
|
||||
assert stats["chunks_inserted"] == 0
|
||||
assert stats["chunks_total"] == (
|
||||
stats["chunks_inserted"] + stats["chunks_newsletter_flagged"] + stats["chunks_already_embedded"]
|
||||
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
|
||||
)
|
||||
|
||||
async def test_dimension_mismatch_aborts(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")])
|
||||
ollama = _FakeOllamaSession(dim=768) # wrong dim
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
with pytest.raises(EmbeddingDimensionError):
|
||||
await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
async def test_conflict_skipped_counted_separately(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
conn = _FakeConn(envelopes=[_env_row("m1@x", "hi")], execute_results=["INSERT 0 0"])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["chunks_conflict_skipped"] == 1
|
||||
assert stats["chunks_inserted"] == 0
|
||||
|
||||
async def test_threading_already_present_is_skipped(self, tmp_path, monkeypatch):
|
||||
_write_eml(tmp_path, "gmail/2025/08/m1@x.eml", _plain_eml(
|
||||
{"From": "a@b.com", "Subject": "hi", "Date": "Fri, 01 Aug 2025 10:00:00 +0000"}, "hello there"
|
||||
))
|
||||
row = _env_row("m1@x", "hi")
|
||||
entities = json.loads(row["entities"]) + [{"type": "threading", "in_reply_to": None, "references": []}]
|
||||
row["entities"] = json.dumps(entities)
|
||||
conn = _FakeConn(envelopes=[row])
|
||||
ollama = _FakeOllamaSession(dim=1024)
|
||||
self._patch(monkeypatch, conn, ollama)
|
||||
|
||||
stats = await run(dsn="postgresql://fake", archive_root=tmp_path, apply=True)
|
||||
|
||||
assert stats["threading_already_present"] == 1
|
||||
assert stats["threading_updated"] == 0
|
||||
assert conn.executemany_calls == []
|
||||
Loading…
Reference in a new issue