deerflow-code/offline-backend-20260512/backend/app/report_collaboration/planning/assembler.py
2026-09-07 18:24:55 +08:00

159 lines
6.6 KiB
Python

"""Bind RoleDemand slots to catalog agents, allowlisted tools, budgets and quality gates."""
from __future__ import annotations
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from app.report_collaboration.contracts.plans import PlanEdge, PlanNode, PlanRole, PlanValidation, QualityGateSpec, ReportPlanCandidate
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
from app.report_collaboration.planning.catalog import AgentCatalogProjection
from app.report_collaboration.planning.controller import StrategyRoleSet
from app.report_collaboration.planning.selector import AgentSelector
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
_GATE_LABELS: dict[str, tuple[str, str]] = {
"cite_required": ("来源核验", "关键事实必须给出可核对来源"),
"dual_verification": ("双重核验", "高风险结论需两条独立核验链"),
"independent_review": ("独立审稿", "审稿员不得覆盖报告正文,只输出 ReviewDecision"),
"coverage_matrix": ("角度覆盖", "必选角度均需有对应证据与分析"),
}
_ACCEPTANCE: dict[str, list[str]] = {
"EvidenceBundle": ["至少包含带 source_id 的事实或摘录", "覆盖本节点角度,缺口写入 coverage_gaps"],
"VerificationResult": ["关键数字均标注支持或反对证据", "冲突来源不得省略"],
"AngleAnalysis": ["结论必须引用已核验证据 ID", "写明假设与局限"],
"ReportVersion": ["模板章节完整", "关键事实带引用"],
"ReviewDecision": ["问题可映射到具体节点或章节", "不得改写报告正文"],
}
class NodeBinding(BaseModel):
model_config = ConfigDict(extra="forbid")
node_id: str
agent_id: str
agent_name: str
role_key: str
reason: str
class AssembledPlan(BaseModel):
model_config = ConfigDict(extra="forbid")
candidate: ReportPlanCandidate
bindings: list[NodeBinding] = Field(default_factory=list)
def _tools_for(role_key: str, suggested: list[str], allowed: list[str]) -> list[str]:
wanted = list(suggested)
if role_key == "researcher":
wanted = ["web_search", "web_fetch", "knowledge_search"]
elif role_key in {"verifier", "analyst", "synthesizer"}:
wanted = ["knowledge_search"]
else:
wanted = []
return [name for name in wanted if name in allowed]
def _input_types(output_by_id: dict[str, str], depends_on: list[str]) -> list[str]:
types: list[str] = []
for node_id in depends_on:
artifact = output_by_id.get(node_id)
if artifact and artifact not in types:
types.append(artifact)
return types
class PlanAssembler:
def __init__(self, *, selector: AgentSelector | None = None) -> None:
self._selector = selector or AgentSelector()
def assemble(
self,
strategy: StrategyRoleSet,
*,
requirement: ReportRequirementSnapshot,
catalog: AgentCatalogProjection,
config: ReportCollaborationConfig,
proposal_group_id: str,
revision: int = 1,
) -> AssembledPlan:
used_user_ids: set[str] = set()
bindings: list[NodeBinding] = []
nodes: list[PlanNode] = []
roles_by_key: dict[str, PlanRole] = {}
output_by_id: dict[str, str] = {}
for slot in strategy.slots:
demand = slot.demand
agent, reason = self._selector.pick(demand.role_key, catalog.agents, avoid_ids=used_user_ids)
if agent.source == "user":
used_user_ids.add(agent.agent_id)
tools = _tools_for(demand.role_key, demand.suggested_tools, catalog.allowed_tool_names)
artifact = demand.output_artifact_type
criteria = list(_ACCEPTANCE.get(artifact, ["成果必须通过对应 Schema 与验收器"]))
if demand.angle:
criteria.append(f"覆盖角度:{demand.angle}")
node = PlanNode(
id=slot.slot_id,
label=demand.angle or demand.display_name,
role_key=demand.role_key,
role_display_name=agent.name if agent.source == "user" else demand.display_name,
angle=demand.angle,
depends_on=list(slot.depends_on),
mission=demand.mission,
output_artifact_type=artifact,
input_artifact_types=_input_types(output_by_id, slot.depends_on),
allowed_tools=tools,
acceptance_criteria=criteria,
)
output_by_id[slot.slot_id] = artifact
nodes.append(node)
bindings.append(
NodeBinding(
node_id=slot.slot_id,
agent_id=agent.agent_id,
agent_name=agent.name,
role_key=demand.role_key,
reason=reason,
)
)
if demand.role_key not in roles_by_key:
roles_by_key[demand.role_key] = PlanRole(
key=demand.role_key,
display_name=demand.display_name,
responsibility=demand.mission,
angle=demand.angle,
)
edges = [PlanEdge(source=dep, target=node.id) for node in nodes for dep in node.depends_on]
gates = [
QualityGateSpec(id=gate_id, label=_GATE_LABELS.get(gate_id, (gate_id, ""))[0], description=_GATE_LABELS.get(gate_id, (gate_id, ""))[1] or None)
for gate_id in strategy.quality_gate_ids
]
bound_names = "、".join(dict.fromkeys(item.agent_name for item in bindings))
rationale = f"{strategy.rationale} 绑定:{bound_names}。"
candidate = ReportPlanCandidate(
id=f"rcp_{strategy.strategy}_{uuid4().hex[:10]}",
proposal_group_id=proposal_group_id,
title=strategy.title,
strategy=strategy.strategy,
summary=strategy.summary,
rationale=rationale,
recommended=strategy.recommended,
estimated_duration_seconds=strategy.estimated_duration_seconds,
estimated_cost_level=strategy.estimated_cost_level, # type: ignore[arg-type]
requirement_revision=requirement.revision,
roles=list(roles_by_key.values()),
nodes=nodes,
edges=edges,
quality_gates=gates,
report_structure_id=requirement.report_structure_id,
validation=PlanValidation(ok=True, errors=[]),
revision=revision,
status="proposed",
)
return AssembledPlan(candidate=candidate, bindings=bindings)