File size: 7,391 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | /**
* 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;
}
|