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

@@ -30,37 +30,40 @@ prompts, grouped by topic → sub-topic) and the **captured-session appendix**
Mercury stage. The 8 topics / 26 sub-topics / ~110-minute agenda are the
structured form of the source survey.
## Where the content lives: in the notebook
The topic bank — every topic, sub-topic, and facilitator prompt — lives in
the **`topic-bank` cell** of
[`notebooks/cx_discovery.ipynb`](notebooks/cx_discovery.ipynb) (the code cell
tagged `topic-bank`, right under the title). **Content is edited there, in
Jupyter — never in a `.py` file.** It is the study's verbatim anchor: wording
tracks the source survey
[`docs/cx_discovery_survey.md`](docs/cx_discovery_survey.md) (the original
`cxxm.md`), and the `key` slugs are stable identities the sidebar widgets,
captured notes, and JSON export all key off — never renumber or rename them
casually. The cell's own comment block carries the full editing rules.
`discoverylib/` holds **code only**: the `Topic`/`SubTopic` schema, the
status vocabulary, and the session engine — every engine function takes the
bank as its first argument. The ~42 sidebar widgets are built by a runtime
loop over `TOPICS`, so board, checklist, script, gate, and export all pick up
a content edit automatically. The test suite reads the tagged cell straight
out of the notebook (no kernel) and pins the content, so `pytest` guards the
bank exactly as shipped.
## Layout
```
discoverylib/ # the engine — all content & logic
topics.py # the topic bank (verbatim anchor from the survey)
session.py # status vocabulary, progress, checklist, export payload
notebooks/cx_discovery.ipynb # THE deliverable — content (topic-bank cell) + presentation
discoverylib/ # the engine — code only, no content
session.py # Topic/SubTopic schema, status vocabulary, progress, export payload
staging.py # stage/backstage detection (copied verbatim)
notebooks/cx_discovery.ipynb # the deliverable (generated — see below)
scripts/
build_notebook.py # regenerates the notebook from cell sources
export_report.py # nbconvert → exports/*.html + *.md
tests/ # engine pins + stage/backstage test
scripts/export_report.py # nbconvert → exports/*.html + *.md
tests/ # content pins (read from the notebook) + engine pins + staging test
docs/cx_discovery_survey.md # source survey (the original cxxm.md)
exports/ # generated report sources
```
## The notebook is generated
The notebook wires ~42 Mercury widgets (a status selector + notes box per
topic, a checkbox per sub-topic), all derived from the topic bank so they can't
drift from `discoverylib`. Rather than hand-maintain that JSON, the notebook is
built from readable cell sources in
[`scripts/build_notebook.py`](scripts/build_notebook.py):
```bash
python scripts/build_notebook.py # regenerate after editing a cell
```
Edit facilitation *content* (topics, sub-topics, prompts, scope, minutes) in
[`discoverylib/topics.py`](discoverylib/topics.py) — not in the notebook.
## Run
```bash
@@ -69,15 +72,23 @@ pip install -e ".[dev]"
mercury --working-dir . # serve the stage (share this screen)
jupyter lab # analyst / facilitator view
pytest # engine pins + stage/backstage
pytest # content pins + engine pins + stage/backstage
jupyter nbconvert --to notebook --execute --inplace notebooks/cx_discovery.ipynb # gate
python scripts/export_report.py # exports/*.html + *.md for the LLM handoff
```
## Extending
New or reshaped discovery content is a `discoverylib/topics.py` edit, a test
pin (`tests/test_topics.py` recounts, `tests/test_session.py` for new logic),
then `python scripts/build_notebook.py`. Add a topic and the sidebar controls,
board, checklist, script, gate, and export all pick it up — because they're all
generated from the bank.
New or reshaped discovery content is an edit to the notebook's `topic-bank`
cell in JupyterLab, then:
1. Re-run the notebook — the in-notebook **gate** recounts the bank
(topics / sub-topics / prompts / key order / agenda minutes); update its
pins if the change is deliberate.
2. `pytest``tests/test_topics.py` pins the same shape from outside the
kernel; re-pin the counts there too.
Add a topic and the sidebar controls, board, checklist, script, gate, and
export all pick it up — they're all derived from `TOPICS` at runtime. New
*logic* (not content) goes in `discoverylib/session.py` with pins in
`tests/test_session.py`.

