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

200 lines
9.0 KiB
Python

"""RC-BE-006: agent catalog, selector, and candidate plans."""
from __future__ import annotations
import pytest
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.planning.assembler import AssembledPlan, NodeBinding, PlanAssembler
from app.report_collaboration.planning.catalog import project_agent_catalog
from app.report_collaboration.planning.controller import PlanController
from app.report_collaboration.planning.selector import AgentSelector
from app.report_collaboration.planning.service import PlanProposalService
from app.report_collaboration.planning.validator import validate_assembled_plan
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
class _AgentStore:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
async def list_visible(self, user_id: str) -> list[dict]:
return list(self._rows)
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict]:
return {row["id"]: {"skills": ["web_search"], "tool_groups": ["web"], "model": "demo"} for row in self._rows if row["id"] in agent_ids}
def _requirement() -> ReportRequirementSnapshot:
return ReportRequirementSnapshot(
topic="新能源汽车主要品牌市场趋势",
audience="企业战略决策人员",
required_angles=["市场份额", "价格策略", "海外进展", "政策环境"],
excluded_angles=[],
revision=1,
)
@pytest.mark.asyncio
async def test_catalog_strips_secrets_and_keeps_builtin_roles() -> None:
store = _AgentStore(
[
{
"id": "ag-research",
"name": "行业研究员",
"description": "负责检索与研究",
"soul": "SECRET_SOUL",
"api_key": "sk-secret",
"base_url": "https://hidden.example",
}
]
)
catalog = await project_agent_catalog(user_id="u1", config=ReportCollaborationConfig(), agent_store=store)
dumped = [item.model_dump() for item in catalog.agents]
blob = str(dumped)
assert "SECRET_SOUL" not in blob
assert "sk-secret" not in blob
assert "hidden.example" not in blob
assert any(item.agent_id == "ag-research" for item in catalog.agents)
assert any(item.agent_id == "rc.role.reviewer" for item in catalog.agents)
assert "web_search" in catalog.allowed_tool_names
def test_selector_prefers_visible_user_agent() -> None:
async def _run() -> None:
catalog = await project_agent_catalog(
user_id="u1",
config=ReportCollaborationConfig(),
agent_store=_AgentStore([{"id": "ag-research", "name": "行业研究员", "description": "检索研究"}]),
)
agent, reason = AgentSelector().pick("researcher", catalog.agents)
assert agent.agent_id == "ag-research"
assert "用户可见" in reason
import asyncio
asyncio.run(_run())
def test_three_strategies_differ_and_stay_acyclic() -> None:
import asyncio
async def _run() -> None:
cfg = ReportCollaborationConfig()
catalog = await project_agent_catalog(user_id="u1", config=cfg, agent_store=None)
requirement = _requirement()
strategies = PlanController().propose(requirement, max_parallel_tasks=cfg.max_parallel_tasks, max_team_members=cfg.max_team_members)
assert [item.strategy for item in strategies] == ["balanced_review", "parallel_depth", "focused_fast"]
assembler = PlanAssembler()
assembled = [
assembler.assemble(item, requirement=requirement, catalog=catalog, config=cfg, proposal_group_id="g1")
for item in strategies
]
node_counts = [len(item.candidate.nodes) for item in assembled]
costs = [item.candidate.estimated_cost_level for item in assembled]
gates = [tuple(gate.id for gate in item.candidate.quality_gates) for item in assembled]
assert node_counts[1] > node_counts[0] > node_counts[2]
assert costs == ["medium", "high", "low"]
assert "dual_verification" in gates[1]
assert "dual_verification" not in gates[0]
assert assembled[0].candidate.recommended is True
group_ids = {item.candidate.proposal_group_id for item in assembled}
assert len(group_ids) == 1
for item in assembled:
validation = validate_assembled_plan(item, catalog=catalog, config=cfg)
assert validation.ok, validation.errors
assert all(node.output_artifact_type for node in item.candidate.nodes)
assert all(node.acceptance_criteria for node in item.candidate.nodes)
assert all(binding.agent_id.startswith("rc.role.") for binding in item.bindings)
assert all(tool in cfg.allowed_tools for node in item.candidate.nodes for tool in node.allowed_tools)
asyncio.run(_run())
def test_validator_rejects_unknown_agent_id() -> None:
import asyncio
async def _run() -> None:
cfg = ReportCollaborationConfig()
catalog = await project_agent_catalog(user_id="u1", config=cfg, agent_store=None)
requirement = _requirement()
strategy = PlanController().propose(requirement, max_parallel_tasks=4, max_team_members=8)[2]
assembled = PlanAssembler().assemble(strategy, requirement=requirement, catalog=catalog, config=cfg, proposal_group_id="g1")
assembled.bindings[0] = NodeBinding(
node_id=assembled.bindings[0].node_id,
agent_id="not-in-catalog",
agent_name="幽灵",
role_key="researcher",
reason="hallucinated",
)
validation = validate_assembled_plan(assembled, catalog=catalog, config=cfg)
assert validation.ok is False
assert any("目录外" in item for item in validation.errors)
asyncio.run(_run())
@pytest.mark.asyncio
async def test_service_writes_plans_without_starting_a_run() -> None:
store = MemoryReportCollaborationStore()
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1")
await store.save_requirement_state(
session["id"],
requirement_json={"snapshot": _requirement().model_dump(), "clarifications": [], "assumptions": [], "clarification_round": 1, "ready_to_plan": True},
requirement_revision=1,
status="clarifying",
)
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan", expected_revision=1)
saved = await PlanProposalService(store).fulfill(session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan")
assert len(saved) == 3
snapshot = await store.get_snapshot(session["id"])
assert snapshot["session"]["status"] == "proposal_ready"
assert snapshot["session"]["selected_plan_id"] is None
assert snapshot["run"] is None
assert {item["strategy"] for item in snapshot["plans"]} == {"balanced_review", "parallel_depth", "focused_fast"}
assert all(item["requirement_revision"] == 1 for item in snapshot["plans"])
assert snapshot["session"]["active_run_id"] is None
selected = await store.select_plan(session["id"], saved[0]["id"], idempotency_key="ik-select", expected_revision=1)
after_select = await store.get_snapshot(session["id"])
assert selected["selected_plan_id"] == saved[0]["id"]
assert after_select["run"] is None
assert after_select["session"]["active_run_id"] is None
def test_cycle_is_rejected() -> None:
cfg = ReportCollaborationConfig()
candidate_nodes_cycle = AssembledPlan.model_validate(
{
"candidate": {
"id": "p1",
"proposal_group_id": "g",
"title": "环",
"strategy": "focused_fast",
"summary": "s",
"rationale": "r",
"estimated_duration_seconds": 10,
"estimated_cost_level": "low",
"requirement_revision": 1,
"roles": [],
"nodes": [
{"id": "a", "label": "A", "depends_on": ["b"], "output_artifact_type": "EvidenceBundle", "acceptance_criteria": ["x"]},
{"id": "b", "label": "B", "depends_on": ["a"], "output_artifact_type": "VerificationResult", "acceptance_criteria": ["x"]},
],
"edges": [{"source": "b", "target": "a"}, {"source": "a", "target": "b"}],
"quality_gates": [{"id": "cite_required", "label": "来源"}],
"validation": {"ok": True, "errors": []},
"revision": 1,
"status": "proposed",
},
"bindings": [],
}
)
from app.report_collaboration.planning.catalog import AgentCatalogProjection
catalog = AgentCatalogProjection(agents=[], tools=[], allowed_tool_names=list(cfg.allowed_tools))
validation = validate_assembled_plan(candidate_nodes_cycle, catalog=catalog, config=cfg)
assert validation.ok is False
assert any("循环" in item for item in validation.errors)