import { nanoid } from 'nanoid'; import { buildDocumentBundle, documentArtifactToParsedPdfContent, getDocumentExtractorProviders, getMediaExtractorProviders, MAX_DOCUMENT_BUNDLE_FILES, MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES, } from '@/lib/document'; import { normalizeDocumentMimeType, SUPPORTED_MEDIA_MIME_TYPES } from '@/lib/document/mime'; import type { MediaArtifact } from '@/lib/document/types'; import { getServerPDFProviders, isServerConfiguredProvider, resolveManagedAliDocMindCredentials, resolvePDFApiKey, resolvePDFBaseUrl, } from '@/lib/server/provider-config'; export const MAX_COURSE_MATERIAL_FILE_SIZE_BYTES = 50 * 1024 * 1024; export class CourseMaterialError extends Error { constructor( readonly code: string, readonly status: number, message: string, ) { super(message); this.name = 'CourseMaterialError'; } } function mediaArtifactText(artifact: MediaArtifact): string { const sections: string[] = []; const synopsis = artifact.providerRaw && typeof artifact.providerRaw === 'object' && 'synopsis' in artifact.providerRaw ? String((artifact.providerRaw as { synopsis?: unknown }).synopsis ?? '').trim() : ''; if (synopsis) sections.push(`## Synopsis\n\n${synopsis}`); if (artifact.transcript?.length) { const transcript = artifact.transcript .filter((segment) => segment.text.trim()) .map((segment) => `[${Math.floor(segment.startMs / 1000)}s] ${segment.text.trim()}`) .join('\n'); if (transcript) sections.push(`## Transcript\n\n${transcript}`); } if (artifact.keyframes?.length) { const keyframes = artifact.keyframes .map((keyframe) => keyframe.description || keyframe.ocrText || '') .filter(Boolean) .join('\n'); if (keyframes) sections.push(`## Keyframes\n\n${keyframes}`); } return sections.join('\n\n'); } function managedDocumentProvider(mimeType: string) { const configured = new Set(Object.keys(getServerPDFProviders())); const candidates = getDocumentExtractorProviders().filter( (provider) => provider.supportedMimeTypes.includes(mimeType) && (provider.id === 'plain-text' || provider.id === 'unpdf' || configured.has(provider.id)), ); // Prefer an operator-managed high-fidelity extractor. Plain text and unpdf // are safe in-process fallbacks and are selected only when no managed // provider for the MIME exists. return ( candidates.find((provider) => isServerConfiguredProvider('pdf', provider.id)) ?? candidates[0] ); } function managedMediaProvider(mimeType: string) { return getMediaExtractorProviders().find( (provider) => provider.supportedMimeTypes.includes(mimeType) && isServerConfiguredProvider('pdf', provider.id), ); } function managedExtractorConfig(providerId: string) { const ali = providerId === 'alidocmind' ? resolveManagedAliDocMindCredentials() : undefined; return { providerId, apiKey: resolvePDFApiKey(providerId), baseUrl: ali?.baseUrl ?? resolvePDFBaseUrl(providerId), accessKeyId: ali?.accessKeyId, accessKeySecret: ali?.accessKeySecret, allowEnvFallback: isServerConfiguredProvider('pdf', providerId), }; } /** * Extract files with server-managed providers and fold them into the exact * pdfContent shape consumed by the original classroom/framework runners. * Caller-supplied provider credentials and storage keys never enter this API. */ export async function extractManagedCourseMaterials( files: readonly File[], ): Promise<{ text: string; images: string[] }> { if (files.length === 0) return { text: '', images: [] }; if (files.length > MAX_DOCUMENT_BUNDLE_FILES) { throw new CourseMaterialError( 'too_many_materials', 413, `At most ${MAX_DOCUMENT_BUNDLE_FILES} course materials are allowed`, ); } const totalBytes = files.reduce((total, file) => total + file.size, 0); if (totalBytes > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) { throw new CourseMaterialError( 'materials_too_large', 413, 'Course materials exceed the 150 MiB aggregate limit', ); } const parts = await Promise.all( files.map(async (file, index) => { if (file.size <= 0 || file.size > MAX_COURSE_MATERIAL_FILE_SIZE_BYTES) { throw new CourseMaterialError( 'material_too_large', 413, `Material "${file.name}" must be between 1 byte and 50 MiB`, ); } const mimeType = normalizeDocumentMimeType({ mimeType: file.type, fileName: file.name }); if (!mimeType) { throw new CourseMaterialError( 'unsupported_material', 415, `Unsupported course material type for "${file.name}"`, ); } const buffer = Buffer.from(await file.arrayBuffer()); const source = { id: `material_${nanoid(8)}`, name: file.name || `material-${index + 1}`, size: file.size, mimeType, order: index + 1, }; if (SUPPORTED_MEDIA_MIME_TYPES.includes(mimeType)) { const provider = managedMediaProvider(mimeType); if (!provider) { throw new CourseMaterialError( 'extractor_not_configured', 422, `No server-managed media extractor supports "${file.name}"`, ); } const artifact = await provider.extract({ buffer, fileName: file.name, fileSize: file.size, mimeType, config: managedExtractorConfig(provider.id), }); const text = mediaArtifactText(artifact); if (!text.trim()) { throw new CourseMaterialError( 'material_parse_failed', 422, `No usable transcript or synopsis was extracted from "${file.name}"`, ); } return { source, text, rawTextLength: text.length, images: [] }; } const provider = managedDocumentProvider(mimeType); if (!provider) { throw new CourseMaterialError( 'extractor_not_configured', 422, `No server-managed document extractor supports "${file.name}"`, ); } const artifact = await provider.extract({ buffer, fileName: file.name, fileSize: file.size, mimeType, config: managedExtractorConfig(provider.id), }); const parsed = documentArtifactToParsedPdfContent(artifact); if (!parsed.text.trim() && parsed.images.length === 0) { throw new CourseMaterialError( 'material_parse_failed', 422, `No usable content was extracted from "${file.name}"`, ); } const fallbackImages = parsed.images.map((src, imageIndex) => ({ id: `img_${imageIndex + 1}`, src, pageNumber: 0, description: undefined, width: undefined, height: undefined, })); const images = (parsed.metadata?.pdfImages ?? fallbackImages).map((image) => ({ id: image.id, src: image.src, pageNumber: image.pageNumber, description: image.description, width: image.width, height: image.height, })); return { source, text: parsed.text, rawTextLength: parsed.text.length, pageCount: parsed.metadata?.pageCount, images, }; }), ); const bundle = buildDocumentBundle(parts); return { text: bundle.text, images: bundle.images .filter((image) => image.visionPriority > 0) .sort((a, b) => b.visionPriority - a.visionPriority) .map((image) => image.src), }; }