Skip to content

Commit f273106

Browse files
committed
fix(knowledge): key vision checkpoints by title and keep excluded images off while disabled
1 parent 824b5d3 commit f273106

5 files changed

Lines changed: 80 additions & 4 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
import {
117117
createKnowledgeConnector,
118118
updateKnowledgeConnector,
119+
updateKnowledgeConnectorDocuments,
119120
} from '@/lib/knowledge/application/connectors'
120121
import * as connectorTokens from '@/lib/knowledge/connectors/access-token'
121122
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
@@ -179,6 +180,12 @@ describe('Confluence image attachments through sync, indexing and reconciliation
179180
})
180181
await syncFinished(ids.connectorId)
181182
}
183+
function setExcluded(documentId: string, operation: 'exclude' | 'restore') {
184+
return updateKnowledgeConnectorDocuments.execute({
185+
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
186+
input: { connectorId: ids.connectorId, operation, documentIds: [documentId] },
187+
})
188+
}
182189
async function imageRemovalPending() {
183190
const [row] = await db
184191
.select({ pending: knowledgeConnector.imageRemovalPending })
@@ -306,6 +313,23 @@ describe('Confluence image attachments through sync, indexing and reconciliation
306313
expect(fixture.extractions.sort()).toEqual(['image-a.png', 'image-b.png'])
307314
}, 120_000)
308315

