classroom-generation.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import { nanoid } from 'nanoid';
  2. import { callLLM } from '@/lib/ai/llm';
  3. import { createStageAPI } from '@/lib/api/stage-api';
  4. import type { StageStore } from '@/lib/api/stage-api-types';
  5. import {
  6. applyOutlineFallbacks,
  7. generateSceneOutlinesFromRequirements,
  8. } from '@/lib/generation/outline-generator';
  9. import {
  10. createSceneWithActions,
  11. generateSceneActions,
  12. generateSceneContent,
  13. } from '@/lib/generation/scene-generator';
  14. import type { AICallFn } from '@/lib/generation/pipeline-types';
  15. import type { AgentInfo } from '@/lib/generation/pipeline-types';
  16. import { formatTeacherPersonaForPrompt } from '@/lib/generation/prompt-formatters';
  17. import { getDefaultAgents } from '@/lib/orchestration/registry/store';
  18. import { createLogger } from '@/lib/logger';
  19. import { isProviderKeyRequired } from '@/lib/ai/providers';
  20. import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
  21. import { resolveModel } from '@/lib/server/resolve-model';
  22. import { buildSearchQuery } from '@/lib/server/search-query-builder';
  23. import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
  24. import { persistClassroom } from '@/lib/server/classroom-storage';
  25. import {
  26. generateMediaForClassroom,
  27. replaceMediaPlaceholders,
  28. generateTTSForClassroom,
  29. } from '@/lib/server/classroom-media-generation';
  30. import type { UserRequirements } from '@/lib/types/generation';
  31. import type { Scene, Stage } from '@/lib/types/stage';
  32. import { AGENT_COLOR_PALETTE, AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults';
  33. const log = createLogger('Classroom');
  34. export interface GenerateClassroomInput {
  35. requirement: string;
  36. pdfContent?: { text: string; images: string[] };
  37. language?: string;
  38. enableWebSearch?: boolean;
  39. enableImageGeneration?: boolean;
  40. enableVideoGeneration?: boolean;
  41. enableTTS?: boolean;
  42. agentMode?: 'default' | 'generate';
  43. }
  44. export type ClassroomGenerationStep =
  45. | 'initializing'
  46. | 'researching'
  47. | 'generating_outlines'
  48. | 'generating_scenes'
  49. | 'generating_media'
  50. | 'generating_tts'
  51. | 'persisting'
  52. | 'completed';
  53. export interface ClassroomGenerationProgress {
  54. step: ClassroomGenerationStep;
  55. progress: number;
  56. message: string;
  57. scenesGenerated: number;
  58. totalScenes?: number;
  59. }
  60. export interface GenerateClassroomResult {
  61. id: string;
  62. url: string;
  63. stage: Stage;
  64. scenes: Scene[];
  65. scenesCount: number;
  66. createdAt: string;
  67. }
  68. function createInMemoryStore(stage: Stage): StageStore {
  69. let state = {
  70. stage: stage as Stage | null,
  71. scenes: [] as Scene[],
  72. currentSceneId: null as string | null,
  73. mode: 'playback' as const,
  74. };
  75. const listeners: Array<(s: typeof state, prev: typeof state) => void> = [];
  76. return {
  77. getState: () => state,
  78. setState: (partial: Partial<typeof state>) => {
  79. const prev = state;
  80. state = { ...state, ...partial };
  81. listeners.forEach((fn) => fn(state, prev));
  82. },
  83. subscribe: (listener: (s: typeof state, prev: typeof state) => void) => {
  84. listeners.push(listener);
  85. return () => {
  86. const idx = listeners.indexOf(listener);
  87. if (idx >= 0) listeners.splice(idx, 1);
  88. };
  89. },
  90. };
  91. }
  92. function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' {
  93. return language === 'en-US' ? 'en-US' : 'zh-CN';
  94. }
  95. function stripCodeFences(text: string): string {
  96. let cleaned = text.trim();
  97. if (cleaned.startsWith('```')) {
  98. cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
  99. }
  100. return cleaned.trim();
  101. }
  102. async function generateAgentProfiles(
  103. requirement: string,
  104. language: string,
  105. aiCall: AICallFn,
  106. ): Promise<AgentInfo[]> {
  107. const systemPrompt =
  108. 'You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Return ONLY valid JSON, no markdown or explanation.';
  109. const userPrompt = `Generate agent profiles for a course with this requirement:
  110. ${requirement}
  111. Requirements:
  112. - Decide the appropriate number of agents based on the course content (typically 3-5)
  113. - Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student"
  114. - Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style)
  115. - Names and personas must be in language: ${language}
  116. Return a JSON object with this exact structure:
  117. {
  118. "agents": [
  119. {
  120. "name": "string",
  121. "role": "teacher" | "assistant" | "student",
  122. "persona": "string (2-3 sentences)"
  123. }
  124. ]
  125. }`;
  126. const response = await aiCall(systemPrompt, userPrompt);
  127. const rawText = stripCodeFences(response);
  128. const parsed = JSON.parse(rawText) as {
  129. agents: Array<{ name: string; role: string; persona: string }>;
  130. };
  131. if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) {
  132. throw new Error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`);
  133. }
  134. const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length;
  135. if (teacherCount !== 1) {
  136. throw new Error(`Expected exactly 1 teacher, got ${teacherCount}`);
  137. }
  138. return parsed.agents.map((a, i) => ({
  139. id: `gen-server-${i}`,
  140. name: a.name,
  141. role: a.role,
  142. persona: a.persona,
  143. }));
  144. }
  145. export async function generateClassroom(
  146. input: GenerateClassroomInput,
  147. options: {
  148. baseUrl: string;
  149. onProgress?: (progress: ClassroomGenerationProgress) => Promise<void> | void;
  150. },
  151. ): Promise<GenerateClassroomResult> {
  152. const { requirement, pdfContent } = input;
  153. await options.onProgress?.({
  154. step: 'initializing',
  155. progress: 5,
  156. message: 'Initializing classroom generation',
  157. scenesGenerated: 0,
  158. });
  159. const {
  160. model: languageModel,
  161. modelInfo,
  162. modelString,
  163. providerId,
  164. apiKey,
  165. } = await resolveModel({});
  166. log.info(`Using server-configured model: ${modelString}`);
  167. // Fail fast if the resolved provider has no API key configured
  168. if (isProviderKeyRequired(providerId) && !apiKey) {
  169. throw new Error(
  170. `No API key configured for provider "${providerId}". ` +
  171. `Set the appropriate key in .env.local or server-providers.yml (e.g. ${providerId.toUpperCase()}_API_KEY).`,
  172. );
  173. }
  174. const aiCall: AICallFn = async (systemPrompt, userPrompt, _images) => {
  175. const result = await callLLM(
  176. {
  177. model: languageModel,
  178. messages: [
  179. { role: 'system', content: systemPrompt },
  180. { role: 'user', content: userPrompt },
  181. ],
  182. maxOutputTokens: modelInfo?.outputWindow,
  183. },
  184. 'generate-classroom',
  185. );
  186. return result.text;
  187. };
  188. const searchQueryAiCall: AICallFn = async (systemPrompt, userPrompt, _images) => {
  189. const result = await callLLM(
  190. {
  191. model: languageModel,
  192. messages: [
  193. { role: 'system', content: systemPrompt },
  194. { role: 'user', content: userPrompt },
  195. ],
  196. maxOutputTokens: 256,
  197. },
  198. 'web-search-query-rewrite',
  199. );
  200. return result.text;
  201. };
  202. const lang = normalizeLanguage(input.language);
  203. const requirements: UserRequirements = {
  204. requirement,
  205. language: lang,
  206. };
  207. const pdfText = pdfContent?.text || undefined;
  208. // Resolve agents based on agentMode
  209. let agents: AgentInfo[];
  210. let agentMode = input.agentMode || 'default';
  211. if (agentMode === 'generate') {
  212. log.info('Generating custom agent profiles via LLM...');
  213. try {
  214. agents = await generateAgentProfiles(requirement, lang, aiCall);
  215. log.info(`Generated ${agents.length} agent profiles`);
  216. } catch (e) {
  217. log.warn('Agent profile generation failed, falling back to defaults:', e);
  218. agents = getDefaultAgents();
  219. agentMode = 'default';
  220. }
  221. } else {
  222. agents = getDefaultAgents();
  223. }
  224. const teacherContext = formatTeacherPersonaForPrompt(agents);
  225. await options.onProgress?.({
  226. step: 'researching',
  227. progress: 10,
  228. message: 'Researching topic',
  229. scenesGenerated: 0,
  230. });
  231. // Web search (optional, graceful degradation)
  232. let researchContext: string | undefined;
  233. if (input.enableWebSearch) {
  234. const tavilyKey = resolveWebSearchApiKey();
  235. if (tavilyKey) {
  236. try {
  237. const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall);
  238. log.info('Running web search for classroom generation', {
  239. hasPdfContext: searchQuery.hasPdfContext,
  240. rawRequirementLength: searchQuery.rawRequirementLength,
  241. rewriteAttempted: searchQuery.rewriteAttempted,
  242. finalQueryLength: searchQuery.finalQueryLength,
  243. });
  244. const searchResult = await searchWithTavily({
  245. query: searchQuery.query,
  246. apiKey: tavilyKey,
  247. });
  248. researchContext = formatSearchResultsAsContext(searchResult);
  249. if (researchContext) {
  250. log.info(`Web search returned ${searchResult.sources.length} sources`);
  251. }
  252. } catch (e) {
  253. log.warn('Web search failed, continuing without search context:', e);
  254. }
  255. } else {
  256. log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search');
  257. }
  258. }
  259. await options.onProgress?.({
  260. step: 'generating_outlines',
  261. progress: 15,
  262. message: 'Generating scene outlines',
  263. scenesGenerated: 0,
  264. });
  265. const outlinesResult = await generateSceneOutlinesFromRequirements(
  266. requirements,
  267. pdfText,
  268. undefined,
  269. aiCall,
  270. undefined,
  271. {
  272. imageGenerationEnabled: input.enableImageGeneration,
  273. videoGenerationEnabled: input.enableVideoGeneration,
  274. researchContext,
  275. teacherContext,
  276. },
  277. );
  278. if (!outlinesResult.success || !outlinesResult.data) {
  279. log.error('Failed to generate outlines:', outlinesResult.error);
  280. throw new Error(outlinesResult.error || 'Failed to generate scene outlines');
  281. }
  282. const outlines = outlinesResult.data;
  283. log.info(`Generated ${outlines.length} scene outlines`);
  284. await options.onProgress?.({
  285. step: 'generating_outlines',
  286. progress: 30,
  287. message: `Generated ${outlines.length} scene outlines`,
  288. scenesGenerated: 0,
  289. totalScenes: outlines.length,
  290. });
  291. const stageId = nanoid(10);
  292. const stage: Stage = {
  293. id: stageId,
  294. name: outlines[0]?.title || requirement.slice(0, 50),
  295. description: undefined,
  296. language: lang,
  297. style: 'interactive',
  298. createdAt: Date.now(),
  299. updatedAt: Date.now(),
  300. // For LLM-generated agents, embed full configs so the client can
  301. // hydrate the agent registry without prior IndexedDB data.
  302. // For default agents, just record IDs — the client already has them.
  303. ...(agentMode === 'generate'
  304. ? {
  305. generatedAgentConfigs: agents.map((a, i) => ({
  306. id: a.id,
  307. name: a.name,
  308. role: a.role,
  309. persona: a.persona || '',
  310. avatar: AGENT_DEFAULT_AVATARS[i % AGENT_DEFAULT_AVATARS.length],
  311. color: AGENT_COLOR_PALETTE[i % AGENT_COLOR_PALETTE.length],
  312. priority: a.role === 'teacher' ? 10 : a.role === 'assistant' ? 7 : 5,
  313. })),
  314. }
  315. : {
  316. agentIds: agents.map((a) => a.id),
  317. }),
  318. };
  319. const store = createInMemoryStore(stage);
  320. const api = createStageAPI(store);
  321. log.info('Stage 2: Generating scene content and actions...');
  322. let generatedScenes = 0;
  323. for (const [index, outline] of outlines.entries()) {
  324. const safeOutline = applyOutlineFallbacks(outline, true);
  325. const progressStart = 30 + Math.floor((index / Math.max(outlines.length, 1)) * 60);
  326. await options.onProgress?.({
  327. step: 'generating_scenes',
  328. progress: Math.max(progressStart, 31),
  329. message: `Generating scene ${index + 1}/${outlines.length}: ${safeOutline.title}`,
  330. scenesGenerated: generatedScenes,
  331. totalScenes: outlines.length,
  332. });
  333. const content = await generateSceneContent(
  334. safeOutline,
  335. aiCall,
  336. undefined,
  337. undefined,
  338. undefined,
  339. undefined,
  340. undefined,
  341. agents,
  342. );
  343. if (!content) {
  344. log.warn(`Skipping scene "${safeOutline.title}" — content generation failed`);
  345. continue;
  346. }
  347. const actions = await generateSceneActions(safeOutline, content, aiCall, undefined, agents);
  348. log.info(`Scene "${safeOutline.title}": ${actions.length} actions`);
  349. const sceneId = createSceneWithActions(safeOutline, content, actions, api);
  350. if (!sceneId) {
  351. log.warn(`Skipping scene "${safeOutline.title}" — scene creation failed`);
  352. continue;
  353. }
  354. generatedScenes += 1;
  355. const progressEnd = 30 + Math.floor(((index + 1) / Math.max(outlines.length, 1)) * 60);
  356. await options.onProgress?.({
  357. step: 'generating_scenes',
  358. progress: Math.min(progressEnd, 90),
  359. message: `Generated ${generatedScenes}/${outlines.length} scenes`,
  360. scenesGenerated: generatedScenes,
  361. totalScenes: outlines.length,
  362. });
  363. }
  364. const scenes = store.getState().scenes;
  365. log.info(`Pipeline complete: ${scenes.length} scenes generated`);
  366. if (scenes.length === 0) {
  367. throw new Error('No scenes were generated');
  368. }
  369. // Phase: Media generation (after all scenes generated)
  370. if (input.enableImageGeneration || input.enableVideoGeneration) {
  371. await options.onProgress?.({
  372. step: 'generating_media',
  373. progress: 90,
  374. message: 'Generating media files',
  375. scenesGenerated: scenes.length,
  376. totalScenes: outlines.length,
  377. });
  378. try {
  379. const mediaMap = await generateMediaForClassroom(outlines, stageId, options.baseUrl);
  380. replaceMediaPlaceholders(scenes, mediaMap);
  381. log.info(`Media generation complete: ${Object.keys(mediaMap).length} files`);
  382. } catch (err) {
  383. log.warn('Media generation phase failed, continuing:', err);
  384. }
  385. }
  386. // Phase: TTS generation
  387. if (input.enableTTS) {
  388. await options.onProgress?.({
  389. step: 'generating_tts',
  390. progress: 94,
  391. message: 'Generating TTS audio',
  392. scenesGenerated: scenes.length,
  393. totalScenes: outlines.length,
  394. });
  395. try {
  396. await generateTTSForClassroom(scenes, stageId, options.baseUrl);
  397. log.info('TTS generation complete');
  398. } catch (err) {
  399. log.warn('TTS generation phase failed, continuing:', err);
  400. }
  401. }
  402. await options.onProgress?.({
  403. step: 'persisting',
  404. progress: 98,
  405. message: 'Persisting classroom data',
  406. scenesGenerated: scenes.length,
  407. totalScenes: outlines.length,
  408. });
  409. const persisted = await persistClassroom(
  410. {
  411. id: stageId,
  412. stage,
  413. scenes,
  414. },
  415. options.baseUrl,
  416. );
  417. log.info(`Classroom persisted: ${persisted.id}, URL: ${persisted.url}`);
  418. await options.onProgress?.({
  419. step: 'completed',
  420. progress: 100,
  421. message: 'Classroom generation completed',
  422. scenesGenerated: scenes.length,
  423. totalScenes: outlines.length,
  424. });
  425. return {
  426. id: persisted.id,
  427. url: persisted.url,
  428. stage,
  429. scenes,
  430. scenesCount: scenes.length,
  431. createdAt: persisted.createdAt,
  432. };
  433. }