PMFlow/backend/app/services/pmflow_ai_turn_runtime_serv...

4796 lines
210 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"]