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:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user