outline-generator.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * Stage 1: Generate scene outlines from user requirements.
  3. * Also contains outline fallback logic.
  4. */
  5. import { nanoid } from 'nanoid';
  6. import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
  7. import type {
  8. UserRequirements,
  9. SceneOutline,
  10. PdfImage,
  11. ImageMapping,
  12. } from '@/lib/types/generation';
  13. import { buildPrompt, PROMPT_IDS } from './prompts';
  14. import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters';
  15. import { parseJsonResponse } from './json-repair';
  16. import { uniquifyMediaElementIds } from './scene-builder';
  17. import type { AICallFn, GenerationResult, GenerationCallbacks } from './pipeline-types';
  18. import { createLogger } from '@/lib/logger';
  19. const log = createLogger('Generation');
  20. /**
  21. * Generate scene outlines from user requirements
  22. * Now uses simplified UserRequirements with just requirement text and language
  23. */
  24. export async function generateSceneOutlinesFromRequirements(
  25. requirements: UserRequirements,
  26. pdfText: string | undefined,
  27. pdfImages: PdfImage[] | undefined,
  28. aiCall: AICallFn,
  29. callbacks?: GenerationCallbacks,
  30. options?: {
  31. visionEnabled?: boolean;
  32. imageMapping?: ImageMapping;
  33. imageGenerationEnabled?: boolean;
  34. videoGenerationEnabled?: boolean;
  35. researchContext?: string;
  36. teacherContext?: string;
  37. },
  38. ): Promise<GenerationResult<SceneOutline[]>> {
  39. // Build available images description for the prompt
  40. let availableImagesText =
  41. requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
  42. let visionImages: Array<{ id: string; src: string }> | undefined;
  43. if (pdfImages && pdfImages.length > 0) {
  44. if (options?.visionEnabled && options?.imageMapping) {
  45. // Vision mode: split into vision images (first N) and text-only (rest)
  46. const allWithSrc = pdfImages.filter((img) => options.imageMapping![img.id]);
  47. const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES);
  48. const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
  49. const noSrcImages = pdfImages.filter((img) => !options.imageMapping![img.id]);
  50. const visionDescriptions = visionSlice.map((img) =>
  51. formatImagePlaceholder(img, requirements.language),
  52. );
  53. const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
  54. formatImageDescription(img, requirements.language),
  55. );
  56. availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
  57. visionImages = visionSlice.map((img) => ({
  58. id: img.id,
  59. src: options.imageMapping![img.id],
  60. width: img.width,
  61. height: img.height,
  62. }));
  63. } else {
  64. // Text-only mode: full descriptions
  65. availableImagesText = pdfImages
  66. .map((img) => formatImageDescription(img, requirements.language))
  67. .join('\n');
  68. }
  69. }
  70. // Build user profile string for prompt injection
  71. const userProfileText =
  72. requirements.userNickname || requirements.userBio
  73. ? `## 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---`
  74. : '';
  75. // Build media generation policy based on enabled flags
  76. const imageEnabled = options?.imageGenerationEnabled ?? false;
  77. const videoEnabled = options?.videoGenerationEnabled ?? false;
  78. let mediaGenerationPolicy = '';
  79. if (!imageEnabled && !videoEnabled) {
  80. mediaGenerationPolicy =
  81. '**IMPORTANT: Do NOT include any mediaGenerations in the outlines. Both image and video generation are disabled.**';
  82. } else if (!imageEnabled) {
  83. mediaGenerationPolicy =
  84. '**IMPORTANT: Do NOT include any image mediaGenerations (type: "image") in the outlines. Image generation is disabled. Video generation is allowed.**';
  85. } else if (!videoEnabled) {
  86. mediaGenerationPolicy =
  87. '**IMPORTANT: Do NOT include any video mediaGenerations (type: "video") in the outlines. Video generation is disabled. Image generation is allowed.**';
  88. }
  89. // Use simplified prompt variables
  90. const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
  91. // New simplified variables
  92. requirement: requirements.requirement,
  93. language: requirements.language,
  94. pdfContent: pdfText
  95. ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
  96. : requirements.language === 'zh-CN'
  97. ? '无'
  98. : 'None',
  99. availableImages: availableImagesText,
  100. userProfile: userProfileText,
  101. mediaGenerationPolicy,
  102. researchContext:
  103. options?.researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
  104. // Server-side generation populates this via options; client-side populates via formatTeacherPersonaForPrompt
  105. teacherContext: options?.teacherContext || '',
  106. });
  107. if (!prompts) {
  108. return { success: false, error: 'Prompt template not found' };
  109. }
  110. try {
  111. callbacks?.onProgress?.({
  112. currentStage: 1,
  113. overallProgress: 20,
  114. stageProgress: 50,
  115. statusMessage: '正在分析需求,生成场景大纲...',
  116. scenesGenerated: 0,
  117. totalScenes: 0,
  118. });
  119. const response = await aiCall(prompts.system, prompts.user, visionImages);
  120. const outlines = parseJsonResponse<SceneOutline[]>(response);
  121. if (!outlines || !Array.isArray(outlines)) {
  122. return {
  123. success: false,
  124. error: 'Failed to parse scene outlines response',
  125. };
  126. }
  127. // Ensure IDs, order, and language
  128. const enriched = outlines.map((outline, index) => ({
  129. ...outline,
  130. id: outline.id || nanoid(),
  131. order: index + 1,
  132. language: requirements.language,
  133. }));
  134. // Replace sequential gen_img_N/gen_vid_N with globally unique IDs
  135. const result = uniquifyMediaElementIds(enriched);
  136. callbacks?.onProgress?.({
  137. currentStage: 1,
  138. overallProgress: 50,
  139. stageProgress: 100,
  140. statusMessage: `已生成 ${result.length} 个场景大纲`,
  141. scenesGenerated: 0,
  142. totalScenes: result.length,
  143. });
  144. return { success: true, data: result };
  145. } catch (error) {
  146. return { success: false, error: String(error) };
  147. }
  148. }
  149. /**
  150. * Apply type fallbacks for outlines that can't be generated as their declared type.
  151. * - interactive without interactiveConfig → slide
  152. * - pbl without pblConfig or languageModel → slide
  153. */
  154. export function applyOutlineFallbacks(
  155. outline: SceneOutline,
  156. hasLanguageModel: boolean,
  157. ): SceneOutline {
  158. if (outline.type === 'interactive' && !outline.interactiveConfig) {
  159. log.warn(
  160. `Interactive outline "${outline.title}" missing interactiveConfig, falling back to slide`,
  161. );
  162. return { ...outline, type: 'slide' };
  163. }
  164. if (outline.type === 'pbl' && (!outline.pblConfig || !hasLanguageModel)) {
  165. log.warn(
  166. `PBL outline "${outline.title}" missing pblConfig or languageModel, falling back to slide`,
  167. );
  168. return { ...outline, type: 'slide' };
  169. }
  170. return outline;
  171. }