124 lines
5.0 KiB
Python
124 lines
5.0 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",
|
|
"calculators/Genesys_Token_Calculator/notebooks/genesys_token_calculator.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", "calculators", "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
|
|
# Mercury app shadow copies are runtime artifacts, not masters
|
|
# (layout varies by mercury version: .mercury_sessions/ or a
|
|
# __mercury__ suffix beside the notebook).
|
|
if ".mercury_sessions" in p.parts or "__mercury__" in p.name:
|
|
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", []))
|
|
]
|