4796 lines
210 KiB
Python
4796 lines
210 KiB
Python
"""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"]
|