docs: update workshop README for notebook-first content model

Rewrite the CX Discovery Workshop README to reflect the architecture
shift where content lives in the notebook's `topic-bank` cell rather
than in `discoverylib/topics.py`. The library now holds code only, the
notebook is the deliverable (no longer generated via build_notebook.py),
and tests pin content read directly from the notebook. Update layout,
run, and extending sections accordingly.
This commit is contained in:
2026-07-31 10:24:42 +00:00
parent 71b913d7fe
commit 53c069fddb
11 changed files with 3613 additions and 3906 deletions

View File

@@ -1,7 +1,52 @@
"""Make discoverylib importable even without the study venv active (the
normal setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
"""Test plumbing: import path + the topic bank served from the notebook.
The bank is CONTENT and lives in the notebook — the cell tagged
``topic-bank`` in ``notebooks/cx_discovery.ipynb``; ``.py`` files hold code
only. The fixtures below read that cell with nbformat and exec it, so
pytest pins the exact content the deliverable ships (no kernel needed —
the cell is 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
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import pytest
STUDY_ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(STUDY_ROOT))
NOTEBOOK = STUDY_ROOT / "notebooks" / "cx_discovery.ipynb"
@pytest.fixture(scope="session")
def topic_bank() -> dict[str, Any]:
"""The executed namespace of the notebook's topic-bank cell."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells if "topic-bank" in c.metadata.get("tags", [])]
assert len(cells) == 1, (
f"expected exactly one cell tagged 'topic-bank' in {NOTEBOOK.name}, "
f"found {len(cells)}"
)
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [topic-bank]", "exec"), ns)
return ns
@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}

View File

@@ -1,7 +1,9 @@
"""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).
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 (
@@ -9,7 +11,6 @@ from discoverylib import (
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
TOPIC_KEYS,
active_topic_key,
build_session,
normalize_status,
@@ -17,7 +18,6 @@ from discoverylib import (
session_json,
subtopic_checklist,
subtopic_id,
topic,
)
@@ -27,8 +27,8 @@ def test_normalize_status_defaults_unknown():
assert normalize_status("garbage") == NOT_STARTED
def test_progress_all_not_started():
p = progress({})
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
@@ -36,7 +36,7 @@ def test_progress_all_not_started():
assert p.label == "0/8 topics complete"
def test_progress_mixed_counts_and_fraction():
def test_progress_mixed_counts_and_fraction(topics):
status = {
"background": COMPLETE,
"cx_strategy": COMPLETE,
@@ -44,25 +44,25 @@ def test_progress_mixed_counts_and_fraction():
"agent_environment": SKIPPED,
# remaining four default to not_started
}
p = progress(status)
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():
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({"channels": IN_PROGRESS}) == "channels"
assert active_topic_key(topics, {"channels": IN_PROGRESS}) == "channels"
# no in-progress → first not-started in canonical order
assert active_topic_key({"background": COMPLETE}) == "cx_strategy"
assert active_topic_key(topics, {"background": COMPLETE}) == "cx_strategy"
# everything resolved → None
all_done = {k: COMPLETE for k in TOPIC_KEYS}
assert active_topic_key(all_done) is 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():
t = topic("channels")
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)
@@ -71,12 +71,12 @@ def test_subtopic_checklist_marks_done():
assert by_key["inbound_context"] is False
def test_build_session_shape_and_subtopic_done_count():
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(status, notes, done)
s = build_session(topics, status, notes, done)
assert len(s["topics"]) == 8
assert s["active_topic_key"] == "channels"
@@ -86,18 +86,18 @@ def test_build_session_shape_and_subtopic_done_count():
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("channels").subtopics)
assert ch.subtopics_total == len(topic_by_key["channels"].subtopics)
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
def test_session_json_is_plain_and_complete():
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(status, notes, done),
payload = session_json(build_session(topics, status, notes, done),
meta={"client": "Acme", "date": "2026-07-19"})
assert payload["study"] == "202607_CX_Discovery_Workshop"

View File

@@ -1,18 +1,20 @@
"""Topic bank integrity — the verbatim facilitation record.
These pins are the anchor guard (Pattern §4): 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.
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 TOPICS, TOPIC_KEYS, topic
from discoverylib.topics import TOPIC_BY_KEY
from discoverylib import Topic, agenda_minutes
# ── Hand-checked shape (recount if you add/remove content) ───────────
def test_topic_count_and_order():
assert len(TOPICS) == 8
assert TOPIC_KEYS == (
def test_topic_count_and_order(topics):
assert len(topics) == 8
assert tuple(t.key for t in topics) == (
"background",
"cx_strategy",
"channels",
@@ -24,22 +26,27 @@ def test_topic_count_and_order():
)
def test_subtopic_and_prompt_totals():
assert sum(len(t.subtopics) for t in TOPICS) == 26
assert sum(t.prompt_count for t in TOPICS) == 95
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():
from discoverylib import agenda_minutes
assert agenda_minutes() == 110
assert agenda_minutes() == sum(t.minutes for t in TOPICS)
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():
keys = [t.key for t in TOPICS]
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:
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
@@ -50,13 +57,13 @@ def test_keys_unique_and_wellformed():
assert st.prompts # no empty sub-topic
def test_lookup_helpers():
assert topic("channels").title == "Channels"
assert TOPIC_BY_KEY["training"].minutes == 5
assert TOPICS[0].key == "background"
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():
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:
for t in topics:
assert "<" not in t.scope and ">" not in t.scope