import type { GenerateClassroomInput } from '@/lib/server/classroom-generation'; import { CourseMaterialError, extractManagedCourseMaterials, } from '@/lib/server/managed-course-materials'; export const LEARNING_GENERATION_CONTRACT_VERSION = 2 as const; export type LearningGenerationMode = 'single' | 'large'; export interface LearningGenerationRequest { contractVersion: typeof LEARNING_GENERATION_CONTRACT_VERSION; ownerUserId: string; mode: LearningGenerationMode; input: GenerateClassroomInput; materialCount: number; } export class LearningGenerationRequestError extends Error { constructor( readonly code: string, readonly status: number, message: string, ) { super(message); this.name = 'LearningGenerationRequestError'; } } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function compatibilityBoolean( value: unknown, snakeCaseValue: unknown, fallback = false, ): boolean { const selected = value ?? snakeCaseValue; if (selected === undefined) return fallback; if (typeof selected !== 'boolean') { throw new LearningGenerationRequestError( 'invalid_request', 400, 'Generation option values must be booleans', ); } return selected; } async function requestPayload( request: Request, ): Promise<{ body: Record; files: File[] }> { const contentType = request.headers.get('content-type')?.toLowerCase() ?? ''; if (contentType.includes('multipart/form-data')) { const contentLength = Number(request.headers.get('content-length') ?? ''); if (Number.isFinite(contentLength) && contentLength > 155 * 1024 * 1024) { throw new LearningGenerationRequestError( 'materials_too_large', 413, 'Multipart request exceeds the course-material upload limit', ); } const form = await request.formData().catch(() => null); if (!form) { throw new LearningGenerationRequestError('invalid_request', 400, 'Malformed multipart body'); } const rawOptions = form.get('options'); if (typeof rawOptions !== 'string') { throw new LearningGenerationRequestError( 'invalid_request', 400, 'Multipart generation requires an options JSON field', ); } const body = asRecord(JSON.parse(rawOptions) as unknown); if (!body) { throw new LearningGenerationRequestError('invalid_request', 400, 'Invalid options JSON'); } const files = form .getAll('materials') .filter((part): part is File => part instanceof File); if (files.length !== form.getAll('materials').length) { throw new LearningGenerationRequestError( 'invalid_request', 400, 'Every materials field must be a file', ); } return { body, files }; } if (!contentType.includes('application/json')) { throw new LearningGenerationRequestError( 'unsupported_media_type', 415, 'Expected application/json or multipart/form-data', ); } const body = asRecord(await request.json().catch(() => null)); if (!body) throw new LearningGenerationRequestError('invalid_request', 400, 'Invalid JSON body'); return { body, files: [] }; } export async function parseLearningGenerationRequest( request: Request, ): Promise { const { body, files } = await requestPayload(request); const version = body.contractVersion ?? body.contract_version; if (version !== undefined && version !== LEARNING_GENERATION_CONTRACT_VERSION) { throw new LearningGenerationRequestError( 'unsupported_contract_version', 400, `Only generation contract version ${LEARNING_GENERATION_CONTRACT_VERSION} is supported`, ); } const owner = body.owner_user_id; if ( typeof owner !== 'string' || !owner.trim() || owner.trim().length > 256 || /[\u0000-\u001f\u007f]/.test(owner) ) { throw new LearningGenerationRequestError('invalid_owner', 400, 'Invalid owner_user_id'); } if (body.mode !== 'single' && body.mode !== 'large') { throw new LearningGenerationRequestError( 'invalid_mode', 400, 'mode must be "single" or "large"', ); } if ( typeof body.requirement !== 'string' || !body.requirement.trim() || body.requirement.trim().length > 20_000 ) { throw new LearningGenerationRequestError( 'invalid_requirement', 400, 'requirement must contain 1-20000 characters', ); } const forbidden = [ 'providerId', 'apiKey', 'baseUrl', 'storageKey', 'webSearchApiKey', 'webSearchProviderId', ].find((field) => body[field] !== undefined); if (forbidden) { throw new LearningGenerationRequestError( 'caller_provider_configuration_forbidden', 400, `${forbidden} is server-managed and cannot be supplied by the caller`, ); } let pdfContent: { text: string; images: string[] } | undefined; try { if (files.length > 0) pdfContent = await extractManagedCourseMaterials(files); } catch (error) { if (error instanceof CourseMaterialError) { throw new LearningGenerationRequestError(error.code, error.status, error.message); } throw error; } return { contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, ownerUserId: owner.trim(), mode: body.mode, materialCount: files.length, input: { requirement: body.requirement.trim(), enableWebSearch: compatibilityBoolean(body.enableWebSearch, body.enable_web_search), enableImageGeneration: compatibilityBoolean( body.enableImageGeneration, body.enable_image_generation, ), enableVideoGeneration: compatibilityBoolean( body.enableVideoGeneration, body.enable_video_generation, ), enableTTS: compatibilityBoolean(body.enableTTS, body.enable_tts), interactiveMode: compatibilityBoolean(body.interactiveMode, body.interactive_mode), taskEngineMode: compatibilityBoolean(body.taskEngineMode, body.task_engine_mode), ...(pdfContent ? { pdfContent } : {}), }, }; }