Skip to content

Commit 204453a

Browse files
authored
fix(knowledge): reject empty document extraction (#19177)
Signed-off-by: userInner <1239989762@qq.com>
1 parent a51e7e7 commit 204453a

2 files changed

Lines changed: 42 additions & 26 deletions

File tree

src/main/features/knowledge/tasks/__tests__/indexDocumentsJobHandler.test.ts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -326,22 +326,38 @@ describe('index-documents job handler', () => {
326326
).toHaveLength(0)
327327
})
328328

329-
it('warns when an item yields no indexable text, and still completes it with an empty material', async () => {
329+
it('rejects a file when its reader yields no indexable text', async () => {
330330
const handler = createIndexDocumentsJobHandler(knowledgeLockManager as never)
331-
knowledgeItemGetByIdMock.mockReturnValue(createNoteItem(NOTE_ITEM_ID))
332-
knowledgeItemUpdateStatusMock.mockReturnValue(createNoteItem(NOTE_ITEM_ID))
331+
knowledgeItemGetByIdMock.mockReturnValue(createFileItem(FILE_ITEM_ID))
333332
loadKnowledgeItemDocumentsMock.mockResolvedValueOnce([])
333+
const failureReason =
334+
'No indexable text was extracted. Check the source, OCR, or document processing settings, then reindex.'
334335

335-
await handler.execute(createCtx({ baseId: 'kb-1', itemId: NOTE_ITEM_ID }))
336+
await expect(handler.execute(createCtx({ baseId: 'kb-1', itemId: FILE_ITEM_ID }))).rejects.toThrow(failureReason)
337+
338+
// JobManager forwards the thrown reason to onSettled; verify that the existing
339+
// settlement seam makes the row visibly failed (and therefore reindexable).
340+
await handler.onSettled?.({
341+
jobId: 'index-job',
342+
type: 'knowledge.index-documents',
343+
scheduleId: null,
344+
parentId: null,
345+
status: 'failed',
346+
input: { baseId: 'kb-1', itemId: FILE_ITEM_ID },
347+
error: { code: 'FAILED', message: failureReason, retryable: false },
348+
attempt: 1,
349+
metadata: {}
350+
})
336351

337-
// An image-only PDF or failed extraction must leave a diagnosable trace —
338-
// without the warn it would look indexed while matching nothing.
352+
expect(embedKnowledgeTextsMock).not.toHaveBeenCalled()
353+
expect(rebuildMaterialMock).not.toHaveBeenCalled()
354+
expect(knowledgeItemUpdateStatusMock).not.toHaveBeenCalledWith(FILE_ITEM_ID, 'embedding')
355+
expect(knowledgeItemUpdateStatusMock).not.toHaveBeenCalledWith(FILE_ITEM_ID, 'completed')
356+
expect(knowledgeItemUpdateStatusMock).toHaveBeenCalledWith(FILE_ITEM_ID, 'failed', { error: failureReason })
339357
expect(loggerWarnMock).toHaveBeenCalledWith(
340-
'Knowledge item produced no indexable text; it will complete with an empty index',
341-
expect.objectContaining({ baseId: 'kb-1', itemId: NOTE_ITEM_ID })
358+
'Knowledge item produced no indexable text; failing indexing',
359+
expect.objectContaining({ baseId: 'kb-1', itemId: FILE_ITEM_ID })
342360
)
343-
expect(lastRebuildInput().units).toEqual([])
344-
expect(knowledgeItemUpdateStatusMock).toHaveBeenCalledWith(NOTE_ITEM_ID, 'completed')
345361
})
346362

347363
it('uses the processed-artifact path (indexedRelativePath) as the material relative path', async () => {
@@ -365,21 +381,17 @@ describe('index-documents job handler', () => {
365381
expect(loadKnowledgeItemDocumentsMock).toHaveBeenCalledWith(expect.objectContaining({ id: FILE_ITEM_ID }))
366382
})
367383

368-
it('completes with empty vectors when the reader returns no documents', async () => {
384+
it('rejects whitespace-only reader output before embedding or material replacement', async () => {
369385
const handler = createIndexDocumentsJobHandler(knowledgeLockManager as never)
370-
knowledgeItemGetByIdMock.mockReturnValue(createNoteItem(NOTE_ITEM_ID))
371-
knowledgeItemUpdateStatusMock.mockReturnValue(createNoteItem(NOTE_ITEM_ID))
372-
loadKnowledgeItemDocumentsMock.mockResolvedValueOnce([])
373-
374-
await handler.execute(createCtx({ baseId: 'kb-1', itemId: NOTE_ITEM_ID }))
386+
knowledgeItemGetByIdMock.mockReturnValue(createFileItem(FILE_ITEM_ID))
387+
loadKnowledgeItemDocumentsMock.mockResolvedValueOnce([{ text: ' \n\t ', metadata: { source: FILE_ITEM_ID } }])
375388

376-
expect(knowledgeItemUpdateStatusMock).toHaveBeenCalledWith(NOTE_ITEM_ID, 'reading')
377-
expect(knowledgeItemUpdateStatusMock).toHaveBeenCalledWith(NOTE_ITEM_ID, 'embedding')
378-
expect(rebuildMaterialMock).toHaveBeenCalledWith(
379-
NOTE_ITEM_ID,
380-
expect.objectContaining({ content: expect.objectContaining({ text: '' }), units: [], embeddings: [] })
389+
await expect(handler.execute(createCtx({ baseId: 'kb-1', itemId: FILE_ITEM_ID }))).rejects.toThrow(
390+
'No indexable text was extracted'
381391
)
382-
expect(knowledgeItemUpdateStatusMock).toHaveBeenCalledWith(NOTE_ITEM_ID, 'completed')
392+
393+
expect(embedKnowledgeTextsMock).not.toHaveBeenCalled()
394+
expect(rebuildMaterialMock).not.toHaveBeenCalled()
383395
})
384396

385397
it('skips vector write when the item becomes deleting inside the mutation lock', async () => {

src/main/features/knowledge/tasks/indexDocumentsJobHandler.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const logger = loggerService.withContext('Knowledge:IndexDocumentsJobHandler')
3333
// Chunks per embedMany call while rebuilding an item's material. Small enough to
3434
// surface incremental progress, large enough to not multiply request overhead.
3535
const EMBEDDING_PROGRESS_BATCH_SIZE = 10
36+
const EMPTY_INDEXABLE_TEXT_ERROR =
37+
'No indexable text was extracted. Check the source, OCR, or document processing settings, then reindex.'
38+
3639
/**
3740
* How long the final percentage lingers after the job exits. The list's item status
3841
* is polled, so deleting the key at completion time blanks the percentage while the
@@ -94,14 +97,15 @@ export function createIndexDocumentsJobHandler(
9497
const documents = await readItemDocuments(ctx, readableItem)
9598
const chunked = await chunkItemDocuments(base, documents, ctx.signal)
9699
if (chunked.chunks.length === 0) {
97-
// Deliberate: the item still completes (an empty material is written) so the
98-
// UI doesn't show a stuck/failed item, but leave a trace — an image-only PDF
99-
// or failed extraction would otherwise look indexed while matching nothing.
100-
logger.warn('Knowledge item produced no indexable text; it will complete with an empty index', {
100+
// Completing here would make a scanned/image-only PDF look searchable while
101+
// persisting an empty material. Reject before embedding or replacement so the
102+
// normal settlement path exposes a failed, reindexable item with this reason.
103+
logger.warn('Knowledge item produced no indexable text; failing indexing', {
101104
baseId: ctx.input.baseId,
102105
itemId: ctx.input.itemId,
103106
jobId: ctx.jobId
104107
})
108+
throw new Error(EMPTY_INDEXABLE_TEXT_ERROR)
105109
}
106110

107111
// Mark embedding separately so the UI reflects the current long-running phase.

0 commit comments

Comments
 (0)