Skip to content

Commit a7dad95

Browse files
committed
feat(connectors): hand source files to the document pipeline instead of extracting them
A connector that extracted text itself stranded the document on a second, weaker parser. The shared pipeline routes PDFs to OCR — the only way a scanned page is readable at all — and owns every other format's parser, but its OCR branch is gated on `mimeType === 'application/pdf'` and connector documents were stored as `text/plain`, so a connector PDF could never reach it. The same file dragged into the UI was read by OCR; synced through a connector it got the local parser. `ExternalDocument` can now carry the source file itself, and SharePoint and OneDrive hand over anything the knowledge base can parse rather than extracting it. The sync engine stores those bytes under the file's own name and type, so the pipeline parses them exactly as it would an upload of the same file. Formats that are already text stay on the text path: HTML still reduces to plain text and the rest are UTF-8 decodes, so nothing already indexed changes representation. The MIME type is derived from the extension rather than the source's own declaration, so a provider that omits or mislabels it cannot strand a PDF on the non-OCR path. Re-syncing an existing document now rewrites `mimeType` too, which is what lets one stored as connector-extracted text stop declaring `text/plain`. This removes the duplicate extraction path rather than leaving both in place: `extractConnectorText` is text-only, and the guard against fabricated content moves to the pipeline where parsing now happens. That guard still matters — `DocParser` and `PptxParser` never throw, returning a placeholder sentence or scraped archive bytes on a legacy binary or an image-only deck — so a `degraded` result now fails the document with the same actionable message it produced before, naming the modern container for legacy formats. The in-flight byte budget already accounted for this: `estimateOpSizeBytes` reads the true source size from listing metadata, so batching reserved against the real file all along and merely over-reserved while only text was stored.
1 parent d1e3eee commit a7dad95

9 files changed

Lines changed: 300 additions & 278 deletions

File tree

