Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

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;
}