"""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)