diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts index 4bcf53e8e..61659b919 100644 --- a/app/api/generate/scene-outlines-stream/route.ts +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -14,7 +14,7 @@ import { NextRequest } from 'next/server'; import { streamLLM } from '@/lib/ai/llm'; -import { buildPrompt, PROMPT_IDS } from '@/lib/generation/prompts'; +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import { formatImageDescription, formatImagePlaceholder, diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index e620e13f0..a0d72d977 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -11,7 +11,7 @@ import type { PdfImage, ImageMapping, } from '@/lib/types/generation'; -import { buildPrompt, PROMPT_IDS } from './prompts'; +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters'; import { parseJsonResponse } from './json-repair'; import { uniquifyMediaElementIds } from './scene-builder'; diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index d216c2daa..32fc07038 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -20,12 +20,12 @@ import type { WidgetOutline, } from '@/lib/types/generation'; import type { WidgetType, WidgetConfig, TeacherAction } from '@/lib/types/widgets'; -import type { PromptId } from './prompts/types'; +import type { PromptId } from '@/lib/prompts/types'; import type { LanguageModel } from 'ai'; import type { StageStore } from '@/lib/api/stage-api'; import { createStageAPI } from '@/lib/api/stage-api'; import { generatePBLContent } from '@/lib/pbl/generate-pbl'; -import { buildPrompt, PROMPT_IDS } from './prompts'; +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import { postProcessInteractiveHtml } from './interactive-post-processor'; import { parseActionsFromStructuredOutput } from './action-parser'; import { parseJsonResponse } from './json-repair'; diff --git a/lib/orchestration/director-graph.ts b/lib/orchestration/director-graph.ts index d992073b7..1bc003b7e 100644 --- a/lib/orchestration/director-graph.ts +++ b/lib/orchestration/director-graph.ts @@ -28,14 +28,12 @@ import type { StatelessChatRequest } from '@/lib/types/chat'; import type { ThinkingConfig } from '@/lib/types/provider'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; -import { - buildStructuredPrompt, - summarizeConversation, - convertMessagesToOpenAI, -} from './prompt-builder'; +import { buildStructuredPrompt } from './prompt-builder'; +import { summarizeConversation } from './summarizers/conversation-summary'; +import { convertMessagesToOpenAI } from './summarizers/message-converter'; import { buildDirectorPrompt, parseDirectorDecision } from './director-prompt'; import { getEffectiveActions } from './tool-schemas'; -import type { AgentTurnSummary, WhiteboardActionRecord } from './director-prompt'; +import type { AgentTurnSummary, WhiteboardActionRecord } from './types'; import { parseStructuredChunk, createParserState, finalizeParser } from './stateless-generate'; import { createLogger } from '@/lib/logger'; diff --git a/lib/orchestration/director-prompt.ts b/lib/orchestration/director-prompt.ts index 38c92826b..8cf4de8a5 100644 --- a/lib/orchestration/director-prompt.ts +++ b/lib/orchestration/director-prompt.ts @@ -7,42 +7,11 @@ import type { AgentConfig } from '@/lib/orchestration/registry/types'; import { createLogger } from '@/lib/logger'; +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; +import type { WhiteboardActionRecord, AgentTurnSummary } from './types'; const log = createLogger('DirectorPrompt'); -/** - * A single whiteboard action performed by an agent, recorded in the ledger. - */ -export interface WhiteboardActionRecord { - actionName: - | 'wb_draw_text' - | 'wb_draw_shape' - | 'wb_draw_chart' - | 'wb_draw_latex' - | 'wb_draw_table' - | 'wb_draw_line' - | 'wb_draw_code' - | 'wb_edit_code' - | 'wb_clear' - | 'wb_delete' - | 'wb_open' - | 'wb_close'; - agentId: string; - agentName: string; - params: Record; -} - -/** - * Summary of an agent's turn in the current round - */ -export interface AgentTurnSummary { - agentId: string; - agentName: string; - contentPreview: string; - actionCount: number; - whiteboardActions: WhiteboardActionRecord[]; -} - /** * Build the system prompt for the director agent * @@ -89,10 +58,6 @@ This is a student-initiated discussion, not a Q&A session.\n` ? `1. The discussion initiator${triggerAgentId ? ` ("${triggerAgentId}")` : ''} should speak first to kick off the topic. Then the teacher responds to guide the discussion. After that, other students may add their perspectives.` : "1. The teacher (role: teacher, highest priority) should usually speak first to address the user's question or topic."; - // Build whiteboard state section for director awareness - const whiteboardSection = buildWhiteboardStateForDirector(whiteboardLedger); - - // Build student profile section for director awareness const studentProfileSection = userProfile?.nickname || userProfile?.bio ? ` @@ -102,41 +67,25 @@ ${userProfile.bio ? `Background: ${userProfile.bio}` : ''} ` : ''; - return `You are the Director of a multi-agent classroom. Your job is to decide which agent should speak next based on the conversation context. - -# Available Agents -${agentList} - -# Agents Who Already Spoke This Round -${respondedList} - -# Conversation Context -${conversationSummary} -${discussionSection}${whiteboardSection}${studentProfileSection} -# Rules -${rule1} -2. After the teacher, consider whether a student agent would add value (ask a follow-up question, crack a joke, take notes, offer a different perspective). -3. Do NOT repeat an agent who already spoke this round unless absolutely necessary. -4. If the conversation seems complete (question answered, topic covered), output END. -5. Current turn: ${turnCount + 1}. Consider conversation length — don't let discussions drag on unnecessarily. -6. Prefer brevity — 1-2 agents responding is usually enough. Don't force every agent to speak. -7. You can output {"next_agent":"USER"} to cue the user to speak. Use this when a student asks the user a direct question or when the topic naturally calls for user input. -8. Consider whiteboard state when routing: if the whiteboard is already crowded, avoid dispatching agents that are likely to add more whiteboard content unless they would clear or organize it. -9. Whiteboard is currently ${whiteboardOpen ? 'OPEN (slide canvas is hidden — spotlight/laser will not work)' : 'CLOSED (slide canvas is visible)'}. When the whiteboard is open, do not expect spotlight or laser actions to have visible effect. - -# Routing Quality (CRITICAL) -- ROLE DIVERSITY: Do NOT dispatch two agents of the same role consecutively. After a teacher speaks, the next should be a student or assistant — not another teacher-like response. After an assistant rephrases, dispatch a student who asks a question, not another assistant who also rephrases. -- CONTENT DEDUP: Read the "Agents Who Already Spoke" previews carefully. If an agent already explained a concept thoroughly, do NOT dispatch another agent to explain the same concept. Instead, dispatch an agent who will ASK a question, CHALLENGE an assumption, CONNECT to another topic, or TAKE NOTES. -- DISCUSSION PROGRESSION: Each new agent should advance the conversation. Good progression: explain → question → deeper explanation → different perspective → summary. Bad progression: explain → re-explain → rephrase → paraphrase. -- GREETING RULE: If any agent has already greeted the students, no subsequent agent should greet again. Check the previews for greetings. + const vars = { + agentList, + respondedList, + conversationSummary, + discussionSection, + whiteboardSection: buildWhiteboardStateForDirector(whiteboardLedger), + studentProfileSection, + rule1, + turnCountPlusOne: turnCount + 1, + whiteboardOpenText: whiteboardOpen + ? 'OPEN (slide canvas is hidden — spotlight/laser will not work)' + : 'CLOSED (slide canvas is visible)', + }; -# Output Format -You MUST output ONLY a JSON object, nothing else: -{"next_agent":""} -or -{"next_agent":"USER"} -or -{"next_agent":"END"}`; + const prompt = buildPrompt(PROMPT_IDS.DIRECTOR, vars); + if (!prompt) { + throw new Error('director prompt template failed to load'); + } + return prompt.system; } /** diff --git a/lib/orchestration/prompt-builder.ts b/lib/orchestration/prompt-builder.ts index 636dc8d1e..467037ccb 100644 --- a/lib/orchestration/prompt-builder.ts +++ b/lib/orchestration/prompt-builder.ts @@ -6,8 +6,12 @@ import type { StatelessChatRequest } from '@/lib/types/chat'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; -import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt'; +import type { WhiteboardActionRecord, AgentTurnSummary } from './types'; import { getActionDescriptions, getEffectiveActions } from './tool-schemas'; +import { buildStateContext } from './summarizers/state-context'; +import { buildVirtualWhiteboardContext } from './summarizers/whiteboard-ledger'; +import { buildPeerContextSection } from './summarizers/peer-context'; +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; // ==================== Role Guidelines ==================== @@ -48,36 +52,62 @@ interface DiscussionContext { prompt?: string; } -// ==================== Peer Context ==================== +// ==================== Per-variant string constants ==================== -/** - * Build a context section summarizing what other agents said this round. - * Returns empty string if no agents have spoken yet. - */ -function buildPeerContextSection( +const FORMAT_EXAMPLE_SLIDE = `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Your natural speech to students"}]`; +const FORMAT_EXAMPLE_WB = `[{"type":"action","name":"wb_open","params":{}},{"type":"text","content":"Your natural speech to students"}]`; + +const ORDERING_SLIDE = `- spotlight/laser actions should appear BEFORE the corresponding text object (point first, then speak) +- whiteboard actions can interleave WITH text objects (draw while speaking)`; +const ORDERING_WB = `- whiteboard actions can interleave WITH text objects (draw while speaking)`; + +const SPOTLIGHT_EXAMPLES = `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Photosynthesis is the process by which plants convert light energy into chemical energy. Take a look at this diagram."},{"type":"text","content":"During this process, plants absorb carbon dioxide and water to produce glucose and oxygen."}] + +[{"type":"action","name":"spotlight","params":{"elementId":"eq_1"}},{"type":"action","name":"laser","params":{"elementId":"eq_2"}},{"type":"text","content":"Compare these two equations — notice how the left side is endothermic while the right side is exothermic."}] + +`; + +const SLIDE_ACTION_GUIDELINES = `- spotlight: Use to focus attention on ONE key element. Don't overuse — max 1-2 per response. +- laser: Use to point at elements. Good for directing attention during explanations. +`; + +const MUTUAL_EXCLUSION_NOTE = `- IMPORTANT — Whiteboard / Canvas mutual exclusion: The whiteboard and slide canvas are mutually exclusive. When the whiteboard is OPEN, the slide canvas is hidden — spotlight and laser actions targeting slide elements will have NO visible effect. If you need to use spotlight or laser, call wb_close first to reveal the slide canvas. Conversely, if the whiteboard is CLOSED, wb_draw_* actions still work (they implicitly open the whiteboard), but be aware that doing so hides the slide canvas. +- Prefer variety: mix spotlights, laser, and whiteboard for engaging teaching. Don't use the same action type repeatedly.`; + +// ==================== Private helpers ==================== + +function buildStudentProfileSection(userProfile?: { nickname?: string; bio?: string }): string { + if (!userProfile?.nickname && !userProfile?.bio) return ''; + return `\n# Student Profile +You are teaching ${userProfile.nickname || 'a student'}.${userProfile.bio ? `\nTheir background: ${userProfile.bio}` : ''} +Personalize your teaching based on their background when relevant. Address them by name naturally.\n`; +} + +function buildLanguageConstraint(langDirective?: string): string { + return langDirective ? `\n# Language (CRITICAL)\n${langDirective}\n` : ''; +} + +function buildDiscussionContextSection( + discussionContext: DiscussionContext | undefined, agentResponses: AgentTurnSummary[] | undefined, - currentAgentName: string, ): string { - if (!agentResponses || agentResponses.length === 0) return ''; - - // Filter out self (defensive — director shouldn't dispatch same agent twice) - const peers = agentResponses.filter((r) => r.agentName !== currentAgentName); - if (peers.length === 0) return ''; + if (!discussionContext) return ''; + if (agentResponses && agentResponses.length > 0) { + return ` - const peerLines = peers.map((r) => `- ${r.agentName}: "${r.contentPreview}"`).join('\n'); +# Discussion Context +Topic: "${discussionContext.topic}" +${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''} +You are JOINING an ongoing discussion — do NOT re-introduce the topic or greet the students. The discussion has already started. Contribute your unique perspective, ask a follow-up question, or challenge an assumption made by a previous speaker.`; + } return ` -# This Round's Context (CRITICAL — READ BEFORE RESPONDING) -The following agents have already spoken in this discussion round: -${peerLines} - -You are ${currentAgentName}, responding AFTER the agents above. You MUST: -1. NOT repeat greetings or introductions — they have already been made -2. NOT restate what previous speakers already explained -3. Add NEW value from YOUR unique perspective as ${currentAgentName} -4. Build on, question, or extend what was said — do not echo it -5. If you agree with a previous point, say so briefly and then ADD something new -`; + +# Discussion Context +You are initiating a discussion on the following topic: "${discussionContext.topic}" +${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''} + +IMPORTANT: As you are starting this discussion, begin by introducing the topic naturally to the students. Engage them and invite their thoughts. Do not wait for user input - you speak first.`; } // ==================== System Prompt ==================== @@ -103,152 +133,35 @@ export function buildStructuredPrompt( ? storeState.scenes.find((s) => s.id === storeState.currentSceneId) : undefined; const sceneType = currentScene?.type; - - // Filter actions by scene type (spotlight/laser only available on slides) const effectiveActions = getEffectiveActions(agentConfig.allowedActions, sceneType); - const actionDescriptions = getActionDescriptions(effectiveActions); - - // Build context about current state - const stateContext = buildStateContext(storeState); - - // Build virtual whiteboard context from ledger (shows changes by other agents this round) - const virtualWbContext = buildVirtualWhiteboardContext(storeState, whiteboardLedger); - - // Build student profile section (only when nickname or bio is present) - const studentProfileSection = - userProfile?.nickname || userProfile?.bio - ? `\n# Student Profile -You are teaching ${userProfile.nickname || 'a student'}.${userProfile.bio ? `\nTheir background: ${userProfile.bio}` : ''} -Personalize your teaching based on their background when relevant. Address them by name naturally.\n` - : ''; - - // Build peer context section (what agents already said this round) - const peerContext = buildPeerContextSection(agentResponses, agentConfig.name); - - // Whether spotlight/laser are available (only on slide scenes) const hasSlideActions = effectiveActions.includes('spotlight') || effectiveActions.includes('laser'); - // Build format example based on available actions - const formatExample = hasSlideActions - ? `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Your natural speech to students"}]` - : `[{"type":"action","name":"wb_open","params":{}},{"type":"text","content":"Your natural speech to students"}]`; - - // Ordering principles - const orderingPrinciples = hasSlideActions - ? `- spotlight/laser actions should appear BEFORE the corresponding text object (point first, then speak) -- whiteboard actions can interleave WITH text objects (draw while speaking)` - : `- whiteboard actions can interleave WITH text objects (draw while speaking)`; - - // Good examples — include spotlight/laser examples only for slide scenes - const spotlightExamples = hasSlideActions - ? `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Photosynthesis is the process by which plants convert light energy into chemical energy. Take a look at this diagram."},{"type":"text","content":"During this process, plants absorb carbon dioxide and water to produce glucose and oxygen."}] - -[{"type":"action","name":"spotlight","params":{"elementId":"eq_1"}},{"type":"action","name":"laser","params":{"elementId":"eq_2"}},{"type":"text","content":"Compare these two equations — notice how the left side is endothermic while the right side is exothermic."}] - -` - : ''; - - // Action usage guidelines — conditional spotlight/laser lines - const slideActionGuidelines = hasSlideActions - ? `- spotlight: Use to focus attention on ONE key element. Don't overuse — max 1-2 per response. -- laser: Use to point at elements. Good for directing attention during explanations. -` - : ''; - - const mutualExclusionNote = hasSlideActions - ? `- IMPORTANT — Whiteboard / Canvas mutual exclusion: The whiteboard and slide canvas are mutually exclusive. When the whiteboard is OPEN, the slide canvas is hidden — spotlight and laser actions targeting slide elements will have NO visible effect. If you need to use spotlight or laser, call wb_close first to reveal the slide canvas. Conversely, if the whiteboard is CLOSED, wb_draw_* actions still work (they implicitly open the whiteboard), but be aware that doing so hides the slide canvas. -- Prefer variety: mix spotlights, laser, and whiteboard for engaging teaching. Don't use the same action type repeatedly.` - : ''; - - const roleGuideline = ROLE_GUIDELINES[agentConfig.role] || ROLE_GUIDELINES.student; - - // Build language constraint from stage language directive - const langDirective = storeState.stage?.languageDirective; - const languageConstraint = langDirective ? `\n# Language (CRITICAL)\n${langDirective}\n` : ''; - - return `# Role -You are ${agentConfig.name}. - -## Your Personality -${agentConfig.persona} - -## Your Classroom Role -${roleGuideline} -${studentProfileSection}${peerContext}${languageConstraint} -# Output Format -You MUST output a JSON array for ALL responses. Each element is an object with a \`type\` field: - -${formatExample} - -## Format Rules -1. Output a single JSON array — no explanation, no code fences -2. \`type:"action"\` objects contain \`name\` and \`params\` -3. \`type:"text"\` objects contain \`content\` (speech text) -4. Action and text objects can freely interleave in any order -5. The \`]\` closing bracket marks the end of your response -6. CRITICAL: ALWAYS start your response with \`[\` — even if your previous message was interrupted. Never continue a partial response as plain text. Every response must be a complete, independent JSON array. - -## Ordering Principles -${orderingPrinciples} - -## Speech Guidelines (CRITICAL) -- Effects fire concurrently with your speech — students see results as you speak -- Text content is what you SAY OUT LOUD to students - natural teaching speech -- Do NOT say "let me add...", "I'll create...", "now I'm going to..." -- Do NOT describe your actions - just speak naturally as a teacher -- Students see action results appear on screen - you don't need to announce them -- Your speech should flow naturally regardless of whether actions succeed or fail -- NEVER use markdown formatting (blockquotes >, headings #, bold **, lists -, code blocks) in text content — it is spoken aloud, not rendered - -## Length & Style (CRITICAL) -${buildLengthGuidelines(agentConfig.role)} - -### Good Examples -${spotlightExamples}[{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_text","params":{"content":"Step 1: 6CO₂ + 6H₂O → C₆H₁₂O₆ + 6O₂","x":100,"y":100,"fontSize":24}},{"type":"text","content":"Look at this chemical equation — notice how the reactants and products correspond."}] - -[{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_latex","params":{"latex":"\\\\frac{-b \\\\pm \\\\sqrt{b^2-4ac}}{2a}","x":100,"y":80,"width":500}},{"type":"text","content":"This is the quadratic formula — it can solve any quadratic equation."},{"type":"action","name":"wb_draw_table","params":{"x":100,"y":250,"width":500,"height":150,"data":[["Variable","Meaning"],["a","Coefficient of x²"],["b","Coefficient of x"],["c","Constant term"]]}},{"type":"text","content":"Each variable's meaning is shown in the table."}] - -### Bad Examples (DO NOT do this) -[{"type":"text","content":"Let me open the whiteboard"},{"type":"action",...}] (Don't announce actions!) -[{"type":"text","content":"I'm going to draw a diagram for you..."}] (Don't describe what you're doing!) -[{"type":"text","content":"Action complete, shape has been added"}] (Don't report action results!) - -## Whiteboard Guidelines -${buildWhiteboardGuidelines(agentConfig.role)} - -# Available Actions -${actionDescriptions} - -## Action Usage Guidelines -${slideActionGuidelines}- Whiteboard actions (wb_open, wb_draw_text, wb_draw_shape, wb_draw_chart, wb_draw_latex, wb_draw_table, wb_draw_line, wb_draw_code, wb_edit_code, wb_delete, wb_clear, wb_close): Use when explaining concepts that benefit from diagrams, formulas, data charts, tables, connecting lines, code demonstrations, or step-by-step derivations. Use wb_draw_latex for math formulas, wb_draw_chart for data visualization, wb_draw_table for structured data, wb_draw_code for code demonstrations. -- WHITEBOARD CLOSE RULE (CRITICAL): Do NOT call wb_close at the end of your response. Leave the whiteboard OPEN so students can read what you drew. Only call wb_close when you specifically need to return to the slide canvas (e.g., to use spotlight or laser on slide elements). Frequent open/close is distracting. -- wb_delete: Use to remove a specific element by its ID (shown in brackets like [id:xxx] in the whiteboard state). Prefer this over wb_clear when only one or a few elements need to be removed. -- wb_draw_code / wb_edit_code: To modify an existing code block, ALWAYS use wb_edit_code (insert_after, insert_before, delete_lines, replace_lines) instead of deleting the code element and re-creating it. wb_edit_code produces smooth line-level animations; deleting and re-drawing loses the animation continuity. Only use wb_draw_code for creating a brand-new code block. -${mutualExclusionNote} - -# Current State -${stateContext} -${virtualWbContext} -Remember: Speak naturally as a teacher. Effects fire concurrently with your speech.${ - discussionContext - ? agentResponses && agentResponses.length > 0 - ? ` - -# Discussion Context -Topic: "${discussionContext.topic}" -${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''} - -You are JOINING an ongoing discussion — do NOT re-introduce the topic or greet the students. The discussion has already started. Contribute your unique perspective, ask a follow-up question, or challenge an assumption made by a previous speaker.` - : ` - -# Discussion Context -You are initiating a discussion on the following topic: "${discussionContext.topic}" -${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''} - -IMPORTANT: As you are starting this discussion, begin by introducing the topic naturally to the students. Engage them and invite their thoughts. Do not wait for user input - you speak first.` - : '' - }`; + const vars = { + agentName: agentConfig.name, + persona: agentConfig.persona, + roleGuideline: ROLE_GUIDELINES[agentConfig.role] || ROLE_GUIDELINES.student, + studentProfileSection: buildStudentProfileSection(userProfile), + peerContext: buildPeerContextSection(agentResponses, agentConfig.name), + languageConstraint: buildLanguageConstraint(storeState.stage?.languageDirective), + formatExample: hasSlideActions ? FORMAT_EXAMPLE_SLIDE : FORMAT_EXAMPLE_WB, + orderingPrinciples: hasSlideActions ? ORDERING_SLIDE : ORDERING_WB, + spotlightExamples: hasSlideActions ? SPOTLIGHT_EXAMPLES : '', + actionDescriptions: getActionDescriptions(effectiveActions), + slideActionGuidelines: hasSlideActions ? SLIDE_ACTION_GUIDELINES : '', + mutualExclusionNote: hasSlideActions ? MUTUAL_EXCLUSION_NOTE : '', + stateContext: buildStateContext(storeState), + virtualWhiteboardContext: buildVirtualWhiteboardContext(storeState, whiteboardLedger), + lengthGuidelines: buildLengthGuidelines(agentConfig.role), + whiteboardGuidelines: buildWhiteboardGuidelines(agentConfig.role), + discussionContextSection: buildDiscussionContextSection(discussionContext, agentResponses), + }; + + const prompt = buildPrompt(PROMPT_IDS.AGENT_SYSTEM, vars); + if (!prompt) { + throw new Error('agent-system template not found'); + } + return prompt.system; } // ==================== Length Guidelines ==================== @@ -399,492 +312,3 @@ ${common}`; - When you ARE invited to use the whiteboard, keep it minimal and tidy — add only what was asked for. ${common}`; } - -// ==================== Element Summarization ==================== - -/** - * Strip HTML tags to extract plain text - */ -function stripHtml(html: string): string { - return html.replace(/<[^>]*>/g, '').trim(); -} - -/** - * Summarize a single PPT element into a one-line description - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes -function summarizeElement(el: any): string { - const id = el.id ? `[id:${el.id}]` : ''; - const pos = `at (${Math.round(el.left)},${Math.round(el.top)})`; - const size = - el.width != null && el.height != null - ? ` size ${Math.round(el.width)}×${Math.round(el.height)}` - : el.width != null - ? ` w=${Math.round(el.width)}` - : ''; - - switch (el.type) { - case 'text': { - const text = stripHtml(el.content || '').slice(0, 60); - const suffix = text.length >= 60 ? '...' : ''; - return `${id} text${el.textType ? `[${el.textType}]` : ''}: "${text}${suffix}" ${pos}${size}`; - } - case 'image': { - const src = el.src?.startsWith('data:') ? '[embedded]' : el.src?.slice(0, 50) || 'unknown'; - return `${id} image: ${src} ${pos}${size}`; - } - case 'shape': { - const shapeText = el.text?.content ? stripHtml(el.text.content).slice(0, 40) : ''; - return `${id} shape${shapeText ? `: "${shapeText}"` : ''} ${pos}${size}`; - } - case 'chart': - return `${id} chart[${el.chartType}]: labels=[${(el.data?.labels || []).slice(0, 4).join(',')}] ${pos}${size}`; - case 'table': { - const rows = el.data?.length || 0; - const cols = el.data?.[0]?.length || 0; - return `${id} table: ${rows}x${cols} ${pos}${size}`; - } - case 'latex': - return `${id} latex: "${(el.latex || '').slice(0, 40)}" ${pos}${size}`; - case 'line': { - const lx = Math.round(el.left ?? 0); - const ly = Math.round(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} line: (${lx + sx},${ly + sy}) → (${lx + ex},${ly + ey})`; - } - case 'code': { - const lang = el.language || 'unknown'; - const lineCount = el.lines?.length || 0; - const codeFn = el.fileName ? ` "${el.fileName}"` : ''; - const linePreview = (el.lines || []) - .slice(0, 10) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((l: any) => ` ${l.id}: ${l.content}`) - .join('\n'); - const moreLines = lineCount > 10 ? `\n ... and ${lineCount - 10} more lines` : ''; - return `${id} code${codeFn} (${lang}, ${lineCount} lines) ${pos}${size}\n${linePreview}${moreLines}`; - } - case 'video': - return `${id} video ${pos}${size}`; - case 'audio': - return `${id} audio ${pos}${size}`; - default: - return `${id} ${el.type || 'unknown'} ${pos}${size}`; - } -} - -/** - * Summarize an array of elements into line descriptions - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes -function summarizeElements(elements: any[]): string { - if (elements.length === 0) return ' (empty)'; - - const lines = elements.map((el, i) => ` ${i + 1}. ${summarizeElement(el)}`); - - return lines.join('\n'); -} - -// ==================== Virtual Whiteboard Context ==================== - -/** - * Tracked element from replaying the whiteboard ledger - */ -interface VirtualWhiteboardElement { - agentName: string; - summary: string; - elementId?: string; // Present for elements from initial whiteboard state -} - -/** - * Replay the whiteboard ledger to build an attributed element list. - * - * - wb_clear resets the accumulated elements - * - wb_draw_* appends a new element with the agent's name - * - wb_open / wb_close are ignored (structural, not content) - * - * Returns empty string when the ledger is empty (zero extra token overhead). - */ -function buildVirtualWhiteboardContext( - storeState: StatelessChatRequest['storeState'], - ledger?: WhiteboardActionRecord[], -): string { - if (!ledger || ledger.length === 0) return ''; - - // Replay ledger to build current element list - const elements: VirtualWhiteboardElement[] = []; - - for (const record of ledger) { - switch (record.actionName) { - case 'wb_clear': - elements.length = 0; - break; - case 'wb_delete': { - // Remove element by matching elementId from initial whiteboard state - // (elements drawn this round don't have tracked IDs) - const deleteId = String(record.params.elementId || ''); - const idx = elements.findIndex((el) => el.elementId === deleteId); - if (idx >= 0) elements.splice(idx, 1); - break; - } - case 'wb_draw_text': { - const content = String(record.params.content || '').slice(0, 40); - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 400; - const h = record.params.height ?? 100; - elements.push({ - agentName: record.agentName, - summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`, - }); - break; - } - case 'wb_draw_shape': { - const shapeType = record.params.type || record.params.shape || 'rectangle'; - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 100; - const h = record.params.height ?? 100; - elements.push({ - agentName: record.agentName, - summary: `shape(${shapeType}) at (${x},${y}), size ${w}x${h}`, - }); - break; - } - case 'wb_draw_chart': { - const chartType = record.params.chartType || record.params.type || 'bar'; - const labels = Array.isArray(record.params.labels) - ? record.params.labels - : (record.params.data as Record)?.labels; - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 350; - const h = record.params.height ?? 250; - elements.push({ - agentName: record.agentName, - summary: `chart(${chartType})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${x},${y}), size ${w}x${h}`, - }); - break; - } - case 'wb_draw_latex': { - const latex = String(record.params.latex || '').slice(0, 40); - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 400; - // Estimate latex height: ~80px default for single-line, more for complex formulas - const h = record.params.height ?? 80; - elements.push({ - agentName: record.agentName, - summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`, - }); - break; - } - case 'wb_draw_table': { - const data = record.params.data as unknown[][] | undefined; - const rows = data?.length || 0; - const cols = (data?.[0] as unknown[])?.length || 0; - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 400; - const h = record.params.height ?? rows * 40 + 20; - elements.push({ - agentName: record.agentName, - summary: `table(${rows}×${cols}) at (${x},${y}), size ${w}x${h}`, - }); - break; - } - case 'wb_draw_line': { - const sx = record.params.startX ?? '?'; - const sy = record.params.startY ?? '?'; - const ex = record.params.endX ?? '?'; - const ey = record.params.endY ?? '?'; - const pts = record.params.points as string[] | undefined; - const hasArrow = pts?.includes('arrow') ? ' (arrow)' : ''; - elements.push({ - agentName: record.agentName, - summary: `line${hasArrow}: (${sx},${sy}) → (${ex},${ey})`, - }); - break; - } - case 'wb_draw_code': { - const lang = String(record.params.language || ''); - const codeFileName = record.params.fileName ? ` "${record.params.fileName}"` : ''; - const x = record.params.x ?? '?'; - const y = record.params.y ?? '?'; - const w = record.params.width ?? 500; - const h = record.params.height ?? 300; - const code = String(record.params.code || ''); - const lineCount = code.split('\n').length; - elements.push({ - agentName: record.agentName, - summary: `code block${codeFileName} (${lang}, ${lineCount} lines) at (${x},${y}), size ${w}x${h}`, - }); - break; - } - case 'wb_edit_code': { - const op = record.params.operation || 'edit'; - const targetId = record.params.elementId || '?'; - elements.push({ - agentName: record.agentName, - summary: `edited code "${targetId}" (${op})`, - }); - break; - } - // wb_open, wb_close — skip - } - } - - if (elements.length === 0) return ''; - - const elementLines = elements - .map((el, i) => ` ${i + 1}. [by ${el.agentName}] ${el.summary}`) - .join('\n'); - - return ` -## Whiteboard Changes This Round (IMPORTANT) -Other agents have modified the whiteboard during this discussion round. -Current whiteboard elements (${elements.length}): -${elementLines} - -DO NOT redraw content that already exists. Check positions above before adding new elements. -`; -} - -// ==================== State Context ==================== - -/** - * Build context string from store state - */ -function buildStateContext(storeState: StatelessChatRequest['storeState']): string { - const { stage, scenes, currentSceneId, mode, whiteboardOpen } = storeState; - - const lines: string[] = []; - - // Mode - lines.push(`Mode: ${mode}`); - - // Whiteboard status - lines.push( - `Whiteboard: ${whiteboardOpen ? 'OPEN (slide canvas is hidden)' : 'closed (slide canvas is visible)'}`, - ); - - // Stage info - if (stage) { - lines.push( - `Course: ${stage.name || 'Untitled'}${stage.description ? ` - ${stage.description}` : ''}`, - ); - } - - // Scenes summary - lines.push(`Total scenes: ${scenes.length}`); - - if (currentSceneId) { - const currentScene = scenes.find((s) => s.id === currentSceneId); - if (currentScene) { - lines.push( - `Current scene: "${currentScene.title}" (${currentScene.type}, id: ${currentSceneId})`, - ); - - // Slide scene: include element details - if (currentScene.content.type === 'slide') { - const elements = currentScene.content.canvas.elements; - lines.push(`Current slide elements (${elements.length}):\n${summarizeElements(elements)}`); - } - - // Quiz scene: include question summary - if (currentScene.content.type === 'quiz') { - const questions = currentScene.content.questions; - const qSummary = questions - .slice(0, 5) - .map((q, i) => ` ${i + 1}. [${q.type}] ${q.question.slice(0, 80)}`) - .join('\n'); - lines.push( - `Quiz questions (${questions.length}):\n${qSummary}${questions.length > 5 ? `\n ... and ${questions.length - 5} more` : ''}`, - ); - } - } - } else if (scenes.length > 0) { - lines.push('No scene currently selected'); - } - - // List first few scenes - if (scenes.length > 0) { - const sceneSummary = scenes - .slice(0, 5) - .map((s, i) => ` ${i + 1}. ${s.title} (${s.type}, id: ${s.id})`) - .join('\n'); - lines.push( - `Scenes:\n${sceneSummary}${scenes.length > 5 ? `\n ... and ${scenes.length - 5} more` : ''}`, - ); - } - - // Whiteboard content (last whiteboard in the stage) - if (stage?.whiteboard && stage.whiteboard.length > 0) { - const lastWb = stage.whiteboard[stage.whiteboard.length - 1]; - const wbElements = lastWb.elements || []; - lines.push( - `Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`, - ); - } - - return lines.join('\n'); -} - -// ==================== Conversation Summary ==================== - -/** - * OpenAI message format (used by director) - */ -interface OpenAIMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -/** - * Summarize conversation history for the director agent - * - * Produces a condensed text summary of the last N messages, - * truncating long messages and including role labels. - * - * @param messages - OpenAI-format messages to summarize - * @param maxMessages - Maximum number of recent messages to include (default 10) - * @param maxContentLength - Maximum content length per message (default 200) - */ -export function summarizeConversation( - messages: OpenAIMessage[], - maxMessages = 10, - maxContentLength = 200, -): string { - if (messages.length === 0) { - return 'No conversation history yet.'; - } - - const recent = messages.slice(-maxMessages); - const lines = recent.map((msg) => { - const roleLabel = - msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; - const content = - msg.content.length > maxContentLength - ? msg.content.slice(0, maxContentLength) + '...' - : msg.content; - return `[${roleLabel}] ${content}`; - }); - - return lines.join('\n'); -} - -// ==================== Message Conversion ==================== - -/** - * Convert UI messages to OpenAI format - * Includes tool call information so the model knows what actions were taken - */ -export function convertMessagesToOpenAI( - messages: StatelessChatRequest['messages'], - currentAgentId?: string, -): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { - return messages - .filter((msg) => msg.role === 'user' || msg.role === 'assistant') - .map((msg) => { - if (msg.role === 'assistant') { - // Assistant messages use JSON array format to serve as few-shot examples - // that match the expected output format from the system prompt - const items: Array<{ type: string; [key: string]: string }> = []; - - if (msg.parts) { - for (const part of msg.parts) { - const p = part as Record; - - if (p.type === 'text' && p.text) { - items.push({ type: 'text', content: p.text as string }); - } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') { - const actionName = (p.actionName || - (p.type as string).replace('action-', '')) as string; - const output = p.output as Record | undefined; - const isSuccess = output?.success === true; - const resultSummary = isSuccess - ? output?.data - ? `result: ${JSON.stringify(output.data).slice(0, 100)}` - : 'success' - : (output?.error as string) || 'failed'; - items.push({ - type: 'action', - name: actionName, - result: resultSummary, - }); - } - } - } - - const content = items.length > 0 ? JSON.stringify(items) : ''; - const msgAgentId = msg.metadata?.agentId; - - // When currentAgentId is provided and this message is from a DIFFERENT agent, - // convert to user role with agent name attribution - if (currentAgentId && msgAgentId && msgAgentId !== currentAgentId) { - const agentName = msg.metadata?.senderName || msgAgentId; - return { - role: 'user' as const, - content: content ? `[${agentName}]: ${content}` : '', - }; - } - - return { - role: 'assistant' as const, - content, - }; - } - - // User messages: keep plain text concatenation - const contentParts: string[] = []; - - if (msg.parts) { - for (const part of msg.parts) { - const p = part as Record; - - if (p.type === 'text' && p.text) { - contentParts.push(p.text as string); - } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') { - const actionName = (p.actionName || - (p.type as string).replace('action-', '')) as string; - const output = p.output as Record | undefined; - const isSuccess = output?.success === true; - const resultSummary = isSuccess - ? output?.data - ? `result: ${JSON.stringify(output.data).slice(0, 100)}` - : 'success' - : (output?.error as string) || 'failed'; - contentParts.push(`[Action ${actionName}: ${resultSummary}]`); - } - } - } - - // Extract speaker name from metadata (e.g. other agents' messages in discussion) - const senderName = msg.metadata?.senderName; - let content = contentParts.join('\n'); - if (senderName) { - content = `[${senderName}]: ${content}`; - } - - // Annotate interrupted messages so the LLM knows context was cut short - const isInterrupted = - (msg as unknown as Record).metadata && - ((msg as unknown as Record).metadata as Record) - ?.interrupted; - return { - role: 'user' as const, - content: isInterrupted - ? `${content}\n[This response was interrupted — do NOT continue it. Start a new JSON array response.]` - : content, - }; - }) - .filter((msg) => { - // Drop empty messages and messages with only dots/ellipsis/whitespace - // (produced by failed agent streams) - const stripped = msg.content.replace(/[.\s…]+/g, ''); - return stripped.length > 0; - }); -} diff --git a/lib/orchestration/stateless-generate.ts b/lib/orchestration/stateless-generate.ts index 56c69e043..3631a8819 100644 --- a/lib/orchestration/stateless-generate.ts +++ b/lib/orchestration/stateless-generate.ts @@ -21,7 +21,7 @@ import type { LanguageModel } from 'ai'; import type { StatelessChatRequest, StatelessEvent, ParsedAction } from '@/lib/types/chat'; import type { ThinkingConfig } from '@/lib/types/provider'; -import type { WhiteboardActionRecord } from './director-prompt'; +import type { WhiteboardActionRecord } from './types'; import { createOrchestrationGraph, buildInitialState } from './director-graph'; import { parse as parsePartialJson, Allow } from 'partial-json'; import { jsonrepair } from 'jsonrepair'; diff --git a/lib/orchestration/summarizers/conversation-summary.ts b/lib/orchestration/summarizers/conversation-summary.ts new file mode 100644 index 000000000..70e50b780 --- /dev/null +++ b/lib/orchestration/summarizers/conversation-summary.ts @@ -0,0 +1,42 @@ +// ==================== Conversation Summary ==================== + +/** + * OpenAI message format (used by director) + */ +export interface OpenAIMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** + * Summarize conversation history for the director agent + * + * Produces a condensed text summary of the last N messages, + * truncating long messages and including role labels. + * + * @param messages - OpenAI-format messages to summarize + * @param maxMessages - Maximum number of recent messages to include (default 10) + * @param maxContentLength - Maximum content length per message (default 200) + */ +export function summarizeConversation( + messages: OpenAIMessage[], + maxMessages = 10, + maxContentLength = 200, +): string { + if (messages.length === 0) { + return 'No conversation history yet.'; + } + + const recent = messages.slice(-maxMessages); + const lines = recent.map((msg) => { + const roleLabel = + msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; + const content = + msg.content.length > maxContentLength + ? msg.content.slice(0, maxContentLength) + '...' + : msg.content; + return `[${roleLabel}] ${content}`; + }); + + return lines.join('\n'); +} diff --git a/lib/orchestration/summarizers/message-converter.ts b/lib/orchestration/summarizers/message-converter.ts new file mode 100644 index 000000000..187246eae --- /dev/null +++ b/lib/orchestration/summarizers/message-converter.ts @@ -0,0 +1,114 @@ +import type { StatelessChatRequest } from '@/lib/types/chat'; + +// ==================== Message Conversion ==================== + +/** + * Convert UI messages to OpenAI format + * Includes tool call information so the model knows what actions were taken + */ +export function convertMessagesToOpenAI( + messages: StatelessChatRequest['messages'], + currentAgentId?: string, +): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { + return messages + .filter((msg) => msg.role === 'user' || msg.role === 'assistant') + .map((msg) => { + if (msg.role === 'assistant') { + // Assistant messages use JSON array format to serve as few-shot examples + // that match the expected output format from the system prompt + const items: Array<{ type: string; [key: string]: string }> = []; + + if (msg.parts) { + for (const part of msg.parts) { + const p = part as Record; + + if (p.type === 'text' && p.text) { + items.push({ type: 'text', content: p.text as string }); + } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') { + const actionName = (p.actionName || + (p.type as string).replace('action-', '')) as string; + const output = p.output as Record | undefined; + const isSuccess = output?.success === true; + const resultSummary = isSuccess + ? output?.data + ? `result: ${JSON.stringify(output.data).slice(0, 100)}` + : 'success' + : (output?.error as string) || 'failed'; + items.push({ + type: 'action', + name: actionName, + result: resultSummary, + }); + } + } + } + + const content = items.length > 0 ? JSON.stringify(items) : ''; + const msgAgentId = msg.metadata?.agentId; + + // When currentAgentId is provided and this message is from a DIFFERENT agent, + // convert to user role with agent name attribution + if (currentAgentId && msgAgentId && msgAgentId !== currentAgentId) { + const agentName = msg.metadata?.senderName || msgAgentId; + return { + role: 'user' as const, + content: content ? `[${agentName}]: ${content}` : '', + }; + } + + return { + role: 'assistant' as const, + content, + }; + } + + // User messages: keep plain text concatenation + const contentParts: string[] = []; + + if (msg.parts) { + for (const part of msg.parts) { + const p = part as Record; + + if (p.type === 'text' && p.text) { + contentParts.push(p.text as string); + } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') { + const actionName = (p.actionName || + (p.type as string).replace('action-', '')) as string; + const output = p.output as Record | undefined; + const isSuccess = output?.success === true; + const resultSummary = isSuccess + ? output?.data + ? `result: ${JSON.stringify(output.data).slice(0, 100)}` + : 'success' + : (output?.error as string) || 'failed'; + contentParts.push(`[Action ${actionName}: ${resultSummary}]`); + } + } + } + + // Extract speaker name from metadata (e.g. other agents' messages in discussion) + const senderName = msg.metadata?.senderName; + let content = contentParts.join('\n'); + if (senderName) { + content = `[${senderName}]: ${content}`; + } + + // Annotate interrupted messages so the LLM knows context was cut short + const isInterrupted = + (msg as unknown as Record).metadata && + ((msg as unknown as Record).metadata as Record) + ?.interrupted; + return { + role: 'user' as const, + content: isInterrupted + ? `${content}\n[This response was interrupted — do NOT continue it. Start a new JSON array response.]` + : content, + }; + }) + .filter((msg) => { + // Drop empty messages and messages with only dots/ellipsis/whitespace + // (produced by failed agent streams) + const stripped = msg.content.replace(/[.\s…]+/g, ''); + return stripped.length > 0; + }); +} diff --git a/lib/orchestration/summarizers/peer-context.ts b/lib/orchestration/summarizers/peer-context.ts new file mode 100644 index 000000000..159cc23f2 --- /dev/null +++ b/lib/orchestration/summarizers/peer-context.ts @@ -0,0 +1,33 @@ +import type { AgentTurnSummary } from '../types'; + +// ==================== Peer Context ==================== + +/** + * Build a context section summarizing what other agents said this round. + * Returns empty string if no agents have spoken yet. + */ +export function buildPeerContextSection( + agentResponses: AgentTurnSummary[] | undefined, + currentAgentName: string, +): string { + if (!agentResponses || agentResponses.length === 0) return ''; + + // Filter out self (defensive — director shouldn't dispatch same agent twice) + const peers = agentResponses.filter((r) => r.agentName !== currentAgentName); + if (peers.length === 0) return ''; + + const peerLines = peers.map((r) => `- ${r.agentName}: "${r.contentPreview}"`).join('\n'); + + return ` +# This Round's Context (CRITICAL — READ BEFORE RESPONDING) +The following agents have already spoken in this discussion round: +${peerLines} + +You are ${currentAgentName}, responding AFTER the agents above. You MUST: +1. NOT repeat greetings or introductions — they have already been made +2. NOT restate what previous speakers already explained +3. Add NEW value from YOUR unique perspective as ${currentAgentName} +4. Build on, question, or extend what was said — do not echo it +5. If you agree with a previous point, say so briefly and then ADD something new +`; +} diff --git a/lib/orchestration/summarizers/state-context.ts b/lib/orchestration/summarizers/state-context.ts new file mode 100644 index 000000000..64b248213 --- /dev/null +++ b/lib/orchestration/summarizers/state-context.ts @@ -0,0 +1,169 @@ +import type { StatelessChatRequest } from '@/lib/types/chat'; + +// ==================== Element Summarization ==================== + +/** + * Strip HTML tags to extract plain text + */ +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, '').trim(); +} + +/** + * Summarize a single PPT element into a one-line description + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes +function summarizeElement(el: any): string { + const id = el.id ? `[id:${el.id}]` : ''; + const pos = `at (${Math.round(el.left)},${Math.round(el.top)})`; + const size = + el.width != null && el.height != null + ? ` size ${Math.round(el.width)}×${Math.round(el.height)}` + : el.width != null + ? ` w=${Math.round(el.width)}` + : ''; + + switch (el.type) { + case 'text': { + const text = stripHtml(el.content || '').slice(0, 60); + const suffix = text.length >= 60 ? '...' : ''; + return `${id} text${el.textType ? `[${el.textType}]` : ''}: "${text}${suffix}" ${pos}${size}`; + } + case 'image': { + const src = el.src?.startsWith('data:') ? '[embedded]' : el.src?.slice(0, 50) || 'unknown'; + return `${id} image: ${src} ${pos}${size}`; + } + case 'shape': { + const shapeText = el.text?.content ? stripHtml(el.text.content).slice(0, 40) : ''; + return `${id} shape${shapeText ? `: "${shapeText}"` : ''} ${pos}${size}`; + } + case 'chart': + return `${id} chart[${el.chartType}]: labels=[${(el.data?.labels || []).slice(0, 4).join(',')}] ${pos}${size}`; + case 'table': { + const rows = el.data?.length || 0; + const cols = el.data?.[0]?.length || 0; + return `${id} table: ${rows}x${cols} ${pos}${size}`; + } + case 'latex': + return `${id} latex: "${(el.latex || '').slice(0, 40)}" ${pos}${size}`; + case 'line': { + const lx = Math.round(el.left ?? 0); + const ly = Math.round(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} line: (${lx + sx},${ly + sy}) → (${lx + ex},${ly + ey})`; + } + case 'code': { + const lang = el.language || 'unknown'; + const lineCount = el.lines?.length || 0; + const codeFn = el.fileName ? ` "${el.fileName}"` : ''; + const linePreview = (el.lines || []) + .slice(0, 10) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((l: any) => ` ${l.id}: ${l.content}`) + .join('\n'); + const moreLines = lineCount > 10 ? `\n ... and ${lineCount - 10} more lines` : ''; + return `${id} code${codeFn} (${lang}, ${lineCount} lines) ${pos}${size}\n${linePreview}${moreLines}`; + } + case 'video': + return `${id} video ${pos}${size}`; + case 'audio': + return `${id} audio ${pos}${size}`; + default: + return `${id} ${el.type || 'unknown'} ${pos}${size}`; + } +} + +/** + * Summarize an array of elements into line descriptions + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes +export function summarizeElements(elements: any[]): string { + if (elements.length === 0) return ' (empty)'; + + const lines = elements.map((el, i) => ` ${i + 1}. ${summarizeElement(el)}`); + + return lines.join('\n'); +} + +// ==================== State Context ==================== + +/** + * Build context string from store state + */ +export function buildStateContext(storeState: StatelessChatRequest['storeState']): string { + const { stage, scenes, currentSceneId, mode, whiteboardOpen } = storeState; + + const lines: string[] = []; + + // Mode + lines.push(`Mode: ${mode}`); + + // Whiteboard status + lines.push( + `Whiteboard: ${whiteboardOpen ? 'OPEN (slide canvas is hidden)' : 'closed (slide canvas is visible)'}`, + ); + + // Stage info + if (stage) { + lines.push( + `Course: ${stage.name || 'Untitled'}${stage.description ? ` - ${stage.description}` : ''}`, + ); + } + + // Scenes summary + lines.push(`Total scenes: ${scenes.length}`); + + if (currentSceneId) { + const currentScene = scenes.find((s) => s.id === currentSceneId); + if (currentScene) { + lines.push( + `Current scene: "${currentScene.title}" (${currentScene.type}, id: ${currentSceneId})`, + ); + + // Slide scene: include element details + if (currentScene.content.type === 'slide') { + const elements = currentScene.content.canvas.elements; + lines.push(`Current slide elements (${elements.length}):\n${summarizeElements(elements)}`); + } + + // Quiz scene: include question summary + if (currentScene.content.type === 'quiz') { + const questions = currentScene.content.questions; + const qSummary = questions + .slice(0, 5) + .map((q, i) => ` ${i + 1}. [${q.type}] ${q.question.slice(0, 80)}`) + .join('\n'); + lines.push( + `Quiz questions (${questions.length}):\n${qSummary}${questions.length > 5 ? `\n ... and ${questions.length - 5} more` : ''}`, + ); + } + } + } else if (scenes.length > 0) { + lines.push('No scene currently selected'); + } + + // List first few scenes + if (scenes.length > 0) { + const sceneSummary = scenes + .slice(0, 5) + .map((s, i) => ` ${i + 1}. ${s.title} (${s.type}, id: ${s.id})`) + .join('\n'); + lines.push( + `Scenes:\n${sceneSummary}${scenes.length > 5 ? `\n ... and ${scenes.length - 5} more` : ''}`, + ); + } + + // Whiteboard content (last whiteboard in the stage) + if (stage?.whiteboard && stage.whiteboard.length > 0) { + const lastWb = stage.whiteboard[stage.whiteboard.length - 1]; + const wbElements = lastWb.elements || []; + lines.push( + `Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`, + ); + } + + return lines.join('\n'); +} diff --git a/lib/orchestration/summarizers/whiteboard-ledger.ts b/lib/orchestration/summarizers/whiteboard-ledger.ts new file mode 100644 index 000000000..b7c23df1d --- /dev/null +++ b/lib/orchestration/summarizers/whiteboard-ledger.ts @@ -0,0 +1,167 @@ +import type { StatelessChatRequest } from '@/lib/types/chat'; +import type { WhiteboardActionRecord } from '../types'; + +// ==================== Virtual Whiteboard Context ==================== + +/** + * Tracked element from replaying the whiteboard ledger + */ +interface VirtualWhiteboardElement { + agentName: string; + summary: string; + elementId?: string; // Present for elements from initial whiteboard state +} + +/** + * Replay the whiteboard ledger to build an attributed element list. + * + * - wb_clear resets the accumulated elements + * - wb_draw_* appends a new element with the agent's name + * - wb_open / wb_close are ignored (structural, not content) + * + * Returns empty string when the ledger is empty (zero extra token overhead). + */ +export function buildVirtualWhiteboardContext( + storeState: StatelessChatRequest['storeState'], + ledger?: WhiteboardActionRecord[], +): string { + if (!ledger || ledger.length === 0) return ''; + + // Replay ledger to build current element list + const elements: VirtualWhiteboardElement[] = []; + + for (const record of ledger) { + switch (record.actionName) { + case 'wb_clear': + elements.length = 0; + break; + case 'wb_delete': { + // Remove element by matching elementId from initial whiteboard state + // (elements drawn this round don't have tracked IDs) + const deleteId = String(record.params.elementId || ''); + const idx = elements.findIndex((el) => el.elementId === deleteId); + if (idx >= 0) elements.splice(idx, 1); + break; + } + case 'wb_draw_text': { + const content = String(record.params.content || '').slice(0, 40); + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 400; + const h = record.params.height ?? 100; + elements.push({ + agentName: record.agentName, + summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`, + }); + break; + } + case 'wb_draw_shape': { + const shapeType = record.params.type || record.params.shape || 'rectangle'; + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 100; + const h = record.params.height ?? 100; + elements.push({ + agentName: record.agentName, + summary: `shape(${shapeType}) at (${x},${y}), size ${w}x${h}`, + }); + break; + } + case 'wb_draw_chart': { + const chartType = record.params.chartType || record.params.type || 'bar'; + const labels = Array.isArray(record.params.labels) + ? record.params.labels + : (record.params.data as Record)?.labels; + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 350; + const h = record.params.height ?? 250; + elements.push({ + agentName: record.agentName, + summary: `chart(${chartType})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${x},${y}), size ${w}x${h}`, + }); + break; + } + case 'wb_draw_latex': { + const latex = String(record.params.latex || '').slice(0, 40); + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 400; + // Estimate latex height: ~80px default for single-line, more for complex formulas + const h = record.params.height ?? 80; + elements.push({ + agentName: record.agentName, + summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`, + }); + break; + } + case 'wb_draw_table': { + const data = record.params.data as unknown[][] | undefined; + const rows = data?.length || 0; + const cols = (data?.[0] as unknown[])?.length || 0; + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 400; + const h = record.params.height ?? rows * 40 + 20; + elements.push({ + agentName: record.agentName, + summary: `table(${rows}×${cols}) at (${x},${y}), size ${w}x${h}`, + }); + break; + } + case 'wb_draw_line': { + const sx = record.params.startX ?? '?'; + const sy = record.params.startY ?? '?'; + const ex = record.params.endX ?? '?'; + const ey = record.params.endY ?? '?'; + const pts = record.params.points as string[] | undefined; + const hasArrow = pts?.includes('arrow') ? ' (arrow)' : ''; + elements.push({ + agentName: record.agentName, + summary: `line${hasArrow}: (${sx},${sy}) → (${ex},${ey})`, + }); + break; + } + case 'wb_draw_code': { + const lang = String(record.params.language || ''); + const codeFileName = record.params.fileName ? ` "${record.params.fileName}"` : ''; + const x = record.params.x ?? '?'; + const y = record.params.y ?? '?'; + const w = record.params.width ?? 500; + const h = record.params.height ?? 300; + const code = String(record.params.code || ''); + const lineCount = code.split('\n').length; + elements.push({ + agentName: record.agentName, + summary: `code block${codeFileName} (${lang}, ${lineCount} lines) at (${x},${y}), size ${w}x${h}`, + }); + break; + } + case 'wb_edit_code': { + const op = record.params.operation || 'edit'; + const targetId = record.params.elementId || '?'; + elements.push({ + agentName: record.agentName, + summary: `edited code "${targetId}" (${op})`, + }); + break; + } + // wb_open, wb_close — skip + } + } + + if (elements.length === 0) return ''; + + const elementLines = elements + .map((el, i) => ` ${i + 1}. [by ${el.agentName}] ${el.summary}`) + .join('\n'); + + return ` +## Whiteboard Changes This Round (IMPORTANT) +Other agents have modified the whiteboard during this discussion round. +Current whiteboard elements (${elements.length}): +${elementLines} + +DO NOT redraw content that already exists. Check positions above before adding new elements. +`; +} diff --git a/lib/orchestration/types.ts b/lib/orchestration/types.ts new file mode 100644 index 000000000..55a4694b8 --- /dev/null +++ b/lib/orchestration/types.ts @@ -0,0 +1,40 @@ +/** + * Shared types for orchestration: whiteboard action ledger + agent turn summaries. + * + * These types describe runtime data structures used by the director, prompt builders, + * summarizers, and the LangGraph runner. They're imported widely, so they live in + * a neutral module rather than alongside any single consumer. + */ + +/** + * A single whiteboard action performed by an agent, recorded in the ledger. + */ +export interface WhiteboardActionRecord { + actionName: + | 'wb_draw_text' + | 'wb_draw_shape' + | 'wb_draw_chart' + | 'wb_draw_latex' + | 'wb_draw_table' + | 'wb_draw_line' + | 'wb_draw_code' + | 'wb_edit_code' + | 'wb_clear' + | 'wb_delete' + | 'wb_open' + | 'wb_close'; + agentId: string; + agentName: string; + params: Record; +} + +/** + * Summary of an agent's turn in the current round. + */ +export interface AgentTurnSummary { + agentId: string; + agentName: string; + contentPreview: string; + actionCount: number; + whiteboardActions: WhiteboardActionRecord[]; +} diff --git a/lib/pbl/pbl-system-prompt.ts b/lib/pbl/pbl-system-prompt.ts index e8cc37b7b..7343c8bf2 100644 --- a/lib/pbl/pbl-system-prompt.ts +++ b/lib/pbl/pbl-system-prompt.ts @@ -5,6 +5,8 @@ * Uses languageDirective for multi-language support. */ +import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; + export interface PBLSystemPromptConfig { projectTopic: string; projectDescription: string; @@ -14,80 +16,15 @@ export interface PBLSystemPromptConfig { } export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string { - const { - projectTopic, - projectDescription, - targetSkills, - issueCount = 3, - languageDirective, - } = config; - - return `You are a Teaching Assistant (TA) on a Project-Based Learning platform. You are fully responsible for designing group projects for students based on the course information provided by the teacher. - -## Your Responsibility - -Design a complete project by: -1. Creating a clear, engaging project title (keep it concise and memorable) -2. Writing a simple, concise project description (2-4 sentences) that covers: - - What the project is about - - Key learning objectives - - What students will accomplish - -Keep the description straightforward and easy to understand. Avoid lengthy explanations. - -The teacher has provided you with: -- **Project Topic**: ${projectTopic} -- **Project Description**: ${projectDescription} -- **Target Skills**: ${targetSkills.join(', ')} -- **Suggested Number of Issues**: ${issueCount} - -Based on this information, you must autonomously design the project. Do not ask for confirmation or additional input - make the best decisions based on the provided context. - -## Mode System - -You have access to different modes, each providing different sets of tools: -- **project_info**: Tools for setting up basic project information (title, description) -- **agent**: Tools for defining project roles and agents -- **issueboard**: Tools for configuring collaboration workflow -- **idle**: A special mode indicating project configuration is complete - -You start in **project_info** mode. Use the \`set_mode\` tool to switch between modes as needed. - -## Workflow - -1. Start in **project_info** mode: Set up the project title and description -2. Switch to **agent** mode: Define 2-4 development roles students will take on (do NOT create management roles for students) -3. Switch to **issueboard** mode: Create ${issueCount} sequential issues that guide students through the project -4. When all project configuration is complete, switch to **idle** mode - -## Agent Design Guidelines - -- Create 2-4 **development** roles that students can choose from -- Each role should have a clear responsibility and unique system prompt -- Roles should be complementary (e.g., "Data Analyst", "Frontend Developer", "Project Manager") -- Do NOT create system agents (Question/Judge agents are auto-created per issue) - -## Issue Design Guidelines - -- Create exactly ${issueCount} issues that form a logical sequence -- Each issue should be completable by one person -- Issues should build on each other (earlier issues provide foundation for later ones) -- Each issue needs: title, description, person_in_charge (use a role name), and relevant participants - -## Issue Agent Auto-Creation - -When you create issues: -- Each issue automatically gets a Question Agent and a Judge Agent -- You do NOT need to manually create these agents -- Focus on designing meaningful issues with clear descriptions - -## Language - -${languageDirective} - -All project content (title, description, agent names and prompts, issue titles and descriptions, questions, messages) must follow this language directive. - -**IMPORTANT**: Once you have configured the project info, defined all necessary agents (roles), and created the issueboard with tasks, you MUST set your mode to **idle** to indicate completion. - -Your initial mode is **project_info**.`; + const prompt = buildPrompt(PROMPT_IDS.PBL_DESIGN, { + projectTopic: config.projectTopic, + projectDescription: config.projectDescription, + targetSkills: config.targetSkills.join(', '), + issueCount: config.issueCount ?? 3, + languageDirective: config.languageDirective, + }); + if (!prompt) { + throw new Error('pbl-design prompt template failed to load'); + } + return prompt.system; } diff --git a/lib/prompts/README.md b/lib/prompts/README.md new file mode 100644 index 000000000..309a64e52 --- /dev/null +++ b/lib/prompts/README.md @@ -0,0 +1,107 @@ +# `lib/prompts` + +File-based prompt loader + templates shared by both the generation pipeline and +the runtime orchestration layer. + +## Directory layout + +``` +lib/prompts/ +├── loader.ts ← file I/O + cache +├── index.ts ← public API (loadPrompt, buildPrompt, …) + PROMPT_IDS +├── types.ts ← PromptId / SnippetId string literal unions +├── templates/ +│ └── / +│ ├── system.md ← required +│ └── user.md ← optional (mostly for offline generation prompts) +└── snippets/ + └── .md ← reusable blocks referenced via {{snippet:…}} +``` + +## Template syntax + +Two kinds of placeholder: + +| Syntax | Semantics | Resolved by | +|---|---|---| +| `{{variableName}}` | Value is provided by the caller via `buildPrompt(id, vars)` | `interpolateVariables` in `loader.ts` | +| `{{snippet:snippet-name}}` | File content is spliced in at load time | `processSnippets` in `loader.ts` | + +Processing order is **snippet includes first, then variable interpolation**, so +snippets may themselves contain `{{variableName}}` placeholders if the caller +provides the value. + +## Naming conventions + +- **Placeholder names use `camelCase`.** Example: `{{agentName}}`, `{{stateContext}}`. +- **Template IDs use `kebab-case`.** Example: `agent-system`, `pbl-design`. +- `lib/prompts/templates/slide-content/{system,user}.md` still uses legacy + `snake_case` placeholders (`{{canvas_width}}`, `{{canvas_height}}`). This + predates the camelCase convention; don't imitate it when writing new templates. + +## Adding a new prompt + +1. Create `lib/prompts/templates//system.md` (and `user.md` if needed). +2. Add `` to the `PromptId` union in `types.ts`. +3. Add `NEW_ID: ''` to the `PROMPT_IDS` constant in `index.ts` + (the `satisfies Record` clause enforces that the value + exists in the union). +4. Call `buildPrompt(PROMPT_IDS.NEW_ID, vars)` from the consuming module. + +## Still in TypeScript (not yet in templates) + +Not every prompt fragment lives in markdown. Some role-conditional content +still exists as TS template literals and needs editing directly: + +| What | Where | Why not in markdown | +|---|---|---| +| `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. + +## Silent-passthrough gotcha + +`interpolateVariables` leaves unknown placeholders **unchanged** rather than +throwing: + +```ts +interpolate('hello {{missing}}', {}) === 'hello {{missing}}' +``` + +This is intentional for partial-render scenarios but means a typo in a +placeholder name ships literal `{{…}}` text to the LLM. Defence: + +- Tests in `tests/prompts/templates.test.ts` assert that the fully-rendered + agent-system / director / pbl-design prompts contain no surviving + `{{…}}` tokens. Keep that check passing when adding variables. +- `{{snippet:name}}` lookups **throw** on a missing snippet file rather than + passing through silently, so a typo like `{{snippet:speach-guidelines}}` + fails at load time instead of reaching the LLM. + +## Testing a template change locally + +The cheapest feedback loop is the template smoke suite: + +```bash +pnpm test tests/prompts +``` + +For end-to-end runtime behaviour (agent loop + template composition + +chat/director integration), use the whiteboard eval harness on one scenario: + +```bash +PORT=3100 pnpm dev & +EVAL_CHAT_MODEL= EVAL_SCORER_MODEL= \ + pnpm eval:whiteboard --base-url http://localhost:3100 \ + --scenario econ-tech-innovation +``` + +## Caching + +`loadPrompt` and `loadSnippet` cache on first read for the lifetime of the +process. Call `clearPromptCache()` if you're iterating on markdown in a +long-lived REPL / dev server and want to pick up changes. Production reads +are idempotent so the cache is always hot. diff --git a/lib/generation/prompts/index.ts b/lib/prompts/index.ts similarity index 86% rename from lib/generation/prompts/index.ts rename to lib/prompts/index.ts index 5eff65540..89e27ff8c 100644 --- a/lib/generation/prompts/index.ts +++ b/lib/prompts/index.ts @@ -8,6 +8,7 @@ */ // Types +import type { PromptId } from './types'; export type { PromptId, SnippetId, LoadedPrompt } from './types'; // Loader functions @@ -36,4 +37,7 @@ export const PROMPT_IDS = { VISUALIZATION3D_CONTENT: 'visualization3d-content', WIDGET_TEACHER_ACTIONS: 'widget-teacher-actions', PBL_ACTIONS: 'pbl-actions', -} as const; + AGENT_SYSTEM: 'agent-system', + DIRECTOR: 'director', + PBL_DESIGN: 'pbl-design', +} as const satisfies Record; diff --git a/lib/generation/prompts/loader.ts b/lib/prompts/loader.ts similarity index 87% rename from lib/generation/prompts/loader.ts rename to lib/prompts/loader.ts index aa81df0d4..bb0680980 100644 --- a/lib/generation/prompts/loader.ts +++ b/lib/prompts/loader.ts @@ -23,7 +23,7 @@ const snippetCache = new Map(); */ function getPromptsDir(): string { // In Next.js, use process.cwd() for the project root - return path.join(process.cwd(), 'lib', 'generation', 'prompts'); + return path.join(process.cwd(), 'lib', 'prompts'); } /** @@ -40,8 +40,9 @@ export function loadSnippet(snippetId: SnippetId): string { snippetCache.set(snippetId, content); return content; } catch { - log.warn(`Snippet not found: ${snippetId}`); - return `{{snippet:${snippetId}}}`; + // Fail loud rather than silently shipping `{{snippet:foo}}` to the LLM. + // A missing snippet is always a config/typo bug — surface at load time. + throw new Error(`Snippet not found: ${snippetId}`); } } @@ -99,6 +100,10 @@ export function loadPrompt(promptId: PromptId): LoadedPrompt | null { * Replaces {{variable}} with values from the variables object */ export function interpolateVariables(template: string, variables: Record): string { + // `\w+` only matches [A-Za-z0-9_], so kebab-case placeholders like + // `{{next-agent}}` pass through unchanged. Convention (per README) is + // camelCase; tests in tests/prompts/templates.test.ts scan templates + // for non-conforming placeholders. return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { const value = variables[key]; if (value === undefined) return match; diff --git a/lib/generation/prompts/snippets/action-types.md b/lib/prompts/snippets/action-types.md similarity index 100% rename from lib/generation/prompts/snippets/action-types.md rename to lib/prompts/snippets/action-types.md diff --git a/lib/generation/prompts/snippets/element-types.md b/lib/prompts/snippets/element-types.md similarity index 100% rename from lib/generation/prompts/snippets/element-types.md rename to lib/prompts/snippets/element-types.md diff --git a/lib/generation/prompts/snippets/json-output-rules.md b/lib/prompts/snippets/json-output-rules.md similarity index 100% rename from lib/generation/prompts/snippets/json-output-rules.md rename to lib/prompts/snippets/json-output-rules.md diff --git a/lib/prompts/snippets/speech-guidelines.md b/lib/prompts/snippets/speech-guidelines.md new file mode 100644 index 000000000..6ded3bef7 --- /dev/null +++ b/lib/prompts/snippets/speech-guidelines.md @@ -0,0 +1,8 @@ +## Speech Guidelines (CRITICAL) +- Effects fire concurrently with your speech — students see results as you speak +- Text content is what you SAY OUT LOUD to students - natural teaching speech +- Do NOT say "let me add...", "I'll create...", "now I'm going to..." +- Do NOT describe your actions - just speak naturally as a teacher +- Students see action results appear on screen - you don't need to announce them +- Your speech should flow naturally regardless of whether actions succeed or fail +- NEVER use markdown formatting (blockquotes >, headings #, bold **, lists -, code blocks) in text content — it is spoken aloud, not rendered \ No newline at end of file diff --git a/lib/prompts/templates/agent-system/system.md b/lib/prompts/templates/agent-system/system.md new file mode 100644 index 000000000..1390ffd9b --- /dev/null +++ b/lib/prompts/templates/agent-system/system.md @@ -0,0 +1,57 @@ +# Role +You are {{agentName}}. + +## Your Personality +{{persona}} + +## Your Classroom Role +{{roleGuideline}} +{{studentProfileSection}}{{peerContext}}{{languageConstraint}} +# Output Format +You MUST output a JSON array for ALL responses. Each element is an object with a `type` field: + +{{formatExample}} + +## Format Rules +1. Output a single JSON array — no explanation, no code fences +2. `type:"action"` objects contain `name` and `params` +3. `type:"text"` objects contain `content` (speech text) +4. Action and text objects can freely interleave in any order +5. The `]` closing bracket marks the end of your response +6. CRITICAL: ALWAYS start your response with `[` — even if your previous message was interrupted. Never continue a partial response as plain text. Every response must be a complete, independent JSON array. + +## Ordering Principles +{{orderingPrinciples}} + +{{snippet:speech-guidelines}} + +## Length & Style (CRITICAL) +{{lengthGuidelines}} + +### Good Examples +{{spotlightExamples}}[{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_text","params":{"content":"Step 1: 6CO₂ + 6H₂O → C₆H₁₂O₆ + 6O₂","x":100,"y":100,"fontSize":24}},{"type":"text","content":"Look at this chemical equation — notice how the reactants and products correspond."}] + +[{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_latex","params":{"latex":"\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}","x":100,"y":80,"width":500}},{"type":"text","content":"This is the quadratic formula — it can solve any quadratic equation."},{"type":"action","name":"wb_draw_table","params":{"x":100,"y":250,"width":500,"height":150,"data":[["Variable","Meaning"],["a","Coefficient of x²"],["b","Coefficient of x"],["c","Constant term"]]}},{"type":"text","content":"Each variable's meaning is shown in the table."}] + +### Bad Examples (DO NOT do this) +[{"type":"text","content":"Let me open the whiteboard"},{"type":"action",...}] (Don't announce actions!) +[{"type":"text","content":"I'm going to draw a diagram for you..."}] (Don't describe what you're doing!) +[{"type":"text","content":"Action complete, shape has been added"}] (Don't report action results!) + +## Whiteboard Guidelines +{{whiteboardGuidelines}} + +# Available Actions +{{actionDescriptions}} + +## Action Usage Guidelines +{{slideActionGuidelines}}- Whiteboard actions (wb_open, wb_draw_text, wb_draw_shape, wb_draw_chart, wb_draw_latex, wb_draw_table, wb_draw_line, wb_draw_code, wb_edit_code, wb_delete, wb_clear, wb_close): Use when explaining concepts that benefit from diagrams, formulas, data charts, tables, connecting lines, code demonstrations, or step-by-step derivations. Use wb_draw_latex for math formulas, wb_draw_chart for data visualization, wb_draw_table for structured data, wb_draw_code for code demonstrations. +- WHITEBOARD CLOSE RULE (CRITICAL): Do NOT call wb_close at the end of your response. Leave the whiteboard OPEN so students can read what you drew. Only call wb_close when you specifically need to return to the slide canvas (e.g., to use spotlight or laser on slide elements). Frequent open/close is distracting. +- wb_delete: Use to remove a specific element by its ID (shown in brackets like [id:xxx] in the whiteboard state). Prefer this over wb_clear when only one or a few elements need to be removed. +- wb_draw_code / wb_edit_code: To modify an existing code block, ALWAYS use wb_edit_code (insert_after, insert_before, delete_lines, replace_lines) instead of deleting the code element and re-creating it. wb_edit_code produces smooth line-level animations; deleting and re-drawing loses the animation continuity. Only use wb_draw_code for creating a brand-new code block. +{{mutualExclusionNote}} + +# Current State +{{stateContext}} +{{virtualWhiteboardContext}} +Remember: Speak naturally as a teacher. Effects fire concurrently with your speech.{{discussionContextSection}} \ No newline at end of file diff --git a/lib/generation/prompts/templates/code-content/system.md b/lib/prompts/templates/code-content/system.md similarity index 100% rename from lib/generation/prompts/templates/code-content/system.md rename to lib/prompts/templates/code-content/system.md diff --git a/lib/generation/prompts/templates/code-content/user.md b/lib/prompts/templates/code-content/user.md similarity index 100% rename from lib/generation/prompts/templates/code-content/user.md rename to lib/prompts/templates/code-content/user.md diff --git a/lib/generation/prompts/templates/diagram-content/system.md b/lib/prompts/templates/diagram-content/system.md similarity index 100% rename from lib/generation/prompts/templates/diagram-content/system.md rename to lib/prompts/templates/diagram-content/system.md diff --git a/lib/generation/prompts/templates/diagram-content/user.md b/lib/prompts/templates/diagram-content/user.md similarity index 100% rename from lib/generation/prompts/templates/diagram-content/user.md rename to lib/prompts/templates/diagram-content/user.md diff --git a/lib/prompts/templates/director/system.md b/lib/prompts/templates/director/system.md new file mode 100644 index 000000000..ded080438 --- /dev/null +++ b/lib/prompts/templates/director/system.md @@ -0,0 +1,35 @@ +You are the Director of a multi-agent classroom. Your job is to decide which agent should speak next based on the conversation context. + +# Available Agents +{{agentList}} + +# Agents Who Already Spoke This Round +{{respondedList}} + +# Conversation Context +{{conversationSummary}} +{{discussionSection}}{{whiteboardSection}}{{studentProfileSection}} +# Rules +{{rule1}} +2. After the teacher, consider whether a student agent would add value (ask a follow-up question, crack a joke, take notes, offer a different perspective). +3. Do NOT repeat an agent who already spoke this round unless absolutely necessary. +4. If the conversation seems complete (question answered, topic covered), output END. +5. Current turn: {{turnCountPlusOne}}. Consider conversation length — don't let discussions drag on unnecessarily. +6. Prefer brevity — 1-2 agents responding is usually enough. Don't force every agent to speak. +7. You can output {"next_agent":"USER"} to cue the user to speak. Use this when a student asks the user a direct question or when the topic naturally calls for user input. +8. Consider whiteboard state when routing: if the whiteboard is already crowded, avoid dispatching agents that are likely to add more whiteboard content unless they would clear or organize it. +9. Whiteboard is currently {{whiteboardOpenText}}. When the whiteboard is open, do not expect spotlight or laser actions to have visible effect. + +# Routing Quality (CRITICAL) +- ROLE DIVERSITY: Do NOT dispatch two agents of the same role consecutively. After a teacher speaks, the next should be a student or assistant — not another teacher-like response. After an assistant rephrases, dispatch a student who asks a question, not another assistant who also rephrases. +- CONTENT DEDUP: Read the "Agents Who Already Spoke" previews carefully. If an agent already explained a concept thoroughly, do NOT dispatch another agent to explain the same concept. Instead, dispatch an agent who will ASK a question, CHALLENGE an assumption, CONNECT to another topic, or TAKE NOTES. +- DISCUSSION PROGRESSION: Each new agent should advance the conversation. Good progression: explain → question → deeper explanation → different perspective → summary. Bad progression: explain → re-explain → rephrase → paraphrase. +- GREETING RULE: If any agent has already greeted the students, no subsequent agent should greet again. Check the previews for greetings. + +# Output Format +You MUST output ONLY a JSON object, nothing else: +{"next_agent":""} +or +{"next_agent":"USER"} +or +{"next_agent":"END"} \ No newline at end of file diff --git a/lib/generation/prompts/templates/game-content/system.md b/lib/prompts/templates/game-content/system.md similarity index 100% rename from lib/generation/prompts/templates/game-content/system.md rename to lib/prompts/templates/game-content/system.md diff --git a/lib/generation/prompts/templates/game-content/user.md b/lib/prompts/templates/game-content/user.md similarity index 100% rename from lib/generation/prompts/templates/game-content/user.md rename to lib/prompts/templates/game-content/user.md diff --git a/lib/generation/prompts/templates/interactive-actions/system.md b/lib/prompts/templates/interactive-actions/system.md similarity index 100% rename from lib/generation/prompts/templates/interactive-actions/system.md rename to lib/prompts/templates/interactive-actions/system.md diff --git a/lib/generation/prompts/templates/interactive-actions/user.md b/lib/prompts/templates/interactive-actions/user.md similarity index 100% rename from lib/generation/prompts/templates/interactive-actions/user.md rename to lib/prompts/templates/interactive-actions/user.md diff --git a/lib/generation/prompts/templates/interactive-outlines/system.md b/lib/prompts/templates/interactive-outlines/system.md similarity index 100% rename from lib/generation/prompts/templates/interactive-outlines/system.md rename to lib/prompts/templates/interactive-outlines/system.md diff --git a/lib/generation/prompts/templates/interactive-outlines/user.md b/lib/prompts/templates/interactive-outlines/user.md similarity index 100% rename from lib/generation/prompts/templates/interactive-outlines/user.md rename to lib/prompts/templates/interactive-outlines/user.md diff --git a/lib/generation/prompts/templates/pbl-actions/system.md b/lib/prompts/templates/pbl-actions/system.md similarity index 100% rename from lib/generation/prompts/templates/pbl-actions/system.md rename to lib/prompts/templates/pbl-actions/system.md diff --git a/lib/generation/prompts/templates/pbl-actions/user.md b/lib/prompts/templates/pbl-actions/user.md similarity index 100% rename from lib/generation/prompts/templates/pbl-actions/user.md rename to lib/prompts/templates/pbl-actions/user.md diff --git a/lib/prompts/templates/pbl-design/system.md b/lib/prompts/templates/pbl-design/system.md new file mode 100644 index 000000000..4a7699214 --- /dev/null +++ b/lib/prompts/templates/pbl-design/system.md @@ -0,0 +1,68 @@ +You are a Teaching Assistant (TA) on a Project-Based Learning platform. You are fully responsible for designing group projects for students based on the course information provided by the teacher. + +## Your Responsibility + +Design a complete project by: +1. Creating a clear, engaging project title (keep it concise and memorable) +2. Writing a simple, concise project description (2-4 sentences) that covers: + - What the project is about + - Key learning objectives + - What students will accomplish + +Keep the description straightforward and easy to understand. Avoid lengthy explanations. + +The teacher has provided you with: +- **Project Topic**: {{projectTopic}} +- **Project Description**: {{projectDescription}} +- **Target Skills**: {{targetSkills}} +- **Suggested Number of Issues**: {{issueCount}} + +Based on this information, you must autonomously design the project. Do not ask for confirmation or additional input - make the best decisions based on the provided context. + +## Mode System + +You have access to different modes, each providing different sets of tools: +- **project_info**: Tools for setting up basic project information (title, description) +- **agent**: Tools for defining project roles and agents +- **issueboard**: Tools for configuring collaboration workflow +- **idle**: A special mode indicating project configuration is complete + +You start in **project_info** mode. Use the `set_mode` tool to switch between modes as needed. + +## Workflow + +1. Start in **project_info** mode: Set up the project title and description +2. Switch to **agent** mode: Define 2-4 development roles students will take on (do NOT create management roles for students) +3. Switch to **issueboard** mode: Create {{issueCount}} sequential issues that guide students through the project +4. When all project configuration is complete, switch to **idle** mode + +## Agent Design Guidelines + +- Create 2-4 **development** roles that students can choose from +- Each role should have a clear responsibility and unique system prompt +- Roles should be complementary (e.g., "Data Analyst", "Frontend Developer", "Project Manager") +- Do NOT create system agents (Question/Judge agents are auto-created per issue) + +## Issue Design Guidelines + +- Create exactly {{issueCount}} issues that form a logical sequence +- Each issue should be completable by one person +- Issues should build on each other (earlier issues provide foundation for later ones) +- Each issue needs: title, description, person_in_charge (use a role name), and relevant participants + +## Issue Agent Auto-Creation + +When you create issues: +- Each issue automatically gets a Question Agent and a Judge Agent +- You do NOT need to manually create these agents +- Focus on designing meaningful issues with clear descriptions + +## Language + +{{languageDirective}} + +All project content (title, description, agent names and prompts, issue titles and descriptions, questions, messages) must follow this language directive. + +**IMPORTANT**: Once you have configured the project info, defined all necessary agents (roles), and created the issueboard with tasks, you MUST set your mode to **idle** to indicate completion. + +Your initial mode is **project_info**. \ No newline at end of file diff --git a/lib/generation/prompts/templates/quiz-actions/system.md b/lib/prompts/templates/quiz-actions/system.md similarity index 100% rename from lib/generation/prompts/templates/quiz-actions/system.md rename to lib/prompts/templates/quiz-actions/system.md diff --git a/lib/generation/prompts/templates/quiz-actions/user.md b/lib/prompts/templates/quiz-actions/user.md similarity index 100% rename from lib/generation/prompts/templates/quiz-actions/user.md rename to lib/prompts/templates/quiz-actions/user.md diff --git a/lib/generation/prompts/templates/quiz-content/system.md b/lib/prompts/templates/quiz-content/system.md similarity index 100% rename from lib/generation/prompts/templates/quiz-content/system.md rename to lib/prompts/templates/quiz-content/system.md diff --git a/lib/generation/prompts/templates/quiz-content/user.md b/lib/prompts/templates/quiz-content/user.md similarity index 100% rename from lib/generation/prompts/templates/quiz-content/user.md rename to lib/prompts/templates/quiz-content/user.md diff --git a/lib/generation/prompts/templates/requirements-to-outlines/system.md b/lib/prompts/templates/requirements-to-outlines/system.md similarity index 100% rename from lib/generation/prompts/templates/requirements-to-outlines/system.md rename to lib/prompts/templates/requirements-to-outlines/system.md diff --git a/lib/generation/prompts/templates/requirements-to-outlines/user.md b/lib/prompts/templates/requirements-to-outlines/user.md similarity index 100% rename from lib/generation/prompts/templates/requirements-to-outlines/user.md rename to lib/prompts/templates/requirements-to-outlines/user.md diff --git a/lib/generation/prompts/templates/simulation-content/system.md b/lib/prompts/templates/simulation-content/system.md similarity index 100% rename from lib/generation/prompts/templates/simulation-content/system.md rename to lib/prompts/templates/simulation-content/system.md diff --git a/lib/generation/prompts/templates/simulation-content/user.md b/lib/prompts/templates/simulation-content/user.md similarity index 100% rename from lib/generation/prompts/templates/simulation-content/user.md rename to lib/prompts/templates/simulation-content/user.md diff --git a/lib/generation/prompts/templates/slide-actions/system.md b/lib/prompts/templates/slide-actions/system.md similarity index 100% rename from lib/generation/prompts/templates/slide-actions/system.md rename to lib/prompts/templates/slide-actions/system.md diff --git a/lib/generation/prompts/templates/slide-actions/user.md b/lib/prompts/templates/slide-actions/user.md similarity index 100% rename from lib/generation/prompts/templates/slide-actions/user.md rename to lib/prompts/templates/slide-actions/user.md diff --git a/lib/generation/prompts/templates/slide-content/system.md b/lib/prompts/templates/slide-content/system.md similarity index 100% rename from lib/generation/prompts/templates/slide-content/system.md rename to lib/prompts/templates/slide-content/system.md diff --git a/lib/generation/prompts/templates/slide-content/user.md b/lib/prompts/templates/slide-content/user.md similarity index 100% rename from lib/generation/prompts/templates/slide-content/user.md rename to lib/prompts/templates/slide-content/user.md diff --git a/lib/generation/prompts/templates/visualization3d-content/system.md b/lib/prompts/templates/visualization3d-content/system.md similarity index 100% rename from lib/generation/prompts/templates/visualization3d-content/system.md rename to lib/prompts/templates/visualization3d-content/system.md diff --git a/lib/generation/prompts/templates/visualization3d-content/user.md b/lib/prompts/templates/visualization3d-content/user.md similarity index 100% rename from lib/generation/prompts/templates/visualization3d-content/user.md rename to lib/prompts/templates/visualization3d-content/user.md diff --git a/lib/generation/prompts/templates/web-search-query-rewrite/system.md b/lib/prompts/templates/web-search-query-rewrite/system.md similarity index 100% rename from lib/generation/prompts/templates/web-search-query-rewrite/system.md rename to lib/prompts/templates/web-search-query-rewrite/system.md diff --git a/lib/generation/prompts/templates/web-search-query-rewrite/user.md b/lib/prompts/templates/web-search-query-rewrite/user.md similarity index 100% rename from lib/generation/prompts/templates/web-search-query-rewrite/user.md rename to lib/prompts/templates/web-search-query-rewrite/user.md diff --git a/lib/generation/prompts/templates/widget-teacher-actions/system.md b/lib/prompts/templates/widget-teacher-actions/system.md similarity index 100% rename from lib/generation/prompts/templates/widget-teacher-actions/system.md rename to lib/prompts/templates/widget-teacher-actions/system.md diff --git a/lib/generation/prompts/templates/widget-teacher-actions/user.md b/lib/prompts/templates/widget-teacher-actions/user.md similarity index 100% rename from lib/generation/prompts/templates/widget-teacher-actions/user.md rename to lib/prompts/templates/widget-teacher-actions/user.md diff --git a/lib/generation/prompts/types.ts b/lib/prompts/types.ts similarity index 77% rename from lib/generation/prompts/types.ts rename to lib/prompts/types.ts index 827089768..39eb6eb1f 100644 --- a/lib/generation/prompts/types.ts +++ b/lib/prompts/types.ts @@ -20,12 +20,19 @@ export type PromptId = | 'game-content' | 'visualization3d-content' | 'widget-teacher-actions' - | 'pbl-actions'; + | 'pbl-actions' + | 'agent-system' + | 'director' + | 'pbl-design'; /** * Snippet identifier */ -export type SnippetId = 'json-output-rules' | 'element-types' | 'action-types'; +export type SnippetId = + | 'json-output-rules' + | 'element-types' + | 'action-types' + | 'speech-guidelines'; /** * Loaded prompt template diff --git a/lib/server/search-query-builder.ts b/lib/server/search-query-builder.ts index 4611c99c6..b15669829 100644 --- a/lib/server/search-query-builder.ts +++ b/lib/server/search-query-builder.ts @@ -1,5 +1,5 @@ import { parseJsonResponse } from '@/lib/generation/json-repair'; -import { PROMPT_IDS, buildPrompt } from '@/lib/generation/prompts'; +import { PROMPT_IDS, buildPrompt } from '@/lib/prompts'; import type { AICallFn } from '@/lib/generation/pipeline-types'; import { createLogger } from '@/lib/logger'; diff --git a/lib/types/chat.ts b/lib/types/chat.ts index 8e69f88e9..6d9c06d34 100644 --- a/lib/types/chat.ts +++ b/lib/types/chat.ts @@ -217,7 +217,7 @@ export interface LectureNoteEntry { // ==================== Stateless Multi-Agent API Types ==================== import type { Stage, Scene, StageMode } from '@/lib/types/stage'; -import type { AgentTurnSummary, WhiteboardActionRecord } from '@/lib/orchestration/director-prompt'; +import type { AgentTurnSummary, WhiteboardActionRecord } from '@/lib/orchestration/types'; /** * Accumulated director state passed between per-agent requests. diff --git a/tests/prompts/loader.test.ts b/tests/prompts/loader.test.ts new file mode 100644 index 000000000..7708d4edf --- /dev/null +++ b/tests/prompts/loader.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { loadPrompt, loadSnippet, buildPrompt, clearPromptCache } from '@/lib/prompts'; + +describe('lib/prompts loader', () => { + beforeEach(() => clearPromptCache()); + + test('loads a known template + interpolates variables', () => { + const result = buildPrompt('slide-actions', { + title: 'Test Slide', + keyPoints: '1. point one', + description: 'desc', + elements: '[]', + courseContext: '', + agents: '', + userProfile: '', + languageDirective: 'en', + }); + expect(result).not.toBeNull(); + expect(result!.system.length).toBeGreaterThan(100); + expect(result!.user).toContain('Test Slide'); + }); + + test('loads a snippet', () => { + const s = loadSnippet('json-output-rules'); + expect(s).toContain('JSON'); + }); + + test('returns null for unknown promptId', () => { + // @ts-expect-error — testing runtime behavior with invalid id + expect(loadPrompt('does-not-exist')).toBeNull(); + }); + + test('throws on unknown snippetId instead of passing through literal', () => { + // @ts-expect-error — testing runtime behavior with invalid id + expect(() => loadSnippet('does-not-exist')).toThrow(/Snippet not found/); + }); +}); diff --git a/tests/prompts/templates.test.ts b/tests/prompts/templates.test.ts new file mode 100644 index 000000000..d5191ad8a --- /dev/null +++ b/tests/prompts/templates.test.ts @@ -0,0 +1,263 @@ +/** + * Structural assertion tests for the orchestration prompt templates. + * + * These replace the byte-equal snapshot suite that was initially added — the + * goal here is catching real regressions (missing variables, broken role + * dispatch, broken scene-type stripping) without forcing a snapshot update + * for every intentional prompt-content tweak. + */ + +import { describe, test, expect } from 'vitest'; +import { buildStructuredPrompt } from '@/lib/orchestration/prompt-builder'; +import { buildDirectorPrompt } from '@/lib/orchestration/director-prompt'; +import { buildPBLSystemPrompt } from '@/lib/pbl/pbl-system-prompt'; +import type { AgentConfig } from '@/lib/orchestration/registry/types'; +import type { StatelessChatRequest } from '@/lib/types/chat'; + +const baseAgent: AgentConfig = { + id: 'a1', + name: 'Mr. Chen', + role: 'teacher', + persona: 'Patient physics teacher.', + avatar: '', + color: '#000', + allowedActions: [ + 'spotlight', + 'laser', + 'wb_open', + 'wb_draw_text', + 'wb_draw_latex', + 'wb_draw_shape', + 'wb_close', + ], + priority: 100, + createdAt: new Date(0), + updatedAt: new Date(0), + isDefault: true, +}; + +const slideState: StatelessChatRequest['storeState'] = { + stage: { + id: 's1', + name: 'Test', + createdAt: 0, + updatedAt: 0, + languageDirective: 'zh-CN', + }, + scenes: [ + { + id: 'sc1', + stageId: 's1', + type: 'slide', + title: 'T', + order: 0, + content: { + type: 'slide', + canvas: { + id: 'c1', + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#fff', + themeColors: [], + fontColor: '#333', + fontName: 'YaHei', + }, + elements: [], + }, + }, + }, + ], + currentSceneId: 'sc1', + mode: 'autonomous', + whiteboardOpen: false, +}; + +const quizState: StatelessChatRequest['storeState'] = { + ...slideState, + scenes: [ + { + ...slideState.scenes[0], + type: 'quiz', + content: { type: 'quiz', questions: [] }, + }, + ], +}; + +// Matches any surviving {{placeholder}} token in rendered output +const UNRESOLVED_PLACEHOLDER = /\{\{\w[\w-]*\}\}/; + +describe('no surviving placeholders', () => { + test('agent-system / teacher / slide', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + expect(out).not.toMatch(UNRESOLVED_PLACEHOLDER); + }); + + test('director prompt', () => { + const out = buildDirectorPrompt([baseAgent], 'No history', [], 0); + expect(out).not.toMatch(UNRESOLVED_PLACEHOLDER); + }); + + test('pbl-design prompt', () => { + const out = buildPBLSystemPrompt({ + projectTopic: 'Smart Garden', + projectDescription: 'IoT project', + targetSkills: ['IoT', 'Python'], + issueCount: 3, + languageDirective: 'en', + }); + expect(out).not.toMatch(UNRESOLVED_PLACEHOLDER); + }); +}); + +describe('role dispatch', () => { + test('teacher prompt carries LEAD TEACHER guideline', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + expect(out).toContain('LEAD TEACHER'); + }); + + test('student prompt does NOT carry LEAD TEACHER guideline', () => { + const studentAgent: AgentConfig = { ...baseAgent, role: 'student' }; + const out = buildStructuredPrompt(studentAgent, slideState); + expect(out).not.toContain('LEAD TEACHER'); + expect(out).toContain('STUDENT'); + }); + + test('assistant prompt carries TEACHING ASSISTANT guideline', () => { + const assistantAgent: AgentConfig = { ...baseAgent, role: 'assistant' }; + const out = buildStructuredPrompt(assistantAgent, slideState); + expect(out).toContain('TEACHING ASSISTANT'); + expect(out).not.toContain('LEAD TEACHER'); + }); +}); + +describe('scene-type action stripping', () => { + test('slide scene exposes spotlight action description', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + expect(out).toMatch(/^- spotlight:/m); + }); + + test('quiz scene strips spotlight + laser from action descriptions', () => { + const out = buildStructuredPrompt(baseAgent, quizState); + expect(out).not.toMatch(/^- spotlight:/m); + expect(out).not.toMatch(/^- laser:/m); + }); +}); + +describe('optional sections toggle on / off correctly', () => { + test('peer context appears when other agents have spoken this round', () => { + const out = buildStructuredPrompt(baseAgent, slideState, undefined, undefined, undefined, [ + { + agentId: 'other', + agentName: 'Lily', + contentPreview: 'quick thought', + actionCount: 1, + whiteboardActions: [], + }, + ]); + expect(out).toContain("This Round's Context"); + expect(out).toContain('Lily'); + }); + + test('peer context is absent when agentResponses is empty/undefined', () => { + const out = buildStructuredPrompt(baseAgent, slideState); + expect(out).not.toContain("This Round's Context"); + }); + + test('language constraint is omitted when stage.languageDirective is absent', () => { + const stateNoLang: StatelessChatRequest['storeState'] = { + ...slideState, + stage: { ...slideState.stage!, languageDirective: undefined }, + }; + const out = buildStructuredPrompt(baseAgent, stateNoLang); + expect(out).not.toContain('# Language (CRITICAL)'); + }); +}); + +describe('director routing contract', () => { + test('output spec mentions next_agent JSON field', () => { + const out = buildDirectorPrompt([baseAgent], 'No history', [], 0); + expect(out).toContain('next_agent'); + }); + + test('Q&A mode omits Discussion Mode block', () => { + const out = buildDirectorPrompt([baseAgent], 'No history', [], 0); + expect(out).not.toContain('Discussion Mode'); + }); + + test('discussion mode inserts Discussion Mode block with topic', () => { + const out = buildDirectorPrompt( + [baseAgent], + 'No history', + [], + 0, + { topic: 'Force decomposition', prompt: 'Think of real examples' }, + 'student_1', + ); + expect(out).toContain('# Discussion Mode'); + expect(out).toContain('Force decomposition'); + expect(out).toContain('student_1'); + }); +}); + +describe('pbl-design template fills all repeated placeholders', () => { + test('issueCount is substituted at every occurrence (3x in template)', () => { + const UNIQUE = 42; + const out = buildPBLSystemPrompt({ + projectTopic: 'Smart Garden', + projectDescription: 'IoT project', + targetSkills: ['IoT'], + issueCount: UNIQUE, + languageDirective: 'en', + }); + // Template references {{issueCount}} at 3 positions: + // "Suggested Number of Issues: N", "Create N sequential issues", "Create exactly N issues" + const occurrences = out.match(new RegExp(`\\b${UNIQUE}\\b`, 'g'))?.length ?? 0; + expect(occurrences).toBeGreaterThanOrEqual(3); + }); +}); + +describe('placeholder naming convention lint', () => { + // The `interpolateVariables` regex is /\{\{(\w+)\}\}/, which is + // strictly [A-Za-z0-9_]. Kebab-case placeholders would silently pass + // through. Convention (per README) is camelCase. This test scans every + // template for non-conforming placeholders. + // + // slide-content/{system,user}.md predates the convention and still uses + // snake_case ({{canvas_width}}, {{canvas_height}}). Grandfather it here; + // new templates must be camelCase. + test('templates (excluding grandfathered) use camelCase placeholders', async () => { + const { readdirSync, readFileSync, statSync } = await import('fs'); + const { join } = await import('path'); + + const templatesDir = join(process.cwd(), 'lib', 'prompts', 'templates'); + const GRANDFATHERED = new Set(['slide-content']); + + const offenders: string[] = []; + for (const promptId of readdirSync(templatesDir)) { + if (GRANDFATHERED.has(promptId)) continue; + const promptDir = join(templatesDir, promptId); + if (!statSync(promptDir).isDirectory()) continue; + + for (const file of ['system.md', 'user.md']) { + const p = join(promptDir, file); + try { + const content = readFileSync(p, 'utf-8'); + // Match {{placeholder}} but NOT {{snippet:name}} + const matches = content.match(/\{\{(?!snippet:)([^}]+)\}\}/g) || []; + for (const m of matches) { + const name = m.slice(2, -2); + // camelCase: starts with lowercase, rest alphanumeric; reject _ and - + if (!/^[a-z][a-zA-Z0-9]*$/.test(name)) { + offenders.push(`${promptId}/${file}: ${m}`); + } + } + } catch { + // user.md is optional + } + } + } + + expect(offenders).toEqual([]); + }); +});