@@ -22,6 +22,7 @@ import {
2222 resolveParserExtension ,
2323 resolveStoredArtifactExtension ,
2424} from '@/lib/knowledge/documents/parser-extension'
25+ import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer'
2526import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
2627import {
2728 assertKnowledgeOpaqueModelInputSafe ,
@@ -295,6 +296,68 @@ async function getMistralApiKey(workspaceId?: string | null): Promise<string | n
295296 return env . MISTRAL_API_KEY || null
296297}
297298
299+ /**
300+ * Reads a PDF's embedded text layer, returning it only when it is good enough to
301+ * index — otherwise `undefined`, leaving the caller to fall through to OCR.
302+ *
303+ * A failure to parse is not an error here: an encrypted or malformed PDF simply
304+ * has no usable layer, which is precisely a case for OCR. The document is fetched
305+ * again on that path, a second read from our own storage, which is a cheap price
306+ * for keeping the two extraction routes independent.
307+ */
308+ async function readEmbeddedPdfText (
309+ fileUrl : string ,
310+ filename : string ,
311+ mimeType : string ,
312+ userId ?: string
313+ ) : Promise <
314+ | {
315+ content : string
316+ processingMethod : 'file-parser'
317+ cloudUrl ?: string
318+ metadata ?: FileParseMetadata
319+ }
320+ | undefined
321+ > {
322+ try {
323+ const buffer = await downloadFileWithTimeout ( fileUrl , userId )
324+ const parsed = await parseBuffer ( buffer , 'pdf' )
325+
326+ /**
327+ * The page count comes from the same parse as the text, rather than a second
328+ * independent read of the file. Counting separately lets the two disagree: a
329+ * count that failed would report no pages, the density check would fall back to
330+ * treating the document as a single page, and a long scan carrying only a header
331+ * would look dense enough to skip OCR and be indexed as that header.
332+ */
333+ const pageCount = parsed . metadata ?. pageCount ?? 0
334+ const verdict = assessPdfTextLayer ( parsed . content , pageCount , parsed . metadata ?. truncated )
335+ if ( ! verdict . usable ) {
336+ logger . info ( 'PDF text layer not usable, routing to OCR' , {
337+ filename,
338+ pageCount,
339+ reason : verdict . reason ,
340+ } )
341+ return undefined
342+ }
343+
344+ logger . info ( 'Using embedded PDF text layer' , { filename, pageCount } )
345+ return {
346+ content : parsed . content ,
347+ processingMethod : 'file-parser' ,
348+ cloudUrl : undefined ,
349+ metadata : parsed . metadata ,
350+ }
351+ } catch ( error ) {
352+ logger . info ( 'Could not read PDF text layer, routing to OCR' , {
353+ filename,
354+ mimeType,
355+ error : toError ( error ) . message ,
356+ } )
357+ return undefined
358+ }
359+ }
360+
298361async function parseDocument (
299362 fileUrl : string ,
300363 filename : string ,
@@ -319,14 +382,23 @@ async function parseDocument(
319382 MISTRAL_API_KEY : mistralApiKey ,
320383 } ) . providerId
321384
322- if ( ocrProvider === 'azure-mistral' ) {
323- assertKnowledgeOpaqueModelInputSafe ( )
324- logger . info ( 'Using Azure Mistral OCR' )
325- return parseWithAzureMistralOCR ( fileUrl , filename , mimeType , userId )
326- }
385+ if ( ocrProvider === 'azure-mistral' || ocrProvider === 'mistral' ) {
386+ /**
387+ * Most PDFs carry a usable text layer, and reading it costs nothing. OCR is
388+ * a per-document call to an external service, so it is reserved for the
389+ * documents that actually need it — which also means everything else stops
390+ * depending on that service being reachable.
391+ */
392+ const embedded = await readEmbeddedPdfText ( fileUrl , filename , mimeType , userId )
393+ if ( embedded ) return embedded
327394
328- if ( ocrProvider === 'mistral' ) {
329395 assertKnowledgeOpaqueModelInputSafe ( )
396+
397+ if ( ocrProvider === 'azure-mistral' ) {
398+ logger . info ( 'Using Azure Mistral OCR' )
399+ return parseWithAzureMistralOCR ( fileUrl , filename , mimeType , userId )
400+ }
401+
330402 logger . info ( 'Using Mistral OCR' )
331403 return parseWithMistralOCR ( fileUrl , filename , mimeType , userId , workspaceId , mistralApiKey )
332404 }
@@ -522,42 +594,19 @@ async function parseWithAzureMistralOCR(
522594
523595 const fileBuffer = await downloadFileForBase64 ( fileUrl , userId )
524596
525- if ( mimeType === 'application/pdf' ) {
526- const pageCount = await getPdfPageCount ( fileBuffer )
527- if ( pageCount > MISTRAL_MAX_PAGES ) {
528- throw new Error (
529- `PDF has ${ pageCount } pages, exceeding the Azure OCR limit of ${ MISTRAL_MAX_PAGES } `
530- )
531- }
532- logger . info ( 'Azure Mistral OCR: PDF page count resolved' , { pageCount } )
533- }
534-
535- const base64Data = fileBuffer . toString ( 'base64' )
536- const dataUri = `data:${ mimeType } ;base64,${ base64Data } `
537-
538597 try {
539- const response = await retryWithExponentialBackoff (
540- ( ) =>
541- makeOCRRequest (
542- env . OCR_AZURE_ENDPOINT ! ,
543- {
544- 'Content-Type' : 'application/json' ,
545- Authorization : `Bearer ${ env . OCR_AZURE_API_KEY } ` ,
546- } ,
547- {
548- model : env . OCR_AZURE_MODEL_NAME ! ,
549- document : {
550- type : 'document_url' ,
551- document_url : dataUri ,
552- } ,
553- include_image_base64 : false ,
554- }
555- ) ,
556- { maxRetries : 3 , initialDelayMs : 1000 , maxDelayMs : 10000 }
557- )
558-
559- const ocrResult = ( await response . json ( ) ) as AzureOCRResponse
560- const content = extractPageContent ( ocrResult . pages || [ ] ) || JSON . stringify ( ocrResult , null , 2 )
598+ /**
599+ * A PDF is chunked to the provider's page cap rather than refused for
600+ * exceeding it, matching the other OCR provider. Refusing meant a long
601+ * document could not be ingested at all, and the cap applies to a single
602+ * request, not to the document.
603+ */
604+ const content =
605+ mimeType === 'application/pdf'
606+ ? await ocrPdfInChunks ( fileBuffer , 'azure-mistral' , ( chunk ) =>
607+ recognizeWithAzureOCR ( chunk . buffer , mimeType )
608+ )
609+ : await recognizeWithAzureOCR ( fileBuffer , mimeType )
561610
562611 if ( ! content . trim ( ) ) {
563612 throw new Error ( 'Azure Mistral OCR returned empty content' )
@@ -573,6 +622,41 @@ async function parseWithAzureMistralOCR(
573622 }
574623}
575624
625+ /** Sends one document to Azure Mistral OCR inline, as a base64 data URI. */
626+ async function recognizeWithAzureOCR ( buffer : Buffer , mimeType : string ) : Promise < string > {
627+ const dataUri = `data:${ mimeType } ;base64,${ buffer . toString ( 'base64' ) } `
628+
629+ const response = await retryWithExponentialBackoff (
630+ ( ) =>
631+ makeOCRRequest (
632+ env . OCR_AZURE_ENDPOINT ! ,
633+ {
634+ 'Content-Type' : 'application/json' ,
635+ Authorization : `Bearer ${ env . OCR_AZURE_API_KEY } ` ,
636+ } ,
637+ {
638+ model : env . OCR_AZURE_MODEL_NAME ! ,
639+ document : {
640+ type : 'document_url' ,
641+ document_url : dataUri ,
642+ } ,
643+ include_image_base64 : false ,
644+ }
645+ ) ,
646+ { maxRetries : 3 , initialDelayMs : 1000 , maxDelayMs : 10000 }
647+ )
648+
649+ const ocrResult = ( await response . json ( ) ) as AzureOCRResponse
650+
651+ /**
652+ * A response carrying no pages is no content. Returning the raw payload instead
653+ * would be indexed as though it were the document: stitched into a chunked run
654+ * as recovered text, and in a single-document run it would satisfy the
655+ * empty-content check that exists to catch exactly this.
656+ */
657+ return extractPageContent ( ocrResult . pages || [ ] )
658+ }
659+
576660async function parseWithMistralOCR (
577661 fileUrl : string ,
578662 filename : string ,
@@ -740,63 +824,118 @@ async function processChunk(
740824 }
741825}
742826
743- async function processMistralOCRInBatches (
744- filename : string ,
745- apiKey : string ,
827+ /**
828+ * Runs a PDF through OCR a chunk at a time and stitches the pages back together.
829+ *
830+ * A provider that caps how many pages one request may carry needs the document
831+ * split, and both providers cap at the same limit — so the splitting, the
832+ * concurrency, the ordering and the partial-failure rule live here once rather
833+ * than being restated per provider, where they had already drifted into one
834+ * provider chunking and the other refusing anything over the cap.
835+ *
836+ * A document is indexed whole or not at all: if any chunk fails, the document
837+ * fails, because a partial result reports success while page ranges are missing
838+ * and nothing downstream can tell.
839+ */
840+ async function ocrPdfInChunks (
746841 pdfBuffer : Buffer ,
747- userId ? : string ,
748- cloudUrl ?: string
749- ) : Promise < {
750- content : string
751- processingMethod : 'mistral-ocr'
752- cloudUrl ?: string
753- } > {
842+ provider : string ,
843+ recognize : (
844+ chunk : { buffer : Buffer ; startPage : number ; endPage : number } ,
845+ chunkIndex : number ,
846+ totalChunks : number
847+ ) => Promise < string | null >
848+ ) : Promise < string > {
754849 const totalPages = await getPdfPageCount ( pdfBuffer )
755- logger . info ( `Splitting PDF into chunks` , { totalPages, maxPagesPerChunk : MISTRAL_MAX_PAGES } )
756850
757- const pdfChunks = await splitPdfIntoChunks ( pdfBuffer , MISTRAL_MAX_PAGES )
758- logger . info (
759- `Split into ${ pdfChunks . length } chunks, processing with concurrency ${ MAX_CONCURRENT_CHUNKS } `
760- )
851+ /**
852+ * Splitting has to load the document, which an encrypted or malformed PDF will
853+ * refuse. That must not decide whether the file reaches OCR at all: those are
854+ * exactly the documents with no readable text layer, so OCR is their only route,
855+ * and the provider may well accept bytes that a local parser would not. When the
856+ * split fails the document is sent whole and the page cap is left to the
857+ * provider — the behaviour before it was chunked.
858+ */
859+ let pdfChunks : { buffer : Buffer ; startPage : number ; endPage : number } [ ]
860+ try {
861+ pdfChunks = await splitPdfIntoChunks ( pdfBuffer , MISTRAL_MAX_PAGES )
862+ } catch ( error ) {
863+ logger . info ( 'PDF could not be split for OCR, sending it whole' , {
864+ provider,
865+ error : toError ( error ) . message ,
866+ } )
867+ pdfChunks = [ { buffer : pdfBuffer , startPage : 0 , endPage : Math . max ( 0 , totalPages - 1 ) } ]
868+ }
869+
870+ logger . info ( 'Splitting PDF for OCR' , {
871+ provider,
872+ totalPages,
873+ chunks : pdfChunks . length ,
874+ maxPagesPerChunk : MISTRAL_MAX_PAGES ,
875+ concurrency : MAX_CONCURRENT_CHUNKS ,
876+ } )
761877
762878 const results : { index : number ; content : string | null } [ ] = [ ]
763879
764880 for ( let i = 0 ; i < pdfChunks . length ; i += MAX_CONCURRENT_CHUNKS ) {
765881 const batch = pdfChunks . slice ( i , i + MAX_CONCURRENT_CHUNKS )
766- const batchPromises = batch . map ( ( chunk , batchIndex ) =>
767- processChunk ( chunk , i + batchIndex , pdfChunks . length , filename , apiKey , userId )
768- )
769-
770- const batchResults = await Promise . all ( batchPromises )
771- for ( const result of batchResults ) {
772- results . push ( result )
773- }
774-
775- logger . info (
776- `Completed batch ${ Math . floor ( i / MAX_CONCURRENT_CHUNKS ) + 1 } /${ Math . ceil ( pdfChunks . length / MAX_CONCURRENT_CHUNKS ) } `
882+ const batchResults = await Promise . all (
883+ batch . map ( ( chunk , batchIndex ) => {
884+ const index = i + batchIndex
885+ return recognize ( chunk , index , pdfChunks . length ) . then (
886+ ( content ) => ( { index, content } ) ,
887+ ( error ) => {
888+ logger . warn ( 'OCR chunk failed' , {
889+ provider,
890+ chunk : index + 1 ,
891+ error : toError ( error ) . message ,
892+ } )
893+ return { index, content : null }
894+ }
895+ )
896+ } )
777897 )
898+ results . push ( ...batchResults )
778899 }
779900
780- const sortedResults = results
901+ const recovered = results
781902 . sort ( ( a , b ) => a . index - b . index )
782- . filter ( ( r ) => r . content !== null )
783- . map ( ( r ) => r . content as string )
784-
785- if ( sortedResults . length === 0 ) {
903+ . map ( ( r ) => r . content )
904+ . filter ( ( content ) : content is string => content !== null && content . trim ( ) . length > 0 )
905+
906+ /**
907+ * Each chunk has already exhausted its own retries, so a missing one is a real
908+ * failure rather than a blip. Failing the document leaves it visible with a
909+ * reason and eligible for the stuck-document sweep, which can retry it and
910+ * produce a complete result — whereas indexing what came back would be
911+ * indistinguishable from a document that never had those pages.
912+ */
913+ if ( recovered . length < pdfChunks . length ) {
786914 throw new Error (
787- `OCR failed for all ${ pdfChunks . length } chunks. ` +
788- `Large PDFs require OCR - file parser fallback would produce poor results.`
915+ `OCR recovered ${ recovered . length } of ${ pdfChunks . length } chunks; ` +
916+ 'indexing the document would omit the rest'
789917 )
790918 }
791919
792- const combinedContent = sortedResults . join ( '\n\n' )
793- logger . info ( `Successfully processed ${ sortedResults . length } / ${ pdfChunks . length } chunks` )
920+ return recovered . join ( '\n\n' )
921+ }
794922
795- return {
796- content : combinedContent ,
797- processingMethod : 'mistral-ocr' ,
798- cloudUrl,
799- }
923+ async function processMistralOCRInBatches (
924+ filename : string ,
925+ apiKey : string ,
926+ pdfBuffer : Buffer ,
927+ userId ?: string ,
928+ cloudUrl ?: string
929+ ) : Promise < {
930+ content : string
931+ processingMethod : 'mistral-ocr'
932+ cloudUrl ?: string
933+ } > {
934+ const content = await ocrPdfInChunks ( pdfBuffer , 'mistral' , ( chunk , index , total ) =>
935+ processChunk ( chunk , index , total , filename , apiKey , userId ) . then ( ( r ) => r . content )
936+ )
937+
938+ return { content, processingMethod : 'mistral-ocr' , cloudUrl }
800939}
801940
802941/**
0 commit comments