Skip to content

Commit 78bb3d6

Browse files
committed
feat(knowledge): read a PDF's text layer before paying for OCR
Every PDF went to OCR, an external per-document call, even though most carry an embedded text layer that costs nothing to read. Across a real corpus of 2,693 documents, local extraction produced text for every PDF that OCR could also read, so the great majority of those calls bought nothing. A PDF's text layer is now read first and used when it is good enough, leaving OCR for the documents that actually need it. Three ways a layer fails, none of which catches the others: there is no text at all (a scan), the text is too sparse to be the document, or there is plenty of text that is not language — a broken encoding, or the raw character ids a CID-keyed font emits with no ToUnicode map, which is common in exactly the contract and procurement material that reaches a knowledge base and which a length check alone reads as healthy. Beyond the cost, this narrows an availability dependency: an OCR outage no longer touches every PDF, only the minority that cannot be read locally. The threshold is env-tunable so the balance can be moved toward cost or fidelity without a deploy. Known limitation: the judgement is per document, so a file mixing typeset pages with scanned inserts can average above the threshold and keep its partial text. Per-page routing would catch it and needs per-page extraction this does not have. The opaque-input refusal now asserts against the outbound request rather than the storage read: local parsing is not model input, so bytes are read before the projection is checked and still never leave the worker when it refuses.
1 parent 10ff622 commit 78bb3d6

5 files changed

Lines changed: 384 additions & 7 deletions

File tree

apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,17 @@ describe('knowledge document model-input provenance', () => {
9191
expect(fetchMock).not.toHaveBeenCalled()
9292
})
9393