316+
it('keeps an excluded image excluded while Image Attachments is off', async () => {
317+
fixture.images.set('image-a', 1)
318+
await sync()
319+
const [excluded] = await images()
320+
await setExcluded(excluded.id, 'exclude')
321+
322+
await setImageAttachments('off')
323+
await expect(setExcluded(excluded.id, 'restore')).rejects.toThrow(
324+
'Turn on image attachments for this connector before restoring an image.'
325+
)
326+
const [kept] = await images()
327+
expect(kept).toMatchObject({ id: excluded.id, userExcluded: true, enabled: false })
328+
329+
await setImageAttachments('semantic')
330+
expect(await setExcluded(excluded.id, 'restore')).toMatchObject({ count: 1 })
331+
}, 120_000)
332+
309333
it('keeps images off for a connector created before the setting existed', async () => {
310334
fixture.images.set('image-a', 1)
311335
await configure(undefined)

‎apps/sim/lib/knowledge/application/connectors.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ import {
8383
MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE,
8484
MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH,
8585
} from '@/lib/knowledge/constants'
86+
import {
87+
isImageExtractionEnabled,
88+
SEMANTIC_IMAGE_MIME_TYPES,
89+
} from '@/lib/knowledge/documents/image-extraction-policy'
8690
import {
8791
documentProcessingOutcomeSelection,
8892
failedDocumentCondition,
@@ -1270,6 +1274,47 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase(
12701274
},
12711275
})
12721276

1277+
/**
1278+
* An excluded image keeps its indexed text, and turning image attachments off
1279+
* skips excluded rows so the exclusion survives turning them back on. Restoring
1280+
* one while images are off would re-enable text the source no longer lists.
1281+
*/
1282+
async function assertImageRestoreAllowed(
1283+
connector: { id: string; connectorType: string },
1284+
documentIds: string[]
1285+
) {
1286+
const imageExtraction = getConnectorMeta(connector.connectorType)?.imageExtraction
1287+
if (!imageExtraction) return
1288+
const [stored] = await db
1289+
.select({ sourceConfig: knowledgeConnector.sourceConfig })
1290+
.from(knowledgeConnector)
1291+
.where(eq(knowledgeConnector.id, connector.id))
1292+
.limit(1)
1293+
if (
1294+
!stored ||
1295+
isImageExtractionEnabled(imageExtraction, stored.sourceConfig as Record<string, unknown>)
1296+
) {
1297+
return
1298+
}
1299+
const [image] = await db
1300+
.select({ id: document.id })
1301+
.from(document)
1302+
.where(
1303+
and(
1304+
eq(document.connectorId, connector.id),
1305+
inArray(document.id, documentIds),
1306+
inArray(document.mimeType, [...SEMANTIC_IMAGE_MIME_TYPES])
1307+
)
1308+
)
1309+
.limit(1)
1310+
if (image) {
1311+
throw new OrchestrationError(
1312+
'validation',
1313+
'Turn on image attachments for this connector before restoring an image.'
1314+
)
1315+
}
1316+
}
1317+
12731318
export const updateKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase({
12741319
operation: knowledgeOperations.updateConnectorDocuments,
12751320
resolveContext: ({
@@ -1294,6 +1339,7 @@ export const updateKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCas
12941339
if (restoring && !requiresConnectorIndexing(context.knowledgeBase.isSearchIndex)) {
12951340
throw new OrchestrationError('validation', 'This search index is inactive; use Sim Search.')
12961341
}
1342+
if (restoring) await assertImageRestoreAllowed(context.connector, documentIds)
12971343
const updated = await db
12981344
.update(document)
12991345
.set({ userExcluded: !restoring, enabled: restoring })

‎apps/sim/lib/knowledge/documents/image-extraction.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ export async function extractImageText(input: {
298298
? createVisionCheckpoint({
299299
context: input.checkpoint,
300300
source,
301+
filename,
301302
model,
302303
contractVersion: IMAGE_EXTRACTION_REPRESENTATION_VERSION,
303304
})

‎apps/sim/lib/knowledge/documents/vision-checkpoints.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function checkpoint(overrides: Partial<Parameters<typeof createVisionCheckpoint>
3434
return createVisionCheckpoint({
3535
context,
3636
source,
37+
filename: 'architecture.png',
3738
model: 'gemini-3.8-flash',
3839
contractVersion: 'image-extract-v1',
3940
...overrides,
@@ -94,12 +95,13 @@ describe('vision extraction checkpoints', () => {
9495

9596
it.each([
9697
{ source: Buffer.from('changed image bytes') },
98+
{ filename: 'renamed.png' },
9799
{ context: { ...context, documentId: 'another-document' } },
98100
{ context: { ...context, indexingPassId: 'new-pass' } },
99101
{ model: 'gpt-5.4-mini' },
100102
{ contractVersion: 'image-extract-v2' },
101103
])(
102-
'never reuses a checkpoint across source, pass, model or contract changes: %o',
104+
'never reuses a checkpoint across source, title, pass, model or contract changes: %o',
103105
async (change) => {
104106
await checkpoint().save(result)
105107
expect(await checkpoint(change).load()).toBeNull()

‎apps/sim/lib/knowledge/documents/vision-checkpoints.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,18 @@ export type VisionExtractionCheckpoint = z.infer<typeof checkpointBodySchema>
3535
/**
3636
* Retry cache for one image's vision extraction, never a record: a pass that dies
3737
* after the paid call but before the index swap resumes from it instead of paying
38-
* again. Source bytes, the canonical document/pass identity, the model and the
39-
* extraction contract version all participate in the address.
38+
* again. Every model input (source bytes and the title the prompt names), the
39+
* canonical document/pass identity, the model and the extraction contract version
40+
* participate in the address.
4041
*/
4142
export function createVisionCheckpoint(options: {
4243
context: OcrCheckpointContext
4344
source: Buffer
45+
filename: string
4446
model: string
4547
contractVersion: string
4648
}) {
47-
const { context, source, model, contractVersion } = options
49+
const { context, source, filename, model, contractVersion } = options
4850
if (!context.knowledgeBaseId || !context.documentId || !context.indexingPassId) {
4951
throw new Error('Vision checkpoint requires a canonical processing identity')
5052
}
@@ -53,6 +55,7 @@ export function createVisionCheckpoint(options: {
5355
version: 1,
5456
...context,
5557
sourceHash: sha256Hex(source),
58+
filename,
5659
model,
5760
contractVersion,
5861
})

0 commit comments

Comments
 (0)