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

465 lines
19 KiB
Python

"""RC-BE-014: runtime intent, impact analysis and safe-point commands."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.report_collaboration.contracts.commands import IntentDecision
from app.report_collaboration.execution.ledger import TaskLedger, TaskResult
from app.report_collaboration.interventions.impact_analyzer import ImpactAnalyzer
from app.report_collaboration.interventions.intent_router import RuntimeIntentContext, RuntimeIntentRouterAgent
from app.report_collaboration.interventions.service import RuntimeCommandService
from deerflow.persistence.base import Base
from deerflow.persistence.report_collaboration import (
MemoryReportCollaborationStore,
ReportCollaborationConflictError,
make_report_collaboration_store,
)
def _plan() -> dict:
nodes = [
_node("research", "researcher", "EvidenceBundle", []),
_node("verify", "verifier", "VerificationResult", ["research"]),
_node("analyze", "analyst", "AngleAnalysis", ["verify"]),
_node("write", "writer", "ReportSectionDraft", ["analyze"]),
_node("review", "reviewer", "ReviewDecision", ["write"]),
]
return {
"id": "plan_intervention",
"proposal_group_id": "grp_intervention",
"title": "运行期干预测试",
"strategy": "quality",
"summary": "完整质量链",
"rationale": "测试",
"recommended": True,
"estimated_duration_seconds": 600,
"estimated_cost_level": "medium",
"requirement_revision": 1,
"roles": [],
"nodes": nodes,
"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",
}
def _node(node_id: str, role: str, artifact: str, deps: list[str]) -> dict:
return {
"id": node_id,
"label": node_id,
"role_key": role,
"role_display_name": role,
"mission": role,
"output_artifact_type": artifact,
"allowed_tools": ["web_search"] if role == "researcher" else [],
"acceptance_criteria": [],
"depends_on": deps,
"max_attempts": 3,
}
async def _boot(store=None) -> tuple[object, str, str]:
store = store or MemoryReportCollaborationStore()
session = await store.create_session(owner_id="u1", title="新能源", idempotency_key="session")
requirement = {
"topic": "新能源汽车市场趋势",
"audience": "企业战略决策人员",
"required_angles": ["市场份额"],
"excluded_angles": [],
"revision": 1,
}
await store.save_requirement_state(
session["id"],
requirement_json={"snapshot": requirement, "clarifications": [], "assumptions": [], "ready_to_plan": True},
requirement_revision=1,
status="proposal_ready",
)
plan = await store.insert_plan(session["id"], _plan())
await store.select_plan(session["id"], plan["id"], idempotency_key="select", expected_revision=1)
run = await store.create_run(session["id"], plan_id=plan["id"], idempotency_key="run", expected_revision=1)
return store, session["id"], run["id"]
@pytest.mark.asyncio
async def test_intent_router_prioritizes_answer_to_waiting_node() -> None:
router = RuntimeIntentRouterAgent()
decision = await router.route(
RuntimeIntentContext(
text="数据口径按中国乘用车零售量",
requirement={"topic": "新能源汽车", "revision": 1},
plan_nodes=_plan()["nodes"],
target_node_ids=["analyze"],
awaiting_node_ids=["analyze"],
)
)
assert decision.intent == "answer_question"
assert decision.confidence == 0.99
assert decision.requested_target_ids == ["analyze"]
@pytest.mark.asyncio
async def test_model_intent_cannot_expand_authorized_target() -> None:
class _Model:
async def classify(self, _context):
return IntentDecision(
intent="reanalyze",
confidence=0.91,
normalized_instruction="重新分析",
scope="plan",
requested_target_ids=["malicious-node"],
requires_confirmation=False,
cost_level="high",
reason="model",
)
decision = await RuntimeIntentRouterAgent(_Model()).route(
RuntimeIntentContext(
text="请重新审视这里",
requirement={"topic": "新能源汽车", "revision": 1},
plan_nodes=_plan()["nodes"],
target_node_ids=["analyze"],
)
)
assert decision.requested_target_ids == ["analyze"]
assert decision.scope == "artifact_lineage"
assert decision.cost_level == "medium"
assert "malicious-node" not in decision.requested_target_ids
@pytest.mark.asyncio
async def test_explicit_angle_text_resolves_one_parallel_branch() -> None:
nodes = [
{**_node("research-market", "researcher", "EvidenceBundle", []), "angle": "市场份额", "label": "市场研究"},
{**_node("research-overseas", "researcher", "EvidenceBundle", []), "angle": "海外进展", "label": "海外研究"},
_node("write", "writer", "ReportSectionDraft", ["research-market", "research-overseas"]),
]
plan = {**_plan(), "nodes": nodes, "edges": [
{"source": "research-market", "target": "write"},
{"source": "research-overseas", "target": "write"},
]}
decision = await RuntimeIntentRouterAgent().route(
RuntimeIntentContext(
text="海外市场资料太旧,重新检索 2026 年最新信息",
requirement={"topic": "新能源汽车", "revision": 1},
plan_nodes=nodes,
)
)
assert decision.requested_target_ids == ["research-overseas"]
impact = ImpactAnalyzer().analyze(
decision,
plan=plan,
node_runs=[],
agent_runs=[],
explicit_target_node_ids=decision.requested_target_ids,
)
assert impact.impact_nodes == ["research-overseas", "write"]
assert impact.requires_confirmation is False
add = await RuntimeIntentRouterAgent().route(
RuntimeIntentContext(
text="增加供应链与原材料角度分析",
requirement={"topic": "新能源汽车", "revision": 1},
plan_nodes=nodes,
)
)
add_impact = ImpactAnalyzer().analyze(
add,
plan=plan,
node_runs=[],
agent_runs=[],
explicit_target_node_ids=[],
)
assert add_impact.impact_nodes == ["research-market", "write"]
assert "research-overseas" not in add_impact.impact_nodes
@pytest.mark.asyncio
async def test_impact_analyzer_keeps_evidence_when_reanalyzing() -> None:
decision = await RuntimeIntentRouterAgent().route(
RuntimeIntentContext(
text="这部分分析不到位,请重新分析",
requirement={"topic": "新能源汽车", "revision": 1},
plan_nodes=_plan()["nodes"],
target_node_ids=["analyze"],
)
)
impact = ImpactAnalyzer().analyze(
decision,
plan=_plan(),
node_runs=[],
agent_runs=[],
explicit_target_node_ids=["analyze"],
)
assert impact.target_node_ids == ["analyze"]
assert impact.impact_nodes == ["analyze", "write", "review"]
assert "research" not in impact.impact_nodes
assert impact.requires_confirmation is False
@pytest.mark.asyncio
async def test_safe_point_supersedes_inflight_attempt_and_late_output() -> None:
store, _session_id, run_id = await _boot()
ledger = TaskLedger(store)
assignment = (await ledger.ready_assignments(run_id))[0]
node_rows = await store.list_node_runs(run_id)
current = next(item for item in node_rows if item["node_run_id"] == assignment.node_run_id)
current["status"] = "running"
await store.save_node_run(current)
await store.save_agent_run(
{
"agent_run_id": "ar-research",
"session_id": assignment.session_id,
"run_id": run_id,
"node_run_id": assignment.node_run_id,
"phase_id": assignment.phase_id,
"role": "researcher",
"display_name": "研究员",
"status": "running",
}
)
service = RuntimeCommandService(store)
command = await service.submit(
run_id,
text="资料太旧了,请重新检索",
idempotency_key="command-research",
target_node_id="research",
agent_run_id="ar-research",
)
assert command["status"] == "accepted_waiting_boundary"
assert command["impact_nodes"] == ["research", "verify", "analyze", "write", "review"]
await store.save_agent_run({"agent_run_id": "ar-research", "status": "completed"})
applied = await service.process_safe_point(run_id, completed_node_id="research")
assert applied == [command["command_id"]]
latest = await store.list_node_runs(run_id)
research = [item for item in latest if item["node_id"] == "research"]
assert [item["status"] for item in research] == ["superseded", "planned"]
assert research[-1]["command_overlay_revision"] == 1
assert research[-1]["agent_snapshot"]["command_overlays"][0]["command_id"] == command["command_id"]
evidence = {
"schema_version": 1,
"claims": [{"claim_id": "late", "text": "旧结果", "kind": "fact", "source_ids": ["s1"], "excerpt": "旧"}],
"sources": [{"source_id": "s1", "title": "旧来源", "excerpt": "旧"}],
}
await ledger.apply_result(
assignment,
TaskResult(kind="artifact_draft", artifact_type="EvidenceBundle", artifact_draft=evidence),
)
artifacts = await store.list_artifacts_full(run_id)
assert len(artifacts) == 1
assert artifacts[0]["validation_status"] == "superseded"
@pytest.mark.asyncio
async def test_add_angle_updates_requirement_and_requeues_only_downstream() -> None:
store, session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await service.submit(
run_id,
text="增加供应链角度分析",
idempotency_key="command-angle",
target_node_id="research",
)
assert command["status"] == "completed"
session = await store.get_session_record(session_id)
assert session["requirement_revision"] == 2
assert "供应链与原材料" in session["requirement_json"]["snapshot"]["required_angles"]
nodes = await store.list_node_runs(run_id)
assert all(int(item.get("command_overlay_revision") or 0) == 1 for item in nodes)
@pytest.mark.asyncio
async def test_high_cost_replan_requires_confirmation_and_is_idempotent() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await service.submit(run_id, text="重新规划,换一套方案", idempotency_key="command-replan")
assert command["status"] == "confirmation_required"
confirmed = await service.confirm(run_id, command["command_id"], idempotency_key="confirm-replan")
assert confirmed["status"] == "completed"
repeated = await service.confirm(run_id, command["command_id"], idempotency_key="confirm-replan")
assert repeated["status"] == "completed"
events = await store.list_events(run_id)
completed = [item for item in events if item["type"] == "command.completed" and item["data"]["command_id"] == command["command_id"]]
assert len(completed) == 1
@pytest.mark.asyncio
async def test_command_status_compare_and_swap_rejects_second_worker() -> None:
store, session_id, run_id = await _boot()
command = await store.create_command(session_id, idempotency_key="cas", text="test", run_id=run_id)
await store.patch_command(command["command_id"], patch={"status": "executing"}, expected_statuses={"pending"})
with pytest.raises(ReportCollaborationConflictError) as caught:
await store.patch_command(command["command_id"], patch={"status": "executing"}, expected_statuses={"pending"})
assert caught.value.code == "COMMAND_STATE_CONFLICT"
@pytest.mark.asyncio
async def test_sql_command_patch_persists_typed_classification() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
store = make_report_collaboration_store(async_sessionmaker(engine, expire_on_commit=False))
try:
_store, session_id, run_id = await _boot(store)
command = await store.create_command(session_id, idempotency_key="sql-command", text="重新分析", run_id=run_id)
patched = await store.patch_command(
command["command_id"],
patch={
"intent": "reanalyze",
"confidence": 0.95,
"target_type": "artifact_lineage",
"target_ids": ["analyze"],
"impact_nodes": ["analyze", "write", "review"],
"status": "accepted",
},
expected_statuses={"pending"},
)
assert patched["intent"] == "reanalyze"
assert patched["impact_nodes"] == ["analyze", "write", "review"]
assert (await store.get_command(command["command_id"])) == patched
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_rerun_without_target_does_not_expand_to_whole_graph() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await service.submit(run_id, text="重新执行这个节点", idempotency_key="rerun-all")
assert command["intent"] == "rerun_node"
assert command["status"] == "confirmation_required"
assert command["impact_nodes"] == []
confirmed = await service.confirm(run_id, command["command_id"], idempotency_key="confirm-rerun-all")
assert confirmed["status"] == "failed"
assert confirmed["error"]
assert "可安全返工" in confirmed["error"]
latest = {item["node_id"]: item for item in await store.list_node_runs(run_id)}
assert all(item["status"] == "planned" and int(item["attempt"]) == 1 for item in latest.values())
@pytest.mark.asyncio
async def test_requirement_patch_waits_until_rework_succeeds() -> None:
store, session_id, run_id = await _boot()
for node in await store.list_node_runs(run_id):
node["status"] = "cancelled"
await store.save_node_run(node)
service = RuntimeCommandService(store)
command = await service.submit(
run_id,
text="增加供应链角度分析",
idempotency_key="angle-no-rework",
target_node_id="research",
)
assert command["status"] == "failed"
session = await store.get_session_record(session_id)
assert session["requirement_revision"] == 1
assert "供应链与原材料" not in (session["requirement_json"]["snapshot"].get("required_angles") or [])
@pytest.mark.asyncio
async def test_add_angle_without_research_branch_is_rejected() -> None:
store = MemoryReportCollaborationStore()
session = await store.create_session(owner_id="u1", title="无研究", idempotency_key="no-research")
await store.save_requirement_state(
session["id"],
requirement_json={"snapshot": {"topic": "新能源汽车市场趋势", "required_angles": ["市场份额"], "revision": 1}},
requirement_revision=1,
status="proposal_ready",
)
plan = _plan()
plan["nodes"] = [_node("write", "writer", "ReportSectionDraft", [])]
plan["edges"] = []
inserted = await store.insert_plan(session["id"], plan)
await store.select_plan(session["id"], inserted["id"], idempotency_key="select", expected_revision=1)
run = await store.create_run(session["id"], plan_id=inserted["id"], idempotency_key="run", expected_revision=1)
command = await RuntimeCommandService(store).submit(
run["id"],
text="增加供应链角度分析",
idempotency_key="angle-no-branch",
)
assert command["status"] == "rejected"
assert "研究支路" in (command.get("error") or "")
@pytest.mark.asyncio
async def test_cancel_command_emits_cancelled_event() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await service.submit(run_id, text="重新规划,换一套方案", idempotency_key="cancel-event")
cancelled = await service.cancel(run_id, command["command_id"], idempotency_key="cancel-event-ack")
assert cancelled["status"] == "cancelled"
events = await store.list_events(run_id)
assert any(item["type"] == "command.cancelled" and item["data"]["command_id"] == command["command_id"] for item in events)
@pytest.mark.asyncio
async def test_stuck_executing_command_is_reclaimed_and_applied() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await store.create_command(
(await store.get_run(run_id))["session_id"],
idempotency_key="stuck-exec",
text="资料太旧了,请重新检索",
run_id=run_id,
)
await store.patch_command(
command["command_id"],
patch={
"intent": "research_again",
"impact_nodes": ["research", "verify", "analyze", "write", "review"],
"normalized_instruction": "资料太旧了,请重新检索",
"status": "executing",
},
expected_statuses={"pending"},
)
assert await service.reclaim_executing(run_id, force=True) == [command["command_id"]]
assert (await store.get_command(command["command_id"]))["status"] == "accepted"
applied = await service.process_safe_point(run_id)
assert applied == [command["command_id"]]
assert (await store.get_command(command["command_id"]))["status"] == "completed"
@pytest.mark.asyncio
async def test_expired_executing_command_is_reclaimed_on_safe_point() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await store.create_command(
(await store.get_run(run_id))["session_id"],
idempotency_key="expired-exec",
text="资料太旧了,请重新检索",
run_id=run_id,
)
await store.patch_command(
command["command_id"],
patch={
"intent": "research_again",
"impact_nodes": ["research", "verify", "analyze", "write", "review"],
"status": "executing",
},
expected_statuses={"pending"},
)
store._commands[command["command_id"]]["updated_at"] = "2020-01-01T00:00:00Z"
applied = await service.process_safe_point(run_id)
assert applied == [command["command_id"]]
@pytest.mark.asyncio
async def test_cancelled_run_wins_over_confirmed_rework() -> None:
store, _session_id, run_id = await _boot()
service = RuntimeCommandService(store)
command = await service.submit(run_id, text="重新规划,换一套方案", idempotency_key="cancel-race-command")
await store.request_cancel_run(run_id, idempotency_key="cancel-the-run")
confirmed = await service.confirm(run_id, command["command_id"], idempotency_key="confirm-after-cancel")
assert confirmed["status"] == "failed"
assert "运行已取消" in confirmed["error"]