Skip to content

Commit df65598

Browse files
committed
Merge branch 'fix/parsers-input-hygiene' into eval/kb-parser-quality
2 parents 6077317 + 48f7308 commit df65598

25 files changed

Lines changed: 375 additions & 68 deletions

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@
745745
"get": {
746746
"operationId": "readFileText",
747747
"summary": "Read File Text",
748-
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
748+
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
749749
"x-sim-operation": "files.read_content",
750750
"x-oauth-scope": "api:read",
751751
"tags": ["Files"],

‎apps/sim/connectors/bitbucket/bitbucket.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,7 @@ describe('bitbucket getDocument', () => {
774774
expect(doc?.skippedReason).toMatch(/Binary/)
775775
})
776776

777-
it('surfaces non-UTF-8 source as skipped instead of indexing replacement characters', async () => {
777+
it('decodes non-UTF-8 source as Windows-1252 instead of skipping or indexing replacement characters', async () => {
778778
mockApi([
779779
[
780780
/\/src\/[a-f0-9]+\/latin1\.txt$/,
@@ -784,8 +784,9 @@ describe('bitbucket getDocument', () => {
784784

785785
const doc = await bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'file:latin1.txt', {})
786786

787-
expect(doc?.skippedReason).toMatch(/Non-UTF-8/)
788-
expect(doc?.content).toBe('')
787+
expect(doc?.skippedReason).toBeUndefined()
788+
expect(doc?.content).toContain('café')
789+
expect(doc?.content).not.toContain('\uFFFD')
789790
})
790791

791792
it('returns null for a file the ref no longer carries', async () => {

‎apps/sim/connectors/bitbucket/bitbucket.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { bitbucketConnectorMeta } from '@/connectors/bitbucket/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -81,7 +82,6 @@ const BINARY_SNIFF_BYTES = 8000
8182
*/
8283
const MAX_TREE_DEPTH = 5
8384
const BINARY_SKIP_REASON = 'Binary file was not indexed'
84-
const NON_UTF8_SKIP_REASON = 'Non-UTF-8 file was not indexed'
8585
/**
8686
* Bitbucket answers a raw read of an LFS-managed file with a 301 to Atlassian's
8787
* media services platform. The connector deliberately surfaces the file as
@@ -1240,13 +1240,7 @@ export const bitbucketConnector: ConnectorConfig = {
12401240
return markSkipped(stub, BINARY_SKIP_REASON)
12411241
}
12421242

1243-
let text: string
1244-
try {
1245-
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
1246-
} catch {
1247-
logger.info('Skipping non-UTF-8 Bitbucket file', { path })
1248-
return markSkipped(stub, NON_UTF8_SKIP_REASON)
1249-
}
1243+
const text = decodeTextBuffer(buffer).text
12501244

12511245
const body = composeBody(stub.title, text)
12521246
if (!body.trim()) return null

‎apps/sim/connectors/box/box.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ const REPRESENTATION_EXTENSIONS = new Set([
133133
'odt',
134134
'otp',
135135
'pdf',
136-
'ppt',
137136
'pptx',
138137
'rtf',
139138
'vi',

‎apps/sim/connectors/databricks/databricks.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { truncate } from '@sim/utils/string'
44
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
67
import {
78
DATABRICKS_CONTENT_TYPES,
@@ -586,7 +587,7 @@ async function exportNotebook(
586587
return { skippedReason: sizeLimitSkipReason(CONNECTOR_MAX_FILE_BYTES) }
587588
}
588589

589-
return { content: decoded.toString('utf8') }
590+
return { content: decodeTextBuffer(decoded).text }
590591
}
591592

592593
/**

‎apps/sim/connectors/google-drive/google-drive.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { isPlainRecord } from '@sim/utils/object'
44
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import {
67
type DrivePermission,
78
driveFileAcl,
@@ -194,10 +195,10 @@ async function fetchFilePayload(accessToken: string, file: DriveFile): Promise<F
194195
},
195196
}
196197
}
197-
return { content: bytes.toString('utf8'), mimeType: 'text/plain' }
198+
return { content: decodeTextBuffer(bytes).text, mimeType: 'text/plain' }
198199
}
199200
if (file.mimeType === 'text/html') {
200-
const html = (await downloadFile(accessToken, file.id)).toString('utf8')
201+
const html = decodeTextBuffer(await downloadFile(accessToken, file.id)).text
201202
return { content: htmlToPlainText(html), mimeType: 'text/plain' }
202203
}
203204
const raw = rawFileType(file)
@@ -210,7 +211,7 @@ async function fetchFilePayload(accessToken: string, file: DriveFile): Promise<F
210211
}
211212
if (isSupportedTextFile(file.mimeType)) {
212213
return {
213-
content: (await downloadFile(accessToken, file.id)).toString('utf8'),
214+
content: decodeTextBuffer(await downloadFile(accessToken, file.id)).text,
214215
mimeType: 'text/plain',
215216
}
216217
}

‎apps/sim/connectors/s3/s3.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isLoopbackHostname } from '@sim/security/ssrf'
44
import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { truncate } from '@sim/utils/string'
66
import { isHosted } from '@/lib/core/config/env-flags'
7+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
78
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
89
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
910
import { s3ConnectorMeta } from '@/connectors/s3/meta'
@@ -658,7 +659,7 @@ export const s3Connector: ConnectorConfig = {
658659
sizeLimitSkipReason(MAX_FILE_SIZE)
659660
)
660661
}
661-
const raw = body.toString('utf-8')
662+
const raw = decodeTextBuffer(body).text
662663
const content = MARKUP_EXTENSIONS.has(getExtension(key)) ? htmlToPlainText(raw) : raw
663664
if (!content.trim()) return null
664665

‎apps/sim/connectors/sftp/sftp.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { type Attributes, type Client, type SFTPWrapper, utils as ssh2Utils } from 'ssh2'
44
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import {
67
createSftpConnection,
78
getFileType,
@@ -779,7 +780,7 @@ export const sftpConnector: ConnectorConfig = {
779780
return markSkipped(stub, 'File appears to be binary and was not indexed')
780781
}
781782

782-
const raw = buffer.toString('utf-8')
783+
const raw = decodeTextBuffer(buffer).text
783784
const content = HTML_EXTENSIONS.has(getExtension(remotePath)) ? htmlToPlainText(raw) : raw
784785
if (!content.trim()) return null
785786

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ const declaredRoutes = [
393393
operationId: 'readFileText',
394394
summary: 'Read File Text',
395395
description:
396-
'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.',
396+
'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.',
397397
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'],
398398
success: { description: 'The extracted text and its extraction-quality flags.' },
399399
}),

‎apps/sim/lib/file-parsers/doc-parser.test.ts‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface WordSections {
3737
footers?: string
3838
footnotes?: string
3939
endnotes?: string
40+
textboxes?: string
4041
}
4142

4243
/** The accessor surface of word-extractor's `Document`, with empty sections by default. */
@@ -48,7 +49,7 @@ function wordDocument(sections: WordSections) {
4849
getFootnotes: () => sections.footnotes ?? '',
4950
getEndnotes: () => sections.endnotes ?? '',
5051
getAnnotations: () => '',
51-
getTextboxes: () => '',
52+
getTextboxes: () => sections.textboxes ?? '',
5253
}
5354
}
5455

@@ -169,6 +170,7 @@ describe('DocParser.parseBuffer', () => {
169170
footers: 'Page footer',
170171
footnotes: '',
171172
endnotes: 'An endnote',
173+
textboxes: 'Pull quote in a text box',
172174
})
173175
)
174176

@@ -177,7 +179,7 @@ describe('DocParser.parseBuffer', () => {
177179
expect(mockWordExtract).toHaveBeenCalledOnce()
178180
expect(mockParseOfficeText).not.toHaveBeenCalled()
179181
expect(result.content).toBe(
180-
'Body paragraph “quoted”\n\nRunning header\n\nPage footer\n\nAn endnote'
182+
'Body paragraph “quoted”\n\nRunning header\n\nPage footer\n\nAn endnote\n\nPull quote in a text box'
181183
)
182184
expect(result.metadata).toMatchObject({
183185
extractionMethod: 'word-extractor',
@@ -200,18 +202,26 @@ describe('DocParser.parseBuffer', () => {
200202
expect((error as Error).message).toMatch(/Word 6\/95/)
201203
})
202204

203-
it('maps any other word-extractor failure to invalid_format with the cause retained', async () => {
204-
const libraryError = new Error('Invalid Short Sector Allocation Table')
205-
mockWordExtract.mockRejectedValue(libraryError)
206-
207-
const error = await new DocParser()
208-
.parseBuffer(buildLegacyOleDoc())
209-
.catch((caught: unknown) => caught)
210-
211-
expect(error).toBeInstanceOf(FileParserError)
212-
expect(error).toMatchObject({ code: 'invalid_format' })
213-
expect((error as FileParserError).cause).toBe(libraryError)
214-
})
205+
it.each([
206+
new Error('Invalid Short Sector Allocation Table'),
207+
new RangeError('Attempt to access memory outside buffer bounds'),
208+
])(
209+
'maps any other word-extractor failure to a stable invalid_format message with the cause retained: %s',
210+
async (libraryError) => {
211+
mockWordExtract.mockRejectedValue(libraryError)
212+
213+
const error = await new DocParser()
214+
.parseBuffer(buildLegacyOleDoc())
215+
.catch((caught: unknown) => caught)
216+
217+
expect(error).toBeInstanceOf(FileParserError)
218+
expect(error).toMatchObject({
219+
code: 'invalid_format',
220+
message: 'This .doc file could not be read',
221+
})
222+
expect((error as FileParserError).cause).toBe(libraryError)
223+
}
224+
)
215225

216226
it('reports a legacy .doc with no text as no_extractable_text', async () => {
217227
mockWordExtract.mockResolvedValue(wordDocument({ body: ' \n' }))

0 commit comments

Comments
 (0)