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:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (0–5)?",
|
||||
),
|
||||
),
|
||||
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 3–5 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 3–5 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
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"nbformat>=5.9",
|
||||
"tabulate>=0.9",
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user