diff --git a/app/services/tutor_service.py b/app/services/tutor_service.py index d1e2936..fb3a31b 100644 --- a/app/services/tutor_service.py +++ b/app/services/tutor_service.py @@ -41,7 +41,16 @@ C --> D[Rerank] ``` Keep diagrams small (5-10 nodes is plenty). Always pair the diagram with one short follow-up question or a single specific hint — the diagram is a teaching aid, not a replacement for the conversation. -- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose.""" +- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose. +- When the explanation is a multi-step PROCESS (a pipeline, a state machine, a data flow, an algorithm walkthrough) and building it up stage by stage would help more than one static picture, use a narrated whiteboard instead of a plain diagram. Emit a fenced block tagged lt-narrated whose body is JSON with a "steps" array; each step has a one-sentence "say" line and its own CUMULATIVE "mermaid" source (step N draws nodes 1..N). 3-6 steps. Produce the whole JSON in the SAME reply (one pass — never promise to send it next): + ```lt-narrated + {"steps":[ + {"say":"First the query is embedded.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]"}, + {"say":"Then we search the vector store.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]"}, + {"say":"Finally the top hits are reranked.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]-->R[Rerank]"} + ]} + ``` + Use the plain ```mermaid path for a single static diagram; use lt-narrated only when the step-by-step build is the teaching point. Still pair it with one short follow-up question or hint. A narrated block plus its one question is a deliberate exception to the "2-4 sentences / one hint" rule above — when you do reach for it, give it the full 3-6 steps rather than truncating to stay brief.""" _TRIAGE_JUDGE_PROMPT = """You sit between a learner working on a graded coding assignment and an AI coding agent the learner can talk to. Your one job: decide which side handles the learner's next prompt. diff --git a/app/static/lab-tutor.css b/app/static/lab-tutor.css index c60a5cf..99cde1a 100644 --- a/app/static/lab-tutor.css +++ b/app/static/lab-tutor.css @@ -392,3 +392,111 @@ white-space: pre-wrap; word-break: break-word; } + +/* Narrated whiteboard */ +.lt-narrated { + margin: 8px 0; + padding: 12px; + background: #fafafa; + border: 1px solid #e5e7eb; + border-radius: 8px; +} +.lt-narrated-stage { + opacity: 0; + transform: translateY(10px) scale(0.96); +} +.lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} +.lt-narrated-spot { + animation: lt-narrated-pulse 0.9s ease-out 1; +} +@keyframes lt-narrated-pulse { + 0% { + filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0)); + transform: scale(1); + } + 35% { + filter: drop-shadow(0 0 6px rgba(31, 111, 235, 0.75)); + transform: scale(1.06); + } + 100% { + filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0)); + transform: scale(1); + } +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-stage, + .lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: none; + } + .lt-narrated-spot { + animation: none; + } +} +.lt-narrated-stage svg { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} +.lt-narrated-caption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--lt-text); + min-height: 1.5em; +} +.lt-narrated-dots { + display: flex; + gap: 6px; + margin-top: 8px; +} +.lt-narrated-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lt-border); + transition: background 0.15s ease, transform 0.15s ease; +} +.lt-narrated-dot--active { + background: var(--lt-accent, #1f6feb); + transform: scale(1.4); +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-dot { + transition: none; + } +} +.lt-narrated-controls { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; +} +.lt-narrated-btn { + padding: 4px 10px; + border: 1px solid var(--lt-border); + background: var(--lt-surface); + border-radius: 6px; + font-size: 12px; + font-family: var(--lt-font); + color: var(--lt-text); + cursor: pointer; + transition: background 0.1s ease; +} +.lt-narrated-btn:hover:not(:disabled) { + background: var(--lt-surface-2); +} +.lt-narrated-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.lt-narrated-controls .lt-narrated-btn:first-child { + margin-right: auto; +} diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 43a632a..ccccabb 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -10,6 +10,206 @@ (function () { "use strict"; + // ── Narrated-whiteboard pure helpers (also unit-tested under Node) ───────── + // Escape literal newline/carriage-return/tab chars that appear INSIDE JSON + // string literals (LLMs often emit raw newlines in multiline values like + // mermaid source). Structural whitespace between tokens is left untouched. + function escapeControlCharsInStrings(src) { + let out = ""; + let inStr = false; + let esc = false; + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + if (esc) { out += ch; esc = false; continue; } + if (ch === "\\" && inStr) { out += ch; esc = true; continue; } + if (ch === '"') { inStr = !inStr; out += ch; continue; } + if (inStr && ch === "\n") { out += "\\n"; continue; } + if (inStr && ch === "\r") { out += "\\r"; continue; } + if (inStr && ch === "\t") { out += "\\t"; continue; } + out += ch; + } + return out; + } + + // Parse an lt-narrated block body into { steps: [{say, mermaid}] } or null. + function parseNarrated(body) { + function tryParse(s) { + try { return JSON.parse(s); } catch { return undefined; } + } + let obj = tryParse(body); + if (obj === undefined) { + let repaired = String(body); + const open = repaired.indexOf("{"); + const close = repaired.lastIndexOf("}"); + if (open !== -1 && close > open) repaired = repaired.slice(open, close + 1); + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // trailing commas + repaired = escapeControlCharsInStrings(repaired); // raw \n \r \t in values + obj = tryParse(repaired); + } + if (!obj || !Array.isArray(obj.steps)) return null; + const steps = obj.steps.filter( + (st) => + st && + typeof st.say === "string" && + st.say.trim() !== "" && + typeof st.mermaid === "string" && + st.mermaid.trim() !== "" + ).map((st) => ({ say: st.say, mermaid: st.mermaid })); + return steps.length > 0 ? { steps } : null; + } + + // No-audio / muted pacing: max(2s, words*240ms) (pattern from OpenMAIC). + function computeNoAudioMs(say) { + const words = String(say).trim().split(/\s+/).filter(Boolean).length; + return Math.max(2000, words * 240); + } + + // Pure playback state machine. State: {mode,step,total}. + // mode: "idle" | "playing" | "paused" | "done". + function narratedReducer(state, action) { + const s = state; + switch (action.type) { + case "PLAY": + if (s.mode === "done") return { mode: "playing", step: 0, total: s.total }; + return { mode: "playing", step: s.step, total: s.total }; + case "PAUSE": + if (s.mode !== "playing") return s; + return { mode: "paused", step: s.step, total: s.total }; + case "ADVANCE": { + // Stale-callback guard: only the active "playing" mode advances. + if (s.mode !== "playing") return s; + const next = s.step + 1; + if (next >= s.total) return { mode: "done", step: s.total - 1, total: s.total }; + return { mode: "playing", step: next, total: s.total }; + } + case "NEXT": { + const next = Math.min(s.step + 1, s.total - 1); + const done = s.step + 1 >= s.total; + return { mode: done ? "done" : s.mode, step: next, total: s.total }; + } + case "PREV": + return { mode: s.mode, step: Math.max(s.step - 1, 0), total: s.total }; + case "REPLAY": + return { mode: "playing", step: 0, total: s.total }; + case "STOP": + return { mode: "idle", step: 0, total: s.total }; + default: + return s; + } + } + + // Distinct human-visible node labels from a Mermaid flowchart/graph + // source, in declaration order. Pure; never throws. Used to spotlight + // the node added by the current step. Worst case (exotic markup): a + // label is missed and the spotlight simply no-ops — never breakage. + // Normalize a raw Mermaid label to the text the SVG actually renders: + // Mermaid strips one pair of surrounding quotes and turns
tags + // into line breaks (textContent has no tag). Matching the helper's + // output to rendered node textContent is what makes the spotlight hit. + function normalizeLabel(raw) { + let s = String(raw).trim(); + if (s.length >= 2) { + const a = s[0]; + const b = s[s.length - 1]; + if ((a === '"' && b === '"') || (a === "'" && b === "'")) { + s = s.slice(1, -1); + } + } + s = s.replace(//gi, ""); + return s.trim(); + } + + function extractNodeLabels(src) { + if (!src || typeof src !== "string") return []; + const RESERVED = new Set([ + "flowchart", "graph", "subgraph", "end", "style", "classDef", + "class", "linkStyle", "click", "direction", "stateDiagram", + "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram", + "journey", "gantt", "pie", "mindmap", + "LR", "RL", "TB", "BT", "TD", + ]); + // Most-specific bracket pairs first so e.g. ([stadium]) is not + // mis-read by the (round) pattern. The node-id quantifier is capped + // (\w{0,63}) — Mermaid ids are short identifiers, and an unbounded + // \w* here backtracks O(n^2) on long word-char runs (ReDoS) since + // this runs in the render path. + const SHAPES = [ + /([A-Za-z_]\w{0,63})\s*\[\[([^\]]+)\]\]/g, // [[subroutine]] + /([A-Za-z_]\w{0,63})\s*\[\(([^)]+)\)\]/g, // [(cylinder)] + /([A-Za-z_]\w{0,63})\s*\(\(([^)]+)\)\)/g, // ((circle)) + /([A-Za-z_]\w{0,63})\s*\(\[([^\]]+)\]\)/g, // ([stadium]) + /([A-Za-z_]\w{0,63})\s*\{\{([^}]+)\}\}/g, // {{hexagon}} + /([A-Za-z_]\w{0,63})\s*\[([^\]]+)\]/g, // [rect] + /([A-Za-z_]\w{0,63})\s*\(([^)]+)\)/g, // (round) + /([A-Za-z_]\w{0,63})\s*\{([^}]+)\}/g, // {rhombus} + /([A-Za-z_]\w{0,63})\s*>([^\]]+)\]/g, // >asymmetric] + ]; + const labelById = Object.create(null); + const order = []; + // Drop edge labels |...| so they are not parsed as node content. + let work = src.replace(/\|[^|]*\|/g, " "); + // Blank a leading diagram header line. + const lines = work.split(/\r?\n/); + if ( + lines.length && + /^\s*(flowchart|graph|stateDiagram(-v2)?|sequenceDiagram|classDiagram|erDiagram|journey|gantt|pie|mindmap)\b/.test( + lines[0] + ) + ) { + lines[0] = ""; + } + work = lines.join("\n"); + // Strip
tags so they do not confuse the >asymmetric] shape + // regex: `C
D` would otherwise produce a spurious br>D] match. + // normalizeLabel already removes them from captured label text; this + // removes them from the source so the surrounding parse is clean too. + work = work.replace(//gi, ""); + // Invariant: exec on the stable `work` (so captured groups stay + // reliable across patterns) while accumulating blanking on a separate + // `stripped` copy (so each successive pattern sees a progressively + // cleaned string). Do not swap these. + let stripped = work; + for (const re of SHAPES) { + re.lastIndex = 0; + let m; + while ((m = re.exec(work)) !== null) { + const id = m[1]; + const label = normalizeLabel(m[2]); + if (!(id in labelById) && label !== "") { + labelById[id] = label; + order.push(id); + } + } + stripped = stripped.replace(re, " $1 "); + } + // Remove edge operators so arrowheads (x/o) are not read as ids. + stripped = stripped.replace(/xo]?|-\.-?>?|={2,}>?/g, " "); + const tokens = stripped.match(/[A-Za-z_]\w*/g) || []; + for (const t of tokens) { + if (RESERVED.has(t)) continue; + if (!(t in labelById)) { + labelById[t] = t; + order.push(t); + } + } + return order.map((id) => labelById[id]); + } + + // Labels present in `currSrc` but not `prevSrc`. No prev (step 0) -> []. + function diffNewNodeLabels(prevSrc, currSrc) { + const curr = extractNodeLabels(currSrc); + if (!prevSrc) return []; + const prev = new Set(extractNodeLabels(prevSrc)); + return curr.filter((l) => !prev.has(l)); + } + + // Node-only export hook for unit tests. In the browser `module` is + // undefined so this is skipped and the widget bootstraps normally. + if (typeof module !== "undefined" && module.exports) { + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer, extractNodeLabels, diffNewNodeLabels }; + return; + } + // ── Config from script tag ──────────────────────────────────────────────── const me = document.currentScript || document.querySelector('script[src*="lab-tutor.js"]'); @@ -140,6 +340,232 @@ } } + + // Render a narrated-whiteboard card: stepwise Mermaid reveal + TTS. + function renderNarratedInto(parent, spec) { + const card = document.createElement("div"); + card.className = "lt-narrated"; + + const stage = document.createElement("div"); + stage.className = "lt-narrated-stage"; + card.appendChild(stage); + + const caption = document.createElement("div"); + caption.className = "lt-narrated-caption"; + card.appendChild(caption); + + const dots = document.createElement("div"); + dots.className = "lt-narrated-dots"; + dots.setAttribute("aria-hidden", "true"); + const dotEls = []; + for (let d = 0; d < spec.steps.length; d++) { + const dot = document.createElement("span"); + dot.className = "lt-narrated-dot"; + dots.appendChild(dot); + dotEls.push(dot); + } + card.appendChild(dots); + + const controls = document.createElement("div"); + controls.className = "lt-narrated-controls"; + const mkBtn = (label, aria) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "lt-narrated-btn"; + b.textContent = label; + b.setAttribute("aria-label", aria); + return b; + }; + const playBtn = mkBtn("▶ Play narration", "Play narration"); + const prevBtn = mkBtn("‹", "Previous step"); + const nextBtn = mkBtn("›", "Next step"); + const muteBtn = mkBtn("🔊", "Mute narration"); + controls.append(playBtn, prevBtn, nextBtn, muteBtn); + card.appendChild(controls); + parent.appendChild(card); + + const total = spec.steps.length; + let state = { mode: "idle", step: 0, total }; + let muted = false; + let noAudioTimer = null; + const synth = window.speechSynthesis || null; + const ttsOk = !!(synth && window.SpeechSynthesisUtterance); + if (!ttsOk) { + muteBtn.style.display = "none"; + muted = true; + } + + function clearTimer() { + if (noAudioTimer) { clearTimeout(noAudioTimer); noAudioTimer = null; } + } + function stopSpeech() { + clearTimer(); + if (ttsOk) { try { synth.cancel(); } catch {} } + } + + function spotlightNew(i) { + try { + if (i <= 0) return; + const labels = diffNewNodeLabels( + spec.steps[i - 1].mermaid, + spec.steps[i].mermaid + ); + if (!labels.length) return; + let nodes = stage.querySelectorAll("g.node"); + // '[class*="node"]' is intentionally broader than the design + // spec's ".nodeLabel/text" — it absorbs Mermaid version variation; + // textContent equality + the `hit` dedup prevent false positives. + if (!nodes.length) nodes = stage.querySelectorAll('[class*="node"]'); + const wanted = new Set(labels); + const hit = new Set(); + nodes.forEach((el) => { + const txt = (el.textContent || "").trim(); + if (wanted.has(txt) && !hit.has(txt)) { + hit.add(txt); + el.classList.add("lt-narrated-spot"); + setTimeout(() => { + try { el.classList.remove("lt-narrated-spot"); } catch {} + }, 1000); + } + }); + } catch { + /* spotlight is a nicety — never a failure path */ + } + } + + async function renderStep(i) { + const step = spec.steps[i]; + caption.textContent = step.say; + stage.classList.remove("lt-narrated-stage--in"); + stage.innerHTML = ""; + // reflow so the entry transition re-triggers + void stage.offsetWidth; + await renderMermaidInto(stage, step.mermaid); + stage.classList.add("lt-narrated-stage--in"); + spotlightNew(i); + } + + function syncControls() { + playBtn.textContent = + state.mode === "playing" ? "⏸ Pause" + : state.mode === "done" ? "↻ Replay" + : "▶ Play narration"; + playBtn.setAttribute( + "aria-label", + state.mode === "playing" ? "Pause narration" + : state.mode === "done" ? "Replay narration" + : "Play narration" + ); + prevBtn.disabled = state.step === 0; + nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing"; + for (let d = 0; d < dotEls.length; d++) { + dotEls[d].classList.toggle("lt-narrated-dot--active", d === state.step); + } + } + + function advance() { + state = narratedReducer(state, { type: "ADVANCE" }); + if (state.mode === "playing") { + run(); + } else { + syncControls(); + } + } + + function speakCurrent() { + const step = spec.steps[state.step]; + stopSpeech(); + if (!muted && ttsOk) { + const u = new SpeechSynthesisUtterance(step.say); + u.onend = () => { if (state.mode === "playing") advance(); }; + u.onerror = () => { if (state.mode === "playing") advance(); }; + try { synth.speak(u); } + catch { noAudioTimer = setTimeout(() => { if (state.mode === "playing") advance(); }, computeNoAudioMs(step.say)); } + } else { + noAudioTimer = setTimeout( + () => { if (state.mode === "playing") advance(); }, + computeNoAudioMs(step.say) + ); + } + } + + async function run() { + await renderStep(state.step); + syncControls(); + if (state.mode === "playing") speakCurrent(); + } + + function doPlayPause() { + if (state.mode === "playing") { + state = narratedReducer(state, { type: "PAUSE" }); + stopSpeech(); + syncControls(); + } else { + const wasDone = state.mode === "done"; + state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" }); + run(); + } + } + function doPrev() { + stopSpeech(); + state = narratedReducer(state, { type: "PREV" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + } + function doNext() { + stopSpeech(); + state = narratedReducer(state, { type: "NEXT" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + } + + playBtn.addEventListener("click", doPlayPause); + prevBtn.addEventListener("click", doPrev); + nextBtn.addEventListener("click", doNext); + muteBtn.addEventListener("click", () => { + muted = !muted; + muteBtn.textContent = muted ? "🔇" : "🔊"; + muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration"); + if (muted) { + stopSpeech(); + // Muting mid-playback must not rely on cancel() firing onerror to + // advance — restart pacing via the no-audio timer explicitly. + if (state.mode === "playing") { + noAudioTimer = setTimeout( + () => { if (state.mode === "playing") advance(); }, + computeNoAudioMs(spec.steps[state.step].say) + ); + } + } + }); + + // Card-scoped keyboard (NEVER document-level — must not leak into + // code-server / Monaco / page shortcuts). + card.tabIndex = 0; + card.setAttribute("role", "group"); + card.setAttribute("aria-label", "Narrated whiteboard"); + card.addEventListener("keydown", (e) => { + const onButton = !!(e.target && e.target.tagName === "BUTTON"); + if (e.key === " " || e.key === "Spacebar") { + // A focused control button activates natively on Space (keyup + // -> click). Defer to it; don't also fire the card shortcut + // (that would double-toggle / double-act). + if (onButton) return; + e.preventDefault(); + doPlayPause(); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + doNext(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + doPrev(); + } + }); + + // Initial paint: first step visible, idle (no autoplay). + renderStep(0).then(syncControls); + } + // ── SVG helpers ─────────────────────────────────────────────────────────── function chatIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -376,8 +802,9 @@ const wrap = document.createElement("div"); wrap.className = "lt-msg lt-msg--tutor"; - // Split on mermaid fences. Even indices = text, odd indices = mermaid code. - const fence = /```mermaid\n([\s\S]*?)\n```/g; + // Split on lt-narrated AND mermaid fences. A combined scanner keeps + // text/diagram/narrated ordering intact. + const fence = /```(lt-narrated|mermaid)\n([\s\S]*?)\n```/g; let lastIndex = 0; let m; let any = false; @@ -386,10 +813,23 @@ const before = text.slice(lastIndex, m.index); if (before.trim()) appendParagraphsBold(wrap, before); const host = document.createElement("div"); - host.className = "lt-mermaid-host"; wrap.appendChild(host); - // Fire-and-forget; the placeholder appears synchronously. - renderMermaidInto(host, m[1]); + if (m[1] === "lt-narrated") { + const spec = parseNarrated(m[2]); + if (spec) { + host.className = "lt-narrated-host"; + renderNarratedInto(host, spec); + } else { + // Fallback: treat the raw body as a failed-mermaid-style card. + host.className = "lt-mermaid lt-mermaid--failed"; + const pre = document.createElement("pre"); + pre.textContent = m[2]; + host.appendChild(pre); + } + } else { + host.className = "lt-mermaid-host"; + renderMermaidInto(host, m[2]); // fire-and-forget + } lastIndex = m.index + m[0].length; } const tail = text.slice(lastIndex); diff --git a/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md b/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md new file mode 100644 index 0000000..c186ee2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md @@ -0,0 +1,682 @@ +# Narrated Whiteboard — Attention Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the shipped narrated-whiteboard card more attention-grabbing — spotlight the just-added node each step, polish the step-in motion, and add keyboard control + progress dots — with no voice/LLM/persistence/infra changes. + +**Architecture:** Two new pure, Node-unit-tested helpers (`extractNodeLabels`, `diffNewNodeLabels`) added to the existing guarded helper block in `app/static/lab-tutor.js` and exported for tests. `renderNarratedInto` calls them after each step's Mermaid renders and adds a transient CSS class to the SVG node whose visible label is new (best-effort, never fatal). CSS gains a spotlight keyframe + spring step-in easing + progress-dot styles. Keyboard handlers are card-scoped and reuse the existing playback handler logic. + +**Tech Stack:** Vanilla ES2020 (no bundler), existing vendored Mermaid, Node v24 built-in test runner (`node --test`, zero deps), CSS. No Python changes. + +--- + +## File Structure + +- `app/static/lab-tutor.js` (modify) — add `extractNodeLabels` + `diffNewNodeLabels` to the guarded helper block and to `module.exports`; add spotlight call in `renderStep`; extract shared playback handlers; add progress dots + card-scoped keyboard. +- `app/static/lab-tutor.css` (modify) — `.lt-narrated-spot` keyframe, spring step-in easing, `.lt-narrated-dots`/`.lt-narrated-dot` styles, reduced-motion coverage. +- `tests/js/test_narrated.js` (modify) — Node unit tests for the two new pure helpers. + +No new files. No persistence/LLM/server changes. Manual browser check for DOM/CSS/keyboard (repo has no JS DOM harness — same tradeoff as the base feature). + +--- + +## Task 1: Pure helpers `extractNodeLabels` + `diffNewNodeLabels` + +**Files:** +- Modify: `app/static/lab-tutor.js` — insert two functions immediately BEFORE the export guard (the line ` if (typeof module !== "undefined" && module.exports) {`, currently ~line 103), and extend the `module.exports = {...}` object. +- Test: `tests/js/test_narrated.js` (append tests at end). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/js/test_narrated.js`: + +```js + +test("extractNodeLabels: bracketed flowchart labels", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Query]-->B[Embed]"); + assert.deepEqual(out.slice().sort(), ["Embed", "Query"]); +}); + +test("extractNodeLabels: bare ids when no label declared", () => { + const out = lt.extractNodeLabels("graph TD\n A-->B"); + assert.deepEqual(out.slice().sort(), ["A", "B"]); +}); + +test("extractNodeLabels: ignores edge-label text and direction/header", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Q] -->|yes| B[E]"); + assert.deepEqual(out.slice().sort(), ["E", "Q"]); + assert.equal(out.includes("yes"), false); + assert.equal(out.includes("LR"), false); + assert.equal(out.includes("flowchart"), false); +}); + +test("extractNodeLabels: dedupes by first declaration", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Q]-->B[E]\n B[E]-->A[Q]"); + assert.deepEqual(out.slice().sort(), ["E", "Q"]); +}); + +test("extractNodeLabels: shape variants (rhombus/circle)", () => { + const out = lt.extractNodeLabels("flowchart TD\n A{Decide}-->B((Done))"); + assert.deepEqual(out.slice().sort(), ["Decide", "Done"]); +}); + +test("extractNodeLabels: non-string / empty -> []", () => { + assert.deepEqual(lt.extractNodeLabels(""), []); + assert.deepEqual(lt.extractNodeLabels(null), []); +}); + +test("diffNewNodeLabels: no prev (step 0) -> []", () => { + assert.deepEqual(lt.diffNewNodeLabels(undefined, "flowchart LR\n A[Q]"), []); + assert.deepEqual(lt.diffNewNodeLabels("", "flowchart LR\n A[Q]"), []); +}); + +test("diffNewNodeLabels: returns only newly added label", () => { + const out = lt.diffNewNodeLabels( + "flowchart LR\n A[Q]-->B[E]", + "flowchart LR\n A[Q]-->B[E]-->C[S]" + ); + assert.deepEqual(out, ["S"]); +}); + +test("diffNewNodeLabels: no change -> []", () => { + assert.deepEqual( + lt.diffNewNodeLabels("flowchart LR\n A[Q]", "flowchart LR\n A[Q]"), + [] + ); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test tests/js/test_narrated.js` +Expected: FAIL — `lt.extractNodeLabels is not a function`. + +- [ ] **Step 3: Implement the helpers** + +In `app/static/lab-tutor.js`, immediately BEFORE this exact existing block: + +```js + // Node-only export hook for unit tests. In the browser `module` is + // undefined so this is skipped and the widget bootstraps normally. + if (typeof module !== "undefined" && module.exports) { + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer }; + return; + } +``` + +insert: + +```js + // Distinct human-visible node labels from a Mermaid flowchart/graph + // source, in declaration order. Pure; never throws. Used to spotlight + // the node added by the current step. Worst case (exotic markup): a + // label is missed and the spotlight simply no-ops — never breakage. + function extractNodeLabels(src) { + if (!src || typeof src !== "string") return []; + const RESERVED = new Set([ + "flowchart", "graph", "subgraph", "end", "style", "classDef", + "class", "linkStyle", "click", "direction", "stateDiagram", + "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram", + "journey", "gantt", "pie", "mindmap", + "LR", "RL", "TB", "BT", "TD", "DT", + ]); + // Most-specific bracket pairs first so e.g. ([stadium]) is not + // mis-read by the (round) pattern. + const SHAPES = [ + /([A-Za-z_]\w*)\s*\[\[([^\]]+)\]\]/g, // [[subroutine]] + /([A-Za-z_]\w*)\s*\[\(([^)]+)\)\]/g, // [(cylinder)] + /([A-Za-z_]\w*)\s*\(\(([^)]+)\)\)/g, // ((circle)) + /([A-Za-z_]\w*)\s*\(\[([^\]]+)\]\)/g, // ([stadium]) + /([A-Za-z_]\w*)\s*\{\{([^}]+)\}\}/g, // {{hexagon}} + /([A-Za-z_]\w*)\s*\[([^\]]+)\]/g, // [rect] + /([A-Za-z_]\w*)\s*\(([^)]+)\)/g, // (round) + /([A-Za-z_]\w*)\s*\{([^}]+)\}/g, // {rhombus} + /([A-Za-z_]\w*)\s*>([^\]]+)\]/g, // >asymmetric] + ]; + const labelById = Object.create(null); + const order = []; + // Drop edge labels |...| so they are not parsed as node content. + let work = src.replace(/\|[^|]*\|/g, " "); + // Blank a leading diagram header line. + const lines = work.split(/\r?\n/); + if ( + lines.length && + /^\s*(flowchart|graph|stateDiagram(-v2)?|sequenceDiagram|classDiagram|erDiagram|journey|gantt|pie|mindmap)\b/.test( + lines[0] + ) + ) { + lines[0] = ""; + } + work = lines.join("\n"); + let stripped = work; + for (const re of SHAPES) { + re.lastIndex = 0; + let m; + while ((m = re.exec(work)) !== null) { + const id = m[1]; + const label = m[2].trim(); + if (!(id in labelById) && label !== "") { + labelById[id] = label; + order.push(id); + } + } + stripped = stripped.replace(re, " $1 "); + } + // Remove edge operators so arrowheads (x/o) are not read as ids. + stripped = stripped.replace(/xo]?|-\.-?>?|={2,}>?/g, " "); + const tokens = stripped.match(/[A-Za-z_]\w*/g) || []; + for (const t of tokens) { + if (RESERVED.has(t)) continue; + if (!(t in labelById)) { + labelById[t] = t; + order.push(t); + } + } + return order.map((id) => labelById[id]); + } + + // Labels present in `currSrc` but not `prevSrc`. No prev (step 0) -> []. + function diffNewNodeLabels(prevSrc, currSrc) { + const curr = extractNodeLabels(currSrc); + if (!prevSrc) return []; + const prev = new Set(extractNodeLabels(prevSrc)); + return curr.filter((l) => !prev.has(l)); + } +``` + +Then change the export line from: + +```js + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer }; +``` + +to: + +```js + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer, extractNodeLabels, diffNewNodeLabels }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/js/test_narrated.js` +Expected: PASS — all tests (the original 10 + the 9 new) pass. + +- [ ] **Step 5: Syntax check** + +Run: `node --check app/static/lab-tutor.js` +Expected: exit 0, no output. + +- [ ] **Step 6: Commit** + +```bash +git add app/static/lab-tutor.js tests/js/test_narrated.js +git commit -m "feat(lab-tutor): node-label extraction + diff helpers for spotlight + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Spotlight DOM application + motion polish CSS + +**Files:** +- Modify: `app/static/lab-tutor.js` — extend `renderStep` inside `renderNarratedInto` to spotlight new nodes. +- Modify: `app/static/lab-tutor.css` — spotlight keyframe, spring step-in, reduced-motion coverage. + +No automated test (DOM); covered by Task 1 pure tests + Task 4 manual verification. + +- [ ] **Step 1: Add the spotlight helper + call in `renderStep`** + +In `app/static/lab-tutor.js`, find this EXACT current `renderStep` (inside `renderNarratedInto`): + +```js + async function renderStep(i) { + const step = spec.steps[i]; + caption.textContent = step.say; + stage.classList.remove("lt-narrated-stage--in"); + stage.innerHTML = ""; + // reflow so the entry transition re-triggers + void stage.offsetWidth; + await renderMermaidInto(stage, step.mermaid); + stage.classList.add("lt-narrated-stage--in"); + } +``` + +Replace it with: + +```js + function spotlightNew(i) { + try { + if (i <= 0) return; + const labels = diffNewNodeLabels( + spec.steps[i - 1].mermaid, + spec.steps[i].mermaid + ); + if (!labels.length) return; + let nodes = stage.querySelectorAll("g.node"); + if (!nodes.length) nodes = stage.querySelectorAll('[class*="node"]'); + const wanted = new Set(labels); + const hit = new Set(); + nodes.forEach((el) => { + const txt = (el.textContent || "").trim(); + if (wanted.has(txt) && !hit.has(txt)) { + hit.add(txt); + el.classList.add("lt-narrated-spot"); + setTimeout(() => { + try { el.classList.remove("lt-narrated-spot"); } catch {} + }, 1000); + } + }); + } catch { + /* spotlight is a nicety — never a failure path */ + } + } + + async function renderStep(i) { + const step = spec.steps[i]; + caption.textContent = step.say; + stage.classList.remove("lt-narrated-stage--in"); + stage.innerHTML = ""; + // reflow so the entry transition re-triggers + void stage.offsetWidth; + await renderMermaidInto(stage, step.mermaid); + stage.classList.add("lt-narrated-stage--in"); + spotlightNew(i); + } +``` + +- [ ] **Step 2: Motion polish + spotlight CSS** + +In `app/static/lab-tutor.css`, find this EXACT current block: + +```css +.lt-narrated-stage { + opacity: 0; + transform: translateY(6px) scale(0.98); +} +.lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-stage, + .lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: none; + } +} +``` + +Replace it with: + +```css +.lt-narrated-stage { + opacity: 0; + transform: translateY(10px) scale(0.96); +} +.lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} +.lt-narrated-spot { + animation: lt-narrated-pulse 0.9s ease-out 1; +} +@keyframes lt-narrated-pulse { + 0% { + filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0)); + transform: scale(1); + } + 35% { + filter: drop-shadow(0 0 6px rgba(31, 111, 235, 0.75)); + transform: scale(1.06); + } + 100% { + filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0)); + transform: scale(1); + } +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-stage, + .lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: none; + } + .lt-narrated-spot { + animation: none; + } +} +``` + +- [ ] **Step 3: Regression + syntax** + +Run: `node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js` +Expected: `node --check` exit 0; all Node tests pass (the helpers/guard untouched). + +- [ ] **Step 4: Commit** + +```bash +git add app/static/lab-tutor.js app/static/lab-tutor.css +git commit -m "feat(lab-tutor): spotlight the newly added node + spring step-in + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Keyboard control + progress dots + +**Files:** +- Modify: `app/static/lab-tutor.js` — extract shared playback handlers, add dots + card-scoped keyboard. +- Modify: `app/static/lab-tutor.css` — dot styles. + +- [ ] **Step 1: Extract shared handlers and wire dots + keyboard** + +In `app/static/lab-tutor.js`, find this EXACT current block (the four `addEventListener` handlers + the final initial-paint line, inside `renderNarratedInto`): + +```js + playBtn.addEventListener("click", () => { + if (state.mode === "playing") { + state = narratedReducer(state, { type: "PAUSE" }); + stopSpeech(); + syncControls(); + } else { + const wasDone = state.mode === "done"; + state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" }); + run(); + } + }); + prevBtn.addEventListener("click", () => { + stopSpeech(); + state = narratedReducer(state, { type: "PREV" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + }); + nextBtn.addEventListener("click", () => { + stopSpeech(); + state = narratedReducer(state, { type: "NEXT" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + }); + muteBtn.addEventListener("click", () => { + muted = !muted; + muteBtn.textContent = muted ? "🔇" : "🔊"; + muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration"); + if (muted) { + stopSpeech(); + // Muting mid-playback must not rely on cancel() firing onerror to + // advance — restart pacing via the no-audio timer explicitly. + if (state.mode === "playing") { + noAudioTimer = setTimeout( + () => { if (state.mode === "playing") advance(); }, + computeNoAudioMs(spec.steps[state.step].say) + ); + } + } + }); + + // Initial paint: first step visible, idle (no autoplay). + renderStep(0).then(syncControls); + } +``` + +Replace it with: + +```js + function doPlayPause() { + if (state.mode === "playing") { + state = narratedReducer(state, { type: "PAUSE" }); + stopSpeech(); + syncControls(); + } else { + const wasDone = state.mode === "done"; + state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" }); + run(); + } + } + function doPrev() { + stopSpeech(); + state = narratedReducer(state, { type: "PREV" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + } + function doNext() { + stopSpeech(); + state = narratedReducer(state, { type: "NEXT" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + } + + playBtn.addEventListener("click", doPlayPause); + prevBtn.addEventListener("click", doPrev); + nextBtn.addEventListener("click", doNext); + muteBtn.addEventListener("click", () => { + muted = !muted; + muteBtn.textContent = muted ? "🔇" : "🔊"; + muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration"); + if (muted) { + stopSpeech(); + // Muting mid-playback must not rely on cancel() firing onerror to + // advance — restart pacing via the no-audio timer explicitly. + if (state.mode === "playing") { + noAudioTimer = setTimeout( + () => { if (state.mode === "playing") advance(); }, + computeNoAudioMs(spec.steps[state.step].say) + ); + } + } + }); + + // Card-scoped keyboard (NEVER document-level — must not leak into + // code-server / Monaco / page shortcuts). + card.tabIndex = 0; + card.setAttribute("role", "group"); + card.setAttribute("aria-label", "Narrated whiteboard"); + card.addEventListener("keydown", (e) => { + if (e.key === " " || e.key === "Spacebar") { + e.preventDefault(); + doPlayPause(); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + doNext(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + doPrev(); + } + }); + + // Initial paint: first step visible, idle (no autoplay). + renderStep(0).then(syncControls); + } +``` + +- [ ] **Step 2: Add the progress dots element** + +In `app/static/lab-tutor.js`, find this EXACT current block (inside `renderNarratedInto`, just after the caption is appended and before the controls are built): + +```js + const caption = document.createElement("div"); + caption.className = "lt-narrated-caption"; + card.appendChild(caption); + + const controls = document.createElement("div"); + controls.className = "lt-narrated-controls"; +``` + +Replace it with: + +```js + const caption = document.createElement("div"); + caption.className = "lt-narrated-caption"; + card.appendChild(caption); + + const dots = document.createElement("div"); + dots.className = "lt-narrated-dots"; + dots.setAttribute("aria-hidden", "true"); + const dotEls = []; + for (let d = 0; d < spec.steps.length; d++) { + const dot = document.createElement("span"); + dot.className = "lt-narrated-dot"; + dots.appendChild(dot); + dotEls.push(dot); + } + card.appendChild(dots); + + const controls = document.createElement("div"); + controls.className = "lt-narrated-controls"; +``` + +- [ ] **Step 3: Make `syncControls` update the active dot** + +In `app/static/lab-tutor.js`, find this EXACT current `syncControls`: + +```js + function syncControls() { + playBtn.textContent = + state.mode === "playing" ? "⏸ Pause" + : state.mode === "done" ? "↻ Replay" + : "▶ Play narration"; + playBtn.setAttribute( + "aria-label", + state.mode === "playing" ? "Pause narration" + : state.mode === "done" ? "Replay narration" + : "Play narration" + ); + prevBtn.disabled = state.step === 0; + nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing"; + } +``` + +Replace it with: + +```js + function syncControls() { + playBtn.textContent = + state.mode === "playing" ? "⏸ Pause" + : state.mode === "done" ? "↻ Replay" + : "▶ Play narration"; + playBtn.setAttribute( + "aria-label", + state.mode === "playing" ? "Pause narration" + : state.mode === "done" ? "Replay narration" + : "Play narration" + ); + prevBtn.disabled = state.step === 0; + nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing"; + for (let d = 0; d < dotEls.length; d++) { + dotEls[d].classList.toggle("lt-narrated-dot--active", d === state.step); + } + } +``` + +- [ ] **Step 4: Dot styles** + +In `app/static/lab-tutor.css`, find this EXACT current block: + +```css +.lt-narrated-caption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--lt-text); + min-height: 1.5em; +} +``` + +Replace it with: + +```css +.lt-narrated-caption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--lt-text); + min-height: 1.5em; +} +.lt-narrated-dots { + display: flex; + gap: 6px; + margin-top: 8px; +} +.lt-narrated-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lt-border); + transition: background 0.15s ease, transform 0.15s ease; +} +.lt-narrated-dot--active { + background: var(--lt-accent, #1f6feb); + transform: scale(1.4); +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-dot { + transition: none; + } +} +``` + +- [ ] **Step 5: Regression + syntax** + +Run: `node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js` +Expected: `node --check` exit 0; all Node tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/static/lab-tutor.js app/static/lab-tutor.css +git commit -m "feat(lab-tutor): card-scoped keyboard control + progress dots + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Full regression + manual verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full automated regression** + +Run: +```bash +node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js +/Users/tushar/Desktop/codebases/course-gen-codex/.venv/bin/python -m pytest tests/test_tutor_service.py tests/test_tutor_routes.py -q +``` +Expected: `node --check` exit 0; all Node tests pass (19 total); pytest all pass (the persona/routes are untouched — pure regression guard). + +- [ ] **Step 2: Manual browser verification (evidence required)** + +Local only — no remote deploy. With the local server running (`http://127.0.0.1:8012`), launch a learner studio container, open the tutor, prompt a multi-step process ("walk me through the RAG pipeline step by step"). Record evidence (screenshot/observation) for EACH: + - [ ] Each step pulses ONLY the newly added node (not the whole diagram). + - [ ] A step that adds no new node (or a non-flowchart diagram) simply doesn't pulse — no error, card still plays. + - [ ] Step-in motion reads as a settle (spring), not a hard blink; reduced-motion OS setting disables pulse + transition but dots/keys still work. + - [ ] Progress dots track the current step during autoplay and prev/next. + - [ ] With the card focused: `Space` toggles play/pause and does NOT scroll the page; `→`/`←` step; keys do NOT leak to the editor when the card is not focused. + - [ ] Existing controls (play/pause/prev/next/mute) behave exactly as before; malformed `lt-narrated` still falls back to the raw card. + +- [ ] **Step 3: Report** + +Do not claim completion until every Step-2 box has recorded evidence (verification-before-completion). Report results. No `git push` / no remote deploy without explicit approval. + +--- + +## Self-Review + +**Spec coverage:** +- New-node spotlight (source diff + label-text match, graceful no-op) → Task 1 (`extractNodeLabels`/`diffNewNodeLabels` + tests) + Task 2 (`spotlightNew` DOM apply, try/catch, `g.node` then `[class*="node"]` fallback, 1000ms cleanup). +- Step 0 → no spotlight → `diffNewNodeLabels` returns `[]` for falsy prev (Task 1 test) and `spotlightNew` early-returns for `i<=0` (Task 2). +- Motion polish (spring cubic-bezier, more travel) → Task 2 CSS. +- Reduced-motion neutralizes stage transition AND spotlight → Task 2 `@media` block. +- Progress dots (decorative, `aria-hidden`, between caption and controls, active tracks `state.step`) → Task 3 Steps 2–4. +- Keyboard card-scoped (`tabindex`/`role`/`aria-label`, listener on card not document, Space preventDefault, arrows) reusing existing handler logic → Task 3 Step 1. +- Pure helpers Node-unit-tested; DOM/CSS/keyboard manual → Tasks 1 & 4. +- Non-goals respected (no voice/LLM/persistence/server/new-file; no tap-to-advance; no autoplay change) — no such tasks; Task 4 forbids push. + +**Placeholder scan:** No TBD/TODO; every code step has complete code; commands have expected output. + +**Type consistency:** `extractNodeLabels(src)→string[]`, `diffNewNodeLabels(prevSrc,currSrc)→string[]` consistent across Task 1 tests, the `module.exports` list, and Task 2's `spotlightNew`. `spotlightNew(i)` called only from `renderStep` after render. Shared handlers `doPlayPause/doPrev/doNext` defined once (Task 3 Step 1) and referenced by both click and keydown. `dotEls` created in Task 3 Step 2 and consumed in Task 3 Step 3's `syncControls`. CSS classes (`lt-narrated-spot`, `lt-narrated-dots`, `lt-narrated-dot`, `lt-narrated-dot--active`) match between JS and CSS tasks. `--lt-accent` referenced with a `#1f6feb` fallback in case the variable name differs. diff --git a/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md b/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md new file mode 100644 index 0000000..7ade78c --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md @@ -0,0 +1,660 @@ +# Narrated Whiteboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the Lab Tutor explain a multi-step concept as a step-by-step Mermaid reveal narrated by browser text-to-speech, with no extra LLM call and zero TTS cost. + +**Architecture:** The tutor emits a fenced ```` ```lt-narrated ```` block (JSON with a `steps[]` array; each step has its own cumulative Mermaid source + a `say` line). The widget parses it and renders a playback card that, per step, re-renders the step's Mermaid and speaks `say` via `window.speechSynthesis`, advancing on the utterance `onend` event (event-based cursor, no timeline). Pure logic (parser, no-audio timer, playback reducer) is factored into testable functions guarded for Node so they can be unit-tested with Node's built-in test runner; DOM/playback wiring stays in `lab-tutor.js`. + +**Tech Stack:** Vanilla ES2020 (no bundler), existing vendored `mermaid.min.js`, browser `SpeechSynthesis`, Python 3 / pytest + `unittest` for the tutor-prompt contract, Node v24 built-in test runner (`node --test`, zero new deps) for JS pure logic. + +--- + +## File Structure + +- `app/static/lab-tutor.js` (modify) — add three pure helpers + a Node-export guard near the top of the IIFE; add `renderNarratedInto` + `lt-narrated` fence handling in `appendTutor`. +- `app/static/lab-tutor.css` (modify) — add `.lt-narrated*` styles incl. per-step entry transition. +- `app/services/tutor_service.py` (modify) — extend `_TUTOR_PERSONA` with `lt-narrated` guidance + single-pass constraint. +- `tests/js/test_narrated.js` (create) — Node `node:test` unit tests for the three pure helpers. +- `tests/test_tutor_service.py` (modify) — add a test asserting the persona contract. + +**V1 simplification (deliberate, flagged at handoff):** per-card playback position is NOT persisted. On reload the narrated card re-renders fresh at `idle` (ready to replay) — identical to how the existing Mermaid card already behaves. The spec's "persist last-played step" is deferred; adding it would require extending the localStorage history schema (`loadHistory` only retains `{role,text}`), which is disproportionate for V1. + +--- + +## Task 1: Pure helpers + Node export guard + +**Files:** +- Modify: `app/static/lab-tutor.js` (insert after line 11 `"use strict";`, before line 14 `const me = document.currentScript`) +- Test: `tests/js/test_narrated.js` + +- [ ] **Step 1: Write the failing test** + +Create `tests/js/test_narrated.js`: + +```js +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +// Requiring lab-tutor.js under Node hits the export guard and returns the +// pure helpers without touching the DOM. +const lt = require(path.join(__dirname, "..", "..", "app", "static", "lab-tutor.js")); + +test("parseNarrated: valid JSON returns normalized steps", () => { + const body = JSON.stringify({ + steps: [ + { say: "Chunk the docs.", mermaid: "flowchart LR\n A-->B" }, + { say: "Embed them.", mermaid: "flowchart LR\n A-->B-->C" }, + ], + }); + const out = lt.parseNarrated(body); + assert.equal(out.steps.length, 2); + assert.equal(out.steps[0].say, "Chunk the docs."); + assert.equal(out.steps[1].mermaid, "flowchart LR\n A-->B-->C"); +}); + +test("parseNarrated: repairs trailing commas", () => { + const body = '{ "steps": [ { "say": "x", "mermaid": "graph TD\\n A", }, ], }'; + const out = lt.parseNarrated(body); + assert.ok(out); + assert.equal(out.steps.length, 1); + assert.equal(out.steps[0].say, "x"); +}); + +test("parseNarrated: strips stray prose around the object", () => { + const body = 'Sure! Here it is:\n{ "steps": [ { "say": "a", "mermaid": "graph TD\\n A" } ] }\nHope that helps.'; + const out = lt.parseNarrated(body); + assert.ok(out); + assert.equal(out.steps[0].say, "a"); +}); + +test("parseNarrated: garbage or empty -> null", () => { + assert.equal(lt.parseNarrated("not json at all <<<"), null); + assert.equal(lt.parseNarrated('{"steps": []}'), null); + assert.equal(lt.parseNarrated('{"steps": [{"say": 1}]}'), null); +}); + +test("computeNoAudioMs: max(2000, words*240)", () => { + assert.equal(lt.computeNoAudioMs("one two three"), 2000); // 3*240=720 -> floor 2000 + assert.equal(lt.computeNoAudioMs("a ".repeat(20).trim()), 20 * 240); + assert.equal(lt.computeNoAudioMs(""), 2000); +}); + +test("narratedReducer: play/advance/done flow", () => { + let s = { mode: "idle", step: 0, total: 3 }; + s = lt.narratedReducer(s, { type: "PLAY" }); + assert.deepEqual(s, { mode: "playing", step: 0, total: 3 }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); + assert.deepEqual(s, { mode: "playing", step: 1, total: 3 }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); // step 2 -> done + assert.equal(s.mode, "done"); + assert.equal(s.step, 2); +}); + +test("narratedReducer: stale ADVANCE ignored when not playing", () => { + let s = { mode: "paused", step: 1, total: 3 }; + const after = lt.narratedReducer(s, { type: "ADVANCE" }); + assert.deepEqual(after, s); // unchanged — stale-callback guard +}); + +test("narratedReducer: PAUSE/NEXT/PREV/REPLAY", () => { + let s = { mode: "playing", step: 0, total: 3 }; + s = lt.narratedReducer(s, { type: "PAUSE" }); + assert.equal(s.mode, "paused"); + s = lt.narratedReducer(s, { type: "NEXT" }); + assert.deepEqual(s, { mode: "paused", step: 1, total: 3 }); + s = lt.narratedReducer(s, { type: "PREV" }); + assert.deepEqual(s, { mode: "paused", step: 0, total: 3 }); + s = lt.narratedReducer(s, { type: "PREV" }); // clamp at 0 + assert.equal(s.step, 0); + s = lt.narratedReducer({ mode: "done", step: 2, total: 3 }, { type: "REPLAY" }); + assert.deepEqual(s, { mode: "playing", step: 0, total: 3 }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/js/test_narrated.js` +Expected: FAIL — `lt.parseNarrated is not a function` (the export guard / helpers don't exist yet). + +- [ ] **Step 3: Write minimal implementation** + +In `app/static/lab-tutor.js`, immediately after line 11 (` "use strict";`) and before line 13's config comment, insert: + +```js + + // ── Narrated-whiteboard pure helpers (also unit-tested under Node) ───────── + // Parse an lt-narrated block body into { steps: [{say, mermaid}] } or null. + function parseNarrated(body) { + function tryParse(s) { + try { return JSON.parse(s); } catch { return undefined; } + } + let obj = tryParse(body); + if (obj === undefined) { + let repaired = String(body); + const open = repaired.indexOf("{"); + const close = repaired.lastIndexOf("}"); + if (open !== -1 && close > open) repaired = repaired.slice(open, close + 1); + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // trailing commas + obj = tryParse(repaired); + } + if (!obj || !Array.isArray(obj.steps)) return null; + const steps = obj.steps.filter( + (st) => + st && + typeof st.say === "string" && + st.say.trim() !== "" && + typeof st.mermaid === "string" && + st.mermaid.trim() !== "" + ).map((st) => ({ say: st.say, mermaid: st.mermaid })); + return steps.length > 0 ? { steps } : null; + } + + // No-audio / muted pacing: max(2s, words*240ms) (pattern from OpenMAIC). + function computeNoAudioMs(say) { + const words = String(say).trim().split(/\s+/).filter(Boolean).length; + return Math.max(2000, words * 240); + } + + // Pure playback state machine. State: {mode,step,total}. + // mode: "idle" | "playing" | "paused" | "done". + function narratedReducer(state, action) { + const s = state; + switch (action.type) { + case "PLAY": + if (s.mode === "done") return { mode: "playing", step: 0, total: s.total }; + return { mode: "playing", step: s.step, total: s.total }; + case "PAUSE": + if (s.mode !== "playing") return s; + return { mode: "paused", step: s.step, total: s.total }; + case "ADVANCE": { + // Stale-callback guard: only the active "playing" mode advances. + if (s.mode !== "playing") return s; + const next = s.step + 1; + if (next >= s.total) return { mode: "done", step: s.total - 1, total: s.total }; + return { mode: "playing", step: next, total: s.total }; + } + case "NEXT": { + const next = Math.min(s.step + 1, s.total - 1); + const done = s.step + 1 >= s.total; + return { mode: done ? "done" : s.mode, step: next, total: s.total }; + } + case "PREV": + return { mode: s.mode, step: Math.max(s.step - 1, 0), total: s.total }; + case "REPLAY": + return { mode: "playing", step: 0, total: s.total }; + case "STOP": + return { mode: "idle", step: 0, total: s.total }; + default: + return s; + } + } + + // Node-only export hook for unit tests. In the browser `module` is + // undefined so this is skipped and the widget bootstraps normally. + if (typeof module !== "undefined" && module.exports) { + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer }; + return; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test tests/js/test_narrated.js` +Expected: PASS — all 8 tests pass. + +- [ ] **Step 5: Verify the browser path is not broken** + +Run: `node -e "global.window={};global.document={currentScript:null,querySelector:()=>null};try{require('./app/static/lab-tutor.js');console.log('browser-path require did not early-return (expected, guard only triggers under module.exports)')}catch(e){console.log('threw:',e.message)}"` +Expected: it threw (because real DOM is absent) OR printed the no-early-return line — either is fine. This step only confirms the guard is `module.exports`-gated, not that the widget runs headless. The authoritative browser check is Task 6. + +- [ ] **Step 6: Commit** + +```bash +git add app/static/lab-tutor.js tests/js/test_narrated.js +git commit -m "feat(lab-tutor): narrated-whiteboard pure helpers + Node unit tests + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Renderer + playback wiring in the widget + +**Files:** +- Modify: `app/static/lab-tutor.js` — add `renderNarratedInto` near the Mermaid helpers (after `renderMermaidInto`, which ends ~line 137); add an `lt-narrated` scan in `appendTutor` (around lines 379-398). + +No automated test (no DOM test harness in this repo by design). Logic correctness is covered by Task 1's pure-function tests; integration is verified manually in Task 6. + +- [ ] **Step 1: Add the narrated renderer** + +In `app/static/lab-tutor.js`, immediately AFTER the `renderMermaidInto` function (just before the `// ──` comment that follows it, ~line 137), insert: + +```js + + // Render a narrated-whiteboard card: stepwise Mermaid reveal + TTS. + let narratedIdCounter = 0; + function renderNarratedInto(parent, spec) { + const card = document.createElement("div"); + card.className = "lt-narrated"; + + const stage = document.createElement("div"); + stage.className = "lt-narrated-stage"; + card.appendChild(stage); + + const caption = document.createElement("div"); + caption.className = "lt-narrated-caption"; + card.appendChild(caption); + + const controls = document.createElement("div"); + controls.className = "lt-narrated-controls"; + const mkBtn = (label, aria) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "lt-narrated-btn"; + b.textContent = label; + b.setAttribute("aria-label", aria); + return b; + }; + const playBtn = mkBtn("▶ Play narration", "Play narration"); + const prevBtn = mkBtn("‹", "Previous step"); + const nextBtn = mkBtn("›", "Next step"); + const muteBtn = mkBtn("🔊", "Mute narration"); + controls.append(playBtn, prevBtn, nextBtn, muteBtn); + card.appendChild(controls); + parent.appendChild(card); + + const total = spec.steps.length; + let state = { mode: "idle", step: 0, total }; + let muted = false; + let noAudioTimer = null; + const synth = window.speechSynthesis || null; + const ttsOk = !!(synth && window.SpeechSynthesisUtterance); + if (!ttsOk) { + muteBtn.style.display = "none"; + muted = true; + } + + function clearTimer() { + if (noAudioTimer) { clearTimeout(noAudioTimer); noAudioTimer = null; } + } + function stopSpeech() { + clearTimer(); + if (ttsOk) { try { synth.cancel(); } catch {} } + } + + async function renderStep(i) { + const step = spec.steps[i]; + caption.textContent = step.say; + stage.classList.remove("lt-narrated-stage--in"); + stage.innerHTML = ""; + // reflow so the entry transition re-triggers + void stage.offsetWidth; + await renderMermaidInto(stage, step.mermaid); + stage.classList.add("lt-narrated-stage--in"); + } + + function syncControls() { + playBtn.textContent = + state.mode === "playing" ? "⏸ Pause" + : state.mode === "done" ? "↻ Replay" + : "▶ Play narration"; + playBtn.setAttribute( + "aria-label", + state.mode === "playing" ? "Pause narration" + : state.mode === "done" ? "Replay narration" + : "Play narration" + ); + prevBtn.disabled = state.step === 0; + nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing"; + } + + function speakCurrent() { + const step = spec.steps[state.step]; + stopSpeech(); + const advance = () => { + const tokenMode = state.mode; + state = narratedReducer(state, { type: "ADVANCE" }); + if (state.mode === "playing" && tokenMode === "playing") { + run(); + } else { + syncControls(); + } + }; + if (!muted && ttsOk) { + const u = new SpeechSynthesisUtterance(step.say); + u.onend = () => { if (state.mode === "playing") advance(); }; + u.onerror = () => { if (state.mode === "playing") advance(); }; + try { synth.speak(u); } + catch { noAudioTimer = setTimeout(() => { if (state.mode === "playing") advance(); }, computeNoAudioMs(step.say)); } + } else { + noAudioTimer = setTimeout( + () => { if (state.mode === "playing") advance(); }, + computeNoAudioMs(step.say) + ); + } + } + + async function run() { + await renderStep(state.step); + syncControls(); + if (state.mode === "playing") speakCurrent(); + } + + playBtn.addEventListener("click", () => { + if (state.mode === "playing") { + state = narratedReducer(state, { type: "PAUSE" }); + stopSpeech(); + syncControls(); + } else { + const wasDone = state.mode === "done"; + state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" }); + run(); + } + }); + prevBtn.addEventListener("click", () => { + stopSpeech(); + state = narratedReducer(state, { type: "PREV" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + }); + nextBtn.addEventListener("click", () => { + stopSpeech(); + state = narratedReducer(state, { type: "NEXT" }); + if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } + renderStep(state.step).then(syncControls); + }); + muteBtn.addEventListener("click", () => { + muted = !muted; + muteBtn.textContent = muted ? "🔇" : "🔊"; + muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration"); + if (muted) stopSpeech(); + }); + + // Initial paint: first step visible, idle (no autoplay). + renderStep(0).then(syncControls); + } +``` + +- [ ] **Step 2: Wire the `lt-narrated` fence into `appendTutor`** + +In `appendTutor` (the mermaid-fence block ~lines 379-398), replace this exact block: + +```js + // Split on mermaid fences. Even indices = text, odd indices = mermaid code. + const fence = /```mermaid\n([\s\S]*?)\n```/g; + let lastIndex = 0; + let m; + let any = false; + while ((m = fence.exec(text)) !== null) { + any = true; + const before = text.slice(lastIndex, m.index); + if (before.trim()) appendParagraphsBold(wrap, before); + const host = document.createElement("div"); + host.className = "lt-mermaid-host"; + wrap.appendChild(host); + // Fire-and-forget; the placeholder appears synchronously. + renderMermaidInto(host, m[1]); + lastIndex = m.index + m[0].length; + } +``` + +with: + +```js + // Split on lt-narrated AND mermaid fences. A combined scanner keeps + // text/diagram/narrated ordering intact. + const fence = /```(lt-narrated|mermaid)\n([\s\S]*?)\n```/g; + let lastIndex = 0; + let m; + let any = false; + while ((m = fence.exec(text)) !== null) { + any = true; + const before = text.slice(lastIndex, m.index); + if (before.trim()) appendParagraphsBold(wrap, before); + const host = document.createElement("div"); + wrap.appendChild(host); + if (m[1] === "lt-narrated") { + const spec = parseNarrated(m[2]); + if (spec) { + host.className = "lt-narrated-host"; + renderNarratedInto(host, spec); + } else { + // Fallback: treat the raw body as a failed-mermaid-style card. + host.className = "lt-mermaid lt-mermaid--failed"; + const pre = document.createElement("pre"); + pre.textContent = m[2]; + host.appendChild(pre); + } + } else { + host.className = "lt-mermaid-host"; + renderMermaidInto(host, m[2]); // fire-and-forget + } + lastIndex = m.index + m[0].length; + } +``` + +- [ ] **Step 3: Re-run the pure-helper tests (regression guard)** + +Run: `node --test tests/js/test_narrated.js` +Expected: PASS — 8/8. (Confirms the edits above didn't disturb the guarded helpers / early return.) + +- [ ] **Step 4: Commit** + +```bash +git add app/static/lab-tutor.js +git commit -m "feat(lab-tutor): narrated-whiteboard renderer + playback wiring + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Styles + +**Files:** +- Modify: `app/static/lab-tutor.css` (append a new section at end of file, after the `.lt-mermaid` rules ending ~line 395) + +- [ ] **Step 1: Append the narrated-whiteboard styles** + +Append to `app/static/lab-tutor.css`: + +```css + +/* Narrated whiteboard */ +.lt-narrated { + margin: 8px 0; + padding: 12px; + background: #fafafa; + border: 1px solid #e5e7eb; + border-radius: 8px; +} +.lt-narrated-stage { + opacity: 0; + transform: translateY(6px) scale(0.98); +} +.lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +@media (prefers-reduced-motion: reduce) { + .lt-narrated-stage, + .lt-narrated-stage--in { + opacity: 1; + transform: none; + transition: none; + } +} +.lt-narrated-stage svg { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} +.lt-narrated-caption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--lt-text); + min-height: 1.5em; +} +.lt-narrated-controls { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; +} +.lt-narrated-btn { + padding: 4px 10px; + border: 1px solid var(--lt-border); + background: var(--lt-surface); + border-radius: 6px; + font-size: 12px; + font-family: var(--lt-font); + color: var(--lt-text); + cursor: pointer; + transition: background 0.1s ease; +} +.lt-narrated-btn:hover:not(:disabled) { + background: var(--lt-surface-2); +} +.lt-narrated-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.lt-narrated-controls .lt-narrated-btn:first-child { + margin-right: auto; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/static/lab-tutor.css +git commit -m "feat(lab-tutor): narrated-whiteboard styles + reduced-motion fallback + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Tutor prompt guidance (TDD) + +**Files:** +- Modify: `app/services/tutor_service.py` — extend `_TUTOR_PERSONA` (string ends at line 44) +- Test: `tests/test_tutor_service.py` (append a new test method/class) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_tutor_service.py`: + +```python +class TutorPersonaNarratedContract(unittest.TestCase): + def test_persona_documents_lt_narrated_and_single_pass(self) -> None: + from app.services.tutor_service import _TUTOR_PERSONA + + self.assertIn("```lt-narrated", _TUTOR_PERSONA) + # JSON shape the widget parses + self.assertIn('"steps"', _TUTOR_PERSONA) + self.assertIn('"say"', _TUTOR_PERSONA) + self.assertIn('"mermaid"', _TUTOR_PERSONA) + # single-pass constraint must be stated + self.assertIn("same reply", _TUTOR_PERSONA.lower()) + # plain mermaid path must still be documented + self.assertIn("```mermaid", _TUTOR_PERSONA) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_tutor_service.py::TutorPersonaNarratedContract -v` +Expected: FAIL — `AssertionError` (no `` ```lt-narrated `` in the persona yet). + +- [ ] **Step 3: Add the guidance to the persona** + +In `app/services/tutor_service.py`, replace the final bullet of `_TUTOR_PERSONA` (line 44): + +``` +- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose.""" +``` + +with: + +``` +- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose. +- When the explanation is a multi-step PROCESS (a pipeline, a state machine, a data flow, an algorithm walkthrough) and building it up stage by stage would help more than one static picture, use a narrated whiteboard instead of a plain diagram. Emit a fenced block tagged lt-narrated whose body is JSON with a "steps" array; each step has a one-sentence "say" line and its own CUMULATIVE "mermaid" source (step N draws nodes 1..N). 3-6 steps. Produce the whole JSON in the SAME reply (one pass — never promise to send it next): + ```lt-narrated + {"steps":[ + {"say":"First the query is embedded.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]"}, + {"say":"Then we search the vector store.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]"} + ]} + ``` + Use the plain ```mermaid path for a single static diagram; use lt-narrated only when the step-by-step build is the teaching point. Still pair it with one short follow-up question or hint.""" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_tutor_service.py::TutorPersonaNarratedContract -v` +Expected: PASS. + +- [ ] **Step 5: Run the full tutor-service suite (regression)** + +Run: `python -m pytest tests/test_tutor_service.py -v` +Expected: PASS — all pre-existing tests still green (the line-98 "plain persona" test must not regress; the new bullet is appended, persona-only behavior unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add app/services/tutor_service.py tests/test_tutor_service.py +git commit -m "feat(lab-tutor): persona guidance for lt-narrated (single-pass) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Full regression + manual browser verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full JS + Python regression** + +Run: `node --test tests/js/ && python -m pytest tests/test_tutor_service.py tests/test_tutor_routes.py -q` +Expected: all PASS. + +- [ ] **Step 2: Manual browser verification (evidence required)** + +Local only — no remote deploy. Start the app locally with `LAB_TUTOR_BASE_URL=http://127.0.0.1:8012`, launch a learner studio container, open the editor, open the tutor, and prompt something that triggers a multi-step process explanation (e.g. "walk me through the RAG pipeline step by step"). Confirm and record evidence (screenshot or written observation) for EACH: + - [ ] A narrated card renders with a "▶ Play narration" button (no autoplay). + - [ ] Play reveals step 1's diagram + caption, speaks it, then auto-advances to step 2 on speech end (event-based sync). + - [ ] Pause stops speech and the cursor does not jump (stale-callback guard holds). + - [ ] Prev/Next move steps and disable correctly at the ends. + - [ ] Mute → playback continues visually on the no-audio timer (~2s+/step). + - [ ] Reload the page: the card re-renders fresh at idle (documented V1 behavior — position not persisted), chat history otherwise intact. + - [ ] A deliberately malformed `lt-narrated` block falls back to a raw card and does NOT break the message log. + +- [ ] **Step 3: Update the running-feature todo and report** + +Do NOT claim completion until every Step-2 box has recorded evidence (verification-before-completion). Report results to the user. No `git push` / no remote deploy without explicit approval. + +--- + +## Self-Review + +**Spec coverage:** +- Block format (`lt-narrated`, JSON, cumulative mermaid) → Task 1 (`parseNarrated`) + Task 4 (persona emits it). +- Widget renderer + Play button (no autoplay) + controls + caption fallback → Task 2 + Task 3. +- Event-based sync, no-audio timer `max(2s, words*240)`, stale-callback guard → Task 1 (reducer/timer) + Task 2 (wiring). +- Per-step CSS entry transition + reduced-motion → Task 3. +- Parser robustness ladder → Task 1. +- Tutor prompt single-pass guidance → Task 4. +- Verification (Python contract + JS units + manual) → Tasks 1, 4, 5. +- Non-goals (no games/video/server-TTS/OpenMAIC/course-gen/deploy) → respected; no such tasks; Task 5 Step 3 forbids push. +- Spec "persist last-played step" → consciously deferred; documented under File Structure as a V1 simplification and surfaced in Task 5 Step 2 / handoff. + +**Placeholder scan:** No TBD/TODO; every code step contains complete code; commands have expected output. + +**Type consistency:** `parseNarrated` returns `{steps:[{say,mermaid}]}` — consumed identically in Task 2. `narratedReducer(state,action)` state shape `{mode,step,total}` and action types `PLAY/PAUSE/ADVANCE/NEXT/PREV/REPLAY/STOP` consistent across Task 1 tests and Task 2 wiring. `computeNoAudioMs(say)` signature consistent. CSS class names (`lt-narrated`, `lt-narrated-stage`, `lt-narrated-stage--in`, `lt-narrated-caption`, `lt-narrated-controls`, `lt-narrated-btn`, `lt-narrated-host`) match between Task 2 and Task 3. diff --git a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md new file mode 100644 index 0000000..a3b42af --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md @@ -0,0 +1,154 @@ +# Narrated Whiteboard — Attention Polish Design + +Date: 2026-05-16 +Branch: `claude/agitated-hamilton-f1b773` +Status: Approved (design), pre-implementation +Builds on: `2026-05-16-narrated-whiteboard-design.md` (the shipped narrated-whiteboard feature) + +## Goal + +Increase engagement of the existing narrated-whiteboard card by making each +step visually arresting and learner-controllable — without touching voice +quality, adding LLM calls, persistence, or new infra. Scope is deliberately +"Bundle A: attention polish only": **new-node spotlight + motion polish + +keyboard/progress-dots**. + +## Non-Goals (explicit) + +- Caption word-sync, predict-the-next gate, recap takeaway, code-anchored + narrative (these were Bundles B/C — out of scope here). +- Tap-to-advance; any change to the no-autoplay behavior. +- Any new LLM call, persistence-schema change, server change, or new file + injection site. +- Voice/TTS changes of any kind. +- Remote deploy. Local build + verify only; no push without explicit approval. + +## Context + +The shipped feature renders an `lt-narrated` card via `renderNarratedInto` +in `app/static/lab-tutor.js`. Per step it sets a caption and re-renders the +step's **cumulative** Mermaid source (`renderMermaidInto`), then plays the +entry transition via the `.lt-narrated-stage--in` class. Playback is the +pure `narratedReducer` state machine; pure helpers (`parseNarrated`, +`computeNoAudioMs`, `narratedReducer`) are unit-tested under Node +(`tests/js/test_narrated.js`) via a module-export guard. There is no JS DOM +test harness in the repo by design. + +## Architecture + +Three independent, additive sub-features. Each degrades gracefully and is +off the critical path (if any fails, the card still plays exactly as today). + +### 1. New-node spotlight + +Per the original design, each step carries its OWN cumulative Mermaid +source. To emphasize only what changed, diff the SOURCE (not the rendered +SVG — SVG-id targeting is brittle across Mermaid versions and was +deliberately avoided in the base feature). + +**Pure helpers (Node-unit-tested, added to the existing guarded block):** + +- `extractNodeLabels(mermaidSrc)` → ordered array of distinct human-visible + node label strings. Parses flowchart/graph node declarations: + - `A[Label]`, `A(Label)`, `A((Label))`, `A{Label}`, `A>Label]`, + `A([Label])`, `A[[Label]]` — capture the bracketed label. + - Bare node ids that appear in edges without a label declaration + (`A --> B`) contribute the id as its own label only if the id never + receives a bracketed label anywhere in the source. + - Ignores the diagram-type header line (`flowchart LR`, `graph TD`, + `stateDiagram-v2`, etc.) and edge-label text (`A -->|text| B`). + - Deterministic, no DOM, no regex catastrophic backtracking (bounded + quantifiers). +- `diffNewNodeLabels(prevMermaidSrc, currMermaidSrc)` → labels present in + `curr` but not in `prev` (set difference on `extractNodeLabels` output, + order preserved by `curr`). For step 0 (`prev` undefined/empty) → `[]` + (no spotlight on the first paint; nothing is "new"). + +**DOM application (in `renderNarratedInto`, best-effort, non-fatal):** + +After `renderMermaidInto(stage, step.mermaid)` resolves for step i where +i > 0: +1. Compute `newLabels = diffNewNodeLabels(spec.steps[i-1].mermaid, + spec.steps[i].mermaid)`. +2. For each new label, find the FIRST SVG node element in `stage` whose + trimmed text content equals the label, scoped to Mermaid node containers + (`g.node`, falling back to any `.nodeLabel`/`text` whose trimmed text + matches). Add class `lt-narrated-spot`. +3. Remove `lt-narrated-spot` after 1000ms (slightly longer than the CSS + animation) so re-render of the next step starts clean. +4. Any exception or zero matches → swallow and continue (spotlight is a + nicety, never a failure path). Matching is by the label the learner + actually sees, so it tracks Mermaid markup changes. + +### 2. Motion polish + +Replace the `.lt-narrated-stage--in` transition timing function with a +spring-ish `cubic-bezier(0.22, 1, 0.36, 1)` and increase initial travel +slightly (`translateY(10px) scale(0.96)` → settle). Pure CSS. The existing +`@media (prefers-reduced-motion: reduce)` block already neutralizes the +stage transform/opacity/transition and must also neutralize the new +spotlight animation. + +### 3. Keyboard + progress dots + +- **Progress dots:** add a `.lt-narrated-dots` container (built once from + `spec.steps.length`) of `.lt-narrated-dot` spans. `syncControls` toggles + `.lt-narrated-dot--active` on the dot at `state.step`. Decorative: + `aria-hidden="true"` (the buttons already carry the accessible state). + Placed between the caption and the controls row. +- **Keyboard:** the card root gets `tabindex="0"`, `role="group"`, and + `aria-label="Narrated whiteboard"`. A `keydown` listener is attached to + the CARD ELEMENT (never `document`) so it cannot interfere with + code-server / Monaco / page shortcuts: + - `Space` → same as clicking play/pause; `event.preventDefault()` to + stop the page scrolling. + - `ArrowRight` → same as Next button; `ArrowLeft` → same as Prev button. + - Reuse the EXISTING button click handlers' logic (extract the + play/pause, next, prev bodies into named functions the listeners and + keys share — no behavior change, just shared call sites). + - Any other key → ignored (no preventDefault). + +## Data Flow + +`spec.steps[i].mermaid` (already in memory) → `diffNewNodeLabels` → label +list → DOM query within the freshly rendered `stage` → transient CSS class. +No new data, no network, no storage. Dots derive purely from +`spec.steps.length` and `state.step`. Keyboard maps to existing playback +actions through `narratedReducer` (unchanged). + +## Error Handling + +- `extractNodeLabels`/`diffNewNodeLabels` are total functions: malformed or + exotic Mermaid → return whatever labels parse (possibly `[]`); never + throw. Unit tests cover empty input, no-new-node steps, label-shape + variants, and a non-flowchart header. +- Spotlight DOM step is wrapped so any error (no SVG, no match, unexpected + markup) is swallowed; the card continues normally. +- Keyboard handler only acts on the three keys; everything else passes + through. Listener is card-scoped and removed implicitly when the message + log is cleared (same lifecycle as today's listeners; no new global + state). +- Reduced-motion users: no spotlight animation, no stage transition (CSS + media query), but dots + keyboard still work. + +## Testing / Verification + +- **Node unit tests** (extend `tests/js/test_narrated.js`): + `extractNodeLabels` (bracket shapes, bare-id edges, header/edge-label + exclusion, dedupe/order) and `diffNewNodeLabels` (step 0 → [], added + node, no-change step, label rename treated as add). Run: + `node --test tests/js/test_narrated.js`. +- **node --check** on `lab-tutor.js`; full Node suite green. +- **Manual browser verification** (consistent with base feature; no DOM + harness): trigger a narrated reply, confirm — only the newly added node + pulses each step; motion feels like a settle not a blink; dots track the + step; `Space`/`←`/`→` work when the card is focused and do NOT scroll the + page or leak to the editor; reduced-motion disables animations but keeps + dots/keys; a non-flowchart or odd diagram simply doesn't spotlight (no + breakage). Evidence required before any "it works" claim. + +## Rollout + +Additive and local-only. If `diffNewNodeLabels` returns `[]` or matching +fails, behavior is identical to the shipped feature. No remote deploy, no +push without explicit approval. diff --git a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md new file mode 100644 index 0000000..57de14e --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md @@ -0,0 +1,191 @@ +# Narrated Whiteboard — Tutor Enhancement Design + +Date: 2026-05-16 +Branch: `claude/agitated-hamilton-f1b773` +Status: Approved (design), pre-implementation + +## Goal + +Make the Lab Tutor's conceptual explanations more engaging by delivering them +as a **step-by-step diagram reveal synced with voice narration** — the feel of +a short Khan-Academy explainer, without generating any video and without any +additional LLM call. + +This is the lightweight, in-IDE reimplementation of OpenMAIC's narrated- +simulation idea. OpenMAIC itself is **not** integrated (AGPL-3.0, Node/LangGraph +stack, classroom-app UX that would force a context switch out of the editor). + +## Non-Goals (explicit) + +- Games / quizzes / MCQs. +- Video or talking-head avatar generation. +- Server-side TTS (OpenAI/ElevenLabs/VoxCPM). Browser TTS only. +- Any OpenMAIC integration. +- Any change to the course-gen authoring pipeline (assignment generation, + judges, starters). Per standing rule, core course-gen edits are surfaced as + a flagged todo, not bundled here. +- Any remote deployment. Build and verify locally only; no push to remote + without explicit approval (remote is under a deploy freeze). + +## Cost Model + +- Browser `SpeechSynthesis` (Web Speech API): $0. Local OS voice engine — not + an LLM, no API, no server, no network. +- Mermaid render + step reveal animation: $0. Client-side only. +- Narration script + diagram spec: LLM-generated, but **part of the existing + single tutor reply** — no new API call. Marginal change in output tokens + (a few hundred), roughly a wash with the prose it replaces. +- Hard constraint: **single-pass only**. The script must be emitted in the + same tutor completion as the diagram. No second "rewrite into narration" + LLM pass. + +## Architecture + +Reuses the existing structured-fenced-block pattern already proven by the +Mermaid feature (`app/static/lab-tutor.js` parses ```` ```mermaid ```` blocks +and renders them via a client-side renderer). + +### Block format + +The tutor emits a fenced block tagged `lt-narrated` whose body is JSON: + +```json +{ + "steps": [ + { "say": "First we chunk the documents.", + "mermaid": "flowchart LR\n A[Docs] --> B[Chunk]" }, + { "say": "Each chunk is embedded into a vector.", + "mermaid": "flowchart LR\n A[Docs] --> B[Chunk] --> C[Embed]" } + ] +} +``` + +- Each step carries its **own cumulative Mermaid source**: step N renders the + diagram containing nodes/edges 1..N. +- Rationale for cumulative re-render over animating one base SVG: Mermaid + auto-generates SVG element ids, so reliably targeting "reveal node 3" by id + is brittle across diagram types and Mermaid versions. Re-rendering a small + diagram per step is cheap (client-side, sub-frame for <10 nodes) and + deterministic. +- Recommended shape: 3–6 steps, one short sentence per `say`, small diagrams. + +### Widget renderer (`app/static/lab-tutor.js`) + +A new renderer registered alongside the existing mermaid renderer. + +- Parser: detect ```` ```lt-narrated ```` fences. Parse with a small + robustness ladder (LLMs occasionally emit trailing commas / stray prose): + `JSON.parse` → a minimal repair pass (strip trailing commas, trim to the + outermost `{...}`) → fallback card. Missing/empty `steps` → fallback card + showing the raw text (mirrors the existing `.lt-mermaid--failed` behavior). +- Render: a card with a **▶ Play narration** button. No autoplay: + 1. Browsers gate audio/speech behind a user gesture. + 2. Surprise speech at a learner mid-task is poor UX. +- Playback state machine per narrated card: + - `idle → playing → (paused) → done` + - On entering step i: render `steps[i].mermaid` into the card's diagram + slot; show `steps[i].say` as a visible caption; speak `say` via + `SpeechSynthesis`; on utterance `onend` advance to i+1; at end → `done`. + - **Stale-callback guard:** the `onend` handler must re-check the card's + `mode` before advancing. If the user paused/stopped between utterance + start and end, a late `onend` must NOT advance the cursor. (Borrowed + pattern: a single cursor advanced only by the active mode's callbacks.) +- **Sync mechanism (validated approach): event-based, no timeline.** There + is no precomputed timeline or per-word timing. Advance is driven purely by + the speech `onend` event (or the no-audio timer below). Authoring controls + pacing purely by ordering/sizing steps. This is deliberately simpler than + timestamp scrubbing and needs zero duration metadata. +- **No-audio / muted timer:** when muted or `speechSynthesis` is + unavailable, advance step i on `setTimeout` of + `max(2000ms, wordCount(say) * 240ms)` so the visual still paces sensibly. +- **Per-step entry transition (polish):** when a step's diagram renders, + apply a short CSS entry transition on the diagram container + (opacity/translate/scale, ~150-250ms) so each reveal feels deliberate + rather than a hard swap. Pure CSS; no animation library. +- Controls: Play/Pause, Prev, Next, Replay, Mute. Caption text is **always** + visible regardless of audio (accessibility, sound-off, and + `prefers-reduced-motion` users get a "show all steps" static fallback). +- Persistence: last-played step index + mute state stored in the existing + enrollment-scoped localStorage chat record, so a narrated card survives + reload like chat messages already do. + +### Audio + +- `window.speechSynthesis` only. +- Handle the `voiceschanged` load race (voices often unavailable on first + synchronous `getVoices()` call): resolve voice list on `voiceschanged` or a + short poll, pick a sane default (prefer a local en-US voice), fall back to + the platform default. +- Mute toggle persisted per the persistence note above. Muted playback still + advances steps on a timer derived from `say` length (so the visual still + plays without audio). + +### Tutor prompt (`app/services/tutor_service.py`) + +Add guidance next to the existing Mermaid nudge in the persona/system prompt: + +- When the explanation is a **multi-step process** (pipeline, state machine, + data flow, algorithm walkthrough), prefer an `lt-narrated` block: 3–6 steps, + cumulative diagrams, one short sentence per step. +- Keep the existing plain ```` ```mermaid ```` path for a single static + diagram. Keep the "don't diagram every reply" bar. +- Emit the JSON in one reply (single-pass constraint). + +## Error Handling + +- Malformed `lt-narrated` JSON → fallback card with raw content + a short + error line. Never throw into the message log. +- A step whose `mermaid` fails to render → show that step's `say` caption with + a small "diagram unavailable" note; narration/stepping continues. +- `speechSynthesis` unavailable (older browser / blocked) → silent mode: + captions + manual Next still work; Mute control hidden or disabled. +- Voices not yet loaded at Play time → defer first utterance until + `voiceschanged` (bounded wait), then proceed. + +## Testing / Verification + +- **Python (TDD):** assert the tutor service / persona contract — the system + prompt contains the `lt-narrated` guidance and the single-pass constraint; + cover any server-side parsing/validation we add for the block. +- **JS (unit-testable seams):** factor the block parser and the playback + step-state reducer as pure functions so they can be unit-tested without a + DOM/browser; test malformed input, cumulative step progression, mute-timer + advance, persistence (de)serialization. +- **Manual:** run a local code-server container with the widget injected, + trigger a narrated explanation, verify reveal+voice sync, controls, + reload persistence, and the no-audio fallback. Evidence before any + "it works" claim (verification-before-completion). + +## Techniques Borrowed from OpenMAIC (concepts only — AGPL, no code copied) + +OpenMAIC's implementation was studied (read-only) to de-risk this design. +We adopt **patterns**, not code. Key validations and borrowed ideas: + +- **Event-based sequential cursor** (their `PlaybackEngine.processNext`): + speech actions block on the audio `ended` event before advancing; no + global timeline, no precomputed durations. This validated our core + approach and is the backbone of the playback loop. +- **No-audio degradation timer:** their fallback of + `max(2s, words*240ms)` is adopted verbatim as our muted/no-TTS pacing. +- **Mode-checked callbacks:** they re-check engine `mode` inside every + `onEnded` so a stale callback can't advance after pause/stop. Adopted as + our stale-callback guard. +- **Per-element entry transition:** they reveal each whiteboard element with + a short staggered CSS/Framer entry transition rather than path-drawing. + We apply the same idea as a pure-CSS per-step container transition (we + re-render a cumulative Mermaid diagram instead of appending elements, + since Mermaid owns its SVG). +- **Parser robustness ladder:** their `JSON.parse → jsonrepair → + partial-json` chain motivated our lighter `JSON.parse → minimal repair → + fallback card`. + +Explicitly NOT adopted: their full discriminated-union action engine, +fire-and-forget action class, server-TTS pre-gen + IndexedDB audio cache, +and multi-agent classroom machinery — all heavier than a single in-IDE +narrated explanation needs (YAGNI for V1). + +## Rollout + +- Local only. No remote deploy. No push without explicit approval. +- Feature is additive: if the tutor never emits `lt-narrated`, behavior is + unchanged from today. diff --git a/pyproject.toml b/pyproject.toml index a0949bc..9d3ba98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["app", "app.api", "app.domain", "app.services", "app.storage"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js new file mode 100644 index 0000000..514d9e0 --- /dev/null +++ b/tests/js/test_narrated.js @@ -0,0 +1,181 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +// Requiring lab-tutor.js under Node hits the export guard and returns the +// pure helpers without touching the DOM. +const lt = require(path.join(__dirname, "..", "..", "app", "static", "lab-tutor.js")); + +test("parseNarrated: valid JSON returns normalized steps", () => { + const body = JSON.stringify({ + steps: [ + { say: "Chunk the docs.", mermaid: "flowchart LR\n A-->B" }, + { say: "Embed them.", mermaid: "flowchart LR\n A-->B-->C" }, + ], + }); + const out = lt.parseNarrated(body); + assert.equal(out.steps.length, 2); + assert.equal(out.steps[0].say, "Chunk the docs."); + assert.equal(out.steps[1].mermaid, "flowchart LR\n A-->B-->C"); +}); + +test("parseNarrated: repairs trailing commas", () => { + const body = '{ "steps": [ { "say": "x", "mermaid": "graph TD\\n A", }, ], }'; + const out = lt.parseNarrated(body); + assert.ok(out); + assert.equal(out.steps.length, 1); + assert.equal(out.steps[0].say, "x"); +}); + +test("parseNarrated: strips stray prose around the object", () => { + const body = 'Sure! Here it is:\n{ "steps": [ { "say": "a", "mermaid": "graph TD\\n A" } ] }\nHope that helps.'; + const out = lt.parseNarrated(body); + assert.ok(out); + assert.equal(out.steps[0].say, "a"); +}); + +test("parseNarrated: garbage or empty -> null", () => { + assert.equal(lt.parseNarrated("not json at all <<<"), null); + assert.equal(lt.parseNarrated('{"steps": []}'), null); + assert.equal(lt.parseNarrated('{"steps": [{"say": 1}]}'), null); +}); + +test("computeNoAudioMs: max(2000, words*240)", () => { + assert.equal(lt.computeNoAudioMs("one two three"), 2000); // 3*240=720 -> floor 2000 + assert.equal(lt.computeNoAudioMs("a ".repeat(20).trim()), 20 * 240); + assert.equal(lt.computeNoAudioMs(""), 2000); +}); + +test("narratedReducer: play/advance/done flow", () => { + let s = { mode: "idle", step: 0, total: 3 }; + s = lt.narratedReducer(s, { type: "PLAY" }); + assert.deepEqual(s, { mode: "playing", step: 0, total: 3 }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); + assert.deepEqual(s, { mode: "playing", step: 1, total: 3 }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); + s = lt.narratedReducer(s, { type: "ADVANCE" }); // step 2 -> done + assert.equal(s.mode, "done"); + assert.equal(s.step, 2); +}); + +test("narratedReducer: stale ADVANCE ignored when not playing", () => { + let s = { mode: "paused", step: 1, total: 3 }; + const after = lt.narratedReducer(s, { type: "ADVANCE" }); + assert.deepEqual(after, s); // unchanged — stale-callback guard +}); + +test("narratedReducer: PAUSE/NEXT/PREV/REPLAY", () => { + let s = { mode: "playing", step: 0, total: 3 }; + s = lt.narratedReducer(s, { type: "PAUSE" }); + assert.equal(s.mode, "paused"); + s = lt.narratedReducer(s, { type: "NEXT" }); + assert.deepEqual(s, { mode: "paused", step: 1, total: 3 }); + s = lt.narratedReducer(s, { type: "PREV" }); + assert.deepEqual(s, { mode: "paused", step: 0, total: 3 }); + s = lt.narratedReducer(s, { type: "PREV" }); // clamp at 0 + assert.equal(s.step, 0); + s = lt.narratedReducer({ mode: "done", step: 2, total: 3 }, { type: "REPLAY" }); + assert.deepEqual(s, { mode: "playing", step: 0, total: 3 }); +}); + +test("parseNarrated: repairs literal newlines/tabs inside string values", () => { + // Raw newline + tab inside the mermaid value (invalid JSON as-is). + const body = '{"steps":[{"say":"Build it.","mermaid":"flowchart LR\n\tA-->B\n\tB-->C"}]}'; + const out = lt.parseNarrated(body); + assert.ok(out, "expected a parsed object, got null"); + assert.equal(out.steps.length, 1); + assert.equal(out.steps[0].say, "Build it."); + assert.equal(out.steps[0].mermaid, "flowchart LR\n\tA-->B\n\tB-->C"); +}); + +test("narratedReducer: STOP resets to idle/step 0", () => { + const s = lt.narratedReducer({ mode: "playing", step: 2, total: 4 }, { type: "STOP" }); + assert.deepEqual(s, { mode: "idle", step: 0, total: 4 }); +}); + +test("extractNodeLabels: bracketed flowchart labels", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Query]-->B[Embed]"); + assert.deepEqual(out.slice().sort(), ["Embed", "Query"]); +}); + +test("extractNodeLabels: bare ids when no label declared", () => { + const out = lt.extractNodeLabels("graph TD\n A-->B"); + assert.deepEqual(out.slice().sort(), ["A", "B"]); +}); + +test("extractNodeLabels: ignores edge-label text and direction/header", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Q] -->|yes| B[E]"); + assert.deepEqual(out.slice().sort(), ["E", "Q"]); + assert.equal(out.includes("yes"), false); + assert.equal(out.includes("LR"), false); + assert.equal(out.includes("flowchart"), false); +}); + +test("extractNodeLabels: dedupes by first declaration", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Q]-->B[E]\n B[E]-->A[Q]"); + assert.deepEqual(out.slice().sort(), ["E", "Q"]); +}); + +test("extractNodeLabels: shape variants (rhombus/circle)", () => { + const out = lt.extractNodeLabels("flowchart TD\n A{Decide}-->B((Done))"); + assert.deepEqual(out.slice().sort(), ["Decide", "Done"]); +}); + +test("extractNodeLabels: non-string / empty -> []", () => { + assert.deepEqual(lt.extractNodeLabels(""), []); + assert.deepEqual(lt.extractNodeLabels(null), []); +}); + +test("diffNewNodeLabels: no prev (step 0) -> []", () => { + assert.deepEqual(lt.diffNewNodeLabels(undefined, "flowchart LR\n A[Q]"), []); + assert.deepEqual(lt.diffNewNodeLabels("", "flowchart LR\n A[Q]"), []); +}); + +test("diffNewNodeLabels: returns only newly added label", () => { + const out = lt.diffNewNodeLabels( + "flowchart LR\n A[Q]-->B[E]", + "flowchart LR\n A[Q]-->B[E]-->C[S]" + ); + assert.deepEqual(out, ["S"]); +}); + +test("diffNewNodeLabels: no change -> []", () => { + assert.deepEqual( + lt.diffNewNodeLabels("flowchart LR\n A[Q]", "flowchart LR\n A[Q]"), + [] + ); +}); + +test("extractNodeLabels: adversarial long word-run completes fast (no ReDoS)", () => { + const big = "flowchart LR\n A" + "x".repeat(20000) + " --> B[End]"; + const t0 = Date.now(); + const out = lt.extractNodeLabels(big); + const ms = Date.now() - t0; + assert.ok(Array.isArray(out), "must return an array, never throw/hang"); + assert.ok(out.includes("End"), "still extracts the valid labeled node"); + assert.ok(ms < 1000, "must complete well under 1s (was O(n^2) before cap), took " + ms + "ms"); +}); + +test("extractNodeLabels: strips surrounding quotes (matches rendered text)", () => { + const out = lt.extractNodeLabels('flowchart LR\n A["Build Context"]-->B[Plain]'); + assert.deepEqual(out.slice().sort(), ["Build Context", "Plain"]); +}); + +test("extractNodeLabels: single-quoted label", () => { + const out = lt.extractNodeLabels("flowchart LR\n A['Embed Query']"); + assert.deepEqual(out, ["Embed Query"]); +}); + +test("extractNodeLabels: removes
tags from labels", () => { + const out = lt.extractNodeLabels("flowchart LR\n A[Line1
Line2]-->B[C
D]"); + assert.deepEqual(out.slice().sort(), ["CD", "Line1Line2"]); +}); + +test("diffNewNodeLabels: quoted labels still diff correctly", () => { + const out = lt.diffNewNodeLabels( + 'flowchart LR\n A["Q"]-->B["E"]', + 'flowchart LR\n A["Q"]-->B["E"]-->C["Rerank Hits"]' + ); + assert.deepEqual(out, ["Rerank Hits"]); +}); diff --git a/tests/test_tutor_service.py b/tests/test_tutor_service.py index 0982fd7..08dbae9 100644 --- a/tests/test_tutor_service.py +++ b/tests/test_tutor_service.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock from app.domain.tutor import TutorChatRequest, TutorSubmitRequest, TutorTriageRequest -from app.services.tutor_service import TutorService +from app.services.tutor_service import TutorService, _TUTOR_PERSONA def _make_fake_client(reply_text: str = "hello back") -> MagicMock: @@ -277,5 +277,20 @@ def test_triage_uses_15s_timeout(self) -> None: fake_client.with_options.assert_called_once_with(timeout=15.0) +class TutorPersonaNarratedContract(unittest.TestCase): + def test_persona_documents_lt_narrated_and_single_pass(self) -> None: + self.assertIn("```lt-narrated", _TUTOR_PERSONA) + # JSON shape the widget parses + self.assertIn('"steps"', _TUTOR_PERSONA) + self.assertIn('"say"', _TUTOR_PERSONA) + self.assertIn('"mermaid"', _TUTOR_PERSONA) + # single-pass constraint must be stated + self.assertIn("same reply", _TUTOR_PERSONA.lower()) + # plain mermaid path must still be documented + self.assertIn("```mermaid", _TUTOR_PERSONA) + # over-trigger guard: lt-narrated must be explicitly gated + self.assertIn("teaching point", _TUTOR_PERSONA) + + if __name__ == "__main__": unittest.main()