94+
/**
95+
* The refusal guards egress to an external model, so it is asserted against the
96+
* outbound request rather than the storage read. A PDF is now parsed locally
97+
* first and only reaches OCR when it has no usable text layer — local parsing is
98+
* not model input, as the case above establishes — so the bytes are read before
99+
* the projection is checked, and never leave the worker when it refuses.
100+
*/
94101
it('rejects secret-bearing opaque document bytes before external OCR', async () => {
102+
const fetchMock = vi.fn()
103+
vi.stubGlobal('fetch', fetchMock)
104+
95105
await expect(
96106
runWithKnowledgeModelInputProvenance(
97107
undefined,
@@ -109,7 +119,7 @@ describe('knowledge document model-input provenance', () => {
109119
)
110120
).rejects.toThrow('Knowledge model input could not be safely projected')
111121

112-
expect(mockDownloadFileFromUrl).not.toHaveBeenCalled()
122+
expect(fetchMock).not.toHaveBeenCalled()
113123
})
114124

115125
it('attaches exact-empty provenance to the internal Mistral OCR request', async () => {

apps/sim/lib/knowledge/documents/document-processor.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
2526
import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
2627
import {
2728
assertKnowledgeOpaqueModelInputSafe,
@@ -295,6 +296,63 @@ 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, pageCount] = await Promise.all([
325+
parseBuffer(buffer, 'pdf'),
326+
getPdfPageCount(buffer),
327+
])
328+
329+
const verdict = assessPdfTextLayer(parsed.content, pageCount)
330+
if (!verdict.usable) {
331+
logger.info('PDF text layer not usable, routing to OCR', {
332+
filename,
333+
pageCount,
334+
reason: verdict.reason,
335+
})
336+
return undefined
337+
}
338+
339+
logger.info('Using embedded PDF text layer', { filename, pageCount })
340+
return {
341+
content: parsed.content,
342+
processingMethod: 'file-parser',
343+
cloudUrl: undefined,
344+
metadata: parsed.metadata,
345+
}
346+
} catch (error) {
347+
logger.info('Could not read PDF text layer, routing to OCR', {
348+
filename,
349+
mimeType,
350+
error: toError(error).message,
351+
})
352+
return undefined
353+
}
354+
}
355+
298356
async function parseDocument(
299357
fileUrl: string,
300358
filename: string,
@@ -319,14 +377,23 @@ async function parseDocument(
319377
MISTRAL_API_KEY: mistralApiKey,
320378
}).providerId
321379

322-
if (ocrProvider === 'azure-mistral') {
323-
assertKnowledgeOpaqueModelInputSafe()
324-
logger.info('Using Azure Mistral OCR')
325-
return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId)
326-
}
380+
if (ocrProvider === 'azure-mistral' || ocrProvider === 'mistral') {
381+
/**
382+
* Most PDFs carry a usable text layer, and reading it costs nothing. OCR is
383+
* a per-document call to an external service, so it is reserved for the
384+
* documents that actually need it — which also means everything else stops
385+
* depending on that service being reachable.
386+
*/
387+
const embedded = await readEmbeddedPdfText(fileUrl, filename, mimeType, userId)
388+
if (embedded) return embedded
327389

328-
if (ocrProvider === 'mistral') {
329390
assertKnowledgeOpaqueModelInputSafe()
391+
392+
if (ocrProvider === 'azure-mistral') {
393+
logger.info('Using Azure Mistral OCR')
394+
return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId)
395+
}
396+
330397
logger.info('Using Mistral OCR')
331398
return parseWithMistralOCR(fileUrl, filename, mimeType, userId, workspaceId, mistralApiKey)
332399
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Every PDF used to be sent to OCR, an external per-document call, even though the
5+
* large majority carry a usable text layer that costs nothing to read. These pin
6+
* the routing: the text layer is tried first, and OCR is reached only when it is
7+
* missing or unreadable.
8+
*/
9+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
const { mockParseBuffer, mockDownload, mockGetDocumentProxy, mockToken, mockBaseUrl } = vi.hoisted(
12+
() => ({
13+
mockParseBuffer: vi.fn(),
14+
mockDownload: vi.fn(),
15+
mockGetDocumentProxy: vi.fn(),
16+
mockToken: vi.fn(),
17+
mockBaseUrl: vi.fn(),
18+
})
19+
)
20+
21+
vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
22+
vi.mock('@/lib/core/utils/urls', async (importOriginal) => ({
23+
...(await importOriginal<typeof import('@/lib/core/utils/urls')>()),
24+
getInternalApiBaseUrl: mockBaseUrl,
25+
}))
26+
27+
vi.mock('@/lib/file-parsers', () => ({
28+
parseBuffer: mockParseBuffer,
29+
isSupportedFileType: (extension: string) => ['pdf'].includes(extension),
30+
}))
31+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadFileFromUrl: mockDownload }))
32+
vi.mock('unpdf', () => ({ getDocumentProxy: mockGetDocumentProxy }))
33+
34+
import { env } from '@/lib/core/config/env'
35+
import { processDocument } from '@/lib/knowledge/documents/document-processor'
36+
import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
37+
38+
/** External, so the OCR path uses the URL directly instead of re-uploading it. */
39+
const PDF_URL = 'https://example.com/Contract.pdf'
40+
const typeset = 'The Supplier shall provide the Services described herein. '.repeat(60)
41+
42+
function parse() {
43+
return runWithKnowledgeModelInputProvenance(
44+
undefined,
45+
() => processDocument(PDF_URL, 'Contract.pdf', 'application/pdf', 1024, 200, 1, 'user-1'),
46+
{ opaqueInputSafe: true }
47+
)
48+
}
49+
50+
describe('PDF OCR triage', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
Object.assign(env, { OCR_PROVIDER: 'mistral', MISTRAL_API_KEY: 'key' })
54+
mockDownload.mockResolvedValue(Buffer.from('%PDF-1.7'))
55+
mockGetDocumentProxy.mockResolvedValue({ numPages: 2 })
56+
mockToken.mockResolvedValue('internal-token')
57+
mockBaseUrl.mockReturnValue('http://sim.local')
58+
})
59+
60+
afterEach(() => {
61+
vi.unstubAllGlobals()
62+
})
63+
64+
it('uses the embedded text layer and never calls OCR', async () => {
65+
mockParseBuffer.mockResolvedValue({ content: typeset, metadata: {} })
66+
const fetchMock = vi.fn()
67+
vi.stubGlobal('fetch', fetchMock)
68+
69+
const result = await parse()
70+
71+
expect(result.metadata.processingMethod).toBe('file-parser')
72+
expect(fetchMock).not.toHaveBeenCalled()
73+
})
74+
75+
it('falls through to OCR when the PDF is a scan', async () => {
76+
mockParseBuffer.mockResolvedValue({ content: '', metadata: {} })
77+
const fetchMock = vi
78+
.fn()
79+
.mockResolvedValue(
80+
new Response(JSON.stringify({ pages: [{ markdown: 'Recognised text' }], usage_info: {} }), {
81+
status: 200,
82+
headers: { 'Content-Type': 'application/json' },
83+
})
84+
)
85+
vi.stubGlobal('fetch', fetchMock)
86+
87+
const result = await parse()
88+
89+
expect(result.metadata.processingMethod).toBe('mistral-ocr')
90+
expect(fetchMock).toHaveBeenCalled()
91+
})
92+
93+
/**
94+
* The case a length check alone cannot see: a CID-keyed font with no Unicode map
95+
* yields plenty of characters, none of them words.
96+
*/
97+
it('falls through to OCR when the text layer is raw CID escapes', async () => {
98+
mockParseBuffer.mockResolvedValue({ content: '/31 /8 /18 /12 /44 '.repeat(60), metadata: {} })
99+
const fetchMock = vi.fn().mockResolvedValue(
100+
new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), {
101+
status: 200,
102+
headers: { 'Content-Type': 'application/json' },
103+
})
104+
)
105+
vi.stubGlobal('fetch', fetchMock)
106+
107+
const result = await parse()
108+
109+
expect(result.metadata.processingMethod).toBe('mistral-ocr')
110+
})
111+
112+
/** An encrypted or malformed PDF has no readable layer, which is a case for OCR. */
113+
it('falls through to OCR when the text layer cannot be parsed at all', async () => {
114+
mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.'))
115+
const fetchMock = vi.fn().mockResolvedValue(
116+
new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), {
117+
status: 200,
118+
headers: { 'Content-Type': 'application/json' },
119+
})
120+
)
121+
vi.stubGlobal('fetch', fetchMock)
122+
123+
const result = await parse()
124+
125+
expect(result.metadata.processingMethod).toBe('mistral-ocr')
126+
})
127+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer'
6+
7+
/** Roughly the character volume of a typeset page. */
8+
const page = (n: number) =>
9+
'The Supplier shall provide the Services described in this Statement of Work. '.repeat(n)
10+
11+
describe('assessPdfTextLayer', () => {
12+
it('accepts an ordinary typeset document', () => {
13+
expect(assessPdfTextLayer(page(60), 2)).toEqual({ usable: true })
14+
})
15+
16+
it('rejects a scan, which carries no text at all', () => {
17+
expect(assessPdfTextLayer('', 12)).toEqual({ usable: false, reason: 'no-text' })
18+
expect(assessPdfTextLayer(' \n ', 12)).toEqual({ usable: false, reason: 'no-text' })
19+
})
20+
21+
/** A scan often still yields a header or a stamp — present, but not the content. */
22+
it('rejects text too sparse to be the document', () => {
23+
expect(assessPdfTextLayer('CONFIDENTIAL', 40)).toEqual({
24+
usable: false,
25+
reason: 'sparse-text',
26+
})
27+
})
28+
29+
/**
30+
* A CID-keyed font with no `ToUnicode` map extracts as raw character ids. There
31+
* is plenty of it, so a length check passes and the document would be indexed as
32+
* gibberish — the failure mode a characters-per-page test alone cannot see.
33+
*/
34+
it('rejects raw CID escapes from a font with no Unicode mapping', () => {
35+
const cid = '/31 /8 /18 /12 /44 /9 /27 /15 /3 /62 '.repeat(40)
36+
37+
expect(assessPdfTextLayer(cid, 1)).toEqual({ usable: false, reason: 'cid-escapes' })
38+
})
39+
40+
it('rejects a text layer that decoded to replacement characters', () => {
41+
expect(assessPdfTextLayer('�'.repeat(500), 1)).toEqual({
42+
usable: false,
43+
reason: 'unreadable-encoding',
44+
})
45+
})
46+
47+
/** Real prose contains slashes and digits; only a dominant share is disqualifying. */
48+
it('keeps a document that merely mentions figures and dates', () => {
49+
const prose = `${page(40)} Payment of /50 net 30, effective 01/04/2026, ref /12 /9.`
50+
51+
expect(assessPdfTextLayer(prose, 1)).toEqual({ usable: true })
52+
})
53+
54+
it('keeps accented and non-Latin prose, which is ordinary text', () => {
55+
expect(assessPdfTextLayer('Zusammenfassung über Verträge. '.repeat(40), 1)).toEqual({
56+
usable: true,
57+
})
58+
expect(assessPdfTextLayer('契約の概要について説明します。'.repeat(40), 1)).toEqual({
59+
usable: true,
60+
})
61+
})
62+
63+
/** An unparseable page count must still apply a floor rather than divide by zero. */
64+
it('treats an unknown page count as a single page', () => {
65+
expect(assessPdfTextLayer('short', 0)).toEqual({ usable: false, reason: 'sparse-text' })
66+
expect(assessPdfTextLayer(page(40), 0)).toEqual({ usable: true })
67+
})
68+
69+
it('scales the threshold with length, so one good page does not carry a long scan', () => {
70+
const onePageOfText = page(30)
71+
72+
expect(assessPdfTextLayer(onePageOfText, 1)).toEqual({ usable: true })
73+
expect(assessPdfTextLayer(onePageOfText, 200)).toEqual({
74+
usable: false,
75+
reason: 'sparse-text',
76+
})
77+
})
78+
})

0 commit comments

Comments
 (0)