171 lines
7.3 KiB
Python
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
|