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

545 lines
22 KiB
Python
Raw Permalink 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 cycle triggers, outcome observation and learning ledger."""
from __future__ import annotations
import hashlib
from typing import Any
from ...schemas.pmflow_ai_operating_runtime import (
PMFlowAIAgentCouncilReview,
PMFlowAIAutonomyDecision,
PMFlowAILearningLedger,
PMFlowAIOperatingCycleState,
PMFlowAIOperatingCycleTrigger,
PMFlowAIOperatingMission,
PMFlowAIOperatingPlan,
PMFlowAIOutcomeObservation,
PMFlowAIProjectWorldStatePacket,
clean_text,
)
from ...schemas.rag import RAGAskRequest
SUPPORTED_OPERATING_TRIGGERS = [
{
"trigger_type": "user_turn",
"display_label": "用户对话",
"contract": "run_immediately_from_customer_turn",
},
{
"trigger_type": "signal",
"display_label": "项目/市场信号",
"contract": "run_when_new_signal_changes_project_context",
},
{
"trigger_type": "project_creation",
"display_label": "项目创建",
"contract": "run_after_project_truth_state_created",
},
{
"trigger_type": "task_update",
"display_label": "任务更新",
"contract": "run_after_task_or_changeset_state_changes",
},
{
"trigger_type": "execution_result",
"display_label": "执行结果",
"contract": "run_after_execution_bridge_reports_result",
},
{
"trigger_type": "daily_review",
"display_label": "每日复盘",
"contract": "scheduled_operating_review",
},
{
"trigger_type": "weekly_strategy_review",
"display_label": "每周策略复盘",
"contract": "scheduled_strategy_review",
},
]
class PMFlowAIOperatingCycleScheduler:
"""Build trigger contracts for operating runs."""
def build_trigger(
self,
*,
request: RAGAskRequest,
mission: PMFlowAIOperatingMission,
trigger_type: str = "user_turn",
source_payload: dict[str, Any] | None = None,
) -> PMFlowAIOperatingCycleTrigger:
normalized_type = clean_text(trigger_type, limit=80)
supported = {item["trigger_type"] for item in SUPPORTED_OPERATING_TRIGGERS}
if normalized_type not in supported:
normalized_type = "user_turn"
project_ref = mission.project_scope.object_refs[0] if mission.project_scope.object_refs else ""
source_ref = f"qa_session:{request.session_id}" if request.session_id is not None else "space_ai_turn"
return PMFlowAIOperatingCycleTrigger(
trigger_type=normalized_type,
source_ref=source_ref,
project_ref=project_ref,
payload={
"question_ref": _stable_question_ref(request.question),
"asked_via": clean_text(request.asked_via or "pmflow", limit=40),
"source_payload": dict(source_payload or {}),
},
)
def supported_triggers(self) -> list[dict[str, Any]]:
return [dict(item) for item in SUPPORTED_OPERATING_TRIGGERS]
def next_cycle_triggers(
self,
*,
mission: PMFlowAIOperatingMission,
autonomy_gate: PMFlowAIAutonomyDecision,
learning_ledger: PMFlowAILearningLedger,
) -> list[dict[str, Any]]:
project_ref = mission.project_scope.object_refs[0] if mission.project_scope.object_refs else ""
triggers = [
{
"trigger_type": "user_turn",
"reason": "用户继续追问、确认或补充后重新运行 operating cycle。",
"project_ref": project_ref,
},
{
"trigger_type": "execution_result",
"reason": "执行交接或任务结果返回后更新 learning ledger 并重排计划。",
"project_ref": project_ref,
},
{
"trigger_type": "daily_review",
"reason": "每日读取项目运行态和学习账本,检查阻塞、确认项和下一步。",
"project_ref": project_ref,
},
{
"trigger_type": "weekly_strategy_review",
"reason": "每周基于结果、偏好和实验信号重新判断方向。",
"project_ref": project_ref,
},
]
if autonomy_gate.confirmation_requests:
triggers.append(
{
"trigger_type": "task_update",
"reason": "确认请求被接受/拒绝后更新决策结果。",
"project_ref": project_ref,
}
)
if learning_ledger.experiment_results:
triggers.append(
{
"trigger_type": "signal",
"reason": "实验结果或市场信号进入后刷新客户验证判断。",
"project_ref": project_ref,
}
)
return triggers[:8]
class OutcomeObserver:
"""Observe decisions, execution results, experiments and preferences."""
def observe(
self,
*,
mission: PMFlowAIOperatingMission,
project_world_state: PMFlowAIProjectWorldStatePacket,
council_review: PMFlowAIAgentCouncilReview,
autonomy_gate: PMFlowAIAutonomyDecision,
operating_plan: PMFlowAIOperatingPlan,
external_outcomes: list[dict[str, Any]] | None = None,
) -> list[PMFlowAIOutcomeObservation]:
observations: list[PMFlowAIOutcomeObservation] = []
observations.extend(
DecisionOutcomeService().observe_pending_confirmations(
mission=mission,
autonomy_gate=autonomy_gate,
)
)
observations.extend(
ExperimentResultService().observe_pending_experiments(
autonomy_gate=autonomy_gate,
)
)
observations.extend(
ExecutionOutcomeService().observe_execution_preparations(
autonomy_gate=autonomy_gate,
)
)
observations.extend(
UserPreferenceLearningService().observe_preferences(
mission=mission,
operating_plan=operating_plan,
council_review=council_review,
)
)
observations.extend(self.from_external_outcomes(external_outcomes or [], project_world_state=project_world_state))
return observations[:24]
def from_external_outcomes(
self,
rows: list[dict[str, Any]],
*,
project_world_state: PMFlowAIProjectWorldStatePacket,
) -> list[PMFlowAIOutcomeObservation]:
observations: list[PMFlowAIOutcomeObservation] = []
for item in rows[:24]:
if not isinstance(item, dict):
continue
outcome_type = clean_text(item.get("outcome_type"), limit=80)
if outcome_type not in {"recommendation_decision", "execution_result", "experiment_result", "user_preference"}:
continue
observations.append(
PMFlowAIOutcomeObservation(
outcome_type=outcome_type,
outcome_status=_normalize_outcome_status(item.get("outcome_status") or item.get("status")),
source_ref=clean_text(item.get("source_ref"), limit=180),
target_ref=clean_text(item.get("target_ref") or project_world_state.primary_project_ref, limit=160),
summary=clean_text(item.get("summary") or item.get("title"), limit=800),
evidence_refs=[clean_text(ref, limit=220) for ref in list(item.get("evidence_refs") or [])[:16]],
learning_signals=dict(item.get("learning_signals") or {}) if isinstance(item.get("learning_signals"), dict) else {},
)
)
return observations
class DecisionOutcomeService:
def observe_pending_confirmations(
self,
*,
mission: PMFlowAIOperatingMission,
autonomy_gate: PMFlowAIAutonomyDecision,
) -> list[PMFlowAIOutcomeObservation]:
observations: list[PMFlowAIOutcomeObservation] = []
project_ref = mission.project_scope.object_refs[0] if mission.project_scope.object_refs else ""
for request in list(autonomy_gate.confirmation_requests or [])[:12]:
if not isinstance(request, dict):
continue
observations.append(
PMFlowAIOutcomeObservation(
outcome_type="recommendation_decision",
outcome_status="pending",
source_ref=clean_text(request.get("confirmation_request_id"), limit=120),
target_ref=clean_text(request.get("target_ref") or project_ref, limit=160),
summary=f"确认请求待用户决策:{clean_text(request.get('title'), limit=240)}",
learning_signals={
"decision_state": "pending_user_confirmation",
"requested_for": list(request.get("requested_for") or []),
},
)
)
return observations
class ExecutionOutcomeService:
def observe_execution_preparations(
self,
*,
autonomy_gate: PMFlowAIAutonomyDecision,
) -> list[PMFlowAIOutcomeObservation]:
observations: list[PMFlowAIOutcomeObservation] = []
for item in list(autonomy_gate.execution_preparations or [])[:8]:
if not isinstance(item, dict):
continue
observations.append(
PMFlowAIOutcomeObservation(
outcome_type="execution_result",
outcome_status="pending",
source_ref=clean_text(item.get("execution_preparation_id"), limit=120),
target_ref=clean_text(item.get("target_ref"), limit=160),
summary="执行交接包已准备,但尚未发起 handoff,等待用户确认。",
learning_signals={
"execution_state": "prepared_waiting_confirmation",
"readiness": clean_text(item.get("readiness"), limit=80),
},
)
)
return observations
class ExperimentResultService:
def observe_pending_experiments(
self,
*,
autonomy_gate: PMFlowAIAutonomyDecision,
) -> list[PMFlowAIOutcomeObservation]:
observations: list[PMFlowAIOutcomeObservation] = []
for item in list(autonomy_gate.prepared_action_packets or [])[:12]:
if not isinstance(item, dict) or item.get("action_family") != "experiment_plan":
continue
observations.append(
PMFlowAIOutcomeObservation(
outcome_type="experiment_result",
outcome_status="pending",
source_ref=clean_text(item.get("prepared_action_id"), limit=120),
target_ref=clean_text(item.get("target_ref"), limit=160),
summary=f"用户验证实验待确认:{clean_text(item.get('title'), limit=240)}",
evidence_refs=list(item.get("evidence_refs") or []),
learning_signals={
"experiment_state": "prepared_waiting_confirmation",
"action_family": item.get("action_family"),
},
)
)
return observations
class UserPreferenceLearningService:
def observe_preferences(
self,
*,
mission: PMFlowAIOperatingMission,
operating_plan: PMFlowAIOperatingPlan,
council_review: PMFlowAIAgentCouncilReview,
) -> list[PMFlowAIOutcomeObservation]:
project_ref = mission.project_scope.object_refs[0] if mission.project_scope.object_refs else ""
observations = [
PMFlowAIOutcomeObservation(
outcome_type="user_preference",
outcome_status="observed",
source_ref=mission.mission_id,
target_ref=project_ref,
summary="用户偏好体现为:需要 PMFlow AI 主动推进项目,而不是只回答问题。",
learning_signals={
"preference_key": "operating_partner_not_qa",
"preferred_behavior": "prepare_plan_with_confirmation_gate",
"risk_threshold": "do_not_execute_without_explicit_confirmation",
},
)
]
if operating_plan.priority_order:
observations.append(
PMFlowAIOutcomeObservation(
outcome_type="user_preference",
outcome_status="observed",
source_ref=str(council_review.review_id),
target_ref=project_ref,
summary="本轮计划保留单一优先级,后续运行应继续消费 priority_order 而不是散列建议。",
learning_signals={
"preference_key": "single_priority_order",
"preferred_behavior": "compile_one_ranked_plan",
"source_effectiveness_policy": "future_runs_should_reuse_evidence_types_that_survive_council_review",
},
)
)
if operating_plan.product_object_projections:
observations.append(
PMFlowAIOutcomeObservation(
outcome_type="user_preference",
outcome_status="observed",
source_ref=mission.mission_id,
target_ref=project_ref,
summary="本轮已把回答升级为经营对象候选;后续应跟踪用户是否保存、确认、修改或拒绝该对象。",
learning_signals={
"preference_key": "product_object_first",
"preferred_behavior": "project_opportunity_or_research_outputs_before_freeform_answer",
},
)
)
return observations
class LearningLedgerService:
"""Load and update durable learning from session capsules."""
def ledger_from_context(
self,
*,
conversation_context: dict[str, Any] | None,
mission: PMFlowAIOperatingMission,
) -> PMFlowAILearningLedger:
capsules = list((conversation_context or {}).get("context_capsules") or [])
project_ref = mission.project_scope.object_refs[0] if mission.project_scope.object_refs else ""
for item in reversed(capsules):
if not isinstance(item, dict) or item.get("capsule_type") != "operating_learning_ledger":
continue
ledger = item.get("learning_ledger") if isinstance(item.get("learning_ledger"), dict) else {}
if not ledger:
continue
if project_ref and ledger.get("scope_ref") not in {"", project_ref}:
continue
return PMFlowAILearningLedger.model_validate(ledger)
return PMFlowAILearningLedger(scope_ref=project_ref)
def update_ledger(
self,
*,
consumed_ledger: PMFlowAILearningLedger,
outcome_observations: list[PMFlowAIOutcomeObservation],
) -> PMFlowAILearningLedger:
decision_outcomes = list(consumed_ledger.decision_outcomes or [])
experiment_results = list(consumed_ledger.experiment_results or [])
execution_outcomes = list(consumed_ledger.execution_outcomes or [])
user_preferences = list(consumed_ledger.user_preferences or [])
learning_items = list(consumed_ledger.learning_items or [])
for observation in outcome_observations:
row = _observation_row(observation)
if observation.outcome_type == "recommendation_decision":
decision_outcomes.append(row)
elif observation.outcome_type == "experiment_result":
experiment_results.append(row)
elif observation.outcome_type == "execution_result":
execution_outcomes.append(row)
elif observation.outcome_type == "user_preference":
user_preferences.append(row)
learning_items.append(_learning_item_from_observation(observation))
return PMFlowAILearningLedger(
ledger_id=consumed_ledger.ledger_id,
scope_ref=consumed_ledger.scope_ref,
learning_items=_dedupe_rows(learning_items, key="learning_key")[-40:],
decision_outcomes=_dedupe_rows(decision_outcomes, key="source_ref")[-40:],
experiment_results=_dedupe_rows(experiment_results, key="source_ref")[-40:],
execution_outcomes=_dedupe_rows(execution_outcomes, key="source_ref")[-40:],
user_preferences=_dedupe_rows(user_preferences, key="learning_key")[-40:],
consumed_memory_refs=_dedupe_texts(
list(consumed_ledger.consumed_memory_refs or []) + [consumed_ledger.ledger_id]
)[-24:],
)
def capsule(self, *, learning_ledger: PMFlowAILearningLedger) -> dict[str, Any]:
return {
"capsule_version": "pmflow_ai_learning_ledger_capsule.v1",
"capsule_type": "operating_learning_ledger",
"scope_ref": learning_ledger.scope_ref,
"ledger_id": learning_ledger.ledger_id,
"updated_at": learning_ledger.updated_at,
"learning_ledger": learning_ledger.model_dump(mode="json", exclude_none=True),
}
class OperatingCycleService:
def __init__(self) -> None:
self.scheduler = PMFlowAIOperatingCycleScheduler()
self.observer = OutcomeObserver()
self.ledger_service = LearningLedgerService()
def build_cycle_state(
self,
*,
request: RAGAskRequest,
mission: PMFlowAIOperatingMission,
project_world_state: PMFlowAIProjectWorldStatePacket,
council_review: PMFlowAIAgentCouncilReview,
autonomy_gate: PMFlowAIAutonomyDecision,
operating_plan: PMFlowAIOperatingPlan,
conversation_context: dict[str, Any] | None,
) -> PMFlowAIOperatingCycleState:
consumed_ledger = self.ledger_service.ledger_from_context(
conversation_context=conversation_context,
mission=mission,
)
trigger = self.scheduler.build_trigger(request=request, mission=mission)
external_outcomes = _external_outcomes_from_context(conversation_context)
observations = self.observer.observe(
mission=mission,
project_world_state=project_world_state,
council_review=council_review,
autonomy_gate=autonomy_gate,
operating_plan=operating_plan,
external_outcomes=external_outcomes,
)
learning_ledger = self.ledger_service.update_ledger(
consumed_ledger=consumed_ledger,
outcome_observations=observations,
)
return PMFlowAIOperatingCycleState(
trigger=trigger,
supported_triggers=self.scheduler.supported_triggers(),
consumed_learning_ledger=consumed_ledger,
outcome_observations=observations,
learning_ledger=learning_ledger,
next_cycle_triggers=self.scheduler.next_cycle_triggers(
mission=mission,
autonomy_gate=autonomy_gate,
learning_ledger=learning_ledger,
),
)
def _external_outcomes_from_context(conversation_context: dict[str, Any] | None) -> list[dict[str, Any]]:
context = conversation_context or {}
rows = context.get("operating_outcomes")
return [dict(item) for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
def _normalize_outcome_status(value: Any) -> str:
status = clean_text(value, limit=80)
if status in {"pending", "accepted", "rejected", "succeeded", "failed", "partial", "observed"}:
return status
return "observed"
def _observation_row(observation: PMFlowAIOutcomeObservation) -> dict[str, Any]:
return {
"observation_id": observation.observation_id,
"outcome_type": observation.outcome_type,
"outcome_status": observation.outcome_status,
"source_ref": observation.source_ref,
"target_ref": observation.target_ref,
"summary": observation.summary,
"learning_signals": dict(observation.learning_signals or {}),
"observed_at": observation.observed_at,
}
def _learning_item_from_observation(observation: PMFlowAIOutcomeObservation) -> dict[str, Any]:
signals = dict(observation.learning_signals or {})
learning_key = clean_text(signals.get("preference_key") or observation.source_ref or observation.outcome_type, limit=180)
return {
"learning_key": learning_key,
"outcome_type": observation.outcome_type,
"outcome_status": observation.outcome_status,
"summary": observation.summary,
"target_ref": observation.target_ref,
"updated_at": observation.observed_at,
}
def _stable_question_ref(question: Any) -> str:
text = clean_text(question, limit=80)
if not text:
return "question:empty"
digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:12]
return f"question:{digest}"
def _dedupe_rows(rows: list[dict[str, Any]], *, key: str) -> list[dict[str, Any]]:
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
value = clean_text(row.get(key), limit=220)
if not value:
value = clean_text(row.get("summary"), limit=220)
marker = value.lower()
if not marker or marker in seen:
continue
seen.add(marker)
out.append(row)
return out
def _dedupe_texts(rows: list[str]) -> list[str]:
out: list[str] = []
for item in rows:
text = clean_text(item, limit=220)
if text and text not in out:
out.append(text)
return out
__all__ = [
"DecisionOutcomeService",
"ExecutionOutcomeService",
"ExperimentResultService",
"LearningLedgerService",
"OperatingCycleService",
"OutcomeObserver",
"PMFlowAIOperatingCycleScheduler",
"UserPreferenceLearningService",
]