apps/sim/connectors/onedrive/onedrive.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/
66
import {
77
CONNECTOR_MAX_FILE_BYTES,
88
ConnectorFileTooLargeError,
9-
ConnectorTextExtractionError,
109
connectorFileExtension,
1110
extractConnectorText,
12-
extractionFailedSkipReason,
1311
isIndexableConnectorFile,
1412
isSkippedDocument,
1513
markSkipped,
1614
parseTagDate,
15+
pipelineParsedMimeType,
1716
readBodyWithLimit,
1817
sizeLimitSkipReason,
1918
stubOrSkipBySize,
@@ -103,13 +102,19 @@ async function downloadFileContent(accessToken: string, fileId: string): Promise
103102
* Fetches a file and extracts its indexable text — a UTF-8 decode for text
104103
* formats, and the shared knowledge-base parsers for Office documents and PDFs.
105104
*/
106-
async function fetchFileContent(
105+
async function fetchFilePayload(
107106
accessToken: string,
108107
fileId: string,
109108
fileName: string
110-
): Promise<string> {
109+
): Promise<Pick<ExternalDocument, 'content' | 'sourceFile' | 'mimeType'>> {
111110
const buffer = await downloadFileContent(accessToken, fileId)
112-
return extractConnectorText(buffer, fileName)
111+
112+
const mimeType = pipelineParsedMimeType(fileName)
113+
if (mimeType) {
114+
return { content: '', mimeType, sourceFile: { bytes: buffer, fileName, mimeType } }
115+
}
116+
117+
return { content: extractConnectorText(buffer, fileName), mimeType: 'text/plain' }
113118
}
114119

115120
/**
@@ -377,23 +382,16 @@ export const onedriveConnector: ConnectorConfig = {
377382
if (!item.file || !isIndexableConnectorFile(item.name)) return null
378383

379384
try {
380-
const content = await fetchFileContent(accessToken, item.id, item.name)
381-
if (!content.trim()) return null
385+
const payload = await fetchFilePayload(accessToken, item.id, item.name)
386+
if (!payload.sourceFile && !payload.content.trim()) return null
382387

383388
const stub = fileToStub(item)
384-
return { ...stub, content, contentDeferred: false }
389+
return { ...stub, ...payload, contentDeferred: false }
385390
} catch (error) {
386391
if (error instanceof ConnectorFileTooLargeError) {
387392
logger.info('Skipping oversized OneDrive file', { fileId: item.id, name: item.name })
388393
return markSkipped(fileToStub(item), sizeLimitSkipReason(error.limitBytes))
389394
}
390-
if (error instanceof ConnectorTextExtractionError) {
391-
logger.info('Skipping OneDrive file with no extractable text', {
392-
fileId: item.id,
393-
name: item.name,
394-
})
395-
return markSkipped(fileToStub(item), extractionFailedSkipReason(error.extension))
396-
}
397395
/**
398396
* A transport or Graph failure that survived `fetchWithRetry`. Returning
399397
* `null` would drop the file from the run with no `failed` row and no error

apps/sim/connectors/sharepoint/sharepoint.test.ts

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,12 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockFetchWithRetry, mockParseBuffer } = vi.hoisted(() => ({
7-
mockFetchWithRetry: vi.fn(),
8-
mockParseBuffer: vi.fn(),
9-
}))
6+
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
107

118
vi.mock('@/lib/knowledge/documents/utils', () => ({
129
fetchWithRetry: mockFetchWithRetry,
1310
VALIDATE_RETRY_OPTIONS: {},
1411
}))
15-
vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mockParseBuffer }))
1612
vi.mock('@/components/icons', () => ({ MicrosoftSharepointIcon: () => null }))
1713

1814
import {
@@ -496,45 +492,45 @@ describe('getDocument content extraction', () => {
496492
)
497493
}
498494

499-
it('indexes the parsed text of an Office document', async () => {
500-
mockGraph({ ...itemRoute('f1', 'SOP.docx'), ...contentRoute('f1', 'ignored') })
501-
mockParseBuffer.mockResolvedValue({
502-
content: 'Approved vendor list',
503-
metadata: { extractionMethod: 'mammoth' },
504-
})
495+
/**
496+
* The connector hands an Office document over untouched so the shared pipeline
497+
* parses it — the same path an upload of the same file takes, which is what
498+
* routes PDFs through OCR.
499+
*/
500+
it('delivers an Office document as its source file rather than extracting it', async () => {
501+
mockGraph({ ...itemRoute('f1', 'SOP.docx'), ...contentRoute('f1', 'PK-docx-bytes') })
505502

506503
const doc = await get('f1')
507504

508-
expect(doc?.content).toBe('Approved vendor list')
509-
expect(doc?.skippedReason).toBeUndefined()
505+
expect(doc?.content).toBe('')
506+
expect(doc?.sourceFile?.fileName).toBe('SOP.docx')
507+
expect(doc?.sourceFile?.mimeType).toBe(
508+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
509+
)
510+
expect(doc?.sourceFile?.bytes.toString()).toBe('PK-docx-bytes')
511+
expect(doc?.mimeType).toBe(
512+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
513+
)
510514
expect(doc?.contentDeferred).toBe(false)
511515
})
512516

513-
/**
514-
* A degraded extraction must become a visible `failed` row, not a silent drop
515-
* and not indexed placeholder text — the same treatment oversized files get.
516-
*/
517-
it('surfaces a degraded extraction as a skipped document with an actionable reason', async () => {
518-
mockGraph({ ...itemRoute('f2', 'Deck.ppt'), ...contentRoute('f2', 'ole2') })
519-
mockParseBuffer.mockResolvedValue({
520-
content: 'Unable to extract text from PowerPoint file.',
521-
metadata: { extractionMethod: 'fallback', degraded: true },
522-
})
517+
it('declares a PDF as application/pdf so the pipeline can route it to OCR', async () => {
518+
mockGraph({ ...itemRoute('f4', 'Contract.pdf'), ...contentRoute('f4', '%PDF-1.7 bytes') })
523519

524-
const doc = await get('f2')
520+
const doc = await get('f4')
525521

526-
expect(doc?.content).toBe('')
527-
expect(doc?.skippedReason).toContain('PPTX')
528-
expect(doc?.externalId).toBe('f2')
522+
expect(doc?.mimeType).toBe('application/pdf')
523+
expect(doc?.sourceFile?.mimeType).toBe('application/pdf')
529524
})
530525

531-
it('reads a text file without invoking a parser', async () => {
526+
it('still extracts a text file itself, since there is nothing for a parser to do', async () => {
532527
mockGraph({ ...itemRoute('f3', 'notes.txt'), ...contentRoute('f3', 'plain notes') })
533528

534529
const doc = await get('f3')
535530

536531
expect(doc?.content).toBe('plain notes')
537-
expect(mockParseBuffer).not.toHaveBeenCalled()
532+
expect(doc?.sourceFile).toBeUndefined()
533+
expect(doc?.mimeType).toBe('text/plain')
538534
})
539535
})
540536

apps/sim/connectors/sharepoint/sharepoint.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/
66
import {
77
CONNECTOR_MAX_FILE_BYTES,
88
ConnectorFileTooLargeError,
9-
ConnectorTextExtractionError,
109
connectorFileExtension,
1110
extractConnectorText,
12-
extractionFailedSkipReason,
1311
isIndexableConnectorFile,
1412
isSkippedDocument,
1513
markSkipped,
1614
parseTagDate,
15+
pipelineParsedMimeType,
1716
readBodyWithLimit,
1817
sizeLimitSkipReason,
1918
stubOrSkipBySize,
@@ -214,14 +213,20 @@ async function downloadFileContent(
214213
* Fetches a file and extracts its indexable text — a UTF-8 decode for text
215214
* formats, and the shared knowledge-base parsers for Office documents and PDFs.
216215
*/
217-
async function fetchFileContent(
216+
async function fetchFilePayload(
218217
accessToken: string,
219218
driveId: string,
220219
itemId: string,
221220
fileName: string
222-
): Promise<string> {
221+
): Promise<Pick<ExternalDocument, 'content' | 'sourceFile' | 'mimeType'>> {
223222
const buffer = await downloadFileContent(accessToken, driveId, itemId, fileName)
224-
return extractConnectorText(buffer, fileName)
223+
224+
const mimeType = pipelineParsedMimeType(fileName)
225+
if (mimeType) {
226+
return { content: '', mimeType, sourceFile: { bytes: buffer, fileName, mimeType } }
227+
}
228+
229+
return { content: extractConnectorText(buffer, fileName), mimeType: 'text/plain' }
225230
}
226231

227232
/**
@@ -925,11 +930,11 @@ export const sharepointConnector: ConnectorConfig = {
925930
}
926931

927932
try {
928-
const content = await fetchFileContent(accessToken, driveId, item.id, item.name)
929-
if (!content.trim()) return null
933+
const payload = await fetchFilePayload(accessToken, driveId, item.id, item.name)
934+
if (!payload.sourceFile && !payload.content.trim()) return null
930935

931936
const stub = itemToStub(item, siteName ?? siteUrl)
932-
return { ...stub, content, contentDeferred: false }
937+
return { ...stub, ...payload, contentDeferred: false }
933938
} catch (error) {
934939
if (error instanceof ConnectorFileTooLargeError) {
935940
logger.info('Skipping oversized SharePoint file', { fileId: item.id, name: item.name })
@@ -938,16 +943,6 @@ export const sharepointConnector: ConnectorConfig = {
938943
sizeLimitSkipReason(error.limitBytes)
939944
)
940945
}
941-
if (error instanceof ConnectorTextExtractionError) {
942-
logger.info('Skipping SharePoint file with no extractable text', {
943-
fileId: item.id,
944-
name: item.name,
945-
})
946-
return markSkipped(
947-
itemToStub(item, siteName ?? siteUrl),
948-
extractionFailedSkipReason(error.extension)
949-
)
950-
}
951946
/**
952947
* A transport or Graph failure that survived `fetchWithRetry`. Returning
953948
* `null` would drop the file from the run with no `failed` row and no error

apps/sim/connectors/types.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,29 @@ export interface ExternalDocument {
2929
externalId: string
3030
/** Document title / filename */
3131
title: string
32-
/** Extracted text content */
32+
/** Extracted text content. Empty when {@link ExternalDocument.sourceFile} carries the document instead. */
3333
content: string
3434
/** MIME type of the content */
3535
mimeType: string
36+
/**
37+
* The source file itself, for connectors that hand over the original document
38+
* rather than text they extracted from it.
39+
*
40+
* Preferred for any format the knowledge base can parse. Extracting inside a
41+
* connector strands the document on a second, weaker parser: the shared
42+
* pipeline routes PDFs to OCR (so scanned pages are readable at all) and owns
43+
* every other format's parser, while a connector doing its own extraction
44+
* stores plain text that no longer declares what it came from.
45+
*
46+
* Carried as one object so the bytes can never disagree with the name and type
47+
* that describe them.
48+
*/
49+
sourceFile?: {
50+
bytes: Buffer
51+
/** Name whose extension names the format, e.g. `Report.pdf`. */
52+
fileName: string
53+
mimeType: string
54+
}
3655
/** Link back to the original document */
3756
sourceUrl?: string
3857
/** Hash of content for change detection (format varies by connector) */

0 commit comments

Comments
 (0)