"""Operating mission builder for PMFlow AI multi-agent runtime.""" from __future__ import annotations import json import re from typing import Any from ...schemas.pmflow_ai_operating_runtime import PMFlowAIOperatingMission, PMFlowAIOperatingScope from ...schemas.rag import RAGAskRequest from ..ai_function_runtime_profile_service import AIFunctionRuntimeProfileService from ..llm_service import llm_service from ..prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService from ..runtime_audit_context import bind_agent_audit_context from .agent_registry import PMFlowAIOperatingAgentRegistry PROMPT_KEY_PMFLOW_AI_OPERATING_MISSION = "PROMPT_PMFLOW_AI_OPERATING_MISSION" FUNCTION_NAME_PMFLOW_AI_OPERATING_MISSION = "pmflow_ai_operating_mission" SCENE_KEY_PMFLOW_AI_OPERATING_RUNTIME = "space_pmflow_ai_operating_runtime" class PMFlowAIOperatingMissionService: """Build a mission contract from user input and customer-safe context.""" def __init__( self, *, db: Any | None = None, tenant_id: int | None = None, generator: Any | None = None, agent_registry: PMFlowAIOperatingAgentRegistry | None = None, ) -> None: self.db = db self.tenant_id = int(tenant_id or 1) self.generator = generator or llm_service self.agent_registry = agent_registry or PMFlowAIOperatingAgentRegistry() async def build_mission( self, *, request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAIOperatingMission: question = " ".join(str(request.question or "").strip().split()) fallback = self._fallback_mission( question=question, request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) generate = getattr(self.generator, "generate", None) if not callable(generate) or not callable(getattr(self.db, "execute", None)): return fallback try: prompt_result = await self._render_prompt( question=question, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) runtime_profile = await AIFunctionRuntimeProfileService(self.db).resolve_profile( function_name=FUNCTION_NAME_PMFLOW_AI_OPERATING_MISSION, scene_key=SCENE_KEY_PMFLOW_AI_OPERATING_RUNTIME, prompt_key=PROMPT_KEY_PMFLOW_AI_OPERATING_MISSION, ) with bind_agent_audit_context(agent_step_key="operating_mission"): raw = await generate( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name=FUNCTION_NAME_PMFLOW_AI_OPERATING_MISSION, use_prompt_profile=False, temperature=runtime_profile.temperature, response_mode=runtime_profile.response_mode, max_tokens=runtime_profile.max_tokens, routing_meta={ "scene_key": SCENE_KEY_PMFLOW_AI_OPERATING_RUNTIME, "runtime": "operating_mission_runtime", "scene_contract": "operating_mission_generation", **runtime_profile.routing_meta(), "allow_low_to_high_escalation": False, "disable_thinking": True, **prompt_result.routing_meta(), }, ) if raw is None or (isinstance(raw, str) and not raw.strip()): raise ValueError("operating_mission_empty_llm_response") mission = PMFlowAIOperatingMission.model_validate( { **fallback.model_dump(mode="json", exclude_none=True), **_extract_json_object(raw), "original_user_input": question, "project_scope": fallback.project_scope.model_dump(mode="json", exclude_none=True), } ) return _repair_mission(mission=mission, fallback=fallback, registry=self.agent_registry) except Exception: return fallback def _fallback_mission( self, *, question: str, request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAIOperatingMission: project_id = _current_project_id( request=request, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) object_refs = _object_refs( project_id=project_id, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) mission_goal = _mission_goal(question=question, object_refs=object_refs) desired_outcome = _desired_outcome(object_refs=object_refs) 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=int(request.session_id) if request.session_id is not None else None, object_refs=object_refs, current_project_id=project_id, surface=_surface_name(request.surface_context), ) target_objects = _target_objects_from_refs(object_refs) unbound = not object_refs required_agents = ( [ "idea_incubation_agent", "market_research_agent", "competitor_agent", "product_strategy_agent", "growth_agent", "monetization_agent", "learning_agent", ] if unbound else [ "product_strategy_agent", "mvp_scope_agent", "pm_breakdown_agent", "tech_architect_agent", "qa_risk_agent", "growth_agent", "learning_agent", ] ) return PMFlowAIOperatingMission( mission_goal=mission_goal, stage="research" if unbound else "execution_bridge", desired_outcome=desired_outcome, desired_business_outcome=desired_outcome, original_user_input=question, project_scope=scope, target_objects=target_objects, deliverable_type="opportunity_brief" if unbound else "operating_decision", action_ladder_level="prepare_object" if unbound else "prepare_execution", success_criteria=[ "明确本轮 Operating Mission 的目标、对象范围和业务交付物。", "组织 specialist agents 产出结构化 work products,并优先形成产品对象投影。", "把可推进动作拆成 safe suggestions、prepared changes、requires confirmation 和 blocked actions。", "保留证据、边界、下一轮触发和 replay 所需运行证据。", ], autonomy_level="prepare_changes", decision_boundary=[ "Project Memory 写入、ChangeSet commit、任务变更、Execution Bridge handoff、发布、外部动作和权限变更必须经过用户确认。", "本轮只能准备经营对象、计划、执行交接包或验证动作,不直接执行写操作。", ], blocked_boundaries=[ "不得用单段回答替代需要沉淀的经营对象。", "不得在证据不足时把假设包装成已确认事实。", "不得自动写入 Project Memory、提交 ChangeSet、创建正式任务、发起 handoff 或发布。", ], required_agents=required_agents, next_cycle_policy={ "trigger_policy": "when_user_confirms_or_new_signal_arrives", "follow_up_triggers": ["user_confirmation", "market_signal", "execution_result", "experiment_result"], "memory_write_policy": "record_learning_only_until_user_confirms", }, ) async def _render_prompt( self, *, question: str, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PromptRuntimeRenderResult: variables = { "question": question[:1600], "conversation_context_json": _dump_json(_mission_safe_conversation_context(conversation_context), limit=5000), "tenant_project_context_json": _dump_json(_customer_safe_surface_context(tenant_project_context), limit=8000), "operating_mission_schema_json": _dump_json(_operating_mission_prompt_contract(), limit=5000), "specialist_contracts_json": _dump_json( [item.model_dump(mode="json", exclude_none=True) for item in self.agent_registry.list_contracts()], limit=12000, ), } return await PromptRuntimeService(self.db).render( tenant_id=self.tenant_id, prompt_key=PROMPT_KEY_PMFLOW_AI_OPERATING_MISSION, **variables, ) def _mission_goal(*, question: str, object_refs: list[str]) -> str: if object_refs: return f"围绕 {', '.join(object_refs[:3])} 推进用户本轮目标:{question or '继续推进当前项目'}" return f"把用户输入转成可推进的 PMFlow operating mission:{question or '继续推进当前空间'}" def _operating_mission_prompt_contract() -> dict[str, Any]: schema = PMFlowAIOperatingMission.model_json_schema() properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} def _field(name: str) -> dict[str, Any]: item = properties.get(name) if isinstance(properties.get(name), dict) else {} result: dict[str, Any] = {"type": item.get("type") or "value"} enum_values = item.get("enum") if isinstance(item.get("enum"), list) else [] if enum_values: result["allowed_values"] = enum_values return result return { "source": "PMFlowAIOperatingMission schema summary", "fields": { "mission_goal": _field("mission_goal"), "stage": _field("stage"), "desired_outcome": _field("desired_outcome"), "desired_business_outcome": _field("desired_business_outcome"), "target_objects": {"type": "array", "item": "customer-safe business object reference"}, "deliverable_type": _field("deliverable_type"), "action_ladder_level": _field("action_ladder_level"), "required_agents": {"type": "array", "item": "agent_id selected from specialist_contracts"}, "success_criteria": _field("success_criteria"), "blocked_boundaries": _field("blocked_boundaries"), "decision_boundary": _field("decision_boundary"), "next_cycle_policy": _field("next_cycle_policy"), }, } def _desired_outcome(*, object_refs: list[str]) -> str: if object_refs: return "形成面向当前项目对象的经营判断、专业 agent 产物、可确认操作包和下一轮推进计划。" return "形成面向当前空间的经营判断、专业 agent 产物、可确认操作包和下一轮推进计划。" def _target_objects_from_refs(object_refs: list[str]) -> list[dict[str, Any]]: return [ { "object_ref": ref, "object_type": ref.split(":", 1)[0] if ":" in ref else "business_object", "title": ref, "binding_strength": "operating_mission", } for ref in object_refs[:12] ] def _surface_name(surface_context: dict[str, Any] | None) -> str: surface = surface_context if isinstance(surface_context, dict) else {} for key in ("surface", "surface_name", "entry", "page"): text = str(surface.get(key) or "").strip() if text: return text[:120] return "space" def _current_project_id( *, request: RAGAskRequest, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> int | None: for value in ( getattr(request, "project_id", None), (conversation_context or {}).get("current_project_id"), _project_id_from_surface(tenant_project_context), ): try: if value is not None and str(value).strip().isdigit(): return int(value) except Exception: continue return None def _project_id_from_surface(tenant_project_context: dict[str, Any] | None) -> int | None: surface = (tenant_project_context or {}).get("surface_context") if not isinstance(surface, dict): return None focus = surface.get("current_focus_object") if isinstance(focus, dict): for key in ("object_id", "project_id", "id"): value = focus.get(key) if value is not None and str(value).strip().isdigit(): return int(value) ref = str(focus.get("object_ref") or "").strip() prefix, _, raw_id = ref.partition(":") if prefix == "project" and raw_id.strip().isdigit(): return int(raw_id) return None def _object_refs( *, project_id: int | None, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> list[str]: refs: list[str] = [] if project_id is not None: refs.append(f"project:{int(project_id)}") source = (conversation_context or {}).get("object_ledger") if isinstance(source, list): for item in source[:12]: if not isinstance(item, dict): continue ref = str(item.get("object_ref") or "").strip() binding_strength = str(item.get("binding_strength") or "").strip() if ref and binding_strength in {"explicit", "session_object_ledger", "current_focus"} and ref not in refs: refs.append(ref[:160]) return refs[:12] def _repair_mission( *, mission: PMFlowAIOperatingMission, fallback: PMFlowAIOperatingMission, registry: PMFlowAIOperatingAgentRegistry, ) -> PMFlowAIOperatingMission: contracts = {item.agent_id for item in registry.list_contracts()} required_agents = [item for item in mission.required_agents if item in contracts] if not required_agents: required_agents = list(fallback.required_agents) decision_boundary = [*list(mission.decision_boundary or []), *list(fallback.decision_boundary or [])] blocked_boundaries = [*list(mission.blocked_boundaries or []), *list(fallback.blocked_boundaries or [])] return mission.model_copy( update={ "mission_goal": mission.mission_goal or fallback.mission_goal, "desired_outcome": mission.desired_outcome or fallback.desired_outcome, "desired_business_outcome": mission.desired_business_outcome or mission.desired_outcome or fallback.desired_business_outcome, "target_objects": mission.target_objects or fallback.target_objects, "required_agents": required_agents[:12], "success_criteria": list(mission.success_criteria or fallback.success_criteria)[:16], "decision_boundary": _dedupe(decision_boundary)[:16], "blocked_boundaries": _dedupe(blocked_boundaries)[:16], "next_cycle_policy": mission.next_cycle_policy or fallback.next_cycle_policy, } ) def _extract_json_object(raw: Any) -> dict[str, Any]: if isinstance(raw, dict): return raw text = str(raw or "").strip() if not text: return {} try: parsed = json.loads(text) return parsed if isinstance(parsed, dict) else {} except Exception: pass match = re.search(r"\{.*\}", text, flags=re.S) if not match: return {} parsed = json.loads(match.group(0)) return parsed if isinstance(parsed, dict) else {} def _dump_json(value: Any, *, limit: int) -> str: return json.dumps(value, ensure_ascii=False, default=str)[:limit] def _customer_safe_surface_context(tenant_project_context: dict[str, Any] | None) -> dict[str, Any]: context = dict(tenant_project_context or {}) surface = context.get("surface_context") return {"surface_context": surface if isinstance(surface, dict) else {}} def _mission_safe_conversation_context(conversation_context: dict[str, Any] | None) -> dict[str, Any]: context = conversation_context if isinstance(conversation_context, dict) else {} safe = {} for key in ("session_id", "conversation_scope", "current_project_id", "surface", "source_channel"): value = context.get(key) if value not in (None, "", [], {}): safe[key] = value capsules = [] for item in list(context.get("context_capsules") or [])[:8]: if isinstance(item, dict): capsule_type = str(item.get("capsule_type") or item.get("type") or "").strip() if capsule_type in {"pmflow_ai_object_ledger", "object_ledger", "session_object_ledger"}: capsules.append( { "capsule_type": capsule_type, "current_focus_object": item.get("current_focus_object"), "recent_objects": list(item.get("recent_objects") or [])[:4], } ) if capsules: safe["context_capsules"] = capsules return safe def _dedupe(rows: list[str]) -> list[str]: out: list[str] = [] seen: set[str] = set() for row in rows: text = " ".join(str(row or "").strip().split()) key = text.lower() if text and key not in seen: seen.add(key) out.append(text) return out __all__ = ["PMFlowAIOperatingMissionService", "PROMPT_KEY_PMFLOW_AI_OPERATING_MISSION"]