Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions app/api/extract-document/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { NextRequest } from 'next/server';
import {
isServerConfiguredProvider,
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,
getDocumentExtractorProvider,
selectDocumentExtractorProvider,
} from '@/lib/document';
import { normalizeDocumentMimeType } from '@/lib/document/mime';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('Extract Document');

function isPdfProviderId(providerId: string): providerId is PDFProviderId {
return providerId in PDF_PROVIDERS;
}

function supportsMimeType(provider: { supportedMimeTypes: 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;
}

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;

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}"`,
);
}

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);
}
}

const config = {
providerId: provider.id,
apiKey: isPdfProviderId(provider.id)
? resolvePDFApiKey(provider.id, managed ? undefined : apiKey || undefined)
: apiKey || undefined,
baseUrl: isPdfProviderId(provider.id)
? resolvePDFBaseUrl(provider.id, clientBaseUrl)
: clientBaseUrl,
};

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,
);
return apiError('PARSE_FAILED', 500, error instanceof Error ? error.message : 'Unknown error');
}
}
40 changes: 23 additions & 17 deletions app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import type { Stage } from '@/lib/types/stage';
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
import { AgentRevealModal } from '@/components/agent/agent-reveal-modal';
import { createLogger } from '@/lib/logger';
import { type GenerationSessionState, ALL_STEPS, getActiveSteps } from './types';
import {
type GenerationSessionState,
ALL_STEPS,
getActiveSteps,
getGenerationStepText,
} from './types';
import { StepVisualizer } from './components/visualizers';
import { resolveTaskEngineModeFromOutlineDoneEvent } from './vocational-mode';

Expand Down Expand Up @@ -231,7 +236,7 @@ function GenerationPreviewContent() {
abortControllerRef.current = controller;
const signal = controller.signal;

// Use a local mutable copy so we can update it after PDF parsing
// Use a local mutable copy so we can update it after document extraction
let currentSession = generationSession;

setError(null);
Expand All @@ -241,38 +246,38 @@ function GenerationPreviewContent() {
// Compute active steps for this session (recomputed after session mutations)
let activeSteps = getActiveSteps(currentSession);

// Determine if we need the PDF analysis step
// Determine if we need the document analysis step
const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText;
// If no PDF to analyze, skip to the next available step
// If no document to analyze, skip to the next available step
if (!hasPdfToAnalyze) {
const firstNonPdfIdx = activeSteps.findIndex((s) => s.id !== 'pdf-analysis');
setCurrentStepIndex(Math.max(0, firstNonPdfIdx));
}

// Step 0: Parse PDF if needed
// Step 0: Extract uploaded course material if needed
if (hasPdfToAnalyze) {
log.debug('=== Generation Preview: Parsing PDF ===');
log.debug('=== Generation Preview: Extracting course material ===');
const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!);
if (!pdfBlob) {
throw new Error(t('generation.pdfLoadFailed'));
throw new Error(t('generation.courseMaterialLoadFailed'));
}

// Ensure pdfBlob is a valid Blob with content
if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) {
log.error('Invalid PDF blob:', {
log.error('Invalid course material blob:', {
type: typeof pdfBlob,
size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A',
});
throw new Error(t('generation.pdfLoadFailed'));
throw new Error(t('generation.courseMaterialLoadFailed'));
}

// Wrap as a File to guarantee multipart/form-data with correct content-type
const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', {
type: 'application/pdf',
type: currentSession.documentMimeType || pdfBlob.type || 'application/pdf',
});

const parseFormData = new FormData();
parseFormData.append('pdf', pdfFile);
parseFormData.append('file', pdfFile);

if (currentSession.pdfProviderId) {
parseFormData.append('providerId', currentSession.pdfProviderId);
Expand All @@ -284,20 +289,20 @@ function GenerationPreviewContent() {
parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl);
}

const parseResponse = await fetch('/api/parse-pdf', {
const parseResponse = await fetch('/api/extract-document', {
method: 'POST',
body: parseFormData,
signal,
});

if (!parseResponse.ok) {
const errorData = await parseResponse.json();
throw new Error(errorData.error || t('generation.pdfParseFailed'));
throw new Error(errorData.error || t('generation.courseMaterialParseFailed'));
}

const parseResult = await parseResponse.json();
if (!parseResult.success || !parseResult.data) {
throw new Error(t('generation.pdfParseFailed'));
throw new Error(t('generation.courseMaterialParseFailed'));
}

let pdfText = parseResult.data.text as string;
Expand Down Expand Up @@ -358,7 +363,7 @@ function GenerationPreviewContent() {
}),
);

// Update session with parsed PDF data
// Update session with extracted document data
const updatedSession = {
...currentSession,
pdfText,
Expand Down Expand Up @@ -1137,6 +1142,7 @@ function GenerationPreviewContent() {
activeSteps.length > 0
? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)]
: ALL_STEPS[0];
const activeStepText = getGenerationStepText(activeStep, session);

if (isReviewingOutlines) {
const outlineStepIndex = Math.max(
Expand Down Expand Up @@ -1327,14 +1333,14 @@ function GenerationPreviewContent() {
? t('generation.generationFailed')
: isComplete
? t('generation.generationComplete')
: t(activeStep.title)}
: t(activeStepText.title, activeStepText.titleValues)}
</h2>
<p className="text-muted-foreground text-base">
{error
? error
: isComplete
? t('generation.classroomReady')
: statusMessage || t(activeStep.description)}
: statusMessage || t(activeStepText.description)}
</p>
</motion.div>
</AnimatePresence>
Expand Down
42 changes: 40 additions & 2 deletions app/generation-preview/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface GenerationSessionState {
// PDF deferred parsing fields
pdfStorageKey?: string;
pdfFileName?: string;
documentMimeType?: string;
pdfProviderId?: string;
pdfProviderConfig?: { apiKey?: string; baseUrl?: string };
// Web search context
Expand All @@ -42,11 +43,48 @@ export type GenerationStep = {
type: 'analysis' | 'writing' | 'visual';
};

function getDocumentTypeLabel(session: GenerationSessionState | null): string {
const mimeType = session?.documentMimeType;
if (mimeType) {
if (mimeType === 'application/pdf') return 'PDF';
if (mimeType.includes('wordprocessingml')) return 'DOCX';
if (mimeType.includes('presentationml')) return 'PPTX';
if (mimeType === 'text/plain') return 'TXT';
if (mimeType.includes('markdown')) return 'Markdown';
}
const extension = session?.pdfFileName?.split('.').pop()?.trim().toLowerCase();
if (extension === 'pdf') return 'PDF';
if (extension === 'docx') return 'DOCX';
if (extension === 'pptx') return 'PPTX';
if (extension === 'txt') return 'TXT';
if (extension === 'md' || extension === 'markdown') return 'Markdown';
return 'document';
}

export function getGenerationStepText(
step: GenerationStep,
session: GenerationSessionState | null,
) {
if (step.id === 'pdf-analysis') {
const documentType = getDocumentTypeLabel(session);
return {
title: 'generation.analyzingCourseMaterial',
titleValues: { type: documentType },
description: 'generation.analyzingCourseMaterialDesc',
};
}
return {
title: step.title,
titleValues: undefined,
description: step.description,
};
}

export const ALL_STEPS: GenerationStep[] = [
{
id: 'pdf-analysis',
title: 'generation.analyzingPdf',
description: 'generation.analyzingPdfDesc',
title: 'generation.analyzingCourseMaterial',
description: 'generation.analyzingCourseMaterialDesc',
icon: ScanLine,
type: 'analysis',
},
Expand Down
Loading
Loading