""" 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 ``"."`` 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"] ], }