545 lines
22 KiB
Python
545 lines
22 KiB
Python
"""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",
|
||
]
|