diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index fc9d58d18..66a6b6877 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -117,6 +117,11 @@ export async function POST(req: NextRequest) { try { startHeartbeat(); + // Default: thinking disabled for low-latency chat. Callers (e.g. eval + // harness) can opt in per-request by sending `thinking: { enabled: true }` + // in the body. + const thinkingConfig: ThinkingConfig = body.thinking ?? { enabled: false }; + const generator = statelessGenerate( { ...body, @@ -124,7 +129,7 @@ export async function POST(req: NextRequest) { }, signal, languageModel, - { enabled: false } satisfies ThinkingConfig, + thinkingConfig, ); for await (const event of generator) { diff --git a/app/eval/whiteboard/page.tsx b/app/eval/whiteboard/page.tsx index 209ca2f27..6007cd909 100644 --- a/app/eval/whiteboard/page.tsx +++ b/app/eval/whiteboard/page.tsx @@ -9,7 +9,7 @@ import type { PPTElement } from '@/lib/types/slides'; const EVAL_STAGE_ID = '__eval_stage__'; const EVAL_SCENE_ID = '__eval_scene__'; const CANVAS_WIDTH = 1000; -const CANVAS_HEIGHT = 562.5; +const CANVAS_HEIGHT = 563; function WhiteboardCanvas() { const [elements, setElements] = useState([]); diff --git a/eval/whiteboard-layout/reporter.ts b/eval/whiteboard-layout/reporter.ts index faba4e289..7ba333e17 100644 --- a/eval/whiteboard-layout/reporter.ts +++ b/eval/whiteboard-layout/reporter.ts @@ -75,6 +75,30 @@ export function generateReport( } lines.push(''); + // Timing summary across all turns in all scenario runs + const allTurnDurations: number[] = []; + for (const scenario of report.scenarios) { + if (scenario.turnDurationsMs) { + for (const ms of scenario.turnDurationsMs) allTurnDurations.push(ms); + } + } + if (allTurnDurations.length > 0) { + const sorted = [...allTurnDurations].sort((a, b) => a - b); + const p50 = sorted[Math.floor(sorted.length * 0.5)]; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + const meanMs = mean(allTurnDurations); + const totalS = allTurnDurations.reduce((a, b) => a + b, 0) / 1000; + lines.push('## Turn latency'); + lines.push('| Metric | Value |'); + lines.push('|--------|-------|'); + lines.push(`| Turns measured | ${allTurnDurations.length} |`); + lines.push(`| Mean | ${(meanMs / 1000).toFixed(2)}s |`); + lines.push(`| p50 | ${(p50 / 1000).toFixed(2)}s |`); + lines.push(`| p95 | ${(p95 / 1000).toFixed(2)}s |`); + lines.push(`| Total across all turns | ${totalS.toFixed(1)}s |`); + lines.push(''); + } + lines.push('## Scenarios'); for (const scenario of report.scenarios) { const lastCp = scenario.checkpoints[scenario.checkpoints.length - 1]; diff --git a/eval/whiteboard-layout/runner.ts b/eval/whiteboard-layout/runner.ts index 64e74ec4d..1ca93df15 100644 --- a/eval/whiteboard-layout/runner.ts +++ b/eval/whiteboard-layout/runner.ts @@ -35,6 +35,8 @@ const { values: args } = parseArgs({ const BASE_URL = args['base-url']!; const CHAT_MODEL_RAW = process.env.EVAL_CHAT_MODEL || process.env.DEFAULT_MODEL; const SCORER_MODEL_RAW = process.env.EVAL_SCORER_MODEL; +const ENABLE_THINKING = + process.env.EVAL_ENABLE_THINKING === '1' || process.env.EVAL_ENABLE_THINKING === 'true'; if (!CHAT_MODEL_RAW) { console.error( 'Error: EVAL_CHAT_MODEL (or DEFAULT_MODEL) must be set. Example: EVAL_CHAT_MODEL=openai:gpt-4.1', @@ -98,12 +100,15 @@ async function runScenario( metadata?: unknown; }> = []; + // Per-turn wall-clock latency around runAgentLoop. Used to compare cost + // when toggling EVAL_ENABLE_THINKING. + const turnDurationsMs: number[] = []; + try { for (let turnIdx = 0; turnIdx < scenario.turns.length; turnIdx++) { const turn = scenario.turns[turnIdx]; console.log(` Turn ${turnIdx + 1}: "${turn.userMessage.slice(0, 50)}..."`); - // Add user message messages.push({ role: 'user', content: turn.userMessage, @@ -127,6 +132,7 @@ async function runScenario( // Use the shared agent loop — same logic as frontend const controller = new AbortController(); + const turnStartMs = Date.now(); await runAgentLoop( { config: scenario.config, @@ -147,10 +153,17 @@ async function runScenario( iterResult = null; actionChain = Promise.resolve(); + // Inject thinking config when EVAL_ENABLE_THINKING is set. + // The chat route defaults to disabled; this opt-in lets us + // measure latency / quality tradeoff without changing prod. + const bodyWithThinking = ENABLE_THINKING + ? { ...body, thinking: { enabled: true } } + : body; + return fetch(`${BASE_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify(bodyWithThinking), signal, }); }, @@ -240,14 +253,20 @@ async function runScenario( controller.signal, MAX_AGENT_TURNS, ); + const turnDurationMs = Date.now() - turnStartMs; + turnDurationsMs.push(turnDurationMs); + console.log( + ` [timing] turn ${turnIdx + 1} ran in ${(turnDurationMs / 1000).toFixed(1)}s`, + ); // Checkpoint: capture + score const isLastTurn = turnIdx === scenario.turns.length - 1; - if (turn.checkpoint || isLastTurn) { + const isCheckpoint = turn.checkpoint || isLastTurn; + + if (isCheckpoint) { const elements = stateManager.getWhiteboardElements(); const screenshotFilename = `run${runIndex}_turn${turnIdx}.png`; const screenshotPath = await captureWhiteboard(elements, scenarioDir, screenshotFilename); - console.log(` Captured: ${screenshotFilename} (${elements.length} elements)`); try { @@ -257,7 +276,6 @@ async function runScenario( } catch (scoreErr) { const msg = scoreErr instanceof Error ? scoreErr.message : String(scoreErr); console.error(` Score error (continuing): ${msg.slice(0, 120)}`); - // Preserve screenshot with null score so the report can still include it checkpoints.push({ turnIndex: turnIdx, screenshotPath, score: null, elements }); } } @@ -265,12 +283,12 @@ async function runScenario( } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(` Error: ${msg}`); - return { scenarioId: scenario.id, runIndex, model, checkpoints, error: msg }; + return { scenarioId: scenario.id, runIndex, model, checkpoints, turnDurationsMs, error: msg }; } finally { stateManager.dispose(); } - return { scenarioId: scenario.id, runIndex, model, checkpoints }; + return { scenarioId: scenario.id, runIndex, model, checkpoints, turnDurationsMs }; } // ==================== Rescore Mode ==================== @@ -331,6 +349,7 @@ async function main() { console.log('=== Whiteboard Layout Eval ==='); console.log(`Chat: ${CHAT_MODEL} | Scorer: ${SCORER_MODEL} | Repeats: ${REPEAT}`); + console.log(`Thinking: ${ENABLE_THINKING ? 'ON' : 'OFF'}`); console.log(''); const scenarios = loadScenarios(); diff --git a/eval/whiteboard-layout/types.ts b/eval/whiteboard-layout/types.ts index 1cea7ce81..fa76ee6a4 100644 --- a/eval/whiteboard-layout/types.ts +++ b/eval/whiteboard-layout/types.ts @@ -60,6 +60,8 @@ export interface ScenarioRunResult { runIndex: number; model: string; checkpoints: CheckpointResult[]; + /** Per-turn wall-clock latency (ms) from runAgentLoop start to end. */ + turnDurationsMs?: number[]; error?: string; } diff --git a/lib/orchestration/prompt-builder.ts b/lib/orchestration/prompt-builder.ts index 467037ccb..47bde46dc 100644 --- a/lib/orchestration/prompt-builder.ts +++ b/lib/orchestration/prompt-builder.ts @@ -202,113 +202,20 @@ ${common} /** * Build role-aware whiteboard guidelines. * - * - Teacher / Assistant: full whiteboard freedom with dedup & coordination rules. - * - Student: whiteboard is opt-in — only use it when explicitly invited by the - * teacher (e.g., "come solve this on the board"), never proactively. + * Content lives in markdown templates under lib/prompts/templates/agent-system-wb-/ + * with the shared reference at lib/prompts/snippets/whiteboard-reference.md. */ function buildWhiteboardGuidelines(role: string): string { - const common = `- Before drawing on the whiteboard, check the "Current State" section below for existing whiteboard elements. -- Do NOT redraw content that already exists — if a formula, chart, concept, or table is already on the whiteboard, reference it instead of duplicating it. -- When adding new elements, calculate positions carefully: check existing elements' coordinates and sizes in the whiteboard state, and ensure at least 20px gap between elements. Canvas size is 1000×562. All elements MUST stay within the canvas boundaries — ensure x >= 0, y >= 0, x + width <= 1000, and y + height <= 562. Never place elements that extend beyond the edges. -- If another agent has already drawn related content, build upon or extend it rather than starting from scratch.`; + const templateId = + role === 'teacher' + ? PROMPT_IDS.AGENT_SYSTEM_WB_TEACHER + : role === 'assistant' + ? PROMPT_IDS.AGENT_SYSTEM_WB_ASSISTANT + : PROMPT_IDS.AGENT_SYSTEM_WB_STUDENT; - const latexGuidelines = ` -### LaTeX Element Sizing (CRITICAL) -LaTeX elements have **auto-calculated width** (width = height × aspectRatio). You control **height**, and the system computes the width to preserve the formula's natural proportions. The height you specify is the ACTUAL rendered height — use it to plan vertical layout. - -**Height guide by formula category:** -| Category | Examples | Recommended height | -|----------|---------|-------------------| -| Inline equations | E=mc^2, a+b=c | 50-80 | -| Equations with fractions | \\frac{-b±√(b²-4ac)}{2a} | 60-100 | -| Integrals / limits | \\int_0^1 f(x)dx, \\lim_{x→0} | 60-100 | -| Summations with limits | \\sum_{i=1}^{n} i^2 | 80-120 | -| Matrices | \\begin{pmatrix}...\\end{pmatrix} | 100-180 | -| Standalone fractions | \\frac{a}{b}, \\frac{1}{2} | 50-80 | -| Nested fractions | \\frac{\\frac{a}{b}}{\\frac{c}{d}} | 80-120 | - -**Key rules:** -- ALWAYS specify height. The height you set is the actual rendered height. -- When placing elements below each other, add height + 20-40px gap. -- Width is auto-computed — long formulas expand horizontally, short ones stay narrow. -- If a formula's auto-computed width exceeds the whiteboard, reduce height. - -**Multi-step derivations:** -Give each step the **same height** (e.g., 70-80px). The system auto-computes width proportionally — all steps render at the same vertical size. - -### LaTeX Support -This project uses KaTeX for formula rendering, which supports virtually all standard LaTeX math commands. You may use any standard LaTeX math command freely. - -- \\text{} can render English text. For non-Latin labels, use a separate TextElement.`; - - if (role === 'teacher') { - return `- Use text elements for notes, steps, and explanations. -- Use chart elements for data visualization (bar charts, line graphs, pie charts, etc.). -- Use latex elements for mathematical formulas and scientific equations. -- Use table elements for structured data, comparisons, and organized information. -- Use code elements for demonstrating code, algorithms, and programming concepts. Code blocks have syntax highlighting and support line-by-line editing. -- Use shape elements sparingly — only for simple diagrams. Do not add large numbers of meaningless shapes. -- Use line elements to connect related elements, draw arrows showing relationships, or annotate diagrams. Specify arrow markers via the points parameter. -- If the whiteboard is too crowded, call wb_clear to wipe it clean before adding new elements. - -### Deleting Elements -- Use wb_delete to remove a specific element by its ID (shown as [id:xxx] in whiteboard state). -- Prefer wb_delete over wb_clear when only 1-2 elements need removal. -- Common use cases: removing an outdated formula before writing the corrected version, clearing a step after explaining it to make room for the next step. - -### Animation-Like Effects with Delete + Draw -All wb_draw_* actions accept an optional **elementId** parameter. When you specify elementId, you can later use wb_delete with that same ID to remove the element. This is essential for creating animation effects. -- To use: add elementId (e.g. "step1", "box_a") when drawing, then wb_delete with that elementId to remove it later. -- Step-by-step reveal: Draw step 1 (elementId:"step1") → speak → delete "step1" → draw step 2 (elementId:"step2") → speak → ... -- State transitions: Draw initial state (elementId:"state") → explain → delete "state" → draw final state -- Progressive diagrams: Draw base diagram → add elements one by one with speech between each -- Example: draw a shape at position A with elementId "obj", explain it, delete "obj", draw the same shape at position B — this creates the illusion of movement. -- Combine wb_delete (by element ID) with wb_draw_* actions to update specific parts without clearing everything. - -### Layout Constraints (IMPORTANT) -The whiteboard canvas is 1000 × 562 pixels. Follow these rules to prevent element overlap: - -**Coordinate system:** -- X range: 0 (left) to 1000 (right), Y range: 0 (top) to 562 (bottom) -- Leave 20px margin from edges (safe area: x 20-980, y 20-542) - -**Spacing rules:** -- Maintain at least 20px gap between adjacent elements -- Vertical stacking: next_y = previous_y + previous_height + 30 -- Side by side: next_x = previous_x + previous_width + 30 - -**Layout patterns:** -- Top-down flow: Start from y=30, stack downward with gaps -- Two-column: Left column x=20-480, right column x=520-980 -- Center single element: x = (1000 - element_width) / 2 - -**Before adding a new element:** -- Check existing elements' positions in the whiteboard state -- Ensure your new element's bounding box does not overlap with any existing element -- If space is insufficient, use wb_delete to remove unneeded elements or wb_clear to start fresh - -### Code Element Layout & Usage -- Code blocks have a **header bar (~32px)** showing the file name and language. The actual code content starts below the header. When calculating vertical space, account for this overhead: effective code area height ≈ element height - 32px. -- Each code line is ~22px tall (at default fontSize 14). Plan height accordingly: a 10-line code block needs about height = 32 (header) + 10 × 22 (lines) + 16 (padding) ≈ 270px. -- Use **wb_edit_code** for step-by-step code demonstrations: draw a skeleton first, then incrementally insert/modify lines with speech between each edit. This creates a "live coding" effect. -- When editing code, reference lines by their stable IDs (L1, L2, ...) shown in the whiteboard state. Do NOT guess line IDs — always check the current whiteboard state first. -${latexGuidelines} -${common}`; + const prompt = buildPrompt(templateId, {}); + if (!prompt) { + throw new Error(`${templateId} template not found`); } - - if (role === 'assistant') { - return `- The whiteboard is primarily the teacher's space. As an assistant, use it sparingly to supplement. -- If the teacher has already set up content on the whiteboard (exercises, formulas, tables), do NOT add parallel derivations or extra formulas — explain verbally instead. -- Only draw on the whiteboard to clarify something the teacher missed, or to add a brief supplementary note that won't clutter the board. -- Limit yourself to at most 1-2 small elements per response. Prefer speech over drawing. -${latexGuidelines} -${common}`; - } - - // Student role: suppress proactive whiteboard usage - return `- The whiteboard is primarily the teacher's space. Do NOT draw on it proactively. -- Only use whiteboard actions when the teacher or user explicitly invites you to write on the board (e.g., "come solve this", "show your work on the whiteboard"). -- If no one asked you to use the whiteboard, express your ideas through speech only. -- When you ARE invited to use the whiteboard, keep it minimal and tidy — add only what was asked for. -${common}`; + return prompt.system; } diff --git a/lib/orchestration/summarizers/state-context.ts b/lib/orchestration/summarizers/state-context.ts index 64b248213..a3b335e2e 100644 --- a/lib/orchestration/summarizers/state-context.ts +++ b/lib/orchestration/summarizers/state-context.ts @@ -1,4 +1,5 @@ import type { StatelessChatRequest } from '@/lib/types/chat'; +import { buildWhiteboardConflicts } from './whiteboard-conflicts'; // ==================== Element Summarization ==================== @@ -163,6 +164,8 @@ export function buildStateContext(storeState: StatelessChatRequest['storeState'] lines.push( `Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`, ); + const conflictsText = buildWhiteboardConflicts(wbElements); + if (conflictsText) lines.push(conflictsText); } return lines.join('\n'); diff --git a/lib/orchestration/summarizers/whiteboard-conflicts.ts b/lib/orchestration/summarizers/whiteboard-conflicts.ts new file mode 100644 index 000000000..094a4f2cd --- /dev/null +++ b/lib/orchestration/summarizers/whiteboard-conflicts.ts @@ -0,0 +1,242 @@ +/** + * Geometric conflict detection for whiteboard elements. + * + * Computes pairwise overlap, line-through-element intersection, and + * canvas-edge clipping from the raw whiteboard JSON, and renders a + * concise text summary for inclusion in the system prompt. + * + * The agent reads bbox coordinates poorly when left to compute + * intersections itself; this surfaces the conflicts directly so the + * model can act on them instead of inferring them. + */ + +const CANVAS_WIDTH = 1000; +const CANVAS_HEIGHT = 563; +const OVERLAP_THRESHOLD = 0.3; // intersection / min-area; flag if >= 30% + +interface BBox { + id: string; + type: string; + label: string; + x: number; + y: number; + w: number; + h: number; +} + +interface LineSeg { + id: string; + label: string; + x1: number; + y1: number; + x2: number; + y2: number; +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, '').trim(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes +function elementLabel(el: any): string { + switch (el.type) { + case 'text': { + const t = stripHtml(el.content || '').slice(0, 24); + return `text "${t}${t.length >= 24 ? '…' : ''}"`; + } + case 'latex': { + const t = String(el.latex || '').slice(0, 24); + return `latex "${t}${t.length >= 24 ? '…' : ''}"`; + } + case 'shape': { + const t = el.text?.content ? stripHtml(el.text.content).slice(0, 16) : ''; + return t ? `shape "${t}"` : 'shape'; + } + case 'table': + return `table ${el.data?.length || 0}×${el.data?.[0]?.length || 0}`; + case 'chart': + return `chart[${el.chartType || 'unknown'}]`; + case 'code': + return `code(${el.language || 'unknown'})`; + case 'image': + return 'image'; + case 'line': { + const pts = el.points as string[] | undefined; + const arrow = pts?.includes('arrow') ? 'arrow' : 'line'; + return arrow; + } + default: + return el.type || 'element'; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement +function toBBox(el: any): BBox | null { + if (el.type === 'line') return null; + if (typeof el.left !== 'number' || typeof el.top !== 'number') return null; + if (typeof el.width !== 'number' || typeof el.height !== 'number') return null; + return { + id: el.id || '', + type: el.type, + label: elementLabel(el), + x: el.left, + y: el.top, + w: el.width, + h: el.height, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTLineElement +function toLineSeg(el: any): LineSeg | null { + if (el.type !== 'line') return null; + const lx = el.left ?? 0; + const ly = el.top ?? 0; + const sx = el.start?.[0] ?? 0; + const sy = el.start?.[1] ?? 0; + const ex = el.end?.[0] ?? 0; + const ey = el.end?.[1] ?? 0; + return { + id: el.id || '', + label: elementLabel(el), + x1: lx + sx, + y1: ly + sy, + x2: lx + ex, + y2: ly + ey, + }; +} + +/** + * Relative overlap = intersection area / min(area_A, area_B). + * 1.0 means one element is fully covered by the other. + */ +function relativeOverlap(a: BBox, b: BBox): number { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.w, b.x + b.w); + const y2 = Math.min(a.y + a.h, b.y + b.h); + if (x2 <= x1 || y2 <= y1) return 0; + const inter = (x2 - x1) * (y2 - y1); + const minArea = Math.min(a.w * a.h, b.w * b.h); + return minArea > 0 ? inter / minArea : 0; +} + +function pointInRect(px: number, py: number, b: BBox): boolean { + return px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h; +} + +/** + * Standard CCW segment-segment intersection (proper crossing only). + */ +function segmentsIntersect( + ax1: number, + ay1: number, + ax2: number, + ay2: number, + bx1: number, + by1: number, + bx2: number, + by2: number, +): boolean { + const ccw = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number) => + (y3 - y1) * (x2 - x1) - (x3 - x1) * (y2 - y1); + const d1 = ccw(bx1, by1, bx2, by2, ax1, ay1); + const d2 = ccw(bx1, by1, bx2, by2, ax2, ay2); + const d3 = ccw(ax1, ay1, ax2, ay2, bx1, by1); + const d4 = ccw(ax1, ay1, ax2, ay2, bx2, by2); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); +} + +function lineCrossesBBox(line: LineSeg, b: BBox): boolean { + if (pointInRect(line.x1, line.y1, b) || pointInRect(line.x2, line.y2, b)) return true; + const edges: Array<[number, number, number, number]> = [ + [b.x, b.y, b.x + b.w, b.y], + [b.x + b.w, b.y, b.x + b.w, b.y + b.h], + [b.x + b.w, b.y + b.h, b.x, b.y + b.h], + [b.x, b.y + b.h, b.x, b.y], + ]; + for (const [ex1, ey1, ex2, ey2] of edges) { + if (segmentsIntersect(line.x1, line.y1, line.x2, line.y2, ex1, ey1, ex2, ey2)) return true; + } + return false; +} + +function shortId(id: string): string { + return id ? `[${id.slice(0, 8)}]` : ''; +} + +/** + * Build a text block listing all detected layout conflicts on the + * current whiteboard. Returns empty string when there are no conflicts + * (so callers can simply concatenate without needing to check). + * + * Detected conflicts: + * - bbox overlap >= 30% of the smaller element's area + * - line/arrow path crossing through any non-line element's bbox + * - any element extending past the 1000×563 canvas bounds + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants +export function buildWhiteboardConflicts(elements: any[]): string { + if (!elements || elements.length === 0) return ''; + + const bboxes: BBox[] = []; + const lines: LineSeg[] = []; + + for (const el of elements) { + if (el?.type === 'line') { + const seg = toLineSeg(el); + if (seg) lines.push(seg); + } else { + const b = toBBox(el); + if (b) bboxes.push(b); + } + } + + const conflicts: string[] = []; + + // Pairwise overlap between bbox elements + for (let i = 0; i < bboxes.length; i++) { + for (let j = i + 1; j < bboxes.length; j++) { + const ratio = relativeOverlap(bboxes[i], bboxes[j]); + if (ratio >= OVERLAP_THRESHOLD) { + conflicts.push( + `OVERLAP: ${bboxes[i].label}${shortId(bboxes[i].id)} and ${bboxes[j].label}${shortId(bboxes[j].id)} share ${Math.round(ratio * 100)}% of the smaller one's area — they sit on top of each other.`, + ); + } + } + } + + // Lines crossing element bboxes + for (const line of lines) { + for (const b of bboxes) { + if (lineCrossesBBox(line, b)) { + conflicts.push( + `LINE CROSSES: ${line.label}${shortId(line.id)} from (${Math.round(line.x1)},${Math.round(line.y1)}) to (${Math.round(line.x2)},${Math.round(line.y2)}) passes through ${b.label}${shortId(b.id)} — the line is drawn over content.`, + ); + } + } + } + + // Edge clipping + for (const b of bboxes) { + const out: string[] = []; + if (b.x < 0) out.push(`left edge by ${Math.round(-b.x)}px`); + if (b.y < 0) out.push(`top edge by ${Math.round(-b.y)}px`); + if (b.x + b.w > CANVAS_WIDTH) + out.push(`right edge by ${Math.round(b.x + b.w - CANVAS_WIDTH)}px`); + if (b.y + b.h > CANVAS_HEIGHT) + out.push(`bottom edge by ${Math.round(b.y + b.h - CANVAS_HEIGHT)}px`); + if (out.length > 0) { + conflicts.push( + `OUT OF CANVAS: ${b.label}${shortId(b.id)} extends past ${out.join(', ')} — content is clipped.`, + ); + } + } + + if (conflicts.length === 0) return ''; + + const lines_out = conflicts.map((c) => ` - ${c}`).join('\n'); + return `\n## ⚠ Layout Conflicts Detected (computed from current whiteboard JSON) +The following geometric conflicts exist on the board RIGHT NOW. Each entry is a real visible problem on the current board. You MUST address these before adding new content — either wb_delete one of the conflicting elements, or wb_clear and start fresh: +${lines_out} +`; +} diff --git a/lib/prompts/README.md b/lib/prompts/README.md index 60b58bc45..6992f7b09 100644 --- a/lib/prompts/README.md +++ b/lib/prompts/README.md @@ -57,7 +57,6 @@ still exists as TS template literals and needs editing directly: |---|---|---| | `ROLE_GUIDELINES` (teacher / assistant / student blocks) | `lib/orchestration/prompt-builder.ts` | Branches by `agentConfig.role` | | Length targets (100 / 80 / 50 chars per role) | `buildLengthGuidelines` in `lib/orchestration/prompt-builder.ts` | Branches by role | -| Whiteboard guidelines (LaTeX sizing table, 1000×562 canvas, layout rules, code block spacing) | `buildWhiteboardGuidelines` in `lib/orchestration/prompt-builder.ts` | Branches by role | These may migrate into snippets in a later pass once Phase 2 eval feedback shows which parts need frequent iteration. diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 027250a4b..6dca06cf7 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -32,6 +32,9 @@ export const PROMPT_IDS = { WIDGET_TEACHER_ACTIONS: 'widget-teacher-actions', PBL_ACTIONS: 'pbl-actions', AGENT_SYSTEM: 'agent-system', + AGENT_SYSTEM_WB_TEACHER: 'agent-system-wb-teacher', + AGENT_SYSTEM_WB_ASSISTANT: 'agent-system-wb-assistant', + AGENT_SYSTEM_WB_STUDENT: 'agent-system-wb-student', DIRECTOR: 'director', PBL_DESIGN: 'pbl-design', } as const satisfies Record; diff --git a/lib/prompts/snippets/whiteboard-reference.md b/lib/prompts/snippets/whiteboard-reference.md new file mode 100644 index 000000000..b78ac4765 --- /dev/null +++ b/lib/prompts/snippets/whiteboard-reference.md @@ -0,0 +1,361 @@ +## Whiteboard Reference + +### Canvas Specifications + +**Dimensions**: 1000 × 563 pixels. + +**Coordinate system**: `x = 0` at the left edge, `x = 1000` at the right edge. `y = 0` at the top, `y = 563` at the bottom. Every element has `(left, top)` at its top-left corner. + +**Safe zone**: keep content within `x ∈ [20, 980]` and `y ∈ [20, 543]` to leave a 20px margin from the canvas edges. + +**Reference points**: +- Centered horizontally: `x = (1000 - width) / 2` +- Centered vertically: `y = (563 - height) / 2` +- Two-column layout: left column `x ∈ [20, 480]`, right column `x ∈ [520, 980]` (40px gutter) + +### JSON Output Context + +Whiteboard actions are `{"type":"action","name":"wb_...", "params":{...}}` items inside the JSON array your response is required to be. All positions are integers (or decimals accepted, but stay in pixel units). + +**LaTeX fields deserve special care — see the "LaTeX JSON Escape" section below.** + +### Action Reference + +For every whiteboard action, the JSON shape below is the **complete, canonical** form. All other prose in this file assumes these shapes. + +#### wb_open + +Open the whiteboard before drawing. Once open, `wb_draw_*` calls auto-render. + +```json +{"type":"action","name":"wb_open","params":{}} +``` + +No parameters. Call before any `wb_draw_*`. Not required before every `wb_draw_*` — only once at the start of a drawing phase. + +#### wb_draw_text + +Place plain text. Use for notes, steps, labels — **not** for math formulas (use `wb_draw_latex` instead). + +```json +{"type":"action","name":"wb_draw_text","params":{"content":"Step 1: identify forces","x":60,"y":60,"width":600,"height":43,"fontSize":18,"color":"#333333"}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `content` | string | yes | Plain text or HTML `

` block. No LaTeX commands. | +| `x` | number | yes | Left edge in pixels. | +| `y` | number | yes | Top edge in pixels. | +| `width` | number | no (default 400) | Text container width. | +| `height` | number | no (default 100) | Text container height. Use the Font Size Table below to pick a matching height. | +| `fontSize` | number | no (default 18) | Point size. Pick from the Font Size Table. | +| `color` | string | no (default `#333333`) | Hex color. | +| `elementId` | string | no | Stable ID for later `wb_delete`. | + +**Common mistake**: embedding LaTeX like `"content":"\\frac{a}{b}"` in a text element — KaTeX is NOT run on text content, so the raw backslash prints. Use `wb_draw_latex` for any math. + +#### wb_draw_shape + +Place a geometric shape. Use for annotations, groupings, or simple diagrams. + +```json +{"type":"action","name":"wb_draw_shape","params":{"shape":"rectangle","x":60,"y":200,"width":200,"height":100,"fillColor":"#5b9bd5"}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `shape` | `"rectangle"` \| `"circle"` \| `"triangle"` | yes | Primitive shape. | +| `x`, `y` | number | yes | Top-left of the shape's bounding box. | +| `width`, `height` | number | yes | Bounding box size. | +| `fillColor` | string | no (default `#5b9bd5`) | Hex fill color. | +| `elementId` | string | no | Stable ID. | + +**Common mistake**: drawing a "parabola" as `wb_draw_shape` with `shape:"triangle"` or as a sequence of `wb_draw_line` segments. Neither renders a curve — there is no function-plot primitive. Prefer explaining algebraically or with a table of key points until this gap is closed. + +#### wb_draw_line + +Draw a straight line or arrow. + +```json +{"type":"action","name":"wb_draw_line","params":{"startX":100,"startY":300,"endX":400,"endY":300,"color":"#333333","width":2,"points":["","arrow"]}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `startX`, `startY` | number | yes | Start coordinates. | +| `endX`, `endY` | number | yes | End coordinates. | +| `color` | string | no (default `#333333`) | Hex color. | +| `width` | number | no (default 2) | **Stroke thickness**, NOT line length. Keep 2–4. | +| `style` | `"solid"` \| `"dashed"` | no (default `"solid"`) | Line style. | +| `points` | `[start, end]` of `""` or `"arrow"` | no (default `["",""]`) | Arrow markers at each end. | +| `elementId` | string | no | Stable ID. | + +**Common mistake**: setting `width` to the desired span (e.g., 300). `width` is stroke thickness; arrow markers scale with it — `width:60` produces a 180×180 arrowhead. + +#### wb_draw_latex + +Render a math formula via KaTeX. + +```json +{"type":"action","name":"wb_draw_latex","params":{"latex":"\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}","x":100,"y":80,"height":80}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `latex` | string | yes | LaTeX source. **Every `\` must be written as `\\` in the JSON string — see "LaTeX JSON Escape" below.** | +| `x`, `y` | number | yes | Top-left. | +| `height` | number | no (default 80) | Preferred rendered height. See the LaTeX Element Height Table below. | +| `width` | number | no (default 400) | Max horizontal space. Auto-computed from height × aspect ratio unless this cap kicks in. | +| `color` | string | no (default `#000000`) | Hex color. | +| `elementId` | string | no | Stable ID. | + +**Most common mistake**: single-backslash commands. If your rendered board shows literal words like `ext`, `heta`, `imes`, `rac`, `ightarrow`, that is the bug. Next response: rewrite with `\\text`, `\\theta`, etc. + +#### wb_draw_chart + +Render a data chart. + +```json +{"type":"action","name":"wb_draw_chart","params":{"chartType":"bar","x":100,"y":150,"width":500,"height":300,"data":{"labels":["Q1","Q2","Q3"],"legends":["Sales"],"series":[[100,120,140]]}}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `chartType` | `"bar"` \| `"column"` \| `"line"` \| `"pie"` \| `"ring"` \| `"area"` \| `"radar"` \| `"scatter"` | yes | Chart kind. | +| `x`, `y`, `width`, `height` | number | yes | Bounding box. | +| `data.labels` | string[] | yes | X-axis labels. | +| `data.legends` | string[] | yes | Series names (one per row in `series`). | +| `data.series` | number[][] | yes | One inner array per legend, length matches `labels`. | +| `themeColors` | string[] | no | Palette override. | +| `elementId` | string | no | Stable ID. | + +**Common mistake**: placing a chart that extends past `x + width = 1000` or `y + height = 563` — charts silently clip at canvas edges. + +#### wb_draw_table + +Render a simple table. + +```json +{"type":"action","name":"wb_draw_table","params":{"x":100,"y":200,"width":500,"height":150,"data":[["Variable","Meaning"],["a","Coefficient of x²"],["b","Coefficient of x"],["c","Constant term"]]}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `x`, `y`, `width`, `height` | number | yes | Bounding box. | +| `data` | string[][] | yes | 2D array. First row is header. All rows same length. | +| `outline` | `{width, style, color}` | no | Border style. | +| `theme` | `{color}` | no | Header color. | +| `elementId` | string | no | Stable ID. | + +**Common mistake**: putting LaTeX into table cells (`"data":[["y = \\frac{1}{2}"]]`). Cell text is rendered as plain text; the backslashes stay. Put the formula in a separate `wb_draw_latex` adjacent to the table. + +#### wb_draw_code + +Draw a code block with syntax highlighting. Includes a ~32px header bar. + +```json +{"type":"action","name":"wb_draw_code","params":{"language":"python","code":"def greet(name):\n print(f'Hello, {name}')","x":100,"y":120,"width":500,"height":120,"fileName":"hello.py","elementId":"code1"}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `language` | string | yes | `"python"`, `"javascript"`, `"typescript"`, `"json"`, `"go"`, `"rust"`, `"java"`, `"c"`, `"cpp"`, etc. | +| `code` | string | yes | Source. Use `\n` for newlines. | +| `x`, `y` | number | yes | Top-left. | +| `width` | number | no (default 500) | | +| `height` | number | no (default 300) | Includes ~32px header. Budget ≈ 32 + 22 per line + 16 padding. | +| `fileName` | string | no | Shown in the header bar. | +| `elementId` | string | no | **Recommended** — lets you edit the block later with `wb_edit_code`. | + +**Common mistake**: underestimating height — a 10-line block needs ~270px. + +#### wb_edit_code + +Modify an existing code block line-by-line. Produces smooth animations — prefer this over redrawing. + +```json +{"type":"action","name":"wb_edit_code","params":{"elementId":"code1","operation":"insert_after","lineId":"L2","content":" return name.upper()"}} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `elementId` | string | yes | Target code block's ID. | +| `operation` | `"insert_after"` \| `"insert_before"` \| `"delete_lines"` \| `"replace_lines"` | yes | Edit operation. | +| `lineId` | string | for inserts | Reference line ID (e.g., `"L2"`) — shown in state. | +| `lineIds` | string[] | for delete/replace | Lines to operate on. | +| `content` | string | for insert/replace | New code. Use `\n` for multiple lines. | + +**Common mistake**: guessing line IDs. Read the current whiteboard state — every code line has a stable ID like `L1`, `L2`, visible in the state context. + +#### wb_delete + +Remove one element by ID. + +```json +{"type":"action","name":"wb_delete","params":{"elementId":"step1"}} +``` + +**Common use**: step-by-step reveals (draw step 1 with `elementId:"step1"`, explain, delete, draw step 2). + +#### wb_clear + +Remove **all** elements from the whiteboard. Use sparingly — prefer `wb_delete` when 1-2 removals would do. + +```json +{"type":"action","name":"wb_clear","params":{}} +``` + +#### wb_close + +Close the whiteboard to reveal the slide canvas. **Do NOT call at the end of a drawing response** — students need time to read. Only close when returning to slide-canvas actions (spotlight/laser). + +```json +{"type":"action","name":"wb_close","params":{}} +``` + +### LaTeX JSON Escape (CRITICAL) + +This is the single highest-leverage rule on the whiteboard. Read it before every math-heavy response. + +**The rule**: in any JSON string containing LaTeX — the `latex` param of `wb_draw_latex`, or a `content` param that happens to contain `\\text{...}` — **every backslash must be written as `\\` (two characters)** in your JSON output. When the JSON parser reads `"\text"` it interprets `\t` as an ASCII TAB control character, so by the time KaTeX receives your string it is literally `ext{...}` — no `\text` command, just garbage. + +Characters at risk (first character of the LaTeX command collides with a JSON escape): + +| Control | JSON escape | LaTeX commands corrupted | +|---|---|---| +| TAB (`\t`) | `\t` | `\text`, `\theta`, `\times`, `\tau`, `\top`, `\tan` | +| CR (`\r`) | `\r` | `\rightarrow`, `\Rightarrow`, `\rho`, `\right`, `\real` | +| FF (`\f`) | `\f` | `\frac`, `\forall`, `\Phi`, `\phi`, `\flat` | +| BS (`\b`) | `\b` | `\beta`, `\binom`, `\bar`, `\bot` | +| VT (`\v`) | `\v` | `\varphi`, `\vec`, `\vdots`, `\vee`, `\varepsilon` | +| LF (`\n`) | `\n` | `\neq`, `\ni`, `\not`, `\notin` | + +**Correctness table** (what you write in JSON → what KaTeX renders): + +| LaTeX source | ❌ Wrong in JSON | ✅ Right in JSON | +|---|---|---| +| `\frac{a}{b}` | `"\frac{a}{b}"` | `"\\frac{a}{b}"` | +| `\text{合规}` | `"\text{合规}"` | `"\\text{合规}"` | +| `\theta` | `"\theta"` | `"\\theta"` | +| `\times` | `"\times"` | `"\\times"` | +| `\rightarrow` | `"\rightarrow"` | `"\\rightarrow"` | +| `\Rightarrow` | `"\Rightarrow"` | `"\\Rightarrow"` | +| `\circ` | `"\circ"` | `"\\circ"` | +| `\tau` | `"\tau"` | `"\\tau"` | +| `\forall` | `"\forall"` | `"\\forall"` | +| `\beta` | `"\beta"` | `"\\beta"` | +| `\varphi` | `"\varphi"` | `"\\varphi"` | +| `\sqrt{x}` | `"\sqrt{x}"` | `"\\sqrt{x}"` | +| `a^2 + b^2 = c^2` | `"a^2 + b^2 = c^2"` | `"a^2 + b^2 = c^2"` (no backslash — stays the same) | + +**Self-check heuristic**: if your previous turn's rendered whiteboard shows literal tokens like `ext`, `heta`, `imes`, `rac`, `irc`, `ightarrow`, `orall`, `eta`, `arphi`, `eq`, you emitted single-backslash LaTeX. In this turn, emit the same formula again with double backslashes, via `wb_delete` + `wb_draw_latex`, or `wb_clear` + redraw. + +**Good complete example**: + +```json +{"type":"action","name":"wb_draw_latex","params":{"latex":"\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}","x":100,"y":80,"height":80}} +``` + +Renders as: the standard quadratic formula. Count the backslashes in the JSON: 4 pairs of `\\`. Each pair is one backslash in the actual LaTeX string, which is what KaTeX needs. + +**Bad example** (this is what produces the `ext`-style garbage): + +```json +{"type":"action","name":"wb_draw_latex","params":{"latex":"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}","x":100,"y":80,"height":80}} +``` + +The JSON parser sees `\f` (form feed), `\p` (kept as `\p`), `\s` (kept as `\s`). KaTeX then receives a broken string where `\frac` is gone. Whether KaTeX complains or silently renders wrong, the board is broken. + +### Bounds & Overlap + +The canvas is **1000 × 563**. Elements that extend past the edges are clipped. + +**Hard bounds** (every element): +- `x ≥ 0` and `x + width ≤ 1000` +- `y ≥ 0` and `y + height ≤ 563` + +**Safe zone** (preferred): `20 ≤ x`, `x + width ≤ 980`, `20 ≤ y`, `y + height ≤ 542`. + +**Spacing**: +- Minimum gap between adjacent elements: 20px +- Vertical stacking: `next.y = prev.y + prev.height + 30` +- Side-by-side: `next.x = prev.x + prev.width + 30` + +**Two-column layout**: +- Left column: `x ∈ [20, 480]`, width ≤ 460 +- Right column: `x ∈ [520, 980]`, width ≤ 460 +- Gutter: 40px + +**Before placing every element, walk the existing elements** (listed in the "Current State" section of your context). For each existing `(x, y, width, height)`: + +- Reject if the new bbox would cover > 30% of its area. +- If space is tight, choose one: `wb_delete` the existing element, shrink the new element, or pick a free region by scanning the canvas quadrants. + +**Worked example** — adding a formula below an existing chart at (100, 80) size 500×200: + +``` +chart occupies x=100..600, y=80..280 +next safe y = 80 + 200 + 30 = 310 +formula at (100, 310, height 80) → occupies y=310..390 +check: y + height = 390 ≤ 563 ✓ +check: no overlap with chart (chart ends at y=280, formula starts at y=310) ✓ +``` + +### Font Size Table + +For `wb_draw_text`: + +| Content type | `fontSize` | +|---|---| +| Whiteboard title | 28-32 | +| Section heading | 20-24 | +| Body / annotation | 16-18 | +| Caption / fine print | 12-14 | + +Keep 2-4px between adjacent hierarchy levels. **Do not use free-form sizes like 8, 11, 48, 64** — pick from this table. + +For a given `fontSize` and 1-line text, a matching `height` is roughly `ceil(fontSize × 1.5) + 20` (1.5 line-height plus 10px top/bottom padding). + +**Pair text and LaTeX by visual weight.** A LaTeX element at `height:80` visually weighs ~28px text; do NOT place 14px captions next to it. Use this table: + +| LaTeX `height` | Companion text `fontSize` | +|---|---| +| 50-60 | 16-20 | +| 70-80 | 20-24 | +| 90-110 | 24-28 | +| 120+ | 28-32 | + +When a formula and annotation sit on the same board, their visual weights should match. Large formula next to tiny caption looks broken. + +### LaTeX Element Height Table + +For `wb_draw_latex` — use the category that best matches your formula: + +| Category | Examples | `height` | +|---|---|---| +| Inline equations | `E=mc^2`, `a+b=c` | 50-80 | +| With fractions | `\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}` | 60-100 | +| Integrals / limits | `\\int_0^1 f(x)dx`, `\\lim_{x \\to 0}` | 60-100 | +| Summations with limits | `\\sum_{i=1}^{n} i^2` | 80-120 | +| Matrices | `\\begin{pmatrix}a & b \\\\ c & d\\end{pmatrix}` | 100-180 | +| Standalone fractions | `\\frac{a}{b}` | 50-80 | +| Nested fractions | `\\frac{\\frac{a}{b}}{\\frac{c}{d}}` | 80-120 | + +Width is auto-computed from `height × aspect_ratio`; `width` acts as a horizontal cap only. + +**Multi-step derivations**: give every step the same `height` so they render at matching vertical sizes. Widths will differ — that's correct; it reflects each step's horizontal complexity. + +### Pre-Output Checklist + +Before emitting whiteboard actions, mentally walk through these: + +1. **[LaTeX escape]** Every `\` in `latex` params or in any text with math is written as `\\` in the JSON. Scan for single-backslash `\frac`, `\text`, `\theta`, `\times`, `\rightarrow`, `\circ`, `\beta`, `\varphi` — none should appear. +2. **[Hard bounds]** For each element: `x ≥ 0`, `y ≥ 0`, `x + width ≤ 1000`, `y + height ≤ 563`. +3. **[Overlap]** Walk existing elements from the state; new bbox overlaps none by more than 30%. If tight, `wb_delete` first. +4. **[Font consistency]** Every `fontSize` comes from the Font Size Table (28-32 / 20-24 / 16-18 / 12-14). No 8, 11, 48, 64. +5. **[LaTeX height]** Every `wb_draw_latex` `height` matches the formula category (see the LaTeX Height Table). +6. **[Redraw guard]** The element is not already on the whiteboard — if the state lists a formula/chart/table matching your intent, reference it instead of redrawing. +7. **[Element type]** Math expressions use `wb_draw_latex`. Plain text uses `wb_draw_text`. Never embed LaTeX commands in text. +8. **[Safe zone]** Where possible, stay within `x ∈ [20, 980]`, `y ∈ [20, 543]`. +9. **[Leave whiteboard open]** Do not call `wb_close` at the end of a drawing turn. Students need to read. +10. **[Visual weight pairing]** Text that sits next to a LaTeX formula uses a `fontSize` matched to the LaTeX `height` per the pairing table above. No tiny 12-14px text next to height-80 formulas. diff --git a/lib/prompts/templates/agent-system-wb-assistant/system.md b/lib/prompts/templates/agent-system-wb-assistant/system.md new file mode 100644 index 000000000..9d481faa4 --- /dev/null +++ b/lib/prompts/templates/agent-system-wb-assistant/system.md @@ -0,0 +1,30 @@ +# Whiteboard — Teaching Assistant Role + +The whiteboard is primarily the teacher's space. Use it sparingly — **at most 1-2 small supplementary elements per response**. + +## What to contribute + +- A brief annotation that clarifies something the teacher missed (e.g., a unit label, a sign). +- A one-line example that pairs with the teacher's abstract formula. +- A small text callout for a subtle point. + +## What NOT to do + +- Parallel derivations or alternative formulas competing with the teacher's. +- Duplicating something already on the board. +- Large tables, charts, or multi-step diagrams — those are the teacher's job. +- Clearing the board or deleting the teacher's elements. + +## Speech over drawing + +When in doubt, clarify verbally. Your `type:"text"` items do your real work; whiteboard actions are a last-resort visual aid. + +## Layout conflicts + +Check the "⚠ Layout Conflicts Detected" list (computed from the whiteboard JSON) above for occupied space. Pick coordinates that produce zero new conflict entries. + +- If conflicts already exist on the board (list non-empty), this turn is **speech-only** — do not add to a board the teacher needs to fix. +- Never call `wb_clear`. Never `wb_delete` an element you did not draw this turn — repair is the teacher's job. +- If the board is crowded (≥6 elements already, regardless of conflicts), this turn is speech-only. + +{{snippet:whiteboard-reference}} diff --git a/lib/prompts/templates/agent-system-wb-student/system.md b/lib/prompts/templates/agent-system-wb-student/system.md new file mode 100644 index 000000000..99cb6abfd --- /dev/null +++ b/lib/prompts/templates/agent-system-wb-student/system.md @@ -0,0 +1,20 @@ +# Whiteboard — Student Role + +**Default: do not touch the whiteboard.** Express your ideas through speech only. + +## When invited + +The teacher or user may explicitly invite you to the board with phrases like "come solve this", "show your work on the whiteboard", "try it yourself". Only in those cases should you use whiteboard actions. + +When invited: +- Keep your contribution minimal and tidy — solve only what was asked. +- Don't add decorative or exploratory elements. +- Leave the board open when you're done (no `wb_close`). + +## Layout conflicts + +If invited to draw, check the "⚠ Layout Conflicts Detected" list (computed from the whiteboard JSON) above. Pick coordinates that add zero new entries to the list, leaving 40px clearance from every existing element. If no such spot exists, say so verbally and skip drawing. + +- Never write on top of existing content. Never `wb_clear` or `wb_delete`. + +{{snippet:whiteboard-reference}} diff --git a/lib/prompts/templates/agent-system-wb-teacher/system.md b/lib/prompts/templates/agent-system-wb-teacher/system.md new file mode 100644 index 000000000..92f60118a --- /dev/null +++ b/lib/prompts/templates/agent-system-wb-teacher/system.md @@ -0,0 +1,35 @@ +# Whiteboard — Teacher Role + +You lead the classroom. The whiteboard is a supporting visual — use it to anchor the **one key idea** of each explanation, not to exhaustively document every detail. + +## Core discipline + +**Draw conservatively. 1-3 elements per response.** If your point can be made verbally, do that instead; the board does not need to mirror your speech. + +Before every response, look at "Current State" / "Whiteboard Changes This Round": + +- If the board already holds the visual you need → reference it in speech ("see the formula on the right"); do not re-draw. +- If the board is full of content from prior turns → call `wb_clear` first; a crowded board loses meaning. +- If you cannot place a new element without overlapping existing elements by more than 30% → `wb_delete` the specific element you want to replace first, do not stack. + +## Layout conflicts + +The "⚠ Layout Conflicts Detected" block (computed from the JSON) above lists any `OVERLAP:`, `LINE CROSSES:`, or `OUT OF CANVAS:` pairs that exist on the board right now. + +**Default: do nothing about existing elements.** No "tidying", no re-aligning — add only what this turn's content needs. Subjective improvements ("could be more compact", "would look nicer centered") are NOT reasons to act. + +**Only when the conflict list is non-empty**: your first action this turn must be `wb_delete` for the offending elementId, or `wb_clear` if 3+ conflicts exist. Don't add new elements until the listed conflicts are resolved. + +## Animated step reveals + +Every `wb_draw_*` accepts `elementId`. To animate a multi-step explanation: draw step 1 with `elementId:"step1"`, narrate; next turn delete `step1` and draw step 2. This replaces drawing many elements with drawing few elements that evolve. + +## Code demonstrations + +For code, always set an `elementId` on first `wb_draw_code`. For subsequent changes use `wb_edit_code` with that ID — never re-draw the whole block. + +## Keep the board open + +Do NOT call `wb_close` at the end of a drawing turn. Students need time to read. Only close when returning to the slide canvas for `spotlight` / `laser`. + +{{snippet:whiteboard-reference}} diff --git a/lib/prompts/types.ts b/lib/prompts/types.ts index 39eb6eb1f..604ba067d 100644 --- a/lib/prompts/types.ts +++ b/lib/prompts/types.ts @@ -22,6 +22,9 @@ export type PromptId = | 'widget-teacher-actions' | 'pbl-actions' | 'agent-system' + | 'agent-system-wb-teacher' + | 'agent-system-wb-assistant' + | 'agent-system-wb-student' | 'director' | 'pbl-design'; @@ -32,7 +35,8 @@ export type SnippetId = | 'json-output-rules' | 'element-types' | 'action-types' - | 'speech-guidelines'; + | 'speech-guidelines' + | 'whiteboard-reference'; /** * Loaded prompt template diff --git a/lib/types/chat.ts b/lib/types/chat.ts index 6d9c06d34..8a5b98c20 100644 --- a/lib/types/chat.ts +++ b/lib/types/chat.ts @@ -6,6 +6,7 @@ */ import type { UIMessage } from 'ai'; +import type { ThinkingConfig } from './provider'; // Session Types export type SessionType = 'qa' | 'discussion' | 'lecture'; @@ -280,6 +281,12 @@ export interface StatelessChatRequest { baseUrl?: string; model?: string; providerType?: string; + /** + * Opt-in: enable provider-side thinking for this request. Default is + * `{ enabled: false }` (low-latency chat). Eval harness sets this to + * `{ enabled: true }` when `EVAL_ENABLE_THINKING=1`. + */ + thinking?: ThinkingConfig; } /** diff --git a/tests/orchestration/whiteboard-conflicts.test.ts b/tests/orchestration/whiteboard-conflicts.test.ts new file mode 100644 index 000000000..255e59875 --- /dev/null +++ b/tests/orchestration/whiteboard-conflicts.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'vitest'; +import { buildWhiteboardConflicts } from '@/lib/orchestration/summarizers/whiteboard-conflicts'; + +// Minimal PPTElement stand-ins — the summarizer only reads geometry fields. +const text = (id: string, left: number, top: number, width: number, height: number) => ({ + type: 'text', + id, + left, + top, + width, + height, + content: '

sample

', +}); + +const table = (id: string, left: number, top: number, width: number, height: number) => ({ + type: 'table', + id, + left, + top, + width, + height, + data: [[{ text: 'a' }]], +}); + +const line = ( + id: string, + left: number, + top: number, + start: [number, number], + end: [number, number], +) => ({ type: 'line', id, left, top, start, end }); + +describe('buildWhiteboardConflicts — no conflicts', () => { + test('empty element list returns empty string', () => { + expect(buildWhiteboardConflicts([])).toBe(''); + }); + + test('two well-separated elements return empty string', () => { + const out = buildWhiteboardConflicts([ + text('t1', 20, 20, 200, 60), + text('t2', 400, 200, 200, 60), + ]); + expect(out).toBe(''); + }); + + test('just-touching bboxes (intersection area = 0) are not reported', () => { + const out = buildWhiteboardConflicts([ + text('t1', 0, 0, 100, 100), + text('t2', 100, 0, 100, 100), // shares only the x=100 edge + ]); + expect(out).toBe(''); + }); + + test('line routed clear of all elements produces no conflict', () => { + const out = buildWhiteboardConflicts([ + text('t1', 100, 100, 200, 60), + line('l1', 0, 0, [50, 50], [50, 400]), + ]); + expect(out).toBe(''); + }); +}); + +describe('buildWhiteboardConflicts — bbox overlap', () => { + test('one element fully inside another reports ~100% overlap', () => { + const out = buildWhiteboardConflicts([ + table('big', 0, 0, 500, 400), + text('small', 50, 50, 100, 80), // entirely inside the table + ]); + expect(out).toContain('OVERLAP:'); + expect(out).toContain('100%'); + }); + + test('50% overlap is reported; 10% is not (30% threshold)', () => { + // Each bbox 100×100; smaller area = 10000. Overlap area = 50×100 = 5000 → 50%. + const overlapping = buildWhiteboardConflicts([ + text('a', 0, 0, 100, 100), + text('b', 50, 0, 100, 100), + ]); + expect(overlapping).toContain('OVERLAP:'); + expect(overlapping).toContain('50%'); + + // Overlap area = 10×100 = 1000 → 10% — below threshold. + const tiny = buildWhiteboardConflicts([text('a', 0, 0, 100, 100), text('b', 90, 0, 100, 100)]); + expect(tiny).toBe(''); + }); + + test('non-line elements without width/height are skipped, not crashed', () => { + const out = buildWhiteboardConflicts([ + text('t1', 0, 0, 100, 100), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { type: 'text', id: 'broken', left: 10, top: 10 } as any, // missing width/height + ]); + // Only one valid element remaining → no overlap to report. + expect(out).toBe(''); + }); +}); + +describe('buildWhiteboardConflicts — line crossing elements', () => { + test('line passing through the middle of a text box is reported', () => { + const out = buildWhiteboardConflicts([ + text('t1', 100, 100, 200, 60), // covers x∈[100,300], y∈[100,160] + line('l1', 0, 0, [0, 130], [400, 130]), // horizontal line through y=130, cuts the box + ]); + expect(out).toContain('LINE CROSSES:'); + expect(out).toContain('t1'); + }); + + test('line whose endpoint is inside a bbox is reported', () => { + const out = buildWhiteboardConflicts([ + text('t1', 100, 100, 200, 60), + line('l1', 0, 0, [50, 50], [200, 130]), // endpoint (200,130) is inside t1 + ]); + expect(out).toContain('LINE CROSSES:'); + }); + + test('line with endpoints on opposite sides of a box but path above the box is clean', () => { + const out = buildWhiteboardConflicts([ + text('t1', 100, 100, 200, 60), + line('l1', 0, 0, [50, 50], [400, 50]), // y=50, above the box (y∈[100,160]) + ]); + expect(out).toBe(''); + }); +}); + +describe('buildWhiteboardConflicts — canvas edge clipping', () => { + test('element extending past right edge is reported', () => { + const out = buildWhiteboardConflicts([text('wide', 900, 100, 200, 60)]); + expect(out).toContain('OUT OF CANVAS:'); + expect(out).toContain('right edge by 100px'); + }); + + test('element extending past bottom edge is reported (canvas height = 563)', () => { + const out = buildWhiteboardConflicts([text('tall', 100, 500, 100, 80)]); + expect(out).toContain('OUT OF CANVAS:'); + expect(out).toContain('bottom edge by 17px'); // 500+80-563 = 17 + }); + + test('element with negative left is reported', () => { + const out = buildWhiteboardConflicts([text('negx', -10, 100, 50, 50)]); + expect(out).toContain('OUT OF CANVAS:'); + expect(out).toContain('left edge by 10px'); + }); + + test('element exactly at right edge (x+w == 1000) is NOT reported', () => { + const out = buildWhiteboardConflicts([text('edge', 900, 100, 100, 60)]); + expect(out).toBe(''); + }); + + test('element exactly at bottom edge (y+h == 563) is NOT reported', () => { + const out = buildWhiteboardConflicts([text('edge', 100, 500, 100, 63)]); + expect(out).toBe(''); + }); +}); + +describe('buildWhiteboardConflicts — output format', () => { + test('renders a single markdown block with a header and bullet list', () => { + const out = buildWhiteboardConflicts([text('a', 0, 0, 100, 100), text('b', 50, 0, 100, 100)]); + expect(out).toMatch(/## ⚠ Layout Conflicts Detected/); + expect(out).toMatch(/\n {2}- OVERLAP:/); + }); + + test('lists multiple conflicts in one block', () => { + const out = buildWhiteboardConflicts([ + text('a', 0, 0, 100, 100), + text('b', 50, 0, 100, 100), // overlap with a + text('outside', 950, 100, 200, 60), // out of canvas + ]); + const bullets = out.split('\n').filter((l) => l.trim().startsWith('- ')); + expect(bullets.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/prompts/templates.test.ts b/tests/prompts/templates.test.ts index d5191ad8a..5209a3bb4 100644 --- a/tests/prompts/templates.test.ts +++ b/tests/prompts/templates.test.ts @@ -129,6 +129,23 @@ describe('role dispatch', () => { expect(out).toContain('TEACHING ASSISTANT'); expect(out).not.toContain('LEAD TEACHER'); }); + + test('teacher whiteboard prompt is sourced from agent-system-wb-teacher template', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + expect(out).toContain('Whiteboard — Teacher Role'); + }); + + test('assistant whiteboard prompt is sourced from agent-system-wb-assistant template', () => { + const assistantAgent: AgentConfig = { ...baseAgent, role: 'assistant' }; + const out = buildStructuredPrompt(assistantAgent, slideState); + expect(out).toContain('Whiteboard — Teaching Assistant Role'); + }); + + test('student whiteboard prompt is sourced from agent-system-wb-student template', () => { + const studentAgent: AgentConfig = { ...baseAgent, role: 'student' }; + const out = buildStructuredPrompt(studentAgent, slideState); + expect(out).toContain('Whiteboard — Student Role'); + }); }); describe('scene-type action stripping', () => { @@ -261,3 +278,37 @@ describe('placeholder naming convention lint', () => { expect(offenders).toEqual([]); }); }); + +describe('whiteboard-reference snippet is wired into every role', () => { + const KEY_SECTIONS = [ + 'Canvas Specifications', + 'Action Reference', + 'LaTeX JSON Escape (CRITICAL)', + 'Bounds & Overlap', + 'Font Size Table', + 'Pre-Output Checklist', + ]; + + test('teacher prompt contains every key whiteboard-reference section', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + for (const section of KEY_SECTIONS) { + expect(out).toContain(section); + } + }); + + test('assistant prompt contains every key whiteboard-reference section', () => { + const assistantAgent: AgentConfig = { ...baseAgent, role: 'assistant' }; + const out = buildStructuredPrompt(assistantAgent, slideState); + for (const section of KEY_SECTIONS) { + expect(out).toContain(section); + } + }); + + test('student prompt contains every key whiteboard-reference section', () => { + const studentAgent: AgentConfig = { ...baseAgent, role: 'student' }; + const out = buildStructuredPrompt(studentAgent, slideState); + for (const section of KEY_SECTIONS) { + expect(out).toContain(section); + } + }); +});