"""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'{esc(p.name)}' + (f" · {esc(p.role)}" if p.role else "") + f' {esc(p.function)}' for p in PARTICIPANTS) or ( f'' f'no participants captured yet') _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'{BASELINE_META[_f][0]}' f'{_shown}' f'{CONFIDENCE_ICON[BASELINE.confidence_for(_f)]}' f'') _ = mr.Markdown(text=( f'
' f'
' f'{esc(CLIENT_NAME)} — CX AI Diagnostic
' f'
' f'{esc(CONFIG.display_name)} · {WORKSHOP_DATE.isoformat()} · ' f'facilitated by {esc(ENGAGEMENT.facilitator)} · ' f'evidence captured {_done}/{_total}
' f'
{_chips}
' f'
' f'
' f'Operational baseline
' f'{_rows}
'))''' 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'{_s}') _levels = "" for _lvl in range(1, 6): _sel = _lvl == _score _levels += ( f'{_lvl}' f'' f'{esc(_c.level_descriptors[_lvl])}') _evidence_html = ( f' {esc(_evidence)}' if _evidence else f'no evidence captured yet — ' f'one line: what makes this a level {_score}?') _ = mr.Markdown(text=( f'
' f'
{_boxes}
' f'
' f'
' f'{DIMENSION_NAME[_c.dimension]}
' f'
' f'{esc(_c.name)}
' f'
' f'{esc(_c.description)}
' f'
' f'"{esc(_c.failure_vignette)}"
' f'{_levels}
' f'
' f'Evidence: {_evidence_html}
' f'
'))''' 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'
' f'{esc(CONFIG.display_name)} has no value ' f'drivers configured yet — capability scoring works normally, but ' f'value-at-stake needs drivers in ' f'configs/{esc(INDUSTRY)}.yaml.
'))''' 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()