"""Pydantic models — every data structure the diagnostic captures or computes. Two families live here: * **Engagement data** — what the workshop records (participants, baseline, scores) and what the engine computes (:class:`ValueAtStake`). The serialized :class:`Engagement` is the source-of-truth export artifact. * **Config data** — the validated shape of ``configs/*.yaml``: the competency model (base) and the industry overlay (drivers, unlock costs). Deviations from the build spec (docs/build_spec_v1.md), both additive: * :class:`UnlockMove` carries ``competency_ids`` (plural). When several foundational competencies tie at the weakest level, the binding constraint *is the set* — a single-competency move would honestly unlock nothing. Moves are tier lifts of the whole binding set. * :class:`ValueAtStake` also records the driver breakdown, the realization band, and guard-rail warnings, so the export explains its own numbers. """ from __future__ import annotations from datetime import date, datetime from typing import Literal from pydantic import BaseModel, Field, field_validator, model_validator Function = Literal["cx", "it", "ops", "finance", "other"] Confidence = Literal["known", "estimated", "unknown"] CONFIDENCE_ICON: dict[str, str] = { "known": "🟢", "estimated": "🟡", "unknown": "🔴", } #: The six numeric baseline fields collected in the workshop form. BASELINE_FIELDS: tuple[str, ...] = ( "annual_contact_volume", "blended_cost_per_contact", "agent_headcount", "annual_attrition_rate", "current_containment_rate", "average_handle_time_seconds", ) # ── Engagement data ────────────────────────────────────────────────── class Participant(BaseModel): name: str role: str = "" function: Function = "other" class OperationalBaseline(BaseModel): """Contact-center MVP fields; other industries overlay their own.""" annual_contact_volume: int = Field(ge=0) blended_cost_per_contact: float = Field(ge=0) agent_headcount: int = Field(ge=0) annual_attrition_rate: float = Field(ge=0, le=1) current_containment_rate: float = Field(ge=0, le=1) average_handle_time_seconds: int = Field(ge=0) csat_baseline: float | None = None revenue_at_risk: float | None = None #: Per-field confidence flags (🟢 known / 🟡 estimated / 🔴 unknown). field_confidence: dict[str, Confidence] = Field(default_factory=dict) @field_validator("field_confidence") @classmethod def _known_fields_only(cls, v: dict[str, Confidence]) -> dict[str, Confidence]: unknown = set(v) - set(BASELINE_FIELDS) if unknown: raise ValueError(f"confidence flags for unknown fields: {sorted(unknown)}") return v def confidence_for(self, field: str) -> Confidence: return self.field_confidence.get(field, "estimated") class CompetencyScore(BaseModel): competency_id: str dimension: str score: int = Field(ge=1, le=5) evidence: str = "" # one line: why this score scorer_role: str = "facilitator" # MVP: facilitator consensus scored_at: datetime class DriverValue(BaseModel): """One value driver's theoretical annual value, as a range.""" driver_id: str name: str theoretical_low: float theoretical_high: float class UnlockMove(BaseModel): """One move in the unlock sequence: lift the binding set one level. ``value_unlocked_*`` is the **annual run-rate** realizable value the lift adds (delta of the realization band times theoretical value). Costs are ``None`` when the config has no entry for a lift — shown as "cost not configured", never guessed. """ competency_ids: list[str] current_level: int = Field(ge=1, le=4) target_level: int = Field(ge=2, le=5) est_cost_low: float | None = None est_cost_high: float | None = None est_weeks: int | None = None value_unlocked_low: float value_unlocked_high: float note: str = "" @model_validator(mode="after") def _one_level_lift(self) -> "UnlockMove": if self.target_level != self.current_level + 1: raise ValueError("unlock moves lift exactly one level") return self class ValueAtStake(BaseModel): theoretical_annual_value_low: float theoretical_annual_value_high: float realizable_18mo_low: float realizable_18mo_high: float trapped_value_low: float # annual: theoretical − realizable run-rate trapped_value_high: float binding_constraints: list[str] # competency_ids capping realization unlock_sequence: list[UnlockMove] # Self-explaining extras (additive to the build spec): weakest_foundational_score: int = Field(ge=1, le=5) realization_factor_low: float = Field(ge=0, le=1) realization_factor_high: float = Field(ge=0, le=1) driver_values: list[DriverValue] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) class Engagement(BaseModel): engagement_id: str # e.g. "acme_2026-07-19" client_name: str industry_config: str # which config was loaded facilitator: str workshop_date: date participants: list[Participant] = Field(default_factory=list) operational_baseline: OperationalBaseline scores: list[CompetencyScore] = Field(default_factory=list) computed_value: ValueAtStake | None = None notes: str = "" # ── Config data (configs/*.yaml) ───────────────────────────────────── class Dimension(BaseModel): id: str name: str class Competency(BaseModel): id: str dimension: str name: str description: str failure_vignette: str level_descriptors: dict[int, str] @field_validator("level_descriptors") @classmethod def _five_levels(cls, v: dict[int, str]) -> dict[int, str]: if set(v) != {1, 2, 3, 4, 5}: raise ValueError("level_descriptors must cover exactly levels 1–5") return v class CappingBand(BaseModel): realized_low: float = Field(ge=0, le=1) realized_high: float = Field(ge=0, le=1) @model_validator(mode="after") def _ordered(self) -> "CappingBand": if self.realized_low > self.realized_high: raise ValueError("realized_low > realized_high") return self class ValueDriver(BaseModel): """A configured value driver. ``kind`` selects the math in value_math.py; ``value_formula`` documents it verbatim in exports.""" id: str name: str kind: Literal["containment_lift", "aht_reduction", "attrition_reduction"] baseline_field: str value_formula: str = "" source: str = "" # kind-specific parameters (validated in value_math dispatch): lift_range_pts_low: float | None = None lift_range_pts_high: float | None = None reduction_pct_low: float | None = None reduction_pct_high: float | None = None cost_per_replacement_default: float | None = None class LiftCost(BaseModel): cost_low: float = Field(ge=0) cost_high: float = Field(ge=0) weeks: int = Field(ge=0) @model_validator(mode="after") def _ordered(self) -> "LiftCost": if self.cost_low > self.cost_high: raise ValueError("cost_low > cost_high") return self class DiagnosticConfig(BaseModel): """base.yaml merged with one industry overlay — what the engine consumes.""" version: str industry: str display_name: str dimensions: list[Dimension] competencies: list[Competency] capping_heuristic: dict[int, CappingBand] foundational_competencies: list[str] value_drivers: list[ValueDriver] = Field(default_factory=list) #: unlock_costs[competency_id]["lift_2_to_3"] -> LiftCost unlock_costs: dict[str, dict[str, LiftCost]] = Field(default_factory=dict) @model_validator(mode="after") def _consistent(self) -> "DiagnosticConfig": comp_ids = [c.id for c in self.competencies] if len(comp_ids) != len(set(comp_ids)): raise ValueError("duplicate competency ids") dim_ids = {d.id for d in self.dimensions} for c in self.competencies: if c.dimension not in dim_ids: raise ValueError(f"competency {c.id}: unknown dimension {c.dimension}") missing = set(self.foundational_competencies) - set(comp_ids) if missing: raise ValueError(f"foundational competencies not defined: {sorted(missing)}") if set(self.capping_heuristic) != {1, 2, 3, 4, 5}: raise ValueError("capping_heuristic must cover exactly scores 1–5") for lo, hi in zip(sorted(self.capping_heuristic), sorted(self.capping_heuristic)[1:]): a, b = self.capping_heuristic[lo], self.capping_heuristic[hi] if a.realized_low > b.realized_low or a.realized_high > b.realized_high: raise ValueError("capping_heuristic must be non-decreasing in score") for cid, lifts in self.unlock_costs.items(): if cid not in comp_ids: raise ValueError(f"unlock_costs for unknown competency {cid}") for key in lifts: if not _valid_lift_key(key): raise ValueError(f"unlock_costs[{cid}]: bad lift key {key!r}") return self def competency(self, competency_id: str) -> Competency: for c in self.competencies: if c.id == competency_id: return c raise KeyError(competency_id) def dimension_name(self, dimension_id: str) -> str: for d in self.dimensions: if d.id == dimension_id: return d.name raise KeyError(dimension_id) def lift_cost(self, competency_id: str, from_level: int) -> LiftCost | None: return self.unlock_costs.get(competency_id, {}).get( f"lift_{from_level}_to_{from_level + 1}") def _valid_lift_key(key: str) -> bool: parts = key.split("_") # "lift", a, "to", b — one-level lifts only return (len(parts) == 4 and parts[0] == "lift" and parts[2] == "to" and parts[1].isdigit() and parts[3].isdigit() and int(parts[3]) == int(parts[1]) + 1 and 1 <= int(parts[1]) <= 4)