Skip to content

Commit 22b441e

Browse files
committed
fix(knowledge): guard every parser against empty output, not just the file parsers
Moving connector parsing into the pipeline exposed a gap on the OCR branch. OCR reads a scanned page with no recoverable text as empty, and the empty-content guard lived inside the file-parser path, so such a document chunked to nothing and reported success — the same silently-complete-but-useless outcome the guard exists to prevent. The check now sits above the parser choice and covers OCR too. Also preserves a source file's extension when its name is too long for a storage key. The extension is what picks the parser; a truncated name would still parse correctly by falling back to the display name, but only by luck.
1 parent a7dad95 commit 22b441e

3 files changed

Lines changed: 46 additions & 5 deletions

File tree

apps/sim/lib/knowledge/connectors/sync-engine.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ function sanitizeStorageTitle(title: string): string {
6565
return title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, MAX_SAFE_TITLE_LENGTH)
6666
}
6767

68+
/**
69+
* Sanitizes a source file's name for a storage key, keeping its extension.
70+
*
71+
* `sanitizeStorageTitle` truncates a long title outright, which for a source file
72+
* would cut the extension off the end — and the extension is what
73+
* `resolveStoredArtifactExtension` reads to pick a parser. Such a document would
74+
* still parse correctly by falling back to its display name, but only by luck;
75+
* preserving the suffix keeps the storage key authoritative for every file rather
76+
* than for most of them.
77+
*/
78+
function sanitizeStorageFileName(fileName: string): string {
79+
const dotIndex = fileName.lastIndexOf('.')
80+
if (dotIndex <= 0) return sanitizeStorageTitle(fileName)
81+
82+
const extension = sanitizeStorageTitle(fileName.slice(dotIndex))
83+
const base = sanitizeStorageTitle(fileName.slice(0, dotIndex)).slice(
84+
0,
85+
Math.max(1, MAX_SAFE_TITLE_LENGTH - extension.length)
86+
)
87+
return base + extension
88+
}
89+
6890
/**
6991
* The bytes to store for a connector document, together with the name and type
7092
* that describe them.
@@ -85,7 +107,7 @@ function connectorStoredArtifact(extDoc: ExternalDocument): {
85107
if (extDoc.sourceFile) {
86108
return {
87109
bytes: extDoc.sourceFile.bytes,
88-
fileName: sanitizeStorageTitle(extDoc.sourceFile.fileName),
110+
fileName: sanitizeStorageFileName(extDoc.sourceFile.fileName),
89111
mimeType: extDoc.sourceFile.mimeType,
90112
}
91113
}

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,16 @@ export async function processDocument(
206206
const { content, processingMethod } = parseResult
207207
const cloudUrl = 'cloudUrl' in parseResult ? parseResult.cloudUrl : undefined
208208

209+
/**
210+
* Guards every parser, not just the file parsers: OCR reads a scanned page
211+
* that has no recoverable text as empty, and chunking empty content yields a
212+
* document that reports success while holding nothing. Failing here keeps it
213+
* visible with a reason instead.
214+
*/
215+
if (parseResult.metadata?.degraded || !content.trim()) {
216+
throw new Error(unreadableDocumentMessage(filename))
217+
}
218+
209219
let chunks: Chunk[]
210220
const metadata: FileParseMetadata = parseResult.metadata ?? {}
211221

@@ -831,10 +841,6 @@ async function parseWithFileParser(
831841
)
832842
}
833843

834-
if (metadata.degraded || !content.trim()) {
835-
throw new Error(unreadableDocumentMessage(filename))
836-
}
837-
838844
return { content, processingMethod: 'file-parser' as const, cloudUrl: undefined, metadata }
839845
} catch (error) {
840846
logger.error('File parser failed', { errorType: toError(error).name })

apps/sim/lib/knowledge/documents/unreadable-document.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ describe('unreadable document handling', () => {
5252
await expect(parse('Scan.pdf')).rejects.toThrow(/scanned, image-only, or password-protected/)
5353
})
5454

55+
/**
56+
* OCR reads a scanned page with no recoverable text as empty. Chunking that
57+
* yields a document reporting success while holding nothing — the same silent
58+
* failure the file-parser guard exists to prevent, so it has to cover OCR too.
59+
*/
60+
it('fails an OCR result that came back empty', async () => {
61+
mockParseBuffer.mockResolvedValue({ content: '', metadata: {} })
62+
63+
await expect(parse('Scanned.pdf', 'application/pdf')).rejects.toThrow(
64+
/No text could be extracted/
65+
)
66+
})
67+
5568
it('accepts a real extraction', async () => {
5669
mockParseBuffer.mockResolvedValue({
5770
content: 'Approved vendor list',

0 commit comments

Comments
 (0)