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:
70
assessments/CX_AI_Diagnostic/tests/conftest.py
Normal file
70
assessments/CX_AI_Diagnostic/tests/conftest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Shared fixtures: the mock workshop scenario every pin is hand-checked
|
||||
against (see test_value_math for the arithmetic). Also makes diaglib
|
||||
importable without the study venv active (normal setup is
|
||||
``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||
|
||||
from diaglib import OperationalBaseline, build_scores, load_config # noqa: E402
|
||||
|
||||
CONFIGS = pathlib.Path(__file__).resolve().parent.parent / "configs"
|
||||
|
||||
SCORED_AT = datetime(2026, 7, 19, 9, 0)
|
||||
|
||||
#: The seed capability profile: data_readiness is the unique weakest
|
||||
#: foundation (level 2), so the binding constraint is a single competency.
|
||||
SEED_SCORES = {
|
||||
"automation_ai_strategy": (2, "AI driven by board pressure; no written thesis"),
|
||||
"value_realization": (2, "Business cases pre-investment only"),
|
||||
"executive_alignment": (3, "COO owns CX AI; steering meets quarterly"),
|
||||
"process_discovery": (3, "Top 10 call reasons mapped with volumes"),
|
||||
"data_readiness": (2, "KB stale; interaction data siloed in recordings"),
|
||||
"technical_architecture": (3, "CCaaS APIs available; shared integration layer WIP"),
|
||||
"use_case_prioritization": (3, "Scored backlog reviewed monthly"),
|
||||
"delivery_capability": (3, "Two bots in production via SI partner"),
|
||||
"talent_and_skills": (2, "One conversation designer, contractor"),
|
||||
"ai_operations": (2, "Containment eyeballed weekly, no drift alerts"),
|
||||
"change_adoption": (3, "Agent champions for copilot rollout"),
|
||||
"governance_and_risk": (3, "AI policy signed; review board for voice bots"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def config():
|
||||
return load_config("contact_center", CONFIGS)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def stub_config():
|
||||
return load_config("financial_services", CONFIGS)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def baseline():
|
||||
return OperationalBaseline(
|
||||
annual_contact_volume=1_200_000,
|
||||
blended_cost_per_contact=6.50,
|
||||
agent_headcount=450,
|
||||
annual_attrition_rate=0.30,
|
||||
current_containment_rate=0.20,
|
||||
average_handle_time_seconds=420,
|
||||
field_confidence={
|
||||
"annual_contact_volume": "known",
|
||||
"blended_cost_per_contact": "estimated",
|
||||
"agent_headcount": "known",
|
||||
"annual_attrition_rate": "estimated",
|
||||
"current_containment_rate": "estimated",
|
||||
"average_handle_time_seconds": "known",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scores(config):
|
||||
return build_scores(config, SEED_SCORES, scored_at=SCORED_AT)
|
||||
73
assessments/CX_AI_Diagnostic/tests/test_config.py
Normal file
73
assessments/CX_AI_Diagnostic/tests/test_config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Config loading and validation — the instrument's shape can't drift."""
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from diaglib import list_industries, load_config
|
||||
from tests.conftest import CONFIGS
|
||||
|
||||
|
||||
def test_contact_center_loads(config):
|
||||
assert config.industry == "contact_center"
|
||||
assert config.version == "1.0"
|
||||
assert len(config.competencies) == 12
|
||||
assert [d.id for d in config.dimensions] == [
|
||||
"strategy_value", "foundations", "delivery", "sustain"]
|
||||
# 4 dimensions × 3 competencies — the heatmap contract
|
||||
for d in config.dimensions:
|
||||
assert sum(1 for c in config.competencies if c.dimension == d.id) == 3
|
||||
assert config.foundational_competencies == [
|
||||
"process_discovery", "data_readiness", "technical_architecture"]
|
||||
assert [d.id for d in config.value_drivers] == [
|
||||
"deflection_lift", "aht_reduction", "attrition_reduction"]
|
||||
|
||||
|
||||
def test_capping_bands_pinned(config):
|
||||
bands = {k: (v.realized_low, v.realized_high)
|
||||
for k, v in config.capping_heuristic.items()}
|
||||
assert bands == {
|
||||
1: (0.00, 0.15), 2: (0.25, 0.40), 3: (0.50, 0.65),
|
||||
4: (0.65, 0.85), 5: (0.80, 1.00),
|
||||
}
|
||||
|
||||
|
||||
def test_every_competency_has_five_levels_and_vignette(config):
|
||||
for c in config.competencies:
|
||||
assert set(c.level_descriptors) == {1, 2, 3, 4, 5}
|
||||
assert c.failure_vignette
|
||||
assert c.description
|
||||
|
||||
|
||||
def test_unlock_costs_reachable(config):
|
||||
# Every foundational competency can be lifted 1→5 in the CC config.
|
||||
for cid in config.foundational_competencies:
|
||||
for level in (1, 2, 3, 4):
|
||||
lift = config.lift_cost(cid, level)
|
||||
assert lift is not None, f"{cid} lift {level}->{level + 1} missing"
|
||||
assert lift.cost_low <= lift.cost_high
|
||||
|
||||
|
||||
def test_stub_config_loads(stub_config):
|
||||
assert stub_config.industry == "financial_services"
|
||||
assert stub_config.value_drivers == []
|
||||
assert len(stub_config.competencies) == 12 # model comes from base
|
||||
|
||||
|
||||
def test_list_industries():
|
||||
assert list_industries(CONFIGS) == ["contact_center", "financial_services"]
|
||||
|
||||
|
||||
def test_overlay_may_not_redefine_base_keys(tmp_path):
|
||||
shutil.copy(CONFIGS / "base.yaml", tmp_path / "base.yaml")
|
||||
(tmp_path / "rogue.yaml").write_text(
|
||||
"extends: base\nindustry: rogue\ncompetencies: []\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="base-only"):
|
||||
load_config("rogue", tmp_path)
|
||||
|
||||
|
||||
def test_overlay_must_extend_base(tmp_path):
|
||||
shutil.copy(CONFIGS / "base.yaml", tmp_path / "base.yaml")
|
||||
(tmp_path / "loner.yaml").write_text("industry: loner\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="extends"):
|
||||
load_config("loner", tmp_path)
|
||||
52
assessments/CX_AI_Diagnostic/tests/test_export.py
Normal file
52
assessments/CX_AI_Diagnostic/tests/test_export.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Export contracts — JSON round-trips, CSV shape pinned to the spec."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from diaglib import (
|
||||
CSV_COLUMNS,
|
||||
build_engagement,
|
||||
load_engagement,
|
||||
parse_participants,
|
||||
scores_dataframe,
|
||||
value_at_stake,
|
||||
write_exports,
|
||||
)
|
||||
|
||||
|
||||
def _engagement(config, baseline, scores):
|
||||
return build_engagement(
|
||||
config=config, client_name="Acme Demo Co", facilitator="Robert Helewka",
|
||||
workshop_date=date(2026, 7, 19),
|
||||
participants=parse_participants(
|
||||
"Jane Example | VP Customer Experience | cx; Sam Sample | Ops Director | ops"),
|
||||
baseline=baseline, scores=scores,
|
||||
computed_value=value_at_stake(config, baseline, scores))
|
||||
|
||||
|
||||
def test_write_exports_and_reload(config, baseline, scores, tmp_path):
|
||||
eng = _engagement(config, baseline, scores)
|
||||
json_path, csv_path = write_exports(eng, config, tmp_path)
|
||||
assert json_path.name == "acme_demo_co_2026-07-19.json"
|
||||
assert csv_path.name == "acme_demo_co_2026-07-19.csv"
|
||||
|
||||
# JSON is the source-of-truth artifact — it must round-trip losslessly.
|
||||
reloaded = load_engagement(json_path)
|
||||
assert reloaded == eng
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
assert list(df.columns) == CSV_COLUMNS
|
||||
assert len(df) == 12
|
||||
|
||||
|
||||
def test_scores_dataframe_flags(config, baseline, scores):
|
||||
df = scores_dataframe(_engagement(config, baseline, scores), config)
|
||||
by_id = df.set_index("competency_id")
|
||||
assert bool(by_id.loc["data_readiness", "is_foundational"])
|
||||
assert bool(by_id.loc["data_readiness", "is_binding_constraint"])
|
||||
assert bool(by_id.loc["process_discovery", "is_foundational"])
|
||||
assert not bool(by_id.loc["process_discovery", "is_binding_constraint"])
|
||||
assert not bool(by_id.loc["automation_ai_strategy", "is_foundational"])
|
||||
assert (df["engagement_id"] == "acme_demo_co_2026-07-19").all()
|
||||
assert (df["industry"] == "contact_center").all()
|
||||
85
assessments/CX_AI_Diagnostic/tests/test_scoring.py
Normal file
85
assessments/CX_AI_Diagnostic/tests/test_scoring.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Scoring, parsing, and assembly — the glue the notebook leans on."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from diaglib import (
|
||||
build_engagement,
|
||||
build_scores,
|
||||
dimension_rollup,
|
||||
evidence_coverage,
|
||||
heatmap_grid,
|
||||
make_engagement_id,
|
||||
parse_participants,
|
||||
value_at_stake,
|
||||
)
|
||||
from tests.conftest import SCORED_AT, SEED_SCORES
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
def test_engagement_id_slug():
|
||||
assert make_engagement_id("Acme", date(2026, 7, 19)) == "acme_2026-07-19"
|
||||
assert make_engagement_id(" Acme & Söhne GmbH! ",
|
||||
date(2026, 7, 19)) == "acme_s_hne_gmbh_2026-07-19"
|
||||
assert make_engagement_id("", date(2026, 7, 19)) == "client_2026-07-19"
|
||||
|
||||
|
||||
def test_parse_participants_forgiving():
|
||||
got = parse_participants(
|
||||
"Jane Example | VP Customer Experience | cx; "
|
||||
"Raj Patel|CIO|IT; Sam Sample | Ops Director; Solo")
|
||||
assert [(p.name, p.role, p.function) for p in got] == [
|
||||
("Jane Example", "VP Customer Experience", "cx"),
|
||||
("Raj Patel", "CIO", "it"), # case-normalized
|
||||
("Sam Sample", "Ops Director", "other"), # function missing
|
||||
("Solo", "", "other"),
|
||||
]
|
||||
assert parse_participants("") == []
|
||||
assert parse_participants(" ; ; ") == []
|
||||
|
||||
|
||||
def test_build_scores_orders_and_validates(config):
|
||||
scores = build_scores(config, SEED_SCORES, scored_at=SCORED_AT)
|
||||
assert [s.competency_id for s in scores] == [c.id for c in config.competencies]
|
||||
assert all(s.scored_at == SCORED_AT for s in scores)
|
||||
with pytest.raises(ValueError, match="unscored"):
|
||||
build_scores(config, {"data_readiness": (3, "")}, scored_at=SCORED_AT)
|
||||
|
||||
|
||||
def test_dimension_rollup_pins(config, scores):
|
||||
rollup = {dim_id: mean for dim_id, _, mean in dimension_rollup(config, scores)}
|
||||
assert rollup["strategy_value"] == approx((2 + 2 + 3) / 3)
|
||||
assert rollup["foundations"] == approx((3 + 2 + 3) / 3)
|
||||
assert rollup["delivery"] == approx((3 + 3 + 2) / 3)
|
||||
assert rollup["sustain"] == approx((2 + 3 + 3) / 3)
|
||||
|
||||
|
||||
def test_evidence_coverage(config, scores):
|
||||
assert evidence_coverage(scores) == (12, 12) # fixture captures all evidence
|
||||
blank = [s.model_copy(update={"evidence": ""}) for s in scores[:3]] + scores[3:]
|
||||
assert evidence_coverage(blank) == (9, 12)
|
||||
|
||||
|
||||
def test_heatmap_grid_shape(config, scores):
|
||||
grid = heatmap_grid(config, scores)
|
||||
assert grid["rows"] == ["Strategy & Value", "Foundations", "Delivery", "Sustain"]
|
||||
assert [len(r) for r in grid["z"]] == [3, 3, 3, 3]
|
||||
assert grid["z"][1] == [3, 2, 3] # foundations row: pd, dr, ta
|
||||
assert "Data Readiness" in grid["text"][1][1]
|
||||
assert "KB stale" in grid["hover"][1][1] # evidence surfaces on hover
|
||||
|
||||
|
||||
def test_build_engagement_assembles(config, baseline, scores):
|
||||
vas = value_at_stake(config, baseline, scores)
|
||||
eng = build_engagement(
|
||||
config=config, client_name="Acme Demo Co", facilitator="Robert Helewka",
|
||||
workshop_date=date(2026, 7, 19),
|
||||
participants=parse_participants("Jane Example | VP CX | cx"),
|
||||
baseline=baseline, scores=scores, computed_value=vas,
|
||||
notes="dry run")
|
||||
assert eng.engagement_id == "acme_demo_co_2026-07-19"
|
||||
assert eng.industry_config == "contact_center"
|
||||
assert len(eng.scores) == 12
|
||||
assert eng.computed_value.binding_constraints == ["data_readiness"]
|
||||
15
assessments/CX_AI_Diagnostic/tests/test_staging.py
Normal file
15
assessments/CX_AI_Diagnostic/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||
|
||||
from diaglib import staging
|
||||
|
||||
|
||||
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
145
assessments/CX_AI_Diagnostic/tests/test_value_math.py
Normal file
145
assessments/CX_AI_Diagnostic/tests/test_value_math.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Value-math pins — every number hand-checked before pinning.
|
||||
|
||||
Mock scenario (the notebook's widget seeds use the same values):
|
||||
|
||||
volume 1,200,000 · $6.50/contact · 450 agents · 30% attrition ·
|
||||
20% containment · 420s AHT · weakest foundation = data_readiness @ 2
|
||||
|
||||
Hand arithmetic:
|
||||
|
||||
deflection low 1.2M × 0.15 × 6.50 = 1,170,000 high ×0.35 = 2,730,000
|
||||
AHT low 1.2M × 6.50 × 0.15 = 1,170,000 high ×0.25 = 1,950,000
|
||||
attrition low 450 × 0.30 × 0.10 × 15,000 = 202,500 high ×0.20 = 405,000
|
||||
theoretical low 2,542,500 high 5,085,000
|
||||
band @2 = (0.25, 0.40)
|
||||
realizable 18mo low 2,542,500 × 0.25 × 1.5 = 953,437.50
|
||||
high 5,085,000 × 0.40 × 1.5 = 3,051,000
|
||||
trapped (annual) low 2,542,500 × (1−0.40) = 1,525,500
|
||||
high 5,085,000 × (1−0.25) = 3,813,750
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from diaglib import (
|
||||
binding_constraints,
|
||||
driver_value,
|
||||
money,
|
||||
value_at_stake,
|
||||
weakest_foundational_score,
|
||||
)
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
def test_driver_pins(config, baseline):
|
||||
by_id = {d.id: driver_value(d, baseline) for d in config.value_drivers}
|
||||
assert by_id["deflection_lift"].theoretical_low == approx(1_170_000)
|
||||
assert by_id["deflection_lift"].theoretical_high == approx(2_730_000)
|
||||
assert by_id["aht_reduction"].theoretical_low == approx(1_170_000)
|
||||
assert by_id["aht_reduction"].theoretical_high == approx(1_950_000)
|
||||
assert by_id["attrition_reduction"].theoretical_low == approx(202_500)
|
||||
assert by_id["attrition_reduction"].theoretical_high == approx(405_000)
|
||||
|
||||
|
||||
def test_value_at_stake_pins(config, baseline, scores):
|
||||
vas = value_at_stake(config, baseline, scores)
|
||||
assert vas.theoretical_annual_value_low == approx(2_542_500)
|
||||
assert vas.theoretical_annual_value_high == approx(5_085_000)
|
||||
assert vas.weakest_foundational_score == 2
|
||||
assert (vas.realization_factor_low, vas.realization_factor_high) == (0.25, 0.40)
|
||||
assert vas.realizable_18mo_low == approx(953_437.50)
|
||||
assert vas.realizable_18mo_high == approx(3_051_000)
|
||||
assert vas.trapped_value_low == approx(1_525_500)
|
||||
assert vas.trapped_value_high == approx(3_813_750)
|
||||
assert vas.binding_constraints == ["data_readiness"]
|
||||
assert vas.warnings == [] # nothing flagged unknown in the fixture
|
||||
|
||||
|
||||
def test_unlock_sequence_pins(config, baseline, scores):
|
||||
vas = value_at_stake(config, baseline, scores)
|
||||
m1, m2, m3 = vas.unlock_sequence
|
||||
|
||||
# Move 1 — the unique weakest foundation lifts alone.
|
||||
assert m1.competency_ids == ["data_readiness"]
|
||||
assert (m1.current_level, m1.target_level) == (2, 3)
|
||||
assert (m1.est_cost_low, m1.est_cost_high, m1.est_weeks) == (300_000, 600_000, 12)
|
||||
assert m1.value_unlocked_low == approx(2_542_500 * 0.25) # 635,625
|
||||
assert m1.value_unlocked_high == approx(5_085_000 * 0.25) # 1,271,250
|
||||
|
||||
# Move 2 — all three foundations now tie at 3: joint lift.
|
||||
assert m2.competency_ids == [
|
||||
"process_discovery", "data_readiness", "technical_architecture"]
|
||||
assert (m2.current_level, m2.target_level) == (3, 4)
|
||||
assert m2.est_cost_low == approx(150_000 + 400_000 + 250_000) # 800,000
|
||||
assert m2.est_cost_high == approx(300_000 + 800_000 + 500_000) # 1,600,000
|
||||
assert m2.est_weeks == 16 # longest workstream
|
||||
assert m2.value_unlocked_low == approx(2_542_500 * 0.15) # 381,375
|
||||
assert m2.value_unlocked_high == approx(5_085_000 * 0.20) # 1,017,000
|
||||
assert "joint lift" in m2.note
|
||||
|
||||
# Move 3 — the trio lifts again, 4 → 5.
|
||||
assert (m3.current_level, m3.target_level) == (4, 5)
|
||||
assert m3.est_cost_low == approx(200_000 + 500_000 + 350_000) # 1,050,000
|
||||
assert m3.est_cost_high == approx(400_000 + 1_000_000 + 700_000) # 2,100,000
|
||||
assert m3.est_weeks == 20
|
||||
assert m3.value_unlocked_low == approx(2_542_500 * 0.15)
|
||||
assert m3.value_unlocked_high == approx(5_085_000 * 0.15) # 762,750
|
||||
|
||||
# The ratio walk declines — the first unlock is the cheapest value.
|
||||
ratios = [((m.value_unlocked_low + m.value_unlocked_high) / 2)
|
||||
/ ((m.est_cost_low + m.est_cost_high) / 2)
|
||||
for m in (m1, m2, m3)]
|
||||
assert ratios[0] > ratios[1] > ratios[2]
|
||||
|
||||
|
||||
def test_structural_ties_hold_at_any_scores(config, baseline, scores):
|
||||
vas = value_at_stake(config, baseline, scores)
|
||||
assert vas.theoretical_annual_value_low <= vas.theoretical_annual_value_high
|
||||
assert vas.realizable_18mo_low <= vas.realizable_18mo_high
|
||||
assert vas.trapped_value_low <= vas.trapped_value_high
|
||||
assert sum(d.theoretical_low for d in vas.driver_values) == approx(
|
||||
vas.theoretical_annual_value_low)
|
||||
assert sum(d.theoretical_high for d in vas.driver_values) == approx(
|
||||
vas.theoretical_annual_value_high)
|
||||
assert set(vas.binding_constraints) <= set(config.foundational_competencies)
|
||||
assert len(vas.unlock_sequence) <= 3
|
||||
|
||||
|
||||
def test_weakest_and_binding_with_ties(config, baseline, scores):
|
||||
assert weakest_foundational_score(config, scores) == 2
|
||||
# Drag process_discovery down to 2 as well — binding set becomes a pair.
|
||||
tied = [s.model_copy(update={"score": 2})
|
||||
if s.competency_id == "process_discovery" else s for s in scores]
|
||||
assert binding_constraints(config, tied) == ["process_discovery", "data_readiness"]
|
||||
vas = value_at_stake(config, baseline, tied)
|
||||
m1 = vas.unlock_sequence[0]
|
||||
assert m1.competency_ids == ["process_discovery", "data_readiness"]
|
||||
assert "joint lift" in m1.note
|
||||
assert m1.est_cost_low == approx(120_000 + 300_000)
|
||||
|
||||
|
||||
def test_unknown_inputs_raise_warnings(config, baseline, scores):
|
||||
flagged = baseline.model_copy(update={"field_confidence": {
|
||||
**baseline.field_confidence, "annual_contact_volume": "unknown"}})
|
||||
vas = value_at_stake(config, flagged, scores)
|
||||
assert any("annual_contact_volume" in w and "unknown" in w for w in vas.warnings)
|
||||
|
||||
|
||||
def test_stub_config_yields_empty_value(stub_config, baseline, scores):
|
||||
vas = value_at_stake(stub_config, baseline, scores)
|
||||
assert vas.theoretical_annual_value_low == 0
|
||||
assert vas.theoretical_annual_value_high == 0
|
||||
assert vas.unlock_sequence == []
|
||||
assert any("no value drivers" in w for w in vas.warnings)
|
||||
|
||||
|
||||
def test_money_two_significant_figures():
|
||||
assert money(953_437.50) == "$950K"
|
||||
assert money(2_542_500) == "$2.5M"
|
||||
assert money(1_271_250) == "$1.3M"
|
||||
assert money(5_085_000) == "$5.1M"
|
||||
assert money(15_000_000) == "$15M"
|
||||
assert money(202_500) == "$200K"
|
||||
assert money(-450_000) == "-$450K"
|
||||
assert money(85) == "$85"
|
||||
assert money(0) == "$0"
|
||||
Reference in New Issue
Block a user