Skip to content

Commit 8181eb5

Browse files
committed
Merge branch 'fix/parsers-input-hygiene' into eval/kb-parser-quality
2 parents ade39c1 + 16fd0b0 commit 8181eb5

38 files changed

Lines changed: 1620 additions & 197 deletions

‎apps/sim/connectors/azure-devops/azure-devops.ts‎

Lines changed: 2 additions & 1 deletion
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 { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -1182,7 +1183,7 @@ async function getFileDocument(
11821183
return null
11831184
}
11841185

1185-
const content = buffer.toString('utf8')
1186+
const content = decodeTextBuffer(buffer).text
11861187
if (!content.trim()) return null
11871188

11881189
const title = path.split('/').filter(Boolean).pop() || path

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
4+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
45
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
56
import { boxConnectorMeta } from '@/connectors/box/meta'
67
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -319,7 +320,7 @@ async function fetchPlainTextContent(
319320
extension: string
320321
): Promise<string> {
321322
const buffer = await downloadWithinLimit(`${BOX_API_BASE}/files/${fileId}/content`, accessToken)
322-
const text = buffer.toString('utf8')
323+
const { text } = decodeTextBuffer(buffer)
323324
return HTML_EXTENSIONS.has(extension) ? htmlToPlainText(text) : text
324325
}
325326

@@ -347,7 +348,7 @@ async function fetchExtractedText(
347348
urlTemplate.replace('{+asset_path}', ''),
348349
accessToken
349350
)
350-
return buffer.toString('utf8')
351+
return decodeTextBuffer(buffer).text
351352
}
352353
if (state === 'error' || !infoUrl) return null
353354
if (attempt === REPRESENTATION_POLL_ATTEMPTS) break

‎apps/sim/connectors/dropbox/dropbox.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } 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 { dropboxConnectorMeta } from '@/connectors/dropbox/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -147,7 +148,7 @@ async function downloadFileContent(
147148
throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
148149
}
149150

150-
const text = buffer.toString('utf8')
151+
const { text } = decodeTextBuffer(buffer)
151152

152153
return isHtml ? htmlToPlainText(text) : text
153154
}

‎apps/sim/connectors/github/github.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import { z } from 'zod'
55
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
6+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
67
import { type RetryOptions, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
78
import { parseGitHubRepository } from '@/lib/oauth/github-repository'
89
import { githubConnectorMeta } from '@/connectors/github/meta'
@@ -296,7 +297,7 @@ async function fetchBlobContent(
296297
throw new ConnectorFileTooLargeError(maxBytes)
297298
}
298299
if (isBinaryBuffer(buffer)) return null
299-
return buffer.toString('utf8')
300+
return decodeTextBuffer(buffer).text
300301
}
301302

302303
/** Resolves links within one snapshot; Contents can truncate dereferenced targets at 1 MiB. */

‎apps/sim/connectors/gitlab/gitlab.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
44
import { isPlainRecord } from '@sim/utils/object'
55
import type { SecureFetchResponse } from '@/lib/core/security/input-validation.server'
6+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
67
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
78
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
89
import { gitlabConnectorMeta } from '@/connectors/gitlab/meta'
@@ -435,7 +436,7 @@ function fileToDocument(
435436
return skipped(sizeLimitSkipReason(MAX_FILE_SIZE), buffer.byteLength)
436437
}
437438