View File

@@ -10,18 +10,13 @@ carries the facilitator's question script and the captured per-topic notes,
which the data-appendix export hands to an LLM to draft the survey write-up
or feed a downstream business-case study.
Engine/presentation split (Pattern §2): the topic bank and all status /
progress logic live here; the notebook only arranges and renders them.
Code/content split: this package holds the **schema and logic only** —
:class:`Topic`/:class:`SubTopic`, the status vocabulary, and the session
engine. The content (the topic bank itself) lives in the ``topic-bank``
cell of ``notebooks/cx_discovery.ipynb``, where it is edited in Jupyter;
every engine function takes the bank as its first argument.
"""
from .topics import (
TOPIC_BY_KEY,
TOPIC_KEYS,
TOPICS,
SubTopic,
Topic,
topic,
)
from .session import (
COMPLETE,
IN_PROGRESS,
@@ -33,6 +28,8 @@ from .session import (
STATUSES,
ChecklistItem,
Progress,
SubTopic,
Topic,
TopicState,
active_topic_key,
agenda_minutes,
@@ -45,11 +42,11 @@ from .session import (
)
from .staging import backstage, on_stage
__version__ = "0.1.0"
__version__ = "0.2.0"
__all__ = [
# topic bank
"TOPICS", "TOPIC_BY_KEY", "TOPIC_KEYS", "Topic", "SubTopic", "topic",
# schema
"Topic", "SubTopic",
# status vocabulary
"STATUSES", "STATUS_LABEL", "STATUS_GLYPH", "STATUS_COLOR",
"NOT_STARTED", "IN_PROGRESS", "COMPLETE", "SKIPPED", "normalize_status",

View File

@@ -2,9 +2,12 @@
Session engine — the discovery workshop's "math".
There are no dollars here; the quantities a facilitator and client watch are
**status** and **progress**. This module owns all of it so the notebook only
arranges outputs (Mercury Notebook Pattern §2, engine/presentation split):
**status** and **progress**. This module owns the schema and all of the
logic, but none of the content: the topic bank itself lives in the
``topic-bank`` cell of ``notebooks/cx_discovery.ipynb`` (content belongs on
the Jupyter surface, where it is edited; ``.py`` files hold code only).
* :class:`Topic` / :class:`SubTopic` — the schema the bank is written in,
* the topic-status vocabulary (:data:`STATUSES`) and its display glyphs,
* :func:`progress` — the "3 / 8 topics complete" headline and its ratios,
* :func:`subtopic_checklist` — the live tick-list for the active topic,
@@ -12,7 +15,7 @@ arranges outputs (Mercury Notebook Pattern §2, engine/presentation split):
raw widget inputs (per-topic status + free-text notes),
* :func:`session_json` — the machine-readable export payload (Pattern §5).
Everything is a pure function of ``(status_by_topic, notes_by_topic,
Everything is a pure function of ``(topics, status_by_topic, notes_by_topic,
done_subtopics)`` so the in-notebook gate can pin it and the export can dump
it. Unknown / missing keys degrade gracefully to "not started" so a
half-filled live session never crashes the render.
@@ -20,10 +23,34 @@ half-filled live session never crashes the render.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
from .topics import TOPICS, TOPIC_BY_KEY, Topic
# ── Schema — what the notebook's topic-bank cell is written in ───────
@dataclass(frozen=True)
class SubTopic:
"""A discussion thread within a topic; its title shows on stage when the
topic is active, its prompts are the backstage facilitation script."""
key: str
title: str
prompts: tuple[str, ...] = ()
@dataclass(frozen=True)
class Topic:
"""A client-facing discovery topic: the unit the progress bar counts."""
key: str
title: str
scope: str
minutes: int
subtopics: tuple[SubTopic, ...] = field(default_factory=tuple)
@property
def prompt_count(self) -> int:
return sum(len(st.prompts) for st in self.subtopics)
# ── Status vocabulary ────────────────────────────────────────────────
@@ -94,13 +121,15 @@ class Progress:
return f"{self.completed}/{self.total} topics complete"
def progress(status_by_topic: dict[str, str]) -> Progress:
def progress(
topics: tuple[Topic, ...], status_by_topic: dict[str, str]
) -> Progress:
"""Tally topic statuses into the headline :class:`Progress`."""
counts = {s: 0 for s in STATUSES}
for t in TOPICS:
for t in topics:
counts[normalize_status(status_by_topic.get(t.key))] += 1
return Progress(
total=len(TOPICS),
total=len(topics),
completed=counts[COMPLETE],
skipped=counts[SKIPPED],
in_progress=counts[IN_PROGRESS],
@@ -108,13 +137,15 @@ def progress(status_by_topic: dict[str, str]) -> Progress:
)
def active_topic_key(status_by_topic: dict[str, str]) -> str | None:
def active_topic_key(
topics: tuple[Topic, ...], status_by_topic: dict[str, str]
) -> str | None:
"""The topic to spotlight on stage: the first in-progress topic, else
the first not-started one, else None (everything resolved)."""
for t in TOPICS:
for t in topics:
if normalize_status(status_by_topic.get(t.key)) == IN_PROGRESS:
return t.key
for t in TOPICS:
for t in topics:
if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED:
return t.key
return None
@@ -146,9 +177,9 @@ def subtopic_id(topic_key: str, subtopic_key: str) -> str:
# ── Agenda ───────────────────────────────────────────────────────────
def agenda_minutes() -> int:
def agenda_minutes(topics: tuple[Topic, ...]) -> int:
"""Sum of the nominal per-topic minute budgets."""
return sum(t.minutes for t in TOPICS)
return sum(t.minutes for t in topics)
# ── Full session state (render + export) ─────────────────────────────
@@ -168,19 +199,22 @@ class TopicState:
def build_session(
topics: tuple[Topic, ...],
status_by_topic: dict[str, str],
notes_by_topic: dict[str, str] | None = None,
done_subtopics: set[str] | frozenset[str] | None = None,
) -> dict[str, Any]:
"""Assemble the complete session state the notebook renders and exports.
Pure function of the three raw inputs; safe against missing keys.
Pure function of the bank and the three raw inputs; safe against
missing keys.
"""
notes_by_topic = notes_by_topic or {}
done_subtopics = frozenset(done_subtopics or ())
by_key = {t.key: t for t in topics}
topic_states: list[TopicState] = []
for t in TOPICS:
for t in topics:
status = normalize_status(status_by_topic.get(t.key))
done = sum(
1 for st in t.subtopics if subtopic_id(t.key, st.key) in done_subtopics
@@ -201,14 +235,14 @@ def build_session(
)
)
prog = progress(status_by_topic)
active = active_topic_key(status_by_topic)
prog = progress(topics, status_by_topic)
active = active_topic_key(topics, status_by_topic)
return {
"topics": topic_states,
"progress": prog,
"active_topic_key": active,
"active_topic": TOPIC_BY_KEY[active] if active else None,
"agenda_minutes": agenda_minutes(),
"active_topic": by_key[active] if active else None,
"agenda_minutes": agenda_minutes(topics),
}

View File

@@ -16,6 +16,7 @@ must appear for LLM consumption) but never render in the Mercury app.
from __future__ import annotations
import os
from typing import Any
def on_stage() -> bool:
@@ -23,7 +24,7 @@ def on_stage() -> bool:
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args, **kwargs) -> None:
def backstage(*args: object, **kwargs: Any) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)

View File

@@ -1,428 +0,0 @@
"""
The discovery topic bank — verbatim source record for the CX Exploration &
Discovery workshop.
This is the study's ``*_VERBATIM`` anchor (Mercury Notebook Pattern): the
facilitation content, structured but **never paraphrased away from** the
source survey ``docs/cx_discovery_survey.md`` (the original ``cxxm.md``).
Editing the wording of a prompt here is editing the anchor — do it against
the survey, not the notebook.
Shape
-----
``TOPICS`` is an ordered tuple of :class:`Topic`. Each topic owns an ordered
tuple of :class:`SubTopic`; each sub-topic owns the ordered facilitator
prompts (the questions you actually ask). The stage shows topic titles, the
one-line ``scope`` per topic, and — for the active topic — the sub-topic
titles as a live checklist. The prompts stay **backstage** (your script);
they never render on the client's screen.
Every topic carries:
* ``key`` — stable slug (snake_case); the identity used by widgets, notes,
status, and the JSON export. NEVER renumber or rename casually —
captured notes key off it.
* ``title`` — client-facing heading shown on stage.
* ``scope`` — one neutral line shown under the title on stage, so the client
stays oriented without being led by the questions.
* ``minutes`` — nominal facilitation budget, summed into the agenda estimate.
The content is deliberately data, not prose in the notebook: it is testable,
diffable, and exportable, and the notebook only arranges it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class SubTopic:
"""A discussion thread within a topic; its title shows on stage when the
topic is active, its prompts are the backstage facilitation script."""
key: str
title: str
prompts: tuple[str, ...] = ()
@dataclass(frozen=True)
class Topic:
"""A client-facing discovery topic: the unit the progress bar counts."""
key: str
title: str
scope: str
minutes: int
subtopics: tuple[SubTopic, ...] = field(default_factory=tuple)
@property
def prompt_count(self) -> int:
return sum(len(st.prompts) for st in self.subtopics)
# ── The topic bank ───────────────────────────────────────────────────
# Ordered as a workshop runs: context first, then the operational areas,
# then the enabling functions, closing on reporting & insight. Wording of
# prompts tracks docs/cx_discovery_survey.md (cxxm.md).
TOPICS: tuple[Topic, ...] = (
Topic(
key="background",
title="Background & Organization",
scope="Lines of business, channels, org structure, decision-making",
minutes=15,
subtopics=(
SubTopic(
"lines_of_business", "Lines of business & scope",
(
"How many distinct lines of business are supported in the "
"contact centre? (e.g. commercial, residential, retail, "
"wholesale)",
"Are you using the contact centre for internal uses such as "
"help desk, finance, HR?",
"Payment card / PCI in scope?",
"What are the availability SLAs or targets for your "
"technology platform?",
),
),
SubTopic(
"channels_supported", "Channels supported",
(
"Which channels do you support today? (Apps, Voice, Video, "
"Chat, SMS, Email, Social Media, Digital Assistants)",
),
),
SubTopic(
"org_structure", "Org & reporting structure",
(
"Mini org chart — reporting structure up to the executive "
"leader.",
"Are your contact centres managed by the same person? "
"(Managers / Team Leads / Agents)",
"Who carries the cost of contact centre agents and "
"supervisors?",
"What revenue is generated by the contact centre?",
),
),
SubTopic(
"decision_making", "Decision-making & priorities",
(
"Is decision-making centralized or decentralized?",
"Who are the key decision makers for changes to the contact "
"centre?",
"How important is CX & EX to your organization's strategy? "
"Is there an executive accountable for CX (CXO, CDO)?",
"Of your leading offers, what is the customer's top priority?",
"Competitive pressures?",
"Any pending or recent acquisitions or spinoffs affecting IT "
"infrastructure and services?",
),
),
SubTopic(
"problems_today", "Problems & causes today",
(
"What do you view as the problems in your contact centre "
"today, and the primary causes?",
"Any in-flight projects — CX or EX improvement initiatives?",
"Are your customer experiences personalized?",
),
),
),
),
Topic(
key="cx_strategy",
title="CX Strategy",
scope="How CX value is defined, measured, and organized to deliver",
minutes=15,
subtopics=(
SubTopic(
"value_definition", "Value & strategic position",
(
"How is the value of Customer Experience defined within your "
"organization? Is CX a key BPI, recognized & measured as a "
"financial value?",
"What are the key capabilities / services delivered by the "
"contact centre?",
"How are you using CX innovation to create market "
"disruption?",
"How are competitors using CX to create competitive "
"differentiation?",
),
),
SubTopic(
"operating_model", "Organization & operating model",
(
"Is there a CX team? Do the CX insights team regularly "
"educate the business?",
"Who are the key decision makers for investment and changes "
"to the contact centre? Channel management strategy?",
"What are the teams, and how are processes managed?",
"Describe your automation strategy. Who is your leader for "
"Data & AI?",
),
),
SubTopic(
"insight_and_voc", "Insight & Voice of Customer",
(
"How do you use analytics & data to generate a consolidated "
"view of your customer experience?",
"Is there a VOC program in place? ROI / business value "
"known?",
"How do customers rate the experience they receive (05)?",
),
),
SubTopic(
"continuous_improvement", "Continuous improvement",
(
"How is customer insight used to drive CX improvement, "
"loyalty and profitability? How do you anticipate needs?",
"Do you have a clear set of CX design guidelines? (Personas, "
"Journey Mapping, Tools)",
"KPI targets for CX — CSAT, NPS, CES? How are you doing?",
),
),
SubTopic(
"employee_engagement", "Employee engagement",
(
"Describe your employee / agent engagement strategy.",
"How engaged are your people in delivering the customer "
"experience?",
"Do you have a VoA / VoE program in place? How are you "
"doing?",
),
),
),
),
Topic(
key="channels",
title="Channels",
scope="Inbound & outbound volumes, metrics, contact reasons",
minutes=20,
subtopics=(
SubTopic(
"inbound_context", "Inbound context",
(
"Hours of operation — any 24×7?",
"Who contacts the contact centre? (Demographics, their "
"situation)",
"Are certain callers or groups prioritized?",
"Languages? (English, Canadian French, Spanish, other)",
),
),
SubTopic(
"voice_metrics", "Voice & video metrics",
(
"Average number of active agents (voice / video).",
"Toll-free numbers & DIDs — approximate quantities.",
"Average Wait Time, Abandon rate, Handle Time, Hold Time, "
"After-call work time.",
"Time to authenticate a caller.",
"% of calls transferred — internally / externally (3rd "
"parties)?",
"Courtesy callback / virtual hold? Post-call survey? First "
"Call Resolution rate? Average revenue per call (sales)?",
),
),
SubTopic(
"digital_channels", "Digital channels",
(
"Email — range of logged-in users, response time, email "
"server.",
"Chat — web (internal/external), app-embedded, SMS, "
"Messenger, WhatsApp, Telegram, iMessage.",
"Website forms, mobile apps.",
),
),
SubTopic(
"inbound_reasons", "Inbound contact reasons",
(
"Top 35 inbound contact reasons and approximate % of calls.",
"Cost per call? Average Handle Time?",
"Busiest / least busy days? Seasonal variances?",
"Most difficult, commonly occurring calls? Easiest commonly "
"occurring call?",
),
),
SubTopic(
"outbound", "Outbound",
(
"Hours of operation.",
"Voice (preview / predictive dialer volume), email, SMS, "
"recorded announcement ports.",
"Self-service applications, live agent connect, campaign "
"management, DNC management.",
"Top 35 outbound contact reasons and approximate % of "
"calls.",
"After-call work?",
),
),
),
),
Topic(
key="agent_environment",
title="Agent & Supervisor Environment",
scope="Locations, endpoints, desktop, applications, knowledge",
minutes=10,
subtopics=(
SubTopic(
"locations_endpoints", "Locations & endpoints",
(
"Location types — WFH / offices.",
"Hard phone, soft phone, CODEC, wireless headset?",
"Agent greeting / pre-recorded messages, whisper "
"announcement.",
"PC — desktop, laptop, VDI.",
),
),
SubTopic(
"desktop_apps", "Desktop & applications",
(
"Agent & supervisor desktop — omnichannel? Custom gadgets, "
"screen pops, workflows.",
"Standard browser? SSO?",
"Applications used to handle calls.",
"Knowledge Management.",
),
),
),
),
Topic(
key="routing_automation",
title="Routing & Automation",
scope="Self-service, IVR, speech, virtual agents, agent assist, RPA",
minutes=15,
subtopics=(
SubTopic(
"self_service", "Self-service & IVR",
(
"IVR persona — branding, style guides, voice actors?",
"DTMF and/or speech? Speech recognition, TTS, NLU, voice "
"biometrics.",
"Self-service applications — ID & validate, deflection, "
"situational offer (outage / time of day / scheduled).",
"API integration (CRM). Intent capture, intent prediction, "
"offer push on prediction / account attribute.",
),
),
SubTopic(
"virtual_agents", "Virtual agents & assist",
(
"Virtual agents — which processes?",
"Agent assist?",
"RPA?",
"Current challenges or desired capabilities. Desire to "
"automate. DevOps team?",
),
),
SubTopic(
"digital_assistants", "Digital assistant apps",
(
"Alexa, Google Assistant, Siri?",
),
),
),
),
Topic(
key="workforce_engagement",
title="Workforce Engagement",
scope="Call recording / QM, WFM, agent self-service, integrations",
minutes=20,
subtopics=(
SubTopic(
"recording_qm", "Call recording & Quality Management",
(
"How is call recording used? Compliance, screen capture, "
"voice transcription (real-time?), desktop analytics, "
"retention period.",
"Quality team — who do they report to, how many people? "
"Scorecards, score method & metrics, number of assessments, "
"coaching, live monitor.",
"Locations with call recording. Number of named agents.",
),
),
SubTopic(
"workforce_management", "Workforce management",
(
"Recruitment process, required qualifications, average "
"tenure, attrition rate, internal moves.",
"Do you measure agent & supervisor experience? Recognition "
"program?",
"Adherence measure — KPIs, gamification, shift length, "
"breaks, annualized utilization %.",
"Forecasting & scheduling — peak volume, historical data, "
"algorithms, WFM interval (15/30 min).",
"Intraday / real-time adherence — what happens when out of "
"compliance?",
"Payroll integration (ADP / Workday). Satisfaction with "
"current tool(s)? Multiskilled agents, FTE calculations.",
),
),
SubTopic(
"agent_self_service", "Agent self-service & integrations",
(
"Agent self-service — absences, shift bids / swaps, "
"performance metrics.",
"Number of named agents.",
"Integration to 3rd party — outsourcer / overflow, payroll.",
),
),
),
),
Topic(
key="training",
title="Training",
scope="Onboarding, format, assessment, eLearning, QM integration",
minutes=5,
subtopics=(
SubTopic(
"onboarding", "Onboarding & enablement",
(
"Onboarding process — how long, training format, "
"assessment?",
"eLearning?",
"QM integration?",
),
),
),
),
Topic(
key="reporting_insights",
title="Reporting & Insights",
scope="Real-time & historical reporting, analytics, BI, CRM",
minutes=10,
subtopics=(
SubTopic(
"reporting", "Reporting",
(
"Real-time and historical — what are the key metrics you "
"report on?",
"Data source integration, dashboards, wallboards, agent & "
"supervisor status.",
),
),
SubTopic(
"analytics_insights", "Analytics & insights",
(
"Integrated view of customer details and contact history? "
"CRM?",
"Does the contact centre collect and use customer insight? "
"Predictive engagement?",
"What data analysis do you perform? Analytics / BI team — how "
"many people? BI platform?",
"Executive-level reporting? Marketing team interlock?",
),
),
),
),
)
# ── Lookups ──────────────────────────────────────────────────────────
TOPIC_BY_KEY: dict[str, Topic] = {t.key: t for t in TOPICS}
TOPIC_KEYS: tuple[str, ...] = tuple(t.key for t in TOPICS)
def topic(key: str) -> Topic:
"""Return the topic with ``key`` (raises ``KeyError`` if unknown)."""
return TOPIC_BY_KEY[key]

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,7 @@ dependencies = [
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"nbformat>=5.9",
"tabulate>=0.9",
]

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()

