353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import {
|
|
isServerConfiguredProvider,
|
|
requireProviderAccess,
|
|
resolveManagedAliDocMindCredentials,
|
|
resolvePDFApiKey,
|
|
resolvePDFBaseUrl,
|
|
} from '@/lib/server/provider-config';
|
|
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
|
|
import type { PDFProviderId } from '@/lib/pdf/types';
|
|
import type { ParsedPdfContent } from '@/lib/types/pdf';
|
|
import {
|
|
documentArtifactToParsedPdfContent,
|
|
extractMedia,
|
|
getDocumentExtractorProvider,
|
|
getMediaExtractorProvider,
|
|
selectDocumentExtractorProvider,
|
|
} from '@/lib/document';
|
|
import type { MediaArtifact } from '@/lib/document';
|
|
import { normalizeDocumentMimeType, SUPPORTED_MEDIA_MIME_TYPES } from '@/lib/document/mime';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
import { providerAccessApiError } from '@/lib/server/provider-access-response';
|
|
|
|
const log = createLogger('Extract Document');
|
|
const MAX_EXTRACT_DOCUMENT_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
|
|
|
function isPdfProviderId(providerId: string): providerId is PDFProviderId {
|
|
return providerId in PDF_PROVIDERS;
|
|
}
|
|
|
|
function supportsMimeType(
|
|
provider: { supportedMimeTypes: readonly string[] },
|
|
mimeType: string,
|
|
): boolean {
|
|
return provider.supportedMimeTypes.map((type) => type.toLowerCase()).includes(mimeType);
|
|
}
|
|
|
|
function isSelfHostedMinerUProvider(
|
|
providerId: string,
|
|
): providerId is Extract<PDFProviderId, 'mineru'> {
|
|
return providerId === 'mineru';
|
|
}
|
|
|
|
function requestedTypeLabel(mimeType: string): string {
|
|
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
|
return 'DOCX';
|
|
}
|
|
if (mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
|
return 'PPTX';
|
|
}
|
|
return mimeType;
|
|
}
|
|
|
|
/**
|
|
* Flatten a MediaArtifact (transcript + keyframes + synopsis) into the
|
|
* text-shaped ParsedPdfContent the generation pipeline consumes. Media takes
|
|
* the same route + downstream path as documents; only the extraction differs.
|
|
*/
|
|
function mediaArtifactToText(artifact: MediaArtifact): string {
|
|
const parts: string[] = [];
|
|
|
|
const synopsis =
|
|
artifact.providerRaw &&
|
|
typeof artifact.providerRaw === 'object' &&
|
|
'synopsis' in artifact.providerRaw
|
|
? String((artifact.providerRaw as { synopsis?: unknown }).synopsis ?? '')
|
|
: '';
|
|
if (synopsis.trim()) {
|
|
parts.push(`## Synopsis\n\n${synopsis.trim()}`);
|
|
}
|
|
|
|
if (artifact.transcript?.length) {
|
|
const lines = artifact.transcript
|
|
.filter((seg) => seg.text?.trim())
|
|
.map((seg) => {
|
|
const ts = formatTimestamp(seg.startMs);
|
|
const speaker = seg.speaker ? `${seg.speaker}: ` : '';
|
|
return `[${ts}] ${speaker}${seg.text.trim()}`;
|
|
});
|
|
if (lines.length) parts.push(`## Transcript\n\n${lines.join('\n')}`);
|
|
}
|
|
|
|
if (artifact.keyframes?.length) {
|
|
const lines = artifact.keyframes
|
|
.filter((kf) => (kf.description || kf.ocrText)?.trim())
|
|
.map((kf) => {
|
|
const ts = formatTimestamp(kf.timeMs);
|
|
return `[${ts}] ${(kf.description || kf.ocrText || '').trim()}`;
|
|
});
|
|
if (lines.length) parts.push(`## Keyframes\n\n${lines.join('\n')}`);
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
function formatTimestamp(ms: number): string {
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
const h = Math.floor(totalSeconds / 3600);
|
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
const s = totalSeconds % 60;
|
|
const mm = String(m).padStart(2, '0');
|
|
const ss = String(s).padStart(2, '0');
|
|
// Use HH:MM:SS once past an hour so a 75-minute video reads 01:15:03, not 75:03.
|
|
return h > 0 ? `${String(h).padStart(2, '0')}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let fileName: string | undefined;
|
|
let resolvedProviderId: string | undefined;
|
|
try {
|
|
const contentType = req.headers.get('content-type') || '';
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
log.error('Invalid Content-Type for document upload:', contentType);
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
`Invalid Content-Type: expected multipart/form-data, got "${contentType}"`,
|
|
);
|
|
}
|
|
|
|
const formData = await req.formData();
|
|
const documentFile = (formData.get('file') || formData.get('pdf')) as File | null;
|
|
const preferredProviderId = formData.get('providerId') as string | null;
|
|
const apiKey = formData.get('apiKey') as string | null;
|
|
const baseUrl = formData.get('baseUrl') as string | null;
|
|
const accessKeyId = formData.get('accessKeyId') as string | null;
|
|
const accessKeySecret = formData.get('accessKeySecret') as string | null;
|
|
|
|
if (!documentFile) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'No course material file provided');
|
|
}
|
|
|
|
fileName = documentFile.name;
|
|
const mimeType = normalizeDocumentMimeType({
|
|
mimeType: documentFile.type,
|
|
fileName: documentFile.name,
|
|
});
|
|
if (!mimeType) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
`Unsupported course material type for "${documentFile.name}"`,
|
|
);
|
|
}
|
|
if (documentFile.size > MAX_EXTRACT_DOCUMENT_FILE_SIZE_BYTES) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
413,
|
|
`Course material file is too large. Maximum size is ${Math.floor(
|
|
MAX_EXTRACT_DOCUMENT_FILE_SIZE_BYTES / 1024 / 1024,
|
|
)}MB.`,
|
|
);
|
|
}
|
|
|
|
// Media (audio/video) takes the media extraction path → MediaArtifact,
|
|
// flattened to the same text shape documents produce. Same route, same
|
|
// downstream generation path.
|
|
if (SUPPORTED_MEDIA_MIME_TYPES.includes(mimeType)) {
|
|
resolvedProviderId = preferredProviderId || 'alidocmind';
|
|
// Reject a document-only provider (e.g. unpdf/mineru) for a media upload
|
|
// with a clear 4xx instead of forwarding it into the media registry and
|
|
// surfacing an opaque 500.
|
|
const mediaProvider = getMediaExtractorProvider(resolvedProviderId);
|
|
if (!mediaProvider || !mediaProvider.supportedMimeTypes.includes(mimeType)) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
`Provider "${resolvedProviderId}" cannot extract ${mimeType}. Choose a media-capable provider (e.g. AliDocMind).`,
|
|
);
|
|
}
|
|
// The media path dispatches directly to AliDocMind's AK/SK adapter and
|
|
// therefore does not naturally pass through resolvePDFApiKey/baseUrl.
|
|
// Apply the same central deployment gate before any caller-owned
|
|
// credentials or endpoint can reach that adapter.
|
|
requireProviderAccess('pdf', resolvedProviderId);
|
|
const mediaManaged = isServerConfiguredProvider('pdf', resolvedProviderId);
|
|
// When managed, resolve the server-owned AK/SK (env OR YAML) explicitly so
|
|
// a YAML-only deployment works — the client-level env fallback reads env
|
|
// vars only. Client-entered creds are used only when unmanaged.
|
|
const mediaManagedCreds = mediaManaged ? resolveManagedAliDocMindCredentials() : undefined;
|
|
const mediaClientBaseUrl = mediaManaged ? undefined : baseUrl || undefined;
|
|
// Same SSRF guard the document path applies: a client-supplied endpoint
|
|
// must not let the server connect to internal/metadata hosts.
|
|
if (mediaClientBaseUrl && process.env.NODE_ENV === 'production') {
|
|
const ssrfError = await validateUrlForSSRF(mediaClientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
const arrayBuffer = await documentFile.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
const mediaArtifact = await extractMedia({
|
|
buffer,
|
|
fileName: documentFile.name,
|
|
fileSize: documentFile.size,
|
|
mimeType,
|
|
config: {
|
|
providerId: resolvedProviderId,
|
|
apiKey: mediaManaged ? undefined : apiKey || undefined,
|
|
baseUrl: mediaManaged ? mediaManagedCreds?.baseUrl : mediaClientBaseUrl,
|
|
accessKeyId: mediaManaged ? mediaManagedCreds?.accessKeyId : accessKeyId || undefined,
|
|
accessKeySecret: mediaManaged
|
|
? mediaManagedCreds?.accessKeySecret
|
|
: accessKeySecret || undefined,
|
|
// Env fallback is a last resort for a managed provider whose creds
|
|
// weren't resolved above (defensive; resolver already covers env+YAML).
|
|
allowEnvFallback: mediaManaged,
|
|
},
|
|
});
|
|
|
|
const mediaText = mediaArtifactToText(mediaArtifact);
|
|
// An artifact with no transcript, keyframes, or synopsis carries no usable
|
|
// content. Returning empty text as 200 would silently generate from
|
|
// nothing — surface a parse error instead.
|
|
if (!mediaText.trim()) {
|
|
return apiError(
|
|
'PARSE_FAILED',
|
|
422,
|
|
`No transcript, keyframes, or synopsis could be extracted from "${documentFile.name}".`,
|
|
);
|
|
}
|
|
const mediaResult: ParsedPdfContent = {
|
|
text: mediaText,
|
|
images: [],
|
|
metadata: {
|
|
pageCount: 0,
|
|
fileName: documentFile.name,
|
|
fileSize: documentFile.size,
|
|
mimeType,
|
|
parser: mediaArtifact.metadata.providerId ?? resolvedProviderId,
|
|
},
|
|
};
|
|
return apiSuccess({ data: mediaResult });
|
|
}
|
|
|
|
let provider = preferredProviderId
|
|
? getDocumentExtractorProvider(preferredProviderId)
|
|
: undefined;
|
|
if (preferredProviderId && !provider) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
`Unknown document extractor provider: ${preferredProviderId}`,
|
|
);
|
|
}
|
|
|
|
if (provider && !supportsMimeType(provider, mimeType)) provider = undefined;
|
|
|
|
try {
|
|
provider =
|
|
provider ||
|
|
selectDocumentExtractorProvider({
|
|
mimeType,
|
|
requiredCapabilities: { text: true },
|
|
});
|
|
} catch (error) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
error instanceof Error ? error.message : `Unsupported course material type "${mimeType}"`,
|
|
);
|
|
}
|
|
resolvedProviderId = provider.id;
|
|
|
|
let managed = isPdfProviderId(provider.id) && isServerConfiguredProvider('pdf', provider.id);
|
|
let clientBaseUrl = managed ? undefined : baseUrl || undefined;
|
|
if (isSelfHostedMinerUProvider(provider.id) && !managed && !clientBaseUrl) {
|
|
const cloudProvider = getDocumentExtractorProvider('mineru-cloud');
|
|
const cloudManaged = isServerConfiguredProvider('pdf', 'mineru-cloud');
|
|
const cloudApiKey = resolvePDFApiKey(
|
|
'mineru-cloud',
|
|
cloudManaged ? undefined : apiKey || undefined,
|
|
);
|
|
if (cloudProvider && supportsMimeType(cloudProvider, mimeType) && cloudApiKey) {
|
|
provider = cloudProvider;
|
|
managed = cloudManaged;
|
|
clientBaseUrl = managed ? undefined : baseUrl || undefined;
|
|
resolvedProviderId = provider.id;
|
|
}
|
|
}
|
|
if (isSelfHostedMinerUProvider(provider.id) && !managed && !clientBaseUrl) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
422,
|
|
`${requestedTypeLabel(mimeType)} extraction requires a configured MinerU document extractor. Configure a self-hosted MinerU base URL or a MinerU Cloud API key in PDF provider settings.`,
|
|
);
|
|
}
|
|
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
|
|
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
|
|
// For a managed AliDocMind provider, resolve server-owned AK/SK (env OR
|
|
// YAML) explicitly so a YAML-only deployment extracts successfully — the
|
|
// client-level env fallback reads env vars only.
|
|
const managedAliCreds =
|
|
managed && provider.id === 'alidocmind' ? resolveManagedAliDocMindCredentials() : undefined;
|
|
const config = {
|
|
providerId: provider.id,
|
|
apiKey: isPdfProviderId(provider.id)
|
|
? resolvePDFApiKey(provider.id, managed ? undefined : apiKey || undefined)
|
|
: apiKey || undefined,
|
|
baseUrl: isPdfProviderId(provider.id)
|
|
? (managedAliCreds?.baseUrl ?? resolvePDFBaseUrl(provider.id, clientBaseUrl))
|
|
: clientBaseUrl,
|
|
// AliDocMind uses AK/SK: managed → server-owned creds; else client values.
|
|
accessKeyId: managed ? managedAliCreds?.accessKeyId : accessKeyId || undefined,
|
|
accessKeySecret: managed ? managedAliCreds?.accessKeySecret : accessKeySecret || undefined,
|
|
// Env fallback is a last resort for a managed provider (defensive; the
|
|
// resolver already covers env+YAML).
|
|
allowEnvFallback: managed,
|
|
};
|
|
|
|
const arrayBuffer = await documentFile.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
|
|
const artifact = await provider.extract({
|
|
buffer,
|
|
fileName: documentFile.name,
|
|
fileSize: documentFile.size,
|
|
mimeType,
|
|
config,
|
|
});
|
|
const result = documentArtifactToParsedPdfContent(artifact);
|
|
|
|
const resultWithMetadata: ParsedPdfContent = {
|
|
...result,
|
|
metadata: {
|
|
...result.metadata,
|
|
pageCount: result.metadata?.pageCount ?? 0,
|
|
fileName: documentFile.name,
|
|
fileSize: documentFile.size,
|
|
mimeType,
|
|
parser: result.metadata?.parser ?? provider.id,
|
|
},
|
|
};
|
|
|
|
return apiSuccess({ data: resultWithMetadata });
|
|
} catch (error) {
|
|
log.error(
|
|
`Document extraction failed [provider=${resolvedProviderId ?? 'unknown'}, file="${fileName ?? 'unknown'}"]:`,
|
|
error,
|
|
);
|
|
const providerAccessError = providerAccessApiError(error);
|
|
if (providerAccessError) return providerAccessError;
|
|
return apiError('PARSE_FAILED', 500, error instanceof Error ? error.message : 'Unknown error');
|
|
}
|
|
}
|