Skip to content

Commit aa20446

Browse files
committed
refactor(knowledge): tighten image extraction after a full review
- Every Gemini attempt takes its own admission, and 429/503 records a provider cooldown for the key; the retry delay is read from the body's RetryInfo when no Retry-After header is sent. - The image is base64-encoded once per extraction, rotated upright even when it already fits, and flattened onto white before a JPEG fallback. - Cost goes through calculateBillableModelCost; both indexing charges share one recordIndexingUsage helper. - Reconciliation waits until queued image removal finishes, and the removal walks by id instead of rescanning from the start. - The per-sync image cap is at least 1, so a sync can always finish. - canExtractImages lives with the extractor; isSemanticImage treats an undeclared policy as OCR, so callers drop their fallbacks. - Tests: unique embedding vectors, restored pool keys, waits for dispatched syncs, fake timers in finally, native noise fixtures; drops mock-only assertions.
1 parent 8ecd86a commit aa20446

19 files changed

Lines changed: 293 additions & 207 deletions

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ See [Observability](/platform/self-hosting/observability).
193193
| Variable | Description |
194194
|----------|-------------|
195195
| `KB_IMAGE_EXTRACTION_MODEL` | Gemini model that reads Confluence image attachments into searchable text. Defaults to `gemini-3.8-flash`; a value that is not a Gemini 3 model in the catalog falls back to the default. Needs `GEMINI_API_KEY` (or a workspace Gemini key); without one, image attachments are left out of syncs |
196-
| `KB_IMAGE_EXTRACTIONS_PER_SYNC` | New or changed images one connector sync sends for reading. Defaults to `500`; the rest wait for the next sync, which reports as partial |
196+
| `KB_IMAGE_EXTRACTIONS_PER_SYNC` | New or changed images one connector sync sends for reading, at least `1`. Defaults to `500`; the rest wait for the next sync, which reports as partial |
197197
| `KB_CONFIG_VISION_REQUESTS_PER_MINUTE` | Image reads per minute per Gemini key, shared by every sync on the deployment. Defaults to `300`; lower it to match a key with a smaller Gemini quota |
198198
| `KB_EMBEDDING_MODEL` | Embedding model for new knowledge bases. Defaults to `text-embedding-3-small`; use `ollama/<model>` for a model on your own Ollama. An unrecognised hosted model id falls back to the default, but an `ollama/` id is taken at face value — if that model is not on the server, knowledge-base creation fails rather than falling back |
199199
| `EMBEDDING_OUTPUT_DIMS` | Vector width new knowledge bases are stored at: `384`, `768`, `1024`, `1536` (default), or `3072`. It must be a width the chosen model can emit; anything else falls back to `1536` with a warning |

‎apps/sim/.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
259259
# keys in the same organization to one group using SHA-256 fingerprints, never raw keys.
260260
# MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"}
261261

