CX Discovery Notebook
This commit is contained in:
630
studies/202607_CX_AI_Diagnostic/scripts/build_notebook.py
Normal file
630
studies/202607_CX_AI_Diagnostic/scripts/build_notebook.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Generate notebooks/diagnostic.ipynb from source cell text.
|
||||
|
||||
The diagnostic has ~50 Mercury widgets (engagement form, six baseline
|
||||
inputs with confidence flags, 12 score sliders + 12 evidence fields, an
|
||||
export button). Hand-maintaining that JSON is error-prone, so the
|
||||
notebook is *generated* — cell sources live here as readable Python
|
||||
strings and nbformat writes valid JSON.
|
||||
|
||||
Re-run after editing any cell: python scripts/build_notebook.py
|
||||
Then execute + export as usual (nbconvert / scripts/export_report.py).
|
||||
|
||||
This is a build tool, not the engine — all study logic stays in diaglib.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import nbformat as nbf
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "notebooks" / "diagnostic.ipynb"
|
||||
|
||||
|
||||
# ── Cell sources ─────────────────────────────────────────────────────
|
||||
|
||||
MD_TITLE = """\
|
||||
# CX AI Advisory Diagnostic
|
||||
|
||||
The facilitator's cockpit for the CX AI Advisory diagnostic workshop:
|
||||
capture capability scores across **12 competencies** in four dimensions,
|
||||
ingest the client's operational baseline, and watch the **value-at-stake
|
||||
analysis** — bounded by the capability gaps — recompute live in the room.
|
||||
|
||||
**This notebook is the deliverable.** Serve it with
|
||||
`mercury --working-dir .` from the study root and share the screen. Work
|
||||
the sidebar top-to-bottom: engagement, baseline, then one competency at a
|
||||
time. The stage shows the current competency's card, the heatmap, the
|
||||
value analysis, and the unlock sequence. Click **Export JSON + CSV** at
|
||||
the bottom to write the structured engagement record to `exports/`.
|
||||
|
||||
All content and math live in `diaglib/` and `configs/*.yaml` — the
|
||||
notebook only arranges and renders them. Outputs are **ranges, never
|
||||
point estimates**, and money displays at two significant figures.
|
||||
|
||||
Confidence legend: 🟢 known · 🟡 estimated · 🔴 unknown — flag each
|
||||
baseline input; unknowns surface as explicit warnings in the analysis."""
|
||||
|
||||
|
||||
SETUP = '''\
|
||||
# ── Setup ──────────────────────────────────────────────────────────
|
||||
import sys, pathlib
|
||||
_ROOT = pathlib.Path.cwd()
|
||||
if not (_ROOT / "diaglib").exists(): # notebook lives in notebooks/
|
||||
_ROOT = _ROOT.parent
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
import datetime as dt
|
||||
import html as _html
|
||||
|
||||
import mercury as mr
|
||||
import pandas as pd
|
||||
from IPython.display import display
|
||||
|
||||
# Single source of truth — all math and content live in the library;
|
||||
# only presentation (and Mercury widgets) lives here.
|
||||
from diaglib import (
|
||||
BASELINE_FIELDS, CONFIDENCE_ICON, CSV_COLUMNS,
|
||||
OperationalBaseline, backstage, build_engagement, build_scores,
|
||||
configs_dir, dimension_rollup, engagement_json, evidence_coverage,
|
||||
heatmap_fig, heatmap_grid, list_industries, load_config, money,
|
||||
parse_participants, scores_dataframe, split_fig, unlock_fig,
|
||||
value_at_stake, value_bands_fig, write_exports,
|
||||
)
|
||||
|
||||
pd.options.display.float_format = "{:,.0f}".format
|
||||
|
||||
CONFIGS_DIR = configs_dir(_ROOT)
|
||||
EXPORTS_DIR = _ROOT / "exports"
|
||||
INDUSTRIES = list_industries(CONFIGS_DIR)
|
||||
# The competency model is industry-independent (overlays may not redefine
|
||||
# it — the loader enforces that), so widgets can build from any config.
|
||||
_BASE = load_config("contact_center", CONFIGS_DIR)
|
||||
COMPETENCIES = _BASE.competencies
|
||||
DIMENSION_NAME = {d.id: d.name for d in _BASE.dimensions}
|
||||
|
||||
# ── Brand palette (docs/brand.md, light theme) ─────────────────────
|
||||
NAVY, INK, MUTED = "#151d2c", "#2e404d", "#586671"
|
||||
BLUE, GREEN, LINE = "#0072bc", "#00a34c", "#e2e6e9"
|
||||
CARD_BG, HAIRLINE, HILITE = "#f8f8f8", "#d5d9db", "#dcecfa"
|
||||
FONT = "Georgia, 'Times New Roman', serif"
|
||||
BODY_FONT = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||
|
||||
|
||||
def esc(s):
|
||||
return _html.escape(str(s))
|
||||
|
||||
|
||||
# ── Workshop seeds — the gate's mock scenario; overwrite live ──────
|
||||
# Headless nbconvert renders every widget at its seed, so the seeds form
|
||||
# a coherent, gate-passing scenario (Pattern §3).
|
||||
SEED_CLIENT = "Acme Demo Co"
|
||||
SEED_DATE = "2026-07-19"
|
||||
SEED_FACILITATOR = "Robert Helewka"
|
||||
SEED_PARTICIPANTS = ("Jane Example | VP Customer Experience | cx; "
|
||||
"Sam Sample | Contact Center Ops Director | ops")
|
||||
SEED_BASELINE = {
|
||||
"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,
|
||||
}
|
||||
CONFIDENCE_CHOICES = ["🟢 known", "🟡 estimated", "🔴 unknown"]
|
||||
SEED_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",
|
||||
}
|
||||
# Seed capability profile: data_readiness is the unique weakest
|
||||
# foundation, so the demo shows a single binding constraint.
|
||||
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"),
|
||||
}
|
||||
# label, min, max, step per baseline field (explicit min/max — Pattern §3).
|
||||
BASELINE_META = {
|
||||
"annual_contact_volume": ("Annual contact volume", 0, 100_000_000, 10_000),
|
||||
"blended_cost_per_contact": ("Blended cost per contact ($)", 0, 100, 0.25),
|
||||
"agent_headcount": ("Agent headcount", 0, 100_000, 10),
|
||||
"annual_attrition_rate": ("Annual attrition rate (0-1)", 0, 1, 0.01),
|
||||
"current_containment_rate": ("Current containment rate (0-1)", 0, 1, 0.01),
|
||||
"average_handle_time_seconds": ("Average handle time (seconds)", 0, 3600, 10),
|
||||
}
|
||||
|
||||
backstage(f"diaglib loaded — {len(COMPETENCIES)} competencies · "
|
||||
f"industries: {', '.join(INDUSTRIES)}")'''
|
||||
|
||||
|
||||
MD_HOWTO = """\
|
||||
## How to run this session
|
||||
|
||||
- **Sidebar §1 — Engagement.** Client, industry config, date, participants
|
||||
(`Name | Role | Function` separated by `;` — functions: cx, it, ops,
|
||||
finance, other), and a running notes box.
|
||||
- **Sidebar §2 — Operational baseline.** Six numbers. If unknown, best
|
||||
estimate is fine — set the confidence flag and the analysis will carry
|
||||
the uncertainty explicitly.
|
||||
- **Sidebar §3 — Capability scoring.** Pick **Now scoring**, read the
|
||||
competency card on stage with the room, set the 1–5 slider, capture one
|
||||
line of evidence, move to the next. The heatmap and value analysis
|
||||
update live as you go.
|
||||
- **Export.** The button at the bottom of the page writes
|
||||
`exports/{engagement_id}.json` (source of truth) and `.csv` (flat
|
||||
scores) — a deliberate snapshot at click time.
|
||||
- **Backstage** (JupyterLab / nbconvert) — the verification gate and the
|
||||
machine-readable appendix; neither shows on the Mercury stage."""
|
||||
|
||||
|
||||
W_ENGAGEMENT = '''\
|
||||
# ── 1 · Engagement setup (sidebar — widgets ONLY, no other output) ──
|
||||
# Mercury re-runs only cells BELOW a changed widget: every .value is
|
||||
# read in the state cell further down, never here (Pattern §3).
|
||||
mr.Markdown("#### 1 · Engagement", position="sidebar")
|
||||
_client_w = mr.TextInput(label="Client name", value=SEED_CLIENT)
|
||||
_industry_w = mr.Select(label="Industry config", value="contact_center",
|
||||
choices=INDUSTRIES)
|
||||
_date_w = mr.DateInput(label="Workshop date", value=SEED_DATE)
|
||||
_facilitator_w = mr.TextInput(label="Facilitator", value=SEED_FACILITATOR)
|
||||
_participants_w = mr.TextInput(
|
||||
label="Participants — Name | Role | Function; ...",
|
||||
value=SEED_PARTICIPANTS)
|
||||
_notes_w = mr.TextInput(label="Session notes", value="")'''
|
||||
|
||||
|
||||
W_BASELINE = '''\
|
||||
# ── 2 · Operational baseline (sidebar — widgets ONLY) ───────────────
|
||||
# Six numbers + a confidence flag each. Explicit min/max on every
|
||||
# NumberInput — Mercury clamps out-of-range seeds to a default range.
|
||||
mr.Markdown("#### 2 · Operational baseline", position="sidebar")
|
||||
_baseline_w, _conf_w = {}, {}
|
||||
for _f in BASELINE_FIELDS:
|
||||
_label, _min, _max, _step = BASELINE_META[_f]
|
||||
_baseline_w[_f] = mr.NumberInput(label=_label, value=SEED_BASELINE[_f],
|
||||
min=_min, max=_max, step=_step)
|
||||
_conf_w[_f] = mr.Select(label=f"{_label} — confidence",
|
||||
value=SEED_CONFIDENCE[_f],
|
||||
choices=CONFIDENCE_CHOICES)'''
|
||||
|
||||
|
||||
W_SCORING = '''\
|
||||
# ── 3 · Capability scoring (sidebar — widgets ONLY) ─────────────────
|
||||
# One screen per competency on stage: pick "Now scoring", read the card
|
||||
# with the room, set the slider, capture one line of evidence. Labels
|
||||
# are distinct per widget so Mercury's cache never collides.
|
||||
mr.Markdown("#### 3 · Capability scoring", position="sidebar")
|
||||
_now_scoring_w = mr.Select(
|
||||
label="Now scoring",
|
||||
value=f"1 · {COMPETENCIES[0].name}",
|
||||
choices=[f"{_i + 1} · {_c.name}" for _i, _c in enumerate(COMPETENCIES)])
|
||||
_score_w, _evidence_w = {}, {}
|
||||
_prev_dim = None
|
||||
for _c in COMPETENCIES:
|
||||
if _c.dimension != _prev_dim:
|
||||
mr.Markdown(f"**{DIMENSION_NAME[_c.dimension]}**", position="sidebar")
|
||||
_prev_dim = _c.dimension
|
||||
_score_w[_c.id] = mr.Slider(label=f"Score — {_c.name}",
|
||||
min=1, max=5, value=SEED_SCORES[_c.id][0])
|
||||
_evidence_w[_c.id] = mr.TextInput(label=f"Evidence — {_c.name}",
|
||||
value=SEED_SCORES[_c.id][1])'''
|
||||
|
||||
|
||||
STATE = '''\
|
||||
# ── Session state (re-runs on any sidebar change) ───────────────────
|
||||
# Read every widget .value; all computation happens in diaglib.
|
||||
CLIENT_NAME = str(_client_w.value).strip() or SEED_CLIENT
|
||||
INDUSTRY = str(_industry_w.value)
|
||||
CONFIG = load_config(INDUSTRY, CONFIGS_DIR)
|
||||
WORKSHOP_DATE = dt.date.fromisoformat(str(_date_w.value) or SEED_DATE)
|
||||
PARTICIPANTS = parse_participants(str(_participants_w.value))
|
||||
NOTES = str(_notes_w.value)
|
||||
|
||||
_vals = {f: float(_baseline_w[f].value) for f in BASELINE_FIELDS}
|
||||
BASELINE = OperationalBaseline(
|
||||
annual_contact_volume=int(_vals["annual_contact_volume"]),
|
||||
blended_cost_per_contact=_vals["blended_cost_per_contact"],
|
||||
agent_headcount=int(_vals["agent_headcount"]),
|
||||
annual_attrition_rate=_vals["annual_attrition_rate"],
|
||||
current_containment_rate=_vals["current_containment_rate"],
|
||||
average_handle_time_seconds=int(_vals["average_handle_time_seconds"]),
|
||||
field_confidence={f: str(_conf_w[f].value).split()[-1]
|
||||
for f in BASELINE_FIELDS},
|
||||
)
|
||||
|
||||
RAW_SCORES = {c.id: (int(_score_w[c.id].value), str(_evidence_w[c.id].value))
|
||||
for c in COMPETENCIES}
|
||||
SCORES = build_scores(CONFIG, RAW_SCORES, scored_at=dt.datetime.now())
|
||||
VAS = value_at_stake(CONFIG, BASELINE, SCORES)
|
||||
ENGAGEMENT = build_engagement(
|
||||
config=CONFIG, client_name=CLIENT_NAME,
|
||||
facilitator=str(_facilitator_w.value), workshop_date=WORKSHOP_DATE,
|
||||
participants=PARTICIPANTS, baseline=BASELINE, scores=SCORES,
|
||||
computed_value=VAS, notes=NOTES)
|
||||
|
||||
NOW_SCORING = COMPETENCIES[int(str(_now_scoring_w.value).split(" · ")[0]) - 1]
|
||||
_done, _total = evidence_coverage(SCORES)
|
||||
|
||||
# One curated line on stage; warnings surface for the room; echo backstage.
|
||||
if CONFIG.value_drivers:
|
||||
_bind = ", ".join(CONFIG.competency(c).name for c in VAS.binding_constraints)
|
||||
print(f"Value at stake: {money(VAS.theoretical_annual_value_low)}-"
|
||||
f"{money(VAS.theoretical_annual_value_high)} theoretical per year · "
|
||||
f"{money(VAS.realizable_18mo_low)}-{money(VAS.realizable_18mo_high)} "
|
||||
f"realizable over 18 months — capped at level "
|
||||
f"{VAS.weakest_foundational_score} by {_bind}")
|
||||
for _warning in VAS.warnings:
|
||||
print(f"⚠ {_warning}")
|
||||
backstage(f"engagement {ENGAGEMENT.engagement_id} · "
|
||||
f"{len(PARTICIPANTS)} participants · evidence {_done}/{_total}")'''
|
||||
|
||||
|
||||
STAGE_HEADER = '''\
|
||||
# ── Stage: engagement banner + baseline echo ────────────────────────
|
||||
_chips = "".join(
|
||||
f'<span style="display:inline-block;border:1px solid {HAIRLINE};'
|
||||
f'border-radius:14px;padding:2px 10px;margin:2px 6px 2px 0;'
|
||||
f'font:12px {BODY_FONT};color:{MUTED}">{esc(p.name)}'
|
||||
+ (f" · {esc(p.role)}" if p.role else "")
|
||||
+ f' <b style="color:{BLUE}">{esc(p.function)}</b></span>'
|
||||
for p in PARTICIPANTS) or (
|
||||
f'<span style="font:13px {BODY_FONT};color:{MUTED}">'
|
||||
f'no participants captured yet</span>')
|
||||
|
||||
_rows = ""
|
||||
for _f in BASELINE_FIELDS:
|
||||
_v = getattr(BASELINE, _f)
|
||||
_shown = f"{_v:,.2f}" if isinstance(_v, float) and _v < 10 else f"{_v:,.0f}"
|
||||
_rows += (
|
||||
f'<tr><td style="padding:3px 14px 3px 0;font:13px {BODY_FONT};'
|
||||
f'color:{MUTED}">{BASELINE_META[_f][0]}</td>'
|
||||
f'<td style="padding:3px 10px;font:600 13px {BODY_FONT};color:{INK};'
|
||||
f'text-align:right">{_shown}</td>'
|
||||
f'<td style="padding:3px 0">{CONFIDENCE_ICON[BASELINE.confidence_for(_f)]}'
|
||||
f'</td></tr>')
|
||||
|
||||
_ = mr.Markdown(text=(
|
||||
f'<div style="max-width:860px">'
|
||||
f'<div style="font:700 24px {FONT};color:{NAVY};margin:4px 0 2px">'
|
||||
f'{esc(CLIENT_NAME)} — CX AI Diagnostic</div>'
|
||||
f'<div style="font:14px {BODY_FONT};color:{MUTED};margin-bottom:8px">'
|
||||
f'{esc(CONFIG.display_name)} · {WORKSHOP_DATE.isoformat()} · '
|
||||
f'facilitated by {esc(ENGAGEMENT.facilitator)} · '
|
||||
f'evidence captured {_done}/{_total}</div>'
|
||||
f'<div style="margin:6px 0 10px">{_chips}</div>'
|
||||
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
|
||||
f'background:{CARD_BG};padding:10px 16px;display:inline-block">'
|
||||
f'<div style="font:700 13px {FONT};color:{NAVY};margin-bottom:4px">'
|
||||
f'Operational baseline</div>'
|
||||
f'<table style="border-collapse:collapse">{_rows}</table></div></div>'))'''
|
||||
|
||||
|
||||
STAGE_CARD = '''\
|
||||
# ── Stage: the scoring screen (one competency at a time) ────────────
|
||||
_c = NOW_SCORING
|
||||
_score, _evidence = RAW_SCORES[_c.id]
|
||||
|
||||
# Progress strip: one box per competency — its current score, solid
|
||||
# border once evidence is captured, highlighted while on screen.
|
||||
_boxes = ""
|
||||
for _i, _cc in enumerate(COMPETENCIES):
|
||||
_s, _e = RAW_SCORES[_cc.id]
|
||||
_bg = HILITE if _cc.id == _c.id else CARD_BG
|
||||
_border = f"1px solid {HAIRLINE}" if not _e else f"1px solid {MUTED}"
|
||||
if _cc.id == _c.id:
|
||||
_border = f"2px solid {BLUE}"
|
||||
_boxes += (
|
||||
f'<span title="{esc(_cc.name)}" style="display:inline-block;'
|
||||
f'width:30px;height:30px;line-height:28px;text-align:center;'
|
||||
f'border:{_border};border-radius:6px;background:{_bg};'
|
||||
f'font:600 14px {BODY_FONT};color:{INK};margin-right:5px">{_s}</span>')
|
||||
|
||||
_levels = ""
|
||||
for _lvl in range(1, 6):
|
||||
_sel = _lvl == _score
|
||||
_levels += (
|
||||
f'<tr><td style="padding:5px 12px;font:700 14px {BODY_FONT};'
|
||||
f'color:{BLUE if _sel else MUTED};border-left:4px solid '
|
||||
f'{BLUE if _sel else "transparent"};background:'
|
||||
f'{HILITE if _sel else "transparent"}">{_lvl}</td>'
|
||||
f'<td style="padding:5px 8px;font:{"600 " if _sel else ""}14px '
|
||||
f'{BODY_FONT};color:{INK if _sel else MUTED};background:'
|
||||
f'{HILITE if _sel else "transparent"}">'
|
||||
f'{esc(_c.level_descriptors[_lvl])}</td></tr>')
|
||||
|
||||
_evidence_html = (
|
||||
f'<span style="color:{GREEN}">✓</span> {esc(_evidence)}' if _evidence
|
||||
else f'<span style="color:{MUTED}">no evidence captured yet — '
|
||||
f'one line: what makes this a level {_score}?</span>')
|
||||
|
||||
_ = mr.Markdown(text=(
|
||||
f'<div style="max-width:860px">'
|
||||
f'<div style="margin:14px 0 8px">{_boxes}</div>'
|
||||
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
|
||||
f'background:#ffffff;padding:16px 20px">'
|
||||
f'<div style="font:600 12px {BODY_FONT};color:{BLUE};'
|
||||
f'text-transform:uppercase;letter-spacing:.06em">'
|
||||
f'{DIMENSION_NAME[_c.dimension]}</div>'
|
||||
f'<div style="font:700 21px {FONT};color:{NAVY};margin:2px 0 6px">'
|
||||
f'{esc(_c.name)}</div>'
|
||||
f'<div style="font:15px {BODY_FONT};color:{INK};margin-bottom:6px">'
|
||||
f'{esc(_c.description)}</div>'
|
||||
f'<div style="font:italic 14px {FONT};color:{MUTED};margin-bottom:10px">'
|
||||
f'"{esc(_c.failure_vignette)}"</div>'
|
||||
f'<table style="border-collapse:collapse;width:100%">{_levels}</table>'
|
||||
f'<div style="font:13px {BODY_FONT};color:{INK};margin-top:10px;'
|
||||
f'border-top:1px solid {LINE};padding-top:8px">'
|
||||
f'<b>Evidence:</b> {_evidence_html}</div>'
|
||||
f'</div></div>'))'''
|
||||
|
||||
|
||||
STAGE_ANALYSIS = '''\
|
||||
# ── Stage: live analysis & visuals ──────────────────────────────────
|
||||
heatmap_fig(heatmap_grid(CONFIG, SCORES)).show()
|
||||
|
||||
if CONFIG.value_drivers:
|
||||
value_bands_fig(VAS).show()
|
||||
split_fig(VAS).show()
|
||||
unlock_fig(VAS, CONFIG).show()
|
||||
# The unlock moves as a table — the room verifies numbers by
|
||||
# reading them, not by trusting the bars.
|
||||
UNLOCK_DF = pd.DataFrame([{
|
||||
"Move": _i + 1,
|
||||
"Competencies": " + ".join(CONFIG.competency(c).name
|
||||
for c in _m.competency_ids),
|
||||
"Lift": f"{_m.current_level} → {_m.target_level}",
|
||||
"Est. cost": (f"{money(_m.est_cost_low)} – {money(_m.est_cost_high)}"
|
||||
if _m.est_cost_low is not None else "not configured"),
|
||||
"Weeks": _m.est_weeks if _m.est_weeks is not None else "—",
|
||||
"Annual value unlocked": (f"{money(_m.value_unlocked_low)} – "
|
||||
f"{money(_m.value_unlocked_high)}"),
|
||||
"Note": _m.note,
|
||||
} for _i, _m in enumerate(VAS.unlock_sequence)])
|
||||
display(UNLOCK_DF.style.hide(axis="index"))
|
||||
else:
|
||||
_ = mr.Markdown(text=(
|
||||
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
|
||||
f'background:{CARD_BG};padding:12px 16px;max-width:860px;'
|
||||
f'font:14px {BODY_FONT};color:{MUTED}">'
|
||||
f'<b style="color:{INK}">{esc(CONFIG.display_name)}</b> has no value '
|
||||
f'drivers configured yet — capability scoring works normally, but '
|
||||
f'value-at-stake needs drivers in '
|
||||
f'<code>configs/{esc(INDUSTRY)}.yaml</code>.</div>'))'''
|
||||
|
||||
|
||||
MD_EXPORT = """\
|
||||
## Export
|
||||
|
||||
The button writes `exports/{engagement_id}.json` — the full engagement
|
||||
record, the source-of-truth artifact — and `exports/{engagement_id}.csv`,
|
||||
one row per competency for cross-engagement analysis. A **deliberate
|
||||
snapshot**: it captures the state at click time; click again after
|
||||
changes to refresh."""
|
||||
|
||||
|
||||
W_EXPORT = '''\
|
||||
# ── Export trigger (widgets ONLY — the click is handled below) ──────
|
||||
_export_w = mr.Button(label="Export JSON + CSV", position="inline")'''
|
||||
|
||||
|
||||
EXPORT_STATE = '''\
|
||||
# ── Export on click — snapshot semantics ────────────────────────────
|
||||
# Kernel globals persist across Mercury re-runs; the guard writes
|
||||
# exactly once per click, at whatever state the cockpit showed then.
|
||||
try:
|
||||
_LAST_EXPORT_CLICKS
|
||||
except NameError:
|
||||
_LAST_EXPORT_CLICKS = 0
|
||||
|
||||
if int(_export_w.n_clicks) > _LAST_EXPORT_CLICKS:
|
||||
_json_path, _csv_path = write_exports(ENGAGEMENT, CONFIG, EXPORTS_DIR)
|
||||
_LAST_EXPORT_CLICKS = int(_export_w.n_clicks)
|
||||
print(f"Exported {_json_path.name} + {_csv_path.name} → exports/")
|
||||
else:
|
||||
backstage("No export click this run — engagement exports come from the "
|
||||
"stage button; report sources from scripts/export_report.py.")'''
|
||||
|
||||
|
||||
MD_GATE = """\
|
||||
## Verification & assertions
|
||||
|
||||
Engine pins use the explicit seed scenario, independent of the sidebar,
|
||||
so the gate tests `diaglib` + `configs/`, not the current session;
|
||||
live-state pins are guarded so a facilitator moving a slider never
|
||||
crashes the room; structural ties hold at **any** widget state. This
|
||||
cell must pass under headless `nbconvert --execute` — it is the study's
|
||||
smoke test. Output renders backstage only."""
|
||||
|
||||
|
||||
GATE = '''\
|
||||
# ── Verification gate — must pass under headless nbconvert ──────────
|
||||
def _approx(got, want, tol=0.5):
|
||||
assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"
|
||||
|
||||
|
||||
# Engine pins — EXPLICIT seed scenario, independent of widget state.
|
||||
# Hand arithmetic in tests/test_value_math.py (same scenario).
|
||||
_gate_cfg = load_config("contact_center", CONFIGS_DIR)
|
||||
_gate_baseline = 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)
|
||||
_gate_scores = build_scores(_gate_cfg, SEED_SCORES,
|
||||
scored_at=dt.datetime(2026, 7, 19, 9, 0))
|
||||
_gate_vas = value_at_stake(_gate_cfg, _gate_baseline, _gate_scores)
|
||||
_approx(_gate_vas.theoretical_annual_value_low, 2_542_500)
|
||||
_approx(_gate_vas.theoretical_annual_value_high, 5_085_000)
|
||||
_approx(_gate_vas.realizable_18mo_low, 953_437.50)
|
||||
_approx(_gate_vas.realizable_18mo_high, 3_051_000)
|
||||
_approx(_gate_vas.trapped_value_low, 1_525_500)
|
||||
_approx(_gate_vas.trapped_value_high, 3_813_750)
|
||||
assert _gate_vas.binding_constraints == ["data_readiness"]
|
||||
assert len(_gate_vas.unlock_sequence) == 3
|
||||
_m1 = _gate_vas.unlock_sequence[0]
|
||||
assert _m1.competency_ids == ["data_readiness"]
|
||||
assert (_m1.est_cost_low, _m1.est_cost_high, _m1.est_weeks) == (300_000, 600_000, 12)
|
||||
_approx(_m1.value_unlocked_low, 635_625)
|
||||
_approx(_m1.value_unlocked_high, 1_271_250)
|
||||
|
||||
# Live-state pins — guarded, so a moved slider can't crash the room.
|
||||
_at_default = (
|
||||
INDUSTRY == "contact_center"
|
||||
and all(getattr(BASELINE, f) == getattr(_gate_baseline, f)
|
||||
for f in BASELINE_FIELDS)
|
||||
and all(RAW_SCORES[k][0] == SEED_SCORES[k][0] for k in SEED_SCORES))
|
||||
if _at_default:
|
||||
_approx(VAS.theoretical_annual_value_low, 2_542_500)
|
||||
_approx(VAS.realizable_18mo_high, 3_051_000)
|
||||
_approx(VAS.trapped_value_high, 3_813_750)
|
||||
|
||||
# Structural ties — hold at ANY widget state.
|
||||
assert len(SCORES) == 12 and len({s.competency_id for s in SCORES}) == 12
|
||||
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
|
||||
_approx(sum(d.theoretical_low for d in VAS.driver_values),
|
||||
VAS.theoretical_annual_value_low)
|
||||
_approx(sum(d.theoretical_high for d in VAS.driver_values),
|
||||
VAS.theoretical_annual_value_high)
|
||||
_approx(VAS.realizable_18mo_low,
|
||||
VAS.theoretical_annual_value_low * VAS.realization_factor_low * 1.5)
|
||||
_approx(VAS.realizable_18mo_high,
|
||||
VAS.theoretical_annual_value_high * VAS.realization_factor_high * 1.5)
|
||||
assert set(VAS.binding_constraints) <= set(CONFIG.foundational_competencies)
|
||||
assert len(VAS.unlock_sequence) <= 3
|
||||
|
||||
# Export payload is serializable and matches the live session.
|
||||
import json as _json
|
||||
_payload = _json.loads(engagement_json(ENGAGEMENT))
|
||||
assert _payload["engagement_id"] == ENGAGEMENT.engagement_id
|
||||
assert len(_payload["scores"]) == 12
|
||||
_df = scores_dataframe(ENGAGEMENT, CONFIG)
|
||||
assert list(_df.columns) == CSV_COLUMNS and len(_df) == 12
|
||||
|
||||
backstage("All assertions passed.")'''
|
||||
|
||||
|
||||
MD_APPENDIX = """\
|
||||
## Data appendix — for the machines
|
||||
|
||||
The full engagement as markdown tables plus one JSON block of state, so
|
||||
the exported report is complete LLM input — and the future Athena
|
||||
study-export payload. Renders **backstage** — hidden on the Mercury
|
||||
stage."""
|
||||
|
||||
|
||||
APPENDIX = '''\
|
||||
# ── Data appendix — LLM-readable dump of the engagement ─────────────
|
||||
# Renders backstage only (JupyterLab / nbconvert exports).
|
||||
backstage("#### Capability scores\\n")
|
||||
_rows = ["| Competency | Dimension | Score | Evidence |", "|---|---|---:|---|"]
|
||||
for _s in SCORES:
|
||||
_cc = CONFIG.competency(_s.competency_id)
|
||||
_ev = _s.evidence.replace("|", "\\\\|") or "—"
|
||||
_rows.append(f"| {_cc.name} | {DIMENSION_NAME[_s.dimension]} | "
|
||||
f"{_s.score} | {_ev} |")
|
||||
backstage("\\n".join(_rows))
|
||||
|
||||
backstage("\\n#### Dimension rollup\\n")
|
||||
_rows = ["| Dimension | Mean score |", "|---|---:|"]
|
||||
for _did, _dname, _mean in dimension_rollup(CONFIG, SCORES):
|
||||
_rows.append(f"| {_dname} | {_mean:.2f} |")
|
||||
backstage("\\n".join(_rows))
|
||||
|
||||
if CONFIG.value_drivers:
|
||||
backstage("\\n#### Value drivers (theoretical annual)\\n")
|
||||
_rows = ["| Driver | Low | High |", "|---|---:|---:|"]
|
||||
for _d in VAS.driver_values:
|
||||
_rows.append(f"| {_d.name} | {money(_d.theoretical_low)} | "
|
||||
f"{money(_d.theoretical_high)} |")
|
||||
_rows.append(f"| **Total** | **{money(VAS.theoretical_annual_value_low)}** "
|
||||
f"| **{money(VAS.theoretical_annual_value_high)}** |")
|
||||
backstage("\\n".join(_rows))
|
||||
backstage(f"\\nRealization band at weakest foundation level "
|
||||
f"{VAS.weakest_foundational_score}: "
|
||||
f"{VAS.realization_factor_low:.0%}-{VAS.realization_factor_high:.0%} · "
|
||||
f"realizable 18-mo {money(VAS.realizable_18mo_low)}-"
|
||||
f"{money(VAS.realizable_18mo_high)} · trapped "
|
||||
f"{money(VAS.trapped_value_low)}-{money(VAS.trapped_value_high)} "
|
||||
f"per year · binding: {', '.join(VAS.binding_constraints)}")
|
||||
|
||||
backstage("\\n#### Engagement state (JSON)\\n")
|
||||
backstage("```json")
|
||||
backstage(engagement_json(ENGAGEMENT))
|
||||
backstage("```")'''
|
||||
|
||||
|
||||
REVIEW = '''\
|
||||
# ── Review a saved engagement (backstage utility, optional) ─────────
|
||||
# Point _REVIEW_JSON at an exports/*.json and run in JupyterLab to
|
||||
# reload a past engagement for review; the live session is untouched.
|
||||
_REVIEW_JSON = ""
|
||||
if _REVIEW_JSON:
|
||||
from diaglib import load_engagement
|
||||
_prev = load_engagement(pathlib.Path(_REVIEW_JSON))
|
||||
backstage(f"loaded {_prev.engagement_id}: {_prev.client_name} · "
|
||||
f"{len(_prev.scores)} scores · " + (
|
||||
"binding: " + ", ".join(_prev.computed_value.binding_constraints)
|
||||
if _prev.computed_value else "no computed value"))'''
|
||||
|
||||
|
||||
def md(source: str) -> nbf.NotebookNode:
|
||||
return nbf.v4.new_markdown_cell(source)
|
||||
|
||||
|
||||
def code(source: str) -> nbf.NotebookNode:
|
||||
return nbf.v4.new_code_cell(source)
|
||||
|
||||
|
||||
def build() -> nbf.NotebookNode:
|
||||
nb = nbf.v4.new_notebook()
|
||||
nb.cells = [
|
||||
md(MD_TITLE),
|
||||
code(SETUP),
|
||||
md(MD_HOWTO),
|
||||
code(W_ENGAGEMENT),
|
||||
code(W_BASELINE),
|
||||
code(W_SCORING),
|
||||
code(STATE),
|
||||
code(STAGE_HEADER),
|
||||
code(STAGE_CARD),
|
||||
code(STAGE_ANALYSIS),
|
||||
md(MD_EXPORT),
|
||||
code(W_EXPORT),
|
||||
code(EXPORT_STATE),
|
||||
md(MD_GATE),
|
||||
code(GATE),
|
||||
md(MD_APPENDIX),
|
||||
code(APPENDIX),
|
||||
code(REVIEW),
|
||||
]
|
||||
nb.metadata = {
|
||||
"kernelspec": {"display_name": "Python 3", "language": "python",
|
||||
"name": "python3"},
|
||||
"language_info": {"name": "python"},
|
||||
}
|
||||
return nb
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
nbf.write(build(), OUT)
|
||||
print(f"wrote {OUT.relative_to(ROOT)} ({len(build().cells)} cells)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user