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