feat: add master notebook library scaffolding and review tooling
Add CLAUDE.md defining the Palladium master notebook conventions and Red Panda Approval criteria, plus a review-notebook slash command for LLM-driven notebook review. Expand .gitignore to block client/engagement documents and generated exports, keeping masters client-clean while allowing text/image sources. Normalize slider widget numeric values from floats to integers in notebook JSON.
This commit is contained in:
62
assessments/CX_AI_Diagnostic/diaglib/__init__.py
Normal file
62
assessments/CX_AI_Diagnostic/diaglib/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""diaglib — the CX AI Advisory Diagnostic engine (Mercury Notebook Pattern).
|
||||
|
||||
All math and data contracts live here; the notebook only arranges and
|
||||
renders. See docs/build_spec_v1.md for the instrument's specification.
|
||||
"""
|
||||
|
||||
from .config import configs_dir, list_industries, load_config
|
||||
from .export import (
|
||||
CSV_COLUMNS,
|
||||
engagement_json,
|
||||
load_engagement,
|
||||
scores_dataframe,
|
||||
write_exports,
|
||||
)
|
||||
from .models import (
|
||||
BASELINE_FIELDS,
|
||||
CONFIDENCE_ICON,
|
||||
Competency,
|
||||
CompetencyScore,
|
||||
DiagnosticConfig,
|
||||
DriverValue,
|
||||
Engagement,
|
||||
OperationalBaseline,
|
||||
Participant,
|
||||
UnlockMove,
|
||||
ValueAtStake,
|
||||
)
|
||||
from .scoring import (
|
||||
build_engagement,
|
||||
build_scores,
|
||||
dimension_rollup,
|
||||
evidence_coverage,
|
||||
heatmap_grid,
|
||||
make_engagement_id,
|
||||
parse_participants,
|
||||
)
|
||||
from .staging import backstage, on_stage
|
||||
from .value_math import (
|
||||
MONTHS_18,
|
||||
binding_constraints,
|
||||
driver_value,
|
||||
html_money,
|
||||
money,
|
||||
unlock_sequence,
|
||||
value_at_stake,
|
||||
weakest_foundational_score,
|
||||
)
|
||||
from .visuals import heatmap_fig, split_fig, unlock_fig, value_bands_fig
|
||||
|
||||
__all__ = [
|
||||
"BASELINE_FIELDS", "CONFIDENCE_ICON", "CSV_COLUMNS", "MONTHS_18",
|
||||
"Competency", "CompetencyScore", "DiagnosticConfig", "DriverValue",
|
||||
"Engagement", "OperationalBaseline", "Participant", "UnlockMove",
|
||||
"ValueAtStake", "backstage", "binding_constraints", "build_engagement",
|
||||
"build_scores", "configs_dir", "dimension_rollup", "driver_value",
|
||||
"engagement_json", "evidence_coverage", "heatmap_fig", "heatmap_grid",
|
||||
"html_money", "list_industries", "load_config", "load_engagement",
|
||||
"make_engagement_id", "money", "on_stage", "parse_participants",
|
||||
"scores_dataframe", "split_fig", "unlock_fig", "unlock_sequence",
|
||||
"value_at_stake", "value_bands_fig", "weakest_foundational_score",
|
||||
"write_exports",
|
||||
]
|
||||
77
assessments/CX_AI_Diagnostic/diaglib/config.py
Normal file
77
assessments/CX_AI_Diagnostic/diaglib/config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Load and merge ``configs/*.yaml`` into a validated DiagnosticConfig.
|
||||
|
||||
``base.yaml`` holds the industry-independent competency model; every other
|
||||
YAML in the directory is an industry overlay declaring ``extends: base``
|
||||
plus its value drivers and unlock costs. Merging is shallow and explicit:
|
||||
the overlay contributes industry identity, drivers, and costs; the base
|
||||
contributes everything else. Overlays may not redefine the competency
|
||||
model — one instrument, many industries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .models import DiagnosticConfig
|
||||
|
||||
#: Overlay keys an industry file may set. Anything else (competencies,
|
||||
#: capping_heuristic, …) belongs in base.yaml and is rejected loudly.
|
||||
_OVERLAY_KEYS = {"extends", "industry", "display_name", "value_drivers", "unlock_costs"}
|
||||
|
||||
|
||||
def configs_dir(start: Path | None = None) -> Path:
|
||||
"""The study's ``configs/`` directory, found from ``start`` (or CWD).
|
||||
|
||||
Walks up so it works from the study root, ``notebooks/``, or ``tests/``.
|
||||
"""
|
||||
here = (start or Path.cwd()).resolve()
|
||||
for candidate in (here, *here.parents):
|
||||
d = candidate / "configs"
|
||||
if (d / "base.yaml").exists():
|
||||
return d
|
||||
raise FileNotFoundError("configs/base.yaml not found above " + str(here))
|
||||
|
||||
|
||||
def list_industries(directory: Path | None = None) -> list[str]:
|
||||
"""Industry config names (file stems), base excluded, sorted."""
|
||||
d = directory or configs_dir()
|
||||
return sorted(p.stem for p in d.glob("*.yaml") if p.stem != "base")
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{path.name}: expected a mapping at top level")
|
||||
return data
|
||||
|
||||
|
||||
def load_config(industry: str, directory: Path | None = None) -> DiagnosticConfig:
|
||||
"""Load ``base.yaml`` + the named industry overlay, validated."""
|
||||
d = directory or configs_dir()
|
||||
base = _read_yaml(d / "base.yaml")
|
||||
overlay = _read_yaml(d / f"{industry}.yaml")
|
||||
|
||||
if overlay.get("extends") != "base":
|
||||
raise ValueError(f"{industry}.yaml must declare 'extends: base'")
|
||||
stray = set(overlay) - _OVERLAY_KEYS
|
||||
if stray:
|
||||
raise ValueError(
|
||||
f"{industry}.yaml sets base-only keys {sorted(stray)} — "
|
||||
"the competency model lives in base.yaml")
|
||||
|
||||
merged: dict[str, Any] = {
|
||||
"version": base["version"],
|
||||
"dimensions": base["dimensions"],
|
||||
"competencies": base["competencies"],
|
||||
"capping_heuristic": base["capping_heuristic"],
|
||||
"foundational_competencies": base["foundational_competencies"],
|
||||
"industry": overlay["industry"],
|
||||
"display_name": overlay.get("display_name", overlay["industry"]),
|
||||
"value_drivers": overlay.get("value_drivers") or [],
|
||||
"unlock_costs": overlay.get("unlock_costs") or {},
|
||||
}
|
||||
return DiagnosticConfig.model_validate(merged)
|
||||
62
assessments/CX_AI_Diagnostic/diaglib/export.py
Normal file
62
assessments/CX_AI_Diagnostic/diaglib/export.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Structured engagement exports — the JSON is the source-of-truth artifact.
|
||||
|
||||
``exports/{engagement_id}.json`` — the full Engagement, serialized.
|
||||
``exports/{engagement_id}.csv`` — one row per competency, for
|
||||
cross-engagement spreadsheet analysis (build spec §8).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .models import DiagnosticConfig, Engagement
|
||||
|
||||
CSV_COLUMNS = [
|
||||
"engagement_id", "client_name", "industry", "workshop_date",
|
||||
"competency_id", "dimension", "score", "evidence",
|
||||
"is_foundational", "is_binding_constraint",
|
||||
]
|
||||
|
||||
|
||||
def engagement_json(engagement: Engagement) -> str:
|
||||
return json.dumps(engagement.model_dump(mode="json"), indent=2,
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
def scores_dataframe(engagement: Engagement,
|
||||
config: DiagnosticConfig) -> pd.DataFrame:
|
||||
binding = set(engagement.computed_value.binding_constraints
|
||||
if engagement.computed_value else [])
|
||||
foundational = set(config.foundational_competencies)
|
||||
rows = [{
|
||||
"engagement_id": engagement.engagement_id,
|
||||
"client_name": engagement.client_name,
|
||||
"industry": engagement.industry_config,
|
||||
"workshop_date": engagement.workshop_date.isoformat(),
|
||||
"competency_id": s.competency_id,
|
||||
"dimension": s.dimension,
|
||||
"score": s.score,
|
||||
"evidence": s.evidence,
|
||||
"is_foundational": s.competency_id in foundational,
|
||||
"is_binding_constraint": s.competency_id in binding,
|
||||
} for s in engagement.scores]
|
||||
return pd.DataFrame(rows, columns=CSV_COLUMNS)
|
||||
|
||||
|
||||
def write_exports(engagement: Engagement, config: DiagnosticConfig,
|
||||
exports_dir: Path) -> tuple[Path, Path]:
|
||||
"""Write both artifacts; returns ``(json_path, csv_path)``."""
|
||||
exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = exports_dir / f"{engagement.engagement_id}.json"
|
||||
csv_path = exports_dir / f"{engagement.engagement_id}.csv"
|
||||
json_path.write_text(engagement_json(engagement), encoding="utf-8")
|
||||
scores_dataframe(engagement, config).to_csv(csv_path, index=False)
|
||||
return json_path, csv_path
|
||||
|
||||
|
||||
def load_engagement(json_path: Path) -> Engagement:
|
||||
"""Reload a saved engagement for review (acceptance §10 nice-to-have)."""
|
||||
return Engagement.model_validate_json(json_path.read_text(encoding="utf-8"))
|
||||
281
assessments/CX_AI_Diagnostic/diaglib/models.py
Normal file
281
assessments/CX_AI_Diagnostic/diaglib/models.py
Normal 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 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)
|
||||
133
assessments/CX_AI_Diagnostic/diaglib/scoring.py
Normal file
133
assessments/CX_AI_Diagnostic/diaglib/scoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Score aggregation, gap analysis, and engagement assembly.
|
||||
|
||||
Everything the notebook needs between raw widget values and the engine's
|
||||
value math lives here — the notebook itself computes nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import get_args
|
||||
|
||||
from .models import (
|
||||
CompetencyScore,
|
||||
DiagnosticConfig,
|
||||
Engagement,
|
||||
Function,
|
||||
OperationalBaseline,
|
||||
Participant,
|
||||
ValueAtStake,
|
||||
)
|
||||
|
||||
FUNCTIONS: tuple[str, ...] = get_args(Function)
|
||||
|
||||
|
||||
# ── Engagement identity ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_engagement_id(client_name: str, workshop_date: date) -> str:
|
||||
"""``"Acme Corp!" + 2026-07-19 -> "acme_corp_2026-07-19"``."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", client_name.lower()).strip("_") or "client"
|
||||
return f"{slug}_{workshop_date.isoformat()}"
|
||||
|
||||
|
||||
def parse_participants(text: str) -> list[Participant]:
|
||||
"""Parse ``"Name | Role | function; Name | Role | function"``.
|
||||
|
||||
Forgiving by design — the facilitator types this live. Missing parts
|
||||
default (role empty, function ``other``); unknown functions map to
|
||||
``other`` rather than erroring mid-workshop.
|
||||
"""
|
||||
participants: list[Participant] = []
|
||||
for entry in text.split(";"):
|
||||
parts = [p.strip() for p in entry.split("|")]
|
||||
if not parts or not parts[0]:
|
||||
continue
|
||||
function = parts[2].lower() if len(parts) > 2 else "other"
|
||||
participants.append(Participant(
|
||||
name=parts[0],
|
||||
role=parts[1] if len(parts) > 1 else "",
|
||||
function=function if function in FUNCTIONS else "other",
|
||||
))
|
||||
return participants
|
||||
|
||||
|
||||
# ── Scores ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scores(config: DiagnosticConfig, raw: dict[str, tuple[int, str]],
|
||||
scored_at: datetime,
|
||||
scorer_role: str = "facilitator") -> list[CompetencyScore]:
|
||||
"""``raw[competency_id] = (score, evidence)`` → validated scores, config order."""
|
||||
missing = [c.id for c in config.competencies if c.id not in raw]
|
||||
if missing:
|
||||
raise ValueError(f"unscored competencies: {missing}")
|
||||
return [
|
||||
CompetencyScore(
|
||||
competency_id=c.id, dimension=c.dimension,
|
||||
score=raw[c.id][0], evidence=raw[c.id][1].strip(),
|
||||
scorer_role=scorer_role, scored_at=scored_at,
|
||||
)
|
||||
for c in config.competencies
|
||||
]
|
||||
|
||||
|
||||
def dimension_rollup(config: DiagnosticConfig,
|
||||
scores: list[CompetencyScore]) -> list[tuple[str, str, float]]:
|
||||
"""``(dimension_id, dimension_name, mean score)`` per dimension, config order."""
|
||||
by_dim: dict[str, list[int]] = {d.id: [] for d in config.dimensions}
|
||||
for s in scores:
|
||||
by_dim[s.dimension].append(s.score)
|
||||
return [(d.id, d.name, sum(v) / len(v))
|
||||
for d in config.dimensions if (v := by_dim[d.id])]
|
||||
|
||||
|
||||
def evidence_coverage(scores: list[CompetencyScore]) -> tuple[int, int]:
|
||||
"""``(scores with evidence captured, total scores)``."""
|
||||
return sum(1 for s in scores if s.evidence), len(scores)
|
||||
|
||||
|
||||
def heatmap_grid(config: DiagnosticConfig, scores: list[CompetencyScore]) -> dict:
|
||||
"""Pure data for the 4×3 heatmap — rows are dimensions, three
|
||||
competencies per row in config order. Returned as plain lists so the
|
||||
visuals layer holds no logic."""
|
||||
by_id = {s.competency_id: s for s in scores}
|
||||
rows, z, text, hover = [], [], [], []
|
||||
for d in config.dimensions:
|
||||
comps = [c for c in config.competencies if c.dimension == d.id]
|
||||
rows.append(d.name)
|
||||
z.append([by_id[c.id].score for c in comps])
|
||||
text.append([f"{by_id[c.id].score}<br>{c.name}" for c in comps])
|
||||
hover.append([
|
||||
f"<b>{c.name}</b> — level {by_id[c.id].score}<br>"
|
||||
f"{c.level_descriptors[by_id[c.id].score]}<br>"
|
||||
f"<i>{by_id[c.id].evidence or 'no evidence captured'}</i>"
|
||||
for c in comps
|
||||
])
|
||||
return {"rows": rows, "z": z, "text": text, "hover": hover,
|
||||
"cols": ["", "", ""]}
|
||||
|
||||
|
||||
# ── Assembly ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_engagement(*, config: DiagnosticConfig, client_name: str,
|
||||
facilitator: str, workshop_date: date,
|
||||
participants: list[Participant],
|
||||
baseline: OperationalBaseline,
|
||||
scores: list[CompetencyScore],
|
||||
computed_value: ValueAtStake | None,
|
||||
notes: str = "") -> Engagement:
|
||||
return Engagement(
|
||||
engagement_id=make_engagement_id(client_name, workshop_date),
|
||||
client_name=client_name.strip() or "Unnamed client",
|
||||
industry_config=config.industry,
|
||||
facilitator=facilitator.strip(),
|
||||
workshop_date=workshop_date,
|
||||
participants=participants,
|
||||
operational_baseline=baseline,
|
||||
scores=scores,
|
||||
computed_value=computed_value,
|
||||
notes=notes,
|
||||
)
|
||||
29
assessments/CX_AI_Diagnostic/diaglib/staging.py
Normal file
29
assessments/CX_AI_Diagnostic/diaglib/staging.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||
|
||||
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
|
||||
into the server process so the widget library can locate ``config.toml``
|
||||
(see ``mercury/config.py``); every kernel that server spawns inherits it.
|
||||
JupyterLab and nbconvert kernels don't have it. That makes the variable a
|
||||
reliable signal for "the audience is looking" (the stage) versus an
|
||||
analyst session or a headless export run (backstage).
|
||||
|
||||
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
|
||||
and land in the nbconvert exports (where the machine-readable appendix
|
||||
must appear for LLM consumption) but never render in the Mercury app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def on_stage() -> bool:
|
||||
"""True when running under the Mercury app (stakeholder-facing)."""
|
||||
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||
|
||||
|
||||
def backstage(*args, **kwargs) -> None:
|
||||
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||
if not on_stage():
|
||||
print(*args, **kwargs)
|
||||
228
assessments/CX_AI_Diagnostic/diaglib/value_math.py
Normal file
228
assessments/CX_AI_Diagnostic/diaglib/value_math.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Value-at-stake math: driver values, capability capping, unlock sequence.
|
||||
|
||||
The pipeline (build spec §6):
|
||||
|
||||
1. Each configured value driver yields a theoretical annual value range
|
||||
from the operational baseline (dispatch on ``driver.kind``).
|
||||
2. Theoretical annual value = sum of drivers.
|
||||
3. The **weakest foundational competency score** selects a realization
|
||||
band from the capping heuristic.
|
||||
4. ``realizable_18mo = theoretical × realization_factor × 1.5``
|
||||
(18 months of annual run-rate).
|
||||
5. Trapped value (annual) = theoretical − realizable run-rate. Range
|
||||
pairing is conservative-consistent: the low trapped estimate assumes
|
||||
the low theoretical *and* the high realization factor, and vice versa.
|
||||
6. Binding constraints = every foundational competency sitting at the
|
||||
weakest score.
|
||||
7. Unlock sequence = up to three **tier lifts**: raise the whole binding
|
||||
set one level, recompute the band, attribute the delta. When several
|
||||
competencies tie at the weakest level a single-competency lift would
|
||||
honestly unlock nothing — the set is the move (see UnlockMove docs).
|
||||
|
||||
Guard rails: every output is a range; 🔴-unknown inputs raise warnings on
|
||||
the result; money *display* is capped at two significant figures
|
||||
(:func:`money`) while raw floats stay exact in exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import (
|
||||
CompetencyScore,
|
||||
DiagnosticConfig,
|
||||
DriverValue,
|
||||
OperationalBaseline,
|
||||
UnlockMove,
|
||||
ValueAtStake,
|
||||
ValueDriver,
|
||||
)
|
||||
|
||||
#: 18 months expressed in years of annual run-rate.
|
||||
MONTHS_18 = 1.5
|
||||
|
||||
#: How many unlock moves the sequence proposes.
|
||||
MAX_UNLOCK_MOVES = 3
|
||||
|
||||
|
||||
# ── Money display (guard rail: ≤ 2 significant figures) ──────────────
|
||||
|
||||
|
||||
def _round_2sf(v: float) -> float:
|
||||
if v == 0:
|
||||
return 0.0
|
||||
from math import floor, log10
|
||||
exp = floor(log10(abs(v)))
|
||||
return round(v, -exp + 1)
|
||||
|
||||
|
||||
def money(v: float) -> str:
|
||||
"""House money format, capped at two significant figures: $2.5M, $950K."""
|
||||
sign, a = ("-" if v < 0 else ""), _round_2sf(abs(v))
|
||||
if a >= 1e6:
|
||||
m = a / 1e6
|
||||
return f"{sign}${m:,.1f}M" if m < 10 else f"{sign}${m:,.0f}M"
|
||||
if a >= 1e3:
|
||||
return f"{sign}${a / 1e3:,.0f}K"
|
||||
return f"{sign}${a:,.0f}"
|
||||
|
||||
|
||||
def html_money(v: float) -> str:
|
||||
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
||||
annotations holding several amounts must use the HTML entity instead."""
|
||||
return money(v).replace("$", "$")
|
||||
|
||||
|
||||
# ── Driver math (dispatch on kind) ───────────────────────────────────
|
||||
|
||||
|
||||
def driver_value(driver: ValueDriver, baseline: OperationalBaseline) -> DriverValue:
|
||||
"""Theoretical annual value range for one configured driver."""
|
||||
if driver.kind == "containment_lift":
|
||||
if driver.lift_range_pts_low is None or driver.lift_range_pts_high is None:
|
||||
raise ValueError(f"driver {driver.id}: containment_lift needs lift_range_pts_low/high")
|
||||
low = baseline.annual_contact_volume * driver.lift_range_pts_low \
|
||||
* baseline.blended_cost_per_contact
|
||||
high = baseline.annual_contact_volume * driver.lift_range_pts_high \
|
||||
* baseline.blended_cost_per_contact
|
||||
elif driver.kind == "aht_reduction":
|
||||
# volume × (AHT × pct) seconds saved × ($/contact ÷ AHT) per second
|
||||
# — the baseline AHT cancels: volume × $/contact × pct.
|
||||
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
|
||||
raise ValueError(f"driver {driver.id}: aht_reduction needs reduction_pct_low/high")
|
||||
low = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
|
||||
* driver.reduction_pct_low
|
||||
high = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
|
||||
* driver.reduction_pct_high
|
||||
elif driver.kind == "attrition_reduction":
|
||||
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
|
||||
raise ValueError(f"driver {driver.id}: attrition_reduction needs reduction_pct_low/high")
|
||||
cost_per_replacement = driver.cost_per_replacement_default or 0.0
|
||||
low = baseline.agent_headcount * baseline.annual_attrition_rate \
|
||||
* driver.reduction_pct_low * cost_per_replacement
|
||||
high = baseline.agent_headcount * baseline.annual_attrition_rate \
|
||||
* driver.reduction_pct_high * cost_per_replacement
|
||||
else: # pragma: no cover — Literal already restricts kinds
|
||||
raise ValueError(f"driver {driver.id}: unknown kind {driver.kind}")
|
||||
return DriverValue(driver_id=driver.id, name=driver.name,
|
||||
theoretical_low=low, theoretical_high=high)
|
||||
|
||||
|
||||
# ── Capping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def weakest_foundational_score(config: DiagnosticConfig,
|
||||
scores: list[CompetencyScore]) -> int:
|
||||
by_id = {s.competency_id: s.score for s in scores}
|
||||
missing = [c for c in config.foundational_competencies if c not in by_id]
|
||||
if missing:
|
||||
raise ValueError(f"foundational competencies unscored: {missing}")
|
||||
return min(by_id[c] for c in config.foundational_competencies)
|
||||
|
||||
|
||||
def binding_constraints(config: DiagnosticConfig,
|
||||
scores: list[CompetencyScore]) -> list[str]:
|
||||
"""Foundational competencies sitting at the weakest score, config order."""
|
||||
weakest = weakest_foundational_score(config, scores)
|
||||
by_id = {s.competency_id: s.score for s in scores}
|
||||
return [c for c in config.foundational_competencies if by_id[c] == weakest]
|
||||
|
||||
|
||||
# ── Unlock sequence (tier lifts of the binding set) ──────────────────
|
||||
|
||||
|
||||
def _tier_move(config: DiagnosticConfig, level: int, members: list[str],
|
||||
th_low: float, th_high: float) -> UnlockMove:
|
||||
band_now = config.capping_heuristic[level]
|
||||
band_next = config.capping_heuristic[level + 1]
|
||||
costs = {m: config.lift_cost(m, level) for m in members}
|
||||
missing = [m for m, c in costs.items() if c is None]
|
||||
note = ""
|
||||
if len(members) > 1:
|
||||
note = "joint lift — the tied competencies must move together to shift the cap"
|
||||
if missing:
|
||||
note = (note + "; " if note else "") + \
|
||||
f"cost not configured for: {', '.join(missing)}"
|
||||
have_all = not missing
|
||||
return UnlockMove(
|
||||
competency_ids=members,
|
||||
current_level=level,
|
||||
target_level=level + 1,
|
||||
est_cost_low=sum(c.cost_low for c in costs.values() if c) if have_all else None,
|
||||
est_cost_high=sum(c.cost_high for c in costs.values() if c) if have_all else None,
|
||||
est_weeks=max((c.weeks for c in costs.values() if c), default=None) if have_all else None,
|
||||
value_unlocked_low=th_low * (band_next.realized_low - band_now.realized_low),
|
||||
value_unlocked_high=th_high * (band_next.realized_high - band_now.realized_high),
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def unlock_sequence(config: DiagnosticConfig, scores: list[CompetencyScore],
|
||||
th_low: float, th_high: float,
|
||||
max_moves: int = MAX_UNLOCK_MOVES) -> list[UnlockMove]:
|
||||
"""Up to ``max_moves`` sequential tier lifts of the binding set.
|
||||
|
||||
Each move lifts every foundational competency at the current weakest
|
||||
level by one level (weeks = the longest workstream, run in parallel;
|
||||
costs summed). Value unlocked is the annual realizable delta from the
|
||||
capping-band shift. Moves stay in sequence order — each one is the
|
||||
prerequisite of the next, so ranking them against each other would be
|
||||
meaningless; the ratio walk (value/cost declining) is the story.
|
||||
"""
|
||||
if th_low == 0 and th_high == 0:
|
||||
return []
|
||||
current = {s.competency_id: s.score for s in scores
|
||||
if s.competency_id in config.foundational_competencies}
|
||||
moves: list[UnlockMove] = []
|
||||
for _ in range(max_moves):
|
||||
level = min(current.values())
|
||||
if level >= 5:
|
||||
break
|
||||
members = [c for c in config.foundational_competencies
|
||||
if current[c] == level]
|
||||
moves.append(_tier_move(config, level, members, th_low, th_high))
|
||||
for m in members:
|
||||
current[m] = level + 1
|
||||
return moves
|
||||
|
||||
|
||||
# ── The full computation ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def value_at_stake(config: DiagnosticConfig, baseline: OperationalBaseline,
|
||||
scores: list[CompetencyScore]) -> ValueAtStake:
|
||||
"""Steps 1–8 of the build spec, as one call. See module docstring."""
|
||||
drivers = [driver_value(d, baseline) for d in config.value_drivers]
|
||||
th_low = sum(d.theoretical_low for d in drivers)
|
||||
th_high = sum(d.theoretical_high for d in drivers)
|
||||
|
||||
weakest = weakest_foundational_score(config, scores)
|
||||
band = config.capping_heuristic[weakest]
|
||||
|
||||
warnings: list[str] = []
|
||||
if not config.value_drivers:
|
||||
warnings.append(
|
||||
f"config '{config.industry}' has no value drivers — "
|
||||
"value-at-stake is zero (stub config)")
|
||||
used_fields = sorted({d.baseline_field for d in config.value_drivers}
|
||||
| ({"annual_contact_volume", "blended_cost_per_contact"}
|
||||
if config.value_drivers else set()))
|
||||
for f in used_fields:
|
||||
if baseline.confidence_for(f) == "unknown":
|
||||
warnings.append(
|
||||
f"baseline input '{f}' is flagged 🔴 unknown — "
|
||||
"the ranges below inherit that uncertainty")
|
||||
|
||||
return ValueAtStake(
|
||||
theoretical_annual_value_low=th_low,
|
||||
theoretical_annual_value_high=th_high,
|
||||
realizable_18mo_low=th_low * band.realized_low * MONTHS_18,
|
||||
realizable_18mo_high=th_high * band.realized_high * MONTHS_18,
|
||||
trapped_value_low=th_low * (1 - band.realized_high),
|
||||
trapped_value_high=th_high * (1 - band.realized_low),
|
||||
binding_constraints=binding_constraints(config, scores),
|
||||
unlock_sequence=unlock_sequence(config, scores, th_low, th_high),
|
||||
weakest_foundational_score=weakest,
|
||||
realization_factor_low=band.realized_low,
|
||||
realization_factor_high=band.realized_high,
|
||||
driver_values=drivers,
|
||||
warnings=warnings,
|
||||
)
|
||||
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal file
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Plotly figure builders — presentation only, consuming engine outputs.
|
||||
|
||||
Chart chrome follows the house dataviz rules (see the repo dataviz
|
||||
reference and docs/brand.md): recessive grid and axes, ink text tokens,
|
||||
fixed entity→color assignments so a color means one thing across every
|
||||
figure, 2px surface gaps between adjacent fills, selective direct labels,
|
||||
one axis per chart. Room-facing: sized and typed to hold attention on a
|
||||
shared screen, not for print.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from .models import DiagnosticConfig, ValueAtStake
|
||||
from .value_math import html_money
|
||||
|
||||
# ── Chrome (dataviz reference palette, light surface) ────────────────
|
||||
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
|
||||
SURFACE, GRID, BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
|
||||
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
|
||||
|
||||
# Fixed entity colors — color follows the entity across every figure.
|
||||
THEORETICAL = "#9ec5f4" # light blue: the outer envelope
|
||||
REALIZABLE = "#2a78d6" # blue: what capability can actually capture
|
||||
TRAPPED = "#eda100" # amber: value the capability gap strands
|
||||
COST = "#e34948" # red: unlock investment
|
||||
UNLOCKED = "#1baf7a" # aqua-green: unlock payoff
|
||||
|
||||
# Diverging maturity scale, centered on level 3 — soft poles so ink text
|
||||
# stays readable in every cell (two hues + neutral midpoint, never rainbow).
|
||||
SCORE_SCALE = [
|
||||
(0.0, "#ef8a76"), (0.5, "#f0efe9"), (1.0, "#57c993"),
|
||||
]
|
||||
|
||||
|
||||
def diag_layout(fig: go.Figure, title: str, subtitle: str | None = None,
|
||||
height: int = 440) -> go.Figure:
|
||||
t = f"<b>{title}</b>"
|
||||
if subtitle:
|
||||
t += f"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>"
|
||||
fig.update_layout(
|
||||
title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor="left"),
|
||||
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
|
||||
font=dict(family=FONT_STACK, size=13, color=INK2),
|
||||
legend=dict(orientation="h", yanchor="top", y=-0.12, x=0,
|
||||
font=dict(size=11, color=INK2)),
|
||||
height=height, margin=dict(t=70, r=30, b=60, l=70),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 1 · Capability heatmap (4 dimensions × 3 competencies) ───────────
|
||||
|
||||
|
||||
def heatmap_fig(grid: dict) -> go.Figure:
|
||||
"""``grid`` comes from scoring.heatmap_grid — pure data in."""
|
||||
n_rows = len(grid["rows"])
|
||||
fig = go.Figure(go.Heatmap(
|
||||
z=grid["z"], text=grid["text"], customdata=grid["hover"],
|
||||
x=list(range(len(grid["cols"]))), y=grid["rows"],
|
||||
zmin=1, zmax=5, colorscale=SCORE_SCALE, showscale=False,
|
||||
texttemplate="%{text}", textfont=dict(size=13, color=INK),
|
||||
hovertemplate="%{customdata}<extra></extra>",
|
||||
xgap=3, ygap=3,
|
||||
))
|
||||
fig.update_xaxes(visible=False)
|
||||
fig.update_yaxes(autorange="reversed", tickfont=dict(size=13, color=INK2),
|
||||
showgrid=False)
|
||||
diag_layout(fig, "Capability heatmap",
|
||||
"12 competencies, levels 1–5 · hover a cell for the evidence",
|
||||
height=90 * n_rows + 120)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 2 · Value at stake (theoretical vs realizable ranges) ────────────
|
||||
|
||||
|
||||
def value_bands_fig(vas: ValueAtStake) -> go.Figure:
|
||||
rows = [
|
||||
("Theoretical value (annual)", vas.theoretical_annual_value_low,
|
||||
vas.theoretical_annual_value_high, THEORETICAL),
|
||||
("Realizable over 18 months", vas.realizable_18mo_low,
|
||||
vas.realizable_18mo_high, REALIZABLE),
|
||||
]
|
||||
fig = go.Figure()
|
||||
for label, low, high, color in rows:
|
||||
fig.add_trace(go.Bar(
|
||||
y=[label], x=[max(high - low, 1)], base=[low], orientation="h",
|
||||
marker=dict(color=color, line=dict(width=2, color=SURFACE)),
|
||||
showlegend=False,
|
||||
hovertemplate=(f"{label}: {html_money(low)} – {html_money(high)}"
|
||||
"<extra></extra>"),
|
||||
))
|
||||
fig.add_annotation(x=high, y=label, xanchor="left", xshift=6,
|
||||
text=f"{html_money(low)} – {html_money(high)}",
|
||||
showarrow=False, font=dict(size=12, color=INK))
|
||||
fig.add_annotation(
|
||||
xref="paper", yref="paper", x=0.02, y=-0.32, xanchor="left",
|
||||
showarrow=False, align="left",
|
||||
text=(f"Trapped by the capability gap: "
|
||||
f"<b>{html_money(vas.trapped_value_low)} – "
|
||||
f"{html_money(vas.trapped_value_high)}</b> per year"),
|
||||
font=dict(size=13, color=INK))
|
||||
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
|
||||
tickfont=dict(color=MUTED), rangemode="tozero")
|
||||
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
|
||||
autorange="reversed") # theoretical on top, then realizable
|
||||
diag_layout(fig, "Value at stake",
|
||||
"ranges, never points · realization capped by the weakest foundation",
|
||||
height=300)
|
||||
fig.update_layout(margin=dict(b=90), bargap=0.5)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 3 · Realizable vs trapped split (scenario-consistent) ────────────
|
||||
|
||||
|
||||
def split_fig(vas: ValueAtStake) -> go.Figure:
|
||||
"""Each scenario bar splits its own theoretical total: realizable
|
||||
run-rate vs trapped, at that scenario's realization factor."""
|
||||
scenarios = [
|
||||
("Conservative", vas.theoretical_annual_value_low, vas.realization_factor_low),
|
||||
("Optimistic", vas.theoretical_annual_value_high, vas.realization_factor_high),
|
||||
]
|
||||
labels = [s[0] for s in scenarios]
|
||||
realizable = [th * r for _, th, r in scenarios]
|
||||
trapped = [th * (1 - r) for _, th, r in scenarios]
|
||||
fig = go.Figure([
|
||||
go.Bar(name="Realizable (annual run-rate)", y=labels, x=realizable,
|
||||
orientation="h",
|
||||
marker=dict(color=REALIZABLE, line=dict(width=2, color=SURFACE)),
|
||||
text=[html_money(v) for v in realizable],
|
||||
textposition="inside", insidetextfont=dict(color="#ffffff"),
|
||||
hovertemplate="Realizable: %{x:$,.0f}<extra>%{y}</extra>"),
|
||||
go.Bar(name="Trapped by capability gap", y=labels, x=trapped,
|
||||
orientation="h",
|
||||
marker=dict(color=TRAPPED, line=dict(width=2, color=SURFACE)),
|
||||
text=[html_money(v) for v in trapped],
|
||||
textposition="inside", insidetextfont=dict(color=INK),
|
||||
hovertemplate="Trapped: %{x:$,.0f}<extra>%{y}</extra>"),
|
||||
])
|
||||
fig.update_layout(barmode="stack", bargap=0.5)
|
||||
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
|
||||
tickfont=dict(color=MUTED))
|
||||
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
|
||||
autorange="reversed") # conservative on top
|
||||
diag_layout(fig, "Where the annual value goes",
|
||||
"each scenario splits its own theoretical total", height=300)
|
||||
fig.update_layout(legend=dict(traceorder="normal"))
|
||||
return fig
|
||||
|
||||
|
||||
# ── 4 · Unlock sequence (cost vs value per move) ─────────────────────
|
||||
|
||||
|
||||
def _move_label(vas_move, config: DiagnosticConfig, idx: int) -> str:
|
||||
"""Compact tick label — full competency names live in the hover and
|
||||
in the on-stage moves table (long joint-lift names don't fit ticks)."""
|
||||
if len(vas_move.competency_ids) == 1:
|
||||
what = config.competency(vas_move.competency_ids[0]).name
|
||||
else:
|
||||
what = f"joint lift ×{len(vas_move.competency_ids)}"
|
||||
return (f"<b>{idx} · {what}</b><br>"
|
||||
f"level {vas_move.current_level} → {vas_move.target_level}")
|
||||
|
||||
|
||||
def unlock_fig(vas: ValueAtStake, config: DiagnosticConfig) -> go.Figure:
|
||||
moves = vas.unlock_sequence
|
||||
labels = [_move_label(m, config, i + 1) for i, m in enumerate(moves)]
|
||||
names = [" + ".join(config.competency(c).name for c in m.competency_ids)
|
||||
for m in moves]
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Bar(
|
||||
name="Investment (range)", x=labels,
|
||||
y=[(m.est_cost_high - m.est_cost_low) if m.est_cost_low is not None else 0
|
||||
for m in moves],
|
||||
base=[m.est_cost_low if m.est_cost_low is not None else 0 for m in moves],
|
||||
customdata=[[n, html_money(m.est_cost_low) + " – " + html_money(m.est_cost_high)
|
||||
if m.est_cost_low is not None else "not configured"]
|
||||
for n, m in zip(names, moves)],
|
||||
marker=dict(color=COST, line=dict(width=2, color=SURFACE)),
|
||||
hovertemplate="%{customdata[0]}<br>Investment: %{customdata[1]}<extra></extra>",
|
||||
))
|
||||
fig.add_trace(go.Bar(
|
||||
name="Annual value unlocked (range)", x=labels,
|
||||
y=[m.value_unlocked_high - m.value_unlocked_low for m in moves],
|
||||
base=[m.value_unlocked_low for m in moves],
|
||||
customdata=[[n, html_money(m.value_unlocked_low) + " – "
|
||||
+ html_money(m.value_unlocked_high)]
|
||||
for n, m in zip(names, moves)],
|
||||
marker=dict(color=UNLOCKED, line=dict(width=2, color=SURFACE)),
|
||||
hovertemplate="%{customdata[0]}<br>Unlocked: %{customdata[1]}<extra></extra>",
|
||||
))
|
||||
for i, m in enumerate(moves):
|
||||
if m.est_cost_low is None:
|
||||
fig.add_annotation(x=labels[i], y=0, yanchor="bottom",
|
||||
text="cost not<br>configured", showarrow=False,
|
||||
font=dict(size=11, color=MUTED))
|
||||
fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.12)
|
||||
fig.update_xaxes(tickfont=dict(size=12, color=INK2), showgrid=False,
|
||||
tickangle=0)
|
||||
fig.update_yaxes(tickformat="$~s", gridcolor=GRID, zerolinecolor=BASELINE,
|
||||
tickfont=dict(color=MUTED))
|
||||
diag_layout(fig, "Unlock sequence",
|
||||
"sequential moves — each is the prerequisite of the next",
|
||||
height=420)
|
||||
return fig
|
||||
Reference in New Issue
Block a user