194 lines
6.1 KiB
TypeScript
194 lines
6.1 KiB
TypeScript
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<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: 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<string, unknown>; 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<LearningGenerationRequest> {
|
|
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 } : {}),
|
|
},
|
|
};
|
|
}
|