View File

@@ -1,7 +1,52 @@
"""Make discoverylib importable even without the study venv active (the
normal setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
"""Test plumbing: import path + the topic bank served from the notebook.
The bank is CONTENT and lives in the notebook — the cell tagged
``topic-bank`` in ``notebooks/cx_discovery.ipynb``; ``.py`` files hold code
only. The fixtures below read that cell with nbformat and exec it, so
pytest pins the exact content the deliverable ships (no kernel needed —
the cell is self-contained by contract).
The sys.path insert makes discoverylib importable even without the study
venv active (the normal setup is ``pip install -e ".[dev]"`` into the
study-local ``.venv/``).
"""
from __future__ import annotations
import pathlib
import sys
from typing import Any
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import pytest
STUDY_ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(STUDY_ROOT))
NOTEBOOK = STUDY_ROOT / "notebooks" / "cx_discovery.ipynb"
@pytest.fixture(scope="session")
def topic_bank() -> dict[str, Any]:
"""The executed namespace of the notebook's topic-bank cell."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells if "topic-bank" in c.metadata.get("tags", [])]
assert len(cells) == 1, (
f"expected exactly one cell tagged 'topic-bank' in {NOTEBOOK.name}, "
f"found {len(cells)}"
)
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [topic-bank]", "exec"), ns)
return ns
@pytest.fixture(scope="session")
def topics(topic_bank: dict[str, Any]) -> tuple[Any, ...]:
"""The TOPICS tuple as the deliverable defines it."""
return topic_bank["TOPICS"] # type: ignore[no-any-return]
@pytest.fixture(scope="session")
def topic_by_key(topics: tuple[Any, ...]) -> dict[str, Any]:
return {t.key: t for t in topics}

