"""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