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.
118 lines
4.6 KiB
Python
118 lines
4.6 KiB
Python
"""Structural checks for every master notebook in the library.
|
|
|
|
Kernel-free: everything here reads the committed ``.ipynb`` JSON with
|
|
nbformat — no study venv, no execution. Content and engine pins stay in
|
|
each master's own ``tests/`` (run in its venv); this layer guards only
|
|
STRUCTURE: the notebook parses, was executed cleanly top-to-bottom, and
|
|
(for notebook-first masters) carries the tagged-cell taxonomy the
|
|
Assessment Pattern requires.
|
|
|
|
Classification is explicit and non-silent: every notebook on disk must be
|
|
listed in exactly one of NOTEBOOK_FIRST or GRANDFATHERED (a completeness
|
|
test enforces it), so a new master cannot dodge the suite, and every
|
|
exemption carries its reason — grandfathered notebooks run the structural
|
|
tier and skip the notebook-first tier with that reason shown by
|
|
``pytest -rs``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import nbformat
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
# Masters built on the notebook-first content model (Assessment Pattern):
|
|
# full check set, including the tagged-cell taxonomy.
|
|
NOTEBOOK_FIRST = {
|
|
"assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb",
|
|
}
|
|
|
|
# Structural tier only, each with its recorded reason (see CLAUDE.md,
|
|
# Known liabilities). Redesigning one of these to notebook-first means
|
|
# moving it up to NOTEBOOK_FIRST — never deleting it from here silently.
|
|
GRANDFATHERED = {
|
|
"assessments/CX_AI_Diagnostic/notebooks/diagnostic.ipynb":
|
|
"generated notebook; pre-dates the notebook-first model (redesign pending)",
|
|
"studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb":
|
|
"pre-tag TEI master (redesign pending)",
|
|
"studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb":
|
|
"pre-tag TEI master (redesign pending)",
|
|
"studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb":
|
|
"real-client study, frozen (see CLAUDE.md known liabilities)",
|
|
"studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_no_current_state.ipynb":
|
|
"real-client study, frozen (see CLAUDE.md known liabilities)",
|
|
"studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_virtual_agents.ipynb":
|
|
"real-client study, frozen (see CLAUDE.md known liabilities)",
|
|
"studies/202607_CTM_GenesysCX/notebooks/ctm_migration_wfm.ipynb":
|
|
"real-client study, frozen (see CLAUDE.md known liabilities)",
|
|
"studies/202607_CTM_GenesysCX/notebooks/ctm_token_calculator.ipynb":
|
|
"real-client study, frozen (see CLAUDE.md known liabilities)",
|
|
"template/MercuryNotebook/notebooks/business_case.ipynb":
|
|
"template encodes the py-engine model (rework pending)",
|
|
}
|
|
|
|
ALL_CLASSIFIED = sorted(NOTEBOOK_FIRST | set(GRANDFATHERED))
|
|
|
|
# Tags that may appear at most once per notebook.
|
|
UNIQUE_TAGS = ("topic-bank", "engagement-data", "gate", "data-appendix")
|
|
|
|
|
|
def discover() -> list[str]:
|
|
"""Every notebook on disk under the master roots (repo-relative)."""
|
|
found: list[str] = []
|
|
for base in ("studies", "assessments", "template"):
|
|
root = REPO / base
|
|
if not root.is_dir():
|
|
continue
|
|
for p in root.rglob("*.ipynb"):
|
|
if ".ipynb_checkpoints" in p.parts or ".venv" in p.parts:
|
|
continue
|
|
if p.parent.name != "notebooks":
|
|
continue
|
|
found.append(p.relative_to(REPO).as_posix())
|
|
return sorted(found)
|
|
|
|
|
|
def load(rel: str) -> Any:
|
|
return nbformat.read(REPO / rel, as_version=4)
|
|
|
|
|
|
def cell_tags(cell: Any) -> list[str]:
|
|
return list(cell.metadata.get("tags", []))
|
|
|
|
|
|
def cells_tagged(nb: Any, tag: str) -> list[int]:
|
|
return [i for i, c in enumerate(nb.cells) if tag in cell_tags(c)]
|
|
|
|
|
|
def execution_problem(nb: Any) -> str | None:
|
|
"""None if executed cleanly top-to-bottom, else what's wrong.
|
|
|
|
Non-empty code cells must carry integer execution counts, strictly
|
|
increasing 1..N in document order (proof of one clean linear run);
|
|
empty cells may be unexecuted (``None``).
|
|
"""
|
|
prev = 0
|
|
for i, c in enumerate(nb.cells):
|
|
if c.cell_type != "code" or not c.source.strip():
|
|
continue
|
|
ec = c.get("execution_count")
|
|
if not isinstance(ec, int):
|
|
return f"cell {i} has no execution count (notebook not executed?)"
|
|
if ec != prev + 1:
|
|
return f"cell {i} has execution count {ec}, expected {prev + 1}"
|
|
prev = ec
|
|
return None
|
|
|
|
|
|
def error_outputs(nb: Any) -> list[int]:
|
|
return [
|
|
i
|
|
for i, c in enumerate(nb.cells)
|
|
if c.cell_type == "code"
|
|
and any(o.get("output_type") == "error" for o in c.get("outputs", []))
|
|
]
|