deerflow-code/offline-backend-20260512/backend/tests/test_report_collaboration_eval.py
2026-09-07 18:24:55 +08:00

220 lines
9.8 KiB
Python

"""RC-BE-018: 20 annotated tasks, deterministic scoring, blind compare, reliability."""
from __future__ import annotations
from collections import Counter
import pytest
from app.report_collaboration.eval import (
METRIC_GATES,
RECOMMENDED_RUNTIME_PROFILE,
compare_blind,
evaluate_catalog,
evaluate_task,
get_eval_task,
load_eval_tasks,
materialize_failure,
materialize_gold,
score_report,
summarize_reliability,
)
from app.report_collaboration.eval.schema import ReportCandidate
from app.report_collaboration.eval.catalog import STANDARD_DIRECTED_MODS
from app.report_collaboration.execution.ledger import TaskResult
from app.report_collaboration.execution.member_runner import WaveExecutor
from app.report_collaboration.execution.quality_roles import QualityRoleKernel
from app.report_collaboration.interventions.intent_router import RuntimeIntentContext, RuntimeIntentRouterAgent
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
def test_catalog_has_twenty_balanced_tasks() -> None:
tasks = load_eval_tasks()
assert len(tasks) == 20
assert len({item.id for item in tasks}) == 20
counts = Counter(item.category for item in tasks)
assert counts == {"policy": 4, "market": 4, "enterprise": 4, "event": 4, "comparison": 4}
assert get_eval_task("ev-05").title == "新能源汽车主要品牌市场趋势"
for task in tasks:
assert task.required_angles
assert task.key_facts
assert task.forbidden_fabrications
assert task.template.sections
assert task.review_rules
assert task.acceptable_sources
assert [item.id for item in task.directed_modifications] == [item.id for item in STANDARD_DIRECTED_MODS]
def test_recommended_profile_matches_disabled_production_defaults() -> None:
config = ReportCollaborationConfig()
for key, value in RECOMMENDED_RUNTIME_PROFILE.items():
assert getattr(config, key) == value
assert config.worker_enabled is False
assert METRIC_GATES["citation_coverage"] >= 0.85
assert METRIC_GATES["angle_coverage"] == 1.0
@pytest.mark.asyncio
async def test_all_gold_reports_clear_eval_gates() -> None:
summary = await evaluate_catalog()
assert summary.tasks == 20
assert summary.passed == 20
assert summary.composite >= 0.95
assert set(summary.by_category) == {"policy", "market", "enterprise", "event", "comparison"}
@pytest.mark.asyncio
async def test_known_failures_are_reproducible() -> None:
task = get_eval_task("ev-05")
gold = await evaluate_task(task, materialize_gold(task))
assert gold.passed
fabricated = await evaluate_task(task, materialize_failure(task, "fabricated"))
assert not fabricated.passed
assert not fabricated.metric("factual_accuracy").passed
missing = await evaluate_task(task, materialize_failure(task, "missing_angle"))
assert not missing.metric("angle_coverage").passed
uncited = await evaluate_task(task, materialize_failure(task, "uncited"))
assert not uncited.metric("citation_coverage").passed
empty = score_report(task, materialize_failure(task, "empty"), directed_hits=[True, True, True])
assert not empty.passed
@pytest.mark.asyncio
async def test_blind_compare_keeps_roundtable_failure_instance() -> None:
task = get_eval_task("ev-05")
hits = [True, True, True]
result = compare_blind(task, materialize_gold(task), materialize_failure(task, "roundtable_essay"), directed_hits=hits)
assert result.winner == "collaboration"
assert result.deltas["citation_coverage"] > 0
assert result.deltas["template_adherence"] > 0
assert not result.roundtable.passed
assert "不得仅以" in result.notes
def test_reliability_counts_twenty_fixed_runs() -> None:
task = get_eval_task("ev-05")
golds = [materialize_gold(task) for _ in range(17)]
failures = [
materialize_failure(task, "empty"),
materialize_failure(task, "illegal_json"),
materialize_gold(task).model_copy(update={"status": "failed", "notes": "early_stop"}),
]
counts = summarize_reliability(golds + failures)
assert counts.runs == 20
assert counts.completed == 17
assert counts.empty_output == 1
assert counts.illegal_json == 1
assert counts.early_stop == 1
assert counts.failure_rate == pytest.approx(3 / 20)
@pytest.mark.asyncio
async def test_rewrite_without_research_is_not_classified_as_research_again() -> None:
task = get_eval_task("ev-05")
router = RuntimeIntentRouterAgent()
rewrite = await router.route(
RuntimeIntentContext(
text="重写第二章,结论更谨慎,不要重新检索",
requirement={"topic": task.title, "required_angles": task.required_angles, "revision": 1},
plan_nodes=[{"id": "write", "role_key": "writer"}, {"id": "research", "role_key": "researcher"}],
)
)
assert rewrite.intent == "rewrite_section"
research = await router.route(
RuntimeIntentContext(
text="海外市场资料太旧,重新检索 2026 年最新信息",
requirement={"topic": task.title, "required_angles": task.required_angles, "revision": 1},
plan_nodes=[{"id": "research", "role_key": "researcher"}],
)
)
assert research.intent == "research_again"
@pytest.mark.asyncio
async def test_scripted_quality_run_covers_ev01_annotations() -> None:
task = get_eval_task("ev-01")
store = MemoryReportCollaborationStore()
session = await store.create_session(owner_id="u-eval", title=task.title, idempotency_key="eval-s")
await store.save_requirement_state(
session["id"],
requirement_json={"snapshot": {"topic": task.title, "required_angles": task.required_angles, "time_range": task.time_range, "revision": 1}},
requirement_revision=1,
status="proposal_ready",
)
plan = {
"id": "plan_eval_pipeline",
"proposal_group_id": "grp_eval",
"title": task.title,
"strategy": "balanced_review",
"summary": "eval",
"rationale": "RC-BE-018",
"recommended": True,
"estimated_duration_seconds": 300,
"estimated_cost_level": "low",
"requirement_revision": 1,
"roles": [],
"nodes": [
{"id": "research", "label": "检索", "role_key": "researcher", "output_artifact_type": "EvidenceBundle", "depends_on": [], "max_attempts": 3, "angle": task.required_angles[0]},
{"id": "verify", "label": "核验", "role_key": "verifier", "output_artifact_type": "VerificationResult", "depends_on": ["research"], "max_attempts": 3},
{"id": "analyze", "label": "分析", "role_key": "analyst", "output_artifact_type": "AngleAnalysis", "depends_on": ["verify"], "max_attempts": 3},
{"id": "write", "label": "写作", "role_key": "writer", "output_artifact_type": "ReportSectionDraft", "depends_on": ["analyze"], "max_attempts": 3},
{"id": "review", "label": "审稿", "role_key": "reviewer", "output_artifact_type": "ReviewDecision", "depends_on": ["write"], "max_attempts": 3},
],
"edges": [
{"source": "research", "target": "verify"},
{"source": "verify", "target": "analyze"},
{"source": "analyze", "target": "write"},
{"source": "write", "target": "review"},
],
"quality_gates": [],
"validation": {"ok": True, "errors": []},
"revision": 1,
"status": "proposed",
}
inserted = await store.insert_plan(session["id"], plan)
await store.select_plan(session["id"], inserted["id"], idempotency_key="eval-sel", expected_revision=None)
run = await store.create_run(session["id"], plan_id=inserted["id"], idempotency_key="eval-run", expected_revision=None)
claims = []
sources = []
angle_blob = " ".join(task.required_angles)
for fact in task.key_facts:
token = fact.acceptable_source_tokens[0]
claims.append(
{
"claim_id": fact.id,
"text": f"{fact.text} {angle_blob}",
"kind": "fact",
"source_ids": [f"src-{fact.id}"],
"excerpt": fact.text,
"published_at": "2025-06-01",
"unit": "万元" if "万" in fact.number else None,
"scope": fact.id,
}
)
sources.append({"source_id": f"src-{fact.id}", "title": f"{token} 公开口径 {angle_blob}", "excerpt": fact.text, "published_at": "2025-06-01"})
kernel = QualityRoleKernel(store)
kernel.enqueue(
"research",
TaskResult(
kind="artifact_draft",
artifact_type="EvidenceBundle",
artifact_draft={"schema_version": 1, "claims": claims, "sources": sources, "conflicts": [], "coverage_gaps": []},
),
)
status = await WaveExecutor(store, kernel).run_until_idle(run["id"])
assert status == "completed"
drafts = [item for item in await store.list_artifacts_full(run["id"]) if item["artifact_type"] == "ReportSectionDraft" and item["validation_status"] == "validated"]
assert drafts
markdown = str(drafts[-1]["content"]["markdown"])
candidate = ReportCandidate(
source="collaboration",
markdown=markdown,
source_index=[{"id": fact.id, "title": f"{fact.acceptable_source_tokens[0]} 公开口径", "url": "https://example.gov.cn/ev-01", "published_at": "2025-06-01", "added_at": "2025-06-02T00:00:00Z"} for fact in task.key_facts],
events_present=["message.created", "report.version.created"],
)
score = score_report(task, candidate, directed_hits=[True, True, True], skip={"template_adherence"})
assert score.metric("factual_accuracy").passed
assert score.metric("angle_coverage").passed
assert score.metric("citation_coverage").passed