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

971 lines
45 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.

"""Operating run kernel for PMFlow AI multi-agent runtime."""
from __future__ import annotations
import time
import re
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import User
from ...schemas.pmflow_ai_operating_runtime import (
PMFlowAIAgentAssignment,
PMFlowAIAgentWorkProduct,
PMFlowAIAutonomyDecision,
PMFlowAIEvidenceLedgerEntry,
PMFlowAIOperatingCycleState,
PMFlowAIOperatingCommandPayload,
PMFlowAIOperatingMission,
PMFlowAIOperatingPlan,
PMFlowAIOperatingRunEvent,
PMFlowAIOperatingRunPacket,
PMFlowAIProductObjectProjection,
PMFlowAIProjectWorldFacet,
PMFlowAIProjectWorldStatePacket,
new_operating_id,
utc_now_iso,
)
from ...schemas.rag import RAGAskRequest
from ..pmflow_ai_agent_runtime import PMFlowAIAgentRunService
from ..pmflow_ai_agent_runtime.agent_evidence_quality import (
is_usable_research_claim_text,
looks_like_source_noise,
normalize_evidence_text,
)
from .agent_registry import PMFlowAIOperatingAgentRegistry
from .autonomy import PMFlowAIAutonomyPolicyService
from .council import PMFlowAIAgentCouncilService
from .cycle import OperatingCycleService
from .mission_service import PMFlowAIOperatingMissionService
from .project_world_state_service import PMFlowAIProjectWorldStateService
from .specialist_agents import PMFlowAISpecialistAgentService
class PMFlowAIOperatingRunService:
"""Create mission-first multi-agent operating runs."""
def __init__(
self,
db: AsyncSession,
*,
tenant_id: int,
mission_service: PMFlowAIOperatingMissionService | None = None,
agent_registry: PMFlowAIOperatingAgentRegistry | None = None,
project_world_state_service: PMFlowAIProjectWorldStateService | None = None,
specialist_agent_service: PMFlowAISpecialistAgentService | None = None,
council_service: PMFlowAIAgentCouncilService | None = None,
autonomy_policy_service: PMFlowAIAutonomyPolicyService | None = None,
operating_cycle_service: OperatingCycleService | None = None,
delivery_runtime: Any | None = None,
) -> None:
self.db = db
self.tenant_id = int(tenant_id)
self.agent_registry = agent_registry or PMFlowAIOperatingAgentRegistry()
self.mission_service = mission_service or PMFlowAIOperatingMissionService(
db=db,
tenant_id=self.tenant_id,
agent_registry=self.agent_registry,
)
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()
self.delivery_runtime = delivery_runtime or PMFlowAIAgentRunService(db, tenant_id=self.tenant_id)
async def run_turn(
self,
*,
request: RAGAskRequest,
current_user: User,
conversation_context: dict[str, Any] | None,
tenant_project_context: dict[str, Any] | None,
started_at: float | None = None,
event_sink: Any | None = None,
) -> dict[str, Any]:
started = started_at or time.perf_counter()
mission = await self.mission_service.build_mission(
request=request,
current_user=current_user,
conversation_context=conversation_context,
tenant_project_context=tenant_project_context,
)
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)
events = [
PMFlowAIOperatingRunEvent(
event_type="mission_created",
summary="已建立本轮 Operating Mission,先定义要推进的目标和确认边界。",
),
PMFlowAIOperatingRunEvent(
event_type="agents_assigned",
summary=f"已分派 {len(assignments)} 个专业 agent 产出结构化工作结果。",
),
PMFlowAIOperatingRunEvent(
event_type="project_world_state_read",
summary=_project_world_state_event_summary(project_world_state),
),
]
delivery_kwargs = {
"request": request,
"current_user": current_user,
"conversation_context": conversation_context,
"tenant_project_context": tenant_project_context,
"started_at": started,
}
if event_sink is not None:
delivery_kwargs["event_sink"] = event_sink
delivery_payload = await self.delivery_runtime.run_turn(**delivery_kwargs)
project_world_state = _merge_delivery_research_evidence(project_world_state, delivery_payload)
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)
product_object_projections = [
PMFlowAIProductObjectProjection.model_validate(item)
for item in list(operating_plan.product_object_projections or [])
if isinstance(item, dict)
]
command_payload = _build_command_payload(
mission=mission,
assignments=assignments,
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,
product_object_projections=product_object_projections,
delivery_payload=delivery_payload,
)
status = _operating_status(operating_plan)
specialist_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,
product_object_projections=product_object_projections,
operating_plan=operating_plan,
)
operating_run = PMFlowAIOperatingRunPacket(
run_id=new_operating_id("orun"),
mission=mission,
status=status,
assignments=assignments,
project_world_state=project_world_state,
work_products=work_products,
agent_council_review=council_review,
autonomy_gate=autonomy_gate,
operating_cycle=operating_cycle,
product_object_projections=product_object_projections,
operating_plan=operating_plan,
events=[
*events,
*[
PMFlowAIOperatingRunEvent(
event_type="specialist_work_product_ready",
summary=f"{product.product_type} 已生成。",
agent_id=product.agent_id,
assignment_id=product.assignment_id,
)
for product in work_products
],
PMFlowAIOperatingRunEvent(
event_type="agent_council_review_ready",
summary=council_review.resolution_summary,
),
PMFlowAIOperatingRunEvent(
event_type="operating_plan_compiled",
summary="已把 Agent Council 会审结果编译进 Operating Plan;回答交付只作为客户侧摘要适配。",
),
PMFlowAIOperatingRunEvent(
event_type="autonomy_gate_applied",
summary=_autonomy_gate_event_summary(autonomy_gate),
),
PMFlowAIOperatingRunEvent(
event_type="learning_ledger_updated",
summary=_learning_event_summary(operating_cycle),
),
],
delivery_ref={
"adapter": "answer_delivery",
"agent_run_id": _agent_run_id(delivery_payload),
"answer_mode": str(delivery_payload.get("answer_mode") or ""),
},
completed_at=utc_now_iso(),
)
delivery_answer = _delivery_customer_answer(delivery_payload)
final_answer = delivery_answer or specialist_answer
payload = dict(delivery_payload or {})
payload["answer"] = final_answer
payload["reply_text"] = final_answer
if isinstance(payload.get("answer_canvas"), dict):
payload["answer_canvas"] = {
**dict(payload["answer_canvas"]),
"direct_answer": final_answer,
"headline": "PMFlow AI",
}
else:
payload["answer_canvas"] = {
"direct_answer": final_answer,
"headline": "PMFlow AI",
"next_actions": [],
"action_candidates": [],
}
payload["processing_stage"] = "multi_agent_operating_runtime"
payload["answer_mode"] = "operating_runtime"
payload["policy_version"] = "pmflow-multi-agent-operating-runtime-v1"
payload["operating_summary"] = specialist_answer
payload["operating_mission"] = mission.model_dump(mode="json", exclude_none=True)
payload["project_world_state_packet"] = project_world_state.model_dump(mode="json", exclude_none=True)
payload["evidence_ledger"] = [item.model_dump(mode="json", exclude_none=True) for item in project_world_state.evidence_ledger]
payload["agent_assignments"] = [item.model_dump(mode="json", exclude_none=True) for item in assignments]
payload["agent_work_products"] = [item.model_dump(mode="json", exclude_none=True) for item in work_products]
payload["agent_council_review"] = council_review.model_dump(mode="json", exclude_none=True)
payload["conflict_ledger"] = [item.model_dump(mode="json", exclude_none=True) for item in council_review.conflict_ledger]
payload["autonomy_gate"] = autonomy_gate.model_dump(mode="json", exclude_none=True)
payload["prepared_action_packets"] = list(operating_plan.prepared_action_packets or [])
payload["confirmation_requests"] = list(operating_plan.confirmation_requests or [])
payload["execution_preparations"] = list(operating_plan.execution_preparations or [])
payload["product_object_projections"] = [item.model_dump(mode="json", exclude_none=True) for item in product_object_projections]
payload["operating_cycle"] = operating_cycle.model_dump(mode="json", exclude_none=True)
payload["consumed_learning_ledger"] = operating_cycle.consumed_learning_ledger.model_dump(mode="json", exclude_none=True)
payload["outcome_observations"] = [item.model_dump(mode="json", exclude_none=True) for item in operating_cycle.outcome_observations]
payload["learning_ledger"] = operating_cycle.learning_ledger.model_dump(mode="json", exclude_none=True)
payload["learning_updates"] = list(operating_plan.learning_updates or [])
payload["next_cycle_triggers"] = list(operating_plan.next_cycle_triggers or [])
payload["operating_plan"] = operating_plan.model_dump(mode="json", exclude_none=True)
payload["operating_run_packet"] = operating_run.model_dump(mode="json", exclude_none=True)
payload["command_payload"] = command_payload.model_dump(mode="json", exclude_none=True)
payload["answer_process_packet"] = _merge_operating_process(
payload.get("answer_process_packet"),
project_world_state=project_world_state,
council_review=council_review.model_dump(mode="json", exclude_none=True),
autonomy_gate=autonomy_gate.model_dump(mode="json", exclude_none=True),
operating_cycle=operating_cycle.model_dump(mode="json", exclude_none=True),
total_duration_ms=int((time.perf_counter() - started) * 1000),
)
return payload
def _merge_delivery_research_evidence(
project_world_state: PMFlowAIProjectWorldStatePacket,
delivery_payload: dict[str, Any],
) -> PMFlowAIProjectWorldStatePacket:
"""Promote inner read-loop evidence into the operating world state."""
deliverable = delivery_payload.get("deliverable_packet") if isinstance(delivery_payload.get("deliverable_packet"), dict) else {}
workspace = delivery_payload.get("agent_workspace") if isinstance(delivery_payload.get("agent_workspace"), dict) else {}
observations = _delivery_observations(delivery_payload, workspace=workspace)
source_rows = _external_source_rows(observations)
terminal_state = delivery_payload.get("terminal_answer_state") if isinstance(delivery_payload.get("terminal_answer_state"), dict) else {}
claims = _claim_texts(workspace, deliverable, terminal_state=terminal_state)
gaps = _gap_texts(workspace, deliverable, list(project_world_state.remaining_gaps or []))
if not source_rows and not claims and not gaps:
return project_world_state
source_count = len(source_rows)
claim_count = len(claims)
status = "ready" if claim_count >= 3 and source_count >= 2 else "partial"
summary = f"已完成外部研究读取:{source_count} 个候选来源、{claim_count} 条可用判断、{len(gaps)} 个剩余证据缺口。"
facet = PMFlowAIProjectWorldFacet(
facet_key="external_research_evidence",
display_label="外部研究证据",
status=status,
summary=summary,
facts=[
{"fact_key": "source_candidates", "value": source_rows[:8]},
{"fact_key": "usable_claims", "value": claims[:8]},
{"fact_key": "evidence_gaps", "value": gaps[:8]},
],
evidence_refs=_dedupe([str(item.get("observation_id") or item.get("id") or "") for item in observations])[:12]
or ["agent_research_delivery"],
gaps=gaps[:12],
)
coverage = dict(project_world_state.coverage or {})
covered = _dedupe([*list(coverage.get("covered_facets") or []), facet.facet_key])
missing = [item for item in list(coverage.get("missing_facets") or []) if item != facet.facet_key]
remaining = [gap for gap in gaps if gap and not _looks_like_internal_token(str(gap))]
if not remaining and status != "ready":
remaining = ["还缺少跨来源验证的公开材料"]
return project_world_state.model_copy(
update={
"status": "ready" if project_world_state.status == "partial" and status == "ready" else project_world_state.status,
"facets": [*[item for item in project_world_state.facets if item.facet_key != facet.facet_key], facet],
"evidence_ledger": [
*list(project_world_state.evidence_ledger or []),
PMFlowAIEvidenceLedgerEntry(
project_ref=project_world_state.primary_project_ref or "space:external_research",
facet_key=facet.facet_key,
source_ref="agent_research_delivery",
summary=summary,
confidence="high" if status == "ready" else "medium",
),
],
"coverage": {
**coverage,
"covered_facets": covered,
"missing_facets": missing,
"coverage_score": max(float(coverage.get("coverage_score") or 0.0), 0.45 if status == "partial" else 0.65),
},
"remaining_gaps": remaining[:24],
}
)
def _external_source_rows(observations: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for observation in observations:
source = str(observation.get("source_kind") or observation.get("source") or "").strip()
facts = _dicts(observation.get("facts"))
if source not in {"external_search", "inspect_source_detail"} and not any(str(item.get("type") or "") in {"external_evidence", "external_source_text"} for item in facts):
continue
for fact in facts:
fact_type = str(fact.get("type") or "").strip()
if fact_type not in {"external_evidence", "external_source_text"}:
continue
url = str(fact.get("url") or "").strip()
title = str(fact.get("title") or url or "公开来源").strip()
summary = str(fact.get("summary") or fact.get("text") or "").strip()
row_summary = _external_source_row_summary(summary)
if not url and not row_summary:
continue
if looks_like_source_noise(title):
continue
if not row_summary:
continue
rows.append({"title": title[:220], "url": url[:500], "summary": row_summary[:420], "source": source})
return _dedupe_dicts_local(rows, key="url")[:12]
def _external_source_row_summary(summary: Any) -> str:
text = normalize_evidence_text(summary)
if not text:
return ""
if not looks_like_source_noise(text):
return text[:420]
fragments = re.split(r"(?<=[。!?.!?])\s+|[\n\r]+|;|;", text)
for fragment in [*fragments, *_research_signal_windows(text)]:
fragment = fragment.strip(" \t|-—::;;,,")
if len(fragment) < 36:
continue
if looks_like_source_noise(fragment):
continue
if is_usable_research_claim_text(fragment):
return fragment[:420]
return ""
def _research_signal_windows(text: str) -> list[str]:
value = re.sub(r"\s+", " ", str(text or "")).strip()
if not value:
return []
pattern = re.compile(
r"\b(?:19|20)\d{2}(?:\b|年)|\d[\d,]*(?:\.\d+)?\s*(?:%|万|亿|千|百|million|billion|trillion|k|m|bn)(?:\b|元|人|次|个|条|家)?|\$|€|£|¥|人民币|美元|营收|收入|成本|融资|投资|增长|下降|需求|供给|采用|参会|预算|赞助|转化|销售|reported|grew|declined|reached|increased|decreased|expected|shows|forecast|projected|valued|budget|sponsor|attendance|attendee|attendees|adoption|revenue",
flags=re.I,
)
rows: list[str] = []
for match in pattern.finditer(value):
fragment = value[max(0, match.start() - 220) : min(len(value), match.end() + 260)]
fragment = _trim_page_shell_edges(fragment=fragment, signal_text=match.group(0))
if len(fragment) > 520:
fragment = fragment[:520].rsplit(" ", 1)[0] or fragment[:520]
if 36 <= len(fragment) <= 520:
rows.append(fragment)
return _dedupe(rows)[:20]
def _trim_page_shell_edges(*, fragment: str, signal_text: str) -> str:
value = str(fragment or "").strip(" \t|-—::;;,,")
lowered = value.lower()
signal_index = lowered.find(str(signal_text or "").lower())
if signal_index < 0:
return value
markers = (
"subscribe",
"sign in",
"newsletter",
"share",
"privacy",
"terms",
"advertisement",
"contact us",
"read more",
"premium",
"site map",
"copyright",
"登录",
"注册",
"隐私政策",
"用户协议",
"广告",
"联系我们",
"订阅",
)
cut = 0
for marker in markers:
idx = lowered.rfind(marker, 0, signal_index)
if idx >= 0:
cut = max(cut, idx + len(marker))
if cut:
value = value[cut:].strip(" \t|-—::;;,,")
lowered = value.lower()
suffix_cut = len(value)
for marker in markers:
idx = lowered.find(marker, max(0, len(str(signal_text or "")) + 24))
if idx >= 0:
suffix_cut = min(suffix_cut, idx)
return value[:suffix_cut].strip(" \t|-—::;;,,")
def _delivery_observations(delivery_payload: dict[str, Any], *, workspace: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
rows.extend(_dicts(delivery_payload.get("agent_os_observations")))
packet = delivery_payload.get("agent_run_packet") if isinstance(delivery_payload.get("agent_run_packet"), dict) else {}
rows.extend(_dicts(packet.get("observations")))
rows.extend(_dicts(workspace.get("observations")))
rows.extend(_dicts(workspace.get("observation_ledger")))
normalized: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
if "source_kind" not in item and item.get("source"):
item["source_kind"] = item.get("source")
if "id" not in item and item.get("observation_id"):
item["id"] = item.get("observation_id")
normalized.append(item)
return _dedupe_dicts_local(normalized, key="id")[:24]
def _claim_texts(
workspace: dict[str, Any],
deliverable: dict[str, Any],
*,
terminal_state: dict[str, Any] | None = None,
) -> list[str]:
terminal_status = str((terminal_state or {}).get("terminal_status") or "").strip()
if terminal_status in {"waiting_for_evidence", "blocked", "refused_by_boundary", "waiting_for_user"}:
return []
claims: list[str] = []
for row in _dicts(workspace.get("claim_ledger")) + _dicts(workspace.get("claims")):
claims.append(str(row.get("claim_text") or row.get("summary") or row.get("text") or "").strip())
for section in _dicts(deliverable.get("sections")):
if str(section.get("section_kind") or "") not in {"claims", "findings", "summary"}:
continue
for item in _dicts(section.get("items")):
claims.append(str(item.get("summary") or item.get("claim_text") or item.get("text") or "").strip())
return _dedupe([item for item in claims if item and is_usable_research_claim_text(item)])[:12]
def _gap_texts(workspace: dict[str, Any], deliverable: dict[str, Any], existing_gaps: list[str]) -> list[str]:
gaps = [str(item or "").strip() for item in existing_gaps if str(item or "").strip()]
for row in _dicts(workspace.get("gap_ledger")) + _dicts(workspace.get("remaining_gaps")):
gaps.append(str(row.get("summary") or row.get("gap_text") or row.get("text") or "").strip())
for section in _dicts(deliverable.get("sections")):
if str(section.get("section_kind") or "") not in {"gaps", "boundaries", "limitations"}:
continue
for item in _dicts(section.get("items")):
gaps.append(str(item.get("summary") or item.get("text") or "").strip())
return _dedupe([item for item in gaps if item])[:16]
def _dedupe(rows: list[str]) -> list[str]:
out: list[str] = []
for item in rows:
text = " ".join(str(item or "").split())[:700]
if text and text not in out:
out.append(text)
return out
def _dedupe_dicts_local(rows: list[dict[str, Any]], *, key: str) -> list[dict[str, Any]]:
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
value = str(row.get(key) or row.get("title") or row.get("summary") or "").strip()
if not value or value in seen:
continue
seen.add(value)
out.append(row)
return out
def _dicts(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _build_command_payload(
*,
mission: PMFlowAIOperatingMission,
assignments: list[PMFlowAIAgentAssignment],
project_world_state: PMFlowAIProjectWorldStatePacket,
work_products: list[PMFlowAIAgentWorkProduct],
council_review: Any,
autonomy_gate: PMFlowAIAutonomyDecision,
operating_cycle: PMFlowAIOperatingCycleState,
operating_plan: PMFlowAIOperatingPlan,
product_object_projections: list[PMFlowAIProductObjectProjection],
delivery_payload: dict[str, Any],
) -> PMFlowAIOperatingCommandPayload:
context_packet = (
delivery_payload.get("customer_interaction_context_packet")
if isinstance(delivery_payload.get("customer_interaction_context_packet"), dict)
else {}
)
return PMFlowAIOperatingCommandPayload(
mission={
"mission_id": mission.mission_id,
"goal": mission.mission_goal,
"desired_outcome": mission.desired_outcome,
"autonomy_level": mission.autonomy_level,
},
project_focus=context_packet.get("current_focus_object") if isinstance(context_packet.get("current_focus_object"), dict) else {},
project_world_state=_project_world_state_command_summary(project_world_state),
agent_council=[
{
"agent_id": assignment.agent_id,
"status": _product_for_agent(work_products=work_products, agent_id=assignment.agent_id).status
if _product_for_agent(work_products=work_products, agent_id=assignment.agent_id) is not None
else "blocked",
"work_product": assignment.expected_work_product,
"summary": _summary_for_agent(work_products=work_products, agent_id=assignment.agent_id),
}
for assignment in assignments
],
agent_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],
priority_order=list(council_review.priority_order or []),
risk_vetoes=list(council_review.risk_vetoes or []),
autonomy_gate=autonomy_gate.model_dump(mode="json", exclude_none=True),
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),
strategic_read=operating_plan.strategic_read,
operating_plan=operating_plan.model_dump(mode="json", exclude_none=True),
prepared_actions=list(operating_plan.prepared_action_packets or []),
confirmation_requests=list(operating_plan.confirmation_requests or []),
execution_preparations=list(operating_plan.execution_preparations or []),
product_object_projections=[item.model_dump(mode="json", exclude_none=True) for item in product_object_projections],
blocked_actions=list(operating_plan.blocked_actions or []),
evidence_boundary=list(operating_plan.evidence_boundary or []),
evidence_ledger=[item.model_dump(mode="json", exclude_none=True) for item in project_world_state.evidence_ledger[:20]],
learning_updates=list(operating_plan.learning_updates or []),
next_cycle={
"status": "ready_for_next_operating_step",
"summary": "下一步应让专业 agent 基于当前项目世界状态和 learning ledger 原生产出更深入的经营计划。",
"triggers": list(operating_plan.next_cycle_triggers or []),
},
)
def _merge_operating_process(
process_packet: Any,
*,
project_world_state: PMFlowAIProjectWorldStatePacket,
council_review: dict[str, Any],
autonomy_gate: dict[str, Any],
operating_cycle: dict[str, Any],
total_duration_ms: int,
) -> dict[str, Any]:
packet = dict(process_packet or {}) if isinstance(process_packet, dict) else {}
existing_steps = list(packet.get("steps") or []) if isinstance(packet.get("steps"), list) else []
packet["packet_version"] = "customer_answer_process.v1"
packet["visibility"] = "customer"
packet["answer_type"] = packet.get("answer_type") or "agent_runtime"
packet["route_display"] = packet.get("route_display") or "agent_runtime"
packet["steps"] = existing_steps
packet["total_duration_ms"] = max(0, int(total_duration_ms or packet.get("total_duration_ms") or 0))
return packet
def _operating_status(operating_plan: PMFlowAIOperatingPlan) -> str:
if operating_plan.requires_confirmation:
return "waiting_for_confirmation"
return "completed"
def _agent_run_id(payload: dict[str, Any]) -> str:
packet = payload.get("agent_run_packet") if isinstance(payload.get("agent_run_packet"), dict) else {}
return str(packet.get("run_id") or "").strip()
def _delivery_customer_answer(delivery_payload: dict[str, Any]) -> str:
"""Prefer the already gated Agent Answer Runtime output as the customer answer.
The operating layer enriches the run with mission/work-product/command packets.
It must not replace a usable natural answer with its own process summary; that
summary is kept separately for audit and command surfaces.
"""
for key in ("answer", "reply_text"):
text = _plain_customer_text(delivery_payload.get(key), limit=5000)
if text and not _looks_like_internal_answer_text(text):
return text
canvas = delivery_payload.get("answer_canvas") if isinstance(delivery_payload.get("answer_canvas"), dict) else {}
text = _plain_customer_text(canvas.get("direct_answer"), limit=5000)
if text and not _looks_like_internal_answer_text(text):
return text
return ""
def _looks_like_internal_answer_text(text: str) -> bool:
value = str(text or "").strip()
if not value:
return True
lowered = value.lower()
internal_markers = (
"agent os",
"turnstate",
"goalframe",
"researchnote",
"claim_ledger",
"gap_ledger",
"answer_runtime_packet",
"runtime",
"trace",
"prompt",
)
if any(marker in lowered for marker in internal_markers):
return True
return _looks_like_internal_token(value)
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 _autonomy_gate_event_summary(autonomy_gate: PMFlowAIAutonomyDecision) -> str:
return (
f"Autonomy Gate 已生成 {len(autonomy_gate.prepared_action_packets)} 个准备动作包、"
f"{len(autonomy_gate.confirmation_requests)} 个确认请求和 "
f"{len(autonomy_gate.execution_preparations)} 个执行准备包;写入和 handoff 均未执行。"
)
def _autonomy_gate_process_summary(autonomy_gate: dict[str, Any]) -> str:
prepared_count = len(list(autonomy_gate.get("prepared_action_packets") or []))
confirmation_count = len(list(autonomy_gate.get("confirmation_requests") or []))
execution_count = len(list(autonomy_gate.get("execution_preparations") or []))
return (
f"已准备 {prepared_count} 个动作包、{confirmation_count} 个确认请求、"
f"{execution_count} 个执行交接包;写入/执行/发布/外部动作全部需要确认后再进入专用链路。"
)
def _learning_event_summary(operating_cycle: PMFlowAIOperatingCycleState) -> str:
return (
f"已消费 {len(operating_cycle.consumed_learning_ledger.learning_items)} 条历史学习,"
f"记录 {len(operating_cycle.outcome_observations)} 条结果观察,"
f"准备 {len(operating_cycle.next_cycle_triggers)} 个下一轮触发合同。"
)
def _learning_process_summary(operating_cycle: dict[str, Any]) -> str:
consumed = operating_cycle.get("consumed_learning_ledger") if isinstance(operating_cycle.get("consumed_learning_ledger"), dict) else {}
learning = operating_cycle.get("learning_ledger") if isinstance(operating_cycle.get("learning_ledger"), dict) else {}
observations = list(operating_cycle.get("outcome_observations") or [])
triggers = list(operating_cycle.get("next_cycle_triggers") or [])
return (
f"已读取 {len(list(consumed.get('learning_items') or []))} 条历史学习,"
f"本轮记录 {len(observations)} 条结果观察,"
f"学习账本累计 {len(list(learning.get('learning_items') or []))} 条,"
f"下一轮可由 {len(triggers)} 类事件触发。"
)
def _project_world_state_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,
"project_refs": list(project_world_state.project_refs or []),
"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": list(project_world_state.remaining_gaps or [])[:12],
}
def _compose_specialist_answer(
*,
mission: PMFlowAIOperatingMission,
project_world_state: PMFlowAIProjectWorldStatePacket,
work_products: list[PMFlowAIAgentWorkProduct],
council_review: Any,
autonomy_gate: PMFlowAIAutonomyDecision,
operating_cycle: PMFlowAIOperatingCycleState,
product_object_projections: list[PMFlowAIProductObjectProjection],
operating_plan: PMFlowAIOperatingPlan,
) -> str:
strategist = _product_for_agent(work_products=work_products, agent_id="product_strategy_agent")
execution = _product_for_agent(work_products=work_products, agent_id="pm_breakdown_agent")
risk = _product_for_agent(work_products=work_products, agent_id="qa_risk_agent")
project_ref = _display_scope_ref(project_world_state.primary_project_ref or "")
if project_world_state.status not in {"ready", "partial"}:
gaps = "、".join(project_world_state.remaining_gaps[:3]) or "缺少可安全绑定的项目对象"
return f"我还不能给出完整推进判断:{project_ref} 的项目世界状态没有读完整,当前缺口是 {gaps}。这轮只能先保留本轮目标和对象候选,不能用空间概览或单段回答冒充项目计划。"
lead = operating_plan.highest_leverage_move or mission.desired_outcome
strategy_text = _product_summary(strategist)
execution_text = _product_summary(execution)
research_product = _product_for_type(work_products=work_products, product_type="ResearchNote")
if project_world_state.primary_project_ref == "space:external_research" or research_product is not None:
return _compose_research_operating_answer(
mission=mission,
project_world_state=project_world_state,
research_product=research_product,
operating_plan=operating_plan,
autonomy_gate=autonomy_gate,
operating_cycle=operating_cycle,
)
risk_blocks = list(council_review.risk_vetoes or []) or (list(risk.blocked_actions or []) if risk is not None else [])
risk_text = ""
if risk_blocks:
first_block = risk_blocks[0]
risk_text = f" 不能直接做的是:{first_block.get('title')},原因是 {first_block.get('reason')}。"
prepared = list(operating_plan.prepared_action_packets or [])
prepared_text = ""
if prepared:
prepared_text = " 我已经准备好候选动作包:" + ";".join(str(item.get("title") or "") for item in prepared[:3] if item.get("title")) + ",这些都需要你确认后才会写入或执行。"
projection_text = ""
if product_object_projections:
projection = product_object_projections[0]
projection_text = f" 本轮已经形成一个待确认的{projection.object_type}:{projection.title}。"
confirmation_text = ""
if autonomy_gate.confirmation_requests:
confirmation_text = f" 本轮有 {len(autonomy_gate.confirmation_requests)} 个确认请求;确认前只展示候选,不会写入项目资料、改动任务或交给执行环境。"
learning_text = ""
if operating_cycle.consumed_learning_ledger.learning_items:
learning_text = f" 我也读取了上一轮 {len(operating_cycle.consumed_learning_ledger.learning_items)} 条历史约束,避免重复使用已失败的推进方式。"
council_text = ""
if council_review.conflict_ledger:
council_text = f" Agent Council 已记录 {len(council_review.conflict_ledger)} 个冲突/边界,并收敛成单一优先级。"
fallback_context = project_world_state.facets[0].summary if project_world_state.facets else ""
context = strategy_text or execution_text or fallback_context
return f"{project_ref} 的最高杠杆动作是:{lead}。{context}{council_text}{projection_text}{prepared_text}{confirmation_text}{learning_text}{risk_text}".strip()
def _compose_research_operating_answer(
*,
mission: PMFlowAIOperatingMission,
project_world_state: PMFlowAIProjectWorldStatePacket,
research_product: PMFlowAIAgentWorkProduct | None,
operating_plan: PMFlowAIOperatingPlan,
autonomy_gate: PMFlowAIAutonomyDecision,
operating_cycle: PMFlowAIOperatingCycleState,
) -> str:
output = research_product.structured_output if research_product is not None and isinstance(research_product.structured_output, dict) else {}
evidence_state = output.get("evidence_state") if isinstance(output.get("evidence_state"), dict) else {}
source_count = int(evidence_state.get("source_count") or 0)
claims = _gated_research_claims(output)
claim_count = len(claims)
ready = bool(evidence_state.get("ready_for_opportunity_brief")) and claim_count > 0
gaps = _customer_research_gaps(list(output.get("research_gaps") or project_world_state.remaining_gaps or []))
source_plan = [item for item in list(output.get("source_plan") or []) if isinstance(item, dict)]
lead = operating_plan.highest_leverage_move or "继续补齐研究证据"
if ready and claims:
answer = "按目前能核验的材料,可以先形成一个初步判断:" + ";".join(claims[:3])
if gaps:
answer += f"。仍需保留的边界是:{';'.join(gaps[:2])}。"
elif claims:
answer = (
"现在可以先给一个有边界的初判:"
+ ";".join(claims[:3])
+ f"。但还不能升级成机会简报或执行动作:当前是 {source_count} 个候选来源、{claim_count} 条可用判断,"
"还需要跨来源补证。"
)
if gaps:
answer += f" 还缺:{';'.join(gaps[:3])}。"
else:
answer = (
"现在还不能直接下市场结论。"
f"已整理 {source_count} 个候选来源和 {claim_count} 条可用判断,"
f"接下来最值得补的是:{lead}。"
)
if gaps:
answer += f"主要缺口:{';'.join(gaps[:3])}。"
if source_plan:
lanes = []
for item in source_plan[:3]:
lane = str(item.get("lane") or item.get("goal") or "").strip()
query = str(item.get("query") or "").strip()
label = lane or query
if label:
lanes.append(label)
if lanes:
answer += " 下一步只推进前置研究补证:" + ";".join(lanes) + "。"
if autonomy_gate.confirmation_requests:
executable = [
item
for item in list(autonomy_gate.confirmation_requests or [])
if isinstance(item, dict) and str(item.get("action_type") or "").strip()
]
if executable:
labels = [_customer_action_label(item.get("action_label") or item.get("title")) for item in executable[:3]]
action_text = ";".join(item for item in labels if item)
if action_text:
answer += " 可继续的动作是:" + action_text + "。"
answer += "确认前只展示候选,不会写入项目资料、改动任务或交给执行环境。"
if operating_cycle.consumed_learning_ledger.learning_items:
answer += f" 这轮沿用了上一轮 {len(operating_cycle.consumed_learning_ledger.learning_items)} 条历史约束,但只用于避免重复走失败路径,不把历史失败当成结论。"
return answer.strip()
def _gated_research_claims(output: dict[str, Any]) -> list[str]:
rows = list(output.get("market_claims") or output.get("claims") or [])
out: list[str] = []
for item in rows[:12]:
text = _plain_customer_text(item, limit=420)
if not text or not is_usable_research_claim_text(text):
continue
if text not in out:
out.append(text)
return out
def _customer_research_gaps(rows: list[Any]) -> list[str]:
out: list[str] = []
for item in rows[:12]:
text = _plain_customer_text(item, limit=260)
if not text:
continue
if _looks_like_internal_token(text):
text = "还缺少可直接支撑结论的公开来源材料"
if text not in out:
out.append(text)
return out
def _customer_action_label(value: object) -> str:
text = _plain_customer_text(value, limit=80)
if not text:
return ""
if _looks_like_internal_token(text):
return ""
latin_count = sum(1 for ch in text if "a" <= ch.lower() <= "z")
cjk_count = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
if latin_count > 16 and cjk_count == 0:
return ""
return text
def _plain_customer_text(value: object, *, limit: int) -> str:
text = " ".join(str(value or "").strip().split())[:limit]
if not text:
return ""
return text.replace("Project Memory", "项目资料").replace("handoff", "执行交接")
def _looks_like_internal_token(text: str) -> bool:
lowered = text.lower().strip()
if re.search(r"\b[a-z]+(?:_[a-z0-9]+){1,}\b", lowered):
return True
if re.search(r"\b(step|run|packet|trace|prompt|function)_[a-z0-9_]+\b", lowered):
return True
return False
def _project_world_state_event_summary(project_world_state: PMFlowAIProjectWorldStatePacket) -> str:
coverage = 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 "没有强项目对象,本轮不会用空间项目数量或 active 状态冒充项目世界状态。"
return f"已读取 {len(project_world_state.project_refs)} 个项目对象的世界状态;覆盖 {covered} 项、部分覆盖 {partial} 项、缺失 {missing} 项。"
def _display_scope_ref(value: str) -> str:
text = str(value or "").strip()
if text == "space:external_research":
return "这个机会研究"
if text.startswith("project:"):
return "当前项目"
if not text:
return "当前对象"
return text
def _summary_for_agent(*, work_products: list[PMFlowAIAgentWorkProduct], agent_id: str) -> str:
product = _product_for_agent(work_products=work_products, agent_id=agent_id)
return product.summary if product else ""
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 _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 _product_summary(product: PMFlowAIAgentWorkProduct | None) -> str:
return str(product.summary if product is not None else "").strip()[:500]
__all__ = ["PMFlowAIOperatingRunService"]