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:
116
assessments/CX_Discovery_Workshop/README.md
Normal file
116
assessments/CX_Discovery_Workshop/README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
79
assessments/CX_Discovery_Workshop/config.toml
Normal file
79
assessments/CX_Discovery_Workshop/config.toml
Normal 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)"
|
||||
59
assessments/CX_Discovery_Workshop/discoverylib/__init__.py
Normal file
59
assessments/CX_Discovery_Workshop/discoverylib/__init__.py
Normal 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",
|
||||
]
|
||||
291
assessments/CX_Discovery_Workshop/discoverylib/session.py
Normal file
291
assessments/CX_Discovery_Workshop/discoverylib/session.py
Normal 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"]
|
||||
],
|
||||
}
|
||||
48
assessments/CX_Discovery_Workshop/discoverylib/staging.py
Normal file
48
assessments/CX_Discovery_Workshop/discoverylib/staging.py
Normal 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]
|
||||
BIN
assessments/CX_Discovery_Workshop/docs/board_preview.png
Normal file
BIN
assessments/CX_Discovery_Workshop/docs/board_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
453
assessments/CX_Discovery_Workshop/docs/cx_discovery_survey.md
Normal file
453
assessments/CX_Discovery_Workshop/docs/cx_discovery_survey.md
Normal 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.
|
||||
|
||||
I’ve 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
|
||||
|
||||
0
assessments/CX_Discovery_Workshop/exports/.gitkeep
Normal file
0
assessments/CX_Discovery_Workshop/exports/.gitkeep
Normal file
5836
assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb
Normal file
5836
assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
assessments/CX_Discovery_Workshop/pyproject.toml
Normal file
36
assessments/CX_Discovery_Workshop/pyproject.toml
Normal 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"]
|
||||
123
assessments/CX_Discovery_Workshop/scripts/export_report.py
Normal file
123
assessments/CX_Discovery_Workshop/scripts/export_report.py
Normal 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()
|
||||
63
assessments/CX_Discovery_Workshop/tests/conftest.py
Normal file
63
assessments/CX_Discovery_Workshop/tests/conftest.py
Normal 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]
|
||||
44
assessments/CX_Discovery_Workshop/tests/test_engagement.py
Normal file
44
assessments/CX_Discovery_Workshop/tests/test_engagement.py
Normal 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"
|
||||
122
assessments/CX_Discovery_Workshop/tests/test_session.py
Normal file
122
assessments/CX_Discovery_Workshop/tests/test_session.py
Normal 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)
|
||||
28
assessments/CX_Discovery_Workshop/tests/test_staging.py
Normal file
28
assessments/CX_Discovery_Workshop/tests/test_staging.py
Normal 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 == ""
|
||||
69
assessments/CX_Discovery_Workshop/tests/test_topics.py
Normal file
69
assessments/CX_Discovery_Workshop/tests/test_topics.py
Normal 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
|
||||
Reference in New Issue
Block a user