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