CX Discovery Notebook

This commit is contained in:
2026-07-23 12:04:37 -04:00
parent cbbc9ba839
commit 71b913d7fe
45 changed files with 23170 additions and 1830 deletions

View File

@@ -0,0 +1,62 @@
"""
discoverylib — self-contained engine for the CX Exploration & Discovery
Workshop (Mercury Notebook Pattern).
Not a financial model: this study's deliverable is a **live facilitation
aid**. The Mercury stage is the visual the client watches on the call —
topic board, the active topic's live sub-topic checklist, and a progress
headline ("3/8 topics complete"). The backstage (JupyterLab / nbconvert)
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.
"""
from .topics import (
TOPIC_BY_KEY,
TOPIC_KEYS,
TOPICS,
SubTopic,
Topic,
topic,
)
from .session import (
COMPLETE,
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
STATUS_COLOR,
STATUS_GLYPH,
STATUS_LABEL,
STATUSES,
ChecklistItem,
Progress,
TopicState,
active_topic_key,
agenda_minutes,
build_session,
normalize_status,
progress,
session_json,
subtopic_checklist,
subtopic_id,
)
from .staging import backstage, on_stage
__version__ = "0.1.0"
__all__ = [
# topic bank
"TOPICS", "TOPIC_BY_KEY", "TOPIC_KEYS", "Topic", "SubTopic", "topic",
# status vocabulary
"STATUSES", "STATUS_LABEL", "STATUS_GLYPH", "STATUS_COLOR",
"NOT_STARTED", "IN_PROGRESS", "COMPLETE", "SKIPPED", "normalize_status",
# progress + session
"Progress", "progress", "active_topic_key", "agenda_minutes",
"ChecklistItem", "subtopic_checklist", "subtopic_id",
"TopicState", "build_session", "session_json",
# staging
"on_stage", "backstage",
]

View File

