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