diff --git a/admin/app/jobs/embed_file_job.ts b/admin/app/jobs/embed_file_job.ts index 8da3710..4607a9a 100644 --- a/admin/app/jobs/embed_file_job.ts +++ b/admin/app/jobs/embed_file_job.ts @@ -4,6 +4,7 @@ import { EmbedJobWithProgress } from '../../types/rag.js' import { RagService } from '#services/rag_service' import { DockerService } from '#services/docker_service' import { OllamaService } from '#services/ollama_service' +import KbIngestState from '#models/kb_ingest_state' import { createHash } from 'crypto' import logger from '@adonisjs/core/services/logger' import fs from 'node:fs/promises' @@ -193,6 +194,18 @@ export class EmbedFileJob { chunks: totalChunks, }) + // Persist the post-job state so scanAndSyncStorage knows this file is done. + // BullMQ's :completed retention (50 jobs) ages out, so the state row is + // the only durable record of "this file finished embedding". + try { + await KbIngestState.markIndexed(filePath, totalChunks) + } catch (stateErr) { + logger.warn( + `[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`, + stateErr instanceof Error ? stateErr.message : String(stateErr) + ) + } + const batchMsg = isZimBatch ? ` (final batch, total chunks: ${totalChunks})` : '' logger.info( `[EmbedFileJob] Successfully embedded ${result.chunks} chunks from file: ${fileName}${batchMsg}` @@ -215,6 +228,23 @@ export class EmbedFileJob { error: error instanceof Error ? error.message : 'Unknown error', }) + // Only persist `failed` for unrecoverable errors. Retryable errors get + // automatic BullMQ retries (30 attempts); marking state failed on every + // transient blip would suppress the retry-driven recovery path. + if (error instanceof UnrecoverableError) { + try { + await KbIngestState.markFailed( + filePath, + error instanceof Error ? error.message : 'Unknown error' + ) + } catch (stateErr) { + logger.warn( + `[EmbedFileJob] Failed to persist failed state for ${fileName}: %s`, + stateErr instanceof Error ? stateErr.message : String(stateErr) + ) + } + } + throw error } } diff --git a/admin/app/models/kb_ingest_state.ts b/admin/app/models/kb_ingest_state.ts new file mode 100644 index 0000000..2add407 --- /dev/null +++ b/admin/app/models/kb_ingest_state.ts @@ -0,0 +1,77 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' +import type { KbIngestStateValue } from '../../types/kb_ingest_state.js' + +const LAST_ERROR_MAX_LEN = 1024 + +/** + * Tracks the per-file decision and outcome of AI knowledge-base ingestion. + * + * The row exists for any embeddable file the scanner has seen and is independent + * of `installed_resources` (which only covers curated downloads). Replaces the + * earlier "any chunks in qdrant ⇒ embedded" binary check, which conflated + * partially-stalled ingestions with fully-indexed files. See RFC #883. + */ +export default class KbIngestState extends BaseModel { + static table = 'kb_ingest_state' + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare file_path: string + + @column() + declare state: KbIngestStateValue + + @column() + declare chunks_embedded: number + + @column() + declare last_error: string | null + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime + + static async getOrCreate(filePath: string): Promise { + return this.firstOrCreate( + { file_path: filePath }, + { file_path: filePath, state: 'pending_decision', chunks_embedded: 0 } + ) + } + + static async markIndexed(filePath: string, chunksEmbedded: number): Promise { + const row = await this.getOrCreate(filePath) + row.state = 'indexed' + row.chunks_embedded = chunksEmbedded + row.last_error = null + await row.save() + } + + static async markFailed(filePath: string, errorMessage: string): Promise { + const row = await this.getOrCreate(filePath) + row.state = 'failed' + row.last_error = errorMessage.slice(0, LAST_ERROR_MAX_LEN) + await row.save() + } + + static async markBrowseOnly(filePath: string): Promise { + const row = await this.getOrCreate(filePath) + row.state = 'browse_only' + await row.save() + } + + static async markStalled(filePath: string): Promise { + const row = await this.getOrCreate(filePath) + row.state = 'stalled' + await row.save() + } + + static async remove(filePath: string): Promise { + await this.query().where('file_path', filePath).delete() + } +} diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index ad0e118..a18b461 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -16,6 +16,8 @@ import { removeStopwords } from 'stopword' import { randomUUID } from 'node:crypto' import { join, resolve, sep } from 'node:path' import KVStore from '#models/kv_store' +import KbIngestState from '#models/kb_ingest_state' +import { decideScanAction } from '../utils/kb_ingest_decision.js' import { ZIMExtractionService } from './zim_extraction_service.js' import { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js' import { ProcessAndEmbedFileResponse, ProcessZIMFileResponse, RAGResult, RerankedRAGResult } from '../../types/rag.js' @@ -1118,6 +1120,11 @@ export class RagService { logger.warn(`[RAG] File was removed from knowledge base but doesn't live in Nomad's uploads directory, so it can't be safely removed. Skipping deletion of physical file...`) } + // Drop the ingest state row last so the file disappears entirely. Without + // this, the next scanAndSyncStorage would see `indexed + no chunks` for a + // path that no longer exists in storage and try to re-embed nothing. + await KbIngestState.remove(source) + return { success: true, message: 'File removed from knowledge base.' } } catch (error) { logger.error('[RAG] Error deleting file from knowledge base:', error) @@ -1330,8 +1337,64 @@ export class RagService { offset = scrollResult.next_page_offset || null } while (offset !== null) - const filesToEmbed = filesInStorage.filter((f) => !sourcesInQdrant.has(f)) - logger.info(`[RAG] ${filesToEmbed.length} of ${filesInStorage.length} files need embedding`) + logger.info(`[RAG] Found ${sourcesInQdrant.size} unique sources in Qdrant`) + + // Load all known per-file ingest states. The state row is authoritative + // over the "any chunks in Qdrant" heuristic — it captures user choices + // (browse_only) and terminal outcomes (failed, stalled) that aren't visible + // from Qdrant alone. See RFC #883 for the full state machine. + const stateRows = await KbIngestState.all() + const stateByPath = new Map(stateRows.map((row) => [row.file_path, row])) + + // Non-embeddable files (e.g. kiwix-library.xml in /storage/zim) would otherwise + // be dispatched to EmbedFileJob, fail with "Unsupported file type", and retry + // on every sync — filter them out before state decisions. + const embeddableFiles = filesInStorage.filter( + (filePath) => determineFileType(filePath) !== 'unknown' + ) + + const filesToEmbed: string[] = [] + let backfilled = 0 + let createdRows = 0 + let skipped = 0 + + for (const filePath of embeddableFiles) { + const stateRow = stateByPath.get(filePath) ?? null + const action = decideScanAction(stateRow, sourcesInQdrant.has(filePath)) + + switch (action.kind) { + case 'skip': + skipped++ + break + case 'backfill_indexed': + // Pre-RFC install (or a fresh admin pointed at an existing Qdrant volume): + // chunks already exist with no state row, so trust Qdrant and record + // `indexed` without re-embedding. chunks_embedded is left 0 because + // we don't count points-per-source during the scroll above. + await KbIngestState.create({ + file_path: filePath, + state: 'indexed', + chunks_embedded: 0, + }) + backfilled++ + break + case 'dispatch': + if (action.createStateRow) { + await KbIngestState.create({ + file_path: filePath, + state: 'pending_decision', + chunks_embedded: 0, + }) + createdRows++ + } + filesToEmbed.push(filePath) + break + } + } + + logger.info( + `[RAG] Scan results: ${filesToEmbed.length} to embed, ${backfilled} backfilled, ${createdRows} new pending, ${skipped} skipped` + ) if (filesToEmbed.length === 0) { return { diff --git a/admin/app/utils/kb_ingest_decision.ts b/admin/app/utils/kb_ingest_decision.ts new file mode 100644 index 0000000..f9417c3 --- /dev/null +++ b/admin/app/utils/kb_ingest_decision.ts @@ -0,0 +1,53 @@ +import type { KbIngestStateValue } from '../../types/kb_ingest_state.js' + +/** + * Decision returned by `decideScanAction` describing what scanAndSyncStorage + * should do for one file given its current state row (if any) and whether + * Qdrant already has chunks for it. + * + * - `skip` — file is in a settled state (already indexed, deliberately not + * indexed, or in a manual-recovery state); no auto-dispatch. + * - `dispatch` — file needs to be (re-)embedded; an EmbedFileJob should be + * dispatched. `createStateRow` indicates whether a new state row needs to + * be created before dispatch (i.e. first time the scanner has seen it). + * - `backfill_indexed` — Qdrant has chunks but no state row exists yet + * (pre-RFC install, or new admin instance pointed at an existing Qdrant + * volume). Create a row in `indexed` state without re-embedding. + */ +export type ScanAction = + | { kind: 'skip' } + | { kind: 'dispatch'; createStateRow: boolean } + | { kind: 'backfill_indexed' } + +export interface KbIngestStateRow { + state: KbIngestStateValue +} + +/** + * Decide what scanAndSyncStorage should do for a single embeddable file. + * + * Replaces the earlier `!sourcesInQdrant.has(filePath)` binary check, which + * couldn't tell a fully-indexed file from a stalled mid-batch ingestion, and + * couldn't honor a user's "browse only" choice. The state row is now the + * authoritative answer; Qdrant chunk presence is corroborating evidence. + */ +export function decideScanAction( + stateRow: KbIngestStateRow | null, + hasChunksInQdrant: boolean +): ScanAction { + if (!stateRow) { + if (hasChunksInQdrant) return { kind: 'backfill_indexed' } + return { kind: 'dispatch', createStateRow: true } + } + + switch (stateRow.state) { + case 'indexed': + return hasChunksInQdrant ? { kind: 'skip' } : { kind: 'dispatch', createStateRow: false } + case 'pending_decision': + return { kind: 'dispatch', createStateRow: false } + case 'browse_only': + case 'failed': + case 'stalled': + return { kind: 'skip' } + } +} diff --git a/admin/database/migrations/1776000000001_create_kb_ingest_state_table.ts b/admin/database/migrations/1776000000001_create_kb_ingest_state_table.ts new file mode 100644 index 0000000..18e8ab4 --- /dev/null +++ b/admin/database/migrations/1776000000001_create_kb_ingest_state_table.ts @@ -0,0 +1,26 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'kb_ingest_state' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + // utf8mb4 caps an indexed varchar at 768 chars (3072 byte InnoDB key limit); + // 512 leaves headroom and is plenty for any NOMAD-managed file path. + table.string('file_path', 512).notNullable().unique() + table + .enum('state', ['pending_decision', 'indexed', 'browse_only', 'failed', 'stalled']) + .notNullable() + .defaultTo('pending_decision') + table.integer('chunks_embedded').notNullable().defaultTo(0) + table.text('last_error').nullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/admin/tests/unit/kb_ingest_decision.spec.ts b/admin/tests/unit/kb_ingest_decision.spec.ts new file mode 100644 index 0000000..1f9336d --- /dev/null +++ b/admin/tests/unit/kb_ingest_decision.spec.ts @@ -0,0 +1,46 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { decideScanAction } from '../../app/utils/kb_ingest_decision.js' + +test('no state row, no chunks → dispatch and create row (new file)', () => { + assert.deepEqual(decideScanAction(null, false), { kind: 'dispatch', createStateRow: true }) +}) + +test('no state row, chunks present → backfill_indexed (pre-RFC install, existing Qdrant volume)', () => { + assert.deepEqual(decideScanAction(null, true), { kind: 'backfill_indexed' }) +}) + +test('indexed + chunks present → skip', () => { + assert.deepEqual(decideScanAction({ state: 'indexed' }, true), { kind: 'skip' }) +}) + +test('indexed + chunks missing → re-dispatch (state stale, Qdrant collection rebuilt or chunks deleted)', () => { + assert.deepEqual(decideScanAction({ state: 'indexed' }, false), { + kind: 'dispatch', + createStateRow: false, + }) +}) + +test('pending_decision → dispatch (preserves current Always behavior until policy is consumed)', () => { + assert.deepEqual(decideScanAction({ state: 'pending_decision' }, false), { + kind: 'dispatch', + createStateRow: false, + }) +}) + +test('browse_only → skip (user opted out of indexing)', () => { + assert.deepEqual(decideScanAction({ state: 'browse_only' }, false), { kind: 'skip' }) +}) + +test('browse_only + chunks present → skip (do not silently re-index after un-index)', () => { + assert.deepEqual(decideScanAction({ state: 'browse_only' }, true), { kind: 'skip' }) +}) + +test('failed → skip (manual retry needed, do not auto-redispatch)', () => { + assert.deepEqual(decideScanAction({ state: 'failed' }, false), { kind: 'skip' }) +}) + +test('stalled → skip (manual retry needed)', () => { + assert.deepEqual(decideScanAction({ state: 'stalled' }, false), { kind: 'skip' }) +}) diff --git a/admin/types/kb_ingest_state.ts b/admin/types/kb_ingest_state.ts new file mode 100644 index 0000000..c59d006 --- /dev/null +++ b/admin/types/kb_ingest_state.ts @@ -0,0 +1,9 @@ +export const KB_INGEST_STATES = [ + 'pending_decision', + 'indexed', + 'browse_only', + 'failed', + 'stalled', +] as const + +export type KbIngestStateValue = (typeof KB_INGEST_STATES)[number] diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index 381b367..e41f910 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -3,6 +3,7 @@ export const KV_STORE_SCHEMA = { 'chat.suggestionsEnabled': 'boolean', 'chat.lastModel': 'string', 'rag.docsEmbedded': 'boolean', + 'rag.defaultIngestPolicy': 'string', 'system.updateAvailable': 'boolean', 'system.latestVersion': 'string', 'system.earlyAccess': 'boolean',