@@ -0,0 +1,251 @@
"""
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):
* 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,
* :func:`build_session` — assembles the full render/export state from the
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,
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.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .topics import TOPICS, TOPIC_BY_KEY, Topic
# ── Status vocabulary ────────────────────────────────────────────────
# Order matters: it's the selector order and the legend order. "skipped"
# is terminal-but-not-complete (a topic consciously set aside), so it
# counts as resolved for the agenda but not toward completion.
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETE = "complete"
SKIPPED = "skipped"
STATUSES: tuple[str, ...] = (NOT_STARTED, IN_PROGRESS, COMPLETE, SKIPPED)
STATUS_LABEL: dict[str, str] = {
NOT_STARTED: "Not started",
IN_PROGRESS: "In progress",
COMPLETE: "Complete",
SKIPPED: "Skipped",
}
# Stage glyphs — calm, unambiguous at a glance on a shared screen.
STATUS_GLYPH: dict[str, str] = {
NOT_STARTED: "", # open circle — pending
IN_PROGRESS: "", # half — under discussion
COMPLETE: "", # filled — done
SKIPPED: "", # slashed — set aside
}
# Brand-aligned status colors (docs/brand.md). Muted gray pending, Future
# Blue active, Success Green complete, light gray skipped.
STATUS_COLOR: dict[str, str] = {
NOT_STARTED: "#a9b2b8",
IN_PROGRESS: "#0072bc",
COMPLETE: "#00a34c",
SKIPPED: "#c3c7ca",
}
def normalize_status(value: str | None) -> str:
"""Coerce any widget value to a known status; default NOT_STARTED."""
return value if value in STATUS_LABEL else NOT_STARTED
# ── Progress ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Progress:
"""The headline the client watches: completed vs. total topics, plus
the resolved (complete + skipped) count that drives the agenda burn."""
total: int
completed: int # status == complete
skipped: int
in_progress: int
not_started: int
@property
def resolved(self) -> int:
"""Topics no longer open for discussion (complete or skipped)."""
return self.completed + self.skipped
@property
def fraction(self) -> float:
"""Completed / total in [0, 1] — the progress-bar fill."""
return self.completed / self.total if self.total else 0.0
@property
def label(self) -> str:
return f"{self.completed}/{self.total} topics complete"
def progress(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:
counts[normalize_status(status_by_topic.get(t.key))] += 1
return Progress(
total=len(TOPICS),
completed=counts[COMPLETE],
skipped=counts[SKIPPED],
in_progress=counts[IN_PROGRESS],
not_started=counts[NOT_STARTED],
)
def active_topic_key(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:
if normalize_status(status_by_topic.get(t.key)) == IN_PROGRESS:
return t.key
for t in TOPICS:
if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED:
return t.key
return None
# ── Sub-topic checklist (live, for the active topic) ─────────────────
@dataclass(frozen=True)
class ChecklistItem:
key: str
title: str
done: bool
def subtopic_checklist(
topic: Topic, done_subtopics: set[str] | frozenset[str]
) -> list[ChecklistItem]:
"""The active topic's sub-topics as a tick-list. ``done_subtopics``
holds the fully-qualified ``"<topic_key>.<subtopic_key>"`` ids the
facilitator has ticked."""
return [
ChecklistItem(st.key, st.title, f"{topic.key}.{st.key}" in done_subtopics)
for st in topic.subtopics
]
def subtopic_id(topic_key: str, subtopic_key: str) -> str:
"""The fully-qualified id used in ``done_subtopics`` and the export."""
return f"{topic_key}.{subtopic_key}"
# ── Agenda ───────────────────────────────────────────────────────────
def agenda_minutes() -> int:
"""Sum of the nominal per-topic minute budgets."""
return sum(t.minutes for t in TOPICS)
# ── Full session state (render + export) ─────────────────────────────
@dataclass(frozen=True)
class TopicState:
key: str
title: str
scope: str
minutes: int
status: str
status_label: str
glyph: str
color: str
notes: str
subtopics_total: int
subtopics_done: int
def build_session(
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.
"""
notes_by_topic = notes_by_topic or {}
done_subtopics = frozenset(done_subtopics or ())
topic_states: list[TopicState] = []
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
)
topic_states.append(
TopicState(
key=t.key,
title=t.title,
scope=t.scope,
minutes=t.minutes,
status=status,
status_label=STATUS_LABEL[status],
glyph=STATUS_GLYPH[status],
color=STATUS_COLOR[status],
notes=(notes_by_topic.get(t.key) or "").strip(),
subtopics_total=len(t.subtopics),
subtopics_done=done,
)
)
prog = progress(status_by_topic)
active = active_topic_key(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(),
}
def session_json(
session: dict[str, Any], meta: dict[str, Any] | None = None
) -> dict[str, Any]:
"""The machine-readable export payload (Pattern §5): every captured
answer/status/note as plain JSON, ready to feed an LLM drafting the
survey write-up or business case. Plotly/HTML never carry this — the
appendix does."""
prog: Progress = session["progress"]
return {
"study": "202607_CX_Discovery_Workshop",
"instrument": "CX Exploration & Discovery Workshop",
"meta": meta or {},
"progress": {
"total_topics": prog.total,
"completed": prog.completed,
"skipped": prog.skipped,
"in_progress": prog.in_progress,
"not_started": prog.not_started,
"fraction_complete": round(prog.fraction, 4),
"label": prog.label,
},
"agenda_minutes": session["agenda_minutes"],
"active_topic": session["active_topic_key"],
"topics": [
{
"key": ts.key,
"title": ts.title,
"scope": ts.scope,
"status": ts.status,
"minutes": ts.minutes,
"subtopics_done": ts.subtopics_done,
"subtopics_total": ts.subtopics_total,
"notes": ts.notes,
}
for ts in session["topics"]
],
}

View File

@@ -0,0 +1,29 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
into the server process so the widget library can locate ``config.toml``
(see ``mercury/config.py``); every kernel that server spawns inherits it.
JupyterLab and nbconvert kernels don't have it. That makes the variable a
reliable signal for "the audience is looking" (the stage) versus an
analyst session or a headless export run (backstage).
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
and land in the nbconvert exports (where the machine-readable appendix
must appear for LLM consumption) but never render in the Mercury app.
"""
from __future__ import annotations
import os
def on_stage() -> bool:
"""True when running under the Mercury app (stakeholder-facing)."""
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args, **kwargs) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)

View File

@@ -0,0 +1,428 @@
"""
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]