159 lines
6.6 KiB
Python
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)
|