212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
/**
|
|
* 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 } 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,
|
|
options?: {
|
|
visionEnabled?: boolean;
|
|
imageMapping?: ImageMapping;
|
|
imageGenerationEnabled?: boolean;
|
|
videoGenerationEnabled?: boolean;
|
|
researchContext?: string;
|
|
teacherContext?: string;
|
|
},
|
|
): Promise<
|
|
GenerationResult<{ languageDirective: string; courseTitle?: 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 {
|
|
const response = await aiCall(prompts.system, prompts.user, visionImages);
|
|
const parsed = parseJsonResponse<
|
|
{ languageDirective: string; courseTitle?: string; outlines: SceneOutline[] } | SceneOutline[]
|
|
>(response);
|
|
|
|
let languageDirective: string;
|
|
let courseTitle: string | undefined;
|
|
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;
|
|
// courseTitle is optional — only honor a non-empty string, and cap its
|
|
// length defensively (the prompt asks for ≤30 chars, but older/hallucinating
|
|
// models may return far more). The downstream Stage.name column is bounded too.
|
|
const rawTitle = parsed.courseTitle;
|
|
courseTitle =
|
|
typeof rawTitle === 'string' && rawTitle.trim() ? rawTitle.trim().slice(0, 120) : undefined;
|
|
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);
|
|
|
|
return { success: true, data: { languageDirective, courseTitle, 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 sanitizeProceduralSkillOutline(outline: SceneOutline): SceneOutline {
|
|
const widgetOutline = { ...(outline.widgetOutline ?? {}) };
|
|
delete widgetOutline.procedureType;
|
|
delete widgetOutline.task;
|
|
delete widgetOutline.tools;
|
|
delete widgetOutline.steps;
|
|
delete widgetOutline.successCriteria;
|
|
delete widgetOutline.errorConsequences;
|
|
|
|
return {
|
|
...outline,
|
|
type: 'interactive',
|
|
widgetType: 'diagram',
|
|
description: outline.description
|
|
? `${outline.description} Present this as a process or structure diagram.`
|
|
: 'Present this topic as a process or structure diagram.',
|
|
widgetOutline,
|
|
};
|
|
}
|
|
|
|
export function applyOutlineFallbacks(
|
|
outline: SceneOutline,
|
|
hasLanguageModel: boolean,
|
|
options: { allowProceduralSkill?: boolean } = {},
|
|
): SceneOutline {
|
|
// Ultra Mode: interactive scenes with widgetType + widgetOutline are valid
|
|
const hasWidgetConfig = outline.widgetType && outline.widgetOutline;
|
|
|
|
if (outline.widgetType === 'procedural-skill' && !options.allowProceduralSkill) {
|
|
log.warn(`Procedural-skill outline "${outline.title}" is not enabled, falling back to diagram`);
|
|
return sanitizeProceduralSkillOutline(outline);
|
|
}
|
|
|
|
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;
|
|
}
|