"""Source authority decisions for PMFlow AI turn runtime. This layer decides which source domain has authority for the current turn. It does not classify user text by keywords. It only consumes structured turn state: object binding, selected goal frame, requested source policy and already performed actions. """ from __future__ import annotations from dataclasses import dataclass, field from ..schemas.pmflow_ai_turn_runtime import PMFlowAITurnState, RuntimeActionKind from .pmflow_ai_static_contract_registry import PMFlowAIStaticContractRegistry @dataclass(frozen=True) class PMFlowAIEvidenceAuthorityDecision: authority_domain: str = "unrestricted" required_evidence: str = "unrestricted" contract_key: str = "" required_first_action: RuntimeActionKind | None = None forbidden_first_actions: tuple[RuntimeActionKind, ...] = field(default_factory=tuple) can_answer_without_tool: bool = False renderer_key: str = "" verifier_policy: str = "" process_label: str = "" reason: str = "" @property def needs_probe(self) -> bool: return bool(self.required_first_action) class PMFlowAISourceAuthority: """Protect source selection before the action selector runs.""" def decide(self, turn_state: PMFlowAITurnState) -> PMFlowAIEvidenceAuthorityDecision: static_contract = PMFlowAIStaticContractRegistry().resolve_for_turn_state(turn_state) if static_contract is not None: return PMFlowAIEvidenceAuthorityDecision( authority_domain=static_contract.authority_domain, required_evidence=static_contract.required_evidence, contract_key=static_contract.contract_key, forbidden_first_actions=static_contract.forbidden_first_actions, can_answer_without_tool=True, renderer_key=static_contract.renderer_key, verifier_policy=static_contract.verifier_policy, process_label=static_contract.process_label, reason=static_contract.reason, ) primary = turn_state.object_state.primary_object if primary is None: return PMFlowAIEvidenceAuthorityDecision() if str(primary.object_type or "").strip().lower() != "project": return PMFlowAIEvidenceAuthorityDecision() if not _is_strong_project_binding(turn_state): return PMFlowAIEvidenceAuthorityDecision() if _project_status_already_read(turn_state) or _project_operating_evidence_available(turn_state): return PMFlowAIEvidenceAuthorityDecision( authority_domain="pmflow_project_operating_state", required_evidence="project_operating_state", forbidden_first_actions=("external_search", "external_fetch"), reason="已读取绑定项目运行态,后续可按证据缺口继续。", ) return PMFlowAIEvidenceAuthorityDecision( authority_domain="pmflow_project_operating_state", required_evidence="project_operating_state", required_first_action="inspect_project_status", forbidden_first_actions=("external_search", "external_fetch"), reason="本轮已绑定 PMFlow 项目对象,必须先读取项目运行态证据,不能用外部公开来源替代项目进展。", ) class PMFlowAIEvidenceAuthority(PMFlowAISourceAuthority): """Backward-compatible name for existing runtime imports.""" def _is_strong_project_binding(turn_state: PMFlowAITurnState) -> bool: primary = turn_state.object_state.primary_object if primary is None: 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_status_already_read(turn_state: PMFlowAITurnState) -> bool: return any(action.action_type == "inspect_project_status" for action in list(turn_state.action_state.performed_actions or [])) def _project_operating_evidence_available(turn_state: PMFlowAITurnState) -> bool: structured = dict(turn_state.evidence_state.structured_evidence or {}) primary = turn_state.object_state.primary_object if primary is None: return False primary_ref = str(primary.object_ref or "").strip() primary_title = str(primary.title or "").strip() packets = structured.get("project_operating_evidence_packets") rows = packets if isinstance(packets, list) else [] single = structured.get("project_operating_evidence_packet") for packet in [*rows, single]: if not isinstance(packet, dict): continue project_ref = str(packet.get("project_ref") or "").strip() project_title = str(packet.get("project_title") or "").strip() if (primary_ref and project_ref == primary_ref) or (primary_title and project_title == primary_title): return True return False