262+
# Knowledge base images read with Gemini (Confluence "Image Attachments: Read with AI").
263+
# Needs GEMINI_API_KEY or a workspace Gemini key; without one, images are left out of syncs.
264+
# KB_IMAGE_EXTRACTION_MODEL=gemini-3.8-flash
265+
# KB_IMAGE_EXTRACTIONS_PER_SYNC=500
266+
# KB_CONFIG_VISION_REQUESTS_PER_MINUTE=300
267+
262268
# Official Sim Search Slack app (optional; requires existing Search access)
263269
# Supply these through the deployment environment (for example, ECS Secrets Manager injection).
264270
# SLACK_SEARCH_APP_ID=

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ export function Document({
402402
const DocumentIcon =
403403
ConnectorIcon || getDocumentIcon(documentData?.mimeType ?? '', effectiveDocumentName)
404404
const isCompleted = documentData?.processingStatus === 'completed'
405-
/** Only a decorative image (a logo or icon) completes with nothing indexed. */
405+
/** A completed image with no chunks held nothing searchable, such as a logo or an icon. */
406406
const isImageWithNothingToIndex =
407407
isCompleted && documentData.mimeType.startsWith('image/') && documentData.chunkCount === 0
408408

‎apps/sim/connectors/confluence/attachments.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,9 @@ export async function listConfluenceAttachments(
430430
state.serverFailureStreak = 0
431431
const { page } = listing
432432
for (const attachment of page.results) {
433-
if (attachment.status !== 'current' || !attachmentMimeType(attachment)) continue
434-
if (isImageAttachment(attachment) && (!input.includeImages || isBelowImageFloor(attachment)))
433+
const mimeType = attachmentMimeType(attachment)
434+
if (attachment.status !== 'current' || !mimeType) continue
435+
if (mimeType.startsWith('image/') && (!input.includeImages || isBelowImageFloor(attachment)))
435436
continue
436437
if (!sameParent(attachmentParent(attachment), parent)) {
437438
throw new Error('Confluence returned an attachment belonging to another parent')
@@ -595,7 +596,7 @@ export async function getConfluenceAttachment(
595596
}
596597
}
597598
if (
598-
isImageAttachment(attachment) &&
599+
mimeType.startsWith('image/') &&
599600
!isImageExtractionEnabled(confluenceConnectorMeta.imageExtraction, sourceConfig)
600601
) {
601602
return {

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

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const fixture = vi.hoisted(() => {
2828
images: new Map<string, number>(),
2929
decorative: new Set<string>(),
3030
extractions: [] as string[],
31+
/** Each chunk gets its own one-hot vector, so the shared HNSW graph sees no duplicates. */
32+
nextVector: 0,
3133
}
3234
})
3335
vi.mock('@/lib/uploads/core/setup.server', () => ({
@@ -39,15 +41,20 @@ vi.mock('@/lib/embeddings', async () => ({
3941
...(await import('@/lib/embeddings/client')),
4042
assertKnowledgeEmbeddingCapacity: async () => {},
4143
embedKnowledge: async (texts: string[]) => ({
42-
embeddings: texts.map(() => [1, ...Array<number>(1535).fill(0)]),
44+
embeddings: texts.map(() => {
45+
const vector = Array<number>(1536).fill(0)
46+
vector[fixture.nextVector++ % 512] = 1
47+
return vector
48+
}),
4349
totalTokens: texts.length,
4450
billableTokens: 0,
4551
isBYOK: true,
4652
modelName: 'text-embedding-3-small',
4753
pricingId: 'text-embedding-3-small',
4854
}),
4955
}))
50-
vi.mock('@/lib/knowledge/documents/image-extraction', () => ({
56+
vi.mock('@/lib/knowledge/documents/image-extraction', async (importOriginal) => ({
57+
...(await importOriginal<typeof import('@/lib/knowledge/documents/image-extraction')>()),
5158
extractImageText: async ({ filename }: { filename: string }) => {
5259
fixture.extractions.push(filename)
5360
const externalId = filename.replace(/\.png$/, '')
@@ -117,7 +124,12 @@ import { IMAGES_DEFERRED_NOTICE } from '@/lib/knowledge/connectors/sync-limits'
117124
describe('Confluence image attachments through sync, indexing and reconciliation', () => {
118125
const ids = createKnowledgeAclFixtureIds()
119126
let billing: Awaited<ReturnType<typeof resolveBillingAttribution>>
120-
const priorGeminiKey = env.GEMINI_API_KEY
127+
const priorGeminiKeys = {
128+
GEMINI_API_KEY: env.GEMINI_API_KEY,
129+
GEMINI_API_KEY_1: env.GEMINI_API_KEY_1,
130+
GEMINI_API_KEY_2: env.GEMINI_API_KEY_2,
131+
GEMINI_API_KEY_3: env.GEMINI_API_KEY_3,
132+
}
121133

122134
async function configure(imageAttachments: 'semantic' | 'off' | undefined) {
123135
await db
@@ -135,34 +147,38 @@ describe('Confluence image attachments through sync, indexing and reconciliation
135147
})
136148
.where(eq(knowledgeConnector.id, ids.connectorId))
137149
}
138-
/** Changes the setting the way a user does; the update dispatches the next sync, which runs in-process. */
139-
async function setImageAttachments(imageAttachments: 'semantic' | 'off') {
140-
await updateKnowledgeConnector.execute({
141-
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
142-
input: {
143-
connectorId: ids.connectorId,
144-
updates: {
145-
sourceConfig: { domain: 'fixture.atlassian.net', spaceKey: 'ENG', imageAttachments },
146-
},
147-
},
148-
})
150+
/** Waits for the sync a create or update dispatched in-process to land. */
151+
async function syncFinished(connectorId: string) {
149152
await expect
150153
.poll(
151154
async () => {
152155
const [row] = await db
153156
.select()
154157
.from(knowledgeConnector)
155-
.where(eq(knowledgeConnector.id, ids.connectorId))
158+
.where(eq(knowledgeConnector.id, connectorId))
156159
return {
157160
status: row?.status,
158161
error: row?.lastSyncError,
159162
synced: Boolean(row?.lastSyncAt),
160163
}
161164
},
162-
{ timeout: 30_000 }
165+
{ timeout: 45_000 }
163166
)
164167
.toEqual({ status: 'active', error: null, synced: true })
165168
}
169+
/** Changes the setting the way a user does; the update dispatches the next sync, which runs in-process. */
170+
async function setImageAttachments(imageAttachments: 'semantic' | 'off') {
171+
await updateKnowledgeConnector.execute({
172+
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' },
173+
input: {
174+
connectorId: ids.connectorId,
175+
updates: {
176+
sourceConfig: { domain: 'fixture.atlassian.net', spaceKey: 'ENG', imageAttachments },
177+
},
178+
},
179+
})
180+
await syncFinished(ids.connectorId)
181+
}
166182
async function imageRemovalPending() {
167183
const [row] = await db
168184
.select({ pending: knowledgeConnector.imageRemovalPending })
@@ -221,7 +237,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
221237
await configure('semantic')
222238
})
223239
afterAll(async () => {
224-
Object.assign(env, { GEMINI_API_KEY: priorGeminiKey })
240+
Object.assign(env, priorGeminiKeys)
225241
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
226242
await db.delete(organization).where(eq(organization.id, ids.organizationId))
227243
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
@@ -259,7 +275,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
259275
await sync()
260276
expect((await images()).every((row) => row.contentHash?.endsWith(':2'))).toBe(true)
261277
expect(await lastSyncError()).toBeNull()
262-
})
278+
}, 120_000)
263279

264280
it('does not read unchanged images again on a full resync', async () => {
265281
fixture.images.set('image-a', 1)
@@ -288,7 +304,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
288304
fixture.extractions.length = 0
289305
await setImageAttachments('semantic')
290306
expect(fixture.extractions.sort()).toEqual(['image-a.png', 'image-b.png'])
291-
})
307+
}, 120_000)
292308

293309
it('keeps images off for a connector created before the setting existed', async () => {
294310
fixture.images.set('image-a', 1)
@@ -347,7 +363,9 @@ describe('Confluence image attachments through sync, indexing and reconciliation
347363
.from(knowledgeConnector)
348364
.where(eq(knowledgeConnector.id, created.connector.id))
349365
expect(row?.sourceConfig).toMatchObject({ imageAttachments: saved })
366+
await syncFinished(created.connector.id)
350367
await db.delete(knowledgeConnector).where(eq(knowledgeConnector.id, created.connector.id))
351-
}
368+
},
369+
120_000
352370
)
353371
})

‎apps/sim/lib/knowledge/connectors/image-admission.ts‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { env, envNumber } from '@/lib/core/config/env'
2-
import { resolveGeminiImageKey } from '@/lib/knowledge/documents/image-extraction-gemini'
32
import type { ImageExtractionPolicy } from '@/lib/knowledge/documents/image-extraction-policy'
43
import { getConnectorMeta } from '@/connectors/registry'
54

65
/** Images one sync may send for vision extraction, new or changed; the rest wait for the next sync. */
76
const IMAGE_EXTRACTIONS_PER_SYNC = envNumber(env.KB_IMAGE_EXTRACTIONS_PER_SYNC, 500, {
8-
min: 0,
7+
min: 1,
98
integer: true,
109
})
1110

@@ -14,14 +13,6 @@ export function imageExtractionPolicyFor(connectorType: string | null): ImageExt
1413
return connectorType && getConnectorMeta(connectorType)?.imageExtraction ? 'semantic' : 'ocr'
1514
}
1615

17-
/**
18-
* Whether the workspace can have images read at all: its own Gemini key, or one
19-
* on the deployment. Without either, images are left unlisted rather than failed.
20-
*/
21-
export async function canExtractImages(workspaceId: string | null): Promise<boolean> {
22-
return (await resolveGeminiImageKey(workspaceId)) !== null
23-
}
24-
2516
/**
2617
* The part of a listing checkpoint that counts this sync's image extractions.
2718
* Living on the checkpoint keeps the count across a sync that yields and resumes

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1754,7 +1754,7 @@ async function syncDedicatedMemberContent(input: {
17541754
})
17551755
return {
17561756
complete: pass.complete,
1757-
imageExtractionsAdmitted: pass.checkpoint.imageExtractionsAdmitted ?? 0,
1757+
imageExtractionsAdmitted: pass.checkpoint.imageExtractionsAdmitted,
17581758
}
17591759
}
17601760

‎apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ interface StoredPage {
9494
sourceSeenAt: Date | null
9595
}
9696

97-
/** Images on, as for a new connector, so only the image opt-out test runs its removal. */
97+
/** Images on, as a new connector is created. */
9898
const SOURCE_CONFIG = {
9999
domain: 'fixture.atlassian.net',
100100
spaceKey: 'ENG',

‎apps/sim/lib/knowledge/connectors/sync-content-pass.ts‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { document, knowledgeConnector } from '@sim/db/schema'
3-
import { and, eq, inArray, isNotNull, isNull, lt, type SQL, sql } from 'drizzle-orm'
3+
import { and, eq, gt, inArray, isNotNull, isNull, lt, type SQL, sql } from 'drizzle-orm'
44
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
55
import type { DbOrTx } from '@/lib/db/types'
66
import {
@@ -12,7 +12,10 @@ import {
1212
GOOGLE_COMPANY_PERMISSION_REFRESH_MS,
1313
isGoogleCompanySource,
1414
} from '@/lib/knowledge/connectors/google-company-scheduler'
15-
import { imageExtractionPolicyFor } from '@/lib/knowledge/connectors/image-admission'
15+
import {
16+
type ImageAdmissionState,
17+
imageExtractionPolicyFor,
18+
} from '@/lib/knowledge/connectors/image-admission'
1619
import {
1720
beginListingCheckpoint,
1821
type ListingCheckpoint,
@@ -98,6 +101,14 @@ interface ContentPassInput {
98101
) => Promise<{ permissionsIncomplete: boolean } | undefined>
99102
}
100103

104+
/** The image fields of a listing checkpoint that a page persists alongside its seen stamps. */
105+
function imageCountOf(state: ImageAdmissionState): ImageAdmissionState {
106+
return {
107+
imageExtractionsAdmitted: state.imageExtractionsAdmitted,
108+
imagesDeferred: state.imagesDeferred,
109+
}
110+
}
111+
101112
/** One durable content cycle shared by content-owned and member-visibility connectors. */
102113
export async function runConnectorContentPass(input: ContentPassInput) {
103114
const { matchContentHash } = input.connectorConfig
@@ -143,10 +154,7 @@ export async function runConnectorContentPass(input: ContentPassInput) {
143154
})
144155
}
145156
let hydratedCount = 0
146-
let storedImageCount = {
147-
imageExtractionsAdmitted: checkpoint.imageExtractionsAdmitted,
148-
imagesDeferred: checkpoint.imagesDeferred,
149-
}
157+
let storedImageCount = imageCountOf(checkpoint)
150158
const syncIntervalMinutes = effectiveConnectorSyncIntervalMinutes(
151159
input.documentAccess,
152160
input.connector.syncIntervalMinutes ?? SIM_SEARCH_SYNC_INTERVAL_MINUTES
@@ -267,10 +275,7 @@ export async function runConnectorContentPass(input: ContentPassInput) {
267275
)
268276
)
269277
}
270-
const imageCount = {
271-
imageExtractionsAdmitted: cycle.imageExtractionsAdmitted,
272-
imagesDeferred: cycle.imagesDeferred,
273-
}
278+
const imageCount = imageCountOf(cycle)
274279
if (
275280
imageCount.imageExtractionsAdmitted !== storedImageCount.imageExtractionsAdmitted ||
276281
imageCount.imagesDeferred !== storedImageCount.imagesDeferred
@@ -370,9 +375,11 @@ export async function runConnectorContentPass(input: ContentPassInput) {
370375
},
371376
})
372377
const imagesRemoved = await removeDisabledImages(input)
373-
const reconciliation = checkpoint.complete
374-
? await reconcileCompletedListing(input, checkpoint, withLease, withAclPage)
375-
: { finished: false, notice: null }
378+
/** Images the removal has not reached yet are unlisted, so reconciling now would count them as absent. */
379+
const reconciliation =
380+
checkpoint.complete && imagesRemoved
381+
? await reconcileCompletedListing(input, checkpoint, withLease, withAclPage)
382+
: { finished: false, notice: null }
376383
/** Unverified permissions, an incomplete listing and unrefreshed content are independent holds; an admin needs each, one per line. */
377384
const holdNotice =
378385
[
@@ -405,6 +412,8 @@ export async function removeDisabledImages(
405412
>
406413
): Promise<boolean> {
407414
if (!input.connector.imageRemovalPending) return true
415+
/** Walks by id through `doc_connector_reconciliation_v2_idx`, so each page resumes where the last ended. */
416+
let afterId: string | undefined
408417
while (true) {
409418
if (Date.now() >= input.deadlineAt) return false
410419
await input.lease.beatIfDue()
@@ -416,11 +425,14 @@ export async function removeDisabledImages(
416425
eq(document.connectorId, input.connectorId),
417426
inArray(document.mimeType, [...SEMANTIC_IMAGE_MIME_TYPES]),
418427
eq(document.userExcluded, false),
419-
isNull(document.archivedAt)
428+
isNull(document.archivedAt),
429+
afterId ? gt(document.id, afterId) : undefined
420430
)
421431
)
432+
.orderBy(document.id)
422433
.limit(25)
423434
if (rows.length === 0) break
435+
afterId = rows.at(-1)?.id
424436
const removed = await hardDeleteDocuments(
425437
rows.map((row) => row.id),
426438
input.runId,

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,6 @@ export interface ContentPassOutcome {
343343
contentFailures?: boolean
344344
permissionFailures?: boolean
345345
imagesDeferred?: boolean
346-
imageExtractionsAdmitted?: number
347346
listingFailures?: ExternalListingFailures | null
348347
startedAt: string
349348
listedCount: number

0 commit comments

Comments
 (0)