PMFlow/backend/app/services/pmflow_ai_operating_runtime/council.py

659 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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",
]