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

@@ -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]