File size: 8,308 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | /**
* Agent Profiles Generation API
*
* Generates agent profiles (teacher, assistant, student) for a course stage
* based on stage info and scene outlines.
*/
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();
// Remove markdown code fences (```json ... ``` or ``` ... ```)
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;
// ββ Validate required fields ββ
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',
);
}
// ββ Model resolution from request headers/body ββ
const {
model: languageModel,
modelString: _modelString,
thinkingConfig,
} = await resolveModelFromRequest(req, body);
modelString = _modelString;
// ββ Build prompt ββ
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.`;
// Build voice list for prompt (if available)
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,
);
// ββ Parse LLM response ββ
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');
}
// ββ Validate parsed structure ββ
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}`,
);
}
// ββ Build output with IDs ββ
const agents = parsed.agents.map((agent, index) => {
// Parse voice "providerId::voiceId" format
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));
}
}
|