CX Discovery Notebook

This commit is contained in:
2026-07-23 12:04:37 -04:00
parent cbbc9ba839
commit 71b913d7fe
45 changed files with 23170 additions and 1830 deletions

View File

@@ -0,0 +1,281 @@
"""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 15")
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 15")
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)