"""Evidence coverage gate for PMFlow AI turn runtime.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Literal from ..schemas.pmflow_ai_turn_runtime import PMFlowAITurnState, RuntimeActionKind CoverageStatus = Literal["not_applicable", "needs_probe", "sufficient", "insufficient_after_probe"] @dataclass(frozen=True) class PMFlowAIEvidenceCoverageDecision: status: CoverageStatus = "not_applicable" missing: list[str] = field(default_factory=list) next_action_type: RuntimeActionKind | None = None reason: str = "" @property def needs_probe(self) -> bool: return self.status == "needs_probe" and self.next_action_type is not None @property def sufficient(self) -> bool: return self.status in {"not_applicable", "sufficient", "insufficient_after_probe"} class PMFlowAIEvidenceCoverageGate: """Require minimum structured evidence before final synthesis.""" def evaluate(self, turn_state: PMFlowAITurnState) -> PMFlowAIEvidenceCoverageDecision: if not _requires_project_execution_coverage(turn_state): return PMFlowAIEvidenceCoverageDecision() missing = _missing_project_execution_signals(turn_state) if not missing: return PMFlowAIEvidenceCoverageDecision(status="sufficient", reason="项目执行态证据覆盖已满足。") if not _action_performed(turn_state, "inspect_project_status"): return PMFlowAIEvidenceCoverageDecision( status="needs_probe", missing=missing, next_action_type="inspect_project_status", reason="当前回答需要项目执行态覆盖,必须先读取项目阶段、当前执行、阻塞和任务队列。", ) return PMFlowAIEvidenceCoverageDecision( status="insufficient_after_probe", missing=missing, reason="已读取项目执行态,但证据覆盖仍不完整;最终回答必须显式保留缺口边界。", ) def _requires_project_execution_coverage(turn_state: PMFlowAITurnState) -> bool: selected = turn_state.selected_frame if selected is None: return False if turn_state.answer_state.answer_shape not in {"execution_guidance", "status_answer"}: return False if selected.operation_kind not in {"inspect_object", "guide_execution"}: return False primary = turn_state.object_state.primary_object if primary is None: return False if str(primary.object_type or "").lower() != "project": return False return str(primary.binding_strength or "").lower() not in {"", "none", "context", "visible_context"} def _missing_project_execution_signals(turn_state: PMFlowAITurnState) -> list[str]: packet = _project_operating_packet_for_turn(turn_state) if not packet: return list(_PROJECT_EXECUTION_COVERAGE_PROFILE.keys()) missing: list[str] = [] for gap_key, facet_keys in _PROJECT_EXECUTION_COVERAGE_PROFILE.items(): if not _any_facet_covered(packet=packet, facet_keys=facet_keys): missing.append(gap_key) return missing _PROJECT_EXECUTION_COVERAGE_PROFILE: dict[str, tuple[str, ...]] = { "project_stage": ("command_state",), "current_execution_state": ("execution_state",), "active_or_next_tasks": ("workbench_state",), "blockers_or_gate": ("risk_blocker_state", "decision_review_state", "release_growth_state"), } def _project_operating_packet_for_turn(turn_state: PMFlowAITurnState) -> dict[str, Any]: structured = dict(turn_state.evidence_state.structured_evidence or {}) packets = structured.get("project_operating_evidence_packets") rows = packets if isinstance(packets, list) else [] primary = turn_state.object_state.primary_object if primary is None: return {} primary_ref = str(primary.object_ref or "").strip() primary_title = str(primary.title or "").strip() for packet in rows: 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 packet single = structured.get("project_operating_evidence_packet") if isinstance(single, dict): project_ref = str(single.get("project_ref") or "").strip() project_title = str(single.get("project_title") or "").strip() if (primary_ref and project_ref == primary_ref) or (primary_title and project_title == primary_title): return single return {} def _any_facet_covered(*, packet: dict[str, Any], facet_keys: tuple[str, ...]) -> bool: return any(_facet_covered(packet.get(key)) for key in facet_keys) def _facet_covered(value: Any) -> bool: if not isinstance(value, dict): return False status = str(value.get("coverage_status") or "").strip() return status in {"covered", "partial"} def _action_performed(turn_state: PMFlowAITurnState, action_type: RuntimeActionKind) -> bool: return any(action.action_type == action_type for action in list(turn_state.action_state.performed_actions or []))