587 lines
22 KiB
Python
587 lines
22 KiB
Python
|
|
"""Unit tests for the summarize+tag job — no DB, no real HTTP, no real Ollama/Anthropic."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from documents_ingest.summarize import (
|
||
|
|
DEFAULT_TAGS_VOCAB_PATH,
|
||
|
|
OLLAMA_MAX_NUM_CTX,
|
||
|
|
OLLAMA_MIN_NUM_CTX,
|
||
|
|
build_partial_prompt,
|
||
|
|
build_user_prompt,
|
||
|
|
compute_num_ctx,
|
||
|
|
get_summary_and_tags,
|
||
|
|
insert_summary,
|
||
|
|
is_duplicate,
|
||
|
|
load_tags_vocab,
|
||
|
|
normalize_and_validate_tags,
|
||
|
|
normalize_tag,
|
||
|
|
run_embed_summaries,
|
||
|
|
run_summarize,
|
||
|
|
summarize_mapreduce,
|
||
|
|
)
|
||
|
|
|
||
|
|
VOCAB = ["ubezpieczenie", "bank", "faktura"]
|
||
|
|
|
||
|
|
|
||
|
|
def _envelope_row(envelope_id, entities=None):
|
||
|
|
return {"id": envelope_id, "entities": json.dumps(entities or [])}
|
||
|
|
|
||
|
|
|
||
|
|
class TestComputeNumCtx:
|
||
|
|
def test_short_prompt_at_least_floor(self):
|
||
|
|
assert compute_num_ctx(100) >= OLLAMA_MIN_NUM_CTX
|
||
|
|
|
||
|
|
def test_zero_length_prompt_uses_floor(self):
|
||
|
|
assert compute_num_ctx(0) == OLLAMA_MIN_NUM_CTX
|
||
|
|
|
||
|
|
def test_scales_with_prompt_length(self):
|
||
|
|
# Regression case from the pilot: a 93k-char document was silently truncated to
|
||
|
|
# prompt_eval_count=2051 tokens by Ollama's runtime default -- num_ctx must scale
|
||
|
|
# well past that for a prompt this size.
|
||
|
|
ctx = compute_num_ctx(93_471)
|
||
|
|
assert ctx > 30_000
|
||
|
|
|
||
|
|
def test_huge_prompt_clamped_to_model_max(self):
|
||
|
|
assert compute_num_ctx(10_000_000) == OLLAMA_MAX_NUM_CTX
|
||
|
|
|
||
|
|
def test_rounds_up_to_1024_step(self):
|
||
|
|
assert compute_num_ctx(1000) % 1024 == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestTagsVocab:
|
||
|
|
def test_default_vocab_file_loads_and_is_nonempty(self):
|
||
|
|
vocab = load_tags_vocab(DEFAULT_TAGS_VOCAB_PATH)
|
||
|
|
assert "ubezpieczenie" in vocab
|
||
|
|
assert len(vocab) >= 5
|
||
|
|
|
||
|
|
|
||
|
|
class TestIsDuplicate:
|
||
|
|
def test_no_entities_not_duplicate(self):
|
||
|
|
assert is_duplicate([]) is False
|
||
|
|
assert is_duplicate(None) is False
|
||
|
|
|
||
|
|
def test_duplicate_of_entity_detected(self):
|
||
|
|
entities = [{"type": "content", "text": "x"}, {"type": "duplicate_of", "envelope_id": "paperless:14"}]
|
||
|
|
assert is_duplicate(entities) is True
|
||
|
|
|
||
|
|
def test_unrelated_entities_not_duplicate(self):
|
||
|
|
entities = [{"type": "content", "text": "x"}, {"type": "tag", "name": "faktura"}]
|
||
|
|
assert is_duplicate(entities) is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestNormalizeTag:
|
||
|
|
def test_lowercases_and_kebab_cases(self):
|
||
|
|
assert normalize_tag(" Ubezpieczenie Auto ") == "ubezpieczenie-auto"
|
||
|
|
|
||
|
|
def test_strips_punctuation(self):
|
||
|
|
assert normalize_tag("PZU!!") == "pzu"
|
||
|
|
|
||
|
|
def test_collapses_repeated_dashes(self):
|
||
|
|
assert normalize_tag("a b") == "a-b"
|
||
|
|
|
||
|
|
def test_nfc_normalizes_diacritics(self):
|
||
|
|
# decomposed "s" + combining cedilla vs precomposed - should normalize the same way
|
||
|
|
import unicodedata
|
||
|
|
decomposed = unicodedata.normalize("NFD", "ś") + "rodowisko" # "ś" decomposed
|
||
|
|
assert normalize_tag(decomposed) == normalize_tag("środowisko")
|
||
|
|
|
||
|
|
|
||
|
|
class TestNormalizeAndValidateTags:
|
||
|
|
def test_keeps_all_vocab_tags(self):
|
||
|
|
tags, truncated = normalize_and_validate_tags(["bank", "faktura"], VOCAB)
|
||
|
|
assert tags == ["bank", "faktura"]
|
||
|
|
assert truncated == 0
|
||
|
|
|
||
|
|
def test_caps_freeform_at_three(self):
|
||
|
|
raw = ["bank", "polisa", "pzu", "auto-osobowe", "extra-one"]
|
||
|
|
tags, truncated = normalize_and_validate_tags(raw, VOCAB)
|
||
|
|
assert tags[0] == "bank"
|
||
|
|
assert len(tags) == 1 + 3 # 1 vocab + 3 freeform kept
|
||
|
|
assert truncated == 1 # "extra-one" dropped
|
||
|
|
|
||
|
|
def test_dedupes_after_normalization(self):
|
||
|
|
tags, truncated = normalize_and_validate_tags(["Bank", "bank", " bank "], VOCAB)
|
||
|
|
assert tags == ["bank"]
|
||
|
|
assert truncated == 0
|
||
|
|
|
||
|
|
def test_ignores_non_string_entries(self):
|
||
|
|
tags, truncated = normalize_and_validate_tags(["bank", 123, None], VOCAB)
|
||
|
|
assert tags == ["bank"]
|
||
|
|
|
||
|
|
|
||
|
|
class TestPromptBuilders:
|
||
|
|
def test_user_prompt_includes_vocab_and_content(self):
|
||
|
|
prompt = build_user_prompt(VOCAB, "treść dokumentu")
|
||
|
|
assert "ubezpieczenie" in prompt
|
||
|
|
assert "treść dokumentu" in prompt
|
||
|
|
assert "JSON" in prompt
|
||
|
|
|
||
|
|
def test_partial_prompt_includes_index_and_total(self):
|
||
|
|
prompt = build_partial_prompt("fragment", 2, 5)
|
||
|
|
assert "2/5" in prompt
|
||
|
|
assert "fragment" in prompt
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeChatResponse:
|
||
|
|
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 RuntimeError(f"HTTP {self._status}")
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeOllamaChatSession:
|
||
|
|
"""Serves a queue of /api/chat responses, one per call (in order)."""
|
||
|
|
|
||
|
|
def __init__(self, contents: list[str]):
|
||
|
|
self._contents = list(contents)
|
||
|
|
self.requests: list[dict] = []
|
||
|
|
|
||
|
|
def post(self, url, json):
|
||
|
|
self.requests.append({"url": url, "json": json})
|
||
|
|
content = self._contents.pop(0)
|
||
|
|
return _FakeChatResponse({"message": {"content": content}})
|
||
|
|
|
||
|
|
async def close(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeBackend:
|
||
|
|
"""A scripted LLMBackend: returns queued responses in order, regardless of prompt."""
|
||
|
|
|
||
|
|
def __init__(self, responses: list[str]):
|
||
|
|
self._responses = list(responses)
|
||
|
|
self.calls: list[tuple] = []
|
||
|
|
|
||
|
|
async def complete(self, system, user, json_mode=True):
|
||
|
|
self.calls.append((system, user, json_mode))
|
||
|
|
return self._responses.pop(0)
|
||
|
|
|
||
|
|
|
||
|
|
class _FailingBackend:
|
||
|
|
async def complete(self, system, user, json_mode=True):
|
||
|
|
raise RuntimeError("boom")
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetSummaryAndTags:
|
||
|
|
async def test_valid_json_first_try(self):
|
||
|
|
backend = _FakeBackend([json.dumps({"summary": "s", "tags": ["bank"]})])
|
||
|
|
data = await get_summary_and_tags(backend, "sys", "user")
|
||
|
|
assert data == {"summary": "s", "tags": ["bank"]}
|
||
|
|
assert len(backend.calls) == 1
|
||
|
|
|
||
|
|
async def test_invalid_json_then_valid_on_retry(self):
|
||
|
|
backend = _FakeBackend(["not json", json.dumps({"summary": "s", "tags": []})])
|
||
|
|
data = await get_summary_and_tags(backend, "sys", "user")
|
||
|
|
assert data == {"summary": "s", "tags": []}
|
||
|
|
assert len(backend.calls) == 2
|
||
|
|
|
||
|
|
async def test_missing_keys_counts_as_invalid(self):
|
||
|
|
backend = _FakeBackend([json.dumps({"summary": "s"}), json.dumps({"summary": "s", "tags": []})])
|
||
|
|
data = await get_summary_and_tags(backend, "sys", "user")
|
||
|
|
assert data == {"summary": "s", "tags": []}
|
||
|
|
|
||
|
|
async def test_gives_up_after_retries_exhausted(self):
|
||
|
|
backend = _FakeBackend(["not json", "still not json"])
|
||
|
|
data = await get_summary_and_tags(backend, "sys", "user", retries=1)
|
||
|
|
assert data is None
|
||
|
|
|
||
|
|
async def test_backend_exception_is_treated_as_invalid(self):
|
||
|
|
data = await get_summary_and_tags(_FailingBackend(), "sys", "user", retries=0)
|
||
|
|
assert data is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestSummarizeMapreduce:
|
||
|
|
async def test_combines_partials_in_order(self):
|
||
|
|
backend = _FakeBackend(["partial one", "partial two"])
|
||
|
|
result = await summarize_mapreduce(backend, ["c1", "c2", "c3"], group_size=2)
|
||
|
|
assert result == "partial one\n\npartial two"
|
||
|
|
# two groups of size 2 and 1 -> two calls, both plain-text (json_mode=False)
|
||
|
|
assert len(backend.calls) == 2
|
||
|
|
assert all(call[2] is False for call in backend.calls)
|
||
|
|
|
||
|
|
async def test_partial_failure_aborts_whole_document(self):
|
||
|
|
result = await summarize_mapreduce(_FailingBackend(), ["c1", "c2"], group_size=2)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
async def test_empty_partial_aborts(self):
|
||
|
|
backend = _FakeBackend([" "])
|
||
|
|
result = await summarize_mapreduce(backend, ["c1"], group_size=2)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestInsertSql:
|
||
|
|
def test_on_conflict_target_includes_model(self):
|
||
|
|
from documents_ingest.summarize import _INSERT_SQL
|
||
|
|
assert "ON CONFLICT (envelope_id, model)" in _INSERT_SQL
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeConn:
|
||
|
|
"""Fakes the three query shapes this job issues: envelope fetch, active-chunk-text
|
||
|
|
fetch, and existing-summary-keys fetch; plus execute() for INSERT/UPDATE."""
|
||
|
|
|
||
|
|
def __init__(self, docs=None, chunks_by_envelope=None, existing_summaries=None,
|
||
|
|
existing_model="bge-m3", execute_results=None):
|
||
|
|
self._docs = docs or []
|
||
|
|
self._chunks_by_envelope = chunks_by_envelope or {}
|
||
|
|
self._existing_summaries = list(existing_summaries 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] = []
|
||
|
|
|
||
|
|
async def fetch(self, query, *params):
|
||
|
|
if "FROM document_chunk" in query:
|
||
|
|
envelope_id = params[0]
|
||
|
|
return [{"text": t} for t in self._chunks_by_envelope.get(envelope_id, [])]
|
||
|
|
if "FROM document_summary" in query:
|
||
|
|
if params and params[0] != self._existing_model:
|
||
|
|
return []
|
||
|
|
return [{"envelope_id": eid} for eid in self._existing_summaries]
|
||
|
|
return self._docs
|
||
|
|
|
||
|
|
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 close(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class TestRunSummarize:
|
||
|
|
def _patch(self, monkeypatch, conn, session=None):
|
||
|
|
async def _fake_connect(dsn):
|
||
|
|
return conn
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.asyncpg.connect", _fake_connect)
|
||
|
|
if session is not None:
|
||
|
|
def _fake_session_factory(*args, **kwargs):
|
||
|
|
return session
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.aiohttp.ClientSession", _fake_session_factory)
|
||
|
|
|
||
|
|
async def test_dry_run_counts_without_calling_backend_or_db(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["treść dokumentu"]},
|
||
|
|
)
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=False)
|
||
|
|
assert stats["documents_fetched"] == 1
|
||
|
|
assert stats["summarized"] == 1
|
||
|
|
assert stats["llm_errors"] == 0
|
||
|
|
assert conn.execute_calls == []
|
||
|
|
|
||
|
|
async def test_duplicate_envelope_skipped_whole(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:74", entities=[{"type": "duplicate_of", "envelope_id": "paperless:14"}])],
|
||
|
|
)
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=False)
|
||
|
|
assert stats["duplicates_skipped"] == 1
|
||
|
|
assert stats["summarized"] == 0
|
||
|
|
|
||
|
|
async def test_no_active_chunks_counted_separately(self, monkeypatch):
|
||
|
|
conn = _FakeConn(docs=[_envelope_row("paperless:2")], chunks_by_envelope={})
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=False)
|
||
|
|
assert stats["no_active_chunks"] == 1
|
||
|
|
assert stats["summarized"] == 0
|
||
|
|
|
||
|
|
async def test_already_summarized_skipped(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["text"]},
|
||
|
|
existing_summaries=["paperless:1"],
|
||
|
|
existing_model="gemma3:12b",
|
||
|
|
)
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=False)
|
||
|
|
assert stats["already_summarized"] == 1
|
||
|
|
assert stats["summarized"] == 0
|
||
|
|
|
||
|
|
async def test_apply_writes_summary_and_tags(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["treść z kwotą 100 zł"]},
|
||
|
|
)
|
||
|
|
session = _FakeOllamaChatSession([json.dumps({"summary": "Streszczenie.", "tags": ["bank", "extra"]})])
|
||
|
|
self._patch(monkeypatch, conn, session)
|
||
|
|
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=True)
|
||
|
|
|
||
|
|
assert stats["summarized"] == 1
|
||
|
|
assert stats["llm_errors"] == 0
|
||
|
|
assert len(conn.execute_calls) == 1
|
||
|
|
envelope_id, summary, tags_json, model = conn.execute_calls[0]
|
||
|
|
assert envelope_id == "paperless:1"
|
||
|
|
assert summary == "Streszczenie."
|
||
|
|
assert json.loads(tags_json) == ["bank", "extra"]
|
||
|
|
assert model == "gemma3:12b"
|
||
|
|
# Regression guard: every Ollama call must carry an explicit num_ctx (plan §5.1
|
||
|
|
# limitation) -- Ollama's runtime default silently truncates long documents otherwise.
|
||
|
|
assert session.requests[0]["json"]["options"]["num_ctx"] >= OLLAMA_MIN_NUM_CTX
|
||
|
|
|
||
|
|
async def test_apply_with_anthropic_backend(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["treść"]},
|
||
|
|
)
|
||
|
|
|
||
|
|
class _TextBlock:
|
||
|
|
type = "text"
|
||
|
|
text = json.dumps({"summary": "Streszczenie API.", "tags": ["faktura"]})
|
||
|
|
|
||
|
|
class _FakeResponse:
|
||
|
|
content = [_TextBlock()]
|
||
|
|
|
||
|
|
class _FakeMessages:
|
||
|
|
def __init__(self):
|
||
|
|
self.create_calls = []
|
||
|
|
|
||
|
|
async def create(self, **kwargs):
|
||
|
|
self.create_calls.append(kwargs)
|
||
|
|
return _FakeResponse()
|
||
|
|
|
||
|
|
class _FakeAsyncAnthropic:
|
||
|
|
def __init__(self, api_key=None):
|
||
|
|
self.api_key = api_key
|
||
|
|
self.messages = _FakeMessages()
|
||
|
|
|
||
|
|
async def close(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
fake_client_holder = {}
|
||
|
|
|
||
|
|
def _factory(api_key=None):
|
||
|
|
client = _FakeAsyncAnthropic(api_key=api_key)
|
||
|
|
fake_client_holder["client"] = client
|
||
|
|
return client
|
||
|
|
|
||
|
|
async def _fake_connect(dsn):
|
||
|
|
return conn
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.asyncpg.connect", _fake_connect)
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.AsyncAnthropic", _factory)
|
||
|
|
|
||
|
|
stats = await run_summarize(
|
||
|
|
dsn="x", backend_name="anthropic", model="claude-haiku-4-5",
|
||
|
|
anthropic_api_key="sk-fake", apply=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert stats["summarized"] == 1
|
||
|
|
client = fake_client_holder["client"]
|
||
|
|
assert client.api_key == "sk-fake"
|
||
|
|
assert len(client.messages.create_calls) == 1
|
||
|
|
call = client.messages.create_calls[0]
|
||
|
|
assert call["model"] == "claude-haiku-4-5"
|
||
|
|
assert call["temperature"] == 0
|
||
|
|
assert call["output_config"]["format"]["type"] == "json_schema"
|
||
|
|
|
||
|
|
async def test_llm_error_isolated_and_counted(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1"), _envelope_row("paperless:2")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["a"], "paperless:2": ["b"]},
|
||
|
|
)
|
||
|
|
session = _FakeOllamaChatSession([
|
||
|
|
"not json", "still not json", # paperless:1 exhausts retry -> llm_error
|
||
|
|
json.dumps({"summary": "ok", "tags": []}), # paperless:2 succeeds
|
||
|
|
])
|
||
|
|
self._patch(monkeypatch, conn, session)
|
||
|
|
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=True)
|
||
|
|
|
||
|
|
assert stats["documents_fetched"] == 2
|
||
|
|
assert stats["llm_errors"] == 1
|
||
|
|
assert stats["summarized"] == 1
|
||
|
|
assert len(conn.execute_calls) == 1 # only the successful one got inserted
|
||
|
|
|
||
|
|
async def test_conflict_skip_counted_as_already_summarized(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["a"]},
|
||
|
|
execute_results=["INSERT 0 0"], # ON CONFLICT DO NOTHING no-op
|
||
|
|
)
|
||
|
|
session = _FakeOllamaChatSession([json.dumps({"summary": "s", "tags": []})])
|
||
|
|
self._patch(monkeypatch, conn, session)
|
||
|
|
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=True)
|
||
|
|
|
||
|
|
assert stats["already_summarized"] == 1
|
||
|
|
assert stats["summarized"] == 0
|
||
|
|
|
||
|
|
async def test_stats_balance_invariant(self, monkeypatch):
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[
|
||
|
|
_envelope_row("paperless:1", entities=[{"type": "duplicate_of", "envelope_id": "paperless:0"}]),
|
||
|
|
_envelope_row("paperless:2"),
|
||
|
|
_envelope_row("paperless:3"),
|
||
|
|
],
|
||
|
|
chunks_by_envelope={"paperless:3": ["content"]},
|
||
|
|
)
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=False)
|
||
|
|
balance = (
|
||
|
|
stats["duplicates_skipped"] + stats["no_active_chunks"]
|
||
|
|
+ stats["already_summarized"] + stats["summarized"] + stats["llm_errors"]
|
||
|
|
)
|
||
|
|
assert balance == stats["documents_fetched"] == 3
|
||
|
|
|
||
|
|
async def test_limit_and_offset_passed_through(self, monkeypatch):
|
||
|
|
class _Conn(_FakeConn):
|
||
|
|
async def fetch(self, query, *params):
|
||
|
|
if "FROM envelope" in query:
|
||
|
|
self.captured = (query, params)
|
||
|
|
return []
|
||
|
|
return await super().fetch(query, *params)
|
||
|
|
|
||
|
|
conn = _Conn()
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", limit=5, offset=10, apply=False)
|
||
|
|
query, params = conn.captured
|
||
|
|
assert "LIMIT" in query and "OFFSET" in query
|
||
|
|
assert params == (5, 10)
|
||
|
|
|
||
|
|
async def test_rerun_after_apply_writes_nothing_new(self, monkeypatch):
|
||
|
|
conn1 = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["a"]},
|
||
|
|
)
|
||
|
|
session1 = _FakeOllamaChatSession([json.dumps({"summary": "s", "tags": []})])
|
||
|
|
self._patch(monkeypatch, conn1, session1)
|
||
|
|
await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=True)
|
||
|
|
|
||
|
|
conn2 = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": ["a"]},
|
||
|
|
existing_summaries=["paperless:1"],
|
||
|
|
existing_model="gemma3:12b",
|
||
|
|
)
|
||
|
|
session2 = _FakeOllamaChatSession([])
|
||
|
|
self._patch(monkeypatch, conn2, session2)
|
||
|
|
stats2 = await run_summarize(dsn="x", backend_name="ollama", model="gemma3:12b", apply=True)
|
||
|
|
|
||
|
|
assert stats2["already_summarized"] == 1
|
||
|
|
assert stats2["summarized"] == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestMapreduceIntegration:
|
||
|
|
def _patch(self, monkeypatch, conn, session=None):
|
||
|
|
async def _fake_connect(dsn):
|
||
|
|
return conn
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.asyncpg.connect", _fake_connect)
|
||
|
|
if session is not None:
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.aiohttp.ClientSession", lambda *a, **k: session)
|
||
|
|
|
||
|
|
async def test_long_document_triggers_mapreduce(self, monkeypatch):
|
||
|
|
long_chunks = ["x" * 100_000, "y" * 150_000] # combined > threshold
|
||
|
|
conn = _FakeConn(
|
||
|
|
docs=[_envelope_row("paperless:1")],
|
||
|
|
chunks_by_envelope={"paperless:1": long_chunks},
|
||
|
|
)
|
||
|
|
session = _FakeOllamaChatSession([
|
||
|
|
"partial summary 1", # map-reduce partial (plain text, one group covers both chunks)
|
||
|
|
json.dumps({"summary": "final", "tags": []}), # final synthesis (JSON)
|
||
|
|
])
|
||
|
|
self._patch(monkeypatch, conn, session)
|
||
|
|
|
||
|
|
stats = await run_summarize(
|
||
|
|
dsn="x", backend_name="ollama", model="gemma3:12b", apply=True,
|
||
|
|
mapreduce_threshold_chars=200_000, chunks_per_group=20,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert stats["documents_mapreduce"] == 1
|
||
|
|
assert stats["summarized"] == 1
|
||
|
|
# first call has no "format" key (plain text), second has format=json
|
||
|
|
assert "format" not in session.requests[0]["json"]
|
||
|
|
assert session.requests[1]["json"]["format"] == "json"
|
||
|
|
|
||
|
|
|
||
|
|
class TestRunEmbedSummaries:
|
||
|
|
class _EmbedConn:
|
||
|
|
def __init__(self, rows):
|
||
|
|
self._rows = rows
|
||
|
|
self.update_calls: list[tuple] = []
|
||
|
|
|
||
|
|
async def fetch(self, query, *params):
|
||
|
|
return self._rows
|
||
|
|
|
||
|
|
async def execute(self, query, *params):
|
||
|
|
self.update_calls.append(params)
|
||
|
|
return "UPDATE 1"
|
||
|
|
|
||
|
|
async def close(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
class _EmbedSession:
|
||
|
|
def __init__(self, dim=1024):
|
||
|
|
self._dim = dim
|
||
|
|
|
||
|
|
def post(self, url, json):
|
||
|
|
return _FakeEmbedResponseForEmbed({"embedding": [0.1] * self._dim})
|
||
|
|
|
||
|
|
async def close(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _patch(self, monkeypatch, conn, session=None):
|
||
|
|
async def _fake_connect(dsn):
|
||
|
|
return conn
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.asyncpg.connect", _fake_connect)
|
||
|
|
if session is not None:
|
||
|
|
monkeypatch.setattr("documents_ingest.summarize.aiohttp.ClientSession", lambda *a, **k: session)
|
||
|
|
|
||
|
|
async def test_dry_run_counts_without_writing(self, monkeypatch):
|
||
|
|
conn = self._EmbedConn(rows=[{"id": 1, "summary": "s"}])
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_embed_summaries(dsn="x", apply=False)
|
||
|
|
assert stats["summaries_fetched"] == 1
|
||
|
|
assert stats["embedded"] == 1
|
||
|
|
assert conn.update_calls == []
|
||
|
|
|
||
|
|
async def test_apply_embeds_and_updates(self, monkeypatch):
|
||
|
|
conn = self._EmbedConn(rows=[{"id": 1, "summary": "s"}])
|
||
|
|
session = self._EmbedSession()
|
||
|
|
self._patch(monkeypatch, conn, session)
|
||
|
|
stats = await run_embed_summaries(dsn="x", embed_model="bge-m3", apply=True)
|
||
|
|
assert stats["embedded"] == 1
|
||
|
|
assert stats["errors"] == 0
|
||
|
|
assert len(conn.update_calls) == 1
|
||
|
|
vector_literal, embed_model, summary_id = conn.update_calls[0]
|
||
|
|
assert embed_model == "bge-m3"
|
||
|
|
assert summary_id == 1
|
||
|
|
|
||
|
|
async def test_balance_invariant(self, monkeypatch):
|
||
|
|
conn = self._EmbedConn(rows=[{"id": 1, "summary": "s"}, {"id": 2, "summary": "t"}])
|
||
|
|
self._patch(monkeypatch, conn)
|
||
|
|
stats = await run_embed_summaries(dsn="x", apply=False)
|
||
|
|
assert stats["embedded"] + stats["errors"] == stats["summaries_fetched"]
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeEmbedResponseForEmbed:
|
||
|
|
def __init__(self, payload):
|
||
|
|
self._payload = payload
|
||
|
|
|
||
|
|
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):
|
||
|
|
pass
|