| import { nanoid } from 'nanoid'; |
| import { callLLM } from '@/lib/ai/llm'; |
| import { createStageAPI } from '@/lib/api/stage-api'; |
| import type { StageStore } from '@/lib/api/stage-api-types'; |
| import { |
| applyOutlineFallbacks, |
| generateSceneOutlinesFromRequirements, |
| } from '@/lib/generation/outline-generator'; |
| import { |
| createSceneWithActions, |
| generateSceneActions, |
| generateSceneContent, |
| } from '@/lib/generation/scene-generator'; |
| import type { AICallFn } from '@/lib/generation/pipeline-types'; |
| import type { AgentInfo } from '@/lib/generation/pipeline-types'; |
| import { getDefaultAgents } from '@/lib/orchestration/registry/store'; |
| import { createLogger } from '@/lib/logger'; |
| import { isProviderKeyRequired } from '@/lib/ai/providers'; |
| import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; |
| import { resolveModel } from '@/lib/server/resolve-model'; |
| import { buildSearchQuery } from '@/lib/server/search-query-builder'; |
| import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; |
| import { persistClassroom } from '@/lib/server/classroom-storage'; |
| import { |
| generateMediaForClassroom, |
| replaceMediaPlaceholders, |
| generateTTSForClassroom, |
| } from '@/lib/server/classroom-media-generation'; |
| import type { UserRequirements } from '@/lib/types/generation'; |
| import type { Scene, Stage } from '@/lib/types/stage'; |
| import { AGENT_COLOR_PALETTE, AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults'; |
|
|
| const log = createLogger('Classroom'); |
|
|
| export interface GenerateClassroomInput { |
| requirement: string; |
| pdfContent?: { text: string; images: string[] }; |
| enableWebSearch?: boolean; |
| enableImageGeneration?: boolean; |
| enableVideoGeneration?: boolean; |
| enableTTS?: boolean; |
| agentMode?: 'default' | 'generate'; |
| } |
|
|
| export type ClassroomGenerationStep = |
| | 'initializing' |
| | 'researching' |
| | 'generating_outlines' |
| | 'generating_scenes' |
| | 'generating_media' |
| | 'generating_tts' |
| | 'persisting' |
| | 'completed'; |
|
|
| export interface ClassroomGenerationProgress { |
| step: ClassroomGenerationStep; |
| progress: number; |
| message: string; |
| scenesGenerated: number; |
| totalScenes?: number; |
| } |
|
|
| export interface GenerateClassroomResult { |
| id: string; |
| url: string; |
| stage: Stage; |
| scenes: Scene[]; |
| scenesCount: number; |
| createdAt: string; |
| } |
|
|
| function createInMemoryStore(stage: Stage): StageStore { |
| let state = { |
| stage: stage as Stage | null, |
| scenes: [] as Scene[], |
| currentSceneId: null as string | null, |
| mode: 'playback' as const, |
| }; |
|
|
| const listeners: Array<(s: typeof state, prev: typeof state) => void> = []; |
|
|
| return { |
| getState: () => state, |
| setState: (partial: Partial<typeof state>) => { |
| const prev = state; |
| state = { ...state, ...partial }; |
| listeners.forEach((fn) => fn(state, prev)); |
| }, |
| subscribe: (listener: (s: typeof state, prev: typeof state) => void) => { |
| listeners.push(listener); |
| return () => { |
| const idx = listeners.indexOf(listener); |
| if (idx >= 0) listeners.splice(idx, 1); |
| }; |
| }, |
| }; |
| } |
|
|
| function stripCodeFences(text: string): string { |
| let cleaned = text.trim(); |
| if (cleaned.startsWith('```')) { |
| cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); |
| } |
| return cleaned.trim(); |
| } |
|
|
| async function generateAgentProfiles( |
| requirement: string, |
| languageDirective: string, |
| aiCall: AICallFn, |
| ): Promise<AgentInfo[]> { |
| const systemPrompt = |
| 'You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Return ONLY valid JSON, no markdown or explanation.'; |
|
|
| const userPrompt = `Generate agent profiles for a course with this requirement: |
| ${requirement} |
| |
| 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" |
| - 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. |
| |
| Return a JSON object with this exact structure: |
| { |
| "agents": [ |
| { |
| "name": "string", |
| "role": "teacher" | "assistant" | "student", |
| "persona": "string (2-3 sentences)" |
| } |
| ] |
| }`; |
|
|
| const response = await aiCall(systemPrompt, userPrompt); |
| const rawText = stripCodeFences(response); |
| const parsed = JSON.parse(rawText) as { |
| agents: Array<{ name: string; role: string; persona: string }>; |
| }; |
|
|
| if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) { |
| throw new Error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`); |
| } |
|
|
| const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length; |
| if (teacherCount !== 1) { |
| throw new Error(`Expected exactly 1 teacher, got ${teacherCount}`); |
| } |
|
|
| return parsed.agents.map((a, i) => ({ |
| id: `gen-server-${i}`, |
| name: a.name, |
| role: a.role, |
| persona: a.persona, |
| })); |
| } |
|
|
| export async function generateClassroom( |
| input: GenerateClassroomInput, |
| options: { |
| baseUrl: string; |
| onProgress?: (progress: ClassroomGenerationProgress) => Promise<void> | void; |
| }, |
| ): Promise<GenerateClassroomResult> { |
| const { requirement, pdfContent } = input; |
|
|
| await options.onProgress?.({ |
| step: 'initializing', |
| progress: 5, |
| message: 'Initializing classroom generation', |
| scenesGenerated: 0, |
| }); |
|
|
| const { |
| model: languageModel, |
| modelInfo, |
| modelString, |
| providerId, |
| apiKey, |
| } = await resolveModel({}); |
| log.info(`Using server-configured model: ${modelString}`); |
|
|
| |
| if (isProviderKeyRequired(providerId) && !apiKey) { |
| throw new Error( |
| `No API key configured for provider "${providerId}". ` + |
| `Set the appropriate key in .env.local or server-providers.yml (e.g. ${providerId.toUpperCase()}_API_KEY).`, |
| ); |
| } |
|
|
| const aiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { |
| const result = await callLLM( |
| { |
| model: languageModel, |
| messages: [ |
| { role: 'system', content: systemPrompt }, |
| { role: 'user', content: userPrompt }, |
| ], |
| maxOutputTokens: modelInfo?.outputWindow, |
| }, |
| 'generate-classroom', |
| ); |
| return result.text; |
| }; |
|
|
| const searchQueryAiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { |
| const result = await callLLM( |
| { |
| model: languageModel, |
| messages: [ |
| { role: 'system', content: systemPrompt }, |
| { role: 'user', content: userPrompt }, |
| ], |
| maxOutputTokens: 256, |
| }, |
| 'web-search-query-rewrite', |
| ); |
| return result.text; |
| }; |
|
|
| const requirements: UserRequirements = { |
| requirement, |
| }; |
| const pdfText = pdfContent?.text || undefined; |
|
|
| await options.onProgress?.({ |
| step: 'researching', |
| progress: 10, |
| message: 'Researching topic', |
| scenesGenerated: 0, |
| }); |
|
|
| |
| let researchContext: string | undefined; |
| if (input.enableWebSearch) { |
| const tavilyKey = resolveWebSearchApiKey(); |
| if (tavilyKey) { |
| try { |
| const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); |
|
|
| log.info('Running web search for classroom generation', { |
| hasPdfContext: searchQuery.hasPdfContext, |
| rawRequirementLength: searchQuery.rawRequirementLength, |
| rewriteAttempted: searchQuery.rewriteAttempted, |
| finalQueryLength: searchQuery.finalQueryLength, |
| }); |
|
|
| const searchResult = await searchWithTavily({ |
| query: searchQuery.query, |
| apiKey: tavilyKey, |
| }); |
| researchContext = formatSearchResultsAsContext(searchResult); |
| if (researchContext) { |
| log.info(`Web search returned ${searchResult.sources.length} sources`); |
| } |
| } catch (e) { |
| log.warn('Web search failed, continuing without search context:', e); |
| } |
| } else { |
| log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search'); |
| } |
| } |
|
|
| await options.onProgress?.({ |
| step: 'generating_outlines', |
| progress: 15, |
| message: 'Generating scene outlines', |
| scenesGenerated: 0, |
| }); |
|
|
| const outlinesResult = await generateSceneOutlinesFromRequirements( |
| requirements, |
| pdfText, |
| undefined, |
| aiCall, |
| undefined, |
| { |
| imageGenerationEnabled: input.enableImageGeneration, |
| videoGenerationEnabled: input.enableVideoGeneration, |
| researchContext, |
| |
| }, |
| ); |
|
|
| if (!outlinesResult.success || !outlinesResult.data) { |
| log.error('Failed to generate outlines:', outlinesResult.error); |
| throw new Error(outlinesResult.error || 'Failed to generate scene outlines'); |
| } |
|
|
| const { languageDirective, outlines } = outlinesResult.data; |
| log.info(`Generated ${outlines.length} scene outlines (languageDirective: ${languageDirective})`); |
|
|
| await options.onProgress?.({ |
| step: 'generating_outlines', |
| progress: 30, |
| message: `Generated ${outlines.length} scene outlines`, |
| scenesGenerated: 0, |
| totalScenes: outlines.length, |
| }); |
|
|
| |
| let agents: AgentInfo[]; |
| const agentMode = input.agentMode || 'default'; |
| if (agentMode === 'generate') { |
| log.info('Generating custom agent profiles via LLM...'); |
| try { |
| agents = await generateAgentProfiles(requirement, languageDirective, aiCall); |
| log.info(`Generated ${agents.length} agent profiles`); |
| } catch (e) { |
| log.warn('Agent profile generation failed, falling back to defaults:', e); |
| agents = getDefaultAgents(); |
| } |
| } else { |
| agents = getDefaultAgents(); |
| } |
|
|
| const stageId = nanoid(10); |
| const stage: Stage = { |
| id: stageId, |
| name: outlines[0]?.title || requirement.slice(0, 50), |
| description: undefined, |
| languageDirective, |
| style: 'interactive', |
| createdAt: Date.now(), |
| updatedAt: Date.now(), |
| |
| |
| |
| ...(agentMode === 'generate' |
| ? { |
| generatedAgentConfigs: agents.map((a, i) => ({ |
| id: a.id, |
| name: a.name, |
| role: a.role, |
| persona: a.persona || '', |
| avatar: AGENT_DEFAULT_AVATARS[i % AGENT_DEFAULT_AVATARS.length], |
| color: AGENT_COLOR_PALETTE[i % AGENT_COLOR_PALETTE.length], |
| priority: a.role === 'teacher' ? 10 : a.role === 'assistant' ? 7 : 5, |
| })), |
| } |
| : { |
| agentIds: agents.map((a) => a.id), |
| }), |
| }; |
|
|
| const store = createInMemoryStore(stage); |
| const api = createStageAPI(store); |
|
|
| log.info('Stage 2: Generating scene content and actions...'); |
| let generatedScenes = 0; |
|
|
| for (const [index, outline] of outlines.entries()) { |
| const safeOutline = applyOutlineFallbacks(outline, true); |
| const progressStart = 30 + Math.floor((index / Math.max(outlines.length, 1)) * 60); |
|
|
| await options.onProgress?.({ |
| step: 'generating_scenes', |
| progress: Math.max(progressStart, 31), |
| message: `Generating scene ${index + 1}/${outlines.length}: ${safeOutline.title}`, |
| scenesGenerated: generatedScenes, |
| totalScenes: outlines.length, |
| }); |
|
|
| const content = await generateSceneContent(safeOutline, aiCall, { agents, languageDirective }); |
| if (!content) { |
| log.warn(`Skipping scene "${safeOutline.title}" — content generation failed`); |
| continue; |
| } |
|
|
| const actions = await generateSceneActions(safeOutline, content, aiCall, { |
| agents, |
| languageDirective, |
| }); |
| log.info(`Scene "${safeOutline.title}": ${actions.length} actions`); |
|
|
| const sceneId = createSceneWithActions(safeOutline, content, actions, api); |
| if (!sceneId) { |
| log.warn(`Skipping scene "${safeOutline.title}" — scene creation failed`); |
| continue; |
| } |
|
|
| generatedScenes += 1; |
| const progressEnd = 30 + Math.floor(((index + 1) / Math.max(outlines.length, 1)) * 60); |
| await options.onProgress?.({ |
| step: 'generating_scenes', |
| progress: Math.min(progressEnd, 90), |
| message: `Generated ${generatedScenes}/${outlines.length} scenes`, |
| scenesGenerated: generatedScenes, |
| totalScenes: outlines.length, |
| }); |
| } |
|
|
| const scenes = store.getState().scenes; |
| log.info(`Pipeline complete: ${scenes.length} scenes generated`); |
|
|
| if (scenes.length === 0) { |
| throw new Error('No scenes were generated'); |
| } |
|
|
| |
| if (input.enableImageGeneration || input.enableVideoGeneration) { |
| await options.onProgress?.({ |
| step: 'generating_media', |
| progress: 90, |
| message: 'Generating media files', |
| scenesGenerated: scenes.length, |
| totalScenes: outlines.length, |
| }); |
|
|
| try { |
| const mediaMap = await generateMediaForClassroom(outlines, stageId, options.baseUrl); |
| replaceMediaPlaceholders(scenes, mediaMap); |
| log.info(`Media generation complete: ${Object.keys(mediaMap).length} files`); |
| } catch (err) { |
| log.warn('Media generation phase failed, continuing:', err); |
| } |
| } |
|
|
| |
| if (input.enableTTS) { |
| await options.onProgress?.({ |
| step: 'generating_tts', |
| progress: 94, |
| message: 'Generating TTS audio', |
| scenesGenerated: scenes.length, |
| totalScenes: outlines.length, |
| }); |
|
|
| try { |
| await generateTTSForClassroom(scenes, stageId, options.baseUrl); |
| log.info('TTS generation complete'); |
| } catch (err) { |
| log.warn('TTS generation phase failed, continuing:', err); |
| } |
| } |
|
|
| await options.onProgress?.({ |
| step: 'persisting', |
| progress: 98, |
| message: 'Persisting classroom data', |
| scenesGenerated: scenes.length, |
| totalScenes: outlines.length, |
| }); |
|
|
| const persisted = await persistClassroom( |
| { |
| id: stageId, |
| stage, |
| scenes, |
| }, |
| options.baseUrl, |
| ); |
|
|
| log.info(`Classroom persisted: ${persisted.id}, URL: ${persisted.url}`); |
|
|
| await options.onProgress?.({ |
| step: 'completed', |
| progress: 100, |
| message: 'Classroom generation completed', |
| scenesGenerated: scenes.length, |
| totalScenes: outlines.length, |
| }); |
|
|
| return { |
| id: persisted.id, |
| url: persisted.url, |
| stage, |
| scenes, |
| scenesCount: scenes.length, |
| createdAt: persisted.createdAt, |
| }; |
| } |
|
|