View File

@@ -1,7 +1,9 @@
"""Session engine pins — status, progress, checklist, and export payload.
Progress and status are this study's "numbers"; these are the hand-checked
acceptance values the in-notebook gate re-pins (Pattern §4).
acceptance values the in-notebook gate re-pins (Pattern §4). Every engine
function takes the bank as its first argument; the ``topics`` fixture
supplies the real bank from the notebook's topic-bank cell.
"""
from discoverylib import (
@@ -9,7 +11,6 @@ from discoverylib import (
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
TOPIC_KEYS,
active_topic_key,
build_session,
normalize_status,
@@ -17,7 +18,6 @@ from discoverylib import (
session_json,
subtopic_checklist,
subtopic_id,
topic,
)
@@ -27,8 +27,8 @@ def test_normalize_status_defaults_unknown():
assert normalize_status("garbage") == NOT_STARTED
def test_progress_all_not_started():
p = progress({})
def test_progress_all_not_started(topics):
p = progress(topics, {})
assert p.total == 8
assert (p.completed, p.skipped, p.in_progress, p.not_started) == (0, 0, 0, 8)
assert p.fraction == 0.0
@@ -36,7 +36,7 @@ def test_progress_all_not_started():
assert p.label == "0/8 topics complete"
def test_progress_mixed_counts_and_fraction():
def test_progress_mixed_counts_and_fraction(topics):
status = {
"background": COMPLETE,
"cx_strategy": COMPLETE,
@@ -44,25 +44,25 @@ def test_progress_mixed_counts_and_fraction():
"agent_environment": SKIPPED,
# remaining four default to not_started
}
p = progress(status)
p = progress(topics, status)
assert (p.completed, p.skipped, p.in_progress, p.not_started) == (2, 1, 1, 4)
assert p.resolved == 3 # complete + skipped
assert p.fraction == 2 / 8
assert p.label == "2/8 topics complete"
def test_active_topic_prefers_in_progress_then_first_open():
def test_active_topic_prefers_in_progress_then_first_open(topics):
# in-progress wins even if a later topic is also in progress
assert active_topic_key({"channels": IN_PROGRESS}) == "channels"
assert active_topic_key(topics, {"channels": IN_PROGRESS}) == "channels"
# no in-progress → first not-started in canonical order
assert active_topic_key({"background": COMPLETE}) == "cx_strategy"
assert active_topic_key(topics, {"background": COMPLETE}) == "cx_strategy"
# everything resolved → None
all_done = {k: COMPLETE for k in TOPIC_KEYS}
assert active_topic_key(all_done) is None
all_done = {t.key: COMPLETE for t in topics}
assert active_topic_key(topics, all_done) is None
def test_subtopic_checklist_marks_done():
t = topic("channels")
def test_subtopic_checklist_marks_done(topic_by_key):
t = topic_by_key["channels"]
done = {subtopic_id("channels", "voice_metrics")}
items = subtopic_checklist(t, done)
assert len(items) == len(t.subtopics)
@@ -71,12 +71,12 @@ def test_subtopic_checklist_marks_done():
assert by_key["inbound_context"] is False
def test_build_session_shape_and_subtopic_done_count():
def test_build_session_shape_and_subtopic_done_count(topics, topic_by_key):
status = {"channels": IN_PROGRESS, "background": COMPLETE}
notes = {"channels": " 6 channels; voice ~70% "}
done = {subtopic_id("channels", "voice_metrics"),
subtopic_id("channels", "outbound")}
s = build_session(status, notes, done)
s = build_session(topics, status, notes, done)
assert len(s["topics"]) == 8
assert s["active_topic_key"] == "channels"
@@ -86,18 +86,18 @@ def test_build_session_shape_and_subtopic_done_count():
ch = next(ts for ts in s["topics"] if ts.key == "channels")
assert ch.status == IN_PROGRESS
assert ch.subtopics_done == 2
assert ch.subtopics_total == len(topic("channels").subtopics)
assert ch.subtopics_total == len(topic_by_key["channels"].subtopics)
assert ch.notes == "6 channels; voice ~70%" # trimmed
bg = next(ts for ts in s["topics"] if ts.key == "background")
assert bg.status == COMPLETE and bg.subtopics_done == 0
def test_session_json_is_plain_and_complete():
def test_session_json_is_plain_and_complete(topics):
status = {"background": COMPLETE, "channels": IN_PROGRESS}
notes = {"background": "3 LOBs; PCI in scope"}
done = {subtopic_id("channels", "voice_metrics")}
payload = session_json(build_session(status, notes, done),
payload = session_json(build_session(topics, status, notes, done),
meta={"client": "Acme", "date": "2026-07-19"})
assert payload["study"] == "202607_CX_Discovery_Workshop"

View File

@@ -1,18 +1,20 @@
"""Topic bank integrity — the verbatim facilitation record.
These pins are the anchor guard (Pattern §4): keys are stable identities
that captured notes and the JSON export key off, so renaming or reordering a
topic must be a deliberate, test-breaking act — never a silent drift.
The bank lives in the notebook's ``topic-bank`` cell (content is edited in
Jupyter, never in ``.py``); the ``topics`` fixture execs that cell, so
these pins guard the exact content the deliverable ships. Keys are stable
identities that captured notes and the JSON export key off, so renaming or
reordering a topic must be a deliberate, test-breaking act — never a
silent drift.
"""
from discoverylib import TOPICS, TOPIC_KEYS, topic
from discoverylib.topics import TOPIC_BY_KEY
from discoverylib import Topic, agenda_minutes
# ── Hand-checked shape (recount if you add/remove content) ───────────
def test_topic_count_and_order():
assert len(TOPICS) == 8
assert TOPIC_KEYS == (
def test_topic_count_and_order(topics):
assert len(topics) == 8
assert tuple(t.key for t in topics) == (
"background",
"cx_strategy",
"channels",
@@ -24,22 +26,27 @@ def test_topic_count_and_order():
)
def test_subtopic_and_prompt_totals():
assert sum(len(t.subtopics) for t in TOPICS) == 26
assert sum(t.prompt_count for t in TOPICS) == 95
def test_subtopic_and_prompt_totals(topics):
assert sum(len(t.subtopics) for t in topics) == 26
assert sum(t.prompt_count for t in topics) == 95
def test_agenda_minutes_sum():
from discoverylib import agenda_minutes
assert agenda_minutes() == 110
assert agenda_minutes() == sum(t.minutes for t in TOPICS)
def test_agenda_minutes_sum(topics):
assert agenda_minutes(topics) == 110
assert agenda_minutes(topics) == sum(t.minutes for t in topics)
def test_bank_uses_library_schema(topics):
# The cell must build on discoverylib's dataclasses, not ad-hoc types,
# or the engine's TopicState/export contracts silently stop applying.
assert all(isinstance(t, Topic) for t in topics)
# ── Structural invariants — hold for every topic ─────────────────────
def test_keys_unique_and_wellformed():
keys = [t.key for t in TOPICS]
def test_keys_unique_and_wellformed(topics):
keys = [t.key for t in topics]
assert len(keys) == len(set(keys)) # unique
for t in TOPICS:
for t in topics:
assert t.key.replace("_", "").isalnum() # snake_case slug
assert t.title and t.scope # client-facing text present
assert t.minutes > 0
@@ -50,13 +57,13 @@ def test_keys_unique_and_wellformed():
assert st.prompts # no empty sub-topic
def test_lookup_helpers():
assert topic("channels").title == "Channels"
assert TOPIC_BY_KEY["training"].minutes == 5
assert TOPICS[0].key == "background"
def test_lookup(topics, topic_by_key):
assert topic_by_key["channels"].title == "Channels"
assert topic_by_key["training"].minutes == 5
assert topics[0].key == "background"
def test_no_double_dollar_or_raw_markup_in_scope():
def test_no_double_dollar_or_raw_markup_in_scope(topics):
# scope lines render on stage HTML — keep them plain text.
for t in TOPICS:
for t in topics:
assert "<" not in t.scope and ">" not in t.scope