PMFlow/backend/app/services/pmflow_ai_evidence_coverage...

128 lines
5.2 KiB
Python

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