OpenMAIC-React / src /lib /generation /outline-generator.ts
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
/**
* Stage 1: Generate scene outlines from user requirements.
* Also contains outline fallback logic.
*/
import { nanoid } from 'nanoid';
import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
import type {
UserRequirements,
SceneOutline,
PdfImage,
ImageMapping,
} from '@/lib/types/generation';
import { buildPrompt, PROMPT_IDS } from '@/lib/prompts';
import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters';
import { parseJsonResponse } from './json-repair';
import { uniquifyMediaElementIds } from './scene-builder';
import type { AICallFn, GenerationResult, GenerationCallbacks } from './pipeline-types';
import { createLogger } from '@/lib/logger';
const log = createLogger('Generation');
/**
* Used when the outline stage fails to produce an explicit directive (LLM
* schema regression, empty response, upstream error). Downstream prompts
* still need *something* that steers the model toward the requirement's
* language rather than defaulting to the training-distribution prior.
*/
export const DEFAULT_LANGUAGE_DIRECTIVE =
'Teach in the language that matches the user requirement.';
/**
* Generate scene outlines from user requirements
* Now uses simplified UserRequirements with just requirement text and language
*/
export async function generateSceneOutlinesFromRequirements(
requirements: UserRequirements,
pdfText: string | undefined,
pdfImages: PdfImage[] | undefined,
aiCall: AICallFn,
callbacks?: GenerationCallbacks,
options?: {
visionEnabled?: boolean;
imageMapping?: ImageMapping;
imageGenerationEnabled?: boolean;
videoGenerationEnabled?: boolean;
researchContext?: string;
teacherContext?: string;
},
): Promise<GenerationResult<{ languageDirective: string; outlines: SceneOutline[] }>> {
// Build available images description for the prompt
let availableImagesText = 'No images available';
let visionImages: Array<{ id: string; src: string }> | undefined;
if (pdfImages && pdfImages.length > 0) {
if (options?.visionEnabled && options?.imageMapping) {
// Vision mode: split into vision images (first N) and text-only (rest)
const allWithSrc = pdfImages.filter((img) => options.imageMapping![img.id]);
const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES);
const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
const noSrcImages = pdfImages.filter((img) => !options.imageMapping![img.id]);
const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img));
const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
formatImageDescription(img),
);
availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
visionImages = visionSlice.map((img) => ({
id: img.id,
src: options.imageMapping![img.id],
width: img.width,
height: img.height,
}));
} else {
// Text-only mode: full descriptions
availableImagesText = pdfImages.map((img) => formatImageDescription(img)).join('\n');
}
}
// Build user profile string for prompt injection
const userProfileText =
requirements.userNickname || requirements.userBio
? `## Student Profile\n\nStudent: ${requirements.userNickname || 'Unknown'}${requirements.userBio ? ` — ${requirements.userBio}` : ''}\n\nConsider this student's background when designing the course. Adapt difficulty, examples, and teaching approach accordingly.\n\n---`
: '';
// Build media snippet conditions based on enabled flags.
const imageEnabled = options?.imageGenerationEnabled ?? false;
const videoEnabled = options?.videoGenerationEnabled ?? false;
const mediaEnabled = imageEnabled || videoEnabled;
const hasSourceImages = (pdfImages?.length ?? 0) > 0;
// Use simplified prompt variables
const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
// New simplified variables
requirement: requirements.requirement,
pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : 'None',
availableImages: availableImagesText,
userProfile: userProfileText,
hasSourceImages,
imageEnabled,
videoEnabled,
mediaEnabled,
researchContext: options?.researchContext || 'None',
// Server-side generation populates this via options; client-side populates via formatTeacherPersonaForPrompt
teacherContext: options?.teacherContext || '',
});
if (!prompts) {
return { success: false, error: 'Prompt template not found' };
}
try {
callbacks?.onProgress?.({
currentStage: 1,
overallProgress: 20,
stageProgress: 50,
statusMessage: '正在分析需求,生成场景大纲...',
scenesGenerated: 0,
totalScenes: 0,
});
const response = await aiCall(prompts.system, prompts.user, visionImages);
const parsed = parseJsonResponse<
{ languageDirective: string; outlines: SceneOutline[] } | SceneOutline[]
>(response);
let languageDirective: string;
let rawOutlines: SceneOutline[];
if (Array.isArray(parsed)) {
// Fallback: LLM returned old flat array format
languageDirective = DEFAULT_LANGUAGE_DIRECTIVE;
rawOutlines = parsed;
} else if (parsed && parsed.outlines) {
languageDirective = parsed.languageDirective || DEFAULT_LANGUAGE_DIRECTIVE;
rawOutlines = parsed.outlines;
} else {
return { success: false, error: 'Failed to parse scene outlines response' };
}
if (!Array.isArray(rawOutlines)) {
return { success: false, error: 'Failed to parse scene outlines response' };
}
// Ensure IDs and order
const enriched = rawOutlines.map((outline, index) => ({
...outline,
id: outline.id || nanoid(),
order: index + 1,
}));
// Replace sequential gen_img_N/gen_vid_N with globally unique IDs
const result = uniquifyMediaElementIds(enriched);
callbacks?.onProgress?.({
currentStage: 1,
overallProgress: 50,
stageProgress: 100,
statusMessage: `已生成 ${result.length} 个场景大纲`,
scenesGenerated: 0,
totalScenes: result.length,
});
return { success: true, data: { languageDirective, outlines: result } };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Apply type fallbacks for outlines that can't be generated as their declared type.
* - interactive without interactiveConfig OR widgetType+widgetOutline → slide
* - pbl without pblConfig or languageModel → slide
*/
export function applyOutlineFallbacks(
outline: SceneOutline,
hasLanguageModel: boolean,
): SceneOutline {
// Ultra Mode: interactive scenes with widgetType + widgetOutline are valid
const hasWidgetConfig = outline.widgetType && outline.widgetOutline;
if (outline.type === 'interactive' && !outline.interactiveConfig && !hasWidgetConfig) {
log.warn(
`Interactive outline "${outline.title}" missing interactiveConfig and widget config, falling back to slide`,
);
return { ...outline, type: 'slide' };
}
if (outline.type === 'pbl' && (!outline.pblConfig || !hasLanguageModel)) {
log.warn(
`PBL outline "${outline.title}" missing pblConfig or languageModel, falling back to slide`,
);
return { ...outline, type: 'slide' };
}
return outline;
}