"""Agent council review and operating plan compiler.""" from __future__ import annotations from typing import Any from ...schemas.pmflow_ai_operating_runtime import ( PMFlowAIAgentCouncilReview, PMFlowAIAgentWorkProduct, PMFlowAIConflictLedgerEntry, PMFlowAIOperatingMission, PMFlowAIOperatingPlan, PMFlowAIProductObjectProjection, PMFlowAIProjectWorldStatePacket, clean_text, ) class PMFlowAIAgentCouncilService: """Review specialist work products before compiling the single plan. The council consumes structured work products and project world-state only. It does not re-route by user wording or fixed question categories. """ def review_and_compile( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], ) -> tuple[PMFlowAIAgentCouncilReview, PMFlowAIOperatingPlan]: critic = CrossAgentCritic() compiler = OperatingPlanCompiler() role_reviews = critic.review_roles(work_products) risk_vetoes = critic.collect_risk_vetoes(work_products) candidate_actions = compiler.collect_candidate_actions( project_world_state=project_world_state, work_products=work_products, ) conflict_ledger = critic.find_conflicts( role_reviews=role_reviews, candidate_actions=candidate_actions, risk_vetoes=risk_vetoes, project_world_state=project_world_state, ) priority_order = compiler.compile_priority_order( candidate_actions=candidate_actions, risk_vetoes=risk_vetoes, ) resolved_conflicts = [_resolved_conflict(item) for item in conflict_ledger] status = "passed" if any(item.resolution == "blocked" for item in conflict_ledger): status = "blocked" elif conflict_ledger: status = "passed_with_conflicts" council_review = PMFlowAIAgentCouncilReview( status=status, role_reviews=role_reviews, conflict_ledger=conflict_ledger, resolved_conflicts=resolved_conflicts, risk_vetoes=risk_vetoes, priority_order=priority_order, resolution_summary=_resolution_summary( status=status, priority_order=priority_order, conflict_ledger=conflict_ledger, risk_vetoes=risk_vetoes, ), ) return council_review, compiler.compile_plan( mission=mission, project_world_state=project_world_state, work_products=work_products, council_review=council_review, ) class CrossAgentCritic: """Detect disagreement, missing role output and risk vetoes.""" def review_roles(self, work_products: list[PMFlowAIAgentWorkProduct]) -> list[dict[str, Any]]: reviews: list[dict[str, Any]] = [] for product in work_products: verification = product.verification if isinstance(product.verification, dict) else {} findings = [clean_text(item, limit=180) for item in list(verification.get("findings") or [])[:8]] reviews.append( { "agent_id": product.agent_id, "product_type": product.product_type, "status": product.status, "verification_status": clean_text(verification.get("status") or product.status, limit=80), "findings": [item for item in findings if item], "claim_count": len(list(product.claims or [])), "recommendation_count": len(list(product.recommendations or [])), "prepared_action_count": len(list(product.prepared_actions or [])), "blocked_action_count": len(list(product.blocked_actions or [])), } ) return reviews def collect_risk_vetoes(self, work_products: list[PMFlowAIAgentWorkProduct]) -> list[dict[str, Any]]: vetoes: list[dict[str, Any]] = [] for product in work_products: for item in list(product.blocked_actions or [])[:12]: if not isinstance(item, dict): continue title = clean_text(item.get("title"), limit=240) if not title: continue vetoes.append( { "title": title, "reason": clean_text(item.get("reason") or "该动作需要先解除风险或确认边界。", limit=500), "source_agent": clean_text(item.get("source_agent") or product.agent_id, limit=80), "target_ref": clean_text(item.get("target_ref"), limit=160), "resolution": "blocked_until_confirmed_or_unblocked", "requires_user_confirm": bool(item.get("requires_user_confirm", True)), } ) return _dedupe_dicts(vetoes, key="title")[:12] def find_conflicts( self, *, role_reviews: list[dict[str, Any]], candidate_actions: list[dict[str, Any]], risk_vetoes: list[dict[str, Any]], project_world_state: PMFlowAIProjectWorldStatePacket, ) -> list[PMFlowAIConflictLedgerEntry]: conflicts: list[PMFlowAIConflictLedgerEntry] = [] for review in role_reviews: if review.get("status") == "blocked" or review.get("verification_status") == "blocked": conflicts.append( PMFlowAIConflictLedgerEntry( conflict_type="role_output_blocked", involved_agents=[clean_text(review.get("agent_id"), limit=80)], summary=f"{review.get('product_type')} 未通过角色产物检查。", resolution="blocked", resolution_reason="该角色产物不能作为最终推进动作的直接依据,只能进入证据边界。", severity="high", source_refs=[clean_text(review.get("agent_id"), limit=80)], ) ) veto_keys = {_action_key(item) for item in risk_vetoes if _action_key(item)} for action in candidate_actions: action_key = _action_key(action) if action_key and action_key in veto_keys: conflicts.append( PMFlowAIConflictLedgerEntry( conflict_type="risk_veto_conflict", involved_agents=_dedupe_texts( [ clean_text(action.get("source_agent"), limit=80), clean_text(_veto_for_key(risk_vetoes, action_key).get("source_agent"), limit=80), ] ), summary=f"建议动作“{action.get('title')}”被风险审查否决。", resolution="blocked", resolution_reason=clean_text( _veto_for_key(risk_vetoes, action_key).get("reason") or "风险审查要求先解除边界后再执行。", limit=800, ), severity="high", source_refs=[clean_text(action.get("source_agent"), limit=80), "risk_vetoes"], ) ) if len(candidate_actions) > 1: first_agents = _dedupe_texts([clean_text(item.get("source_agent"), limit=80) for item in candidate_actions[:6]]) conflicts.append( PMFlowAIConflictLedgerEntry( conflict_type="priority_order_competition", involved_agents=first_agents, summary="多个专业 agent 同时提出下一步,必须收敛为一个优先级顺序。", resolution="resolved", resolution_reason="按风险否决、准备度、项目世界状态来源和角色责任编译为单一 priority_order。", severity="medium", source_refs=first_agents, ) ) if project_world_state.remaining_gaps: conflicts.append( PMFlowAIConflictLedgerEntry( conflict_type="evidence_gap_boundary", involved_agents=["agent_council"], summary="项目世界状态仍有未覆盖 facet,完整经营判断需要保留证据边界。", resolution="accepted", resolution_reason="缺口进入 evidence_boundary,不阻断只读诊断和准备类动作。", severity="medium", source_refs=[project_world_state.world_state_id], ) ) return conflicts[:16] class OperatingPlanCompiler: """Compile one ordered operating plan from council-reviewed products.""" def collect_candidate_actions( self, *, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], ) -> list[dict[str, Any]]: actions: list[dict[str, Any]] = [] for product in work_products: for item in list(product.recommendations or [])[:8]: action = _normalize_action(item, source_agent=product.agent_id, action_kind="recommendation") if action: actions.append(action) for item in list(product.prepared_actions or [])[:8]: action = _normalize_action(item, source_agent=product.agent_id, action_kind="prepared_change") if action: actions.append(action) actions.extend(_world_state_actions(project_world_state)) return _dedupe_dicts(actions, key="title")[:24] def compile_priority_order( self, *, candidate_actions: list[dict[str, Any]], risk_vetoes: list[dict[str, Any]], ) -> list[dict[str, Any]]: veto_keys = {_action_key(item) for item in risk_vetoes if _action_key(item)} ranked: list[dict[str, Any]] = [] for index, action in enumerate(candidate_actions): key = _action_key(action) if key and key in veto_keys: continue ranked.append( { **action, "priority_score": _priority_score(action=action, original_index=index), "council_status": "selected", } ) ranked = sorted(ranked, key=lambda item: item.get("priority_score", 9999)) return [ {key: value for key, value in {**item, "priority_rank": index + 1}.items() if key != "priority_score"} for index, item in enumerate(ranked[:8]) ] def compile_plan( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], council_review: PMFlowAIAgentCouncilReview, ) -> PMFlowAIOperatingPlan: priority_order = list(council_review.priority_order or []) prepared_changes = _prepared_changes_from_priority(priority_order=priority_order, work_products=work_products) blocked_actions = _blocked_actions_from_review(council_review=council_review, work_products=work_products) evidence_boundary = _evidence_boundary( project_world_state=project_world_state, work_products=work_products, council_review=council_review, ) product_object_projections = ProductObjectProjectionCompiler().compile( mission=mission, project_world_state=project_world_state, work_products=work_products, priority_order=priority_order, evidence_boundary=evidence_boundary, ) return PMFlowAIOperatingPlan( strategic_read=_strategic_read_from_products( project_world_state=project_world_state, work_products=work_products, fallback=mission.desired_outcome, ), highest_leverage_move=_highest_leverage_move(priority_order=priority_order, fallback=mission.desired_outcome), next_cycle_plan=priority_order[:6], safe_suggestions=[item for item in priority_order[:6] if item.get("action_kind") != "prepared_change"], prepared_changes=prepared_changes[:8], requires_confirmation=[ { "title": "确认是否按 Agent Council 编译后的优先级继续准备项目动作。", "requires_user_confirm": True, "source": "agent_council", "priority_order_ref": council_review.review_id, } ], blocked_actions=blocked_actions[:12], evidence_boundary=evidence_boundary, council_review=council_review.model_dump(mode="json", exclude_none=True), conflict_ledger=[item.model_dump(mode="json", exclude_none=True) for item in council_review.conflict_ledger], resolved_conflicts=list(council_review.resolved_conflicts or []), risk_vetoes=list(council_review.risk_vetoes or []), priority_order=priority_order, product_object_projections=[item.model_dump(mode="json", exclude_none=True) for item in product_object_projections], ) class ProductObjectProjectionCompiler: """Compile specialist artifacts into PMFlow product object candidates.""" def compile( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], priority_order: list[dict[str, Any]], evidence_boundary: list[str], ) -> list[PMFlowAIProductObjectProjection]: research_product = _product_for_type(work_products, "ResearchNote") research_ready = _research_ready(research_product) preferred_type = _projection_type_for_deliverable(mission.deliverable_type) if mission.stage == "research" and not research_ready: preferred_type = "ResearchNote" primary_product = _primary_product_for_projection(work_products=work_products, object_type=preferred_type) source_agents = _dedupe_texts([item.agent_id for item in work_products if item.status in {"ready", "draft", "needs_followup"}]) evidence_refs = _dedupe_texts([ref for product in work_products for ref in list(product.evidence_refs or [])]) title = _projection_title(mission=mission, object_type=preferred_type) summary = _projection_summary(mission=mission, product=primary_product, priority_order=priority_order) fields = { "mission_id": mission.mission_id, "stage": mission.stage, "desired_business_outcome": mission.desired_business_outcome or mission.desired_outcome, "priority_order": priority_order[:6], "source_work_products": [ { "agent_id": product.agent_id, "product_type": product.product_type, "status": product.status, "summary": product.summary, } for product in work_products[:12] ], "structured_output": primary_product.structured_output if primary_product is not None else {}, "remaining_boundaries": evidence_boundary[:8], } status = "ready_for_review" if summary and source_agents and (preferred_type != "ResearchNote" or research_ready) else "draft" return [ PMFlowAIProductObjectProjection( object_type=preferred_type, title=title, summary=summary, source_agents=source_agents, target_refs=list(project_world_state.project_refs or mission.project_scope.object_refs or []), evidence_refs=evidence_refs[:16], fields=fields, action_ladder_level=mission.action_ladder_level, confirmation_required=True, status=status, blocked_boundaries=evidence_boundary[:8] if status != "ready_for_review" else [], next_cycle_policy=mission.next_cycle_policy, ) ] def _normalize_action(item: dict[str, Any], *, source_agent: str, action_kind: str) -> dict[str, Any]: if not isinstance(item, dict): return {} title = clean_text(item.get("title") or item.get("display_label"), limit=240) if not title: return {} return { "title": title, "reason": clean_text(item.get("reason") or item.get("summary") or "", limit=500), "action_type": clean_text(item.get("action_type") or action_kind, limit=100), "action_kind": action_kind, "source_agent": clean_text(item.get("source_agent") or source_agent, limit=80), "target_ref": clean_text(item.get("target_ref"), limit=160), "requires_user_confirm": bool(item.get("requires_user_confirm", True)), } def _world_state_actions(project_world_state: PMFlowAIProjectWorldStatePacket) -> list[dict[str, Any]]: actions: list[dict[str, Any]] = [] for packet in list(project_world_state.project_operating_evidence_packets or [])[:8]: if not isinstance(packet, dict): continue for item in list(packet.get("safe_next_actions") or [])[:8]: if not isinstance(item, dict): continue title = clean_text(item.get("display_label") or item.get("title"), limit=240) if title: actions.append( { "title": title, "reason": clean_text(item.get("reason") or "来自项目世界状态的可推进动作。", limit=500), "action_type": "prepare_from_project_world_state", "action_kind": "world_state_action", "source_agent": "project_world_state", "target_ref": clean_text(item.get("target_ref") or packet.get("project_ref"), limit=160), "requires_user_confirm": True, } ) return _dedupe_dicts(actions, key="title")[:12] def _priority_score(*, action: dict[str, Any], original_index: int) -> int: role_priority = { "project_world_state": 10, "pm_breakdown_agent": 20, "product_strategy_agent": 30, "mvp_scope_agent": 40, "idea_incubation_agent": 45, "market_research_agent": 50, "competitor_agent": 55, "tech_architect_agent": 60, "qa_risk_agent": 70, "growth_agent": 80, "monetization_agent": 85, "learning_agent": 90, } kind_priority = { "world_state_action": 0, "prepared_change": 5, "recommendation": 10, } return ( int(kind_priority.get(clean_text(action.get("action_kind"), limit=80), 20)) + int(role_priority.get(clean_text(action.get("source_agent"), limit=80), 90)) + original_index ) def _prepared_changes_from_priority( *, priority_order: list[dict[str, Any]], work_products: list[PMFlowAIAgentWorkProduct], ) -> list[dict[str, Any]]: prepared = [] priority_titles = {clean_text(item.get("title"), limit=240).lower() for item in priority_order} for product in work_products: for item in list(product.prepared_actions or [])[:8]: title = clean_text(item.get("title"), limit=240) if title and title.lower() in priority_titles: prepared.append({**item, "source_agent": clean_text(item.get("source_agent") or product.agent_id, limit=80)}) for item in priority_order[:8]: if not isinstance(item, dict): continue if item.get("action_kind") not in {"world_state_action", "prepared_change"}: continue title = clean_text(item.get("title"), limit=240) if not title: continue prepared.append( { "title": title, "action_type": clean_text(item.get("action_type") or "prepare_task_change", limit=100), "target_ref": clean_text(item.get("target_ref"), limit=160), "requires_user_confirm": True, "source_agent": clean_text(item.get("source_agent") or "agent_council", limit=80), } ) return _dedupe_dicts(prepared, key="title") def _blocked_actions_from_review( *, council_review: PMFlowAIAgentCouncilReview, work_products: list[PMFlowAIAgentWorkProduct], ) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [ { **item, "source": "agent_council_risk_veto", "council_status": "blocked", } for item in list(council_review.risk_vetoes or []) if isinstance(item, dict) ] for product in work_products: for item in list(product.blocked_actions or [])[:8]: if isinstance(item, dict): rows.append({**item, "source_agent": clean_text(item.get("source_agent") or product.agent_id, limit=80)}) return _dedupe_dicts(rows, key="title") def _evidence_boundary( *, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], council_review: PMFlowAIAgentCouncilReview, ) -> list[str]: boundaries: list[str] = [] for conflict in list(council_review.conflict_ledger or [])[:8]: if conflict.resolution == "blocked": boundaries.append(f"会审阻断:{conflict.summary} {conflict.resolution_reason}") else: boundaries.append(f"会审边界:{conflict.summary} {conflict.resolution_reason}") for product in work_products: verification = product.verification if isinstance(product.verification, dict) else {} for item in list(verification.get("findings") or [])[:4]: text = clean_text(item, limit=180) if text: boundaries.append(f"{product.product_type} 验证边界:{text}") if project_world_state.status in {"missing_scope", "unavailable"}: boundaries.append("未读取到可安全绑定的项目世界状态,本轮不能把空间概览当作项目推进依据。") for gap in list(project_world_state.remaining_gaps or [])[:8]: boundaries.append(f"项目世界状态缺口:{gap}") if not boundaries: boundaries.append(f"已基于 Agent Council 会审和项目世界状态 {project_world_state.world_state_id} 编译计划。") return _dedupe_texts(boundaries)[:12] def _strategic_read_from_products( *, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], fallback: str, ) -> str: strategist = next((item for item in work_products if item.agent_id == "product_strategy_agent"), None) output = strategist.structured_output if strategist is not None and isinstance(strategist.structured_output, dict) else {} current_read = clean_text(output.get("current_read"), limit=900) highest = clean_text(output.get("highest_leverage_move"), limit=300) if current_read or highest: return f"{project_world_state.primary_project_ref or '当前项目'}:{current_read}{';最高杠杆动作:' + highest if highest else ''}"[:1200] summaries = ";".join(clean_text(product.summary, limit=300) for product in work_products[:3] if product.summary) return summaries[:1200] or clean_text(fallback, limit=1200) def _highest_leverage_move(*, priority_order: list[dict[str, Any]], fallback: str) -> str: first = next((clean_text(item.get("title"), limit=1200) for item in priority_order if isinstance(item, dict)), "") return first or clean_text(fallback, limit=1200) def _projection_type_for_deliverable(deliverable_type: str) -> str: mapping = { "idea_seed": "IdeaSeed", "research_note": "ResearchNote", "opportunity_brief": "OpportunityBrief", "product_hypothesis": "ProductHypothesis", "mvp_option": "MVPOption", "requirement_draft": "RequirementDraft", "changeset_draft": "ChangeSetDraft", "task_draft": "TaskDraft", "execution_package": "ExecutionPackage", "qa_plan": "QAPlan", "release_signal": "ReleaseSignal", "growth_experiment": "GrowthExperiment", "revenue_signal": "RevenueSignal", "operating_decision": "OpportunityBrief", } return mapping.get(clean_text(deliverable_type, limit=80), "OpportunityBrief") def _primary_product_for_projection(*, work_products: list[PMFlowAIAgentWorkProduct], object_type: str) -> PMFlowAIAgentWorkProduct | None: return next((item for item in work_products if item.product_type == object_type), None) or next( (item for item in work_products if item.agent_id == "product_strategy_agent"), None, ) or (work_products[0] if work_products else None) def _product_for_type(work_products: list[PMFlowAIAgentWorkProduct], product_type: str) -> PMFlowAIAgentWorkProduct | None: return next((item for item in work_products if item.product_type == product_type), None) def _research_ready(product: PMFlowAIAgentWorkProduct | None) -> bool: if product is None or product.status != "ready": return False output = product.structured_output if isinstance(product.structured_output, dict) else {} state = output.get("evidence_state") if isinstance(output.get("evidence_state"), dict) else {} return bool(state.get("ready_for_opportunity_brief")) def _projection_title(*, mission: PMFlowAIOperatingMission, object_type: str) -> str: goal = clean_text(mission.original_user_input or mission.mission_goal, limit=120) if object_type == "OpportunityBrief": return f"机会简报:{goal}"[:240] if goal else "机会简报" if object_type == "ResearchNote": return f"研究记录:{goal}"[:240] if goal else "研究记录" return f"{object_type}:{goal}"[:240] if goal else object_type def _projection_summary( *, mission: PMFlowAIOperatingMission, product: PMFlowAIAgentWorkProduct | None, priority_order: list[dict[str, Any]], ) -> str: product_summary = clean_text(product.summary if product is not None else "", limit=700) if product is not None and product.product_type == "ResearchNote": output = product.structured_output if isinstance(product.structured_output, dict) else {} gaps = [clean_text(item, limit=120) for item in list(output.get("research_gaps") or [])[:3] if clean_text(item, limit=120)] plan_count = len(list(output.get("source_plan") or [])) if product.status != "ready": return ( product_summary or f"先进入研究补证:已生成 {plan_count} 条来源补齐路径" + (f",还缺:{';'.join(gaps)}" if gaps else "。") )[:1200] lead = clean_text(priority_order[0].get("title"), limit=240) if priority_order else "" outcome = clean_text(mission.desired_business_outcome or mission.desired_outcome, limit=500) parts = [item for item in (product_summary, f"第一优先级:{lead}" if lead else "", outcome) if item] return ";".join(parts)[:1200] def _resolved_conflict(conflict: PMFlowAIConflictLedgerEntry) -> dict[str, Any]: return { "conflict_id": conflict.conflict_id, "conflict_type": conflict.conflict_type, "resolution": conflict.resolution, "resolution_reason": conflict.resolution_reason, "severity": conflict.severity, } def _resolution_summary( *, status: str, priority_order: list[dict[str, Any]], conflict_ledger: list[PMFlowAIConflictLedgerEntry], risk_vetoes: list[dict[str, Any]], ) -> str: lead = clean_text(priority_order[0].get("title"), limit=240) if priority_order else "" conflict_count = len(conflict_ledger) veto_count = len(risk_vetoes) if status == "blocked": return f"会审发现 {veto_count} 个风险否决和 {conflict_count} 个冲突,已阻断不可直接执行动作;可推进项按单一优先级保留。" if lead: return f"会审完成,已把 {conflict_count} 个冲突收敛为单一优先级;第一优先级是“{lead}”。" return "会审完成,但当前没有足够可推进动作,只能保留证据边界和确认项。" def _veto_for_key(vetoes: list[dict[str, Any]], key: str) -> dict[str, Any]: return next((item for item in vetoes if _action_key(item) == key), {}) def _action_key(item: dict[str, Any]) -> str: return clean_text(item.get("title") or item.get("display_label"), limit=240).lower() def _dedupe_dicts(rows: list[dict[str, Any]], *, key: str) -> list[dict[str, Any]]: seen: set[str] = set() out: list[dict[str, Any]] = [] for item in rows: if not isinstance(item, dict): continue value = clean_text(item.get(key), limit=240) normalized = value.lower() if not value or normalized in seen: continue seen.add(normalized) out.append(item) return out def _dedupe_texts(rows: list[str]) -> list[str]: out: list[str] = [] for item in rows: text = clean_text(item, limit=180) if text and text not in out: out.append(text) return out __all__ = [ "CrossAgentCritic", "OperatingPlanCompiler", "ProductObjectProjectionCompiler", "PMFlowAIAgentCouncilService", ]