feat: add master notebook library scaffolding and review tooling

Add CLAUDE.md defining the Palladium master notebook conventions and
Red Panda Approval criteria, plus a review-notebook slash command for
LLM-driven notebook review.

Expand .gitignore to block client/engagement documents and generated
exports, keeping masters client-clean while allowing text/image sources.

Normalize slider widget numeric values from floats to integers in
notebook JSON.
This commit is contained in:
2026-07-31 16:16:07 +00:00
parent 53c069fddb
commit a967f73d09
61 changed files with 4881 additions and 4257 deletions

View File

@@ -0,0 +1,116 @@
# CX Exploration & Discovery Workshop
An **Assessment** — a reusable workshop master (see
[`docs/Assessment_Pattern_V1-00.md`](../../docs/Assessment_Pattern_V1-00.md))
— and a **live facilitation aid** for a CX discovery session, built on the
[Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md).
Unlike the TEI business-case studies, this deliverable computes no dollars —
its "numbers" are **topic status and progress**. The Mercury stage is the
visual you share on a call or in a workshop while you work through the
questions; the question script and captured notes live backstage and export
as LLM input for the survey write-up or a downstream business case.
## What the client sees (the stage)
A calm **topic board**: each topic, its one-line scope, a status glyph, and —
for the topic under discussion — a **live sub-topic checklist**, above a
**progress bar** ("3/8 topics complete"). No wall of questions: you ask those.
![board preview](docs/board_preview.png)
You drive it from the sidebar — a **status** selector and a **notes** box per
topic, plus a **checkbox** per sub-topic. As the conversation moves you set a
topic *In progress*, tick sub-topics as you cover them, mark it *Complete* (or
*Skipped*), and jot answers. Every change re-renders the board and progress bar
(Mercury re-runs the cells below the widgets).
## What you work from (backstage)
JupyterLab and the exports carry the **facilitator question script** (all 95
prompts, grouped by topic → sub-topic) and the **captured-session appendix**
(status + notes as a markdown table and one JSON block). Neither shows on the
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
Two tagged cells of
[`notebooks/cx_discovery.ipynb`](notebooks/cx_discovery.ipynb) carry
everything a consultant edits — **in Jupyter, never in a `.py` file**:
- **`topic-bank`** — the workshop content: every topic, sub-topic, and
facilitator prompt. 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.
- **`engagement-data`** — the client facts (client, workshop date,
facilitator, attendees). **Placeholders in this master**; filled in the
engagement copy. The values flow to the stage board header, the export
preamble, and the JSON appendix `meta`.
`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 cells straight
out of the notebook (no kernel) and pins content shape, so `pytest` guards
the bank exactly as shipped.
## Layout
```
notebooks/cx_discovery.ipynb # THE deliverable — content + data + presentation
# tagged cells: topic-bank · engagement-data ·
# presentation ×4 · gate · data-appendix
# stage shows ONLY: title · progress line · board
discoverylib/ # the engine — code only, no content
session.py # Topic/SubTopic schema, status vocabulary, progress, export payload
staging.py # stage/backstage detection (+ backstage_md for the appendix)
scripts/export_report.py # execute once → exports/*.html + LLM-ready *.md
tests/ # content + engagement pins (read from the notebook), engine pins, staging
docs/cx_discovery_survey.md # source survey (the original cxxm.md)
exports/ # generated report sources (never committed)
```
## Run
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
mercury --working-dir . # serve the stage (share this screen)
jupyter lab # analyst / facilitator view
pytest # content + engagement + engine + staging pins
jupyter nbconvert --to notebook --execute --inplace notebooks/cx_discovery.ipynb # gate
python scripts/export_report.py # exports/*.html + *.md for the LLM handoff
```
The `.md` export opens with a generated preamble (what the document is, the
engagement line, how to read it) and ends with the **Session state (JSON)**
block — the machine source of truth for the write-up. Presentation cells
(setup, the facilitator run-book, widgets, board) are stripped from it; the
HTML export keeps the full presentation for human review.
## Extending
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`.
**Running this for a client?** Don't fill client data into this master —
follow the copy-out checklist in
[`docs/Assessment_Pattern_V1-00.md`](../../docs/Assessment_Pattern_V1-00.md):
copy the directory out of Palladium, stamp it `YYYYMM_Client_CX_Discovery`,
fill the `engagement-data` cell there, and treat the copy as confidential.

View File

@@ -0,0 +1,79 @@
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
# See docs/brand.md for the source palette. Loaded from the directory where
# you launch `mercury` (this study root); restart the server to apply.
[main]
title = "CX Discovery Workshop"
favicon_emoji = "🧭"
footer = "CX Exploration & Discovery Workshop · NTT DATA"
notebooks_button_label = "Workshops"
[welcome]
header = "CX Discovery Workshop"
message = """
The live visual for a CX exploration & discovery session — open
**cx_discovery.ipynb**. Topics and progress render on screen for the
client; the facilitator drives the question script and captures notes
backstage. Mark each topic's status in the sidebar as the conversation
moves — the board and the progress bar update live. Afterward, export the
captured notes for drafting with `python scripts/export_report.py`.
"""
[theme]
# ── Type — Georgia headings, Arial body (web-safe; no network fetch). ──
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
font_size = "15px"
font_weight = "normal"
heading_font_weight = "700"
# ── Text — NTT ink scale ──
text_color = "#2e404d"
muted_text_color = "#586671"
# ── Surfaces — white content on a soft neutral canvas ──
background_color = "#f4f5f6"
content_background_color = "#ffffff"
surface_color = "#ffffff"
card_background_color = "#f8f8f8"
border_color = "#d5d9db"
border_radius = "10px"
# ── Accents — Future Blue ──
primary_color = "#0072bc"
accent_color = "#0072bc"
focus_border_color = "#0072bc"
hover_background_color = "#eef5fb"
selected_background_color = "#dcecfa"
# ── State colors — brand Success / Warning / Error (docs/brand.md) ──
success_color = "#00cb5d"
warning_color = "#ffc400"
danger_color = "#e42600"
slider_track_color = "#e2e6e9"
# ── Sidebar — clean white, hairline divider ──
sidebar_background_color = "#ffffff"
sidebar_text_color = "#2e404d"
sidebar_title_color = "#151d2c"
sidebar_shadow = "1px 0 0 #d5d9db"
# ── Top bar — deep NTT navy ──
topbar_background_color = "#151d2c"
topbar_text_color = "#ffffff"
topbar_border_color = "rgba(255,255,255,0.08)"
# ── Footer ──
footer_background_color = "#ffffff"
footer_text_color = "#586671"
footer_border_color = "#d5d9db"
# ── Run button — subtle brand-blue gradient ──
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
run_button_text_color = "#ffffff"
# ── Depth — soft, navy-tinted shadows ──
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"

View File

@@ -0,0 +1,59 @@
"""
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.
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 .session import (
COMPLETE,
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
STATUS_COLOR,
STATUS_GLYPH,
STATUS_LABEL,
STATUSES,
ChecklistItem,
Progress,
SubTopic,
Topic,
TopicState,
active_topic_key,
agenda_minutes,
build_session,
normalize_status,
progress,
session_json,
subtopic_checklist,
subtopic_id,
)
from .staging import backstage, backstage_md, on_stage
__version__ = "0.3.0"
__all__ = [
# schema
"Topic", "SubTopic",
# 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", "backstage_md",
]

View File

@@ -0,0 +1,291 @@
"""
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 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,
* :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 ``(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.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ── 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 ────────────────────────────────────────────────
# 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(
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:
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(
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:
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(topics: tuple[Topic, ...]) -> 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
# The covered sub-topic KEYS in canonical bank order — the export must
# say WHICH threads were discussed, not just how many.
subtopics_covered: tuple[str, ...] = ()
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 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:
status = normalize_status(status_by_topic.get(t.key))
covered = tuple(
st.key 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=len(covered),
subtopics_covered=covered,
)
)
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": by_key[active] if active else None,
"agenda_minutes": agenda_minutes(topics),
}
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 {
"assessment": "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,
"subtopics_covered": list(ts.subtopics_covered),
"notes": ts.notes,
}
for ts in session["topics"]
],
}

View File

@@ -0,0 +1,48 @@
"""
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
from typing import Any
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: object, **kwargs: Any) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)
def backstage_md(text: str) -> None:
"""Markdown that renders only backstage (JupyterLab, nbconvert).
Emitted as a ``text/markdown`` display, so nbconvert's markdown export
carries it verbatim — a stream ``print`` would be indented as a code
block. Falls back to ``print`` when IPython isn't importable (plain
pytest), where ``display`` itself already degrades to ``print``.
"""
if on_stage():
return
try:
from IPython.display import Markdown, display
except ImportError:
print(text)
return
display(Markdown(text)) # type: ignore[no-untyped-call]

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,453 @@
Contact Center Exploration & Discovery Workshops
The Contact Center Discovery and Exploration workshops allow us to gain a clear perspective of your contact center operations. The discovery workshop is focused on developing an understanding of the current state architecture and IT support.
Ive attached a contact center survey to collect information and context in advance of the workshop and is also useful to develop business cases.
# CX Exploration
## Background
How many distinct lines of business are supported in the contact centre?
(eg, commercial, residential, retail, wholesale, etc)
Are you using contact center for internal Uses such as help desk, finance, HR, etc.?
What channels do you support?
Apps
Voice
Video
Chat
SMS
Email
Social Media
Digital Assistants
Payment card/PCI?
What are the availability SLAs or targets for your technology platform?
Mini org chart
Reporting structure
- Executive leader
What is the structure of decision making in their organization? Centralized or decentralized?
- Who are the key decision makers for implementing a plan for changes to the contact centre?
How important is CX & EX to your organization's strategy?
- Is there an executive that is accountable for CX? CXO, CDO?
Of your leading offers, what is the customer's top priority?
Competitive pressures?
Are their pending or recent acquisitions or spinoffs that require changes to their IT infrastructure and services?
What do they view as the problems in their contact centre today and what are the primary causes?
CX / Customer Success Leadership:
Are your contact centers 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 center?
Do you have KPI targets for CX?:
CSAT:
NPS:
CES:
How are you doing?
Is there a VOC program in place?
• ROI/business value known?
• How do customers rate the experience they receive from your organization? 0-5
What are the key features of their corporate culture?
Is their a Voice of the Agent, or Employee satisfaction survey in place?
- How are they doing?
Automation strategy?
Any inflight projects?
- Any CX or EX improvement initiatives?
Decision
Channel management strategy?
Are your customer experiences personalized?
Do you have a clear set of design guidelines for CX?
Personas
Journey Mapping
Tools:
How are processes managed?
## CX Strategy
How is the value of Customer Experience defined within your organization?
- CX is a key BPI, recognized & measured as a financial value
Strategic Value
What revenue is generated by the contact center?
What are the key capabilities / services that are delivered by the contact centre?
Proposition
How are you using CX innovation to create market disruption?
How are competitors using CX to create competitive differentiation?
Organization Structure & Operating Model
Is there an executive that is accountable for CX? CXO, CDO?
Is there a CX team?
Do the CX insights team regularly educate the business
How are decisions made in the organization, is it centralized or decentralized?
- Who are the key decision makers for investment and changes to the contact centre'?
- Channel management strategy?
Who carries the cost of contact centre agents and supervisors?
Are they managed by the same person?
What are the teams?
How are processes managed?
Describe your automation strategy. Are there any desires or goals related to the contact centre?
- Who is your leader for Data & AI?
Are there any inflight projects that would impact the contact centre?
Are there recent or pending acquisitions or spinoffs?
Insight
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 from your organization? 0-5
Approach
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 for CX?
Personas
Journey Mapping
Tools:
Do you have KPI targets for CX?:
CSAT:
NPS:
CES:
How are you doing?
Employee Engagement
Please describe you about 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?
## Channels
May 6, 2025
2:52 PM
Inbound
Hours of operation
Who contacts the contact center? (Demographics, their situation)
Are certain callers or groups prioritized?
Languages?
English
Canadian French
Spanish
Video
Average number of active agents:
Voice
Average number of active agents:
Toll Free numbers & DIDs:
Approximate quantities or a list
Hours of operation? Any 24x7?
Average Wait Time:
Average Abandon rate:
Average Handle Time:
Average Call Hold Time:
Average After work call time:
Time to authenticate a caller:
% of calls transferred?
Internally?
Externally?
3rd parties handling certain types of calls, or being conferenced in?
Courtesy Callback/Virtual Hold
Post Call Survey
First Call Resolution Rate?
Average revenue per call (sales)?
Email, range of logged in users:
○ Response time:
○ Email server
Chat, range of logged in users:
Web Chat Internal
Web Chat External
App Integration / embedded
SMS
Facebook Messenger
WhatsApp
Telegram
iMessage
Web Site Forms
Mobile Apps
Top 3-5 Inbound contact reasons and approximate % of calls:
1.
Cost Per call?
Average Handle Time?
Busiest days:
Least busy days:
Seasonal variances?
What are your most difficult, commonly occurring calls?
What is the easiest commonly occurring call?
Outbound
Hours of operation
Voice, range of logged in users:
Preview, volume
Predictive dialer
Email, range of logged in users:
SMS, range of logged in users:
Recorded announcement, ports:
Self service applications
Transfer to agent
Live agent connect
Campaign management
DNC management
Top 3-5 outbound contact reasons and approximate % of calls:
1.
After call work?
## Agent & Supervisor Environment
Location types:
- WFH:
- Offices:
Hard phone
Soft phone
CODEC?
Headset, wireless?:
Agent greeting or pre-recorded messages
Whisper announcement
PC
Desktop
Laptop
VDI
Agent & Supervisor desktop (omnichannel?):
Custom gadgets
Screen pops
Workflows
Standard browser:
SSO:
Applications used to handle calls:
Knowledge Management:
## Routing & Automation
Self Service
IVR Persona?
Branding?
Style guides?
Voice actors?
DTMF
Speech
Speech recognition
Text To Speech
Natural Language Understanding
Voice Biometrics
Self service applications:
ID & Validate
Deflection
Situational Offer (Outage/ Time of Day / Scheduled)
API integration:
CRM
Intent Capture
Intent prediction
Offer push based on prediction/account attribute
Virtual Agents
Processes
Agent Assist
RPA
Current challenges or desired capabilities
Process management
Desire to automate
DevOps team?
Digital Assistant Apps
Alexa
Google Assistant
Siri
## Routing & Automation
Self Service
IVR Persona?
Branding?
Style guides?
Voice actors?
DTMF
Speech
Speech recognition
Text To Speech
Natural Language Understanding
Voice Biometrics
Self service applications:
ID & Validate
Deflection
Situational Offer (Outage/ Time of Day / Scheduled)
API integration:
CRM
Intent Capture
Intent prediction
Offer push based on prediction/account attribute
Agent Assist
RPA
Current challenges or desired capabilities
Process management
Desire to automate
DevOps team?
Digital Assistant Apps
Alexa
Google Assistant
Siri
Workforce Engagement
May 6, 2025
2:50 PM
Call Recording/QM
• How is call recording used?
Compliance
Screen capture
Voice transcription
Real time
Desktop analytics
Retention period
Quality Management
Do you have a quality team?
Who do they report to?
How many people work for the team?
Scorecards
Score method and metrics?
How many assessments are conducted?
Coaching
Live Monitor
• Locations with CR
• Number of Named Agents:
Workforce Engagement
• What is your recruitment process?
• What qualifications are required for the call center?
• What is the average staff tenure?
• What is the attrition rate?
• What proportion of the staff leave for other positions in the company per annum?
○ Are staff hired for the CC from other parts of the organization?
• Do you measure agent & supervisor experience?
○ How are they doing?
○ What type of recognition program do you have in place?
• Agent adherence measure?
○ KPIs
○ Gamification
○ How long is a shift?
○ What breaks are allocated
○ What annualized utilization % do you calculate?
• Forecasting & Scheduling
○ Peak call volume days, time of month, year?
○ Validate historical data
○ How are calculations performed (algorithms used)
○ WFM Interval (15/30 minutes)
• Intraday/ Realtime adherence
○ What happens when you are out of compliance, surprises happen?
• Payroll Integration
○ Payroll platform
• Satisfaction with current tool(s)?
○ Multiskilled agents
○ FTE Calculations
• Agent self-service?
○ How are absences reported?
○ Shift bids / swaps
○ Performance metrics
• Number of Named Agents:
• Integration to 3rd party
○ outsourcer / overflow:
○ Payroll: ADP / Workday
Training
• Onboarding process
○ How long is it?
○ Training format?
○ Assessment?
eLearning
QM Integration
## Reporting & Insights
Reporting
• Real time
• Historical
• What are the key metrics that you report on?
Data source integration
Dashboards
Wallboards
Agent & Supervisor status
Analytics & Insights
Do you have an integrated view of customer details and contact history?
CRM:
Does your contact centre collect and use customer insight?
Predictive engagement:
What sort of data analysis do you perform?
Do you have an analytics or business insights team?
How many people?
BI Platform:
What executive level reporting do you perform?
Marketing Team interlock

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "discoverylib"
version = "0.3.0"
description = "CX Exploration & Discovery Workshop — live facilitation aid (Mercury Notebook Pattern)"
requires-python = ">=3.10"
# The notebook is the deliverable (served with Mercury, exported via
# nbconvert, tables via tabulate) — the whole toolchain is a required
# runtime dependency, not an extra. `pip install -e .` must be enough.
dependencies = [
"pandas>=2.0",
"plotly>=5.18",
"mercury>=3.2",
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"nbformat>=5.9",
"tabulate>=0.9",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
include = ["discoverylib*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
strict = true
packages = ["discoverylib"]

View File

@@ -0,0 +1,123 @@
"""Export the discovery notebook as an LLM-readable report source.
Executes the notebook ONCE (widget defaults — or whatever you've captured
and saved in the notebook), then converts the executed copy twice:
exports/cx_discovery.html — human-reviewable, full presentation
exports/cx_discovery.md — leanest LLM input: presentation-tagged cells
(setup, widgets, board) are stripped, a
framing preamble is prepended, and the data
appendix ends the file with one fenced JSON
block — the machine source of truth.
Engagement identity (client, date, facilitator) is read from the notebook's
``engagement-data`` cell and stamped into the preamble.
Run from the study root: python scripts/export_report.py
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
NOTEBOOK = ROOT / "notebooks" / "cx_discovery.ipynb"
EXPORTS = ROOT / "exports"
# Cells tagged with any of these never reach the .md export — they are
# stage presentation (source and widget-repr noise), not session record.
STRIP_TAGS_FROM_MD = '{"presentation"}'
def engagement_data() -> dict[str, Any]:
"""Exec the engagement-data cell (same trick as tests/conftest.py)."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells
if "engagement-data" in c.metadata.get("tags", [])]
assert len(cells) == 1, "expected exactly one engagement-data cell"
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [engagement-data]", "exec"), ns)
return ns["ENGAGEMENT"] # type: ignore[no-any-return]
def preamble() -> str:
eng = engagement_data()
who = " · ".join(str(eng[k]).strip()
for k in ("client", "workshop_date", "facilitator")
if str(eng.get(k, "")).strip())
attendees = ", ".join(str(a) for a in eng.get("attendees", ()) or ())
if who:
line = who + (f" — attendees: {attendees}" if attendees else "")
else:
line = "master copy — placeholders; no engagement captured."
return "\n".join([
"<!-- Export preamble — generated by scripts/export_report.py -->",
"**What this is** — the exported record of a CX Exploration & Discovery",
"workshop session, produced from the Mercury-served notebook that ran the",
"session. It is LLM input for drafting the survey write-up or seeding a",
"business case.",
"",
f"**Engagement** — {line}",
"",
"**How to read it** — first the workshop content as annotated source (the",
"topic bank, then the engagement data), then the facilitation script",
"(every prompt, grouped by topic and sub-topic), the verification gate,",
"and finally the captured-session record: the per-topic table (status,",
"covered sub-topics, notes) and — last — **Session state (JSON)**, one",
"fenced `json` block. Where prose and JSON disagree, the JSON block is",
"the source of truth.",
"",
"---",
"",
"",
])
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] not in NOTEBOOK.name:
sys.exit(f"no notebook matches {sys.argv[1]!r}")
EXPORTS.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
executed = Path(tmp) / NOTEBOOK.name
# 1. Execute once — both formats convert the same session state.
# (Never combine --execute with TagRemovePreprocessor in one call:
# the cell could be stripped before it runs.)
subprocess.run(
[sys.executable, "-m", "nbconvert", "--execute",
"--to", "notebook", "--output", str(executed), str(NOTEBOOK)],
check=True, cwd=ROOT,
)
# 2. HTML — full presentation, human review artifact.
subprocess.run(
[sys.executable, "-m", "nbconvert", "--to", "html",
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
str(executed)],
check=True, cwd=ROOT,
)
# 3. Markdown — LLM artifact: strip presentation cells.
subprocess.run(
[sys.executable, "-m", "nbconvert", "--to", "markdown",
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
"--TagRemovePreprocessor.enabled=True",
f"--TagRemovePreprocessor.remove_cell_tags={STRIP_TAGS_FROM_MD}",
str(executed)],
check=True, cwd=ROOT,
)
# 4. Prepend the framing preamble to the markdown export.
md = EXPORTS / f"{NOTEBOOK.stem}.md"
md.write_text(preamble() + md.read_text(encoding="utf-8"), encoding="utf-8")
for p in sorted(EXPORTS.iterdir()):
if p.suffix in (".html", ".md"):
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
"""Test plumbing: import path + notebook content served from tagged cells.
Content and client data live in the notebook, never in ``.py`` — the cells
tagged ``topic-bank`` and ``engagement-data`` in
``notebooks/cx_discovery.ipynb``. The fixtures below read those cells with
nbformat and exec them, so pytest pins the exact content the deliverable
ships (no kernel needed — tagged cells are 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
import pytest
STUDY_ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(STUDY_ROOT))
NOTEBOOK = STUDY_ROOT / "notebooks" / "cx_discovery.ipynb"
def tagged_cell_ns(tag: str) -> dict[str, Any]:
"""Exec the single cell carrying ``tag`` and return its namespace."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells if tag in c.metadata.get("tags", [])]
assert len(cells) == 1, (
f"expected exactly one cell tagged {tag!r} in {NOTEBOOK.name}, "
f"found {len(cells)}"
)
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [{tag}]", "exec"), ns)
return ns
@pytest.fixture(scope="session")
def topic_bank() -> dict[str, Any]:
"""The executed namespace of the notebook's topic-bank cell."""
return tagged_cell_ns("topic-bank")
@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}
@pytest.fixture(scope="session")
def engagement() -> dict[str, Any]:
"""The ENGAGEMENT dict as the deliverable's engagement-data cell ships it."""
return tagged_cell_ns("engagement-data")["ENGAGEMENT"] # type: ignore[no-any-return]

View File

@@ -0,0 +1,44 @@
"""Engagement-data cell — shape pins for the client-facts cell.
The cell (tagged ``engagement-data``) carries client-specific session facts
and lives in the notebook, never in ``.py``. The MASTER ships placeholders;
an engagement copy ships real values — both must stay green, so these pins
check SHAPE only and never assert emptiness (or any particular value).
"""
import json
import pathlib
from discoverylib import build_session, session_json
NOTEBOOK = (
pathlib.Path(__file__).resolve().parent.parent / "notebooks" / "cx_discovery.ipynb"
)
def test_keys_and_types(engagement):
assert set(engagement) == {"client", "workshop_date", "facilitator", "attendees"}
for key in ("client", "workshop_date", "facilitator"):
assert isinstance(engagement[key], str)
assert all(isinstance(a, str) for a in engagement["attendees"])
def test_flows_into_export_meta(topics, engagement):
payload = session_json(build_session(topics, {}), meta=engagement)
assert payload["meta"]["client"] == engagement["client"]
assert payload["assessment"] == "CX_Discovery_Workshop"
json.dumps(payload) # the attendees tuple serializes as a JSON list
def test_cell_sits_above_the_widgets():
# Position rule: engagement data must never re-run on a sidebar change,
# so its cell precedes the widget-defining cell (Mercury re-runs only
# cells BELOW a changed widget's cell).
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
eng = next(i for i, c in enumerate(nb.cells)
if "engagement-data" in c.metadata.get("tags", []))
widgets = next(i for i, c in enumerate(nb.cells)
if c.cell_type == "code" and "mr.Select(" in c.source)
assert eng < widgets, "engagement-data cell must sit above the widget cell"

View File

@@ -0,0 +1,122 @@
"""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). 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 (
COMPLETE,
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
active_topic_key,
build_session,
normalize_status,
progress,
session_json,
subtopic_checklist,
subtopic_id,
)
def test_normalize_status_defaults_unknown():
assert normalize_status("complete") == COMPLETE
assert normalize_status(None) == NOT_STARTED
assert normalize_status("garbage") == NOT_STARTED
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
assert p.resolved == 0
assert p.label == "0/8 topics complete"
def test_progress_mixed_counts_and_fraction(topics):
status = {
"background": COMPLETE,
"cx_strategy": COMPLETE,
"channels": IN_PROGRESS,
"agent_environment": SKIPPED,
# remaining four default to not_started
}
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(topics):
# in-progress wins even if a later topic is also in progress
assert active_topic_key(topics, {"channels": IN_PROGRESS}) == "channels"
# no in-progress → first not-started in canonical order
assert active_topic_key(topics, {"background": COMPLETE}) == "cx_strategy"
# everything resolved → 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(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)
by_key = {i.key: i.done for i in items}
assert by_key["voice_metrics"] is True
assert by_key["inbound_context"] is False
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(topics, status, notes, done)
assert len(s["topics"]) == 8
assert s["active_topic_key"] == "channels"
assert s["active_topic"].title == "Channels"
assert s["agenda_minutes"] == 110
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_by_key["channels"].subtopics)
# WHICH threads were covered, in canonical bank order — the export's
# whole purpose is naming them, not just counting them.
assert ch.subtopics_covered == ("voice_metrics", "outbound")
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
assert bg.subtopics_covered == ()
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(topics, status, notes, done),
meta={"client": "Acme", "date": "2026-07-19"})
assert payload["assessment"] == "CX_Discovery_Workshop"
assert payload["meta"]["client"] == "Acme"
assert payload["progress"]["completed"] == 1
assert payload["progress"]["fraction_complete"] == round(1 / 8, 4)
assert payload["active_topic"] == "channels"
assert len(payload["topics"]) == 8
bg = next(t for t in payload["topics"] if t["key"] == "background")
assert bg["status"] == COMPLETE
assert bg["notes"] == "3 LOBs; PCI in scope"
ch = next(t for t in payload["topics"] if t["key"] == "channels")
assert ch["subtopics_done"] == 1
assert ch["subtopics_covered"] == ["voice_metrics"]
# JSON-serializable (no dataclasses / sets leaked through)
import json
json.dumps(payload)

View File

@@ -0,0 +1,28 @@
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
from discoverylib import staging
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""
def test_backstage_md_renders_only_off_stage(monkeypatch, capsys):
# Off stage it must emit SOMETHING (rich markdown under a kernel;
# IPython's display degrades to print under plain pytest) …
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
staging.backstage_md("**visible**")
assert capsys.readouterr().out != ""
# … and on stage, nothing at all.
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
staging.backstage_md("**hidden**")
assert capsys.readouterr().out == ""

View File

@@ -0,0 +1,69 @@
"""Topic bank integrity — the verbatim facilitation record.
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 Topic, agenda_minutes
# ── Hand-checked shape (recount if you add/remove content) ───────────
def test_topic_count_and_order(topics):
assert len(topics) == 8
assert tuple(t.key for t in topics) == (
"background",
"cx_strategy",
"channels",
"agent_environment",
"routing_automation",
"workforce_engagement",
"training",
"reporting_insights",
)
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(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(topics):
keys = [t.key for t in topics]
assert len(keys) == len(set(keys)) # unique
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
assert t.subtopics # no empty topic
sub_keys = [st.key for st in t.subtopics]
assert len(sub_keys) == len(set(sub_keys)) # unique within topic
for st in t.subtopics:
assert st.prompts # no empty sub-topic
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(topics):
# scope lines render on stage HTML — keep them plain text.
for t in topics:
assert "<" not in t.scope and ">" not in t.scope