PMFlow/backend/app/services/pmflow_ai_answer_verifier_r...

289 lines
13 KiB
Python

"""Verifier policy registry for PMFlow AI turn runtime.
Verifier policies decide whether a runtime payload is allowed to leave the
turn loop. They consume canonical TurnState, authority decisions and registered
contracts; they do not classify raw user text.
"""
from __future__ import annotations
from typing import Any
from ..schemas.pmflow_ai_turn_runtime import PMFlowAITurnState, PMFlowAIVerificationFailure
from .pmflow_ai_evidence_authority import PMFlowAIEvidenceAuthority
from .pmflow_ai_evidence_coverage_gate import PMFlowAIEvidenceCoverageGate
from .pmflow_ai_static_contract_registry import PMFlowAIStaticContractRegistry
class PMFlowAIAnswerVerifierRegistry:
"""Run ordered verifier policies against a runtime payload."""
def verify_payload(
self,
*,
turn_state: PMFlowAITurnState,
payload: dict[str, Any] | None,
) -> PMFlowAIVerificationFailure | None:
if not isinstance(payload, dict):
return None
if _payload_is_final_answer(payload):
failure = self._verify_final_answer(turn_state=turn_state, payload=payload)
if failure is not None:
return failure
if isinstance(payload.get("interaction_request"), dict):
failure = self._verify_interaction_request(turn_state=turn_state)
if failure is not None:
return failure
failure = self._verify_collection_answer_shape(turn_state=turn_state)
if failure is not None:
return failure
return None
def _verify_final_answer(
self,
*,
turn_state: PMFlowAITurnState,
payload: dict[str, Any],
) -> PMFlowAIVerificationFailure | None:
authority = PMFlowAIEvidenceAuthority().decide(turn_state)
if authority.required_first_action is not None:
return _verification_failure(
failure_type=_wrong_source_failure_type(authority.authority_domain),
reason=authority.reason or "Final answer attempted before reading required authority evidence.",
state_patch={"can_answer_now": False, "needs_internal_evidence": True},
next_action_hint=authority.required_first_action,
)
if authority.verifier_policy in {"static_contract", "protected_refusal"}:
failure = self._verify_static_contract(turn_state=turn_state, payload=payload)
if failure is not None:
return failure
if authority.verifier_policy == "protected_refusal":
return None
coverage = PMFlowAIEvidenceCoverageGate().evaluate(turn_state)
if coverage.needs_probe and coverage.next_action_type is not None:
return _verification_failure(
failure_type="goal_fulfillment_missing_project_operating_facets",
reason=coverage.reason,
state_patch={"can_answer_now": False, "needs_internal_evidence": True},
next_action_hint=coverage.next_action_type,
)
return None
def _verify_static_contract(
self,
*,
turn_state: PMFlowAITurnState,
payload: dict[str, Any],
) -> PMFlowAIVerificationFailure | None:
authority = PMFlowAIEvidenceAuthority().decide(turn_state)
contract = PMFlowAIStaticContractRegistry().get(authority.contract_key)
if contract is None:
return _verification_failure(
failure_type="static_contract_missing",
reason="Source Authority selected a static contract, but the contract registry could not resolve it.",
state_patch={"can_answer_now": False},
next_action_hint="render_or_fail_closed",
customer_safe_summary="这轮缺少可公开说明的产品边界材料,不能把内部校验细节展示给用户。",
customer_safe_effect={
"blocked_claims": ["不能把内部校验失败原因当成用户结论。"],
"answerable_parts": [],
},
retry_would_change_state=False,
)
answer = str(payload.get("answer") or payload.get("reply_text") or "").strip()
runtime_packet = payload.get("answer_runtime_packet")
if not isinstance(runtime_packet, dict):
return _verification_failure(
failure_type="static_contract_mismatch",
reason="Static contract answer did not pass through Answer Runtime objective/plan/draft packet.",
state_patch={
"can_answer_now": True,
"needs_internal_evidence": False,
"needs_external_evidence": False,
},
next_action_hint="synthesize_answer",
customer_safe_summary="这轮答案没有正确承接客户可见的产品边界,不能把内部校验细节展示给用户。",
customer_safe_effect={
"blocked_claims": ["不能把内部实现、合同校验或运行时诊断作为用户答案。"],
"answerable_parts": ["可以说明客户侧可见的产品能力边界。"],
},
retry_would_change_state=False,
)
packet = payload.get("customer_evidence_packet") if isinstance(payload.get("customer_evidence_packet"), dict) else {}
source_summary = {
str(item or "").strip()
for item in list(packet.get("source_summary") or [])
if str(item or "").strip()
}
required_sources = {str(item or "").strip() for item in contract.source_summary if str(item or "").strip()}
objective = runtime_packet.get("objective") if isinstance(runtime_packet.get("objective"), dict) else {}
plan = runtime_packet.get("plan") if isinstance(runtime_packet.get("plan"), dict) else {}
draft = runtime_packet.get("draft") if isinstance(runtime_packet.get("draft"), dict) else {}
if (
answer
and required_sources.issubset(source_summary)
and _answer_runtime_supports_contract(
turn_state=turn_state,
contract_key=contract.contract_key,
objective=objective,
plan=plan,
draft=draft,
)
):
return None
return _verification_failure(
failure_type="static_contract_mismatch",
reason="Static contract answer payload did not satisfy the registered customer-safe product contract and Answer Runtime plan.",
state_patch={
"can_answer_now": True,
"needs_internal_evidence": False,
"needs_external_evidence": False,
},
next_action_hint="synthesize_answer",
customer_safe_summary="这轮答案没有正确承接客户可见的产品边界,不能把内部校验细节展示给用户。",
customer_safe_effect={
"blocked_claims": ["不能把内部实现、合同校验或运行时诊断作为用户答案。"],
"answerable_parts": ["可以说明客户侧可见的产品能力边界。"],
},
retry_would_change_state=False,
)
def _verify_interaction_request(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="Generated interaction_request without a user-actionable gap.",
state_patch={"must_ask_user": False, "can_answer_now": True},
next_action_hint="render_or_fail_closed",
)
def _verify_collection_answer_shape(self, *, turn_state: PMFlowAITurnState) -> PMFlowAIVerificationFailure | None:
selected = turn_state.selected_frame
if selected is None or selected.operation_kind != "list_collection":
return None
if not any("list_collection" in observation.can_answer_operations for observation in turn_state.observations):
return None
return _verification_failure(
failure_type="wrong_answer_shape",
reason="Collection evidence can answer list_collection, so clarification is not allowed.",
state_patch={"must_ask_user": False, "can_answer_now": True},
next_action_hint="render_collection_list",
)
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 _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 _wrong_source_failure_type(authority_domain: str) -> str:
if authority_domain == "pmflow_project_operating_state":
return "wrong_source_or_missing_project_operating_state"
return "wrong_source_or_missing_required_evidence"
def _answer_runtime_supports_contract(
*,
turn_state: PMFlowAITurnState,
contract_key: str,
objective: dict[str, Any],
plan: dict[str, Any],
draft: dict[str, Any],
) -> bool:
evidence_to_use = {
str(item or "").strip()
for item in list(objective.get("evidence_to_use") or [])
if str(item or "").strip()
}
draft_sources = {
str(item or "").strip()
for item in list(plan.get("draft_sources") or [])
if str(item or "").strip()
}
source_renderer = str(draft.get("source_renderer") or "").strip()
if not evidence_to_use and not draft_sources and not source_renderer:
return False
if contract_key == "protected_internal_refusal.v1":
must_include = {
str(item or "").strip()
for item in list(objective.get("must_include") or [])
if str(item or "").strip()
}
must_not_include = {
str(item or "").strip()
for item in list(objective.get("must_not_include") or [])
if str(item or "").strip()
}
protected_categories = {
str(item or "").strip()
for item in list(turn_state.disclosure_state.protected_categories or [])
if str(item or "").strip()
}
return (
str(objective.get("required_decision") or "").strip()
== "explain_customer_safe_boundary_and_allowed_alternative"
and "说明用户侧信息披露边界" in must_include
and protected_categories.issubset(must_not_include)
and str(plan.get("answer_strategy") or "").strip()
== "state_disclosure_boundary_then_offer_customer_safe_alternative"
)
return bool(evidence_to_use or draft_sources or source_renderer)