deer-flow/backend/tests/test_migration_0024_project_documents.py
Zeren Wang 553f85e788
feat(projects): best-effort LLM summaries for shelf documents (#6581)
Opt-in per-document summary column rendered into the injected
<documents> index, list_project_documents JSON, and the documents API.

Pipeline (projects/summaries.py, behind the narrow enqueue_summary
interface):
- The two shelf write routes enqueue a job on created=True; dedup hits
  never regenerate. Off by default (projects.summaries_enabled).
- Bounded worker pool (summary_concurrency + bounded summary_queue_size;
  a full queue drops the job) fed by FIFO; shutdown drains queued jobs,
  delivers one sentinel per worker via blocking put (queue smaller than
  the worker pool cannot strand idle workers), and bounds only
  genuinely in-flight jobs with the grace period.
- Jobs carry (document_id, project_id, user_id) explicitly; the job's
  owner is bound into the user ContextVar around the model call so
  tracing/usage attribution never falls back to 'default'.
- Text source reuses read_text_serving_path (convertible-first rule,
  content validation); conversion has no deadline (await_drained), the
  model call has a caller-side wait limit — the worker slot is retained
  until the provider call actually finishes and late results are
  discarded.
- Output contract: strip leading <think> reasoning -> unwrap fence/
  quotes -> collapse to one line -> empty is NULL -> UTF-8 byte cap on
  a character boundary; length-capped responses (provider-normalized
  detectors) are failures.
- set_summary pins updated_at (literal self-assignment) so a landing
  summary never reorders the shelf; owner-predicated UPDATE is a no-op
  on rows purged mid-generation.
- Metrics: eligible/success/failed/skipped/queue_dropped/
  uncapped_accepted.

Schema/config:
- project_documents.summary (migration 0036, nullable, no backfill;
  forward-compat floor unchanged).
- Six projects.summary_* keys, all registered in STARTUP_ONLY_FIELDS
  (captured once at lifespan startup); config_version 57 -> 58.
- run_oneshot_llm gains an optional model_overrides passthrough (passed
  only when set, existing callers unchanged) and a detailed variant
  exposing the raw response message for termination-signal detection.

Docs: API.md response field, ARCHITECTURE.md pipeline paragraph,
migrations AGENTS.md chain head, root AGENTS.md records the scoped
all-core exception with the lift-out path.
2026-10-10 07:11:02 +08:00

121 lines
3.7 KiB
Python

"""Migration tests for 0024_project_documents (Phase-2 spec §6.1).
Pins the table shape and the four indexes on upgrade, and the clean downgrade
(the chain-head pin moved on to 0025_repair_run_change_seq with that
revision).
"""
from __future__ import annotations
import asyncio
import pytest
import sqlalchemy as sa
from alembic import command
from deerflow.persistence.bootstrap import _get_alembic_config
from deerflow.persistence.engine import close_engine, init_engine
pytestmark = pytest.mark.asyncio
REVISION = "0024_project_documents"
PREVIOUS = "0023_user_preferences"
TABLE = "project_documents"
INDEXES = {
"ix_project_documents_project_id",
"ix_project_documents_user_id",
"ix_project_documents_sha256",
"ix_project_documents_trashed_at",
}
COLUMNS = {
"id",
"project_id",
"user_id",
"name",
"stored_relpath",
"sha256",
"size_bytes",
"source_thread_id",
"source_kind",
"source_name",
"trashed_at",
"trash_origin",
"created_at",
"updated_at",
# Added by 0037_project_document_summaries: the empty-DB bootstrap path
# creates the table from the ORM model, so the column is present here.
"summary",
}
#: The shape produced by the 0024 revision's own ``create_table`` — without
#: the later 0037 column. Bootstrap (create_all from ORM) carries ``summary``;
#: a downgrade-and-re-upgrade to 0024 does not.
COLUMNS_AT_0024 = COLUMNS - {"summary"}
async def _engine(tmp_path, name: str = "test.db"):
url = f"sqlite+aiosqlite:///{tmp_path / name}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
from deerflow.persistence.engine import get_engine
return get_engine()
async def _inspect(engine):
async with engine.connect() as conn:
def _read(sync_conn):
inspector = sa.inspect(sync_conn)
return (
inspector.has_table(TABLE),
{c["name"] for c in inspector.get_columns(TABLE)} if inspector.has_table(TABLE) else set(),
{i["name"] for i in inspector.get_indexes(TABLE)} if inspector.has_table(TABLE) else set(),
)
return await conn.run_sync(_read)
async def test_0024_upgrade_creates_table_and_indexes(tmp_path):
engine = await _engine(tmp_path)
try:
exists, columns, indexes = await _inspect(engine)
assert exists
assert columns == COLUMNS
assert INDEXES <= indexes
finally:
await close_engine()
async def test_0024_downgrade_drops_table_and_reupgrade_recreates(tmp_path):
engine = await _engine(tmp_path)
try:
cfg = _get_alembic_config(engine)
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
exists, _, _ = await _inspect(engine)
assert not exists
# Downgrade is guarded and idempotent: repeating it is a no-op.
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
exists, columns, indexes = await _inspect(engine)
assert exists
assert columns == COLUMNS_AT_0024
assert INDEXES <= indexes
finally:
await close_engine()
async def test_0024_upgrade_is_guarded_against_an_existing_table(tmp_path):
"""The inspector.has_table idiom keeps re-running the revision a no-op."""
engine = await _engine(tmp_path)
try:
cfg = _get_alembic_config(engine)
# Simulate a database that already carries the table (create_all
# bootstrap path): upgrading again must not fail.
await asyncio.to_thread(command.upgrade, cfg, REVISION)
exists, columns, _ = await _inspect(engine)
assert exists and columns == COLUMNS
finally:
await close_engine()