841 lines
39 KiB
Python
841 lines
39 KiB
Python
"""Specialist work-product layer for the durable PMFlow Agent OS runtime."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from ...schemas.pmflow_agent_runtime import (
|
||
PMFlowAgentArtifactPacket,
|
||
PMFlowAgentStepPacket,
|
||
PMFlowAgentVerificationPacket,
|
||
new_pmflow_agent_id,
|
||
)
|
||
from ...schemas.pmflow_ai_operating_runtime import (
|
||
PMFlowAIAgentAssignment,
|
||
PMFlowAIAgentCouncilReview,
|
||
PMFlowAIAgentWorkProduct,
|
||
PMFlowAIAutonomyDecision,
|
||
PMFlowAIOperatingCycleState,
|
||
PMFlowAIOperatingMission,
|
||
PMFlowAIOperatingPlan,
|
||
PMFlowAIOperatingScope,
|
||
PMFlowAIProjectWorldStatePacket,
|
||
)
|
||
from ...schemas.rag import RAGAskRequest
|
||
from ..pmflow_ai_operating_runtime.agent_registry import PMFlowAIOperatingAgentRegistry
|
||
from ..pmflow_ai_operating_runtime.autonomy import PMFlowAIAutonomyPolicyService
|
||
from ..pmflow_ai_operating_runtime.council import PMFlowAIAgentCouncilService
|
||
from ..pmflow_ai_operating_runtime.cycle import OperatingCycleService
|
||
from ..pmflow_ai_operating_runtime.project_world_state_service import PMFlowAIProjectWorldStateService
|
||
from ..pmflow_ai_operating_runtime.specialist_agents import PMFlowAISpecialistAgentService
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PMFlowAgentSpecialistResult:
|
||
mission: PMFlowAIOperatingMission | None = None
|
||
project_world_state: PMFlowAIProjectWorldStatePacket | None = None
|
||
assignments: list[PMFlowAIAgentAssignment] = field(default_factory=list)
|
||
work_products: list[PMFlowAIAgentWorkProduct] = field(default_factory=list)
|
||
council_review: PMFlowAIAgentCouncilReview | None = None
|
||
autonomy_gate: PMFlowAIAutonomyDecision | None = None
|
||
operating_cycle: PMFlowAIOperatingCycleState | None = None
|
||
operating_plan: PMFlowAIOperatingPlan | None = None
|
||
command_payload: dict[str, Any] = field(default_factory=dict)
|
||
steps: list[PMFlowAgentStepPacket] = field(default_factory=list)
|
||
artifacts: list[PMFlowAgentArtifactPacket] = field(default_factory=list)
|
||
verifications: list[PMFlowAgentVerificationPacket] = field(default_factory=list)
|
||
answer: str | None = None
|
||
skipped_reason: str | None = None
|
||
|
||
def customer_safe_payload(self) -> dict[str, Any]:
|
||
return {
|
||
"packet_version": "pmflow_agent_specialists.v1",
|
||
"visibility": "customer",
|
||
"skipped_reason": self.skipped_reason,
|
||
"mission": _dump_model(self.mission),
|
||
"project_world_state": _dump_model(self.project_world_state),
|
||
"assignments": [_dump_model(item) for item in self.assignments],
|
||
"work_products": [_dump_model(item) for item in self.work_products],
|
||
"agent_council_review": _dump_model(self.council_review),
|
||
"autonomy_gate": _dump_model(self.autonomy_gate),
|
||
"operating_cycle": _dump_model(self.operating_cycle),
|
||
"consumed_learning_ledger": _dump_model(getattr(self.operating_cycle, "consumed_learning_ledger", None)),
|
||
"outcome_observations": [
|
||
_dump_model(item)
|
||
for item in list(getattr(self.operating_cycle, "outcome_observations", []) or [])
|
||
],
|
||
"learning_ledger": _dump_model(getattr(self.operating_cycle, "learning_ledger", None)),
|
||
"next_cycle_triggers": list(getattr(self.operating_cycle, "next_cycle_triggers", []) or []),
|
||
"operating_plan": _dump_model(self.operating_plan),
|
||
"command_payload": dict(self.command_payload or {}),
|
||
"steps": [item.model_dump(mode="json", exclude_none=True) for item in self.steps],
|
||
"artifacts": [item.model_dump(mode="json", exclude_none=True) for item in self.artifacts],
|
||
"verifications": [item.model_dump(mode="json", exclude_none=True) for item in self.verifications],
|
||
}
|
||
|
||
|
||
class PMFlowAgentSpecialistRuntimeService:
|
||
"""Run role-specific agents over a strong project anchor.
|
||
|
||
This service is not a question classifier. It only starts when the durable
|
||
Agent OS already has a structured project anchor, then consumes one shared
|
||
project world-state packet to produce role work products.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
db: Any,
|
||
tenant_id: int,
|
||
agent_registry: Any | None = None,
|
||
project_world_state_service: Any | None = None,
|
||
specialist_agent_service: Any | None = None,
|
||
council_service: Any | None = None,
|
||
autonomy_policy_service: Any | None = None,
|
||
operating_cycle_service: Any | None = None,
|
||
) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id or 1)
|
||
self.agent_registry = agent_registry or PMFlowAIOperatingAgentRegistry()
|
||
self.project_world_state_service = project_world_state_service or PMFlowAIProjectWorldStateService(
|
||
db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
self.specialist_agent_service = specialist_agent_service or PMFlowAISpecialistAgentService()
|
||
self.council_service = council_service or PMFlowAIAgentCouncilService()
|
||
self.autonomy_policy_service = autonomy_policy_service or PMFlowAIAutonomyPolicyService()
|
||
self.operating_cycle_service = operating_cycle_service or OperatingCycleService()
|
||
|
||
async def run(
|
||
self,
|
||
*,
|
||
request: RAGAskRequest,
|
||
current_user: Any,
|
||
conversation_context: dict[str, Any] | None,
|
||
tenant_project_context: dict[str, Any] | None,
|
||
) -> PMFlowAgentSpecialistResult:
|
||
project_ref = _strong_project_ref(
|
||
request=request,
|
||
conversation_context=conversation_context,
|
||
tenant_project_context=tenant_project_context,
|
||
)
|
||
if not project_ref:
|
||
return PMFlowAgentSpecialistResult(skipped_reason="本轮没有强项目锚点,未运行项目专业 agent。")
|
||
|
||
mission = _build_mission(
|
||
request=request,
|
||
current_user=current_user,
|
||
conversation_context=conversation_context,
|
||
project_ref=project_ref,
|
||
)
|
||
project_world_state = await self.project_world_state_service.build_packet(
|
||
mission=mission,
|
||
current_user=current_user,
|
||
tenant_project_context=tenant_project_context,
|
||
)
|
||
assignments = self.agent_registry.assignments_for_mission(mission)
|
||
work_products = self.specialist_agent_service.produce_work_products(
|
||
mission=mission,
|
||
assignments=assignments,
|
||
project_world_state=project_world_state,
|
||
)
|
||
council_review, operating_plan = self.council_service.review_and_compile(
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
work_products=work_products,
|
||
)
|
||
autonomy_gate, operating_plan = self.autonomy_policy_service.apply(
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
work_products=work_products,
|
||
council_review=council_review,
|
||
operating_plan=operating_plan,
|
||
)
|
||
operating_cycle = self.operating_cycle_service.build_cycle_state(
|
||
request=request,
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
council_review=council_review,
|
||
autonomy_gate=autonomy_gate,
|
||
operating_plan=operating_plan,
|
||
conversation_context=conversation_context,
|
||
)
|
||
operating_plan = _attach_cycle_to_plan(
|
||
operating_plan=operating_plan,
|
||
operating_cycle=operating_cycle,
|
||
)
|
||
command_payload = _build_command_payload(
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
assignments=assignments,
|
||
work_products=work_products,
|
||
council_review=council_review,
|
||
autonomy_gate=autonomy_gate,
|
||
operating_cycle=operating_cycle,
|
||
operating_plan=operating_plan,
|
||
)
|
||
result = PMFlowAgentSpecialistResult(
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
assignments=assignments,
|
||
work_products=work_products,
|
||
council_review=council_review,
|
||
autonomy_gate=autonomy_gate,
|
||
operating_cycle=operating_cycle,
|
||
operating_plan=operating_plan,
|
||
command_payload=command_payload,
|
||
)
|
||
result.steps = _specialist_steps(result)
|
||
result.artifacts = _specialist_artifacts(result)
|
||
result.verifications = _specialist_verifications(result)
|
||
result.answer = _compose_specialist_answer(
|
||
mission=mission,
|
||
project_world_state=project_world_state,
|
||
work_products=work_products,
|
||
council_review=council_review,
|
||
autonomy_gate=autonomy_gate,
|
||
operating_cycle=operating_cycle,
|
||
operating_plan=operating_plan,
|
||
)
|
||
return result
|
||
|
||
|
||
def _build_mission(
|
||
*,
|
||
request: RAGAskRequest,
|
||
current_user: Any,
|
||
conversation_context: dict[str, Any] | None,
|
||
project_ref: str,
|
||
) -> PMFlowAIOperatingMission:
|
||
question = _text(getattr(request, "question", ""), 1600)
|
||
project_id = _project_id_from_ref(project_ref)
|
||
return PMFlowAIOperatingMission(
|
||
mission_goal=f"围绕 {project_ref} 推进用户本轮目标:{question or '继续推进当前项目'}",
|
||
desired_outcome="形成面向当前项目对象的经营判断、专业 agent 产物、可确认动作和下一步推进计划。",
|
||
original_user_input=question,
|
||
project_scope=PMFlowAIOperatingScope(
|
||
tenant_id=int(getattr(current_user, "tenant_id", 0) or 0) or None,
|
||
user_id=int(getattr(current_user, "id", 0) or 0) or None,
|
||
session_id=_safe_int(getattr(request, "session_id", None)) or _safe_int((conversation_context or {}).get("session_id")),
|
||
object_refs=[project_ref],
|
||
current_project_id=project_id,
|
||
surface="pmflow_ai",
|
||
),
|
||
success_criteria=[
|
||
"专业 agent 产物必须来自同一份项目世界状态。",
|
||
"每个角色产物必须有结构化输出、依据引用和角色检查结果。",
|
||
"用户可见回答必须从角色产物和会审后的推进顺序编译。",
|
||
],
|
||
autonomy_level="prepare_changes",
|
||
decision_boundary=[
|
||
"Project Memory 写入、ChangeSet commit、任务变更、Execution Bridge handoff、发布和外部动作必须经过用户确认。",
|
||
],
|
||
)
|
||
|
||
|
||
def _specialist_steps(result: PMFlowAgentSpecialistResult) -> list[PMFlowAgentStepPacket]:
|
||
world_state = result.project_world_state
|
||
world_status = str(getattr(world_state, "status", "") or "unknown")
|
||
council_status = str(getattr(result.council_review, "status", "") or "unknown")
|
||
autonomy_status = str(getattr(result.autonomy_gate, "status", "") or "unknown")
|
||
cycle_status = "completed" if result.operating_cycle is not None else "blocked"
|
||
return [
|
||
PMFlowAgentStepPacket(
|
||
step_id=new_pmflow_agent_id("spec"),
|
||
kind="specialist_assignment",
|
||
status="completed",
|
||
label="组织专业 Agent:分派项目经营角色",
|
||
summary=f"已分派 {len(result.assignments)} 个角色,共同消费同一份项目世界状态。",
|
||
),
|
||
PMFlowAgentStepPacket(
|
||
step_id=new_pmflow_agent_id("spec"),
|
||
kind="specialist_work_products",
|
||
status="completed" if world_status in {"ready", "partial"} else "blocked",
|
||
label="生成专业产物:策略、用户验证、产品、技术、执行和风险",
|
||
summary=f"已生成 {len(result.work_products)} 个结构化工作产物,项目状态为 {world_status}。",
|
||
),
|
||
PMFlowAgentStepPacket(
|
||
step_id=new_pmflow_agent_id("spec"),
|
||
kind="specialist_council_compile",
|
||
status="completed" if council_status != "blocked" else "blocked",
|
||
label="会审产物:收敛优先级和边界",
|
||
summary=_text(getattr(result.council_review, "resolution_summary", "") or "已把角色产物收敛成单一推进顺序。", 600),
|
||
),
|
||
PMFlowAgentStepPacket(
|
||
step_id=new_pmflow_agent_id("spec"),
|
||
kind="autonomy_gate_prepared_actions",
|
||
status="completed" if autonomy_status != "blocked" else "blocked",
|
||
label="确认门禁:准备动作包并阻断直接写入/执行",
|
||
summary=_autonomy_gate_summary(result.autonomy_gate),
|
||
),
|
||
PMFlowAgentStepPacket(
|
||
step_id=new_pmflow_agent_id("spec"),
|
||
kind="operating_cycle_learning_loop",
|
||
status=cycle_status,
|
||
label="学习闭环:记录结果和下一轮触发",
|
||
summary=_operating_cycle_summary(result.operating_cycle),
|
||
),
|
||
]
|
||
|
||
|
||
def _specialist_artifacts(result: PMFlowAgentSpecialistResult) -> list[PMFlowAgentArtifactPacket]:
|
||
artifacts: list[PMFlowAgentArtifactPacket] = []
|
||
if result.mission is not None:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="operating_mission",
|
||
status="created",
|
||
title="本轮项目经营目标",
|
||
summary=result.mission.mission_goal,
|
||
payload=_dump_model(result.mission),
|
||
)
|
||
)
|
||
if result.project_world_state is not None:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="project_world_state",
|
||
status=result.project_world_state.status,
|
||
title="项目世界状态",
|
||
summary=_project_world_summary(result.project_world_state),
|
||
payload=_dump_model(result.project_world_state),
|
||
)
|
||
)
|
||
for product in result.work_products[:8]:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="specialist_work_product",
|
||
status=product.status,
|
||
title=_work_product_title(product),
|
||
summary=product.summary,
|
||
payload=_dump_model(product),
|
||
)
|
||
)
|
||
if result.council_review is not None:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="agent_council_review",
|
||
status=result.council_review.status,
|
||
title="专业 Agent 会审",
|
||
summary=result.council_review.resolution_summary,
|
||
payload=_dump_model(result.council_review),
|
||
)
|
||
)
|
||
if result.autonomy_gate is not None:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="autonomy_gate",
|
||
status=result.autonomy_gate.status,
|
||
title="确认门禁",
|
||
summary=_autonomy_gate_summary(result.autonomy_gate),
|
||
payload=_dump_model(result.autonomy_gate),
|
||
)
|
||
)
|
||
for packet in list(result.autonomy_gate.prepared_action_packets or [])[:8]:
|
||
if isinstance(packet, dict):
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="prepared_action_packet",
|
||
status=str(packet.get("status") or "prepared_waiting_confirmation")[:32],
|
||
title=_text(packet.get("title"), 240) or "待确认动作包",
|
||
summary=_prepared_action_summary(packet),
|
||
payload=packet,
|
||
)
|
||
)
|
||
for request in list(result.autonomy_gate.confirmation_requests or [])[:8]:
|
||
if isinstance(request, dict):
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="confirmation_request",
|
||
status=str(request.get("status") or "waiting_for_user_confirmation")[:32],
|
||
title=_text(request.get("title"), 240) or "确认请求",
|
||
summary=_text(request.get("confirmation_reason"), 1000),
|
||
payload=request,
|
||
)
|
||
)
|
||
for preparation in list(result.autonomy_gate.execution_preparations or [])[:4]:
|
||
if isinstance(preparation, dict):
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="execution_preparation",
|
||
status=str(preparation.get("readiness") or "prepared_waiting_confirmation")[:32],
|
||
title="执行交接准备包",
|
||
summary="已准备执行交接材料,但未发起 handoff。",
|
||
payload=preparation,
|
||
)
|
||
)
|
||
if result.operating_cycle is not None:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="operating_cycle",
|
||
status="created",
|
||
title="持续推进闭环",
|
||
summary=_operating_cycle_summary(result.operating_cycle),
|
||
payload=_dump_model(result.operating_cycle),
|
||
)
|
||
)
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="learning_ledger",
|
||
status="updated",
|
||
title="项目学习账本",
|
||
summary=_learning_ledger_summary(result.operating_cycle),
|
||
payload=_dump_model(result.operating_cycle.learning_ledger),
|
||
)
|
||
)
|
||
if result.command_payload:
|
||
artifacts.append(
|
||
PMFlowAgentArtifactPacket(
|
||
kind="agent_command_payload",
|
||
status="created",
|
||
title="PMFlow AI 指挥产物",
|
||
summary=_text(result.command_payload.get("strategic_read"), 1000),
|
||
payload=result.command_payload,
|
||
)
|
||
)
|
||
return artifacts[:24]
|
||
|
||
|
||
def _specialist_verifications(result: PMFlowAgentSpecialistResult) -> list[PMFlowAgentVerificationPacket]:
|
||
verifications: list[PMFlowAgentVerificationPacket] = []
|
||
for product in result.work_products[:12]:
|
||
verification = product.verification if isinstance(product.verification, dict) else {}
|
||
findings = [{"type": "role_contract", "summary": _customer_gap_text(item)} for item in list(verification.get("findings") or [])[:8]]
|
||
verifications.append(
|
||
PMFlowAgentVerificationPacket(
|
||
target_ref=f"specialist_work_product:{product.agent_id}",
|
||
result=_text(verification.get("status") or product.status or "unknown", 32),
|
||
retry_would_change_state=False,
|
||
findings=findings,
|
||
)
|
||
)
|
||
if result.council_review is not None:
|
||
verifications.append(
|
||
PMFlowAgentVerificationPacket(
|
||
target_ref="agent_council_review",
|
||
result=result.council_review.status,
|
||
retry_would_change_state=False,
|
||
findings=[
|
||
{"type": "council_boundary", "summary": _customer_gap_text(item.summary)}
|
||
for item in list(result.council_review.conflict_ledger or [])[:8]
|
||
],
|
||
)
|
||
)
|
||
if result.autonomy_gate is not None:
|
||
findings = _autonomy_verification_findings(result.autonomy_gate)
|
||
verifications.append(
|
||
PMFlowAgentVerificationPacket(
|
||
target_ref="autonomy_gate",
|
||
result="passed" if not findings else "blocked",
|
||
retry_would_change_state=False,
|
||
findings=findings,
|
||
)
|
||
)
|
||
if result.operating_cycle is not None:
|
||
findings = _learning_loop_verification_findings(result.operating_cycle)
|
||
verifications.append(
|
||
PMFlowAgentVerificationPacket(
|
||
target_ref="operating_cycle_learning_loop",
|
||
result="passed" if not findings else "blocked",
|
||
retry_would_change_state=False,
|
||
findings=findings,
|
||
)
|
||
)
|
||
return verifications[:16]
|
||
|
||
|
||
def _build_command_payload(
|
||
*,
|
||
mission: PMFlowAIOperatingMission,
|
||
project_world_state: PMFlowAIProjectWorldStatePacket,
|
||
assignments: list[PMFlowAIAgentAssignment],
|
||
work_products: list[PMFlowAIAgentWorkProduct],
|
||
council_review: PMFlowAIAgentCouncilReview,
|
||
autonomy_gate: PMFlowAIAutonomyDecision,
|
||
operating_cycle: PMFlowAIOperatingCycleState,
|
||
operating_plan: PMFlowAIOperatingPlan,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"packet_version": "pmflow_agent_os_command_payload.v1",
|
||
"visibility": "customer",
|
||
"mission": {
|
||
"mission_id": mission.mission_id,
|
||
"goal": mission.mission_goal,
|
||
"autonomy_level": mission.autonomy_level,
|
||
},
|
||
"project_world_state": _project_world_command_summary(project_world_state),
|
||
"specialist_agents": [
|
||
{
|
||
"agent_id": assignment.agent_id,
|
||
"work_product": assignment.expected_work_product,
|
||
"status": _product_for_agent(work_products, assignment.agent_id).status
|
||
if _product_for_agent(work_products, assignment.agent_id) is not None
|
||
else "blocked",
|
||
"summary": _product_for_agent(work_products, assignment.agent_id).summary
|
||
if _product_for_agent(work_products, assignment.agent_id) is not None
|
||
else "",
|
||
}
|
||
for assignment in assignments
|
||
],
|
||
"priority_order": list(operating_plan.priority_order or []),
|
||
"safe_suggestions": list(operating_plan.safe_suggestions or []),
|
||
"prepared_changes": list(operating_plan.prepared_changes or []),
|
||
"requires_confirmation": list(operating_plan.requires_confirmation or []),
|
||
"prepared_action_packets": list(operating_plan.prepared_action_packets or []),
|
||
"confirmation_requests": list(operating_plan.confirmation_requests or []),
|
||
"execution_preparations": list(operating_plan.execution_preparations or []),
|
||
"blocked_actions": list(operating_plan.blocked_actions or []),
|
||
"evidence_boundary": [_customer_gap_text(item) for item in list(operating_plan.evidence_boundary or [])[:12]],
|
||
"strategic_read": operating_plan.strategic_read,
|
||
"highest_leverage_move": operating_plan.highest_leverage_move,
|
||
"agent_council_review": _dump_model(council_review),
|
||
"autonomy_gate": _dump_model(autonomy_gate),
|
||
"operating_cycle": _dump_model(operating_cycle),
|
||
"consumed_learning_ledger": _dump_model(operating_cycle.consumed_learning_ledger),
|
||
"outcome_observations": [_dump_model(item) for item in list(operating_cycle.outcome_observations or [])],
|
||
"learning_ledger": _dump_model(operating_cycle.learning_ledger),
|
||
"learning_updates": list(operating_plan.learning_updates or []),
|
||
"next_cycle": {
|
||
"status": "ready_for_next_operating_step",
|
||
"summary": _operating_cycle_summary(operating_cycle),
|
||
"triggers": list(operating_plan.next_cycle_triggers or []),
|
||
},
|
||
"prohibited_side_effects": list(autonomy_gate.prohibited_side_effects or []),
|
||
"work_product_refs": [product.work_product_id for product in work_products],
|
||
}
|
||
|
||
|
||
def _compose_specialist_answer(
|
||
*,
|
||
mission: PMFlowAIOperatingMission,
|
||
project_world_state: PMFlowAIProjectWorldStatePacket,
|
||
work_products: list[PMFlowAIAgentWorkProduct],
|
||
council_review: PMFlowAIAgentCouncilReview,
|
||
autonomy_gate: PMFlowAIAutonomyDecision,
|
||
operating_cycle: PMFlowAIOperatingCycleState,
|
||
operating_plan: PMFlowAIOperatingPlan,
|
||
) -> str:
|
||
project_label = _project_label(project_world_state)
|
||
if project_world_state.status not in {"ready", "partial"}:
|
||
gaps = "、".join(_customer_gap_text(item) for item in list(project_world_state.remaining_gaps or [])[:3])
|
||
boundary = gaps or "项目运行信息还没有读完整"
|
||
return (
|
||
f"我这轮已经确认对象是 {project_label},但还不能给完整推进方案:{boundary}。"
|
||
"我会保留本轮目标和对象锚点,下一步先补齐项目状态读取,再让策略、产品、执行和风险角色会审。"
|
||
)
|
||
|
||
lead = _text(operating_plan.highest_leverage_move or mission.desired_outcome, 500)
|
||
first_priority = _first_action(operating_plan.priority_order)
|
||
first_block = _first_action(operating_plan.blocked_actions)
|
||
strategist = _product_for_agent(work_products, "project_strategist")
|
||
product = _product_for_agent(work_products, "product_manager")
|
||
execution = _product_for_agent(work_products, "execution_planner")
|
||
strategy_read = _text((strategist.summary if strategist else "") or operating_plan.strategic_read, 500)
|
||
product_read = _text(product.summary if product else "", 360)
|
||
execution_read = _text(execution.summary if execution else "", 360)
|
||
|
||
first_sentence = f"{project_label} 现在最应该先推进:{lead}。"
|
||
if first_priority and first_priority != lead:
|
||
first_sentence += f"我把几个专业角色的建议收敛后,第一步落到“{first_priority}”。"
|
||
else:
|
||
first_sentence += "这个结论是策略、产品、执行、技术和风险角色基于同一份项目状态交叉后的结果。"
|
||
|
||
support_parts = [item for item in (strategy_read, product_read, execution_read) if item]
|
||
support = ";".join(support_parts[:2])
|
||
second_sentence = f"具体看,{support}。" if support else "具体执行前,还需要继续补齐可验证的项目状态和验收证据。"
|
||
|
||
boundary = "确认前我不会写项目记忆、改任务、发起执行 handoff 或发布。"
|
||
if first_block:
|
||
boundary = f"先不要直接做“{first_block}”;{boundary}"
|
||
prepared_count = len(list(autonomy_gate.prepared_action_packets or []))
|
||
confirmation_count = len(list(autonomy_gate.confirmation_requests or []))
|
||
execution_count = len(list(autonomy_gate.execution_preparations or []))
|
||
if prepared_count or execution_count:
|
||
boundary += f" 我已经把 {prepared_count} 个候选动作包和 {execution_count} 个执行交接包整理好,全部停在确认门禁。"
|
||
if confirmation_count:
|
||
boundary += f" 当前有 {confirmation_count} 个确认请求,确认后也只会进入对应专用链路,不会在这里直接执行。"
|
||
if council_review.conflict_ledger:
|
||
boundary += f" 本轮会审还记录了 {len(council_review.conflict_ledger)} 个冲突或边界,已经进入后续确认。"
|
||
learning_sentence = _learning_answer_sentence(operating_cycle)
|
||
if learning_sentence:
|
||
boundary += f" {learning_sentence}"
|
||
return f"{first_sentence}\n\n{second_sentence}\n\n{boundary}".strip()
|
||
|
||
|
||
def _attach_cycle_to_plan(
|
||
*,
|
||
operating_plan: PMFlowAIOperatingPlan,
|
||
operating_cycle: PMFlowAIOperatingCycleState,
|
||
) -> PMFlowAIOperatingPlan:
|
||
learning_updates = [
|
||
{
|
||
"learning_key": item.get("learning_key"),
|
||
"summary": item.get("summary"),
|
||
"outcome_type": item.get("outcome_type"),
|
||
"outcome_status": item.get("outcome_status"),
|
||
}
|
||
for item in list(operating_cycle.learning_ledger.learning_items or [])[-12:]
|
||
if isinstance(item, dict)
|
||
]
|
||
return operating_plan.model_copy(
|
||
update={
|
||
"operating_cycle": operating_cycle.model_dump(mode="json", exclude_none=True),
|
||
"consumed_learning_ledger": operating_cycle.consumed_learning_ledger.model_dump(mode="json", exclude_none=True),
|
||
"learning_updates": learning_updates,
|
||
"next_cycle_triggers": list(operating_cycle.next_cycle_triggers or []),
|
||
}
|
||
)
|
||
|
||
|
||
def _operating_cycle_summary(operating_cycle: PMFlowAIOperatingCycleState | None) -> str:
|
||
if operating_cycle is None:
|
||
return "未建立持续推进闭环。"
|
||
consumed_count = len(list(operating_cycle.consumed_learning_ledger.learning_items or []))
|
||
observation_count = len(list(operating_cycle.outcome_observations or []))
|
||
learning_count = len(list(operating_cycle.learning_ledger.learning_items or []))
|
||
trigger_count = len(list(operating_cycle.next_cycle_triggers or []))
|
||
return (
|
||
f"已读取 {consumed_count} 条历史学习,本轮记录 {observation_count} 条结果观察,"
|
||
f"学习账本累计 {learning_count} 条,后续可由 {trigger_count} 类事件继续触发。"
|
||
)
|
||
|
||
|
||
def _learning_ledger_summary(operating_cycle: PMFlowAIOperatingCycleState) -> str:
|
||
ledger = operating_cycle.learning_ledger
|
||
return (
|
||
f"学习项 {len(list(ledger.learning_items or []))} 条,"
|
||
f"决策结果 {len(list(ledger.decision_outcomes or []))} 条,"
|
||
f"执行结果 {len(list(ledger.execution_outcomes or []))} 条,"
|
||
f"实验结果 {len(list(ledger.experiment_results or []))} 条。"
|
||
)
|
||
|
||
|
||
def _learning_loop_verification_findings(operating_cycle: PMFlowAIOperatingCycleState) -> list[dict[str, Any]]:
|
||
findings: list[dict[str, Any]] = []
|
||
if not operating_cycle.learning_ledger.scope_ref:
|
||
findings.append({"type": "learning_scope_missing", "summary": "学习账本缺少项目对象范围。"})
|
||
if operating_cycle.learning_ledger.scope_ref != operating_cycle.consumed_learning_ledger.scope_ref:
|
||
findings.append({"type": "learning_scope_mismatch", "summary": "读取的学习账本和更新后的账本范围不一致。"})
|
||
if not list(operating_cycle.outcome_observations or []):
|
||
findings.append({"type": "outcome_observation_missing", "summary": "本轮没有记录结果观察,后续运行无法学习。"})
|
||
if not list(operating_cycle.next_cycle_triggers or []):
|
||
findings.append({"type": "next_cycle_trigger_missing", "summary": "本轮没有生成下一轮触发合同。"})
|
||
return findings[:8]
|
||
|
||
|
||
def _learning_answer_sentence(operating_cycle: PMFlowAIOperatingCycleState) -> str:
|
||
consumed_count = len(list(operating_cycle.consumed_learning_ledger.learning_items or []))
|
||
observation_count = len(list(operating_cycle.outcome_observations or []))
|
||
if not consumed_count:
|
||
return ""
|
||
parts: list[str] = []
|
||
parts.append(f"我也读取了上一轮 {consumed_count} 条学习记录来约束本轮计划。")
|
||
if observation_count:
|
||
parts.append(f"这轮已记录 {observation_count} 条结果观察,后面继续问、确认或有执行结果回流时,会接着这份状态推进。")
|
||
return "".join(parts)
|
||
|
||
|
||
def _autonomy_gate_summary(autonomy_gate: PMFlowAIAutonomyDecision | None) -> str:
|
||
if autonomy_gate is None:
|
||
return "未生成确认门禁。"
|
||
prepared_count = len(list(autonomy_gate.prepared_action_packets or []))
|
||
confirmation_count = len(list(autonomy_gate.confirmation_requests or []))
|
||
execution_count = len(list(autonomy_gate.execution_preparations or []))
|
||
blocked_count = len(list(autonomy_gate.blocked_actions or []))
|
||
return (
|
||
f"已准备 {prepared_count} 个候选动作包、{confirmation_count} 个确认请求、"
|
||
f"{execution_count} 个执行交接包;{blocked_count} 个动作被边界或风险阻断,未执行任何写入。"
|
||
)
|
||
|
||
|
||
def _prepared_action_summary(packet: dict[str, Any]) -> str:
|
||
family = _text(packet.get("action_family"), 120)
|
||
title = _text(packet.get("title"), 240)
|
||
if family == "task_change":
|
||
return f"任务变更候选:{title}。确认前不会改任务。"
|
||
if family == "experiment_plan":
|
||
return f"用户验证实验候选:{title}。确认前不会写入项目事实。"
|
||
if family == "project_memory_candidate":
|
||
return f"项目记忆候选:{title}。确认前不会写 Project Memory。"
|
||
return f"待确认动作:{title}。确认前不会执行。"
|
||
|
||
|
||
def _autonomy_verification_findings(autonomy_gate: PMFlowAIAutonomyDecision) -> list[dict[str, Any]]:
|
||
findings: list[dict[str, Any]] = []
|
||
prepared_packets = [dict(item) for item in list(autonomy_gate.prepared_action_packets or []) if isinstance(item, dict)]
|
||
confirmation_requests = [dict(item) for item in list(autonomy_gate.confirmation_requests or []) if isinstance(item, dict)]
|
||
execution_preparations = [dict(item) for item in list(autonomy_gate.execution_preparations or []) if isinstance(item, dict)]
|
||
requested_ids = {
|
||
str(requested_for)
|
||
for request in confirmation_requests
|
||
for requested_for in list(request.get("requested_for") or [])
|
||
}
|
||
for packet in prepared_packets:
|
||
prepared_id = str(packet.get("prepared_action_id") or "")
|
||
if not bool(packet.get("confirmation_required")):
|
||
findings.append({"type": "confirmation_boundary", "summary": f"{packet.get('title') or '候选动作'} 缺少确认要求。"})
|
||
if prepared_id and prepared_id not in requested_ids:
|
||
findings.append({"type": "confirmation_request_missing", "summary": f"{packet.get('title') or '候选动作'} 没有关联确认请求。"})
|
||
for request in confirmation_requests:
|
||
if bool(request.get("can_execute_after_confirmation")):
|
||
findings.append({"type": "direct_execution_block", "summary": f"{request.get('title') or '确认请求'} 不能在确认后直接执行,必须进入专用链路。"})
|
||
for preparation in execution_preparations:
|
||
if str(preparation.get("readiness") or "") == "prepared_waiting_confirmation" and not list(preparation.get("required_confirmations") or []):
|
||
findings.append({"type": "execution_confirmation_missing", "summary": "执行交接准备包缺少确认请求。"})
|
||
prohibited = set(str(item) for item in list(autonomy_gate.prohibited_side_effects or []))
|
||
for required in ("write_project_memory", "execute_handoff"):
|
||
if required not in prohibited:
|
||
findings.append({"type": "prohibited_side_effect_missing", "summary": f"确认门禁缺少 {required} 禁止项。"})
|
||
return findings[:12]
|
||
|
||
|
||
def _project_world_command_summary(project_world_state: PMFlowAIProjectWorldStatePacket) -> dict[str, Any]:
|
||
return {
|
||
"packet_version": project_world_state.packet_version,
|
||
"world_state_id": project_world_state.world_state_id,
|
||
"status": project_world_state.status,
|
||
"primary_project_ref": project_world_state.primary_project_ref,
|
||
"coverage": dict(project_world_state.coverage or {}),
|
||
"facet_summaries": [
|
||
{
|
||
"facet_key": facet.facet_key,
|
||
"display_label": facet.display_label,
|
||
"status": facet.status,
|
||
"summary": facet.summary,
|
||
}
|
||
for facet in list(project_world_state.facets or [])[:12]
|
||
],
|
||
"remaining_gaps": [_customer_gap_text(item) for item in list(project_world_state.remaining_gaps or [])[:12]],
|
||
}
|
||
|
||
|
||
def _project_world_summary(project_world_state: PMFlowAIProjectWorldStatePacket) -> str:
|
||
coverage = dict(project_world_state.coverage or {})
|
||
covered = len(list(coverage.get("covered_facets") or []))
|
||
partial = len(list(coverage.get("partial_facets") or []))
|
||
missing = len(list(coverage.get("missing_facets") or []))
|
||
if project_world_state.status == "missing_scope":
|
||
return "缺少强项目对象,未读取项目世界状态。"
|
||
return f"已读取 {len(project_world_state.project_refs)} 个项目对象;覆盖 {covered} 项、部分覆盖 {partial} 项、缺失 {missing} 项。"
|
||
|
||
|
||
def _project_label(project_world_state: PMFlowAIProjectWorldStatePacket) -> str:
|
||
for packet in list(project_world_state.project_operating_evidence_packets or [])[:4]:
|
||
if not isinstance(packet, dict):
|
||
continue
|
||
title = _text(packet.get("project_title") or packet.get("title"), 120)
|
||
if title:
|
||
return f"项目“{title}”"
|
||
for facet in list(project_world_state.facets or [])[:6]:
|
||
for fact in list(facet.facts or [])[:12]:
|
||
if not isinstance(fact, dict):
|
||
continue
|
||
if fact.get("key") == "project_title" and _text(fact.get("value"), 120):
|
||
return f"项目“{_text(fact.get('value'), 120)}”"
|
||
return project_world_state.primary_project_ref or "这个项目"
|
||
|
||
|
||
def _first_action(rows: list[dict[str, Any]]) -> str:
|
||
for item in list(rows or [])[:8]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
title = _text(item.get("title") or item.get("display_label"), 240)
|
||
if title:
|
||
return title
|
||
return ""
|
||
|
||
|
||
def _work_product_title(product: PMFlowAIAgentWorkProduct) -> str:
|
||
titles = {
|
||
"project_strategist": "策略判断",
|
||
"customer_discovery": "用户验证计划",
|
||
"product_manager": "产品范围计划",
|
||
"technical_architect": "技术路径评估",
|
||
"execution_planner": "执行推进计划",
|
||
"risk_reviewer": "风险审查",
|
||
}
|
||
return titles.get(product.agent_id, product.product_type or "专业工作产物")
|
||
|
||
|
||
def _product_for_agent(work_products: list[PMFlowAIAgentWorkProduct], agent_id: str) -> PMFlowAIAgentWorkProduct | None:
|
||
return next((item for item in work_products if item.agent_id == agent_id), None)
|
||
|
||
|
||
def _strong_project_ref(
|
||
*,
|
||
request: RAGAskRequest,
|
||
conversation_context: dict[str, Any] | None,
|
||
tenant_project_context: dict[str, Any] | None,
|
||
) -> str | None:
|
||
request_project_id = _safe_int(getattr(request, "project_id", None))
|
||
if request_project_id is not None:
|
||
return f"project:{request_project_id}"
|
||
context_project_id = _safe_int((conversation_context or {}).get("current_project_id"))
|
||
if context_project_id is not None:
|
||
return f"project:{context_project_id}"
|
||
surface = _surface_context(tenant_project_context)
|
||
focus = surface.get("current_focus_object")
|
||
if isinstance(focus, dict):
|
||
object_ref = _text(focus.get("object_ref"), 160)
|
||
object_type = _text(focus.get("object_type"), 80)
|
||
object_id = _safe_int(focus.get("object_id"))
|
||
if object_ref.startswith("project:"):
|
||
return object_ref
|
||
if object_type == "project" and object_id is not None:
|
||
return f"project:{object_id}"
|
||
return None
|
||
|
||
|
||
def _surface_context(tenant_project_context: dict[str, Any] | None) -> dict[str, Any]:
|
||
surface = dict(tenant_project_context or {}).get("surface_context")
|
||
return dict(surface) if isinstance(surface, dict) else {}
|
||
|
||
|
||
def _project_id_from_ref(project_ref: str) -> int | None:
|
||
prefix, _, raw_id = str(project_ref or "").partition(":")
|
||
if prefix == "project" and raw_id.strip().isdigit():
|
||
return int(raw_id)
|
||
return None
|
||
|
||
|
||
def _customer_gap_text(value: Any) -> str:
|
||
text = _text(value, 500)
|
||
normalized = text.lower()
|
||
if "_" in normalized:
|
||
if "evidence" in normalized and "missing" in normalized:
|
||
return "这个角色产物还缺少可追溯依据"
|
||
if "structured" in normalized and "empty" in normalized:
|
||
return "这个角色产物没有形成结构化结果"
|
||
if "execution" in normalized and "missing" in normalized:
|
||
return "执行摘要暂时缺失"
|
||
if "truth" in normalized and "missing" in normalized:
|
||
return "项目真实状态暂时缺失"
|
||
if "project" in normalized and ("world" in normalized or "state" in normalized):
|
||
return "项目运行信息还没有读完整"
|
||
return text.replace("_", " ") if len(text) < 80 and "_" in text else text
|
||
|
||
|
||
def _dump_model(value: Any) -> dict[str, Any]:
|
||
if value is None:
|
||
return {}
|
||
if hasattr(value, "model_dump"):
|
||
dumped = value.model_dump(mode="json", exclude_none=True)
|
||
return dumped if isinstance(dumped, dict) else {}
|
||
return dict(value) if isinstance(value, dict) else {}
|
||
|
||
|
||
def _text(value: Any, limit: int) -> str:
|
||
return " ".join(str(value or "").strip().split())[:limit]
|
||
|
||
|
||
def _safe_int(value: Any) -> int | None:
|
||
try:
|
||
if value is None or value == "":
|
||
return None
|
||
return int(value)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
__all__ = ["PMFlowAgentSpecialistResult", "PMFlowAgentSpecialistRuntimeService"]
|