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:
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