PMFlow/backend/app/schemas/project_operating_evidence.py

171 lines
7.3 KiB
Python

"""Project operating evidence contracts for PMFlow AI.
The packet is a customer-safe, read-only snapshot of one project across the
PMFlow OS surfaces. It is evidence, not judgement: tools fill it, the absorber
turns it into TurnState semantics, and presentation renders the final answer.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
EvidenceCoverageStatus = Literal["covered", "partial", "missing", "stale"]
OperatingActionSafety = Literal["safe_read", "prepare_only", "requires_confirmation", "blocked"]
class ProjectOperatingEvidenceRef(BaseModel):
ref_type: str = Field(default="", max_length=80)
ref_id: str = Field(default="", max_length=160)
display_label: str = Field(default="", max_length=200)
source_surface: str = Field(default="", max_length=120)
href: str = Field(default="", max_length=240)
@field_validator("ref_type", "ref_id", "display_label", "source_surface", "href", mode="before")
@classmethod
def _clean_text(cls, value: Any) -> str:
return _clean(value, limit=240)
class ProjectOperatingEvidenceFacet(BaseModel):
facet_key: str = Field(..., min_length=1, max_length=80)
display_label: str = Field(default="", max_length=120)
coverage_status: EvidenceCoverageStatus = "missing"
summary: str = Field(default="", max_length=500)
facts: list[str] = Field(default_factory=list)
business_values: dict[str, Any] = Field(default_factory=dict)
evidence_refs: list[ProjectOperatingEvidenceRef] = Field(default_factory=list)
remaining_gaps: list[str] = Field(default_factory=list)
@field_validator("facet_key", "display_label", "summary", mode="before")
@classmethod
def _clean_text(cls, value: Any) -> str:
return _clean(value, limit=500)
@field_validator("facts", "remaining_gaps", mode="before")
@classmethod
def _clean_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [_clean(item, limit=240) for item in rows[:12] if _clean(item, limit=240)]
@model_validator(mode="after")
def _cap_refs(self) -> "ProjectOperatingEvidenceFacet":
self.evidence_refs = list(self.evidence_refs or [])[:12]
return self
class ProjectOperatingEvidenceCoverage(BaseModel):
policy_version: str = "project_operating_coverage.v1"
covered_facets: list[str] = Field(default_factory=list)
partial_facets: list[str] = Field(default_factory=list)
missing_facets: list[str] = Field(default_factory=list)
stale_facets: list[str] = Field(default_factory=list)
coverage_score: float = Field(default=0.0, ge=0.0, le=1.0)
@field_validator("covered_facets", "partial_facets", "missing_facets", "stale_facets", mode="before")
@classmethod
def _clean_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [_clean(item, limit=80) for item in rows[:20] if _clean(item, limit=80)]
class ProjectOperatingSuggestedAction(BaseModel):
action_key: str = Field(default="", max_length=100)
display_label: str = Field(default="", max_length=180)
safety: OperatingActionSafety = "prepare_only"
reason: str = Field(default="", max_length=400)
target_ref: str = Field(default="", max_length=160)
@field_validator("action_key", "display_label", "reason", "target_ref", mode="before")
@classmethod
def _clean_text(cls, value: Any) -> str:
return _clean(value, limit=400)
class ProjectOperatingEvidencePacket(BaseModel):
packet_version: str = "project_operating_evidence.v1"
project_id: int | None = None
project_ref: str = Field(default="", max_length=160)
project_title: str = Field(default="", max_length=200)
project_status: str = Field(default="", max_length=120)
object_binding: dict[str, Any] = Field(default_factory=dict)
command_state: ProjectOperatingEvidenceFacet
workbench_state: ProjectOperatingEvidenceFacet
decision_review_state: ProjectOperatingEvidenceFacet
execution_state: ProjectOperatingEvidenceFacet
quality_test_state: ProjectOperatingEvidenceFacet
release_growth_state: ProjectOperatingEvidenceFacet
team_state: ProjectOperatingEvidenceFacet
project_memory_state: ProjectOperatingEvidenceFacet
risk_blocker_state: ProjectOperatingEvidenceFacet
recent_timeline_state: ProjectOperatingEvidenceFacet
coverage: ProjectOperatingEvidenceCoverage = Field(default_factory=ProjectOperatingEvidenceCoverage)
evidence_refs: list[ProjectOperatingEvidenceRef] = Field(default_factory=list)
remaining_gaps: list[str] = Field(default_factory=list)
safe_next_actions: list[ProjectOperatingSuggestedAction] = Field(default_factory=list)
blocked_actions: list[ProjectOperatingSuggestedAction] = Field(default_factory=list)
@field_validator("project_ref", "project_title", "project_status", mode="before")
@classmethod
def _clean_text(cls, value: Any) -> str:
return _clean(value, limit=200)
@field_validator("remaining_gaps", mode="before")
@classmethod
def _clean_gaps(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [_clean(item, limit=120) for item in rows[:20] if _clean(item, limit=120)]
@model_validator(mode="after")
def _sync_coverage(self) -> "ProjectOperatingEvidencePacket":
facets = project_operating_facets(self)
covered = [facet.facet_key for facet in facets if facet.coverage_status == "covered"]
partial = [facet.facet_key for facet in facets if facet.coverage_status == "partial"]
missing = [facet.facet_key for facet in facets if facet.coverage_status == "missing"]
stale = [facet.facet_key for facet in facets if facet.coverage_status == "stale"]
score = (len(covered) + len(partial) * 0.5) / max(len(facets), 1)
self.coverage = ProjectOperatingEvidenceCoverage(
covered_facets=covered,
partial_facets=partial,
missing_facets=missing,
stale_facets=stale,
coverage_score=round(float(score), 3),
)
self.remaining_gaps = _unique([gap for facet in facets for gap in facet.remaining_gaps] + list(self.remaining_gaps or []), limit=20)
self.evidence_refs = list(self.evidence_refs or [])[:30]
self.safe_next_actions = list(self.safe_next_actions or [])[:12]
self.blocked_actions = list(self.blocked_actions or [])[:12]
return self
def project_operating_facets(packet: ProjectOperatingEvidencePacket) -> list[ProjectOperatingEvidenceFacet]:
return [
packet.command_state,
packet.workbench_state,
packet.decision_review_state,
packet.execution_state,
packet.quality_test_state,
packet.release_growth_state,
packet.team_state,
packet.project_memory_state,
packet.risk_blocker_state,
packet.recent_timeline_state,
]
def _clean(value: Any, *, limit: int) -> str:
return " ".join(str(value or "").strip().replace("\x00", "").split())[:limit]
def _unique(values: list[Any], *, limit: int) -> list[str]:
out: list[str] = []
for value in values:
text = _clean(value, limit=160)
if text and text not in out:
out.append(text)
if len(out) >= limit:
break
return out