| |
| |
| |
| |
| |
| |
|
|
| import { NextRequest } from 'next/server'; |
| import { nanoid } from 'nanoid'; |
| import { callLLM } from '@/lib/ai/llm'; |
| import { createLogger } from '@/lib/logger'; |
| import { apiError, apiSuccess } from '@/lib/server/api-response'; |
| import { resolveModelFromRequest } from '@/lib/server/resolve-model'; |
| import { AGENT_COLOR_PALETTE } from '@/lib/constants/agent-defaults'; |
|
|
| const log = createLogger('Agent Profiles API'); |
|
|
| export const maxDuration = 120; |
|
|
| interface RequestBody { |
| stageInfo: { name: string; description?: string }; |
| sceneOutlines?: { title: string; description?: string }[]; |
| languageDirective: string; |
| availableAvatars: string[]; |
| avatarDescriptions?: Array<{ path: string; desc: string }>; |
| availableVoices?: Array<{ providerId: string; voiceId: string; voiceName: string }>; |
| } |
|
|
| function stripCodeFences(text: string): string { |
| let cleaned = text.trim(); |
| |
| if (cleaned.startsWith('```')) { |
| cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); |
| } |
| return cleaned.trim(); |
| } |
|
|
| export async function POST(req: NextRequest) { |
| let stageName: string | undefined; |
| let modelString: string | undefined; |
| try { |
| const body = (await req.json()) as RequestBody; |
| const { |
| stageInfo, |
| sceneOutlines, |
| languageDirective, |
| availableAvatars, |
| avatarDescriptions, |
| availableVoices, |
| } = body; |
| stageName = stageInfo?.name; |
|
|
| |
| if (!stageInfo?.name) { |
| return apiError('MISSING_REQUIRED_FIELD', 400, 'stageInfo.name is required'); |
| } |
| if (!languageDirective) { |
| return apiError('MISSING_REQUIRED_FIELD', 400, 'languageDirective is required'); |
| } |
| if (!availableAvatars || availableAvatars.length === 0) { |
| return apiError( |
| 'MISSING_REQUIRED_FIELD', |
| 400, |
| 'availableAvatars is required and must not be empty', |
| ); |
| } |
|
|
| |
| const { |
| model: languageModel, |
| modelString: _modelString, |
| thinkingConfig, |
| } = await resolveModelFromRequest(req, body); |
| modelString = _modelString; |
|
|
| |
| const sceneSummary = sceneOutlines?.length |
| ? sceneOutlines |
| .map((s, i) => `${i + 1}. ${s.title}${s.description ? ` β ${s.description}` : ''}`) |
| .join('\n') |
| : null; |
|
|
| const systemPrompt = `You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Decide the appropriate number of agents (typically 3-5) based on the course content and complexity. Return ONLY valid JSON, no markdown or explanation.`; |
|
|
| |
| const voiceListStr = |
| availableVoices && availableVoices.length > 0 |
| ? JSON.stringify( |
| availableVoices.map((v) => ({ |
| id: `${v.providerId}::${v.voiceId}`, |
| name: v.voiceName, |
| })), |
| ) |
| : ''; |
|
|
| const voicePrompt = voiceListStr |
| ? `- Each agent should be assigned a voice that matches their persona from this list: ${voiceListStr} |
| - Pick a voice that suits the agent's personality and role (e.g. authoritative voice for teacher, lively voice for energetic student) |
| - Try to use different voices for each agent` |
| : ''; |
|
|
| const voiceJsonField = voiceListStr |
| ? ',\n "voice": "string (voice id from available list, e.g. \'qwen-tts::Cherry\')"' |
| : ''; |
|
|
| const userPrompt = `Generate agent profiles for the following course: |
| |
| Course name: ${stageInfo.name} |
| ${stageInfo.description ? `Course description: ${stageInfo.description}` : ''} |
| ${sceneSummary ? `\nScene outlines:\n${sceneSummary}\n` : ''} |
| Requirements: |
| - Decide the appropriate number of agents based on the course content (typically 3-5) |
| - Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student" |
| - Priority values: teacher=10 (highest), assistant=7, student=4-6 |
| - Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style) |
| - Language directive for this course: ${languageDirective} |
| Agent names and personas must follow this language directive. |
| - Each agent must be assigned one avatar from this list: ${JSON.stringify(avatarDescriptions && avatarDescriptions.length > 0 ? avatarDescriptions.map((a) => ({ path: a.path, description: a.desc })) : availableAvatars)} |
| - Pick an avatar that visually matches the agent's personality and role |
| - Try to use different avatars for each agent |
| - Use the "path" value as the avatar field in the output |
| - Each agent must be assigned one color from this list: ${JSON.stringify(AGENT_COLOR_PALETTE)} |
| - Each agent must have a different color |
| ${voicePrompt} |
| |
| Return a JSON object with this exact structure: |
| { |
| "agents": [ |
| { |
| "name": "string", |
| "role": "teacher" | "assistant" | "student", |
| "persona": "string (2-3 sentences)", |
| "avatar": "string (from available list)", |
| "color": "string (hex color from palette)", |
| "priority": number (10 for teacher, 7 for assistant, 4-6 for student)${voiceJsonField} |
| } |
| ] |
| }`; |
|
|
| log.info(`Generating agent profiles for "${stageInfo.name}" [model=${modelString}]`); |
|
|
| const result = await callLLM( |
| { |
| model: languageModel, |
| system: systemPrompt, |
| prompt: userPrompt, |
| }, |
| 'agent-profiles', |
| undefined, |
| thinkingConfig, |
| ); |
|
|
| |
| const rawText = stripCodeFences(result.text); |
| let parsed: { |
| agents: Array<{ |
| name: string; |
| role: string; |
| persona: string; |
| avatar: string; |
| color: string; |
| priority: number; |
| voice?: string; |
| }>; |
| }; |
|
|
| try { |
| parsed = JSON.parse(rawText); |
| } catch { |
| log.error('Failed to parse LLM response as JSON:', rawText.substring(0, 500)); |
| return apiError('PARSE_FAILED', 500, 'Failed to parse agent profiles from LLM response'); |
| } |
|
|
| |
| if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) { |
| log.error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`); |
| return apiError( |
| 'GENERATION_FAILED', |
| 500, |
| `Expected at least 2 agents but LLM returned ${parsed.agents?.length ?? 0}`, |
| ); |
| } |
|
|
| const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length; |
| if (teacherCount !== 1) { |
| log.error(`Expected exactly 1 teacher, got ${teacherCount}`); |
| return apiError( |
| 'GENERATION_FAILED', |
| 500, |
| `Expected exactly 1 teacher but LLM returned ${teacherCount}`, |
| ); |
| } |
|
|
| |
| const agents = parsed.agents.map((agent, index) => { |
| |
| let voiceConfig: { providerId: string; voiceId: string } | undefined; |
| if (agent.voice && agent.voice.includes('::')) { |
| const [providerId, voiceId] = agent.voice.split('::'); |
| if (providerId && voiceId) { |
| voiceConfig = { providerId, voiceId }; |
| } |
| } |
|
|
| return { |
| id: `gen-${nanoid(8)}`, |
| name: agent.name, |
| role: agent.role, |
| persona: agent.persona, |
| avatar: agent.avatar || availableAvatars[index % availableAvatars.length], |
| color: agent.color || AGENT_COLOR_PALETTE[index % AGENT_COLOR_PALETTE.length], |
| priority: |
| agent.priority ?? (agent.role === 'teacher' ? 10 : agent.role === 'assistant' ? 7 : 5), |
| ...(voiceConfig ? { voiceConfig } : {}), |
| }; |
| }); |
|
|
| log.info(`Successfully generated ${agents.length} agent profiles for "${stageInfo.name}"`); |
|
|
| return apiSuccess({ agents }); |
| } catch (error) { |
| log.error( |
| `Agent profiles generation failed [stage="${stageName ?? 'unknown'}", model=${modelString ?? 'unknown'}]:`, |
| error, |
| ); |
| return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); |
| } |
| } |
|
|