route.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. /**
  2. * Agent Profiles Generation API
  3. *
  4. * Generates agent profiles (teacher, assistant, student) for a course stage
  5. * based on stage info and scene outlines.
  6. */
  7. import { NextRequest } from 'next/server';
  8. import { nanoid } from 'nanoid';
  9. import { callLLM } from '@/lib/ai/llm';
  10. import { createLogger } from '@/lib/logger';
  11. import { apiError, apiSuccess } from '@/lib/server/api-response';
  12. import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
  13. import { AGENT_COLOR_PALETTE } from '@/lib/constants/agent-defaults';
  14. const log = createLogger('Agent Profiles API');
  15. export const maxDuration = 120;
  16. interface RequestBody {
  17. stageInfo: { name: string; description?: string };
  18. sceneOutlines?: { title: string; description?: string }[];
  19. language: string;
  20. availableAvatars: string[];
  21. avatarDescriptions?: Array<{ path: string; desc: string }>;
  22. availableVoices?: Array<{ providerId: string; voiceId: string; voiceName: string }>;
  23. }
  24. function stripCodeFences(text: string): string {
  25. let cleaned = text.trim();
  26. // Remove markdown code fences (```json ... ``` or ``` ... ```)
  27. if (cleaned.startsWith('```')) {
  28. cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
  29. }
  30. return cleaned.trim();
  31. }
  32. export async function POST(req: NextRequest) {
  33. let stageName: string | undefined;
  34. let modelString: string | undefined;
  35. try {
  36. const body = (await req.json()) as RequestBody;
  37. const {
  38. stageInfo,
  39. sceneOutlines,
  40. language,
  41. availableAvatars,
  42. avatarDescriptions,
  43. availableVoices,
  44. } = body;
  45. stageName = stageInfo?.name;
  46. // ── Validate required fields ──
  47. if (!stageInfo?.name) {
  48. return apiError('MISSING_REQUIRED_FIELD', 400, 'stageInfo.name is required');
  49. }
  50. if (!language) {
  51. return apiError('MISSING_REQUIRED_FIELD', 400, 'language is required');
  52. }
  53. if (!availableAvatars || availableAvatars.length === 0) {
  54. return apiError(
  55. 'MISSING_REQUIRED_FIELD',
  56. 400,
  57. 'availableAvatars is required and must not be empty',
  58. );
  59. }
  60. // ── Model resolution from request headers ──
  61. const { model: languageModel, modelString: _modelString } = await resolveModelFromHeaders(req);
  62. modelString = _modelString;
  63. // ── Build prompt ──
  64. const sceneSummary = sceneOutlines?.length
  65. ? sceneOutlines
  66. .map((s, i) => `${i + 1}. ${s.title}${s.description ? ` — ${s.description}` : ''}`)
  67. .join('\n')
  68. : null;
  69. 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.`;
  70. // Build voice list for prompt (if available)
  71. const voiceListStr =
  72. availableVoices && availableVoices.length > 0
  73. ? JSON.stringify(
  74. availableVoices.map((v) => ({
  75. id: `${v.providerId}::${v.voiceId}`,
  76. name: v.voiceName,
  77. })),
  78. )
  79. : '';
  80. const voicePrompt = voiceListStr
  81. ? `- Each agent should be assigned a voice that matches their persona from this list: ${voiceListStr}
  82. - Pick a voice that suits the agent's personality and role (e.g. authoritative voice for teacher, lively voice for energetic student)
  83. - Try to use different voices for each agent`
  84. : '';
  85. const voiceJsonField = voiceListStr
  86. ? ',\n "voice": "string (voice id from available list, e.g. \'qwen-tts::Cherry\')"'
  87. : '';
  88. const userPrompt = `Generate agent profiles for the following course:
  89. Course name: ${stageInfo.name}
  90. ${stageInfo.description ? `Course description: ${stageInfo.description}` : ''}
  91. ${sceneSummary ? `\nScene outlines:\n${sceneSummary}\n` : ''}
  92. Requirements:
  93. - Decide the appropriate number of agents based on the course content (typically 3-5)
  94. - Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student"
  95. - Priority values: teacher=10 (highest), assistant=7, student=4-6
  96. - Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style)
  97. - Names and personas must be in language: ${language}
  98. - 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)}
  99. - Pick an avatar that visually matches the agent's personality and role
  100. - Try to use different avatars for each agent
  101. - Use the "path" value as the avatar field in the output
  102. - Each agent must be assigned one color from this list: ${JSON.stringify(AGENT_COLOR_PALETTE)}
  103. - Each agent must have a different color
  104. ${voicePrompt}
  105. Return a JSON object with this exact structure:
  106. {
  107. "agents": [
  108. {
  109. "name": "string",
  110. "role": "teacher" | "assistant" | "student",
  111. "persona": "string (2-3 sentences)",
  112. "avatar": "string (from available list)",
  113. "color": "string (hex color from palette)",
  114. "priority": number (10 for teacher, 7 for assistant, 4-6 for student)${voiceJsonField}
  115. }
  116. ]
  117. }`;
  118. log.info(`Generating agent profiles for "${stageInfo.name}" [model=${modelString}]`);
  119. const result = await callLLM(
  120. {
  121. model: languageModel,
  122. system: systemPrompt,
  123. prompt: userPrompt,
  124. },
  125. 'agent-profiles',
  126. );
  127. // ── Parse LLM response ──
  128. const rawText = stripCodeFences(result.text);
  129. let parsed: {
  130. agents: Array<{
  131. name: string;
  132. role: string;
  133. persona: string;
  134. avatar: string;
  135. color: string;
  136. priority: number;
  137. voice?: string;
  138. }>;
  139. };
  140. try {
  141. parsed = JSON.parse(rawText);
  142. } catch {
  143. log.error('Failed to parse LLM response as JSON:', rawText.substring(0, 500));
  144. return apiError('PARSE_FAILED', 500, 'Failed to parse agent profiles from LLM response');
  145. }
  146. // ── Validate parsed structure ──
  147. if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) {
  148. log.error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`);
  149. return apiError(
  150. 'GENERATION_FAILED',
  151. 500,
  152. `Expected at least 2 agents but LLM returned ${parsed.agents?.length ?? 0}`,
  153. );
  154. }
  155. const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length;
  156. if (teacherCount !== 1) {
  157. log.error(`Expected exactly 1 teacher, got ${teacherCount}`);
  158. return apiError(
  159. 'GENERATION_FAILED',
  160. 500,
  161. `Expected exactly 1 teacher but LLM returned ${teacherCount}`,
  162. );
  163. }
  164. // ── Build output with IDs ──
  165. const agents = parsed.agents.map((agent, index) => {
  166. // Parse voice "providerId::voiceId" format
  167. let voiceConfig: { providerId: string; voiceId: string } | undefined;
  168. if (agent.voice && agent.voice.includes('::')) {
  169. const [providerId, voiceId] = agent.voice.split('::');
  170. if (providerId && voiceId) {
  171. voiceConfig = { providerId, voiceId };
  172. }
  173. }
  174. return {
  175. id: `gen-${nanoid(8)}`,
  176. name: agent.name,
  177. role: agent.role,
  178. persona: agent.persona,
  179. avatar: agent.avatar || availableAvatars[index % availableAvatars.length],
  180. color: agent.color || AGENT_COLOR_PALETTE[index % AGENT_COLOR_PALETTE.length],
  181. priority:
  182. agent.priority ?? (agent.role === 'teacher' ? 10 : agent.role === 'assistant' ? 7 : 5),
  183. ...(voiceConfig ? { voiceConfig } : {}),
  184. };
  185. });
  186. log.info(`Successfully generated ${agents.length} agent profiles for "${stageInfo.name}"`);
  187. return apiSuccess({ agents });
  188. } catch (error) {
  189. log.error(
  190. `Agent profiles generation failed [stage="${stageName ?? 'unknown'}", model=${modelString ?? 'unknown'}]:`,
  191. error,
  192. );
  193. return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
  194. }
  195. }