"""Bounded agentic runtime for one Space PMFlow AI turn. This service owns the turn loop. Older planners, task-graph services, preemptive analyzers and presentation are now bounded tools or renderers from the perspective of the Space API. """ from __future__ import annotations import json import time from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from ..models import User from ..schemas.customer_answer import CustomerAnswerProcessPacket, CustomerAnswerProcessStep, CustomerEvidencePacket from ..schemas.pmflow_ai_turn_runtime import ( PMFlowAIAnswerDraft, PMFlowAIAnswerIntentPlan, PMFlowAIAnswerObjective, PMFlowAIAnswerPlan, PMFlowAIAnswerQualityFinding, PMFlowAIAnswerState, PMFlowAIAttempt, PMFlowAIBoundary, PMFlowAIClaim, PMFlowAIEvidenceGap, PMFlowAIGoalFrame, PMFlowAIInterpretationBranch, PMFlowAIInterpretationState, PMFlowAIJudgementState, PMFlowAIObservation, PMFlowAIProblemFrame, PMFlowAIRuntimeAction, PMFlowAIRuntimeStep, PMFlowAITargetCollection, PMFlowAITargetScope, PMFlowAITerminalAnswerState, PMFlowAIThreadWorkspace, PMFlowAISufficiencyDecision, PMFlowAISufficiencyState, PMFlowAITurnDelta, PMFlowAITurnActionState, PMFlowAITurnEvidenceState, PMFlowAITurnGoal, PMFlowAITurnObjectRef, PMFlowAITurnObjectState, PMFlowAITurnState, PMFlowAIVerificationFailure, ProblemDecisionType, RuntimeActionKind, ) from ..schemas.project_operating_evidence import ProjectOperatingEvidencePacket, project_operating_facets from ..schemas.rag import RAGAskRequest from .pmflow_ai_answer_renderer_registry import PMFlowAIAnswerRendererCandidate, PMFlowAIAnswerRendererRegistry from .pmflow_ai_answer_verifier_registry import PMFlowAIAnswerVerifierRegistry from .customer_answer_presentation_service import CustomerAnswerPresentationService from .llm_service import LLMService from .pmflow_ai_evidence_coverage_gate import PMFlowAIEvidenceCoverageGate from .pmflow_ai_evidence_authority import PMFlowAIEvidenceAuthority from .pmflow_ai_evidence_absorber import PMFlowAIEvidenceAbsorber from .pmflow_ai_evidence_tool_registry import PMFlowAIEvidenceToolRegistry from .pmflow_ai_static_contract_registry import PMFlowAIStaticContract, PMFlowAIStaticContractRegistry from .pmflow_ai_turn_contract import ( CANONICAL_RUNTIME_ACTIONS, canonical_action_hint_for_gap, normalize_turn_hypothesis_contract, validate_turn_state_contract, ) from .pmflow_ai_turn_object_ledger import ledger_focus_as_turn_object from .pmflow_ai_turn_state_reviser import PMFlowAITurnStateReviser from .prompt_runtime_service import PromptRuntimeService TURN_GOAL_STATE_ASSESSOR_PROMPT_KEY = "PROMPT_PMFLOW_AI_TURN_GOAL_STATE_ASSESSOR" TURN_STATE_ANSWER_SYNTHESIZER_PROMPT_KEY = "PROMPT_PMFLOW_AI_TURN_STATE_ANSWER_SYNTHESIZER" class PMFlowAITurnRuntimeService: """Run one PMFlow AI turn through a bounded action loop.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id or 1) async def run_space_turn( self, *, request: RAGAskRequest, current_user: User, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any], presentation_service: CustomerAnswerPresentationService | None = None, started_at: float | None = None, ) -> dict[str, Any]: started = started_at if isinstance(started_at, (int, float)) else time.perf_counter() presentation = presentation_service or CustomerAnswerPresentationService(self.db, tenant_id=self.tenant_id) reviser = PMFlowAITurnStateReviser() turn_hypothesis = await self._infer_turn_hypothesis( request=request, current_user=current_user, tenant_project_context=tenant_project_context, conversation_context=conversation_context or {}, ) planner_available = False route_plan_dict = _neutral_route_hypothesis(request=request, turn_hypothesis=turn_hypothesis) turn_state = self.build_initial_turn_state( request=request, route_plan=route_plan_dict, conversation_context=conversation_context or {}, turn_hypothesis=turn_hypothesis, ) turn_state = reviser.initialize_working_state(turn_state=turn_state, hypothesis=turn_hypothesis) effective_route_plan_dict = dict(turn_state.route_plan_input or route_plan_dict) payload: dict[str, Any] | None = None for _step in range(turn_state.step_budget): decision = self.assess_sufficiency(turn_state) if decision.must_ask_user: verification_failure = self._verify_user_interaction_allowed(turn_state=turn_state) if verification_failure is not None: reviser.revise_from_verification(turn_state=turn_state, failure=verification_failure) turn_state.sufficiency_state.needs_user_input = False turn_state.sufficiency_state.can_answer_with_assumptions = True continue payload = await self.run_action( action=PMFlowAIRuntimeAction( action_id="ask_clarification", action_type="ask_clarification", status="candidate", reason=decision.reason, tool_name="clarification_builder", ), request=request, current_user=current_user, route_plan=effective_route_plan_dict, conversation_context=conversation_context or {}, presentation_service=presentation, planner_available=planner_available, started_at=started, turn_state=turn_state, ) observation = reviser.observe_payload( turn_state=turn_state, action_type="ask_clarification", payload=payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=observation) break if decision.can_answer_now: synthesize_action = PMFlowAIRuntimeAction( action_id="synthesize_answer", action_type="synthesize_answer", status="candidate", reason=decision.reason or "已有证据足够进入最终表达。", tool_name="turn_state_answer_synthesizer", ) payload = await self.run_action( action=synthesize_action, request=request, current_user=current_user, route_plan=effective_route_plan_dict, conversation_context=conversation_context or {}, presentation_service=presentation, planner_available=planner_available, started_at=started, turn_state=turn_state, ) self.absorb_evidence(turn_state=turn_state, action=synthesize_action, payload=payload, started_at=started) observation = reviser.observe_payload( turn_state=turn_state, action_type=synthesize_action.action_type, payload=payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=observation) verification_failure = self._verify_runtime_payload(turn_state=turn_state, payload=payload) if verification_failure is not None: reviser.revise_from_verification(turn_state=turn_state, failure=verification_failure) if not _verification_failure_allows_retry(verification_failure): payload = _build_verification_blocked_payload( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) break payload = None continue break action = self.select_next_minimal_action(turn_state) if action is None: break payload = await self.run_action( action=action, request=request, current_user=current_user, route_plan=effective_route_plan_dict, conversation_context=conversation_context or {}, presentation_service=presentation, planner_available=planner_available, started_at=started, turn_state=turn_state, ) self.absorb_evidence(turn_state=turn_state, action=action, payload=payload, started_at=started) observation = reviser.observe_payload( turn_state=turn_state, action_type=action.action_type, payload=payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=observation) if _payload_is_final_answer(payload): verification_failure = self._verify_runtime_payload(turn_state=turn_state, payload=payload) if verification_failure is not None: reviser.revise_from_verification(turn_state=turn_state, failure=verification_failure) if not _verification_failure_allows_retry(verification_failure): payload = _build_verification_blocked_payload( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) break payload = None continue break if payload is None: fallback_action = PMFlowAIRuntimeAction( action_id="synthesize_answer", action_type="synthesize_answer", status="candidate", reason="Action loop 未得到可返回 payload,按当前 TurnState 显式合成受控回答。", tool_name="turn_state_answer_synthesizer", ) payload = await self._run_state_answer_synthesis( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) self.absorb_evidence(turn_state=turn_state, action=fallback_action, payload=payload, started_at=started) observation = reviser.observe_payload( turn_state=turn_state, action_type=fallback_action.action_type, payload=payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=observation) verification_failure = self._verify_runtime_payload(turn_state=turn_state, payload=payload) if verification_failure is not None: reviser.revise_from_verification(turn_state=turn_state, failure=verification_failure) if not _verification_failure_allows_retry(verification_failure): payload = _build_verification_blocked_payload( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) else: payload = None recovery_action = self.select_next_minimal_action(turn_state) if payload is None and recovery_action is not None and recovery_action.action_type not in {"ask_clarification", "synthesize_answer"}: recovery_payload = await self.run_action( action=recovery_action, request=request, current_user=current_user, route_plan=effective_route_plan_dict, conversation_context=conversation_context or {}, presentation_service=presentation, planner_available=planner_available, started_at=started, turn_state=turn_state, ) self.absorb_evidence(turn_state=turn_state, action=recovery_action, payload=recovery_payload, started_at=started) recovery_observation = reviser.observe_payload( turn_state=turn_state, action_type=recovery_action.action_type, payload=recovery_payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=recovery_observation) payload = await self._run_state_answer_synthesis( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) self.absorb_evidence(turn_state=turn_state, action=fallback_action, payload=payload, started_at=started) final_observation = reviser.observe_payload( turn_state=turn_state, action_type=fallback_action.action_type, payload=payload, ) reviser.revise_from_observation(turn_state=turn_state, observation=final_observation) final_failure = self._verify_runtime_payload(turn_state=turn_state, payload=payload) if final_failure is not None: reviser.revise_from_verification(turn_state=turn_state, failure=final_failure) if not _verification_failure_allows_retry(final_failure): payload = _build_verification_blocked_payload( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) else: payload = None if payload is None: payload = _build_verification_blocked_payload( request=request, current_user=current_user, route_plan=effective_route_plan_dict, turn_state=turn_state, presentation_service=presentation, started_at=started, ) payload = dict(payload or {}) payload.setdefault("pmflow_ai_route_plan", effective_route_plan_dict if planner_available else {}) terminal_state = _build_terminal_answer_state( request=request, turn_state=turn_state, answer=str(payload.get("answer") or payload.get("reply_text") or ""), ) turn_state.terminal_answer_state = terminal_state payload["terminal_answer_state"] = terminal_state.model_dump(mode="json", exclude_none=True) payload["pmflow_ai_turn_state"] = self._customer_turn_state_snapshot(turn_state) payload["answer_process_packet"] = self._build_runtime_process_packet( turn_state=turn_state, payload=payload, total_duration_ms=int((time.perf_counter() - started) * 1000), ) payload["turn_presentation_contract"] = build_turn_presentation_contract_from_runtime_payload( payload=payload, turn_state=turn_state, ) payload["evidence_findings"] = [ item.model_dump(mode="json", exclude_none=True) for item in list(turn_state.evidence_state.known_facts or [])[:12] ] payload["processing_stage"] = str(payload.get("processing_stage") or "turn_runtime") payload["answer_mode"] = str(payload.get("answer_mode") or "turn_runtime") payload["policy_version"] = _append_policy_version(payload.get("policy_version"), "pmflow-turn-runtime-v1") return payload async def _infer_turn_hypothesis( self, *, request: RAGAskRequest, current_user: User, tenant_project_context: dict[str, Any], conversation_context: dict[str, Any], ) -> dict[str, Any]: surface = _dict(request.surface_context) runtime_context = _dict(surface.get("space_runtime_context")) workbench = _dict(runtime_context.get("workbench")) visible_objects: list[dict[str, Any]] = [] for item in list(workbench.get("projects") or workbench.get("recent_projects") or [])[:8]: if isinstance(item, dict): visible_objects.append( { "object_type": "project", "object_ref": str(item.get("object_ref") or item.get("objectRef") or item.get("id") or ""), "title": str(item.get("title") or item.get("name") or "")[:160], "status": str(item.get("status") or "")[:80], } ) try: prompt_result = await PromptRuntimeService(self.db).render( tenant_id=self.tenant_id, prompt_key=TURN_GOAL_STATE_ASSESSOR_PROMPT_KEY, question=str(request.question or "").strip()[:1200], project_id=str(request.project_id or ""), conversation_context=_json_preview(conversation_context, limit=1600), tenant_project_context=_json_preview(tenant_project_context, limit=1600), visible_context_objects=_json_preview(visible_objects, limit=1200), ) routing_meta = prompt_result.routing_meta() routing_meta.update( { "scene_key": "pmflow_ai_turn_runtime", "scene_contract": "turn_state_goal_assessment", "tier_alias": "low", "response_contract": "json_object", } ) raw = await LLMService().generate( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name="pmflow_ai_turn_goal_state_assessor", temperature=0.0, max_tokens=1800, use_prompt_profile=False, routing_meta=routing_meta, ) parsed = _parse_json_object(raw) if parsed: parsed["hypothesis_origin"] = "turn_state_goal_assessor" return normalize_turn_hypothesis_contract(parsed) except Exception: pass _ = current_user return normalize_turn_hypothesis_contract({ "hypothesis_origin": "fallback_neutral", "normalized_goal": str(request.question or "").strip()[:160] or "回答当前问题", "desired_outcome": "useful_answer", "answer_shape": "general_answer", "subject": "", "subject_terms": [], "needs_user_input": False, "can_answer_with_assumptions": True, "freshness_required": False, "primary_object": {"object_type": "unknown", "object_ref": "", "title": "", "binding_strength": "none"}, "goal_frames": [ { "frame_id": "fallback_general_answer", "operation_kind": "answer_general", "target_scope": {"scope_type": "current_space", "binding_strength": "surface", "source": "fallback"}, "target_object_requirement": "none", "answer_shape": "general_answer", "confidence": 0.3, } ], "operation_gaps": [], "success_criteria": ["回答当前问题", "说明依据和下一步"], }) def build_initial_turn_state( self, *, request: RAGAskRequest, route_plan: dict[str, Any], conversation_context: dict[str, Any] | None = None, turn_hypothesis: dict[str, Any] | None = None, ) -> PMFlowAITurnState: hypothesis = normalize_turn_hypothesis_contract(_dict(turn_hypothesis)) action_route_plan = dict(route_plan or {}) answer_shape = _coerce_answer_shape(_answer_shape_from_goal_frames(hypothesis) or hypothesis.get("answer_shape")) turn_delta = _turn_delta_from_surface_context(request.surface_context) thread_workspace = _thread_workspace_from_context( conversation_context=conversation_context or {}, route_plan=route_plan, request=request, turn_delta=turn_delta, turn_hypothesis=hypothesis, ) primary_object = _primary_object_from_hypothesis( hypothesis=hypothesis, request=request, thread_workspace=thread_workspace, conversation_context=conversation_context or {}, ) if thread_workspace and thread_workspace.primary_object: primary_object = thread_workspace.primary_object context_objects = _context_objects_from_surface(request.surface_context) gaps = _evidence_gaps_from_operation_gap_payload(hypothesis) if _legacy_needs_user_input_requires_blocker(hypothesis=hypothesis, answer_shape=answer_shape): gaps.append( PMFlowAIEvidenceGap( key="blocking_user_input", label="需要用户确认阻塞信息", severity="blocking", gap_type="user_clarification_gap", customer_question="请补充继续判断必须由你确认的信息。", reason=str(hypothesis.get("reason") or "turn_state_goal_assessor_requested_clarification"), ) ) problem_frame = _build_problem_frame_from_hypothesis( hypothesis=hypothesis, request=request, primary_object=primary_object, thread_workspace=thread_workspace, ) interpretation_state = _build_interpretation_state_from_hypothesis( hypothesis=hypothesis, problem_frame=problem_frame, ) can_answer_with_assumptions = bool(hypothesis.get("can_answer_with_assumptions")) needs_user_input = ( _legacy_needs_user_input_requires_blocker(hypothesis=hypothesis, answer_shape=answer_shape) or _operation_gaps_payload_has_actionable_blocker(hypothesis) ) and not can_answer_with_assumptions needs_internal, needs_external = _evidence_flags_from_working_contract( hypothesis=hypothesis, ) state = PMFlowAITurnState( turn_goal=PMFlowAITurnGoal( user_goal=str(hypothesis.get("normalized_goal") or request.question or "answer_open_question"), desired_outcome=str(hypothesis.get("desired_outcome") or "useful_answer"), user_posture=str(request.consultant_mode_preference or "ask"), success_criteria=_success_criteria_from_hypothesis(hypothesis=hypothesis, answer_shape=answer_shape), ), object_state=PMFlowAITurnObjectState( primary_object=primary_object, context_objects=context_objects, forbidden_substitution=bool(thread_workspace and thread_workspace.forbidden_substitution), workspace_focus=(thread_workspace.model_dump(mode="json", exclude_none=True) if thread_workspace else {}), ), problem_frame=problem_frame, interpretation_state=interpretation_state, evidence_state=PMFlowAITurnEvidenceState( evidence_gaps=gaps, evidence_quality="unknown", stale_or_missing=[], ), judgement_state=PMFlowAIJudgementState(confidence="unknown"), action_state=PMFlowAITurnActionState(), sufficiency_state=PMFlowAISufficiencyState( can_answer_now=False, can_answer_with_assumptions=can_answer_with_assumptions, needs_user_input=needs_user_input, needs_internal_evidence=needs_internal, needs_external_evidence=needs_external, needs_confirmation_before_write=False, reason=str(hypothesis.get("reason") or ""), ), answer_state=PMFlowAIAnswerState( answer_intent=str(hypothesis.get("desired_outcome") or "answer"), answer_shape=answer_shape, required_sections=_required_sections(answer_shape), prohibited_content=["内部路由", "prompt key", "trace", "模型供应商", "数据库表", "自动写入结果"], citation_requirements=["外部事实需要来源", "内部项目判断需要对象证据"], ), runtime_steps=[ PMFlowAIRuntimeStep( key="understand_goal", label=_initial_goal_step_label( hypothesis=hypothesis, thread_workspace=thread_workspace, turn_delta=turn_delta, ), action_type="inspect_surface_context", status="completed", ) ], route_plan_input=_route_summary(action_route_plan), legacy_runtime_decision={"runtime_owner": "turn_state_action_loop", "route_hypothesis_only": True, "turn_hypothesis": hypothesis}, max_steps=3, thread_workspace=thread_workspace, turn_delta=turn_delta, ) PMFlowAITurnStateReviser().initialize_working_state(turn_state=state, hypothesis=hypothesis) _apply_source_authority_process_label(state) self.refresh_action_state_from_working_state(state) _record_contract_issues(state) return state def assess_sufficiency(self, turn_state: PMFlowAITurnState) -> PMFlowAISufficiencyDecision: self.refresh_action_state_from_working_state(turn_state) authority = PMFlowAIEvidenceAuthority().decide(turn_state) if authority.needs_probe and authority.required_first_action is not None: turn_state.sufficiency_state.can_answer_now = False turn_state.sufficiency_state.needs_internal_evidence = True turn_state.sufficiency_state.reason = authority.reason return PMFlowAISufficiencyDecision( needs_action=True, next_action_type=authority.required_first_action, reason=authority.reason, ) if authority.can_answer_without_tool: turn_state.sufficiency_state.can_answer_now = True turn_state.sufficiency_state.needs_internal_evidence = False turn_state.sufficiency_state.needs_external_evidence = False turn_state.sufficiency_state.needs_user_input = False turn_state.sufficiency_state.reason = authority.reason return PMFlowAISufficiencyDecision( can_answer_now=True, reason=authority.reason, ) coverage = PMFlowAIEvidenceCoverageGate().evaluate(turn_state) if coverage.needs_probe: if coverage.next_action_type is not None and _action_performed(turn_state, coverage.next_action_type): turn_state.sufficiency_state.can_answer_now = True else: turn_state.sufficiency_state.can_answer_now = False turn_state.sufficiency_state.needs_internal_evidence = True turn_state.sufficiency_state.reason = coverage.reason return PMFlowAISufficiencyDecision( needs_action=True, next_action_type=coverage.next_action_type, reason=coverage.reason, ) pending_probe = self._select_pending_executable_probe(turn_state) if turn_state.sufficiency_state.can_answer_now and pending_probe is not None: turn_state.sufficiency_state.can_answer_now = False turn_state.sufficiency_state.reason = pending_probe.reason return PMFlowAISufficiencyDecision( needs_action=True, next_action_type=pending_probe.action_type, reason=pending_probe.reason, ) if ( turn_state.sufficiency_state.needs_user_input and not turn_state.sufficiency_state.can_answer_with_assumptions and not _has_executable_system_gap(turn_state) ): return PMFlowAISufficiencyDecision( must_ask_user=True, can_answer_now=False, needs_action=False, next_action_type="ask_clarification", reason=turn_state.sufficiency_state.reason or "需要用户补充阻塞信息。", ) if turn_state.sufficiency_state.can_answer_now: return PMFlowAISufficiencyDecision( can_answer_now=True, reason=turn_state.sufficiency_state.reason or "WorkingState observation 已满足当前目标。", ) candidate = self.select_next_minimal_action(turn_state) if candidate is not None: return PMFlowAISufficiencyDecision( needs_action=True, next_action_type=candidate.action_type, reason=candidate.reason, ) return PMFlowAISufficiencyDecision( can_answer_now=True, reason="已有证据足够进入最终表达。", ) def choose_next_action(self, turn_state: PMFlowAITurnState) -> PMFlowAIRuntimeAction | None: return self.select_next_minimal_action(turn_state) def refresh_action_state_from_working_state(self, turn_state: PMFlowAITurnState) -> PMFlowAITurnState: actions = _runtime_actions_from_working_state(turn_state) turn_state.action_state.candidate_actions = actions turn_state.action_state.next_best_action = next( (action for action in actions if not _action_performed(turn_state, action.action_type)), None, ) return turn_state def select_next_minimal_action(self, turn_state: PMFlowAITurnState) -> PMFlowAIRuntimeAction | None: self.refresh_action_state_from_working_state(turn_state) authority = PMFlowAIEvidenceAuthority().decide(turn_state) if authority.needs_probe and authority.required_first_action is not None: action = PMFlowAIRuntimeAction( action_id=authority.required_first_action, action_type=authority.required_first_action, status="candidate", reason=authority.reason, tool_name=_tool_name_for_action(authority.required_first_action), ) if _action_performed(turn_state, action.action_type): return None turn_state.action_state.candidate_actions = [ candidate for candidate in list(turn_state.action_state.candidate_actions or []) if candidate.action_type not in authority.forbidden_first_actions ] if not any(candidate.action_type == action.action_type for candidate in turn_state.action_state.candidate_actions): turn_state.action_state.candidate_actions.insert(0, action) turn_state.action_state.next_best_action = action return action if authority.can_answer_without_tool: action = PMFlowAIRuntimeAction( action_id="synthesize_answer", action_type="synthesize_answer", status="candidate", reason=authority.reason, tool_name="turn_state_answer_synthesizer", ) turn_state.action_state.candidate_actions = [action] turn_state.action_state.next_best_action = None if _action_performed(turn_state, "synthesize_answer") else action return turn_state.action_state.next_best_action coverage = PMFlowAIEvidenceCoverageGate().evaluate(turn_state) if coverage.needs_probe and coverage.next_action_type is not None: action = PMFlowAIRuntimeAction( action_id=coverage.next_action_type, action_type=coverage.next_action_type, status="candidate", reason=coverage.reason, tool_name=_tool_name_for_action(coverage.next_action_type), ) if _action_performed(turn_state, action.action_type): return None if not any(candidate.action_type == action.action_type for candidate in turn_state.action_state.candidate_actions): turn_state.action_state.candidate_actions.insert(0, action) turn_state.action_state.next_best_action = action return action selected = turn_state.selected_frame if selected is not None: for action in list(turn_state.action_state.candidate_actions or []): if _action_performed(turn_state, action.action_type): continue if _action_matches_selected_frame(action=action, turn_state=turn_state): turn_state.action_state.next_best_action = action return action for candidate in list(turn_state.action_state.candidate_actions or []): if not _action_performed(turn_state, candidate.action_type): turn_state.action_state.next_best_action = candidate return candidate return None def _select_pending_executable_probe(self, turn_state: PMFlowAITurnState) -> PMFlowAIRuntimeAction | None: for action in list(turn_state.action_state.candidate_actions or []): if action.action_type in {"ask_clarification", "synthesize_answer"}: continue if _action_performed(turn_state, action.action_type): continue if not _action_matches_selected_frame(action=action, turn_state=turn_state): continue if _action_is_required_by_unresolved_system_gap(action=action, turn_state=turn_state): return action return None def _verify_user_interaction_allowed(self, *, turn_state: PMFlowAITurnState) -> PMFlowAIVerificationFailure | None: if _has_actionable_user_gap(turn_state): return None return _verification_failure( failure_type="interaction_not_user_actionable", reason="Runtime attempted to ask the user from a system evidence gap.", state_patch={"must_ask_user": False, "can_answer_now": True}, next_action_hint="select_minimal_read_or_render_action", ) def _verify_runtime_payload(self, *, turn_state: PMFlowAITurnState, payload: dict[str, Any] | None) -> PMFlowAIVerificationFailure | None: return PMFlowAIAnswerVerifierRegistry().verify_payload(turn_state=turn_state, payload=payload) async def run_action( self, *, action: PMFlowAIRuntimeAction, request: RAGAskRequest, current_user: User, route_plan: dict[str, Any], conversation_context: dict[str, Any], presentation_service: CustomerAnswerPresentationService, planner_available: bool, started_at: float, turn_state: PMFlowAITurnState, ) -> dict[str, Any] | None: elapsed = int((time.perf_counter() - started_at) * 1000) if action.action_type == "ask_clarification": payload = _build_thread_workspace_clarification_payload(turn_state) if not payload: payload = _build_generic_clarification_payload(turn_state) payload["processing_stage"] = "turn_runtime_clarification" workspace_payload = _workspace_payload_from_turn_state(turn_state) if workspace_payload: payload["research_workspace"] = workspace_payload return payload if action.action_type == "inspect_surface_context": _ = presentation_service, route_plan, elapsed return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, ) if action.action_type == "inspect_saved_ideas": return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, ) if action.action_type == "inspect_internal_projects": return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, route_plan=route_plan, started_at=started_at, ) if action.action_type == "resolve_project_objects": return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, route_plan=route_plan, started_at=started_at, ) if action.action_type == "inspect_project_status": return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, route_plan=route_plan, started_at=started_at, ) if action.action_type in {"external_search", "external_fetch"}: return await PMFlowAIEvidenceToolRegistry(self.db, tenant_id=self.tenant_id).run( action_type=action.action_type, request=request, current_user=current_user, turn_state=turn_state, conversation_context=conversation_context, route_plan=route_plan, started_at=started_at, ) if action.action_type == "synthesize_answer": return await self._run_state_answer_synthesis( request=request, current_user=current_user, route_plan=route_plan, turn_state=turn_state, presentation_service=presentation_service, started_at=started_at, ) return None def absorb_evidence( self, *, turn_state: PMFlowAITurnState, action: PMFlowAIRuntimeAction, payload: dict[str, Any] | None, started_at: float, ) -> PMFlowAITurnState: result_summary = _payload_summary(payload) performed = action.model_copy( update={ "status": "performed" if payload is not None else "skipped", "result_summary": result_summary or ("未命中可用证据" if payload is None else "完成"), } ) if payload is None: turn_state.action_state.blocked_actions.append(performed.model_copy(update={"status": "skipped"})) else: turn_state.action_state.performed_actions.append(performed) absorption = PMFlowAIEvidenceAbsorber().absorb( turn_state=turn_state, action=action, payload=payload, ) findings = absorption.findings turn_state.evidence_state.known_facts.extend(findings) turn_state.evidence_state.structured_evidence = _merge_structured_evidence( turn_state.evidence_state.structured_evidence, absorption.structured_evidence, ) turn_state.evidence_state.sources.extend(absorption.sources) turn_state.evidence_state.stale_or_missing.extend(absorption.remaining_gaps) turn_state.evidence_state.evidence_quality = absorption.evidence_quality turn_state.judgement_state = absorption.judgement_state turn_state.runtime_steps.append( PMFlowAIRuntimeStep( key=performed.action_id, label=_runtime_step_label(action=performed, turn_state=turn_state), action_type=performed.action_type, status="completed" if payload is not None else "skipped", duration_ms=max(0, int((time.perf_counter() - started_at) * 1000)), evidence_delta=findings[:3], blocking=performed.action_type == "ask_clarification", ) ) if payload is not None: has_evidence = bool(findings or absorption.sources or _payload_is_final_answer(payload)) turn_state.completed = bool(_payload_is_final_answer(payload)) if _payload_is_final_answer(payload): turn_state.sufficiency_state.can_answer_now = True if has_evidence and action.action_type in {"inspect_surface_context", "inspect_internal_projects", "inspect_project_status", "resolve_project_objects"}: turn_state.sufficiency_state.needs_internal_evidence = False if has_evidence and action.action_type in {"external_search", "external_fetch"}: turn_state.sufficiency_state.needs_external_evidence = False return turn_state async def _run_state_answer_synthesis( self, *, request: RAGAskRequest, current_user: User, route_plan: dict[str, Any], turn_state: PMFlowAITurnState, presentation_service: CustomerAnswerPresentationService, started_at: float, ) -> dict[str, Any]: _refresh_answer_runtime_plan(request=request, turn_state=turn_state) answer = await self._render_answer_from_registry(request=request, turn_state=turn_state) turn_state.answer_draft = PMFlowAIAnswerDraft( draft_id="draft_final_candidate", source_renderer=_answer_plan_primary_source(turn_state), answer_text=answer, plan_id=turn_state.answer_plan.plan_id or "answer_plan", ) project_operating_verification = _project_operating_answer_verification_packet(turn_state) if project_operating_verification: answer = _apply_project_operating_answer_verification_to_answer( answer=answer, verification=project_operating_verification, ) turn_state.answer_draft = PMFlowAIAnswerDraft( draft_id="draft_with_project_operating_verification", source_renderer=_answer_plan_primary_source(turn_state), answer_text=answer, plan_id=turn_state.answer_plan.plan_id or "answer_plan", ) answer = _apply_answer_quality_gate(request=request, turn_state=turn_state, answer=answer) turn_state.answer_draft = PMFlowAIAnswerDraft( draft_id="draft_quality_checked", source_renderer=_answer_plan_primary_source(turn_state), answer_text=answer, plan_id=turn_state.answer_plan.plan_id or "answer_plan", ) packet = _build_state_customer_packet( request=request, current_user=current_user, route_plan=route_plan, turn_state=turn_state, answer=answer, ) if project_operating_verification: packet = _apply_project_operating_answer_verification_to_packet( packet=packet, verification=project_operating_verification, ) has_external_source = _has_external_source(turn_state.evidence_state.sources) authority = PMFlowAIEvidenceAuthority().decide(turn_state) payload = { "_runtime_payload_kind": "final_answer", "answer": answer, "reply_text": answer, "sources": list(turn_state.evidence_state.sources or []), "sources_used": _sources_used_from_turn_sources(turn_state.evidence_state.sources), "response_time_ms": int((time.perf_counter() - started_at) * 1000), "thinking_mode": request.thinking_mode or "auto", "processing_stage": "turn_runtime_state_answer", "answer_mode": "turn_runtime_state_answer", "grounding_status": "grounded" if turn_state.evidence_state.known_facts or authority.can_answer_without_tool else "partially_grounded", "source_policy": "external" if has_external_source else "internal", "research_contract": { "contract_version": "turn_runtime_state_answer.v1", "effective_lane": "external" if has_external_source else "internal", "external_sources_required": has_external_source, "project_fit_required": False, "completion_surface": "space_ai_concierge", }, "policy_version": "pmflow-turn-runtime-state-answer-v1", "pmflow_ai_route_plan": route_plan, "customer_evidence_packet": packet, "preserve_customer_evidence_packet": True, "customer_answer_policy": {"visibility": "customer", "protected": True}, "answer_runtime_packet": _answer_runtime_packet(turn_state), } if project_operating_verification: payload["project_operating_answer_verification_packet"] = project_operating_verification if not bool(project_operating_verification.get("can_claim_full_execution_guidance")): payload["grounding_status"] = "partially_grounded" terminal_state = _build_terminal_answer_state( request=request, turn_state=turn_state, answer=answer, ) turn_state.terminal_answer_state = terminal_state payload["terminal_answer_state"] = terminal_state.model_dump(mode="json", exclude_none=True) workspace_payload = _workspace_payload_from_turn_state(turn_state) if workspace_payload: payload["research_workspace"] = workspace_payload payload["customer_interaction_context_packet"] = presentation_service.build_customer_interaction_context_packet( packet, research_workspace=workspace_payload, ) payload["answer_canvas"] = presentation_service.build_answer_canvas( packet=packet, answer=answer, research_workspace=workspace_payload, ) if project_operating_verification: payload["answer_canvas"] = _apply_project_operating_answer_verification_to_canvas( canvas=payload.get("answer_canvas"), verification=project_operating_verification, ) return payload async def _render_answer_from_registry( self, *, request: RAGAskRequest, turn_state: PMFlowAITurnState, ) -> str: for candidate in PMFlowAIAnswerRendererRegistry().select_candidates(turn_state): answer = await self._execute_answer_renderer_candidate( candidate=candidate, request=request, turn_state=turn_state, ) if answer: return answer return "" async def _execute_answer_renderer_candidate( self, *, candidate: PMFlowAIAnswerRendererCandidate, request: RAGAskRequest, turn_state: PMFlowAITurnState, ) -> str: question = str(request.question or "") if candidate.renderer_key == "static_contract": return _render_source_authority_answer_from_turn_state(turn_state=turn_state) if candidate.renderer_key == "collection_snapshot": return _render_collection_answer_from_working_state(question=question, turn_state=turn_state) if candidate.renderer_key == "project_operating_packet": return _render_project_operating_answer_from_turn_state(question=question, turn_state=turn_state) if candidate.renderer_key == "open_judgement": return _render_open_judgement_answer_from_turn_state(question=question, turn_state=turn_state) if candidate.renderer_key == "llm_state_compose": answer = await self._compose_state_answer(request=request, turn_state=turn_state) return str(answer or "").strip() if candidate.renderer_key == "safe_fallback": return _render_state_answer_fallback(question=question, turn_state=turn_state) return "" async def _compose_state_answer(self, *, request: RAGAskRequest, turn_state: PMFlowAITurnState) -> str: findings = [ item.model_dump(mode="json", exclude_none=True) for item in list(turn_state.evidence_state.known_facts or [])[:10] ] problem_frame = turn_state.problem_frame.model_dump(mode="json", exclude_none=True) interpretation_state = turn_state.interpretation_state.model_dump(mode="json", exclude_none=True) judgement_state = turn_state.judgement_state.model_dump(mode="json", exclude_none=True) answer_objective = turn_state.answer_objective.model_dump(mode="json", exclude_none=True) answer_plan = turn_state.answer_plan.model_dump(mode="json", exclude_none=True) project_operating_verification = _project_operating_answer_verification_packet(turn_state) if not findings: return "" try: prompt_result = await PromptRuntimeService(self.db).render( tenant_id=self.tenant_id, prompt_key=TURN_STATE_ANSWER_SYNTHESIZER_PROMPT_KEY, question=str(request.question or "").strip()[:1000], target_subject=turn_state.problem_frame.subject or _turn_external_subject(turn_state) or "未指定", problem_frame=json.dumps(problem_frame, ensure_ascii=False), interpretation_state=json.dumps(interpretation_state, ensure_ascii=False), judgement_state=json.dumps(judgement_state, ensure_ascii=False), answer_objective=json.dumps(answer_objective, ensure_ascii=False), answer_plan=json.dumps(answer_plan, ensure_ascii=False), answer_shape=str(turn_state.answer_state.answer_shape or ""), success_criteria=json.dumps(list(turn_state.turn_goal.success_criteria or []), ensure_ascii=False), evidence_findings=json.dumps(findings, ensure_ascii=False), project_operating_answer_verification=json.dumps(project_operating_verification, ensure_ascii=False), ) routing_meta = prompt_result.routing_meta() routing_meta.update( { "scene_key": "pmflow_ai_turn_runtime", "scene_contract": "state_based_answer_synthesis", "tier_alias": "high", "response_contract": "markdown_answer", } ) return str( await LLMService().generate( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name="pmflow_ai_turn_state_answer_synthesizer", temperature=0.2, max_tokens=5000, use_prompt_profile=False, routing_meta=routing_meta, ) or "" ).strip() except Exception: return "" def _build_runtime_process_packet( self, *, turn_state: PMFlowAITurnState, payload: dict[str, Any], total_duration_ms: int, ) -> dict[str, Any]: existing = payload.get("answer_process_packet") if isinstance(payload.get("answer_process_packet"), dict) else {} answer_type = str( existing.get("answer_type") or (payload.get("customer_evidence_packet") or {}).get("answer_type") or payload.get("answer_type") or turn_state.answer_state.answer_shape or "general_answer" ) steps: list[CustomerAnswerProcessStep] = [] for item in list(turn_state.runtime_steps or [])[:8]: steps.append( CustomerAnswerProcessStep( key=item.key, label=item.label, status=item.status, display_summary=_step_summary(item), duration_ms=item.duration_ms, blocking=item.blocking, ) ) if not any(step.key == "state_answer" for step in steps): steps.append( CustomerAnswerProcessStep( key="state_answer", label=_answer_step_label(turn_state.answer_state.answer_shape), status="completed", blocking=False, ) ) return CustomerAnswerProcessPacket( answer_type=answer_type[:120] or "general_answer", route_display=str(existing.get("route_display") or _route_display(turn_state)), steps=steps[:8], total_duration_ms=max(0, int(total_duration_ms or 0)), ).model_dump(mode="json", exclude_none=True) @staticmethod def _customer_turn_state_snapshot(turn_state: PMFlowAITurnState) -> dict[str, Any]: snapshot = turn_state.model_dump(mode="json", exclude_none=True) snapshot.pop("legacy_runtime_decision", None) return snapshot def build_turn_presentation_contract_from_runtime_payload( *, payload: dict[str, Any] | None, turn_state: PMFlowAITurnState | None = None, ) -> dict[str, Any]: """Build the single customer interaction surface from final TurnState output.""" working = payload if isinstance(payload, dict) else {} interaction = working.get("interaction_request") if isinstance(working.get("interaction_request"), dict) else {} if interaction: blocking = str(interaction.get("mode") or "blocking").strip().lower() != "optional" return _turn_presentation_contract( turn_state="selection_required" if _interaction_has_choice_questions(interaction) else "input_required", primary_surface="interaction_request", blocking=blocking, reason_code="runtime_interaction_request", answer_can_show=True, next_actions_can_show=not blocking, interaction_request_can_show=True, ) packet = working.get("customer_evidence_packet") if isinstance(working.get("customer_evidence_packet"), dict) else {} answer_type = str(packet.get("answer_type") or working.get("answer_type") or "").strip() grounding = str(working.get("grounding_status") or "").strip() if grounding in {"blocked", "insufficient_evidence"} or answer_type in {"protected_scope", "insufficient_evidence"}: return _turn_presentation_contract( turn_state="blocked", primary_surface="blocked_notice", blocking=True, reason_code=grounding or answer_type or "blocked", answer_can_show=True, next_actions_can_show=False, interaction_request_can_show=False, ) has_answer = bool(str(working.get("answer") or working.get("reply_text") or "").strip()) if not has_answer: return _turn_presentation_contract( turn_state="blocked", primary_surface="blocked_notice", blocking=True, reason_code="runtime_payload_without_answer", answer_can_show=False, next_actions_can_show=False, interaction_request_can_show=False, ) reason = "runtime_answer_ready" if turn_state is not None and turn_state.completed: reason = "runtime_completed" return _turn_presentation_contract( turn_state="answer_ready", primary_surface="answer_canvas", blocking=False, reason_code=reason, answer_can_show=True, next_actions_can_show=True, interaction_request_can_show=False, ) def _build_verification_blocked_payload( *, request: RAGAskRequest, current_user: User, route_plan: dict[str, Any], turn_state: PMFlowAITurnState, presentation_service: CustomerAnswerPresentationService, started_at: float, ) -> dict[str, Any]: last_failure = turn_state.verification_failures[-1] if turn_state.verification_failures else None next_action = str(getattr(last_failure, "next_action_hint", "") or "").strip() terminal_state = _build_terminal_answer_state(request=request, turn_state=turn_state) turn_state.terminal_answer_state = terminal_state answer = _render_terminal_state_fallback(terminal_state) if not answer: answer = "这轮没有形成足够可靠的可展示结论,我不会把内部诊断、浅状态或旧上下文当成用户答案。" limitations = [ boundary.customer_safe_reason for boundary in list(terminal_state.boundaries or [])[:6] if str(boundary.customer_safe_reason or "").strip() ] if not limitations: limitations = ["缺少可安全展示的确认依据。"] packet = CustomerEvidencePacket( tenant_id=_safe_int(getattr(current_user, "tenant_id", None)), user_id=_safe_int(getattr(current_user, "id", None)), question=str(request.question or "").strip()[:500], answer_type="insufficient_evidence", route_display=_route_display(turn_state), facts=[], source_summary=[], confidence="unknown", limitations=limitations, suggested_actions=[ { "title": str(terminal_state.next_possible_actions[0] if terminal_state.next_possible_actions else "继续读取必要证据")[:160], "priority": "high", "action_type": "read", } ] if next_action == "inspect_project_status" else [], fallback_summary=answer[:1200], ).model_dump(mode="json", exclude_none=True) payload = { "_runtime_payload_kind": "final_answer", "answer": answer, "reply_text": answer, "sources": [], "sources_used": [], "response_time_ms": int((time.perf_counter() - started_at) * 1000), "thinking_mode": request.thinking_mode or "auto", "processing_stage": "turn_runtime_verification_blocked", "answer_mode": "turn_runtime_verification_blocked", "grounding_status": "insufficient_evidence", "source_policy": "internal", "research_contract": { "contract_version": "turn_runtime_verification_blocked.v1", "effective_lane": "internal", "completion_surface": "space_ai_concierge", }, "policy_version": "pmflow-turn-runtime-verification-blocked-v1", "pmflow_ai_route_plan": route_plan, "customer_evidence_packet": packet, "terminal_answer_state": terminal_state.model_dump(mode="json", exclude_none=True), "preserve_customer_evidence_packet": True, "customer_answer_policy": {"visibility": "customer", "protected": True}, } payload["customer_interaction_context_packet"] = presentation_service.build_customer_interaction_context_packet(packet) payload["answer_canvas"] = presentation_service.build_answer_canvas(packet=packet, answer=answer) return payload def _turn_presentation_contract( *, turn_state: str, primary_surface: str, blocking: bool, reason_code: str, answer_can_show: bool, next_actions_can_show: bool, interaction_request_can_show: bool, ) -> dict[str, Any]: return { "version": "pmflow_ai_turn_presentation.v1", "turn_state": turn_state, "primary_surface": primary_surface, "blocking": bool(blocking), "reason_code": str(reason_code or "").strip()[:120], "answer_can_show": bool(answer_can_show), "next_actions_can_show": bool(next_actions_can_show), "interaction_request_can_show": bool(interaction_request_can_show), "source": "turn_runtime", } def _interaction_has_choice_questions(interaction: dict[str, Any]) -> bool: for question in list(interaction.get("questions") or []): if not isinstance(question, dict): continue input_type = str(question.get("input_type") or question.get("type") or "").strip() options = [item for item in list(question.get("options") or []) if isinstance(item, dict)] if input_type in {"single_choice", "multi_choice"} and options: return True return False def _runtime_actions_from_working_state(turn_state: PMFlowAITurnState) -> list[PMFlowAIRuntimeAction]: selected = turn_state.selected_frame operation = selected.operation_kind if selected is not None else "answer_general" gap_keys = {str(gap.key or "").strip() for gap in list(turn_state.operation_gaps or [])} gap_action_hints = _action_hints_from_operation_gaps(turn_state) actions: list[PMFlowAIRuntimeAction] = [] def add(action_type: RuntimeActionKind, *, reason: str, tool_name: str) -> None: if any(item.action_type == action_type for item in actions): return actions.append( PMFlowAIRuntimeAction( action_id=action_type, action_type=action_type, reason=reason, tool_name=tool_name, ) ) authority = PMFlowAIEvidenceAuthority().decide(turn_state) if authority.needs_probe and authority.required_first_action is not None: add( authority.required_first_action, reason=authority.reason, tool_name=_tool_name_for_action(authority.required_first_action), ) return actions if authority.can_answer_without_tool: add( "synthesize_answer", reason=authority.reason, tool_name="turn_state_answer_synthesizer", ) return actions if ( _has_actionable_user_gap(turn_state) and turn_state.sufficiency_state.needs_user_input and not _has_executable_system_gap(turn_state) ): add( "ask_clarification", reason="WorkingState 存在用户可处理的阻塞缺口,需要先确认。", tool_name="clarification_builder", ) return actions for hinted_action in gap_action_hints: if hinted_action in authority.forbidden_first_actions and not _action_performed(turn_state, "inspect_project_status"): continue if hinted_action == "inspect_surface_context": add("inspect_surface_context", reason="WorkingState 缺口要求先读取当前空间上下文。", tool_name="surface_context_tool") elif hinted_action == "inspect_saved_ideas": add("inspect_saved_ideas", reason="WorkingState 缺口要求读取暂存想法集合。", tool_name="saved_ideas_tool") elif hinted_action == "inspect_internal_projects": add("inspect_internal_projects", reason="WorkingState 缺口要求读取内部项目证据。", tool_name="internal_project_evidence_tool") elif hinted_action == "resolve_project_objects": add("resolve_project_objects", reason="WorkingState 缺口要求解析项目对象。", tool_name="project_object_resolver_tool") elif hinted_action == "inspect_project_status": add("inspect_project_status", reason="WorkingState 缺口要求读取项目状态证据。", tool_name="project_status_evidence_tool") elif hinted_action == "external_search": add("external_search", reason="WorkingState 缺口要求读取外部证据。", tool_name="external_query_gateway") if operation == "list_collection": collection_type = str((selected.target_collection.object_type if selected and selected.target_collection else "") or "") if collection_type == "saved_idea" or "saved_ideas" in gap_keys: add("inspect_saved_ideas", reason="WorkingState 需要读取当前空间暂存想法集合。", tool_name="saved_ideas_tool") elif "project_lookup" in gap_keys: add("resolve_project_objects", reason="WorkingState 需要解析当前空间项目对象集合。", tool_name="project_object_resolver_tool") else: add("inspect_surface_context", reason="WorkingState 需要读取当前空间可见对象集合。", tool_name="surface_context_tool") elif operation == "inspect_object": if "surface_context" in gap_keys and not _turn_object_is_strong(turn_state.object_state.primary_object): add("inspect_surface_context", reason="WorkingState 缺少强对象锚点,需要先读取当前空间上下文。", tool_name="surface_context_tool") if "project_lookup" in gap_keys: add("resolve_project_objects", reason="WorkingState 需要先解析目标项目对象。", tool_name="project_object_resolver_tool") if "project_status" in gap_keys: add("inspect_project_status", reason="WorkingState 需要读取绑定项目状态证据。", tool_name="project_status_evidence_tool") if "internal_projects" in gap_keys: add("inspect_internal_projects", reason="WorkingState 需要读取内部项目证据。", tool_name="internal_project_evidence_tool") if not actions: add("inspect_internal_projects", reason="WorkingState 需要读取内部项目证据。", tool_name="internal_project_evidence_tool") elif operation in {"judge_direction", "compare_objects"}: if "saved_ideas" in gap_keys: add("inspect_saved_ideas", reason="WorkingState 需要先读取暂存想法候选。", tool_name="saved_ideas_tool") if "surface_context" in gap_keys: add("inspect_surface_context", reason="WorkingState 需要读取当前空间资源上下文。", tool_name="surface_context_tool") if "internal_projects" in gap_keys: add("inspect_internal_projects", reason="WorkingState 需要读取内部资源与项目证据。", tool_name="internal_project_evidence_tool") if "external_fresh" in gap_keys or any(gap.gap_type == "freshness_gap" for gap in list(turn_state.operation_gaps or [])): add("external_search", reason="WorkingState 需要读取外部新近证据。", tool_name="external_query_gateway") if not actions: add("external_search", reason="WorkingState 需要外部证据来判断方向。", tool_name="external_query_gateway") elif operation == "guide_execution": if "project_status" in gap_keys: add("inspect_project_status", reason="WorkingState 需要读取执行对象状态。", tool_name="project_status_evidence_tool") elif "internal_projects" in gap_keys: add("inspect_internal_projects", reason="WorkingState 需要读取内部执行上下文。", tool_name="internal_project_evidence_tool") else: add("inspect_surface_context", reason="WorkingState 需要读取当前空间动作上下文。", tool_name="surface_context_tool") elif operation == "explain_assistant": pass else: if "surface_context" in gap_keys: add("inspect_surface_context", reason="WorkingState 需要读取当前空间上下文。", tool_name="surface_context_tool") if "internal_projects" in gap_keys: add("inspect_internal_projects", reason="WorkingState 需要读取内部项目证据。", tool_name="internal_project_evidence_tool") if "external_fresh" in gap_keys: add("external_search", reason="WorkingState 需要读取外部新近证据。", tool_name="external_query_gateway") if not any(action.action_type == "ask_clarification" for action in actions): add( "synthesize_answer", reason="基于当前 WorkingState 和已吸收证据生成最终表达。", tool_name="turn_state_answer_synthesizer", ) return actions[:6] def _tool_name_for_action(action_type: RuntimeActionKind) -> str: return { "inspect_surface_context": "surface_context_tool", "inspect_saved_ideas": "saved_ideas_tool", "inspect_internal_projects": "internal_project_evidence_tool", "resolve_project_objects": "project_object_resolver_tool", "inspect_project_status": "project_status_evidence_tool", "external_search": "external_query_gateway", "external_fetch": "external_query_gateway", "synthesize_answer": "turn_state_answer_synthesizer", "ask_clarification": "clarification_builder", }.get(action_type, "") def _action_hints_from_operation_gaps(turn_state: PMFlowAITurnState) -> list[RuntimeActionKind]: out: list[RuntimeActionKind] = [] for gap in list(turn_state.operation_gaps or []): if gap.user_actionable and gap.gap_type in {"user_clarification_gap", "user_authorization_gap"}: continue if gap.gap_type not in {"system_evidence_gap", "freshness_gap"}: continue hint = _canonical_action_hint( hint=str(gap.system_action_hint or "").strip(), gap=gap, turn_state=turn_state, ) if hint in CANONICAL_RUNTIME_ACTIONS and hint not in {"ask_clarification", "synthesize_answer"} and hint not in out: out.append(hint) # type: ignore[arg-type] return out def _canonical_action_hint( *, hint: str, gap: PMFlowAIOperationGap, turn_state: PMFlowAITurnState, ) -> str: canonical = canonical_action_hint_for_gap( hint=hint, gap_type=str(gap.gap_type or ""), frame=turn_state.selected_frame, ) if canonical: return canonical selected = turn_state.selected_frame operation = selected.operation_kind if selected is not None else "answer_general" gap_type = str(gap.gap_type or "").strip() primary = turn_state.object_state.primary_object if gap_type not in {"system_evidence_gap", "freshness_gap"}: return "" if gap_type == "freshness_gap": authority = PMFlowAIEvidenceAuthority().decide(turn_state) if "external_search" in authority.forbidden_first_actions and not _action_performed(turn_state, "inspect_project_status"): return authority.required_first_action or "" return "external_search" if operation == "inspect_object": if _turn_object_is_strong(primary) and _turn_object_type(primary) == "project": return "inspect_project_status" return "resolve_project_objects" if operation == "list_collection": collection_type = str((selected.target_collection.object_type if selected and selected.target_collection else "") or "") if collection_type == "saved_idea": return "inspect_saved_ideas" return "inspect_surface_context" if operation == "guide_execution": if _selected_frame_requires_existing_object(selected) and _turn_object_is_strong(primary): return "inspect_project_status" return "inspect_surface_context" if operation in {"judge_direction", "compare_objects"}: authority = PMFlowAIEvidenceAuthority().decide(turn_state) if authority.required_first_action is not None: return authority.required_first_action return "external_search" return "" def _has_executable_system_gap(turn_state: PMFlowAITurnState) -> bool: if _action_hints_from_operation_gaps(turn_state): return True has_system_gap = any( gap.gap_type in {"system_evidence_gap", "freshness_gap"} and not gap.user_actionable for gap in list(turn_state.operation_gaps or []) ) selected = turn_state.selected_frame if selected is None: return has_system_gap if selected.operation_kind == "list_collection": return has_system_gap if selected.operation_kind in {"inspect_object", "guide_execution"}: return has_system_gap and _turn_object_is_strong(turn_state.object_state.primary_object) if selected.operation_kind in {"judge_direction", "compare_objects"}: return has_system_gap return has_system_gap def _turn_object_is_strong(value: PMFlowAITurnObjectRef | None) -> bool: if value is None: return False binding = str(value.binding_strength or "").strip().lower() if binding in {"", "none", "context", "visible_context"}: return False return bool(str(value.object_ref or value.title or "").strip()) def _turn_object_type(value: PMFlowAITurnObjectRef | None) -> str: return str((value.object_type if value is not None else "") or "").strip().lower() def _selected_frame_requires_existing_object(selected: PMFlowAIGoalFrame | None) -> bool: if selected is None: return False if selected.target_object_requirement == "single": return True scope = selected.target_scope return bool(scope and scope.scope_type == "current_project" and scope.binding_strength == "explicit") def _dict(value: Any) -> dict[str, Any]: return dict(value) if isinstance(value, dict) else {} def _json_preview(value: Any, *, limit: int) -> str: try: text = json.dumps(value, ensure_ascii=False, default=str) except Exception: text = str(value) return text[: max(100, int(limit))] def _parse_json_object(value: Any) -> dict[str, Any]: text = str(value or "").strip() if not text: return {} if text.startswith("```"): first_line = text.splitlines()[0].strip().lower() if text.splitlines() else "" if first_line.startswith("```"): lines = text.splitlines()[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] text = "\n".join(lines).strip() start = text.find("{") end = text.rfind("}") if start >= 0 and end > start: text = text[start : end + 1] try: parsed = json.loads(text) except Exception: return {} return parsed if isinstance(parsed, dict) else {} def _neutral_route_hypothesis(*, request: RAGAskRequest, turn_hypothesis: dict[str, Any]) -> dict[str, Any]: shape = _coerce_answer_shape(_answer_shape_from_goal_frames(turn_hypothesis) or turn_hypothesis.get("answer_shape")) return { "plan_version": "pmflow_ai_turn_runtime_hypothesis.v1", "decision_origin": "turn_state_runtime", "route_hypothesis_only": True, "display_contract": {"answer_shape": shape}, "surface_direct_eligible": False, "needs_clarification": _operation_gaps_payload_has_actionable_blocker(turn_hypothesis), "question": str(request.question or "")[:500], } def _coerce_answer_shape(value: Any) -> str: text = str(value or "").strip().lower() allowed = { "strategic_judgement", "status_answer", "clarification", "execution_guidance", "fact_list", "composite_answer", "general_answer", } return text if text in allowed else "general_answer" def _answer_shape_from_goal_frames(hypothesis: dict[str, Any]) -> str: rows = hypothesis.get("goal_frames") frames = rows if isinstance(rows, list) else [] for frame in frames: if not isinstance(frame, dict): continue value = str(frame.get("answer_shape") or "").strip() if value: return value return "" def _has_direct_working_contract(hypothesis: dict[str, Any]) -> bool: return _has_direct_goal_frame_payload(hypothesis) or _has_direct_operation_gap_payload(hypothesis) def _has_direct_goal_frame_payload(hypothesis: dict[str, Any]) -> bool: return any(isinstance(item, dict) for item in list(hypothesis.get("goal_frames") or [])) def _has_direct_operation_gap_payload(hypothesis: dict[str, Any]) -> bool: return any(isinstance(item, dict) for item in list(hypothesis.get("operation_gaps") or [])) def _evidence_gaps_from_operation_gap_payload(hypothesis: dict[str, Any]) -> list[PMFlowAIEvidenceGap]: rows = hypothesis.get("operation_gaps") raw_gaps = rows if isinstance(rows, list) else [] out: list[PMFlowAIEvidenceGap] = [] for index, raw in enumerate(raw_gaps[:20], start=1): if not isinstance(raw, dict): continue key = str(raw.get("key") or f"operation_gap_{index}")[:120] if not key: continue gap_type = str(raw.get("gap_type") or "system_evidence_gap") blocking = bool(raw.get("blocking")) try: out.append( PMFlowAIEvidenceGap( key=key, label=str(raw.get("label") or raw.get("reason") or key)[:240], severity="blocking" if blocking else "non_blocking", gap_type=gap_type, user_actionable=bool(raw.get("user_actionable")), customer_question=str(raw.get("customer_question") or "")[:240], system_action_hint=str(raw.get("system_action_hint") or "")[:160], reason=str(raw.get("reason") or "")[:400], ) ) except Exception: continue return out def _operation_gaps_payload_has_actionable_blocker(hypothesis: dict[str, Any]) -> bool: rows = hypothesis.get("operation_gaps") raw_gaps = rows if isinstance(rows, list) else [] for raw in raw_gaps: if not isinstance(raw, dict): continue gap_type = str(raw.get("gap_type") or "") if bool(raw.get("blocking")) and gap_type in {"user_clarification_gap", "user_authorization_gap"}: return True return False def _legacy_needs_user_input_requires_blocker(*, hypothesis: dict[str, Any], answer_shape: str) -> bool: if _has_direct_operation_gap_payload(hypothesis): return False if not bool(hypothesis.get("needs_user_input")): return False if bool(hypothesis.get("can_answer_with_assumptions")): return False return answer_shape in {"clarification", "execution_guidance"} def _evidence_flags_from_working_contract( *, hypothesis: dict[str, Any], ) -> tuple[bool, bool]: internal_actions = {"inspect_surface_context", "inspect_saved_ideas", "inspect_internal_projects", "resolve_project_objects", "inspect_project_status"} external_actions = {"external_search", "external_fetch"} needs_internal = False needs_external = False for raw in list(hypothesis.get("operation_gaps") or []): if not isinstance(raw, dict): continue hint = str(raw.get("system_action_hint") or "").strip() gap_type = str(raw.get("gap_type") or "").strip() needs_internal = needs_internal or hint in internal_actions needs_external = needs_external or hint in external_actions or gap_type == "freshness_gap" return needs_internal, needs_external def _primary_object_from_hypothesis( *, hypothesis: dict[str, Any], request: RAGAskRequest, thread_workspace: PMFlowAIThreadWorkspace | None, conversation_context: dict[str, Any] | None = None, ) -> PMFlowAITurnObjectRef: if thread_workspace and thread_workspace.primary_object: return thread_workspace.primary_object primary = _dict(hypothesis.get("primary_object")) binding = str(primary.get("binding_strength") or "none").strip().lower() or "none" title = str(primary.get("title") or "").strip() object_ref = str(primary.get("object_ref") or "").strip() object_type = str(primary.get("object_type") or "unknown").strip() or "unknown" if request.project_id and (object_type == "unknown" or not object_ref): object_type = "project" object_ref = f"project:{int(request.project_id)}" binding = "request_project_id" if _hypothesis_rejects_session_focus(hypothesis): if binding in {"none", "context", "visible_context"} or not object_ref: return PMFlowAITurnObjectRef(object_type="unknown", object_ref="", title="", binding_strength="none") if binding in {"none", "context", "visible_context"}: ledger_object = _ledger_primary_object_from_context( conversation_context=conversation_context or {}, hypothesis=hypothesis, ) if ledger_object is not None: return ledger_object if not object_ref: return PMFlowAITurnObjectRef(object_type="unknown", object_ref="", title="", binding_strength="none") return PMFlowAITurnObjectRef( object_type=object_type[:80], object_ref=object_ref[:160], title=title[:200], binding_strength=binding[:80], ) def _ledger_primary_object_from_context( *, conversation_context: dict[str, Any], hypothesis: dict[str, Any], ) -> PMFlowAITurnObjectRef | None: if _hypothesis_rejects_session_focus(hypothesis): return None if not _hypothesis_can_use_session_focus(hypothesis): return None ledger_ref = ledger_focus_as_turn_object(_dict(conversation_context.get("object_ledger"))) if not ledger_ref: return None object_ref = str(ledger_ref.get("object_ref") or "").strip() title = str(ledger_ref.get("title") or "").strip() if not object_ref and not title: return None return PMFlowAITurnObjectRef( object_type=str(ledger_ref.get("object_type") or "unknown")[:80], object_ref=object_ref[:160], title=title[:200], binding_strength="session_object_ledger", role="current_focus", ) def _hypothesis_can_use_session_focus(hypothesis: dict[str, Any]) -> bool: frames = [item for item in list(hypothesis.get("goal_frames") or []) if isinstance(item, dict)] if not frames: return False decision = _object_binding_decision(hypothesis) if decision == "accept_previous_focus": return True if decision in {"reject_previous_focus", "create_new_focus", "no_object"}: return False for frame in frames[:4]: operation = str(frame.get("operation_kind") or "").strip() requirement = str(frame.get("target_object_requirement") or "").strip() scope = _dict(frame.get("target_scope")) if str(scope.get("scope_type") or "") == "current_project": return True if operation == "inspect_object" and requirement == "single": return True if requirement == "single" and str(scope.get("scope_type") or "") == "current_project": return True return False def _hypothesis_rejects_session_focus(hypothesis: dict[str, Any]) -> bool: decision = _object_binding_decision(hypothesis) if decision in {"reject_previous_focus", "create_new_focus", "no_object"}: return True for raw in list(hypothesis.get("operation_gaps") or [])[:8]: if not isinstance(raw, dict): continue if str(raw.get("gap_type") or "").strip() == "scope_gap" and str(raw.get("system_action_hint") or "").strip(): return True return False def _object_binding_decision(hypothesis: dict[str, Any]) -> str: raw = hypothesis.get("object_binding_decision") if isinstance(raw, dict): decision = str(raw.get("decision") or "").strip() if decision in {"accept_previous_focus", "reject_previous_focus", "create_new_focus", "no_object"}: return decision return "" def _context_objects_from_surface(surface_context: dict[str, Any] | None) -> list[PMFlowAITurnObjectRef]: surface = _dict(surface_context) runtime_context = _dict(surface.get("space_runtime_context")) workbench = _dict(runtime_context.get("workbench")) rows = list(workbench.get("projects") or workbench.get("recent_projects") or [])[:12] out: list[PMFlowAITurnObjectRef] = [] for item in rows: if not isinstance(item, dict): continue title = str(item.get("title") or item.get("name") or "").strip() object_ref = str(item.get("object_ref") or item.get("objectRef") or "").strip() if not object_ref and item.get("id") is not None: object_ref = f"project:{item.get('id')}" if title or object_ref: out.append( PMFlowAITurnObjectRef( object_type="project", object_ref=object_ref[:160], title=title[:200], binding_strength="visible_context", role="context", ) ) return out def _build_problem_frame_from_hypothesis( *, hypothesis: dict[str, Any], request: RAGAskRequest, primary_object: PMFlowAITurnObjectRef | None, thread_workspace: PMFlowAIThreadWorkspace | None, ) -> PMFlowAIProblemFrame: subject = str( hypothesis.get("subject") or (primary_object.title if primary_object else "") or (thread_workspace.topic if thread_workspace else "") or "" ).strip() terms = [ str(item or "").strip()[:120] for item in list(hypothesis.get("subject_terms") or []) if str(item or "").strip() ][:12] ambiguity = [ str(item or "").strip()[:120] for item in list(hypothesis.get("ambiguity_points") or []) if str(item or "").strip() ][:12] return PMFlowAIProblemFrame( subject=subject[:240], subject_terms=terms, domain_hints=[], ambiguity_points=ambiguity, user_question_type=str(hypothesis.get("desired_outcome") or "")[:120], decision_type=_decision_type_from_goal_frame(hypothesis), ) def _decision_type_from_goal_frame(hypothesis: dict[str, Any]) -> ProblemDecisionType: frames = [item for item in list(hypothesis.get("goal_frames") or []) if isinstance(item, dict)] operation = str((frames[0] if frames else {}).get("operation_kind") or "").strip() return { "judge_direction": "market_opportunity", "compare_objects": "market_opportunity", "inspect_object": "project_status", "guide_execution": "execution_guidance", "list_collection": "object_lookup", }.get(operation, "general_answer") # type: ignore[return-value] def _build_interpretation_state_from_hypothesis( *, hypothesis: dict[str, Any], problem_frame: PMFlowAIProblemFrame, ) -> PMFlowAIInterpretationState: ambiguity = list(problem_frame.ambiguity_points or []) evidence_needs = _interpretation_needs_from_working_contract(hypothesis) branch = PMFlowAIInterpretationBranch( branch_id="working_interpretation", name=problem_frame.subject or str(hypothesis.get("normalized_goal") or "当前问题")[:120], assumptions=ambiguity[:4] or ["按当前用户目标和可见上下文推进"], evidence_needs=evidence_needs or ["按 TurnState 缺口选择证据"], risk_hints=[], confidence="medium", ) return PMFlowAIInterpretationState( branches=[branch], selected_branch_id=branch.branch_id, compare_branches=False, assumption_policy="state_assumptions" if ambiguity else "single_branch", ) def _interpretation_needs_from_working_contract(hypothesis: dict[str, Any]) -> list[str]: rows = [] for raw in list(hypothesis.get("operation_gaps") or [])[:8]: if not isinstance(raw, dict): continue reason = str(raw.get("reason") or raw.get("label") or raw.get("key") or "").strip() if reason: rows.append(reason[:160]) return rows def _success_criteria_from_hypothesis(*, hypothesis: dict[str, Any], answer_shape: str) -> list[str]: rows = [ str(item or "").strip()[:160] for item in list(hypothesis.get("success_criteria") or []) if str(item or "").strip() ] if rows: return rows[:8] return _required_sections(answer_shape)[:8] def _turn_delta_from_surface_context(surface_context: dict[str, Any] | None) -> PMFlowAITurnDelta | None: surface = _dict(surface_context) raw = surface.get("pmflow_ai_turn_delta") if not isinstance(raw, dict): return None try: return PMFlowAITurnDelta.model_validate(raw) except Exception: return None def _thread_workspace_from_context( *, conversation_context: dict[str, Any], route_plan: dict[str, Any], request: RAGAskRequest, turn_delta: PMFlowAITurnDelta | None, turn_hypothesis: dict[str, Any] | None = None, ) -> PMFlowAIThreadWorkspace | None: raw = ( conversation_context.get("thread_workspace") if isinstance(conversation_context.get("thread_workspace"), dict) else conversation_context.get("active_workspace") ) hypothesis = _dict(turn_hypothesis) workspace = _thread_workspace_from_payload(raw if isinstance(raw, dict) else {}) if workspace: return _apply_thread_workspace_update( workspace=workspace, turn_hypothesis=hypothesis, turn_delta=turn_delta, ) workspace = _thread_workspace_from_hypothesis( turn_hypothesis=hypothesis, turn_delta=turn_delta, ) if workspace: return workspace _ = route_plan, request, turn_delta return None def _thread_workspace_from_payload(payload: dict[str, Any]) -> PMFlowAIThreadWorkspace | None: if not isinstance(payload, dict) or not payload: return None nested = payload.get("pmflow_ai_thread_workspace") if isinstance(nested, dict): try: workspace = PMFlowAIThreadWorkspace.model_validate(nested) if not workspace.collected_inputs: workspace.collected_inputs = _clean_workspace_inputs(payload.get("collected_inputs")) return workspace except Exception: pass if str(payload.get("packet_version") or "").startswith("pmflow_ai_thread_workspace"): try: return PMFlowAIThreadWorkspace.model_validate(payload) except Exception: return None kind = str(payload.get("workspace_kind") or "").strip() if kind == "new_project_ideation": title = str(payload.get("primary_object_title") or payload.get("topic") or "").strip() if not title: return None return PMFlowAIThreadWorkspace( workspace_id=str(payload.get("workspace_id") or f"tw_{_stable_hash('new_project_ideation:' + title)}"), workspace_kind="new_project_ideation", topic=title, primary_object=PMFlowAITurnObjectRef( object_type="new_idea", object_ref=str(payload.get("primary_object_ref") or ""), title=title, binding_strength="active_workspace_strong", ), active_goal=str(payload.get("research_goal") or "evaluate_or_incubate_idea"), collected_inputs=_clean_workspace_inputs(payload.get("collected_inputs")), pending_questions=list(payload.get("open_questions") or []), evidence_requirements=list(payload.get("analysis_dimensions") or []), forbidden_substitution=True, status="active", ) return None def _thread_workspace_from_hypothesis( *, turn_hypothesis: dict[str, Any], turn_delta: PMFlowAITurnDelta | None, ) -> PMFlowAIThreadWorkspace | None: update = _dict(turn_hypothesis.get("thread_workspace_update")) decision = _object_binding_decision(turn_hypothesis) should_create = bool(update.get("should_create_workspace")) or decision == "create_new_focus" should_continue = bool(update.get("should_continue_workspace")) kind = str(update.get("workspace_kind") or "").strip() if not kind and should_create: kind = "new_project_ideation" if kind != "new_project_ideation" and not (should_create or should_continue): return None binding = _dict(turn_hypothesis.get("object_binding_decision")) primary = _dict(turn_hypothesis.get("primary_object")) topic = str( update.get("topic") or binding.get("new_focus_title") or primary.get("title") or turn_hypothesis.get("subject") or "" ).strip() if not topic: return None collected_inputs = _clean_workspace_inputs(update.get("collected_inputs")) collected_inputs.update(_turn_delta_inputs(turn_delta)) status = str(update.get("status") or "active").strip() if status not in {"active", "paused", "done"}: status = "active" return PMFlowAIThreadWorkspace( workspace_id=str(update.get("workspace_id") or f"tw_{_stable_hash('new_project_ideation:' + topic)}"), workspace_kind="new_project_ideation", topic=topic, primary_object=PMFlowAITurnObjectRef( object_type="new_idea", object_ref=str(primary.get("object_ref") or ""), title=topic, binding_strength="active_workspace_strong", ), active_goal=str(update.get("active_goal") or turn_hypothesis.get("normalized_goal") or "evaluate_or_incubate_idea"), collected_inputs=collected_inputs, pending_questions=_clean_text_list(update.get("pending_questions") or turn_hypothesis.get("ambiguity_points")), evidence_requirements=_clean_text_list(update.get("evidence_requirements") or turn_hypothesis.get("success_criteria")), forbidden_substitution=True, status=status, # type: ignore[arg-type] ) def _apply_thread_workspace_update( *, workspace: PMFlowAIThreadWorkspace, turn_hypothesis: dict[str, Any], turn_delta: PMFlowAITurnDelta | None, ) -> PMFlowAIThreadWorkspace: update = _dict(turn_hypothesis.get("thread_workspace_update")) if not update and not turn_delta: return workspace merged = workspace.model_copy(deep=True) topic = str(update.get("topic") or "").strip() if topic: merged.topic = topic if merged.primary_object: merged.primary_object.title = topic active_goal = str(update.get("active_goal") or "").strip() if active_goal: merged.active_goal = active_goal collected_inputs = _clean_workspace_inputs(merged.collected_inputs) collected_inputs.update(_clean_workspace_inputs(update.get("collected_inputs"))) collected_inputs.update(_turn_delta_inputs(turn_delta)) merged.collected_inputs = collected_inputs if isinstance(update.get("pending_questions"), list): merged.pending_questions = _clean_text_list(update.get("pending_questions")) if isinstance(update.get("evidence_requirements"), list): merged.evidence_requirements = _clean_text_list(update.get("evidence_requirements")) status = str(update.get("status") or "").strip() if status in {"active", "paused", "done"}: merged.status = status # type: ignore[assignment] return merged def _clean_workspace_inputs(value: Any) -> dict[str, Any]: raw = value if isinstance(value, dict) else {} out: dict[str, Any] = {} for key, item in list(raw.items())[:24]: normalized_key = " ".join(str(key or "").strip().split())[:80] if not normalized_key: continue normalized_value = _clean_workspace_value(item) if normalized_value == "" or normalized_value is None or normalized_value == [] or normalized_value == {}: continue out[normalized_key] = normalized_value return out def _clean_workspace_value(value: Any) -> Any: if isinstance(value, list): out: list[Any] = [] for item in value[:8]: normalized = _clean_workspace_value(item) if normalized == "" or normalized is None or normalized == [] or normalized == {}: continue out.append(normalized) return out if isinstance(value, dict): return _clean_workspace_inputs(value) return " ".join(str(value or "").strip().split())[:500] def _clean_text_list(value: Any, *, limit: int = 12) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows[:limit]: text = " ".join(str(item or "").strip().split())[:240] if text and text not in out: out.append(text) return out def _turn_delta_inputs(turn_delta: PMFlowAITurnDelta | None) -> dict[str, Any]: if turn_delta is None: return {} out: dict[str, Any] = {} for answer in list(turn_delta.answers or [])[:12]: key = str(answer.question_id or answer.question_label or "").strip() if not key: continue value = answer.display_value or "、".join(answer.labels or answer.values or []) value = " ".join(str(value or "").strip().split())[:500] if value: out[key[:80]] = value supplement = " ".join(str(turn_delta.supplement_text or "").strip().split())[:800] if supplement: out["latest_supplement"] = supplement return out def _workspace_payload_from_turn_state(turn_state: PMFlowAITurnState) -> dict[str, Any]: workspace = turn_state.thread_workspace if workspace is None or workspace.primary_object is None: return {} primary = workspace.primary_object return { "workspace_version": "research_workspace.v1", "workspace_id": workspace.workspace_id, "workspace_kind": workspace.workspace_kind, "topic": workspace.topic or primary.title, "primary_object_ref": primary.object_ref or None, "primary_object_id": None, "primary_object_title": primary.title or None, "candidate_set_binding": None, "candidate_items": [], "selected_candidate_refs": [], "research_goal": workspace.active_goal, "analysis_dimensions": list(workspace.evidence_requirements or []), "evidence_refs": [], "confidence_state": "medium", "collected_inputs": _clean_workspace_inputs(workspace.collected_inputs), "open_questions": list(workspace.pending_questions or []), "next_recommended_actions": [], "status": workspace.status, "pmflow_ai_thread_workspace": workspace.model_dump(mode="json", exclude_none=True), } def _build_thread_workspace_clarification_payload(turn_state: PMFlowAITurnState) -> dict[str, Any]: workspace = turn_state.thread_workspace if workspace is None or workspace.workspace_kind != "new_project_ideation": return {} primary_title = str((workspace.primary_object.title if workspace.primary_object else "") or "").strip() questions = _thread_workspace_clarification_questions(turn_state) if primary_title and primary_title != "新项目" and not questions: return {} if not questions: questions = [ { "id": "idea_direction", "label": "你想聊的新项目方向是什么?", "input_type": "text", "required": True, "placeholder": "用一句话描述方向、目标用户或你想验证的问题...", "options": [], } ] answer = ( "可以。我先把这个方向作为一个独立工作区继续推进。" "下面这些是当前真正会影响判断的缺口,一次补齐后我会接着给机会、MVP 切口、风险和下一步。" ) return { "_runtime_payload_kind": "final_answer", "answer": answer, "reply_text": answer, "answer_mode": "route_clarification", "grounding_status": "needs_clarification", "source_policy": "internal", "customer_evidence_packet": { "answer_type": "clarification", "route_display": "需要补充范围", "facts": [], "source_summary": [], "limitations": ["还缺新项目方向。"], }, "interaction_request": { "id": "new-project-ideation-direction", "type": "confirm_and_supplement", "title": "补齐这个方向的关键判断条件", "mode": "blocking", "presentation": "stepper", "questions": questions[:4], "supplement": { "enabled": True, "required": False, "placeholder": "也可以补充目标用户、使用场景、限制或你已有判断。", }, "submit_label": "确认并继续", "cancel_label": "先不补充", }, "policy_version": "pmflow-turn-runtime-thread-clarification-v1", } def _build_generic_clarification_payload(turn_state: PMFlowAITurnState) -> dict[str, Any]: questions = _generic_clarification_questions(turn_state) if not questions: questions = [ { "id": "blocking_context", "label": "请补充当前最影响我继续判断的对象、目标或动作边界。", "input_type": "text", "required": True, "placeholder": "补充对象、目标、限制或你希望我继续判断的方向。", "options": [], } ] limitations = [str(item.get("label") or "").strip() for item in questions if str(item.get("label") or "").strip()] answer = "我需要先确认这些会改变判断结果的信息:" if len(questions) > 1 else f"我需要先确认一个阻塞点:{limitations[0]}" return { "_runtime_payload_kind": "final_answer", "answer": answer, "reply_text": answer, "answer_mode": "turn_runtime_clarification", "grounding_status": "needs_clarification", "source_policy": "internal", "customer_evidence_packet": { "answer_type": "clarification", "route_display": "需要补充范围", "facts": [], "source_summary": [], "limitations": limitations[:4], }, "interaction_request": { "id": "turn-runtime-clarification", "type": "confirm_and_supplement", "title": "还需要确认这些点" if len(questions) > 1 else "还需要确认一点", "mode": "blocking", "presentation": "stepper", "questions": questions[:4], "supplement": {"enabled": True, "required": False, "placeholder": "也可以补充你的判断或已知依据。"}, "submit_label": "确认并继续", "cancel_label": "先不补充", }, "policy_version": "pmflow-turn-runtime-clarification-v1", } def _thread_workspace_clarification_questions(turn_state: PMFlowAITurnState) -> list[dict[str, Any]]: workspace = turn_state.thread_workspace labels: list[str] = [] if workspace is not None: labels.extend(str(item or "").strip() for item in list(workspace.pending_questions or []) if str(item or "").strip()) labels.extend(_user_gap_questions(turn_state)) return _question_models_from_labels(labels, id_prefix="workspace_gap") def _generic_clarification_questions(turn_state: PMFlowAITurnState) -> list[dict[str, Any]]: labels = _user_gap_questions(turn_state) return _question_models_from_labels(labels, id_prefix="blocking_context") def _user_gap_questions(turn_state: PMFlowAITurnState) -> list[str]: labels: list[str] = [] for gap in list(turn_state.operation_gaps or []): if gap.blocking and gap.user_actionable and gap.gap_type in {"user_clarification_gap", "user_authorization_gap"}: labels.append(gap.customer_question or gap.reason or gap.key) for gap in list(turn_state.evidence_state.evidence_gaps or []): if ( gap.severity == "blocking" and gap.user_actionable and gap.gap_type in {"user_clarification_gap", "user_authorization_gap"} ): labels.append(gap.customer_question or gap.label or gap.reason or gap.key) return _unique_text(labels, limit=6) def _question_models_from_labels(labels: list[str], *, id_prefix: str) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for index, label in enumerate(_unique_text(labels, limit=4), start=1): out.append( { "id": f"{id_prefix}_{index}", "label": label, "input_type": "text", "required": True, "placeholder": "直接补充这个判断条件;不知道也可以写你的当前假设。", "options": [], } ) return out def _stable_hash(seed: str) -> str: import hashlib return hashlib.sha1(str(seed or "").encode("utf-8"), usedforsecurity=False).hexdigest()[:12] def _safe_int(value: Any) -> int | None: try: if value is None: return None return int(value) except Exception: return None def _action_is_required_by_unresolved_system_gap(*, action: PMFlowAIRuntimeAction, turn_state: PMFlowAITurnState) -> bool: action_type = str(action.action_type or "").strip() if not action_type: return False for gap in list(turn_state.operation_gaps or []): if gap.user_actionable: continue if gap.gap_type not in {"system_evidence_gap", "freshness_gap"}: continue if not gap.blocking and not ( turn_state.sufficiency_state.needs_internal_evidence or turn_state.sufficiency_state.needs_external_evidence ): continue hinted_action = _canonical_action_hint(hint=str(gap.system_action_hint or "").strip(), gap=gap, turn_state=turn_state) if hinted_action == action_type: return True if not hinted_action and gap.gap_type == "freshness_gap" and action_type in {"external_search", "external_fetch"}: return True return False def _action_performed(turn_state: PMFlowAITurnState, action_type: RuntimeActionKind) -> bool: return any(item.action_type == action_type for item in list(turn_state.action_state.performed_actions or [])) def _action_matches_selected_frame(*, action: PMFlowAIRuntimeAction, turn_state: PMFlowAITurnState) -> bool: selected = turn_state.selected_frame if selected is None: return True authority = PMFlowAIEvidenceAuthority().decide(turn_state) if authority.required_first_action is not None and action.action_type == authority.required_first_action: return True if action.action_type in authority.forbidden_first_actions and not _action_performed(turn_state, "inspect_project_status"): return False operation = selected.operation_kind if action.action_type == "synthesize_answer": return True if operation == "list_collection": return action.action_type in {"inspect_surface_context", "inspect_saved_ideas", "resolve_project_objects"} if operation == "inspect_object": if action.action_type == "inspect_surface_context" and not _turn_object_is_strong(turn_state.object_state.primary_object): return True return action.action_type in {"resolve_project_objects", "inspect_project_status", "inspect_internal_projects"} if operation in {"judge_direction", "compare_objects"}: return action.action_type in {"external_search", "external_fetch", "inspect_surface_context", "inspect_saved_ideas"} if operation == "guide_execution": return action.action_type in {"inspect_surface_context", "inspect_project_status", "inspect_internal_projects", "ask_clarification"} if operation == "explain_assistant": return action.action_type == "synthesize_answer" return True def _has_actionable_user_gap(turn_state: PMFlowAITurnState) -> bool: for gap in list(turn_state.operation_gaps or []): if gap.blocking and gap.user_actionable and gap.gap_type in {"user_clarification_gap", "user_authorization_gap"}: return True for gap in list(turn_state.evidence_state.evidence_gaps or []): if gap.severity == "blocking" and gap.user_actionable and gap.gap_type in {"user_clarification_gap", "user_authorization_gap"}: return True return False def _verification_failure( *, failure_type: str, reason: str, state_patch: dict[str, Any], next_action_hint: str, customer_safe_summary: str = "", customer_safe_effect: dict[str, Any] | None = None, retry_would_change_state: bool = True, ) -> PMFlowAIVerificationFailure: safe_effect = dict(customer_safe_effect or {}) delta = { "packet_version": "pmflow_ai_verification_delta.v1", "verification_ok": False, "failure_type": failure_type, "claim_deltas": [], "boundary_deltas": [], "attempt_delta": { "action_type": "verify_answer", "status": "failed", "customer_visible_summary": customer_safe_summary, }, "retry_would_change_state": bool(retry_would_change_state), "internal_diagnostic": reason, "customer_safe_effect": safe_effect, "next_action_hint": next_action_hint, "state_patch": dict(state_patch or {}), } return PMFlowAIVerificationFailure( failure_id="runtime_verification_failed", failure_type=failure_type, reason=reason, internal_diagnostic=reason, customer_safe_summary=customer_safe_summary, customer_safe_effect=safe_effect, verification_delta=delta, retry_would_change_state=retry_would_change_state, state_patch=state_patch, next_action_hint=next_action_hint, ) def _record_contract_issues(turn_state: PMFlowAITurnState) -> None: issues = validate_turn_state_contract(turn_state) for index, issue in enumerate(issues[:6], start=1): turn_state.verification_failures.append( PMFlowAIVerificationFailure( failure_id=f"turn_contract_violation_{index}", failure_type="turn_contract_violation", reason=issue, internal_diagnostic=issue, customer_safe_summary="这轮内部状态合同没有形成可直接展示的稳定结论。", customer_safe_effect={"blocked_claims": ["不能把内部状态合同问题当成用户结论。"]}, verification_delta={ "packet_version": "pmflow_ai_verification_delta.v1", "verification_ok": False, "failure_type": "turn_contract_violation", "claim_deltas": [], "boundary_deltas": [], "attempt_delta": { "action_type": "verify_turn_contract", "status": "failed", "customer_visible_summary": "这轮内部状态合同没有形成可直接展示的稳定结论。", }, "retry_would_change_state": False, "internal_diagnostic": issue, "customer_safe_effect": {"blocked_claims": ["不能把内部状态合同问题当成用户结论。"]}, "next_action_hint": "repair_turn_contract", "state_patch": {"can_answer_now": False}, }, retry_would_change_state=False, state_patch={"can_answer_now": False}, next_action_hint="repair_turn_contract", ) ) def _required_sections(answer_shape: str) -> list[str]: return { "strategic_judgement": ["判断", "依据", "产品定位", "风险", "下一步"], "status_answer": ["当前状态", "依据", "缺口", "下一步"], "clarification": ["阻塞原因", "需要确认的问题"], "execution_guidance": ["可执行动作", "风险", "确认边界"], "fact_list": ["匹配结果", "依据", "下一步"], "composite_answer": ["分任务判断", "证据", "缺口", "下一步"], }.get(answer_shape, ["回答", "依据", "下一步"]) def _route_summary(route_plan: dict[str, Any]) -> dict[str, Any]: display_contract = _dict(route_plan.get("display_contract")) return { "route_hypothesis_only": True, "display_contract": display_contract, } def _initial_goal_step_label( *, hypothesis: dict[str, Any], thread_workspace: PMFlowAIThreadWorkspace | None, turn_delta: PMFlowAITurnDelta | None, ) -> str: subject = str(hypothesis.get("subject") or "").strip() if not subject and thread_workspace and thread_workspace.primary_object: subject = thread_workspace.primary_object.title or "" subject = subject.strip() if thread_workspace and thread_workspace.workspace_kind == "new_project_ideation": if subject: return f"识别目标:评估新项目方向「{subject}」是否值得做" return "识别目标:开启新项目方向判断" goal = str(hypothesis.get("normalized_goal") or hypothesis.get("desired_outcome") or "").strip() _ = turn_delta return f"识别目标:{goal[:120] or '回答当前问题'}" def _apply_source_authority_process_label(turn_state: PMFlowAITurnState) -> None: authority = PMFlowAIEvidenceAuthority().decide(turn_state) if not authority.process_label: return if not turn_state.runtime_steps: return first = turn_state.runtime_steps[0] turn_state.runtime_steps[0] = first.model_copy(update={"label": f"识别目标:{authority.process_label}"}) def _payload_summary(payload: dict[str, Any] | None) -> str: if not isinstance(payload, dict): return "" tool_result = payload.get("runtime_evidence_tool_result") if isinstance(payload.get("runtime_evidence_tool_result"), dict) else {} tool_summary = str(tool_result.get("summary") or "").strip() if tool_summary: return tool_summary[:180] canvas = payload.get("answer_canvas") if isinstance(payload.get("answer_canvas"), dict) else {} headline = str(canvas.get("headline") or "").strip() if headline: return headline[:180] answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() return answer[:180] def _runtime_step_label(*, action: PMFlowAIRuntimeAction, turn_state: PMFlowAITurnState) -> str: static_contract = _static_contract_from_turn_state(turn_state) if action.action_type == "synthesize_answer" and static_contract is not None: return static_contract.answer_step_label labels = { "inspect_surface_context": "读取空间上下文:检查项目、待确认、AI 进行中和暂存想法", "inspect_saved_ideas": "检查暂存想法:吸收 saved ideas 和新创意缺口", "inspect_internal_projects": "查询内部证据:读取项目状态、MVP、风险、容量或对象匹配", "resolve_project_objects": "解析项目对象:匹配当前空间可见项目", "inspect_project_status": "读取项目状态:吸收项目进展、任务和执行证据", "external_search": "查询外部证据:获取公开来源并进入证据校验", "external_fetch": "读取外部页面:补充来源细节", "ask_clarification": "判断阻塞缺口:准备一个必须确认的问题", "synthesize_answer": "生成建议:按当前证据输出回答", } return labels.get(action.action_type, f"执行动作:{action.action_type}") def _step_summary(step: PMFlowAIRuntimeStep) -> str | None: if not step.evidence_delta: return None return ";".join([item.fact for item in step.evidence_delta[:2]])[:240] def _answer_step_label(answer_shape: str) -> str: return { "strategic_judgement": "生成建议:给出判断、证据、定位、风险和下一步", "status_answer": "生成状态回答:整理当前状态、依据、缺口和下一步", "clarification": "生成追问:只问真正阻塞推进的问题", "execution_guidance": "生成执行建议:保留确认边界", "fact_list": "生成匹配结果:只返回命中的对象和依据", "composite_answer": "合并多任务答案:保留多个目标和证据", }.get(answer_shape, "生成回答:按证据表达当前结论") def _route_display(turn_state: PMFlowAITurnState) -> str: shape = str(turn_state.answer_state.answer_shape or "").strip() return shape or "turn_runtime" def _append_policy_version(value: Any, suffix: str) -> str: text = str(value or "").strip() if not text: return suffix if suffix in text.split("|"): return text return f"{text}|{suffix}" def _payload_is_final_answer(payload: dict[str, Any] | None) -> bool: if not isinstance(payload, dict): return False kind = str(payload.get("_runtime_payload_kind") or "").strip() if kind == "evidence": return False if kind == "final_answer": return True return bool(str(payload.get("answer") or payload.get("reply_text") or "").strip()) def _verification_failure_allows_retry(failure: PMFlowAIVerificationFailure | None) -> bool: if failure is None: return False if not bool(failure.retry_would_change_state): return False next_action = str(failure.next_action_hint or "").strip() if next_action in {"", "render_or_fail_closed"}: return False return True def _build_terminal_answer_state( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, answer: str = "", ) -> PMFlowAITerminalAnswerState: """Build the universal answer substrate before customer prose. This is intentionally not a scenario presenter. It converts the final turn state into claim/boundary ledgers that any later composer can consume without seeing raw verifier reasons, trace, prompt keys or runtime packets. """ static_contract = _static_contract_from_turn_state(turn_state) claims = _terminal_claims_from_turn_state(turn_state=turn_state, static_contract=static_contract, answer=answer) boundaries = _terminal_boundaries_from_turn_state(turn_state=turn_state, static_contract=static_contract) attempts = _terminal_attempts_from_turn_state(turn_state=turn_state) remaining_gaps = _terminal_remaining_gaps(turn_state) next_actions = _terminal_next_actions(turn_state) terminal_status = _terminal_status( turn_state=turn_state, claims=claims, boundaries=boundaries, remaining_gaps=remaining_gaps, ) intent = _terminal_answer_intent_plan( request=request, turn_state=turn_state, claims=claims, boundaries=boundaries, terminal_status=terminal_status, ) return PMFlowAITerminalAnswerState( user_goal=str( turn_state.turn_goal.desired_outcome or turn_state.turn_goal.user_goal or request.question or "" )[:400], target_object=_terminal_target_object(turn_state), terminal_status=terminal_status, claims=claims, boundaries=boundaries, attempts=attempts, answer_intent_plan=intent, remaining_gaps=remaining_gaps, next_possible_actions=next_actions, confidence=_terminal_confidence(turn_state=turn_state, claims=claims, boundaries=boundaries), ) def _terminal_claims_from_turn_state( *, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract | None, answer: str, ) -> list[PMFlowAIClaim]: claims: list[PMFlowAIClaim] = [] if static_contract is not None: for index, item in enumerate(list(static_contract.customer_safe_capabilities or ())[:6], start=1): text = str(item or "").strip() if not text: continue claims.append( PMFlowAIClaim( claim_id=f"static_capability_{index}", claim_text=text, claim_type="customer_safe_capability", support=list(static_contract.source_summary or ()), confidence=static_contract.confidence, customer_value="说明用户侧可以获得的产品能力边界。", allowed_to_state=True, ) ) for index, finding in enumerate(list(turn_state.evidence_state.known_facts or [])[:12], start=1): text = str(finding.implication or finding.fact or "").strip() if not text: continue claims.append( PMFlowAIClaim( claim_id=f"evidence_claim_{index}", claim_text=text, claim_type="evidence_supported_claim", support=[str(finding.source or "本轮证据")], confidence=finding.confidence, customer_value=str(finding.relevance_to_goal or "")[:400], allowed_to_state=True, ) ) direct = str(turn_state.judgement_state.direct_answer or "").strip() if direct: claims.append( PMFlowAIClaim( claim_id="judgement_direct_answer", claim_text=direct, claim_type="judgement", support=_terminal_source_labels(turn_state), confidence=turn_state.evidence_state.evidence_quality, customer_value="直接回应本轮用户目标。", allowed_to_state=True, ) ) judgement_claim_rows: list[tuple[str, str, str]] = [] if turn_state.judgement_state.opportunity: judgement_claim_rows.append(("judgement_opportunity", "judgement", turn_state.judgement_state.opportunity)) if turn_state.judgement_state.better_angle: judgement_claim_rows.append(("judgement_better_angle", "judgement", turn_state.judgement_state.better_angle)) for index, item in enumerate(list(turn_state.judgement_state.can_do or [])[:4], start=1): judgement_claim_rows.append((f"judgement_can_do_{index}", "judgement_action", item)) for index, item in enumerate(list(turn_state.judgement_state.mvp or [])[:4], start=1): judgement_claim_rows.append((f"judgement_mvp_{index}", "judgement_mvp", item)) for index, item in enumerate(list(turn_state.judgement_state.next_actions or [])[:4], start=1): judgement_claim_rows.append((f"judgement_next_action_{index}", "judgement_next_action", item)) for claim_id, claim_type, text in judgement_claim_rows[:10]: cleaned = str(text or "").strip() if not cleaned: continue claims.append( PMFlowAIClaim( claim_id=claim_id, claim_text=cleaned, claim_type=claim_type, support=_terminal_source_labels(turn_state), confidence=turn_state.judgement_state.confidence, customer_value="补充本轮判断中的可行动信息。", allowed_to_state=True, ) ) if not claims and str(answer or "").strip(): claims.append( PMFlowAIClaim( claim_id="answer_draft_claim", claim_text=str(answer or "").strip()[:600], claim_type="answer_draft", support=_terminal_source_labels(turn_state), confidence=turn_state.evidence_state.evidence_quality, customer_value="作为最终 composer 的候选表达材料,需要继续经边界校验。", allowed_to_state=True, ) ) return _dedupe_terminal_claims(claims) def _terminal_boundaries_from_turn_state( *, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract | None, ) -> list[PMFlowAIBoundary]: boundaries: list[PMFlowAIBoundary] = [] if static_contract is not None and turn_state.disclosure_state.allowed_response_mode == "protected_refusal": limitations = [str(item or "").strip() for item in list(static_contract.limitations or ()) if str(item or "").strip()] protected = [str(item or "").strip() for item in list(turn_state.disclosure_state.protected_categories or []) if str(item or "").strip()] alternatives = [str(item or "").strip() for item in list(static_contract.customer_safe_capabilities or ()) if str(item or "").strip()] boundaries.append( PMFlowAIBoundary( boundary_id="protected_internal_boundary", blocked_claim="不能披露 PMFlow 的内部实现、技术架构、源码、数据库字段、prompt、模型供应商、内部路由、trace 或工具调用细节。", boundary_type="protected_internal_information", customer_safe_reason="这属于 PMFlow 内部实现与机密信息,用户侧入口只能说明客户可见的产品能力边界。", allowed_alternative="可以说明 PMFlow 在空间入口和具体项目里可使用的产品能力。", support=list(static_contract.source_summary or ()), internal_diagnostic=";".join([*limitations[:3], *protected[:5]])[:800], ) ) for index, failure in enumerate(list(turn_state.verification_failures or [])[:8], start=1): effect = failure.customer_safe_effect if isinstance(failure.customer_safe_effect, dict) else {} blocked_claims = [ str(item or "").strip() for item in list(effect.get("blocked_claims") or []) if str(item or "").strip() ] answerable_parts = [ str(item or "").strip() for item in list(effect.get("answerable_parts") or []) if str(item or "").strip() ] customer_reason = str(failure.customer_safe_summary or "").strip() if not customer_reason and not blocked_claims and not answerable_parts: customer_reason = "这轮内部校验没有形成可安全展示的结论,不能把诊断信息当成用户答案。" boundaries.append( PMFlowAIBoundary( boundary_id=f"verification_boundary_{index}", blocked_claim=";".join(blocked_claims)[:600] or "不能把内部校验失败原因当成用户结论。", boundary_type=str(failure.failure_type or "verification_boundary")[:100], customer_safe_reason=customer_reason, allowed_alternative=";".join(answerable_parts)[:500], support=[], internal_diagnostic=str(failure.internal_diagnostic or failure.reason or "")[:800], ) ) for index, gap in enumerate(list(turn_state.operation_gaps or [])[:8], start=1): reason = str(gap.customer_question or gap.reason or gap.key or "").strip() if not reason: continue boundaries.append( PMFlowAIBoundary( boundary_id=f"operation_gap_boundary_{index}", blocked_claim="这部分还不能直接当成已确认结论。", boundary_type=str(gap.gap_type or "operation_gap"), customer_safe_reason=reason, allowed_alternative=str(gap.system_action_hint or "").strip(), support=[], ) ) for index, item in enumerate(list(turn_state.judgement_state.should_not_do or [])[:4], start=1): text = str(item or "").strip() if not text: continue boundaries.append( PMFlowAIBoundary( boundary_id=f"judgement_should_not_do_{index}", blocked_claim=text, boundary_type="judgement_boundary", customer_safe_reason=text, allowed_alternative="", support=_terminal_source_labels(turn_state), ) ) for index, item in enumerate(list(turn_state.judgement_state.risks or [])[:4], start=1): text = str(item or "").strip() if not text: continue boundaries.append( PMFlowAIBoundary( boundary_id=f"judgement_risk_{index}", blocked_claim=text, boundary_type="risk_boundary", customer_safe_reason=text, allowed_alternative="", support=_terminal_source_labels(turn_state), ) ) return _dedupe_terminal_boundaries(boundaries) def _terminal_attempts_from_turn_state(turn_state: PMFlowAITurnState) -> list[PMFlowAIAttempt]: attempts: list[PMFlowAIAttempt] = [] for index, step in enumerate(list(turn_state.runtime_steps or [])[:12], start=1): attempts.append( PMFlowAIAttempt( attempt_id=f"runtime_step_{index}", action_type=str(step.action_type or step.key or "").strip(), status=str(step.status or "").strip(), target=str(step.key or "").strip(), customer_visible_summary=_customer_attempt_summary(str(step.label or step.key or "")), ) ) for index, failure in enumerate(list(turn_state.verification_failures or [])[:8], start=1): attempts.append( PMFlowAIAttempt( attempt_id=f"verification_failure_{index}", action_type="verify_answer", status="failed", target=str(failure.failure_type or "").strip(), customer_visible_summary=str(failure.customer_safe_summary or "校验结果没有形成可直接展示的结论。"), produced_boundary_ids=[f"verification_boundary_{index}"], internal_diagnostic=str(failure.internal_diagnostic or failure.reason or "")[:800], ) ) return attempts[:20] def _terminal_answer_intent_plan( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], terminal_status: str, ) -> PMFlowAIAnswerIntentPlan: planner = _plan_terminal_answer_intent( request=request, turn_state=turn_state, claims=claims, boundaries=boundaries, terminal_status=terminal_status, ) return PMFlowAIAnswerIntentPlan( opening_move=planner["opening_move"], answer_focus=planner["answer_focus"], user_value_focus=planner["user_value_focus"], detail_level=planner["detail_level"], structure_preference=planner["structure_preference"], lead_claim_id=planner["lead_claim_id"], lead_boundary_id=planner["lead_boundary_id"], answer_moves=planner["answer_moves"], composition_guidance=planner["composition_guidance"], anti_template_rules=planner["anti_template_rules"], avoid_sections=planner["avoid_sections"], avoid_repeating_previous=planner["avoid_repeating_previous"], must_include_claim_ids=planner["must_include_claim_ids"], must_include_boundary_ids=planner["must_include_boundary_ids"], optional_claim_ids=planner["optional_claim_ids"], ask_user=turn_state.sufficiency_state.needs_user_input, ) def _plan_terminal_answer_intent( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], terminal_status: str, ) -> dict[str, Any]: lead_boundary = _select_lead_boundary(boundaries) lead_claim = _select_lead_claim(claims=claims, lead_boundary=lead_boundary) must_claim_ids = [item.claim_id for item in claims[:6] if item.allowed_to_state and item.claim_id] must_boundary_ids = [item.boundary_id for item in boundaries[:6] if item.boundary_id] focus = _answer_intent_focus( request=request, turn_state=turn_state, lead_claim=lead_claim, lead_boundary=lead_boundary, ) answer_moves = _answer_intent_moves( terminal_status=terminal_status, lead_claim=lead_claim, lead_boundary=lead_boundary, needs_user_input=turn_state.sufficiency_state.needs_user_input, ) return { "opening_move": _answer_opening_move( terminal_status=terminal_status, lead_claim=lead_claim, lead_boundary=lead_boundary, needs_user_input=turn_state.sufficiency_state.needs_user_input, ), "answer_focus": focus, "user_value_focus": _answer_user_value_focus( turn_state=turn_state, lead_claim=lead_claim, lead_boundary=lead_boundary, ), "detail_level": _answer_detail_level( request=request, terminal_status=terminal_status, claims=claims, boundaries=boundaries, ), "structure_preference": _answer_structure_preference( terminal_status=terminal_status, claims=claims, boundaries=boundaries, ), "lead_claim_id": str(lead_claim.claim_id if lead_claim else ""), "lead_boundary_id": str(lead_boundary.boundary_id if lead_boundary else ""), "answer_moves": answer_moves, "composition_guidance": _answer_composition_guidance( terminal_status=terminal_status, lead_claim=lead_claim, lead_boundary=lead_boundary, ), "anti_template_rules": _answer_anti_template_rules( terminal_status=terminal_status, claims=claims, boundaries=boundaries, ), "avoid_sections": _answer_should_avoid_sections( request=request, terminal_status=terminal_status, claims=claims, boundaries=boundaries, ), "avoid_repeating_previous": True, "must_include_claim_ids": must_claim_ids, "must_include_boundary_ids": must_boundary_ids, "optional_claim_ids": [item.claim_id for item in claims[6:10] if item.claim_id], } def _select_lead_boundary(boundaries: list[PMFlowAIBoundary]) -> PMFlowAIBoundary | None: for boundary in list(boundaries or []): if str(boundary.customer_safe_reason or boundary.blocked_claim or "").strip(): return boundary return None def _select_lead_claim(*, claims: list[PMFlowAIClaim], lead_boundary: PMFlowAIBoundary | None) -> PMFlowAIClaim | None: for claim in list(claims or []): if not claim.allowed_to_state or not str(claim.claim_text or "").strip(): continue if lead_boundary is not None and claim.claim_type == "answer_draft": continue return claim for claim in list(claims or []): if claim.allowed_to_state and str(claim.claim_text or "").strip(): return claim return None def _answer_intent_focus( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, lead_claim: PMFlowAIClaim | None, lead_boundary: PMFlowAIBoundary | None, ) -> str: objective = str(turn_state.answer_objective.primary_user_need or turn_state.turn_goal.desired_outcome or "").strip() if lead_boundary is not None: boundary_text = str(lead_boundary.blocked_claim or lead_boundary.customer_safe_reason or "").strip() if boundary_text: return boundary_text[:400] if lead_claim is not None and str(lead_claim.customer_value or "").strip(): return str(lead_claim.customer_value).strip()[:400] return objective[:400] or str(request.question or "直接回应用户本轮问题").strip()[:400] def _answer_user_value_focus( *, turn_state: PMFlowAITurnState, lead_claim: PMFlowAIClaim | None, lead_boundary: PMFlowAIBoundary | None, ) -> str: if lead_boundary is not None and str(lead_boundary.allowed_alternative or "").strip(): return str(lead_boundary.allowed_alternative).strip()[:400] if lead_claim is not None and str(lead_claim.customer_value or "").strip(): return str(lead_claim.customer_value).strip()[:400] if turn_state.judgement_state.next_actions: return str(turn_state.judgement_state.next_actions[0])[:400] return "把本轮可确认内容、不能确认边界和下一步用用户能理解的话说清楚。" def _answer_opening_move( *, terminal_status: str, lead_claim: PMFlowAIClaim | None, lead_boundary: PMFlowAIBoundary | None, needs_user_input: bool, ) -> str: if needs_user_input: return "ask_once_for_missing_user_input" if lead_boundary is not None and lead_claim is not None: return "answer_boundary_then_useful_alternative" if lead_boundary is not None: return "state_boundary_directly" if lead_claim is not None: return "lead_with_confirmed_claim" if terminal_status in {"blocked", "partially_answerable"}: return "state_current_limit_without_filling_blanks" return "direct_answer" def _answer_intent_moves( *, terminal_status: str, lead_claim: PMFlowAIClaim | None, lead_boundary: PMFlowAIBoundary | None, needs_user_input: bool, ) -> list[str]: moves: list[str] = [] if needs_user_input: moves.append("一次性说明缺什么以及为什么影响判断") if lead_boundary is not None: moves.append("先说清不能声明或不能披露的边界") if str(lead_boundary.allowed_alternative or "").strip(): moves.append("给出用户仍然可以获得的替代信息") if lead_claim is not None: moves.append("只陈述 claim ledger 中允许声明的内容") if terminal_status == "partially_answerable": moves.append("把能答和不能答分开,不把缺口包装成结论") if not moves: moves.append("直接回答本轮可确认内容") return moves[:8] def _answer_detail_level( *, request: RAGAskRequest, terminal_status: str, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> str: question_length = len(str(request.question or "").strip()) material_count = len(claims) + len(boundaries) if terminal_status in {"blocked", "refused_by_boundary"} and material_count <= 4 and question_length <= 80: return "brief" if material_count >= 8 or question_length >= 180: return "detailed" return "balanced" def _answer_structure_preference( *, terminal_status: str, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> str: material_count = len(claims) + len(boundaries) if terminal_status in {"blocked", "refused_by_boundary"} and material_count <= 4: return "short_paragraphs_no_heading" if material_count >= 6: return "compact_grouped_bullets" return "natural_short_paragraphs" def _answer_composition_guidance( *, terminal_status: str, lead_claim: PMFlowAIClaim | None, lead_boundary: PMFlowAIBoundary | None, ) -> list[str]: guidance = [ "先回答用户真正问的点,不解释内部流程。", "只使用 Claim Ledger 和 Boundary Ledger 中的客户安全材料。", ] if lead_boundary is not None: guidance.append("边界要说成人话,不出现合同、runtime、verifier、payload 或 trace。") if lead_claim is not None: guidance.append("claim 要转成用户影响和可行动信息,不堆内部证据摘要。") if terminal_status == "partially_answerable": guidance.append("能确定的先说,不能确定的单独收束成边界或缺口。") return guidance[:8] def _answer_anti_template_rules( *, terminal_status: str, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> list[str]: rules = [ "不要固定套用“判断依据 / 证据边界 / 下一步”三段式。", "不要复述 AnswerIntentPlan、Claim Ledger、Boundary Ledger 这些结构名称。", "不要把内部校验、合同、runtime、trace、prompt、字段名讲给用户。", ] if terminal_status in {"blocked", "refused_by_boundary"}: rules.append("短边界问题用短回答,不展开成项目分析。") if len(claims) <= 2 and len(boundaries) <= 2: rules.append("材料很少时不要凑段落或凑列表。") return rules[:8] def _answer_should_avoid_sections( *, request: RAGAskRequest, terminal_status: str, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> bool: question_length = len(str(request.question or "").strip()) material_count = len(claims) + len(boundaries) if material_count <= 4 and question_length <= 120: return True return terminal_status in {"blocked", "refused_by_boundary"} and material_count <= 6 def _terminal_status( *, turn_state: PMFlowAITurnState, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], remaining_gaps: list[str], ) -> str: if turn_state.disclosure_state.allowed_response_mode == "protected_refusal": return "refused_by_boundary" if turn_state.sufficiency_state.needs_user_input: return "needs_user_input" if boundaries and claims: return "partially_answerable" if boundaries and not claims: return "blocked" if remaining_gaps: return "partially_answerable" return "answerable" def _terminal_confidence( *, turn_state: PMFlowAITurnState, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> EvidenceConfidence: if claims: confidences = [item.confidence for item in claims if item.confidence != "unknown"] if confidences: return confidences[0] if boundaries: return "medium" return turn_state.evidence_state.evidence_quality def _terminal_target_object(turn_state: PMFlowAITurnState) -> str: primary = turn_state.object_state.primary_object if primary is not None: title = str(primary.title or primary.object_ref or primary.object_type or "").strip() if title: return title[:240] return str(turn_state.problem_frame.subject or _turn_external_subject(turn_state) or "").strip()[:240] def _terminal_remaining_gaps(turn_state: PMFlowAITurnState) -> list[str]: rows: list[str] = [] for gap in list(turn_state.operation_gaps or [])[:10]: rows.append(str(gap.customer_question or gap.reason or gap.key or "").strip()) for gap in list(turn_state.evidence_state.evidence_gaps or [])[:10]: rows.append(str(gap.customer_question or gap.label or gap.reason or gap.key or "").strip()) return _unique_text(rows, limit=12) def _terminal_next_actions(turn_state: PMFlowAITurnState) -> list[str]: rows: list[str] = [] rows.extend(str(item or "").strip() for item in list(turn_state.judgement_state.next_actions or [])[:6]) for gap in list(turn_state.operation_gaps or [])[:6]: if str(gap.system_action_hint or "").strip(): rows.append(str(gap.system_action_hint or "").strip()) return _unique_text(rows, limit=8) def _terminal_source_labels(turn_state: PMFlowAITurnState) -> list[str]: rows: list[str] = [] for source in list(turn_state.evidence_state.sources or [])[:8]: if not isinstance(source, dict): continue label = str(source.get("display_label") or source.get("label") or source.get("source") or "").strip() if label: rows.append(label) return _unique_text(rows, limit=8) def _render_terminal_state_fallback(terminal_state: PMFlowAITerminalAnswerState) -> str: answer = _compose_terminal_customer_answer(terminal_state) findings = _critique_terminal_customer_answer(terminal_state=terminal_state, answer=answer) if findings: answer = _repair_terminal_customer_answer(terminal_state=terminal_state, answer=answer, findings=findings) return answer.strip() def _compose_terminal_customer_answer(terminal_state: PMFlowAITerminalAnswerState) -> str: """Compose a customer-safe answer from terminal ledgers and intent. This layer is intentionally generic: it follows the AnswerIntentPlan and ledger material, but it does not choose scenario presenters or inspect raw user wording. """ intent = terminal_state.answer_intent_plan claims = [claim for claim in list(terminal_state.claims or []) if claim.allowed_to_state and str(claim.claim_text or "").strip()] boundaries = [ boundary for boundary in list(terminal_state.boundaries or []) if str(boundary.customer_safe_reason or boundary.blocked_claim or "").strip() ] lead_boundary = _terminal_boundary_by_id(boundaries, intent.lead_boundary_id) or (boundaries[0] if boundaries else None) lead_claim = _terminal_claim_by_id(claims, intent.lead_claim_id) or (claims[0] if claims else None) included_claims = _terminal_ordered_claims(claims=claims, intent=intent, lead_claim=lead_claim) included_boundaries = _terminal_ordered_boundaries(boundaries=boundaries, intent=intent, lead_boundary=lead_boundary) if intent.structure_preference == "compact_grouped_bullets" and not intent.avoid_sections: return _compose_grouped_terminal_answer( terminal_state=terminal_state, intent=intent, claims=included_claims, boundaries=included_boundaries, ) return _compose_paragraph_terminal_answer( terminal_state=terminal_state, intent=intent, claims=included_claims, boundaries=included_boundaries, ) def _compose_paragraph_terminal_answer( *, terminal_state: PMFlowAITerminalAnswerState, intent: PMFlowAIAnswerIntentPlan, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> str: lead_boundary = boundaries[0] if boundaries else None lead_claim = claims[0] if claims else None paragraphs: list[str] = [] if lead_boundary is not None: boundary_text = _boundary_customer_text(lead_boundary) if boundary_text: paragraphs.append(boundary_text) alternative = _boundary_customer_alternative(lead_boundary) if alternative: paragraphs.append(alternative) if lead_claim is not None: claim_text = _claim_customer_text(lead_claim) if claim_text and not _same_text_in_rows(claim_text, paragraphs): paragraphs.append(claim_text) for claim in claims[1:4]: claim_text = _claim_customer_text(claim) if claim_text and not _same_text_in_rows(claim_text, paragraphs): paragraphs.append(claim_text) if not paragraphs and terminal_state.remaining_gaps: paragraphs.append(_gap_customer_sentence(terminal_state.remaining_gaps)) if not paragraphs: paragraphs.append("这轮还没有形成可以可靠展示的结论。") if intent.detail_level == "brief": paragraphs = paragraphs[:2] if len(paragraphs) > 1 else paragraphs[:1] return "\n\n".join(_customer_safe_sentence(row) for row in paragraphs if _customer_safe_sentence(row)).strip() def _compose_grouped_terminal_answer( *, terminal_state: PMFlowAITerminalAnswerState, intent: PMFlowAIAnswerIntentPlan, claims: list[PMFlowAIClaim], boundaries: list[PMFlowAIBoundary], ) -> str: lines: list[str] = [] if claims: first = _claim_customer_text(claims[0]) if first: lines.append(first) claim_rows = [_claim_customer_text(item) for item in claims[1:6]] claim_rows = [item for item in claim_rows if item and not _same_text_in_rows(item, lines)] if claim_rows: lines.append("") lines.extend(f"- {item}" for item in claim_rows) if boundaries: boundary_rows = [_boundary_customer_text(item) for item in boundaries[:3]] boundary_rows = [item for item in boundary_rows if item and not _same_text_in_rows(item, lines)] if boundary_rows: if lines: lines.append("") lines.extend(f"- {item}" for item in boundary_rows) if not lines and terminal_state.remaining_gaps: lines.append(_gap_customer_sentence(terminal_state.remaining_gaps)) if intent.detail_level == "brief": lines = lines[:3] return "\n".join(_customer_safe_sentence(row) if not row.startswith("- ") else f"- {_customer_safe_sentence(row[2:])}" for row in lines).strip() def _critique_terminal_customer_answer(*, terminal_state: PMFlowAITerminalAnswerState, answer: str) -> list[str]: text = str(answer or "").strip() findings: list[str] = [] if not text: findings.append("empty_answer") return findings leaked = _answer_runtime_leak_terms(text) if leaked: findings.append("internal_runtime_terms") template_terms = ("判断依据", "证据边界", "下一步", "综合判断", "执行建议", "方向判断") if _has_fixed_section_heading(text=text, section_terms=template_terms) and terminal_state.answer_intent_plan.avoid_sections: findings.append("fixed_section_template") if _has_repeated_answer_sentences(text): findings.append("repeated_sentence") if terminal_state.answer_intent_plan.detail_level == "brief" and len(text) > 360: findings.append("too_verbose_for_brief_intent") lead_boundary = _terminal_boundary_by_id( list(terminal_state.boundaries or []), terminal_state.answer_intent_plan.lead_boundary_id, ) if lead_boundary is not None: expected = _boundary_customer_text(lead_boundary) if expected and not _answer_contains_meaning(answer=text, expected=expected): findings.append("missing_lead_boundary") lead_claim = _terminal_claim_by_id( list(terminal_state.claims or []), terminal_state.answer_intent_plan.lead_claim_id, ) if lead_claim is not None: expected = _claim_customer_text(lead_claim) if expected and not _answer_contains_meaning(answer=text, expected=expected): findings.append("missing_lead_claim") return findings[:8] def _repair_terminal_customer_answer( *, terminal_state: PMFlowAITerminalAnswerState, answer: str, findings: list[str], ) -> str: _ = answer, findings intent = terminal_state.answer_intent_plan claims = [claim for claim in list(terminal_state.claims or []) if claim.allowed_to_state and str(claim.claim_text or "").strip()] boundaries = [ boundary for boundary in list(terminal_state.boundaries or []) if str(boundary.customer_safe_reason or boundary.blocked_claim or "").strip() ] lead_boundary = _terminal_boundary_by_id(boundaries, intent.lead_boundary_id) or (boundaries[0] if boundaries else None) lead_claim = _terminal_claim_by_id(claims, intent.lead_claim_id) or (claims[0] if claims else None) rows: list[str] = [] if lead_boundary is not None: boundary_text = _boundary_customer_text(lead_boundary) if boundary_text: rows.append(boundary_text) alternative = _boundary_customer_alternative(lead_boundary) if alternative and intent.detail_level != "brief": rows.append(alternative) if lead_claim is not None: claim_text = _claim_customer_text(lead_claim) if claim_text and not _same_text_in_rows(claim_text, rows): rows.append(claim_text) if not rows and terminal_state.remaining_gaps: rows.append(_gap_customer_sentence(terminal_state.remaining_gaps)) if not rows: rows.append("这轮还没有形成可以可靠展示的结论。") if intent.detail_level == "brief": rows = rows[:2] return "\n\n".join(_customer_safe_sentence(row) for row in rows if _customer_safe_sentence(row)).strip() def _terminal_claim_by_id(claims: list[PMFlowAIClaim], claim_id: str) -> PMFlowAIClaim | None: target = str(claim_id or "").strip() if not target: return None for claim in claims: if str(claim.claim_id or "").strip() == target: return claim return None def _terminal_boundary_by_id(boundaries: list[PMFlowAIBoundary], boundary_id: str) -> PMFlowAIBoundary | None: target = str(boundary_id or "").strip() if not target: return None for boundary in boundaries: if str(boundary.boundary_id or "").strip() == target: return boundary return None def _terminal_ordered_claims( *, claims: list[PMFlowAIClaim], intent: PMFlowAIAnswerIntentPlan, lead_claim: PMFlowAIClaim | None, ) -> list[PMFlowAIClaim]: ordered: list[PMFlowAIClaim] = [] if lead_claim is not None: ordered.append(lead_claim) for claim_id in list(intent.must_include_claim_ids or []): claim = _terminal_claim_by_id(claims, claim_id) if claim is not None and claim not in ordered: ordered.append(claim) for claim in claims: if claim not in ordered: ordered.append(claim) return ordered[:8] def _terminal_ordered_boundaries( *, boundaries: list[PMFlowAIBoundary], intent: PMFlowAIAnswerIntentPlan, lead_boundary: PMFlowAIBoundary | None, ) -> list[PMFlowAIBoundary]: ordered: list[PMFlowAIBoundary] = [] if lead_boundary is not None: ordered.append(lead_boundary) for boundary_id in list(intent.must_include_boundary_ids or []): boundary = _terminal_boundary_by_id(boundaries, boundary_id) if boundary is not None and boundary not in ordered: ordered.append(boundary) for boundary in boundaries: if boundary not in ordered: ordered.append(boundary) return ordered[:8] def _claim_customer_text(claim: PMFlowAIClaim) -> str: text = str(claim.claim_text or claim.customer_value or "").strip() return _customer_safe_sentence(text) def _boundary_customer_text(boundary: PMFlowAIBoundary) -> str: text = str(boundary.customer_safe_reason or boundary.blocked_claim or "").strip() return _customer_safe_sentence(text) def _boundary_customer_alternative(boundary: PMFlowAIBoundary) -> str: text = str(boundary.allowed_alternative or "").strip() return _customer_safe_sentence(text) def _gap_customer_sentence(gaps: list[str]) -> str: gap = str((gaps or [""])[0] or "").strip() if not gap: return "这轮还缺少会影响判断的关键信息,不能把未确认内容当成结论。" return f"现在还缺少会影响判断的信息:{gap}。" def _customer_safe_sentence(text: str) -> str: cleaned = str(text or "").strip() if not cleaned: return "" replacements = { "AnswerIntentPlan": "表达策略", "Claim Ledger": "可说明内容", "Boundary Ledger": "边界", "Answer Runtime": "回答生成流程", "runtime": "处理流程", "Runtime": "处理流程", "verifier": "校验", "Verifier": "校验", "payload": "返回内容", "trace": "诊断记录", "prompt": "内部配置", "字段": "内部信息", } for old, new in replacements.items(): cleaned = cleaned.replace(old, new) cleaned = " ".join(cleaned.split()) return cleaned[:700] def _same_text_in_rows(text: str, rows: list[str]) -> bool: candidate = _normalize_answer_text(text) return bool(candidate and any(_normalize_answer_text(row) == candidate for row in rows)) def _normalize_answer_text(text: str) -> str: return "".join(ch for ch in str(text or "").lower() if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") def _has_repeated_answer_sentences(text: str) -> bool: parts = [ _normalize_answer_text(part) for part in str(text or "").replace("\n", "。").replace(";", "。").replace(";", "。").split("。") ] seen: set[str] = set() for part in parts: if len(part) < 8: continue if part in seen: return True seen.add(part) return False def _has_fixed_section_heading(*, text: str, section_terms: tuple[str, ...]) -> bool: rows = [row.strip() for row in str(text or "").replace("\r", "\n").split("\n")] if len(rows) <= 1: rows = [part.strip() for part in str(text or "").replace(";", "。").split("。")] for row in rows: compact = row.lstrip("-*0123456789.、)) ").strip() if any(compact.startswith(f"{term}:") or compact.startswith(f"{term}:") for term in section_terms): return True return False def _answer_contains_meaning(*, answer: str, expected: str) -> bool: answer_norm = _normalize_answer_text(answer) expected_norm = _normalize_answer_text(expected) if not expected_norm: return True if expected_norm in answer_norm: return True tokens = [expected_norm[i : i + 4] for i in range(0, max(0, len(expected_norm) - 3), 4)] tokens = [token for token in tokens if len(token) >= 4] if not tokens: return False matches = sum(1 for token in tokens if token in answer_norm) return matches >= max(1, len(tokens) // 2) def _customer_attempt_summary(label: str) -> str: text = str(label or "").strip() protected_terms = ("合同", "Runtime", "runtime", "Verifier", "verifier", "payload", "trace", "Answer Runtime") if any(item in text for item in protected_terms): return "整理本轮可公开说明的范围。" return text[:500] def _dedupe_terminal_claims(claims: list[PMFlowAIClaim]) -> list[PMFlowAIClaim]: out: list[PMFlowAIClaim] = [] seen: set[str] = set() for claim in claims: key = str(claim.claim_text or claim.claim_id or "").strip() if not key or key in seen: continue seen.add(key) out.append(claim) if len(out) >= 20: break return out def _dedupe_terminal_boundaries(boundaries: list[PMFlowAIBoundary]) -> list[PMFlowAIBoundary]: out: list[PMFlowAIBoundary] = [] seen: set[str] = set() for boundary in boundaries: key = str(boundary.blocked_claim or boundary.customer_safe_reason or boundary.boundary_id or "").strip() if not key or key in seen: continue seen.add(key) out.append(boundary) if len(out) >= 20: break return out def _turn_external_subject(turn_state: PMFlowAITurnState) -> str: primary = turn_state.object_state.primary_object title = str(getattr(primary, "title", "") or "").strip() if primary else "" binding = str(getattr(primary, "binding_strength", "") or "").strip().lower() if primary else "" if title and binding not in {"", "none", "context", "visible_context"}: return title if turn_state.thread_workspace and turn_state.thread_workspace.primary_object: title = str(turn_state.thread_workspace.primary_object.title or "").strip() if title: return title return str(turn_state.problem_frame.subject or "").strip() def _sources_used_from_turn_sources(sources: list[dict[str, Any]] | None) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for item in list(sources or [])[:12]: if not isinstance(item, dict): continue url = str(item.get("target_url") or item.get("url") or "").strip() label = str(item.get("display_label") or item.get("label") or item.get("raw_source_label") or item.get("source") or "外部公开来源").strip() source = str(item.get("source") or item.get("source_type") or "").strip().lower() if url: source = "external" key = url or f"{source}:{label}" if key in seen: continue seen.add(key) row = {"source": source or "pmflow", "display_label": label[:180] or "外部公开来源"} if url: row["target_url"] = url out.append(row) if len(out) >= 6: break return out def _has_external_source(sources: list[dict[str, Any]] | None) -> bool: for item in list(sources or []): if not isinstance(item, dict): continue source = str(item.get("source") or item.get("source_type") or "").strip().lower() if source == "external": return True if str(item.get("target_url") or item.get("url") or "").strip(): return True return False def _refresh_answer_runtime_plan(*, request: RAGAskRequest, turn_state: PMFlowAITurnState) -> None: turn_state.answer_objective = _build_answer_objective(request=request, turn_state=turn_state) turn_state.answer_plan = _build_answer_plan(turn_state=turn_state) if not any(step.key == "plan_answer" for step in list(turn_state.runtime_steps or [])): turn_state.runtime_steps.append( PMFlowAIRuntimeStep( key="plan_answer", label="规划回答:确认用户需要、证据用法和表达边界", action_type="synthesize_answer", status="completed", ) ) def _build_answer_objective(*, request: RAGAskRequest, turn_state: PMFlowAITurnState) -> PMFlowAIAnswerObjective: selected = turn_state.selected_frame static_contract = _static_contract_from_turn_state(turn_state) objective = PMFlowAIAnswerObjective( primary_user_need=_answer_primary_need(request=request, turn_state=turn_state), required_decision=_answer_required_decision(turn_state), expected_output=_expected_output_from_turn_state(turn_state), continuation_mode=_continuation_mode_from_turn_state(turn_state), must_include=_answer_must_include(turn_state=turn_state, static_contract=static_contract), must_not_include=_answer_must_not_include(turn_state=turn_state, static_contract=static_contract), evidence_to_use=_answer_evidence_to_use(turn_state=turn_state, static_contract=static_contract), open_gaps=_answer_open_gaps(turn_state), confidence=turn_state.evidence_state.evidence_quality, ) if selected is not None and not objective.expected_output: objective.expected_output = str(selected.answer_shape or turn_state.answer_state.answer_shape or "general_answer") return objective def _build_answer_plan(*, turn_state: PMFlowAITurnState) -> PMFlowAIAnswerPlan: objective = turn_state.answer_objective protected = turn_state.disclosure_state.allowed_response_mode == "protected_refusal" has_facts = bool(turn_state.evidence_state.known_facts or turn_state.evidence_state.structured_evidence) has_user_gap = _has_actionable_user_gap(turn_state) if protected: strategy = "state_disclosure_boundary_then_offer_customer_safe_alternative" boundary_mode = "brief_required" elif has_facts: strategy = "turn_evidence_into_decision_and_next_actions" boundary_mode = "only_if_needed" elif has_user_gap: strategy = "ask_once_for_blocking_user_inputs" boundary_mode = "only_if_needed" else: strategy = "state_current_limits_without_inventing_evidence" boundary_mode = "only_if_needed" repeated_content_policy = ( "avoid_repeating_prior_boundary_or_evidence_summary; if the user repeats a close question, acknowledge the stable boundary briefly and add only new useful angle" ) return PMFlowAIAnswerPlan( plan_id="answer_plan", answer_strategy=strategy, repeated_content_policy=repeated_content_policy, boundary_statement_mode=boundary_mode, evidence_usage_plan=_evidence_usage_plan(turn_state), user_value_plan=_user_value_plan(turn_state, repeated_content_policy=repeated_content_policy), sections=_answer_sections(turn_state), questions_to_ask_once=_user_gap_questions(turn_state), draft_sources=_draft_sources(turn_state), ) def _answer_primary_need(*, request: RAGAskRequest, turn_state: PMFlowAITurnState) -> str: goal = str(turn_state.turn_goal.desired_outcome or turn_state.turn_goal.user_goal or "").strip() if goal: return goal[:240] subject = str(turn_state.problem_frame.subject or "").strip() if subject: return subject[:240] return str(request.question or "回答当前问题").strip()[:240] def _answer_required_decision(turn_state: PMFlowAITurnState) -> str: if turn_state.disclosure_state.allowed_response_mode == "protected_refusal": return "explain_customer_safe_boundary_and_allowed_alternative" if turn_state.judgement_state.direct_answer: return turn_state.judgement_state.direct_answer[:240] if turn_state.sufficiency_state.needs_user_input: return "identify_blocking_user_inputs" if turn_state.evidence_state.known_facts or turn_state.evidence_state.structured_evidence: return "convert_available_evidence_to_user_decision" return "state_current_answer_limits" def _expected_output_from_turn_state(turn_state: PMFlowAITurnState) -> str: shape = str(turn_state.answer_state.answer_shape or "").strip() return { "strategic_judgement": "decision_with_rationale_risks_and_next_steps", "status_answer": "status_with_evidence_boundaries_and_next_steps", "clarification": "single_batch_blocking_questions", "execution_guidance": "actionable_guidance_with_confirmation_boundary", "fact_list": "matched_objects_with_basis", "composite_answer": "multi_part_answer_with_separate_evidence", }.get(shape, "direct_customer_safe_answer") def _continuation_mode_from_turn_state(turn_state: PMFlowAITurnState) -> str: if turn_state.turn_delta and (turn_state.turn_delta.answers or turn_state.turn_delta.supplement_text): return "continuation_with_user_supplied_delta" if turn_state.thread_workspace is not None: return "continuation_with_thread_workspace" return "new_turn" def _answer_must_include(*, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract | None) -> list[str]: rows: list[str] = [] rows.extend(str(item or "").strip() for item in list(turn_state.turn_goal.success_criteria or []) if str(item or "").strip()) rows.extend(str(item or "").strip() for item in list(turn_state.answer_state.required_sections or []) if str(item or "").strip()) if static_contract is not None: rows.extend(str(item or "").strip() for item in static_contract.source_summary if str(item or "").strip()) if turn_state.disclosure_state.allowed_response_mode == "protected_refusal": rows.append("说明用户侧信息披露边界") rows.append("提供客户可见的替代说明范围") return _unique_text(rows, limit=10) def _answer_must_not_include(*, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract | None) -> list[str]: rows: list[str] = [] rows.extend(str(item or "").strip() for item in list(turn_state.answer_state.prohibited_content or []) if str(item or "").strip()) rows.extend(str(item or "").strip() for item in list(turn_state.disclosure_state.protected_categories or []) if str(item or "").strip()) if static_contract is not None: rows.extend(str(item or "").strip() for item in static_contract.limitations if str(item or "").strip()) return _unique_text(rows, limit=10) def _answer_evidence_to_use(*, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract | None) -> list[str]: rows: list[str] = [] if static_contract is not None: rows.extend(str(item or "").strip() for item in static_contract.source_summary if str(item or "").strip()) for item in list(turn_state.evidence_state.known_facts or [])[:8]: source = str(item.source or "").strip() relevance = str(item.relevance_to_goal or "").strip() rows.append(":".join([value for value in [source, relevance] if value]) or str(item.fact or "")[:120]) for source in list(turn_state.evidence_state.sources or [])[:6]: if not isinstance(source, dict): continue label = str(source.get("display_label") or source.get("label") or source.get("source") or "").strip() if label: rows.append(label) if _project_operating_packet_from_turn_state(turn_state) is not None: rows.append("项目运行态证据") return _unique_text(rows, limit=10) def _answer_open_gaps(turn_state: PMFlowAITurnState) -> list[str]: rows: list[str] = [] for gap in list(turn_state.operation_gaps or [])[:12]: rows.append(str(gap.customer_question or gap.reason or gap.key or "").strip()) for gap in list(turn_state.evidence_state.evidence_gaps or [])[:12]: rows.append(str(gap.customer_question or gap.label or gap.reason or gap.key or "").strip()) return _unique_text(rows, limit=10) def _evidence_usage_plan(turn_state: PMFlowAITurnState) -> list[str]: rows: list[str] = [] if turn_state.answer_objective.evidence_to_use: rows.append("只使用 AnswerObjective.evidence_to_use 中列出的客户安全证据") if _project_operating_packet_from_turn_state(turn_state) is not None: rows.append("把项目运行态证据转成状态含义、可推进动作和证据边界") if turn_state.evidence_state.known_facts: rows.append("把 facts 转成判断、影响和下一步,不直接堆证据摘要") if not rows: rows.append("无足够证据时说明边界,不补编事实") return rows[:8] def _user_value_plan(turn_state: PMFlowAITurnState, *, repeated_content_policy: str) -> list[str]: rows = [ "先回答用户真正要的结论或边界", "把证据翻译成用户影响和下一步", "把仍未覆盖的信息一次性表达清楚", ] if str(repeated_content_policy or "").strip(): rows.append("避免重复上一轮已经表达过的边界或证据") return rows[:8] def _answer_sections(turn_state: PMFlowAITurnState) -> list[str]: if turn_state.disclosure_state.allowed_response_mode == "protected_refusal": return ["边界", "可说明的产品能力"] sections = [str(item or "").strip() for item in list(turn_state.answer_state.required_sections or []) if str(item or "").strip()] if sections: return _unique_text(sections, limit=8) shape = str(turn_state.answer_state.answer_shape or "").strip() return { "fact_list": ["结果", "依据", "下一步"], "status_answer": ["当前状态", "判断", "下一步"], "execution_guidance": ["建议", "依据", "边界"], "strategic_judgement": ["判断", "机会", "风险", "下一步"], }.get(shape, ["回答", "依据", "下一步"]) def _draft_sources(turn_state: PMFlowAITurnState) -> list[str]: sources: list[str] = [] if _static_contract_from_turn_state(turn_state) is not None: sources.append("产品能力或披露边界合同") if _project_operating_packet_from_turn_state(turn_state) is not None: sources.append("项目运行态证据") if turn_state.customer_packet_draft: sources.append("本轮结构化结果") if turn_state.evidence_state.known_facts: sources.append("本轮证据") if not sources: sources.append("当前回答边界") return sources[:6] def _answer_runtime_packet(turn_state: PMFlowAITurnState) -> dict[str, Any]: return { "packet_version": "pmflow_ai_answer_runtime_packet.v1", "objective": turn_state.answer_objective.model_dump(mode="json", exclude_none=True), "plan": turn_state.answer_plan.model_dump(mode="json", exclude_none=True), "draft": turn_state.answer_draft.model_dump(mode="json", exclude_none=True), "quality_findings": [ item.model_dump(mode="json", exclude_none=True) for item in list(turn_state.answer_quality_findings or [])[:8] ], } def _answer_plan_primary_source(turn_state: PMFlowAITurnState) -> str: sources = list(turn_state.answer_plan.draft_sources or []) return str(sources[0] if sources else "answer_runtime_renderer") def _apply_answer_quality_gate(*, request: RAGAskRequest, turn_state: PMFlowAITurnState, answer: str) -> str: findings = _evaluate_answer_quality(request=request, turn_state=turn_state, answer=answer) turn_state.answer_quality_findings = _dedupe_answer_quality_findings(findings, limit=8) if not any(item.blocking for item in findings): return str(answer or "").strip() revised = _revise_answer_from_quality_findings(request=request, turn_state=turn_state, answer=answer) if revised != str(answer or "").strip(): turn_state.runtime_steps.append( PMFlowAIRuntimeStep( key="revise_answer_quality", label="修正回答:按目标、证据计划和客户安全边界重写", action_type="synthesize_answer", status="completed", blocking=False, ) ) revision_findings = _evaluate_answer_quality(request=request, turn_state=turn_state, answer=revised) if any(item.blocking for item in revision_findings): unresolved = PMFlowAIAnswerQualityFinding( finding_id="post_revision_quality_failed", finding_type="post_revision_quality_failed", reason="Answer remained unsafe or non-answer after the quality gate revision pass.", revision_instruction="Fail closed with a customer-safe boundary answer instead of returning the failed draft.", blocking=True, ) turn_state.answer_quality_findings = _dedupe_answer_quality_findings( [*findings, *revision_findings, unresolved], limit=8, ) turn_state.runtime_steps.append( PMFlowAIRuntimeStep( key="block_answer_quality", label="阻断回答:质量门禁复检未通过,返回客户安全边界", action_type="synthesize_answer", status="completed", blocking=False, ) ) return _render_answer_quality_fail_closed(request=request, turn_state=turn_state) turn_state.answer_quality_findings = _dedupe_answer_quality_findings(findings, limit=8) return str(revised or "").strip() def _evaluate_answer_quality( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, answer: str, ) -> list[PMFlowAIAnswerQualityFinding]: _ = request text = str(answer or "").strip() findings: list[PMFlowAIAnswerQualityFinding] = [] if not text: findings.append( PMFlowAIAnswerQualityFinding( finding_id="answer_empty", finding_type="empty_answer", reason="Renderer produced an empty answer draft.", revision_instruction="Use the Answer Runtime objective and available customer-safe packet to generate a bounded answer.", blocking=True, ) ) leaked_terms = _answer_runtime_leak_terms(text) if leaked_terms: findings.append( PMFlowAIAnswerQualityFinding( finding_id="internal_runtime_leak", finding_type="internal_runtime_leak", reason="Answer draft contains internal runtime terms that are not customer-visible.", revision_instruction="Rewrite using customer-safe labels from the answer plan and evidence packet.", blocking=True, ) ) if _static_contract_from_turn_state(turn_state) is not None and not _static_contract_material_used(turn_state): findings.append( PMFlowAIAnswerQualityFinding( finding_id="static_contract_material_missing", finding_type="static_contract_material_missing", reason="Static/protected contract answer did not carry contract material through Answer Runtime.", revision_instruction="Render from required claims, customer-safe capabilities and source summary instead of a free-form answer.", blocking=True, ) ) if _answer_repeats_plan_instead_of_answer(text=text, turn_state=turn_state): findings.append( PMFlowAIAnswerQualityFinding( finding_id="plan_echo", finding_type="plan_echo", reason="Answer draft repeats planning language instead of directly answering the user.", revision_instruction="Remove planning labels and answer in customer-facing product language.", blocking=True, ) ) terminal_state = _build_terminal_answer_state(request=request, turn_state=turn_state, answer=text) terminal_findings = _critique_terminal_customer_answer(terminal_state=terminal_state, answer=text) if "fixed_section_template" in terminal_findings: findings.append( PMFlowAIAnswerQualityFinding( finding_id="fixed_section_template", finding_type="fixed_section_template", reason="Answer draft uses a fixed section template instead of following the answer intent plan.", revision_instruction="Rewrite as a natural customer-facing answer that follows the terminal answer intent.", blocking=True, ) ) if "repeated_sentence" in terminal_findings: findings.append( PMFlowAIAnswerQualityFinding( finding_id="repeated_sentence", finding_type="repeated_sentence", reason="Answer draft repeats the same sentence instead of adding useful content.", revision_instruction="Deduplicate and keep only the lead claim, boundary and useful alternative.", blocking=True, ) ) return findings[:8] def _revise_answer_from_quality_findings( *, request: RAGAskRequest, turn_state: PMFlowAITurnState, answer: str, ) -> str: static_contract = _static_contract_from_turn_state(turn_state) if static_contract is not None: revised = _render_static_contract_answer_from_answer_runtime( turn_state=turn_state, static_contract=static_contract, ) if revised: return revised terminal_state = _build_terminal_answer_state( request=request, turn_state=turn_state, answer=str(answer or "").strip(), ) turn_state.terminal_answer_state = terminal_state revised = _render_terminal_state_fallback(terminal_state) if revised: return revised revised = _render_state_answer_fallback(question=str(request.question or ""), turn_state=turn_state) if revised: return revised return str(answer or "").strip() def _dedupe_answer_quality_findings( findings: list[PMFlowAIAnswerQualityFinding], *, limit: int, ) -> list[PMFlowAIAnswerQualityFinding]: deduped: list[PMFlowAIAnswerQualityFinding] = [] seen: set[str] = set() for finding in findings: key = str(finding.finding_id or finding.finding_type or "").strip() if not key or key in seen: continue seen.add(key) deduped.append(finding) if len(deduped) >= limit: break return deduped def _render_answer_quality_fail_closed(*, request: RAGAskRequest, turn_state: PMFlowAITurnState) -> str: terminal_state = _build_terminal_answer_state(request=request, turn_state=turn_state) turn_state.terminal_answer_state = terminal_state answer = _render_terminal_state_fallback(terminal_state) if answer and not _answer_runtime_leak_terms(answer): return answer return "这轮还没有形成可以可靠展示的结论。" def _answer_runtime_leak_terms(text: str) -> list[str]: protected_runtime_terms = ( "evidence_findings", "problem_frame", "interpretation_state", "judgement_state", "TurnState", "runtime_steps", "PMFlowAITurnState", "answer_objective", "answer_plan", "answer_runtime_packet", "AnswerIntentPlan", "Claim Ledger", "Boundary Ledger", "Attempt Ledger", "runtime trace", "runtime step", "tool trace", ) return [item for item in protected_runtime_terms if item in str(text or "")] def _static_contract_material_used(turn_state: PMFlowAITurnState) -> bool: objective_sources = { str(item or "").strip() for item in list(turn_state.answer_objective.evidence_to_use or []) if str(item or "").strip() } draft_sources = { str(item or "").strip() for item in list(turn_state.answer_plan.draft_sources or []) if str(item or "").strip() } return bool(objective_sources or draft_sources or str(turn_state.answer_draft.source_renderer or "").strip()) def _answer_repeats_plan_instead_of_answer(*, text: str, turn_state: PMFlowAITurnState) -> bool: plan_terms = { str(turn_state.answer_plan.answer_strategy or "").strip(), str(turn_state.answer_plan.repeated_content_policy or "").strip(), str(turn_state.answer_objective.required_decision or "").strip(), } plan_terms = {item for item in plan_terms if item} if not plan_terms: return False compact = " ".join(str(text or "").strip().split()) return any(item and item in compact for item in plan_terms) def _build_state_customer_packet( *, request: RAGAskRequest, current_user: User, route_plan: dict[str, Any], turn_state: PMFlowAITurnState, answer: str, ) -> dict[str, Any]: _ = route_plan facts: list[dict[str, Any]] = [] for item in list(turn_state.evidence_state.known_facts or [])[:12]: facts.append( { "type": "runtime_evidence_finding", "display_label": str(item.source or "本轮证据")[:160], "display_summary": str(item.implication or item.fact or "")[:1200], "business_values": { "事实": str(item.fact or "")[:240], "含义": str(item.implication or "")[:240], "目标相关性": str(item.relevance_to_goal or "")[:120], "来源": str(item.source or "")[:240], }, "source_display_labels": [str(item.source or "本轮证据")[:80]], "confidence": item.confidence, "action_hint": str(item.implication or "")[:240] or None, } ) source_summary: list[str] = [] for source in list(turn_state.evidence_state.sources or [])[:8]: if not isinstance(source, dict): continue label = str(source.get("display_label") or source.get("label") or source.get("source") or "").strip() if label and label not in source_summary: source_summary.append(label[:120]) suggested_action_titles = list(turn_state.judgement_state.next_actions or []) if not suggested_action_titles: suggested_action_titles = [ str(item.reason or item.action_type or "").strip() for item in list(turn_state.action_state.candidate_actions or [])[:3] if str(item.reason or item.action_type or "").strip() ] static_contract = _static_contract_from_turn_state(turn_state) if static_contract is not None: source_summary = list(static_contract.source_summary) suggested_action_titles = [] suggested_actions = [ { "title": title[:160], "priority": "medium", "action_type": "clarify" if turn_state.sufficiency_state.needs_user_input else "external_research", } for title in suggested_action_titles[:5] if str(title or "").strip() ] return CustomerEvidencePacket( tenant_id=_safe_int(getattr(current_user, "tenant_id", None)), user_id=_safe_int(getattr(current_user, "id", None)), question=str(request.question or "").strip()[:500], answer_type=str(turn_state.answer_state.answer_shape or "general_answer")[:120], route_display=_route_display(turn_state), facts=facts, source_summary=source_summary, confidence=static_contract.confidence if static_contract is not None else ("high" if facts else "medium"), limitations=list(static_contract.limitations) if static_contract is not None else ([] if facts else ["这轮没有拿到足够外部证据,结论需要后续核验。"]), suggested_actions=suggested_actions[:5], fallback_summary=str(answer or "")[:1200], ).model_dump(mode="json", exclude_none=True) def _render_state_answer_fallback(*, question: str, turn_state: PMFlowAITurnState) -> str: terminal_state = _build_terminal_answer_state( request=RAGAskRequest(question=str(question or "")), turn_state=turn_state, ) turn_state.terminal_answer_state = terminal_state answer = _render_terminal_state_fallback(terminal_state) if answer: return answer return "这轮还没有形成可以可靠展示的结论。" def _render_project_operating_answer_from_turn_state(*, question: str, turn_state: PMFlowAITurnState) -> str: _ = question if not _has_strong_project_primary_for_runtime(turn_state): return "" packet = _project_operating_packet_from_turn_state(turn_state) if packet is None: return "" verification = _project_operating_answer_verification_packet(turn_state) title = str(packet.project_title or "当前项目").strip() status = str(packet.project_status or "").strip() coverage_score = float(packet.coverage.coverage_score or 0.0) lines: list[str] = [ f"当前项目:{title}{f',状态:{status}' if status else ''}。", f"我的判断:这个项目可以继续推进,但要按项目运行态证据处理,不能只看“进行中/active”这类浅状态。", "", "关键进展:", ] for facet in [ packet.command_state, packet.execution_state, packet.workbench_state, packet.decision_review_state, packet.quality_test_state, packet.release_growth_state, packet.team_state, packet.project_memory_state, packet.risk_blocker_state, packet.recent_timeline_state, ]: text = _project_operating_facet_display(facet) if text: lines.append(f"- {text}") safe_actions = [ str(item.display_label or "").strip() for item in list(packet.safe_next_actions or [])[:5] if str(item.display_label or "").strip() ] blocked_actions = [ str(item.display_label or "").strip() for item in list(packet.blocked_actions or [])[:5] if str(item.display_label or "").strip() ] boundaries = [ str(item or "").strip() for item in list(verification.get("answer_boundaries") or [])[:5] if str(item or "").strip() ] if safe_actions: lines.extend(["", "可推进的动作:"]) lines.extend(f"- {item}" for item in safe_actions) if blocked_actions: lines.extend(["", "不能直接做的动作:"]) lines.extend(f"- {item}" for item in blocked_actions) if boundaries: lines.extend(["", "需要注意的边界:"]) lines.extend(f"- {item}" for item in boundaries) if packet.remaining_gaps: lines.extend(["", "还缺的证据:"]) lines.extend(f"- {str(item).strip()}" for item in list(packet.remaining_gaps or [])[:5] if str(item).strip()) lines.extend(["", f"证据覆盖度:{round(coverage_score * 100)}%。"]) return "\n".join(lines).strip() def _project_operating_facet_display(facet: Any) -> str: label = str(getattr(facet, "display_label", "") or getattr(facet, "facet_key", "") or "").strip() summary = str(getattr(facet, "summary", "") or "").strip() facts = [ str(item or "").strip() for item in list(getattr(facet, "facts", []) or [])[:3] if str(item or "").strip() ] status = str(getattr(facet, "coverage_status", "") or "").strip() if not summary and facts: summary = ";".join(facts[:2]) if not summary: return "" suffix = "" if status == "covered" else f"({_coverage_status_label(status)})" return f"{label or '项目证据'}:{summary}{suffix}" def _render_open_judgement_answer_from_turn_state(*, question: str, turn_state: PMFlowAITurnState) -> str: selected = turn_state.selected_frame operation = selected.operation_kind if selected is not None else "" shape = str(turn_state.answer_state.answer_shape or "").strip() if shape not in {"strategic_judgement", "composite_answer"} and operation not in {"judge_direction", "compare_objects"}: return "" if turn_state.sufficiency_state.needs_user_input and not turn_state.sufficiency_state.can_answer_with_assumptions: return "" if _has_actionable_user_gap(turn_state) and not turn_state.sufficiency_state.can_answer_with_assumptions: return "" if _has_strong_project_primary_for_runtime(turn_state): return "" workspace = turn_state.thread_workspace subject = ( (workspace.topic if workspace is not None else "") or turn_state.problem_frame.subject or _turn_external_subject(turn_state) or str(question or "").strip() or "这个方向" ) subject = " ".join(str(subject).strip().split())[:160] or "这个方向" collected = _clean_workspace_inputs(workspace.collected_inputs if workspace is not None else {}) known_inputs = _open_judgement_known_inputs(turn_state=turn_state, collected_inputs=collected) gaps = _open_judgement_open_gaps(turn_state=turn_state, workspace=workspace) criteria = [ str(item or "").strip() for item in list(turn_state.turn_goal.success_criteria or []) if str(item or "").strip() ][:5] lines = [ f"我的初步判断:{subject}可以继续看,但现在应该先按“窄场景、强痛点、可验证成本收益”的方式推进,不适合直接拉成大而全项目。", "", ] if known_inputs: lines.append("我当前已经确认的输入:") lines.extend(f"- {item}" for item in known_inputs[:6]) lines.append("") if criteria: lines.append("这一轮我会按这些目标判断:") lines.extend(f"- {item}" for item in criteria[:4]) lines.append("") lines.extend( [ "更合适的第一版切口:", "- 先选一个高频、重复、人工成本明确的子流程,做成能节省时间或减少返工的单点工具。", "- 第一版只证明一个闭环:输入材料、AI 处理、人工审核、结果交付和成本对比。", "- 不先承诺全自动替代,先把人工参与点、审核规则和失败回退做清楚。", "", "主要风险:", "- 如果目标用户、原始材料、审核标准或付费方不清楚,产品会变成泛 AI 工具,难以验证价值。", "- 如果效果只能靠人工大量修正,降本结论会被实际运营成本抵消。", "- 如果涉及内容、合规、数据或平台规则,必须先做边界验证,不能只按技术可行性判断。", "", ] ) if gaps: lines.append("还缺这些信息,但它们不是阻止我给初判的理由:") lines.extend(f"- {item}" for item in gaps[:5]) lines.append("") lines.extend( [ "下一步建议:", "- 先补齐一个具体使用场景、目标用户和现有人工流程。", "- 用 3 到 5 个真实样本跑一遍人工流程和 AI 流程,记录耗时、返工率、审核通过率和交付质量。", "- 如果样本验证能稳定节省时间,再进入 MVP;如果只能做演示效果,先不要立项成正式产品。", ] ) return "\n".join(lines).strip() def _open_judgement_known_inputs(*, turn_state: PMFlowAITurnState, collected_inputs: dict[str, Any]) -> list[str]: inputs: list[str] = [] for key, value in list(collected_inputs.items())[:8]: text = _workspace_input_display(key=key, value=value) if text and text not in inputs: inputs.append(text) subject = str(turn_state.problem_frame.subject or "").strip() if subject and not any(subject in item for item in inputs): inputs.insert(0, f"方向:{subject}") return inputs[:8] def _workspace_input_display(*, key: str, value: Any) -> str: clean_key = " ".join(str(key or "").strip().split())[:60] if isinstance(value, list): clean_value = "、".join(str(item).strip() for item in value[:5] if str(item).strip())[:240] elif isinstance(value, dict): clean_value = ";".join( f"{str(k).strip()}={str(v).strip()}" for k, v in list(value.items())[:5] if str(k).strip() and str(v).strip() )[:240] else: clean_value = " ".join(str(value or "").strip().split())[:240] if not clean_key or not clean_value: return "" return f"{clean_key}:{clean_value}" def _open_judgement_open_gaps( *, turn_state: PMFlowAITurnState, workspace: PMFlowAIThreadWorkspace | None, ) -> list[str]: gaps: list[str] = [] if workspace is not None: gaps.extend(str(item or "").strip() for item in list(workspace.pending_questions or []) if str(item or "").strip()) for gap in list(turn_state.operation_gaps or []): if gap.user_actionable and gap.customer_question: gaps.append(str(gap.customer_question or "").strip()) elif gap.reason and gap.gap_type in {"business_assumption_gap", "scope_gap", "user_clarification_gap"}: gaps.append(str(gap.reason or "").strip()) return _unique_text(gaps, limit=8) def _has_strong_project_primary_for_runtime(turn_state: PMFlowAITurnState) -> bool: primary = turn_state.object_state.primary_object if primary is None: return False if str(primary.object_type or "").strip().lower() != "project": return False binding = str(primary.binding_strength or "").strip().lower() if binding in {"", "none", "context", "visible_context"}: return False return bool(str(primary.object_ref or primary.title or "").strip()) def _project_operating_answer_verification_packet(turn_state: PMFlowAITurnState) -> dict[str, Any]: packet = _project_operating_packet_from_turn_state(turn_state) if packet is None: return {} missing_or_partial_facets: list[dict[str, Any]] = [] for facet in project_operating_facets(packet): status = str(facet.coverage_status or "").strip() if status not in {"missing", "partial", "stale"}: continue missing_or_partial_facets.append( { "facet_key": facet.facet_key, "display_label": facet.display_label or facet.facet_key, "coverage_status": status, "summary": facet.summary, "remaining_gaps": list(facet.remaining_gaps or [])[:5], } ) safe_next_actions = [_project_operating_action_row(item) for item in list(packet.safe_next_actions or [])] blocked_actions = [_project_operating_action_row(item) for item in list(packet.blocked_actions or [])] safe_next_actions = [item for item in safe_next_actions if item][:8] blocked_actions = [item for item in blocked_actions if item][:8] coverage_score = float(packet.coverage.coverage_score or 0.0) answer_boundaries = _project_operating_answer_boundaries( missing_or_partial_facets=missing_or_partial_facets, blocked_actions=blocked_actions, ) return { "packet_version": "project_operating_answer_verification.v1", "visibility": "customer", "project_ref": packet.project_ref, "project_id": packet.project_id, "project_title": packet.project_title, "project_status": packet.project_status, "coverage_score": coverage_score, "coverage_profile": packet.coverage.model_dump(mode="json", exclude_none=True), "missing_or_partial_facets": missing_or_partial_facets[:10], "safe_next_actions": safe_next_actions, "blocked_actions": blocked_actions, "answer_boundaries": answer_boundaries, "can_claim_full_execution_guidance": coverage_score >= 0.85 and not missing_or_partial_facets and not blocked_actions, } def _project_operating_packet_from_turn_state(turn_state: PMFlowAITurnState) -> ProjectOperatingEvidencePacket | None: structured = dict(turn_state.evidence_state.structured_evidence or {}) candidates: list[dict[str, Any]] = [] packets = structured.get("project_operating_evidence_packets") if isinstance(packets, list): candidates.extend(dict(item) for item in packets if isinstance(item, dict)) single = structured.get("project_operating_evidence_packet") if isinstance(single, dict) and single: candidates.append(dict(single)) if not candidates: return None primary_ref = "" primary = turn_state.object_state.primary_object if primary is not None: primary_ref = str(primary.object_ref or "").strip() for raw in candidates: if primary_ref and str(raw.get("project_ref") or "").strip() not in {"", primary_ref}: continue try: return ProjectOperatingEvidencePacket.model_validate(raw) except Exception: continue for raw in candidates: try: return ProjectOperatingEvidencePacket.model_validate(raw) except Exception: continue return None def _project_operating_action_row(action: Any) -> dict[str, Any]: data = action.model_dump(mode="json", exclude_none=True) if hasattr(action, "model_dump") else dict(action or {}) label = str(data.get("display_label") or data.get("action_key") or "").strip() if not label: return {} return { "action_key": str(data.get("action_key") or "").strip()[:100], "display_label": label[:160], "safety": str(data.get("safety") or "prepare_only").strip()[:80], "reason": str(data.get("reason") or "").strip()[:320], "target_ref": str(data.get("target_ref") or "").strip()[:160], } def _project_operating_answer_boundaries( *, missing_or_partial_facets: list[dict[str, Any]], blocked_actions: list[dict[str, Any]], ) -> list[str]: boundaries: list[str] = [] for facet in missing_or_partial_facets[:4]: label = str(facet.get("display_label") or facet.get("facet_key") or "").strip() status = str(facet.get("coverage_status") or "").strip() if label: boundaries.append(f"{label}证据{_coverage_status_label(status)},不能把它当成已验证结论。") for action in blocked_actions[:4]: label = str(action.get("display_label") or "").strip() reason = str(action.get("reason") or "").strip() if label and reason: boundaries.append(f"不能直接执行:{label},原因:{reason}。") elif label: boundaries.append(f"不能直接执行:{label}。") return _unique_text(boundaries, limit=8) def _coverage_status_label(status: str) -> str: labels = { "missing": "缺失", "partial": "不完整", "stale": "可能过期", } return labels.get(str(status or "").strip(), "不足") def _apply_project_operating_answer_verification_to_packet( *, packet: dict[str, Any], verification: dict[str, Any], ) -> dict[str, Any]: patched = dict(packet or {}) limitations = _unique_text( [ *(str(item or "") for item in list(patched.get("limitations") or [])), *( text for text in ( _customer_safe_project_operating_text(item, limit=240) for item in list(verification.get("answer_boundaries") or []) ) if text ), ], limit=8, ) patched["limitations"] = limitations actions = [dict(item) for item in list(patched.get("suggested_actions") or []) if isinstance(item, dict)] for action in list(verification.get("safe_next_actions") or [])[:5]: if not isinstance(action, dict): continue label = _customer_safe_project_operating_text(action.get("display_label"), limit=160) if not label: continue actions.append( { "title": label, "priority": "medium", "action_type": "read" if str(action.get("safety") or "") == "safe_read" else "prepare", "object_ref": str(action.get("target_ref") or verification.get("project_ref") or "").strip()[:120] or None, "project": _customer_safe_project_operating_text(action.get("reason"), limit=120) or None, } ) patched["suggested_actions"] = _dedupe_suggested_actions(actions, limit=5) return patched def _apply_project_operating_answer_verification_to_answer(*, answer: str, verification: dict[str, Any]) -> str: text = str(answer or "").strip() safe_actions = [ _customer_safe_project_operating_text(item.get("display_label"), limit=160) for item in list(verification.get("safe_next_actions") or [])[:4] if isinstance(item, dict) ] safe_actions = [item for item in safe_actions if item] blocked_actions = [ _customer_safe_project_operating_text(item.get("display_label"), limit=160) for item in list(verification.get("blocked_actions") or [])[:4] if isinstance(item, dict) ] blocked_actions = [item for item in blocked_actions if item] boundaries = [ _customer_safe_project_operating_text(item, limit=240) for item in list(verification.get("answer_boundaries") or [])[:4] if _customer_safe_project_operating_text(item, limit=240) ] sections: list[str] = [] if safe_actions: sections.extend([f"- 可推进:{item}" for item in safe_actions]) if blocked_actions: if sections: sections.append("") sections.extend([f"- 不能直接做:{item}" for item in blocked_actions]) if boundaries: if sections: sections.append("") sections.extend([f"- 需要注意:{item}" for item in boundaries]) if not sections: return text addition = "\n".join(sections).strip() if not text: return addition return f"{text}\n\n{addition}".strip() def _apply_project_operating_answer_verification_to_canvas( *, canvas: Any, verification: dict[str, Any], ) -> dict[str, Any]: patched = dict(canvas or {}) if isinstance(canvas, dict) else {} patched["limitations"] = _unique_text( [ *(str(item or "") for item in list(patched.get("limitations") or [])), *( text for text in ( _customer_safe_project_operating_text(item, limit=240) for item in list(verification.get("answer_boundaries") or []) ) if text ), ], limit=8, ) next_actions = [dict(item) for item in list(patched.get("next_actions") or []) if isinstance(item, dict)] for action in list(verification.get("safe_next_actions") or [])[:5]: if not isinstance(action, dict): continue label = _customer_safe_project_operating_text(action.get("display_label"), limit=160) if not label: continue next_actions.append( { "title": label, "reason": _customer_safe_project_operating_text(action.get("reason"), limit=320), "action_type": "read" if str(action.get("safety") or "") == "safe_read" else "prepare", "object_ref": str(action.get("target_ref") or verification.get("project_ref") or "").strip()[:120] or None, "requires_user_confirm": str(action.get("safety") or "") != "safe_read", } ) patched["next_actions"] = _dedupe_suggested_actions(next_actions, limit=5) return patched def _customer_safe_project_operating_text(value: Any, *, limit: int) -> str: text = " ".join(str(value or "").strip().split())[:limit] if not text: return "" if _answer_runtime_leak_terms(text): return "" return text def _dedupe_suggested_actions(actions: list[dict[str, Any]], *, limit: int) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for action in actions: title = str(action.get("title") or "").strip() if not title or title in seen: continue seen.add(title) out.append(action) if len(out) >= limit: break return out def _unique_text(values: list[str], *, limit: int) -> list[str]: out: list[str] = [] for value in values: text = " ".join(str(value or "").strip().split())[:240] if text and text not in out: out.append(text) return out[:limit] def _render_source_authority_answer_from_turn_state(*, turn_state: PMFlowAITurnState) -> str: static_contract = _static_contract_from_turn_state(turn_state) if static_contract is None: return "" return _render_static_contract_answer_from_answer_runtime( turn_state=turn_state, static_contract=static_contract, ) def _render_static_contract_answer_from_answer_runtime( *, turn_state: PMFlowAITurnState, static_contract: PMFlowAIStaticContract, ) -> str: """Render a customer-safe static contract through Answer Runtime state. The static registry provides boundary facts and allowed capability material. It no longer owns the final prose; the turn's objective and plan decide how much boundary/context to show for this specific answer. """ objective = turn_state.answer_objective protected = turn_state.disclosure_state.allowed_response_mode == "protected_refusal" capability_rows = _unique_text(list(static_contract.customer_safe_capabilities or ()), limit=6) claim_rows = _unique_text(list(static_contract.required_claims or ()), limit=6) sections = list(turn_state.answer_plan.sections or []) if protected: lines = [ "这个问题触及 PMFlow 的内部实现与机密信息,用户侧入口不能披露这些内容。", ] if claim_rows: lines.append("不能提供的范围包括:" + "、".join(claim_rows[1:2] or claim_rows[:1]) + "。") if capability_rows: lines.append("") lines.append("我可以说明产品里可使用的能力边界:") lines.extend(f"- {item}" for item in capability_rows[:4]) return "\n".join(lines).strip() lines = ["我是 PMFlow AI,是 PMFlow 里的项目操作副驾。"] if objective.primary_user_need and objective.primary_user_need not in lines[0]: lines.append(f"这轮我按你的问题说明能力边界:{objective.primary_user_need}") if capability_rows: lines.append("") lines.append("我能帮你处理这些事:") lines.extend(f"- {item}" for item in capability_rows[:4]) if claim_rows: lines.append("") lines.append(claim_rows[-1]) if sections and "下一步" in sections: lines.append("如果你问具体项目进展,我会先读取绑定项目的运行态证据,再给判断和下一步。") return "\n".join(lines).strip() def _static_contract_from_turn_state(turn_state: PMFlowAITurnState) -> PMFlowAIStaticContract | None: authority = PMFlowAIEvidenceAuthority().decide(turn_state) if not authority.contract_key: return None return PMFlowAIStaticContractRegistry().get(authority.contract_key) def _render_collection_answer_from_working_state(*, question: str, turn_state: PMFlowAITurnState) -> str: _ = question draft = dict(turn_state.customer_packet_draft or {}) selected = turn_state.selected_frame operation = selected.operation_kind if selected is not None else "" should_render = operation == "list_collection" or str(turn_state.answer_state.answer_shape or "") == "fact_list" if not should_render: return "" items = draft.get("items") if isinstance(draft.get("items"), list) else [] count = draft.get("count") object_type = str(draft.get("object_type") or "").strip() if not items: for observation in list(turn_state.observations or []): if observation.observation_type != "collection_snapshot": continue if not observation.items and observation.count is None: continue object_type = object_type or str(observation.object_type or "").strip() count = observation.count items = [item.model_dump(mode="json", exclude_none=True) for item in observation.items[:20]] break if not items and count is None: return "" object_label = _collection_object_label(object_type) if not items: return f"当前没有读到可展示的{object_label}。" total = count if isinstance(count, int) and count >= len(items) else len(items) lines = [f"当前可见{object_label} {total} 个:"] for item in items[:20]: if not isinstance(item, dict): continue line = _collection_item_line(item) if line: lines.append(f"- {line}") if len(lines) == 1: return f"当前可见{object_label} {total} 个,但没有读到可展示名称。" return "\n".join(lines) def _collection_object_label(object_type: str) -> str: labels = { "project": "项目", "saved_idea": "暂存想法", "external_source": "外部来源", } return labels.get(str(object_type or "").strip(), "对象") def _collection_item_line(item: dict[str, Any]) -> str: title = str(item.get("title") or "").strip() summary = str(item.get("summary") or "").strip() if title.startswith("项目:"): title = title.removeprefix("项目:").strip() if title.endswith("。"): title = title[:-1] cleaned_summary = summary.removeprefix("项目:").strip() if cleaned_summary.endswith("。"): cleaned_summary = cleaned_summary[:-1] if cleaned_summary and cleaned_summary != title and not title.startswith(cleaned_summary): return f"{title}({cleaned_summary})" if title else cleaned_summary return title or cleaned_summary def _merge_structured_evidence(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: merged = dict(existing or {}) incoming = dict(incoming or {}) incoming_packets = incoming.get("project_operating_evidence_packets") if isinstance(incoming_packets, list): existing_packets = merged.get("project_operating_evidence_packets") packets = [dict(item) for item in existing_packets if isinstance(item, dict)] if isinstance(existing_packets, list) else [] by_ref = { str(packet.get("project_ref") or packet.get("project_id") or index): packet for index, packet in enumerate(packets) } for packet in incoming_packets: if not isinstance(packet, dict): continue key = str(packet.get("project_ref") or packet.get("project_id") or len(by_ref)) by_ref[key] = dict(packet) merged_packets = list(by_ref.values())[:12] merged["project_operating_evidence_packets"] = merged_packets merged["project_operating_evidence_packet"] = merged_packets[0] if len(merged_packets) == 1 else {} for key, value in incoming.items(): if key not in {"project_operating_evidence_packets", "project_operating_evidence_packet"}: merged[key] = value return merged __all__ = ["PMFlowAITurnRuntimeService"]