fix(KB): surface file-warning compute failures instead of masking as healthy (PR #895 review)

`computeFileWarnings()` previously caught all errors and returned an empty
map, which the frontend rendered as "every file is healthy" — reintroducing
exactly the silent-failure mode this surface exists to expose.

Return `{ ok, warnings }`; flip `ok: false` from the catch. KB modal renders
an inline amber notice under the Stored Files header when `ok === false`,
leaving per-row warning rendering untouched. Transient failures self-heal on
the next 30s poll; no toast spam.
This commit is contained in:
Jake Turner 2026-05-17 04:23:28 +00:00 committed by Jake Turner
parent cbd86b7af9
commit cbae48a3c8
5 changed files with 44 additions and 21 deletions

View File

@ -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) {

View File

@ -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<Record<string, FileWarning[]>> {
public async computeFileWarnings(): Promise<FileWarningsResult> {
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: {} }
}
}

View File

@ -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
</div>
</div>
<StyledTable<KbFileGroup>
{warningsUnavailable && (
<div className="mb-4 inline-flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-3 py-2">
<span aria-hidden="true"></span>
<span>
File warnings unavailable couldn't read storage state. Retrying
</span>
</div>
)}
<StyledTable<{ source: string }>
className="font-semibold"
rowLines={true}
columns={[

View File

@ -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<string, FileWarning[]> }>(
'/rag/file-warnings'
)
return response.data.warnings
const response = await this.client.get<FileWarningsResult>('/rag/file-warnings')
return response.data
})()
}

View File

@ -44,4 +44,16 @@ export type RerankedRAGResult = Omit<RAGResult, 'keywords'> & {
export type FileWarning =
| { kind: 'zero_chunks'; fileSizeBytes: number }
| { kind: 'partial_stall'; chunksEmbedded: number; chunksExpected: number }
| { 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<string, FileWarning[]>
}