"""PlanController: 2–3 strategy RoleDemand graphs. Models never emit executable graphs.""" from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field from app.report_collaboration.contracts.plans import RoleDemand from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES STRATEGIES: tuple[str, ...] = ("balanced_review", "parallel_depth", "focused_fast") class RoleSlot(BaseModel): """Controller-local slot. ``demand`` is what a model would emit; ``slot_id`` is server-assigned.""" model_config = ConfigDict(extra="forbid") slot_id: str demand: RoleDemand depends_on: list[str] = Field(default_factory=list) class StrategyRoleSet(BaseModel): model_config = ConfigDict(extra="forbid") strategy: str title: str summary: str rationale: str estimated_duration_seconds: int estimated_cost_level: str recommended: bool = False slots: list[RoleSlot] = Field(default_factory=list) quality_gate_ids: list[str] = Field(default_factory=list) def resolve_angles(requirement: ReportRequirementSnapshot, count: int) -> list[str]: excluded = {item.strip() for item in requirement.excluded_angles if item and item.strip()} source = [item for item in requirement.required_angles if item.strip() and item.strip() not in excluded] if not source: source = [item for item in DEFAULT_ANGLES if item not in excluded] if not source: source = ["综合趋势"] if count <= 1: return source[:1] picked = source[:count] return picked if picked else source[:1] def _research_demand(angle: str) -> RoleDemand: return RoleDemand( role_key="researcher", display_name="研究员", angle=angle, mission=f"检索并整理「{angle}」的事实、来源、日期和摘录,覆盖冲突与缺口", depends_on_roles=[], output_artifact_type="EvidenceBundle", suggested_tools=["web_search", "web_fetch", "knowledge_search"], ) class PlanController: """Deterministic strategy templates. A later planner model may only fill RoleDemand.""" def propose(self, requirement: ReportRequirementSnapshot, *, max_parallel_tasks: int, max_team_members: int) -> list[StrategyRoleSet]: topic = requirement.topic focused_angles = resolve_angles(requirement, 1) balanced_n = min(2, max(1, max_parallel_tasks)) parallel_n = min(3, max(1, max_parallel_tasks), max(1, max_team_members - 5)) balanced_angles = resolve_angles(requirement, balanced_n) parallel_angles = resolve_angles(requirement, parallel_n) return [ self._balanced(topic, balanced_angles), self._parallel(topic, parallel_angles), self._focused(topic, focused_angles), ] def _balanced(self, topic: str, angles: list[str]) -> StrategyRoleSet: slots: list[RoleSlot] = [] research_ids: list[str] = [] for index, angle in enumerate(angles): slot_id = f"research-{index}" research_ids.append(slot_id) slots.append( RoleSlot( slot_id=slot_id, demand=_research_demand(angle), depends_on=[], ) ) slots.append( RoleSlot( slot_id="verify", demand=RoleDemand( role_key="verifier", display_name="核验员", mission="交叉核验关键数字口径、时效与冲突来源", depends_on_roles=["researcher"], output_artifact_type="VerificationResult", ), depends_on=list(research_ids), ) ) slots.append( RoleSlot( slot_id="analyze", demand=RoleDemand( role_key="analyst", display_name="分析员", mission=f"综合各角度证据,形成关于「{topic}」的判断、假设和局限", depends_on_roles=["verifier"], output_artifact_type="AngleAnalysis", ), depends_on=["verify"], ) ) slots.append( RoleSlot( slot_id="write", demand=RoleDemand( role_key="writer", display_name="撰写员", mission="按模板撰写带引用的完整报告", depends_on_roles=["analyst"], output_artifact_type="ReportVersion", ), depends_on=["analyze"], ) ) slots.append( RoleSlot( slot_id="review", demand=RoleDemand( role_key="reviewer", display_name="审稿员", mission="独立审稿:结论与证据一致、章节完整", depends_on_roles=["writer"], output_artifact_type="ReviewDecision", ), depends_on=["write"], ) ) return StrategyRoleSet( strategy="balanced_review", title="均衡协作 · 研究核验写作审稿全链路", summary=f"{len(angles)} 条研究线 + 交叉核验 + 分析 + 写作 + 独立审稿,质量与成本均衡。", rationale="在覆盖主要角度的同时控制团队规模,适合大多数正式报告。", estimated_duration_seconds=1500, estimated_cost_level="medium", recommended=True, slots=slots, quality_gate_ids=["cite_required", "independent_review"], ) def _parallel(self, topic: str, angles: list[str]) -> StrategyRoleSet: slots: list[RoleSlot] = [] research_ids: list[str] = [] for index, angle in enumerate(angles): slot_id = f"research-{index}" research_ids.append(slot_id) slots.append(RoleSlot(slot_id=slot_id, demand=_research_demand(angle), depends_on=[])) mid = max(1, len(research_ids) // 2) first, second = research_ids[:mid], research_ids[mid:] or research_ids[:1] slots.append( RoleSlot( slot_id="verify-a", demand=RoleDemand( role_key="verifier", display_name="核验员", angle="主证据链", mission="核验前半研究线的口径、时效与冲突", depends_on_roles=["researcher"], output_artifact_type="VerificationResult", ), depends_on=list(first), ) ) slots.append( RoleSlot( slot_id="verify-b", demand=RoleDemand( role_key="verifier", display_name="核验员", angle="交叉证据链", mission="二次核验其余研究线与高风险结论", depends_on_roles=["researcher"], output_artifact_type="VerificationResult", ), depends_on=list(second), ) ) slots.append( RoleSlot( slot_id="analyze", demand=RoleDemand( role_key="analyst", display_name="分析员", mission=f"分角度深入分析「{topic}」,并给出 12–24 个月展望", depends_on_roles=["verifier"], output_artifact_type="AngleAnalysis", ), depends_on=["verify-a", "verify-b"], ) ) slots.append( RoleSlot( slot_id="write", demand=RoleDemand( role_key="writer", display_name="撰写员", mission="撰写完整报告", depends_on_roles=["analyst"], output_artifact_type="ReportVersion", ), depends_on=["analyze"], ) ) slots.append( RoleSlot( slot_id="review", demand=RoleDemand( role_key="reviewer", display_name="审稿员", mission="高风险场景独立审稿", depends_on_roles=["writer"], output_artifact_type="ReviewDecision", ), depends_on=["write"], ) ) return StrategyRoleSet( strategy="parallel_depth", title="深度并行 · 多角度双重核验", summary=f"{len(angles)} 条并行研究 + 双重核验 + 分析 + 写作 + 审稿,覆盖更广、成本更高。", rationale="面向高风险决策:角度覆盖与证据强度优先,接受更高成本与时长。", estimated_duration_seconds=2400, estimated_cost_level="high", slots=slots, quality_gate_ids=["cite_required", "dual_verification", "independent_review", "coverage_matrix"], ) def _focused(self, topic: str, angles: list[str]) -> StrategyRoleSet: angle = angles[0] slots = [ RoleSlot( slot_id="research-0", demand=RoleDemand( role_key="researcher", display_name="研究员", angle=angle, mission=f"按指定角度「{angle}」检索证据,范围收窄", depends_on_roles=[], output_artifact_type="EvidenceBundle", suggested_tools=["web_search", "web_fetch", "knowledge_search"], ), depends_on=[], ), RoleSlot( slot_id="verify", demand=RoleDemand( role_key="verifier", display_name="核验员", mission="核验关键事实与数字来源", depends_on_roles=["researcher"], output_artifact_type="VerificationResult", ), depends_on=["research-0"], ), RoleSlot( slot_id="analyze", demand=RoleDemand( role_key="analyst", display_name="分析员", mission=f"形成关于「{topic}」的聚焦结论", depends_on_roles=["verifier"], output_artifact_type="AngleAnalysis", ), depends_on=["verify"], ), RoleSlot( slot_id="write", demand=RoleDemand( role_key="writer", display_name="撰写员", mission="快速产出带引用的报告", depends_on_roles=["analyst"], output_artifact_type="ReportVersion", ), depends_on=["analyze"], ), RoleSlot( slot_id="review", demand=RoleDemand( role_key="reviewer", display_name="审稿员", mission="快速审稿,拦截高危问题", depends_on_roles=["writer"], output_artifact_type="ReviewDecision", ), depends_on=["write"], ), ] return StrategyRoleSet( strategy="focused_fast", title="快速聚焦 · 窄范围短链路", summary="单研究线 + 核验 + 分析 + 写作 + 审稿,保留引用核验与审稿,最快出稿。", rationale="适合时间紧、角度已知、需要可信结论而非全面覆盖的场景。", estimated_duration_seconds=800, estimated_cost_level="low", slots=slots, quality_gate_ids=["cite_required", "independent_review"], )