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:
@@ -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()
|
||||
Reference in New Issue
Block a user