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, generateSceneActions, generateSceneContent, formatTeacherPersonaForPrompt, PBLGenerationError, withGenerationRetry, type AICallFn, type AgentInfo, } from '@openmaic/generation'; import { createSceneWithActions } from '@/lib/server/scene-generation'; import { generatePBLV2Project } from '@/lib/pbl/v2/agents/planner'; import { getDefaultAgents, getDefaultGeneratedAgentConfigs, } from '@/lib/orchestration/registry/store'; import { createLogger } from '@/lib/logger'; import { isProviderKeyRequired } from '@/lib/ai/providers'; import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { getStageModel, type LlmStage } from '@/lib/server/model-routes'; import type { LanguageModel } from 'ai'; import type { ThinkingConfig } from '@/lib/types/provider'; import { resolveVocationalActive } from '@/lib/config/feature-flags'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types'; import { persistClassroom, type PersistClassroomOwnership } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, replaceMediaPlaceholders, generateTTSForClassroom, } from '@/lib/server/classroom-media-generation'; import { buildVideoManifestFromOutlines } from '@/lib/media/video-manifest'; import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; import { applyClassroomOutlineMode } from '@/lib/server/classroom-outline-mode'; const log = createLogger('Classroom'); export function containPBLGenerationError(error: unknown, sceneTitle: string): null { if (!(error instanceof PBLGenerationError)) throw error; log.warn(`PBL generation failed for scene "${sceneTitle}": ${error.message}`); return null; } export interface GenerateClassroomInput { requirement: string; pdfContent?: { text: string; images: string[] }; enableWebSearch?: boolean; webSearchProviderId?: WebSearchProviderId; webSearchApiKey?: string; webSearchModelId?: string; baiduSubSources?: BaiduSubSources; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; /** Use the original app Interactive Mode outline prompt for this classroom. */ interactiveMode?: boolean; /** Enable the original vocational Task Engine outline and scene path. */ taskEngineMode?: boolean; } 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; } export function containsGeneratedInteractiveHtml(scenes: Scene[]): boolean { return scenes.some( (scene) => scene.type === 'interactive' && scene.content.type === 'interactive' && typeof scene.content.html === 'string' && scene.content.html.trim().length > 0, ); } 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) => { 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); }; }, }; } export async function generateClassroom( input: GenerateClassroomInput, options: { baseUrl: string; onProgress?: (progress: ClassroomGenerationProgress) => Promise | void; /** * Cooperative cancellation: checked between scenes (and before the final * persist). An aborted run throws `AbortError` and persists nothing — the * server-side generation commits atomically at the end. */ signal?: AbortSignal; /** Server-derived owner metadata; never accepted through generation input. */ ownership?: PersistClassroomOwnership; }, ): Promise { const { requirement, pdfContent } = input; if (options.signal?.aborted) { throw new DOMException('Classroom generation cancelled', 'AbortError'); } await options.onProgress?.({ step: 'initializing', progress: 5, message: 'Initializing classroom generation', scenesGenerated: 0, }); const { model: languageModel, modelInfo, modelString, providerId, apiKey, thinkingConfig: classroomThinking, } = await resolveModel({ stage: 'generate-classroom' }); log.info(`Using server-configured model: ${modelString}`); // Fail fast if the resolved provider has no API key configured 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).`, ); } // The web-search query rewrite is a light, separable stage operators may route // to a cheaper model. It defaults to the classroom model and is only // re-resolved lazily (inside the web-search branch, and only when a route is // configured). This keeps a misconfigured optional route from aborting all // classroom generation, and skips the extra resolution when web search is off. let searchQueryModel = languageModel; let searchQueryThinking = classroomThinking; 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', undefined, classroomThinking, ); return result.text; }; // Per-stage model resolution for the scene pipeline. The classroom used to // bind a single `languageModel` (from the `generate-classroom` stage) into one // `sceneAiCall` closure shared by scene-content and scene-actions. That made // every `MODEL_ROUTES` entry for `scene-content` / `scene-content:` / // `scene-actions` a no-op on this path — the browser UI already routes each // stage independently via /api/generate/*, but the one-shot skill API did not. // // Each stage is resolved lazily and only when a route is actually configured // (getStageModel returns undefined), so unrouted deployments pay zero extra // cost and reuse the classroom model. Resolution failure (e.g. an unknown // provider in the route) degrades to the classroom model with a warn, mirroring // the existing web-search-query-rewrite handling below — a misconfigured // optional route never aborts classroom generation. const stageModelCache = new Map< LlmStage, { model: LanguageModel; outputWindow?: number; thinking: ThinkingConfig | undefined; } >(); const resolveStageModel = async ( stage: LlmStage, ): Promise<{ model: LanguageModel; outputWindow?: number; thinking: ThinkingConfig | undefined; }> => { const cached = stageModelCache.get(stage); if (cached) return cached; // No route configured → reuse the classroom model, no extra resolution. if (!getStageModel(stage)) { const fallback = { model: languageModel, outputWindow: modelInfo?.outputWindow, thinking: classroomThinking, }; stageModelCache.set(stage, fallback); return fallback; } try { const resolved = await resolveModel({ stage }); const entry = { model: resolved.model, outputWindow: resolved.modelInfo?.outputWindow, thinking: resolved.thinkingConfig, }; log.info(`Stage "${stage}" routed to model: ${resolved.modelString}`); stageModelCache.set(stage, entry); return entry; } catch (err) { log.warn( `Stage "${stage}" route "${getStageModel(stage)}" could not be resolved; ` + `falling back to the generate-classroom model.`, err, ); const fallback = { model: languageModel, outputWindow: modelInfo?.outputWindow, thinking: classroomThinking, }; stageModelCache.set(stage, fallback); return fallback; } }; // scene-content routes per outline type via the composite key // `scene-content:` (slide/quiz/interactive/pbl), falling back to the // base `scene-content` route — same resolution the browser UI uses at // /api/generate/scene-content. Returns the aiCall plus the resolved model // and thinking config, because PBL scene generation drives its own LLM // calls through the model object (generatePBLSceneContent) rather than the // aiCall closure, and consumes the route's thinking config separately. const resolveSceneContentCall = async (outlineType?: string) => { const stage = (outlineType ? `scene-content:${outlineType}` : 'scene-content') as LlmStage; const { model, outputWindow, thinking } = await resolveStageModel(stage); const aiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { const result = await callLLM( { model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], maxOutputTokens: outputWindow, maxRetries: 0, }, 'generate-classroom-scene', undefined, thinking, ); return result.text; }; return { aiCall, model, thinking }; }; // scene-actions routes via the `scene-actions` stage. let sceneActionsAiCall: AICallFn | undefined; const getSceneActionsAiCall = async (): Promise => { if (sceneActionsAiCall) return sceneActionsAiCall; const { model, outputWindow, thinking } = await resolveStageModel('scene-actions'); sceneActionsAiCall = async (systemPrompt, userPrompt, _images) => { const result = await callLLM( { model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], maxOutputTokens: outputWindow, maxRetries: 0, }, 'generate-classroom-scene', undefined, thinking, ); return result.text; }; return sceneActionsAiCall; }; const searchQueryAiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { const result = await callLLM( { model: searchQueryModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], maxOutputTokens: 256, }, 'web-search-query-rewrite', undefined, searchQueryThinking, ); return result.text; }; const requirements: UserRequirements = { requirement, ...(input.interactiveMode ? { interactiveMode: true } : {}), ...(input.taskEngineMode ? { taskEngineMode: true } : {}), }; const vocationalActive = resolveVocationalActive(requirements); const pdfText = pdfContent?.text || undefined; await options.onProgress?.({ step: 'researching', progress: 10, message: 'Researching topic', scenesGenerated: 0, }); // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { const webSearchConfig = resolveClassroomWebSearchConfig(input); if (webSearchConfig) { // Re-resolve the query-rewrite model only when explicitly routed. If // resolution itself fails (e.g. unknown provider in the route), fall back // to the classroom model here; a route with a missing key resolves fine // and surfaces only later in callLLM, which the outer try/catch below // degrades gracefully — either way the pipeline still works. const rewriteRoute = getStageModel('web-search-query-rewrite'); if (rewriteRoute) { try { const rewriteResolved = await resolveModel({ stage: 'web-search-query-rewrite' }); searchQueryModel = rewriteResolved.model; searchQueryThinking = rewriteResolved.thinkingConfig; } catch (err) { log.warn( `web-search-query-rewrite route "${rewriteRoute}" unavailable; using classroom model for query rewrite`, err, ); } } 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 searchWeb({ providerId: webSearchConfig.providerId, query: searchQuery.query, apiKey: webSearchConfig.apiKey, baseUrl: webSearchConfig.baseUrl, baiduSubSources: webSearchConfig.baiduSubSources, claudeModelId: webSearchConfig.claudeModelId, }); 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 web search API key configured, skipping web search'); } } await options.onProgress?.({ step: 'generating_outlines', progress: 15, message: 'Generating scene outlines', scenesGenerated: 0, }); // Resolve the same default roster before outline generation so Interactive // Mode receives the same teacher persona context as the foreground path. const agents: AgentInfo[] = getDefaultAgents(); const outlineAiCall = applyClassroomOutlineMode(aiCall, requirements, { pdfText, researchContext, teacherContext: formatTeacherPersonaForPrompt(agents), imageGenerationEnabled: input.enableImageGeneration, videoGenerationEnabled: input.enableVideoGeneration, }); const outlinesResult = await generateSceneOutlinesFromRequirements( requirements, pdfText, undefined, outlineAiCall, { 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, courseTitle, outlines } = outlinesResult.data; log.info( `Generated ${outlines.length} scene outlines (languageDirective: ${languageDirective}, courseTitle: ${courseTitle ?? 'n/a'})`, ); await options.onProgress?.({ step: 'generating_outlines', progress: 30, message: `Generated ${outlines.length} scene outlines`, scenesGenerated: 0, totalScenes: outlines.length, }); const stageId = nanoid(10); const stage: Stage = { id: stageId, name: courseTitle || outlines[0]?.title || requirement.slice(0, 50), description: undefined, languageDirective, videoManifest: buildVideoManifestFromOutlines(outlines), style: 'interactive', createdAt: Date.now(), updatedAt: Date.now(), ...(input.interactiveMode ? { interactiveMode: true } : {}), // Record the default agent IDs — the client already has them in its registry. agentIds: agents.map((a) => a.id), // Freeze the complete persona/presentation/voice snapshot as well. Bundle // publication must not depend on the learner device's future defaults. generatedAgentConfigs: getDefaultGeneratedAgentConfigs(agents.map((agent) => agent.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()) { // Cooperative cancellation point: stop before starting the next scene. if (options.signal?.aborted) { throw new DOMException('Classroom generation cancelled', 'AbortError'); } const safeOutline = applyOutlineFallbacks(outline, true, { allowProceduralSkill: vocationalActive, }); 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 reportSceneRetry = async ( phase: 'content' | 'actions', event: { attempt: number; maxAttempts: number; reason: string }, ) => { const nextAttempt = Math.min(event.attempt + 1, event.maxAttempts); const message = `Retrying scene ${index + 1}/${outlines.length} ${phase} (${nextAttempt}/${event.maxAttempts}): ${safeOutline.title}`; log.warn(`${message} — ${event.reason}`); await options.onProgress?.({ step: 'generating_scenes', progress: Math.max(progressStart, 31), message, scenesGenerated: generatedScenes, totalScenes: outlines.length, }); }; // Resolve this scene's content model lazily, per outline type. The package // gets the provider-bound AICallFn and the app injects its agentic PBL loop // as the classified fallback, preserving single-call → loop routing. const contentCall = await resolveSceneContentCall(safeOutline.type); const content = await (async () => { try { return await withGenerationRetry( () => generateSceneContent(safeOutline, contentCall.aiCall, { agents, languageDirective, allowProceduralSkill: vocationalActive, ...(safeOutline.type === 'pbl' ? { pblLoopFallback: (input) => generatePBLV2Project( input, contentCall.model, callLLM, { logger: log }, contentCall.thinking, ), } : {}), }), { label: `scene ${index + 1}/${outlines.length} content`, shouldRetryResult: (result) => result === null, onRetry: (event) => reportSceneRetry('content', event), }, ); } catch (error) { return containPBLGenerationError(error, safeOutline.title); } })(); if (!content) { log.warn(`Skipping scene "${safeOutline.title}" — content generation failed`); continue; } const actionsAiCall = await getSceneActionsAiCall(); const actions = await withGenerationRetry( () => generateSceneActions(safeOutline, content, actionsAiCall, { agents, languageDirective, }), { label: `scene ${index + 1}/${outlines.length} actions`, onRetry: (event) => reportSceneRetry('actions', event), }, ); 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.interactiveMode && !containsGeneratedInteractiveHtml(scenes)) { throw new Error('Interactive Mode acceptance failed: no interactive scene with generated HTML'); } // Phase: Media generation (after all scenes 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); } } // Phase: TTS generation 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); } } // Cancellation point before the atomic commit: a cancelled run never // persists a partial classroom. if (options.signal?.aborted) { throw new DOMException('Classroom generation cancelled', 'AbortError'); } 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, options.ownership, ); 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, }; }