thu-maic--openmaic
203 行
6.2 KiB
TypeScript
203 行
6.2 KiB
TypeScript
/**
|
|
* Scene Content Generation API
|
|
*
|
|
* Generates scene content (slides/quiz/interactive/pbl) from an outline.
|
|
* This is the first half of the two-step scene generation pipeline.
|
|
* Does NOT generate actions — use /api/generate/scene-actions for that.
|
|
*/
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import { callLLM } from '@/lib/ai/llm';
|
|
import {
|
|
applyOutlineFallbacks,
|
|
generateSceneContent,
|
|
buildVisionUserContent,
|
|
} from '@/lib/generation/generation-pipeline';
|
|
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
|
|
import type {
|
|
SceneOutline,
|
|
PdfImage,
|
|
ImageMapping,
|
|
UserRequirements,
|
|
} from '@/lib/types/generation';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { llmApiError } from '@/lib/server/llm-error-response';
|
|
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
|
|
import { resolveVocationalActive } from '@/lib/config/feature-flags';
|
|
|
|
const log = createLogger('Scene Content API');
|
|
|
|
export const maxDuration = 300;
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let outlineTitle: string | undefined;
|
|
let resolvedModelString: string | undefined;
|
|
try {
|
|
const body = await req.json();
|
|
const {
|
|
outline: rawOutline,
|
|
allOutlines,
|
|
pdfImages,
|
|
imageMapping,
|
|
stageInfo: _stageInfo,
|
|
stageId,
|
|
agents,
|
|
languageDirective,
|
|
requirements,
|
|
} = body as {
|
|
outline: SceneOutline;
|
|
allOutlines: SceneOutline[];
|
|
pdfImages?: PdfImage[];
|
|
imageMapping?: ImageMapping;
|
|
stageInfo: {
|
|
name: string;
|
|
description?: string;
|
|
style?: string;
|
|
};
|
|
stageId: string;
|
|
agents?: AgentInfo[];
|
|
languageDirective?: string;
|
|
requirements?: UserRequirements;
|
|
};
|
|
|
|
// Validate required fields
|
|
if (!rawOutline) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
|
|
}
|
|
if (!allOutlines || allOutlines.length === 0) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
'allOutlines is required and must not be empty',
|
|
);
|
|
}
|
|
if (!stageId) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
|
|
}
|
|
|
|
const outline: SceneOutline = { ...rawOutline };
|
|
|
|
// ── Model resolution from request headers/body ──
|
|
// Route per scene-content type (e.g. `scene-content:quiz`); getStageModel
|
|
// falls back to the base `scene-content` route when the type is unrouted.
|
|
const stage = outline.type ? (`scene-content:${outline.type}` as const) : 'scene-content';
|
|
const {
|
|
model: languageModel,
|
|
modelInfo,
|
|
modelString,
|
|
thinkingConfig,
|
|
} = await resolveModelFromRequest(req, body, stage);
|
|
outlineTitle = rawOutline?.title;
|
|
resolvedModelString = modelString;
|
|
|
|
// Detect vision capability
|
|
const hasVision = !!modelInfo?.capabilities?.vision;
|
|
|
|
// Vision-aware AI call function
|
|
const aiCall = async (
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
images?: Array<{ id: string; src: string }>,
|
|
): Promise<string> => {
|
|
if (images?.length && hasVision) {
|
|
const result = await callLLM(
|
|
{
|
|
model: languageModel,
|
|
system: systemPrompt,
|
|
messages: [
|
|
{
|
|
role: 'user' as const,
|
|
content: buildVisionUserContent(userPrompt, images),
|
|
},
|
|
],
|
|
maxOutputTokens: modelInfo?.outputWindow,
|
|
maxRetries: 0,
|
|
},
|
|
'scene-content',
|
|
undefined,
|
|
thinkingConfig,
|
|
);
|
|
return result.text;
|
|
}
|
|
const result = await callLLM(
|
|
{
|
|
model: languageModel,
|
|
system: systemPrompt,
|
|
prompt: userPrompt,
|
|
maxOutputTokens: modelInfo?.outputWindow,
|
|
maxRetries: 0,
|
|
},
|
|
'scene-content',
|
|
undefined,
|
|
thinkingConfig,
|
|
);
|
|
return result.text;
|
|
};
|
|
|
|
// ── Apply fallbacks ──
|
|
const vocationalActive = resolveVocationalActive(requirements);
|
|
const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel, {
|
|
allowProceduralSkill: vocationalActive,
|
|
});
|
|
|
|
// ── Filter images assigned to this outline ──
|
|
let assignedImages: PdfImage[] | undefined;
|
|
if (
|
|
pdfImages &&
|
|
pdfImages.length > 0 &&
|
|
effectiveOutline.suggestedImageIds &&
|
|
effectiveOutline.suggestedImageIds.length > 0
|
|
) {
|
|
const suggestedIds = new Set(effectiveOutline.suggestedImageIds);
|
|
assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id));
|
|
}
|
|
|
|
// ── Media generation is handled client-side in parallel (media-orchestrator.ts) ──
|
|
// The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is.
|
|
// resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements.
|
|
const generatedMediaMapping: ImageMapping = {};
|
|
|
|
// ── Generate content ──
|
|
log.info(
|
|
`Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`,
|
|
);
|
|
|
|
const userLocale = req.headers?.get('x-user-locale') ?? '';
|
|
|
|
const content = await generateSceneContent(effectiveOutline, aiCall, {
|
|
assignedImages,
|
|
imageMapping,
|
|
languageModel: effectiveOutline.type === 'pbl' ? languageModel : undefined,
|
|
visionEnabled: hasVision,
|
|
generatedMediaMapping,
|
|
agents,
|
|
languageDirective,
|
|
thinkingConfig,
|
|
targetLanguage: userLocale || undefined,
|
|
userRequirements: requirements,
|
|
allowProceduralSkill: vocationalActive,
|
|
});
|
|
|
|
if (!content) {
|
|
log.error(`Failed to generate content for: "${effectiveOutline.title}"`);
|
|
|
|
return apiError(
|
|
'GENERATION_FAILED',
|
|
500,
|
|
`Failed to generate content: ${effectiveOutline.title}`,
|
|
);
|
|
}
|
|
|
|
log.info(`Content generated successfully: "${effectiveOutline.title}"`);
|
|
|
|
return apiSuccess({ content, effectiveOutline });
|
|
} catch (error) {
|
|
log.error(
|
|
`Scene content generation failed [scene="${outlineTitle ?? 'unknown'}", model=${resolvedModelString ?? 'unknown'}]:`,
|
|
error,
|
|
);
|
|
return llmApiError(error);
|
|
}
|
|
}
|