Files
openmaic/OpenMAIC/lib/server/classroom-generation.ts

680 lines
23 KiB
TypeScript

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<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);
};
},
};
}
export async function generateClassroom(
input: GenerateClassroomInput,
options: {
baseUrl: string;
onProgress?: (progress: ClassroomGenerationProgress) => Promise<void> | 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<GenerateClassroomResult> {
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:<type>` /
// `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:<type>` (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<AICallFn> => {
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,
};
}