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,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]