arena-learning / studyArena /lib /server /classroom-generation.ts
Nitish kumar
Upload folder using huggingface_hub
c20f20c verified
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 { formatTeacherPersonaForPrompt } from '@/lib/generation/prompt-formatters';
import { getDefaultAgents } from '@/lib/orchestration/registry/store';
import { createLogger } from '@/lib/logger';
import { parseModelString } from '@/lib/ai/providers';
import { resolveApiKey, resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { resolveModel } from '@/lib/server/resolve-model';
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';
const log = createLogger('Classroom');
export interface GenerateClassroomInput {
requirement: string;
pdfContent?: { text: string; images: string[] };
language?: 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 normalizeLanguage(language?: string): 'zh-CN' | 'en-US' {
return language === 'en-US' ? 'en-US' : 'zh-CN';
}
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,
language: 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)
- Names and personas must be in language: ${language}
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 } = resolveModel({});
log.info(`Using server-configured model: ${modelString}`);
// Fail fast if the resolved provider has no API key configured
const { providerId } = parseModelString(modelString);
const apiKey = resolveApiKey(providerId);
if (!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 lang = normalizeLanguage(input.language);
const requirements: UserRequirements = {
requirement,
language: lang,
};
const pdfText = pdfContent?.text || undefined;
// Resolve agents based on agentMode
let agents: AgentInfo[];
const agentMode = input.agentMode || 'default';
if (agentMode === 'generate') {
log.info('Generating custom agent profiles via LLM...');
try {
agents = await generateAgentProfiles(requirement, lang, 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 teacherContext = formatTeacherPersonaForPrompt(agents);
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 tavilyKey = resolveWebSearchApiKey();
if (tavilyKey) {
try {
log.info('Running web search for requirement context...');
const searchResult = await searchWithTavily({ query: requirement, 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,
teacherContext,
},
);
if (!outlinesResult.success || !outlinesResult.data) {
log.error('Failed to generate outlines:', outlinesResult.error);
throw new Error(outlinesResult.error || 'Failed to generate scene outlines');
}
const outlines = outlinesResult.data;
log.info(`Generated ${outlines.length} scene outlines`);
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: outlines[0]?.title || requirement.slice(0, 50),
description: undefined,
language: lang,
style: 'interactive',
createdAt: Date.now(),
updatedAt: Date.now(),
};
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,
undefined,
undefined,
undefined,
undefined,
undefined,
agents,
);
if (!content) {
log.warn(`Skipping scene "${safeOutline.title}" — content generation failed`);
continue;
}
const actions = await generateSceneActions(safeOutline, content, aiCall, undefined, agents);
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');
}
// 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);
}
}
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,
};
}