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:
291
assessments/CX_Discovery_Workshop/discoverylib/session.py
Normal file
291
assessments/CX_Discovery_Workshop/discoverylib/session.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Session engine — the discovery workshop's "math".
|
||||
|
||||
There are no dollars here; the quantities a facilitator and client watch are
|
||||
**status** and **progress**. This module owns the schema and all of the
|
||||
logic, but none of the content: the topic bank itself lives in the
|
||||
``topic-bank`` cell of ``notebooks/cx_discovery.ipynb`` (content belongs on
|
||||
the Jupyter surface, where it is edited; ``.py`` files hold code only).
|
||||
|
||||
* :class:`Topic` / :class:`SubTopic` — the schema the bank is written in,
|
||||
* the topic-status vocabulary (:data:`STATUSES`) and its display glyphs,
|
||||
* :func:`progress` — the "3 / 8 topics complete" headline and its ratios,
|
||||
* :func:`subtopic_checklist` — the live tick-list for the active topic,
|
||||
* :func:`build_session` — assembles the full render/export state from the
|
||||
raw widget inputs (per-topic status + free-text notes),
|
||||
* :func:`session_json` — the machine-readable export payload (Pattern §5).
|
||||
|
||||
Everything is a pure function of ``(topics, status_by_topic, notes_by_topic,
|
||||
done_subtopics)`` so the in-notebook gate can pin it and the export can dump
|
||||
it. Unknown / missing keys degrade gracefully to "not started" so a
|
||||
half-filled live session never crashes the render.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Schema — what the notebook's topic-bank cell is written in ───────
|
||||
@dataclass(frozen=True)
|
||||
class SubTopic:
|
||||
"""A discussion thread within a topic; its title shows on stage when the
|
||||
topic is active, its prompts are the backstage facilitation script."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
prompts: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Topic:
|
||||
"""A client-facing discovery topic: the unit the progress bar counts."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
scope: str
|
||||
minutes: int
|
||||
subtopics: tuple[SubTopic, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def prompt_count(self) -> int:
|
||||
return sum(len(st.prompts) for st in self.subtopics)
|
||||
|
||||
|
||||
# ── Status vocabulary ────────────────────────────────────────────────
|
||||
# Order matters: it's the selector order and the legend order. "skipped"
|
||||
# is terminal-but-not-complete (a topic consciously set aside), so it
|
||||
# counts as resolved for the agenda but not toward completion.
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETE = "complete"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
STATUSES: tuple[str, ...] = (NOT_STARTED, IN_PROGRESS, COMPLETE, SKIPPED)
|
||||
|
||||
STATUS_LABEL: dict[str, str] = {
|
||||
NOT_STARTED: "Not started",
|
||||
IN_PROGRESS: "In progress",
|
||||
COMPLETE: "Complete",
|
||||
SKIPPED: "Skipped",
|
||||
}
|
||||
|
||||
# Stage glyphs — calm, unambiguous at a glance on a shared screen.
|
||||
STATUS_GLYPH: dict[str, str] = {
|
||||
NOT_STARTED: "○", # open circle — pending
|
||||
IN_PROGRESS: "◐", # half — under discussion
|
||||
COMPLETE: "●", # filled — done
|
||||
SKIPPED: "⊘", # slashed — set aside
|
||||
}
|
||||
|
||||
# Brand-aligned status colors (docs/brand.md). Muted gray pending, Future
|
||||
# Blue active, Success Green complete, light gray skipped.
|
||||
STATUS_COLOR: dict[str, str] = {
|
||||
NOT_STARTED: "#a9b2b8",
|
||||
IN_PROGRESS: "#0072bc",
|
||||
COMPLETE: "#00a34c",
|
||||
SKIPPED: "#c3c7ca",
|
||||
}
|
||||
|
||||
|
||||
def normalize_status(value: str | None) -> str:
|
||||
"""Coerce any widget value to a known status; default NOT_STARTED."""
|
||||
return value if value in STATUS_LABEL else NOT_STARTED
|
||||
|
||||
|
||||
# ── Progress ─────────────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class Progress:
|
||||
"""The headline the client watches: completed vs. total topics, plus
|
||||
the resolved (complete + skipped) count that drives the agenda burn."""
|
||||
|
||||
total: int
|
||||
completed: int # status == complete
|
||||
skipped: int
|
||||
in_progress: int
|
||||
not_started: int
|
||||
|
||||
@property
|
||||
def resolved(self) -> int:
|
||||
"""Topics no longer open for discussion (complete or skipped)."""
|
||||
return self.completed + self.skipped
|
||||
|
||||
@property
|
||||
def fraction(self) -> float:
|
||||
"""Completed / total in [0, 1] — the progress-bar fill."""
|
||||
return self.completed / self.total if self.total else 0.0
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.completed}/{self.total} topics complete"
|
||||
|
||||
|
||||
def progress(
|
||||
topics: tuple[Topic, ...], status_by_topic: dict[str, str]
|
||||
) -> Progress:
|
||||
"""Tally topic statuses into the headline :class:`Progress`."""
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for t in topics:
|
||||
counts[normalize_status(status_by_topic.get(t.key))] += 1
|
||||
return Progress(
|
||||
total=len(topics),
|
||||
completed=counts[COMPLETE],
|
||||
skipped=counts[SKIPPED],
|
||||
in_progress=counts[IN_PROGRESS],
|
||||
not_started=counts[NOT_STARTED],
|
||||
)
|
||||
|
||||
|
||||
def active_topic_key(
|
||||
topics: tuple[Topic, ...], status_by_topic: dict[str, str]
|
||||
) -> str | None:
|
||||
"""The topic to spotlight on stage: the first in-progress topic, else
|
||||
the first not-started one, else None (everything resolved)."""
|
||||
for t in topics:
|
||||
if normalize_status(status_by_topic.get(t.key)) == IN_PROGRESS:
|
||||
return t.key
|
||||
for t in topics:
|
||||
if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED:
|
||||
return t.key
|
||||
return None
|
||||
|
||||
|
||||
# ── Sub-topic checklist (live, for the active topic) ─────────────────
|
||||
@dataclass(frozen=True)
|
||||
class ChecklistItem:
|
||||
key: str
|
||||
title: str
|
||||
done: bool
|
||||
|
||||
|
||||
def subtopic_checklist(
|
||||
topic: Topic, done_subtopics: set[str] | frozenset[str]
|
||||
) -> list[ChecklistItem]:
|
||||
"""The active topic's sub-topics as a tick-list. ``done_subtopics``
|
||||
holds the fully-qualified ``"<topic_key>.<subtopic_key>"`` ids the
|
||||
facilitator has ticked."""
|
||||
return [
|
||||
ChecklistItem(st.key, st.title, f"{topic.key}.{st.key}" in done_subtopics)
|
||||
for st in topic.subtopics
|
||||
]
|
||||
|
||||
|
||||
def subtopic_id(topic_key: str, subtopic_key: str) -> str:
|
||||
"""The fully-qualified id used in ``done_subtopics`` and the export."""
|
||||
return f"{topic_key}.{subtopic_key}"
|
||||
|
||||
|
||||
# ── Agenda ───────────────────────────────────────────────────────────
|
||||
def agenda_minutes(topics: tuple[Topic, ...]) -> int:
|
||||
"""Sum of the nominal per-topic minute budgets."""
|
||||
return sum(t.minutes for t in topics)
|
||||
|
||||
|
||||
# ── Full session state (render + export) ─────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class TopicState:
|
||||
key: str
|
||||
title: str
|
||||
scope: str
|
||||
minutes: int
|
||||
status: str
|
||||
status_label: str
|
||||
glyph: str
|
||||
color: str
|
||||
notes: str
|
||||
subtopics_total: int
|
||||
subtopics_done: int
|
||||
# The covered sub-topic KEYS in canonical bank order — the export must
|
||||
# say WHICH threads were discussed, not just how many.
|
||||
subtopics_covered: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def build_session(
|
||||
topics: tuple[Topic, ...],
|
||||
status_by_topic: dict[str, str],
|
||||
notes_by_topic: dict[str, str] | None = None,
|
||||
done_subtopics: set[str] | frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the complete session state the notebook renders and exports.
|
||||
|
||||
Pure function of the bank and the three raw inputs; safe against
|
||||
missing keys.
|
||||
"""
|
||||
notes_by_topic = notes_by_topic or {}
|
||||
done_subtopics = frozenset(done_subtopics or ())
|
||||
by_key = {t.key: t for t in topics}
|
||||
|
||||
topic_states: list[TopicState] = []
|
||||
for t in topics:
|
||||
status = normalize_status(status_by_topic.get(t.key))
|
||||
covered = tuple(
|
||||
st.key for st in t.subtopics
|
||||
if subtopic_id(t.key, st.key) in done_subtopics
|
||||
)
|
||||
topic_states.append(
|
||||
TopicState(
|
||||
key=t.key,
|
||||
title=t.title,
|
||||
scope=t.scope,
|
||||
minutes=t.minutes,
|
||||
status=status,
|
||||
status_label=STATUS_LABEL[status],
|
||||
glyph=STATUS_GLYPH[status],
|
||||
color=STATUS_COLOR[status],
|
||||
notes=(notes_by_topic.get(t.key) or "").strip(),
|
||||
subtopics_total=len(t.subtopics),
|
||||
subtopics_done=len(covered),
|
||||
subtopics_covered=covered,
|
||||
)
|
||||
)
|
||||
|
||||
prog = progress(topics, status_by_topic)
|
||||
active = active_topic_key(topics, status_by_topic)
|
||||
return {
|
||||
"topics": topic_states,
|
||||
"progress": prog,
|
||||
"active_topic_key": active,
|
||||
"active_topic": by_key[active] if active else None,
|
||||
"agenda_minutes": agenda_minutes(topics),
|
||||
}
|
||||
|
||||
|
||||
def session_json(
|
||||
session: dict[str, Any], meta: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""The machine-readable export payload (Pattern §5): every captured
|
||||
answer/status/note as plain JSON, ready to feed an LLM drafting the
|
||||
survey write-up or business case. Plotly/HTML never carry this — the
|
||||
appendix does."""
|
||||
prog: Progress = session["progress"]
|
||||
return {
|
||||
"assessment": "CX_Discovery_Workshop",
|
||||
"instrument": "CX Exploration & Discovery Workshop",
|
||||
"meta": meta or {},
|
||||
"progress": {
|
||||
"total_topics": prog.total,
|
||||
"completed": prog.completed,
|
||||
"skipped": prog.skipped,
|
||||
"in_progress": prog.in_progress,
|
||||
"not_started": prog.not_started,
|
||||
"fraction_complete": round(prog.fraction, 4),
|
||||
"label": prog.label,
|
||||
},
|
||||
"agenda_minutes": session["agenda_minutes"],
|
||||
"active_topic": session["active_topic_key"],
|
||||
"topics": [
|
||||
{
|
||||
"key": ts.key,
|
||||
"title": ts.title,
|
||||
"scope": ts.scope,
|
||||
"status": ts.status,
|
||||
"minutes": ts.minutes,
|
||||
"subtopics_done": ts.subtopics_done,
|
||||
"subtopics_total": ts.subtopics_total,
|
||||
"subtopics_covered": list(ts.subtopics_covered),
|
||||
"notes": ts.notes,
|
||||
}
|
||||
for ts in session["topics"]
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user