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.
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""
|
|
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"]
|
|
],
|
|
}
|