diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 01479f4..de84e11 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -69,8 +69,8 @@ export default class RagController { } public async getFileWarnings({ response }: HttpContext) { - const warnings = await this.ragService.computeFileWarnings() - return response.status(200).json({ warnings }) + const result = await this.ragService.computeFileWarnings() + return response.status(200).json(result) } public async deleteFile({ request, response }: HttpContext) { diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 91f1658..7c8f943 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -19,7 +19,8 @@ import KVStore from '#models/kv_store' import KbIngestState from '#models/kb_ingest_state' import { decideScanAction, type IngestPolicy } from '../utils/kb_ingest_decision.js' import KbRatioRegistry from '#models/kb_ratio_registry' -import { decideWarnings, type FileWarning } from '../utils/kb_warning_decision.js' +import { decideWarnings } from '../utils/kb_warning_decision.js' +import type { FileWarning, FileWarningsResult } from '../../types/rag.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' @@ -1090,16 +1091,18 @@ export class RagService { /** * Compute conditional warnings (RFC #883 §6) for every source the scanner - * sees on disk. Returns a map from source path → list of warnings, with - * sources that have no warnings omitted entirely (so the frontend can - * `warningsBySource[source] ?? []` for clean defaults). + * sees on disk. Returns `{ ok, warnings }` — `ok: false` distinguishes a + * computation failure (Qdrant unreachable, DB outage, FS error) from the + * healthy-but-empty case, which is critical because the whole point of this + * surface is to expose silent failures; reporting "everything healthy" when + * we couldn't actually check would reintroduce the bug we set out to fix. * * Per-source chunk counts come from a single Qdrant scroll over the * collection's points; expected-chunk estimates come from the ratio * registry. Files in the scanner's directories that have no qdrant points * at all show up with `chunksInQdrant: 0` so Warning A can fire. */ - public async computeFileWarnings(): Promise> { + public async computeFileWarnings(): Promise { try { await this._ensureCollection( RagService.CONTENT_COLLECTION_NAME, @@ -1165,10 +1168,10 @@ export class RagService { if (warnings.length > 0) out[source] = warnings } - return out + return { ok: true, warnings: out } } catch (error) { logger.error('[RAG] Error computing file warnings:', error) - return {} + return { ok: false, warnings: {} } } } diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 9311718..0667d88 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -51,15 +51,17 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o select: (data) => data || [], }) - // Per-file conditional warnings (RFC #883 §6). Only sources with at least - // one triggered warning are returned, so an empty map means everything is - // healthy. Polled at the same idle cadence as health for low overhead. - const { data: fileWarnings = {} } = useQuery({ + // Per-file conditional warnings (RFC #883 §6). `ok: false` means the + // computation itself failed (Qdrant/DB/FS) — distinct from `ok: true` with + // an empty map, which means everything is healthy. We surface the failure + // explicitly so a silent backend failure doesn't masquerade as health. + const { data: warningsResult } = useQuery({ queryKey: ['kbFileWarnings'], queryFn: () => api.getKbFileWarnings(), - select: (data) => data ?? {}, refetchInterval: 30_000, }) + const fileWarnings = warningsResult?.warnings ?? {} + const warningsUnavailable = warningsResult !== undefined && warningsResult.ok === false // Global auto-index policy. KVStore returns `null` for an unset key, which // we treat as 'Always' for backward compatibility with installs that predate @@ -444,7 +446,15 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o - + {warningsUnavailable && ( +
+ + + File warnings unavailable — couldn't read storage state. Retrying… + +
+ )} + className="font-semibold" rowLines={true} columns={[ diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 950f593..f16f730 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -5,7 +5,7 @@ import { FileEntry } from '../../types/files' import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads' import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps' -import { EmbedJobWithProgress, FileWarning } from '../../types/rag' +import { EmbedJobWithProgress, FileWarningsResult } from '../../types/rag' import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections' import { catchInternal } from './util' import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama' @@ -477,10 +477,8 @@ class API { async getKbFileWarnings() { return catchInternal(async () => { - const response = await this.client.get<{ warnings: Record }>( - '/rag/file-warnings' - ) - return response.data.warnings + const response = await this.client.get('/rag/file-warnings') + return response.data })() } diff --git a/admin/types/rag.ts b/admin/types/rag.ts index 8aaa0f6..aa14127 100644 --- a/admin/types/rag.ts +++ b/admin/types/rag.ts @@ -44,4 +44,16 @@ export type RerankedRAGResult = Omit & { export type FileWarning = | { kind: 'zero_chunks'; fileSizeBytes: number } - | { kind: 'partial_stall'; chunksEmbedded: number; chunksExpected: number } \ No newline at end of file + | { kind: 'partial_stall'; chunksEmbedded: number; chunksExpected: number } + +/** + * Result of computing per-file warnings. `ok: false` means the computation + * itself failed (Qdrant unreachable, DB outage, FS read error) — distinct from + * `ok: true` with an empty map, which means every scanned file is healthy. + * The frontend should surface a neutral "warnings unavailable" indicator on + * `!ok` rather than implying everything is fine. + */ +export type FileWarningsResult = { + ok: boolean + warnings: Record +} \ No newline at end of file