438-
const content = buffer.toString('utf8')
439+
const content = decodeTextBuffer(buffer).text
439440
const body = composeBody(title, content)
440441
if (!body.trim()) return null
441442

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,19 +1498,15 @@ describe('htmlToPlainText entity decoding', () => {
14981498

14991499
describe('isIndexableConnectorFile', () => {
15001500
it('accepts the Office and PDF formats the knowledge base can parse', () => {
1501-
for (const name of [
1502-
'sop.pdf',
1503-
'sop.doc',
1504-
'sop.docx',
1505-
'sheet.xls',
1506-
'sheet.xlsx',
1507-
'deck.ppt',
1508-
'deck.pptx',
1509-
]) {
1501+
for (const name of ['sop.pdf', 'sop.doc', 'sop.docx', 'sheet.xls', 'sheet.xlsx', 'deck.pptx']) {
15101502
expect(isIndexableConnectorFile(name)).toBe(true)
15111503
}
15121504
})
15131505

1506+
it('refuses legacy .ppt up front because no parser reads it', () => {
1507+
expect(isIndexableConnectorFile('deck.ppt')).toBe(false)
1508+
})
1509+
15141510
it('still accepts the plain-text formats connectors already synced', () => {
15151511
for (const name of ['a.txt', 'a.md', 'a.html', 'a.htm', 'a.csv', 'a.log', 'a.tsv', 'a.rst']) {
15161512
expect(isIndexableConnectorFile(name)).toBe(true)
@@ -1578,6 +1574,30 @@ describe('extractConnectorText', () => {
15781574
it('leaves whitespace-only content alone for the caller to reject', () => {
15791575
expect(extractConnectorText(Buffer.from(' '), 'blank.txt')).toBe(' ')
15801576
})
1577+
1578+
it('decodes a Latin-1 file as Windows-1252 instead of indexing mojibake', () => {
1579+
expect(extractConnectorText(Buffer.from('Caf\xe9 \xa3 42', 'latin1'), 'notes.txt')).toBe(
1580+
'Café £ 42'
1581+
)
1582+
})
1583+
1584+
it('strips a UTF-8 BOM', () => {
1585+
expect(
1586+
extractConnectorText(
1587+
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('a,b')]),
1588+
'data.csv'
1589+
)
1590+
).toBe('a,b')
1591+
})
1592+
1593+
it('decodes UTF-16 with a BOM', () => {
1594+
expect(
1595+
extractConnectorText(
1596+
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('<p>Hällo</p>', 'utf16le')]),
1597+
'page.html'
1598+
)
1599+
).toBe('Hällo')
1600+
})
15811601
})
15821602

15831603
describe('pipelineParsedMimeType', () => {

‎apps/sim/connectors/utils.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isPayloadSizeLimitError,
55
readResponseToBufferWithLimit,
66
} from '@/lib/core/utils/stream-limits'
7+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
78
import { MAX_FILE_SIZE as KB_DOCUMENT_MAX_BYTES } from '@/lib/uploads/utils/validation'
89
import type { ExternalDocument } from '@/connectors/types'
910

@@ -427,7 +428,6 @@ export const PIPELINE_PARSED_MIME_TYPES: ReadonlyMap<string, string> = new Map([
427428
['xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
428429
['xlsb', 'application/vnd.ms-excel.sheet.binary.macroEnabled.12'],
429430
['xltx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.template'],
430-
['ppt', 'application/vnd.ms-powerpoint'],
431431
['pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
432432
['pptm', 'application/vnd.ms-powerpoint.presentation.macroEnabled.12'],
433433
['potx', 'application/vnd.openxmlformats-officedocument.presentationml.template'],
@@ -497,16 +497,18 @@ export function pipelineParsedMimeType(fileName: string): string | undefined {
497497
*
498498
* Only for formats that are already text — anything the shared parsers handle is
499499
* delivered to them verbatim instead, via {@link pipelineParsedMimeType}. HTML is
500-
* additionally reduced to plain text; everything else is a UTF-8 decode.
500+
* additionally reduced to plain text; everything else is decoded with the shared
501+
* BOM/UTF-8/Windows-1252 detection so a Latin-1 file never indexes as mojibake.
501502
*/
502503
export function extractConnectorText(buffer: Buffer, fileName: string): string {
503504
const extension = connectorFileExtension(fileName)
505+
const { text } = decodeTextBuffer(buffer)
504506

505507
if (extension === 'html' || extension === 'htm') {
506-
return htmlToPlainText(buffer.toString('utf8'))
508+
return htmlToPlainText(text)
507509
}
508510

509-
return buffer.toString('utf8')
511+
return text
510512
}
511513

512514
/**

‎apps/sim/lib/copilot/vfs/file-reader.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import { randomFillSync } from 'node:crypto'
66
import { crc32 } from 'node:zlib'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({
9+
const { fetchWorkspaceFileBuffer, mockParseBuffer } = vi.hoisted(() => ({
1010
fetchWorkspaceFileBuffer: vi.fn(),
11+
mockParseBuffer: vi.fn(),
1112
}))
1213

1314
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1415
fetchWorkspaceFileBuffer,
1516
}))
17+
vi.mock('@/lib/file-parsers', () => ({
18+
parseBuffer: mockParseBuffer,
19+
}))
1620

1721
import {
1822
MAX_IMAGE_READ_BYTES,
@@ -21,6 +25,7 @@ import {
2125
MAX_TEXT_READ_BYTES,
2226
readFileRecord,
2327
} from '@/lib/copilot/vfs/file-reader'
28+
import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders'
2429
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2530
import { MAX_TRANSCODE_INPUT_BYTES } from '@/lib/uploads/server/heic'
2631

@@ -201,3 +206,46 @@ describe('readFileRecord', () => {
201206
SHARP_TEST_TIMEOUT_MS
202207
)
203208
})
209+
210+
describe('readFileRecord parseable documents', () => {
211+
beforeEach(() => {
212+
vi.clearAllMocks()
213+
})
214+
215+
function documentRecord(name: string, type: string, size: number) {
216+
return { ...imageRecord(name, size, type), id: 'wf_doc' }
217+
}
218+
219+
it('returns the parsed text of a document', async () => {
220+
fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes'))
221+
mockParseBuffer.mockResolvedValue({
222+
content: 'Quarterly review\nSecond line',
223+
metadata: { extractionMethod: 'word-extractor' },
224+
})
225+
226+
const result = await readFileRecord(documentRecord('review.doc', 'application/msword', 5))
227+
228+
expect(result).toEqual({ content: 'Quarterly review\nSecond line', totalLines: 2 })
229+
})
230+
231+
/**
232+
* A parser that could only scrape bytes flags the result `degraded`; that must
233+
* reach the model as the could-not-parse placeholder, never as file content.
234+
*/
235+
it('reports degraded parser output as could-not-parse instead of handing it to the model', async () => {
236+
fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes'))
237+
mockParseBuffer.mockResolvedValue({
238+
content: '[Content_Types].xml _rels/.rels theme/theme/themeManager.xml',
239+
metadata: { degraded: true, warning: 'Basic text extraction used' },
240+
})
241+
242+
const result = await readFileRecord(
243+
documentRecord('deck.pptx', 'application/vnd.ms-powerpoint', 5)
244+
)
245+
246+
expect(result).toEqual(
247+
readPlaceholder.couldNotParse('deck.pptx', 'application/vnd.ms-powerpoint', 5)
248+
)
249+
expect(result?.content).not.toContain('[Content_Types].xml')
250+
})
251+
})

‎apps/sim/lib/copilot/vfs/file-reader.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const TEXT_TYPES = new Set([
104104
'application/javascript',
105105
])
106106

107-
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt'])
107+
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx'])
108108

109109
export function isReadableFileType(contentType: string): boolean {
110110
return TEXT_TYPES.has(contentType) || contentType.startsWith('text/')
@@ -587,6 +587,10 @@ export async function readFileRecord(
587587
try {
588588
const { parseBuffer } = await import('@/lib/file-parsers')
589589
const result = await parseBuffer(fetched.buffer, ext)
590+
if (result.metadata?.degraded === true) {
591+
/** Scraped ZIP internals or placeholder prose, not the document's text. */
592+
throw new Error(result.metadata.warning ?? 'Parser returned degraded output')
593+
}
590594
const content = result.content || ''
591595
const lines = content.split('\n').length
592596
span.setAttributes({

‎apps/sim/lib/file-parsers/csv-parser.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
1-
import { createReadStream, existsSync } from 'fs'
1+
import { existsSync } from 'fs'
2+
import { readFile } from 'fs/promises'
23
import { Readable } from 'stream'
34
import { createLogger } from '@sim/logger'
45
import { type Options, parse } from 'csv-parse'
56
import { FileParserError } from '@/lib/file-parsers/errors'
67
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
7-
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
8+
import {
9+
type DecodedText,
10+
decodeTextBuffer,
11+
sanitizeTextForUTF8,
12+
truncationNotice,
13+
} from '@/lib/file-parsers/utils'
814

915
const logger = createLogger('CsvParser')
1016

1117
const CONFIG = {
1218
MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview
1319
MAX_SAMPLE_ROWS: 100, // Sample for metadata
1420
MAX_ERRORS: 100, // Stop after 100 errors
15-
STREAM_CHUNK_SIZE: 16384, // 16KB chunks for streaming
1621
}
1722

1823
export class CsvParser implements FileParser {
24+
/**
25+
* Reads the whole file before parsing rather than streaming 16 KB chunks:
26+
* encoding detection needs the complete byte sequence (a BOM-less UTF-16 or
27+
* Windows-1252 file cannot be recognized per chunk, and a multi-byte UTF-8
28+
* sequence split across chunk boundaries would be misread). The upload size
29+
* caps already bound the file, and `parseBuffer` — the production path —
30+
* always held the full buffer.
31+
*/
1932
async parseFile(filePath: string): Promise<FileParseResult> {
2033
if (!filePath) {
2134
throw new Error('No file path provided')
@@ -25,11 +38,7 @@ export class CsvParser implements FileParser {
2538
throw new Error(`File not found: ${filePath}`)
2639
}
2740

28-
const stream = createReadStream(filePath, {
29-
highWaterMark: CONFIG.STREAM_CHUNK_SIZE,
30-
})
31-
32-
return this.parseStream(stream)
41+
return this.parseBuffer(await readFile(filePath))
3342
}
3443

3544
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
@@ -38,14 +47,18 @@ export class CsvParser implements FileParser {
3847
`Parsing CSV buffer, size: ${bufferSize} bytes (${(bufferSize / 1024 / 1024).toFixed(2)} MB)`
3948
)
4049

50+
const decoded = decodeTextBuffer(buffer)
4151
const stream = new Readable({ read() {} })
42-
stream.push(buffer)
52+
stream.push(decoded.text)
4353
stream.push(null)
4454

45-
return this.parseStream(stream)
55+
return this.parseStream(stream, decoded)
4656
}
4757

48-
private parseStream(inputStream: NodeJS.ReadableStream): Promise<FileParseResult> {
58+
private parseStream(
59+
inputStream: NodeJS.ReadableStream,
60+
decoded: DecodedText
61+
): Promise<FileParseResult> {
4962
return new Promise((resolve, reject) => {
5063
let rowCount = 0
5164
let errorCount = 0
@@ -145,6 +158,8 @@ export class CsvParser implements FileParser {
145158
errors: errors.slice(0, 10),
146159
truncated: rowCount > CONFIG.MAX_PREVIEW_ROWS,
147160
sampledData: sampledRows,
161+
encoding: decoded.encoding,
162+
...(decoded.warning ? { warning: decoded.warning } : {}),
148163
},
149164
})
150165
}

0 commit comments

Comments
 (0)