"""Tests for the per-document summary pipeline.
Covers the write-time output contract, the bounded queue, caller-side wait
semantics (slot retained until provider work finishes; late results never
written), provider-normalized length-cap detection, ``updated_at``
preservation, and owner-scoped identity on queued jobs.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage
from deerflow.config.projects_config import ProjectsConfig
from deerflow.persistence.projects import ProjectDocumentRepository, ProjectRepository
from deerflow.projects import summaries
from deerflow.projects.summaries import SummaryGenerator, _sanitize_summary
from deerflow.utils.oneshot_llm import OneshotLLMResult
pytestmark = pytest.mark.anyio
# ---------------------------------------------------------------------------
# Output contract (pure)
# ---------------------------------------------------------------------------
class TestSanitizeSummary:
def test_plain_line_passes_through(self):
assert _sanitize_summary("Q3 营收报告", 256) == "Q3 营收报告"
def test_code_fence_and_list_marker_unwrapped(self):
assert _sanitize_summary("```\n- quarterly report\n```", 256) == "quarterly report"
def test_surrounding_quotes_stripped(self):
assert _sanitize_summary('"a short report"', 256) == "a short report"
def test_curly_quotes_strip_as_pairs(self):
assert _sanitize_summary("“季度报告”", 256) == "季度报告"
assert _sanitize_summary("‘report’", 256) == "report"
# Mismatched pairs are not stripped.
assert _sanitize_summary("“report”".replace("”", '"'), 256) == '“report"'
def test_newlines_collapse_to_one_line(self):
result = _sanitize_summary("line one\n\nline two\tand more", 256)
assert result == "line one line two and more"
assert "\n" not in result
def test_empty_after_clean_is_none(self):
assert _sanitize_summary("```\n\n```", 256) is None
assert _sanitize_summary(" \n ", 256) is None
def test_truncation_respects_character_boundary(self):
# 每 is 3 UTF-8 bytes; a 7-byte cap must not split the third character.
result = _sanitize_summary("每每每每", 7)
assert result == "每每"
assert len(result.encode("utf-8")) <= 7
def test_leading_think_block_stripped_before_truncation(self):
reasoning = "" + " deliberation" * 100 + ""
result = _sanitize_summary(f"{reasoning}\nQ3 营收报告", 256)
assert result == "Q3 营收报告"
def test_reasoning_only_output_is_none(self):
assert _sanitize_summary("only internal reasoning", 256) is None
# An unfinished reasoning section has no answer to store.
assert _sanitize_summary("unclosed reasoning", 256) is None
def test_think_tag_inside_the_answer_is_kept_literal(self):
result = _sanitize_summary("文档讨论了 标签的用法", 256)
assert result == "文档讨论了 标签的用法"
# ---------------------------------------------------------------------------
# Repository: set_summary preserves updated_at
@pytest.fixture
async def repos(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
sf = get_session_factory()
yield sf, ProjectRepository(sf), ProjectDocumentRepository(sf)
await close_engine()
async def _shelf_row(projects, docs, *, user_id="u1"):
project = await projects.create(name="P", user_id=user_id)
row = await docs.insert_active(
project["id"],
document_id="doc-1",
name="a.txt",
relpath=f"{project['id']}/documents/ab/abcd/x",
sha256="abcd",
size_bytes=10,
user_id=user_id,
)
assert row is not None
return project, row
class TestSetSummary:
async def test_summary_write_preserves_updated_at(self, repos):
_sf, projects, docs = repos
_project, row = await _shelf_row(projects, docs)
before = row["updated_at"]
assert await docs.set_summary("doc-1", "one line", user_id="u1") is True
after = await docs.get("doc-1", user_id="u1")
assert after["summary"] == "one line"
# The shelf order key and rendered "modified" date must not move (§6.4).
assert after["updated_at"] == before
async def test_foreign_or_vanished_row_is_noop(self, repos):
_sf, projects, docs = repos
await _shelf_row(projects, docs)
assert await docs.set_summary("doc-1", "x", user_id="u2") is False
assert await docs.set_summary("gone", "x", user_id="u1") is False
assert (await docs.get("doc-1", user_id="u1"))["summary"] is None
# ---------------------------------------------------------------------------
# Generator: queue, model-call semantics, identity
# ---------------------------------------------------------------------------
def _app_config(**overrides) -> SimpleNamespace:
return SimpleNamespace(projects=ProjectsConfig(summaries_enabled=True, **overrides), uploads=None)
def _result(text: str, metadata: dict | None = None) -> OneshotLLMResult:
message = AIMessage(content=text, response_metadata=metadata or {})
return OneshotLLMResult(text=text, message=message)
def _patch_source(monkeypatch, serving: tuple[Path | None, str | None]):
async def fake_serving_path(repo, paths, *, user_id, row, auto_convert):
return serving
monkeypatch.setattr(summaries, "read_text_serving_path", fake_serving_path)
@pytest.fixture
def text_file(tmp_path) -> Path:
path = tmp_path / "doc.txt"
path.write_text("Quarterly revenue report with regional breakdown.", encoding="utf-8")
return path
async def _drain(generator: SummaryGenerator, timeout: float = 5.0) -> None:
await asyncio.wait_for(generator._queue.join(), timeout)
class TestGenerator:
async def test_success_writes_summary_and_counts(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", lambda **kwargs: _fake_model("Q3 营收报告"))
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
assert generator.enqueue(document_id="doc-1", project_id=(await docs.get("doc-1", user_id="u1"))["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
assert (await docs.get("doc-1", user_id="u1"))["summary"] == "Q3 营收报告"
assert generator.metrics()["success"] == 1
assert generator.metrics()["eligible"] == 1
async def test_ineligible_source_skips_without_model_call(self, repos, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (None, "binary"))
called = False
async def model(**kwargs):
nonlocal called
called = True
return _result("x")
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", model)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
generator.enqueue(document_id="doc-1", project_id=(await docs.get("doc-1", user_id="u1"))["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
assert called is False
assert (await docs.get("doc-1", user_id="u1"))["summary"] is None
assert generator.metrics()["skipped"] == 1
async def test_length_capped_response_stores_null(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", lambda **kwargs: _fake_model("truncated …", {"finish_reason": "length"}))
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
generator.enqueue(document_id="doc-1", project_id=(await docs.get("doc-1", user_id="u1"))["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
assert (await docs.get("doc-1", user_id="u1"))["summary"] is None
assert generator.metrics()["failed"] == 1
async def test_anthropic_style_stop_reason_also_detected(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", lambda **kwargs: _fake_model("partial", {"stop_reason": "max_tokens"}))
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
generator.enqueue(document_id="doc-1", project_id=(await docs.get("doc-1", user_id="u1"))["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
assert (await docs.get("doc-1", user_id="u1"))["summary"] is None
async def test_missing_termination_metadata_accepted_and_counted(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", lambda **kwargs: _fake_model("clean line"))
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
generator.enqueue(document_id="doc-1", project_id=(await docs.get("doc-1", user_id="u1"))["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
assert (await docs.get("doc-1", user_id="u1"))["summary"] == "clean line"
assert generator.metrics()["uncapped_accepted"] == 1
async def test_timeout_holds_slot_and_discards_late_result(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
blocker = asyncio.Event()
second_started = asyncio.Event()
async def blocking_model(**kwargs):
await blocker.wait()
return _result("late")
async def second_model(**kwargs):
second_started.set()
return _result("second")
calls = iter([blocking_model, second_model])
async def dispatch(**kwargs):
return await next(calls)(**kwargs)
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", dispatch)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config(summary_concurrency=1))
generator._timeout = 0.2 # shorten the caller-side wait for the test
await generator.start()
row = await docs.get("doc-1", user_id="u1")
generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1")
generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1")
# The first job times out its wait; the worker must still hold the
# slot until the provider call actually finishes, so the second job
# cannot start while the first is blocked.
await asyncio.sleep(0.5)
assert not second_started.is_set()
blocker.set()
await _drain(generator)
await generator.stop()
assert second_started.is_set()
# Late result never written; the timeout counted as a failure.
assert (await docs.get("doc-1", user_id="u1"))["summary"] == "second" # second job overwrites nothing late
assert generator.metrics()["failed"] >= 1
async def test_full_queue_drops_and_counts(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
blocker = asyncio.Event()
async def blocking_model(**kwargs):
await blocker.wait()
return _result("x")
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", blocking_model)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config(summary_concurrency=1, summary_queue_size=1))
await generator.start()
row = await docs.get("doc-1", user_id="u1")
assert generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1") is True # in-flight
# Let the single worker pick up the first job.
await asyncio.sleep(0.1)
assert generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1") is True # queued
assert generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1") is False # dropped
assert generator.metrics()["queue_dropped"] == 1
blocker.set()
await _drain(generator)
await generator.stop()
async def test_job_with_wrong_owner_writes_nothing(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", lambda **kwargs: _fake_model("x"))
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
row = await docs.get("doc-1", user_id="u1")
# Identity comes from the job payload, not ambient context (§6.1):
# a job carrying another user's id cannot even read the row.
generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u2")
await _drain(generator)
await generator.stop()
assert (await docs.get("doc-1", user_id="u1"))["summary"] is None
async def test_disabled_generator_accepts_nothing(self, repos):
sf, _projects, _docs = repos
cfg = SimpleNamespace(projects=ProjectsConfig(summaries_enabled=False), uploads=None)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=cfg)
await generator.start()
assert generator.enqueue(document_id="d", project_id="p", user_id="u") is False
await generator.stop()
async def test_invocation_tracing_user_is_the_job_owner(self, repos, text_file, monkeypatch):
from deerflow.runtime.user_context import get_effective_user_id
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
seen: list[str] = []
async def model(**kwargs):
seen.append(get_effective_user_id())
return _result("owned")
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", model)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
row = await docs.get("doc-1", user_id="u1")
generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1")
await _drain(generator)
await generator.stop()
# Workers run on the lifespan's empty context; without the explicit
# binding this would be "default".
assert seen == ["u1"]
async def test_worker_cancellation_cancels_the_shielded_model_task(self, repos, text_file, monkeypatch):
sf, projects, docs = repos
await _shelf_row(projects, docs)
_patch_source(monkeypatch, (text_file, None))
model_started = asyncio.Event()
model_cancelled = asyncio.Event()
async def blocking_model(**kwargs):
model_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
model_cancelled.set()
raise
return _result("unreachable")
monkeypatch.setattr(summaries, "run_oneshot_llm_detailed", blocking_model)
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
monkeypatch.setattr(summaries, "_STOP_GRACE_SECONDS", 0.1)
await generator.start()
workers = list(generator._workers)
row = await docs.get("doc-1", user_id="u1")
generator.enqueue(document_id="doc-1", project_id=row["project_id"], user_id="u1")
await asyncio.wait_for(model_started.wait(), 2)
# stop() cancels the worker after the grace budget; the shielded
# child must be cancelled and settled with it — never left running
# untracked after stop() returns.
await generator.stop()
assert model_cancelled.is_set()
assert all(task.done() for task in workers)
async def test_idle_workers_stop_without_waiting_out_the_grace(self, repos):
import time
sf, _projects, _docs = repos
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config())
await generator.start()
started = time.monotonic()
await generator.stop()
elapsed = time.monotonic() - started
# Sentinels wake idle loops: an empty queue shuts down immediately
# instead of consuming the full grace period.
assert elapsed < 2.0
assert generator._workers == []
async def test_idle_stop_with_queue_smaller_than_worker_count(self, repos):
"""Regression: queue_size=1 < concurrency=2 must still wake BOTH idle
workers — a sentinel dropped by a full queue would strand one worker
until the grace expires."""
import time
sf, _projects, _docs = repos
generator = SummaryGenerator(session_factory=sf, paths=None, app_config=_app_config(summary_concurrency=2, summary_queue_size=1))
await generator.start()
workers = list(generator._workers)
started = time.monotonic()
await generator.stop()
assert time.monotonic() - started < 2.0
assert all(task.done() for task in workers)
async def _fake_model(text: str, metadata: dict | None = None) -> OneshotLLMResult:
return _result(text, metadata)