docs: update workshop README for notebook-first content model

Rewrite the CX Discovery Workshop README to reflect the architecture
shift where content lives in the notebook's `topic-bank` cell rather
than in `discoverylib/topics.py`. The library now holds code only, the
notebook is the deliverable (no longer generated via build_notebook.py),
and tests pin content read directly from the notebook. Update layout,
run, and extending sections accordingly.
This commit is contained in:
2026-07-31 10:24:42 +00:00
parent 71b913d7fe
commit 53c069fddb
11 changed files with 3613 additions and 3906 deletions

View File

@@ -1,362 +0,0 @@
"""Generate notebooks/cx_discovery.ipynb from source cell text.
The discovery notebook has ~42 Mercury widgets (a status selector + a notes
box per topic, plus a checkbox per sub-topic). Hand-maintaining that JSON is
error-prone, so the notebook is *generated* from this script — the 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 discoverylib.
"""
from __future__ import annotations
import pathlib
import nbformat as nbf
ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT = ROOT / "notebooks" / "cx_discovery.ipynb"
# ── Cell sources ─────────────────────────────────────────────────────
MD_TITLE = """\
# CX Exploration & Discovery Workshop
The live visual for a discovery session. On screen the client sees the
**topic board** — each topic, its one-line scope, and a status glyph — the
**live sub-topic checklist** for whatever we're discussing now, and a
**progress headline** ("3/8 topics complete"). You drive the conversation
from the question script (which stays backstage); the client is never shown
a wall of questions.
**This notebook is the deliverable.** Serve it with
`mercury --working-dir .` and share that screen; mark each topic's status in
the sidebar as the discussion moves and tick sub-topics as you cover them —
the board and progress bar update live. Capture answers in the per-topic
notes boxes. Afterward, `python scripts/export_report.py` writes an
LLM-readable report source (notes + status + progress as markdown and JSON)
to feed the survey write-up or a downstream business-case study.
All content and logic live in `discoverylib/` — the notebook only arranges
and renders them.
Confidence legend: 🟢 confirmed · 🟡 estimated · 🔴 unknown — used in the
captured notes, not on the client-facing board."""
SETUP = '''\
# ── Setup ──────────────────────────────────────────────────────────
import sys, pathlib
_ROOT = pathlib.Path.cwd()
if not (_ROOT / "discoverylib").exists(): # notebook lives in notebooks/
_ROOT = _ROOT.parent
sys.path.insert(0, str(_ROOT))
import html as _html
import mercury as mr
# Single source of truth — the topic bank and all status/progress logic
# live in the library; only presentation (and Mercury widgets) lives here.
from discoverylib import (
TOPICS, STATUSES, STATUS_LABEL, STATUS_GLYPH, STATUS_COLOR,
build_session, subtopic_checklist, subtopic_id, session_json,
)
from discoverylib.staging import backstage
# ── Brand palette (docs/brand.md, light theme) ─────────────────────
NAVY, INK, MUTED = "#151d2c", "#2e404d", "#586671"
BLUE, GREEN, LINE = "#0072bc", "#00a34c", "#e2e6e9"
CARD_BG, HAIRLINE = "#f8f8f8", "#d5d9db"
FONT = "Georgia, 'Times New Roman', serif"
BODY_FONT = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
def esc(s):
return _html.escape(str(s))
backstage(f"discoverylib loaded — {len(TOPICS)} topics · "
f"{sum(len(t.subtopics) for t in TOPICS)} sub-topics · "
f"{sum(t.prompt_count for t in TOPICS)} prompts")'''
MD_HOWTO = """\
## How to run this session
- **Sidebar** — one **status** selector and a **notes** box per topic, plus a
**sub-topic checkbox** for each thread. As the conversation moves, set the
topic you're on to *In progress*, tick sub-topics as you cover them, mark
the topic *Complete* (or *Skipped*) when you move on, and jot answers in its
notes box.
- **Stage** (this page) — the client-facing board and progress bar below
re-render on every change.
- **Backstage** (JupyterLab / the export) — the facilitator question script and
the captured-notes appendix. Neither shows on the Mercury stage."""
# The widget block is generated from the topic bank so it stays DRY and
# can never drift from discoverylib. Widgets ONLY — no other output
# (Mercury leaks stray widget-cell output into the sidebar).
WIDGETS = '''\
# ── Session controls (Mercury sidebar — widgets ONLY, no other output) ─
# Generated from the topic bank: per topic a status selector + notes box,
# per sub-topic a checkbox. Mercury re-runs only cells BELOW this one, so
# every .value is read in the next cell down — never here.
#
# _status_w[topic_key] -> Select (Not started / In progress / …)
# _notes_w[topic_key] -> TextInput (captured answers, backstage)
# _sub_w[(topic_key, sub)] -> CheckBox (covered?)
# Labels are distinct per widget, so Mercury's label-keyed widget cache
# never collides across topics/sub-topics.
_status_w, _notes_w, _sub_w = {}, {}, {}
for _t in TOPICS:
_status_w[_t.key] = mr.Select(
label=f"{_t.title} — status",
value=STATUS_LABEL[STATUSES[0]],
choices=[STATUS_LABEL[s] for s in STATUSES],
)
for _st in _t.subtopics:
_sub_w[(_t.key, _st.key)] = mr.CheckBox(
value=False, appearance="box",
label=f"{_t.title}: {_st.title}")
_notes_w[_t.key] = mr.TextInput(
label=f"{_t.title} — notes", value="")'''
STATE = '''\
# ── Session state (re-runs on any sidebar change) ───────────────────
# Read every widget .value and rebuild the session via the engine.
_LABEL_TO_STATUS = {STATUS_LABEL[s]: s for s in STATUSES}
STATUS_BY_TOPIC = {k: _LABEL_TO_STATUS.get(str(w.value), STATUSES[0])
for k, w in _status_w.items()}
NOTES_BY_TOPIC = {k: str(w.value) for k, w in _notes_w.items()}
DONE_SUBTOPICS = {subtopic_id(tk, sk)
for (tk, sk), w in _sub_w.items() if bool(w.value)}
SESSION = build_session(STATUS_BY_TOPIC, NOTES_BY_TOPIC, DONE_SUBTOPICS)
PROGRESS = SESSION["progress"]
# One curated line on stage; full echo backstage.
print(f"{PROGRESS.label} · {PROGRESS.resolved}/{PROGRESS.total} resolved · "
f"agenda ~{SESSION['agenda_minutes']} min")
backstage("status: " + ", ".join(f"{ts.key}={ts.status}" for ts in SESSION["topics"]))'''
# The client-facing board. Pure HTML string built from SESSION; rendered
# with mr.Markdown so it shows on the Mercury stage (and in exports).
BOARD = '''\
# ── Stage: the client-facing topic board + progress ─────────────────
def _progress_bar(frac, completed, total):
pct = max(0.0, min(1.0, frac)) * 100
return (
f'<div style="margin:6px 0 18px">'
f'<div style="height:12px;border-radius:6px;background:{LINE};'
f'overflow:hidden">'
f'<div style="height:100%;width:{pct:.1f}%;background:{GREEN};'
f'border-radius:6px;transition:width .3s"></div></div>'
f'<div style="font:13px {BODY_FONT};color:{MUTED};margin-top:6px">'
f'{completed} of {total} topics complete</div></div>')
def _topic_row(ts, is_active):
ring = f"2px solid {BLUE}" if is_active else f"1px solid {HAIRLINE}"
bg = "#eef5fb" if is_active else CARD_BG
active_tag = (f'<span style="font:600 12px {BODY_FONT};color:{BLUE};'
f'margin-left:8px">discussing now →</span>' if is_active else "")
sub = ""
if is_active and ts.subtopics_total:
items = []
for it in subtopic_checklist(_ACTIVE_TOPIC, DONE_SUBTOPICS):
mark = "" if it.done else ""
col = GREEN if it.done else MUTED
wt = "600" if it.done else "400"
items.append(
f'<li style="font:{wt} 14px {BODY_FONT};color:{col};'
f'margin:3px 0;list-style:none">'
f'<span style="display:inline-block;width:1.2em">{mark}</span>'
f'{esc(it.title)}</li>')
sub = (f'<ul style="margin:10px 0 2px;padding:0 0 0 34px">'
f'{"".join(items)}</ul>')
return (
f'<div style="border:{ring};border-radius:10px;background:{bg};'
f'padding:12px 16px;margin:8px 0">'
f'<div style="display:flex;align-items:baseline">'
f'<span style="font-size:18px;color:{ts.color};width:1.4em">{ts.glyph}</span>'
f'<span style="font:700 17px {FONT};color:{NAVY}">{esc(ts.title)}</span>'
f'{active_tag}'
f'<span style="margin-left:auto;font:12px {BODY_FONT};color:{MUTED}">'
f'{esc(ts.status_label)}</span></div>'
f'<div style="font:14px {BODY_FONT};color:{MUTED};margin:4px 0 0 34px">'
f'{esc(ts.scope)}</div>'
f'{sub}</div>')
_ACTIVE_TOPIC = SESSION["active_topic"]
_rows = "".join(_topic_row(ts, ts.key == SESSION["active_topic_key"])
for ts in SESSION["topics"])
_header = (
f'<div style="font:700 24px {FONT};color:{NAVY};margin:4px 0 2px">'
f'CX Discovery — topics</div>'
f'<div style="font:14px {BODY_FONT};color:{MUTED}">'
f'{len(SESSION["topics"])} topics · ~{SESSION["agenda_minutes"]} minutes</div>')
_board_html = (
f'<div style="max-width:760px">{_header}'
f'{_progress_bar(PROGRESS.fraction, PROGRESS.completed, PROGRESS.total)}'
f'{_rows}</div>')
# Assign to _ so the bare-expression repr doesn't render a second copy.
_ = mr.Markdown(text=_board_html)'''
MD_SCRIPT = """\
## Facilitator question script (backstage)
The prompts below render in JupyterLab and in the exports — **not** on the
Mercury stage. They are your running order; ask them conversationally and let
the topics expand or drop as the client answers. Tick each sub-topic in the
sidebar as you cover it."""
SCRIPT = '''\
# ── Facilitator question script — backstage only ────────────────────
# Renders in JupyterLab and the exports; hidden on the Mercury stage.
_lines = ["\\n# CX Discovery — facilitation script\\n"]
for _t in TOPICS:
_lines.append(f"\\n## {_t.title} · ~{_t.minutes} min")
_lines.append(f"_{_t.scope}_\\n")
for _st in _t.subtopics:
_lines.append(f"\\n**{_st.title}** — `{subtopic_id(_t.key, _st.key)}`")
for _p in _st.prompts:
_lines.append(f" - {_p}")
backstage("\\n".join(_lines))'''
MD_GATE = """\
## Verification & assertions
Engine pins use explicit values independent of the sidebar, so the gate tests
`discoverylib`, not the current session; 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 _assert(cond, msg):
assert cond, msg
# Engine shape — independent of widget state (Pattern §4)
_assert(len(TOPICS) == 8, "expected 8 topics")
_assert(sum(len(t.subtopics) for t in TOPICS) == 26, "expected 26 sub-topics")
_assert(SESSION["agenda_minutes"] == 110, "agenda minutes drifted")
_assert(set(STATUS_BY_TOPIC) == {t.key for t in TOPICS}, "status keys ≠ topics")
# Structural ties — hold at ANY sidebar setting
_p = SESSION["progress"]
_assert(_p.total == len(SESSION["topics"]) == 8, "progress total ≠ topic count")
_assert(_p.completed + _p.skipped + _p.in_progress + _p.not_started == _p.total,
"status counts don't sum to total")
_assert(0.0 <= _p.fraction <= 1.0, "fraction out of range")
_assert(_p.resolved == _p.completed + _p.skipped, "resolved identity broke")
for _ts in SESSION["topics"]:
_assert(0 <= _ts.subtopics_done <= _ts.subtopics_total, "sub-topic count out of range")
_assert(_ts.status in STATUSES, f"unknown status {_ts.status}")
# Export payload is plain-JSON serializable and consistent with the session
import json as _json
_payload = session_json(SESSION, meta={"note": "gate check"})
_json.dumps(_payload) # raises if not serializable
_assert(_payload["progress"]["completed"] == _p.completed, "export/session mismatch")
_assert(len(_payload["topics"]) == 8, "export lost a topic")
backstage("All assertions passed.")
backstage(f" {_p.label} · {_p.resolved}/{_p.total} resolved")'''
MD_APPENDIX = """\
## Data appendix — for the machines
The captured session as a markdown table plus one JSON block of state, so the
exported report is complete LLM input for drafting the survey write-up or
seeding a business-case study. Renders **backstage** — hidden on the Mercury
stage."""
APPENDIX = '''\
# ── Data appendix — LLM-readable dump of the captured session ───────
# Renders backstage only (JupyterLab / nbconvert exports).
import json as _json
# Session metadata — edit these live for the client, or leave as defaults.
_META = {
"client": "", # 🟡 fill in for the engagement
"date": "", # 🟡 workshop date
"facilitator": "", # 🟡
}
backstage("\\n#### Captured session\\n")
_rows = ["| Topic | Status | Sub-topics | Notes |",
"|---|---|---:|---|"]
for _ts in SESSION["topics"]:
_note = _ts.notes.replace("|", "\\\\|").replace("\\n", " ") or ""
_rows.append(f"| {_ts.title} | {_ts.status_label} | "
f"{_ts.subtopics_done}/{_ts.subtopics_total} | {_note} |")
backstage("\\n".join(_rows))
backstage(f"\\n{PROGRESS.label} · {PROGRESS.resolved}/{PROGRESS.total} resolved "
f"· agenda ~{SESSION['agenda_minutes']} min\\n")
backstage("\\n#### Session state (JSON)\\n")
backstage("```json")
backstage(_json.dumps(session_json(SESSION, meta=_META), indent=2, ensure_ascii=False))
backstage("```")'''
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(WIDGETS),
code(STATE),
code(BOARD),
md(MD_SCRIPT),
code(SCRIPT),
md(MD_GATE),
code(GATE),
md(MD_APPENDIX),
code(APPENDIX),
]
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()