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 Mercury stage. The 8 topics / 26 sub-topics / ~110-minute agenda are the
structured form of the source survey. 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 ## Layout
``` ```
discoverylib/ # the engine — all content & logic notebooks/cx_discovery.ipynb # THE deliverable — content (topic-bank cell) + presentation
topics.py # the topic bank (verbatim anchor from the survey) discoverylib/ # the engine — code only, no content
session.py # status vocabulary, progress, checklist, export payload session.py # Topic/SubTopic schema, status vocabulary, progress, export payload
staging.py # stage/backstage detection (copied verbatim) staging.py # stage/backstage detection (copied verbatim)
notebooks/cx_discovery.ipynb # the deliverable (generated — see below) scripts/export_report.py # nbconvert → exports/*.html + *.md
scripts/ tests/ # content pins (read from the notebook) + engine pins + staging test
build_notebook.py # regenerates the notebook from cell sources
export_report.py # nbconvert → exports/*.html + *.md
tests/ # engine pins + stage/backstage test
docs/cx_discovery_survey.md # source survey (the original cxxm.md) docs/cx_discovery_survey.md # source survey (the original cxxm.md)
exports/ # generated report sources 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 ## Run
```bash ```bash
@@ -69,15 +72,23 @@ pip install -e ".[dev]"
mercury --working-dir . # serve the stage (share this screen) mercury --working-dir . # serve the stage (share this screen)
jupyter lab # analyst / facilitator view 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 jupyter nbconvert --to notebook --execute --inplace notebooks/cx_discovery.ipynb # gate
python scripts/export_report.py # exports/*.html + *.md for the LLM handoff python scripts/export_report.py # exports/*.html + *.md for the LLM handoff
``` ```
## Extending ## Extending
New or reshaped discovery content is a `discoverylib/topics.py` edit, a test New or reshaped discovery content is an edit to the notebook's `topic-bank`
pin (`tests/test_topics.py` recounts, `tests/test_session.py` for new logic), cell in JupyterLab, then:
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 1. Re-run the notebook — the in-notebook **gate** recounts the bank
generated from 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 which the data-appendix export hands to an LLM to draft the survey write-up
or feed a downstream business-case study. or feed a downstream business-case study.
Engine/presentation split (Pattern §2): the topic bank and all status / Code/content split: this package holds the **schema and logic only** —
progress logic live here; the notebook only arranges and renders them. :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 ( from .session import (
COMPLETE, COMPLETE,
IN_PROGRESS, IN_PROGRESS,
@@ -33,6 +28,8 @@ from .session import (
STATUSES, STATUSES,
ChecklistItem, ChecklistItem,
Progress, Progress,
SubTopic,
Topic,
TopicState, TopicState,
active_topic_key, active_topic_key,
agenda_minutes, agenda_minutes,
@@ -45,11 +42,11 @@ from .session import (
) )
from .staging import backstage, on_stage from .staging import backstage, on_stage
__version__ = "0.1.0" __version__ = "0.2.0"
__all__ = [ __all__ = [
# topic bank # schema
"TOPICS", "TOPIC_BY_KEY", "TOPIC_KEYS", "Topic", "SubTopic", "topic", "Topic", "SubTopic",
# status vocabulary # status vocabulary
"STATUSES", "STATUS_LABEL", "STATUS_GLYPH", "STATUS_COLOR", "STATUSES", "STATUS_LABEL", "STATUS_GLYPH", "STATUS_COLOR",
"NOT_STARTED", "IN_PROGRESS", "COMPLETE", "SKIPPED", "normalize_status", "NOT_STARTED", "IN_PROGRESS", "COMPLETE", "SKIPPED", "normalize_status",

View File

@@ -2,9 +2,12 @@
Session engine — the discovery workshop's "math". Session engine — the discovery workshop's "math".
There are no dollars here; the quantities a facilitator and client watch are 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 **status** and **progress**. This module owns the schema and all of the
arranges outputs (Mercury Notebook Pattern §2, engine/presentation split): 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, * the topic-status vocabulary (:data:`STATUSES`) and its display glyphs,
* :func:`progress` — the "3 / 8 topics complete" headline and its ratios, * :func:`progress` — the "3 / 8 topics complete" headline and its ratios,
* :func:`subtopic_checklist` — the live tick-list for the active topic, * :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), raw widget inputs (per-topic status + free-text notes),
* :func:`session_json` — the machine-readable export payload (Pattern §5). * :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 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 it. Unknown / missing keys degrade gracefully to "not started" so a
half-filled live session never crashes the render. 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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any 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 ──────────────────────────────────────────────── # ── Status vocabulary ────────────────────────────────────────────────
@@ -94,13 +121,15 @@ class Progress:
return f"{self.completed}/{self.total} topics complete" 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`.""" """Tally topic statuses into the headline :class:`Progress`."""
counts = {s: 0 for s in STATUSES} 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 counts[normalize_status(status_by_topic.get(t.key))] += 1
return Progress( return Progress(
total=len(TOPICS), total=len(topics),
completed=counts[COMPLETE], completed=counts[COMPLETE],
skipped=counts[SKIPPED], skipped=counts[SKIPPED],
in_progress=counts[IN_PROGRESS], 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 topic to spotlight on stage: the first in-progress topic, else
the first not-started one, else None (everything resolved).""" 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: if normalize_status(status_by_topic.get(t.key)) == IN_PROGRESS:
return t.key return t.key
for t in TOPICS: for t in topics:
if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED: if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED:
return t.key return t.key
return None return None
@@ -146,9 +177,9 @@ def subtopic_id(topic_key: str, subtopic_key: str) -> str:
# ── Agenda ─────────────────────────────────────────────────────────── # ── Agenda ───────────────────────────────────────────────────────────
def agenda_minutes() -> int: def agenda_minutes(topics: tuple[Topic, ...]) -> int:
"""Sum of the nominal per-topic minute budgets.""" """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) ───────────────────────────── # ── Full session state (render + export) ─────────────────────────────
@@ -168,19 +199,22 @@ class TopicState:
def build_session( def build_session(
topics: tuple[Topic, ...],
status_by_topic: dict[str, str], status_by_topic: dict[str, str],
notes_by_topic: dict[str, str] | None = None, notes_by_topic: dict[str, str] | None = None,
done_subtopics: set[str] | frozenset[str] | None = None, done_subtopics: set[str] | frozenset[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Assemble the complete session state the notebook renders and exports. """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 {} notes_by_topic = notes_by_topic or {}
done_subtopics = frozenset(done_subtopics or ()) done_subtopics = frozenset(done_subtopics or ())
by_key = {t.key: t for t in topics}
topic_states: list[TopicState] = [] topic_states: list[TopicState] = []
for t in TOPICS: for t in topics:
status = normalize_status(status_by_topic.get(t.key)) status = normalize_status(status_by_topic.get(t.key))
done = sum( done = sum(
1 for st in t.subtopics if subtopic_id(t.key, st.key) in done_subtopics 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) prog = progress(topics, status_by_topic)
active = active_topic_key(status_by_topic) active = active_topic_key(topics, status_by_topic)
return { return {
"topics": topic_states, "topics": topic_states,
"progress": prog, "progress": prog,
"active_topic_key": active, "active_topic_key": active,
"active_topic": TOPIC_BY_KEY[active] if active else None, "active_topic": by_key[active] if active else None,
"agenda_minutes": agenda_minutes(), "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 from __future__ import annotations
import os import os
from typing import Any
def on_stage() -> bool: def on_stage() -> bool:
@@ -23,7 +24,7 @@ def on_stage() -> bool:
return os.getenv("MERCURY_CONFIG_DIR") is not None 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).""" """``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage(): if not on_stage():
print(*args, **kwargs) 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", "jupyterlab>=4.0",
"ipywidgets>=8.0", "ipywidgets>=8.0",
"nbconvert>=7", "nbconvert>=7",
"nbformat>=5.9",
"tabulate>=0.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 """Test plumbing: import path + the topic bank served from the notebook.
normal setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
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 pathlib
import sys 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. """Session engine pins — status, progress, checklist, and export payload.
Progress and status are this study's "numbers"; these are the hand-checked 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 ( from discoverylib import (
@@ -9,7 +11,6 @@ from discoverylib import (
IN_PROGRESS, IN_PROGRESS,
NOT_STARTED, NOT_STARTED,
SKIPPED, SKIPPED,
TOPIC_KEYS,
active_topic_key, active_topic_key,
build_session, build_session,
normalize_status, normalize_status,
@@ -17,7 +18,6 @@ from discoverylib import (
session_json, session_json,
subtopic_checklist, subtopic_checklist,
subtopic_id, subtopic_id,
topic,
) )
@@ -27,8 +27,8 @@ def test_normalize_status_defaults_unknown():
assert normalize_status("garbage") == NOT_STARTED assert normalize_status("garbage") == NOT_STARTED
def test_progress_all_not_started(): def test_progress_all_not_started(topics):
p = progress({}) p = progress(topics, {})
assert p.total == 8 assert p.total == 8
assert (p.completed, p.skipped, p.in_progress, p.not_started) == (0, 0, 0, 8) assert (p.completed, p.skipped, p.in_progress, p.not_started) == (0, 0, 0, 8)
assert p.fraction == 0.0 assert p.fraction == 0.0
@@ -36,7 +36,7 @@ def test_progress_all_not_started():
assert p.label == "0/8 topics complete" assert p.label == "0/8 topics complete"
def test_progress_mixed_counts_and_fraction(): def test_progress_mixed_counts_and_fraction(topics):
status = { status = {
"background": COMPLETE, "background": COMPLETE,
"cx_strategy": COMPLETE, "cx_strategy": COMPLETE,
@@ -44,25 +44,25 @@ def test_progress_mixed_counts_and_fraction():
"agent_environment": SKIPPED, "agent_environment": SKIPPED,
# remaining four default to not_started # 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.completed, p.skipped, p.in_progress, p.not_started) == (2, 1, 1, 4)
assert p.resolved == 3 # complete + skipped assert p.resolved == 3 # complete + skipped
assert p.fraction == 2 / 8 assert p.fraction == 2 / 8
assert p.label == "2/8 topics complete" 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 # 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 # 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 # everything resolved → None
all_done = {k: COMPLETE for k in TOPIC_KEYS} all_done = {t.key: COMPLETE for t in topics}
assert active_topic_key(all_done) is None assert active_topic_key(topics, all_done) is None
def test_subtopic_checklist_marks_done(): def test_subtopic_checklist_marks_done(topic_by_key):
t = topic("channels") t = topic_by_key["channels"]
done = {subtopic_id("channels", "voice_metrics")} done = {subtopic_id("channels", "voice_metrics")}
items = subtopic_checklist(t, done) items = subtopic_checklist(t, done)
assert len(items) == len(t.subtopics) assert len(items) == len(t.subtopics)
@@ -71,12 +71,12 @@ def test_subtopic_checklist_marks_done():
assert by_key["inbound_context"] is False 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} status = {"channels": IN_PROGRESS, "background": COMPLETE}
notes = {"channels": " 6 channels; voice ~70% "} notes = {"channels": " 6 channels; voice ~70% "}
done = {subtopic_id("channels", "voice_metrics"), done = {subtopic_id("channels", "voice_metrics"),
subtopic_id("channels", "outbound")} subtopic_id("channels", "outbound")}
s = build_session(status, notes, done) s = build_session(topics, status, notes, done)
assert len(s["topics"]) == 8 assert len(s["topics"]) == 8
assert s["active_topic_key"] == "channels" 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") ch = next(ts for ts in s["topics"] if ts.key == "channels")
assert ch.status == IN_PROGRESS assert ch.status == IN_PROGRESS
assert ch.subtopics_done == 2 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 assert ch.notes == "6 channels; voice ~70%" # trimmed
bg = next(ts for ts in s["topics"] if ts.key == "background") bg = next(ts for ts in s["topics"] if ts.key == "background")
assert bg.status == COMPLETE and bg.subtopics_done == 0 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} status = {"background": COMPLETE, "channels": IN_PROGRESS}
notes = {"background": "3 LOBs; PCI in scope"} notes = {"background": "3 LOBs; PCI in scope"}
done = {subtopic_id("channels", "voice_metrics")} 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"}) meta={"client": "Acme", "date": "2026-07-19"})
assert payload["study"] == "202607_CX_Discovery_Workshop" assert payload["study"] == "202607_CX_Discovery_Workshop"

View File

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