feat(KB): per-file ingest action + state indicator on Stored Files (RFC #883 §5)

Closes the Manual-mode UX dead-end: after toggling 'Auto-index new content
for AI?' to Manual, a freshly-downloaded ZIM (or any pending_decision file)
had no UI path to opt in for embedding short of the global Sync Storage /
Re-embed All bulk actions. Per RFC #883 §5, each Stored Files row now
carries a state pill and an adaptive single-button action.

State pill (left of any existing warning chips):
  - 'Indexed'    — green; row had chunks in Qdrant or state row is 'indexed'
  - 'Not Indexed' — neutral; state is pending_decision or browse_only
  - 'Failed'     — red
  - 'Stalled'    — amber
  - admin_docs collapsed row has no pill ('Managed by NOMAD' carries it)

Adaptive action button (paired with the existing Delete button per row):
  - pending_decision         → 'Index' (force=false)
  - browse_only              → 'Index' (force=true)
  - failed / stalled         → 'Retry' (force=true)
  - indexed + warning chip   → 'Re-embed' (force=true; confirm modal first)
  - indexed healthy / null   → no action button (bulk Re-embed All covers it)

Backend: GET /api/rag/files now returns
  { files: Array<{ source, state, chunksEmbedded }> }
instead of a flat string[]. State + chunk-count come from a single
KbIngestState query unioned into the existing Qdrant-derived source list
(no new round trips). New POST /api/rag/files/embed validates the source is
known, refuses if any inflight job already targets the same filePath
(prevents double-click duplicate-chunk hazard), pre-deletes Qdrant points
when force=true, then dispatches via the existing _dispatchEmbedJobsFor
helper used by reembedAll.

Per-file Re-embed (force=true on an already-indexed file) routes through a
StyledModal confirmation since it deletes existing vectors before queueing
a fresh job — same destructive-action weight as Delete's inline confirm but
heavier since it affects search until the rebuild finishes.

Folds in PR #907's blank-screen fix because my new render needs the same
generic restored: `<StyledTable<KbFileGroup>>` and `record.displayName`
(instead of the unresolved `sourceToDisplayName(record.source)` that ships
in rc.5 and ReferenceErrors on modal open). PR #907 also adds title
tooltips on the three bulk-action buttons; those tooltips are NOT included
here — let PR #907 land first or independently for that part.

Multi-select bulk-opt-in deferred per discussion: most Manual-mode users
ingest 1-2 files at a time, the existing global toggle covers the bulk
case, and checkboxes would expand scope past what rc.6 should hold. Will
file a follow-up issue for an 'Index N pending files' single-click button
once this lands.

Tests-in-PR scope was limited to keeping `kb_file_grouping.spec.ts` green
after the StoredFileInfo[] signature change (added asInfos() wrapper).
Dedicated unit tests for embedSingleFile (unknown source / inflight refused
/ force=true delete-then-dispatch) and the new state-pill rendering will
land in a follow-up PR alongside Playwright coverage of the row actions.

Verification path: NOMAD3 currently runs project-nomad-admin:integration-
rc6-preview (PRs #907 + #908 atop rc.5). After this branch is built into a
new integration tag, I'll re-run targeted Playwright UAT on the KB modal
covering: state pill rendering per state, Index click on pending_decision
opts in cleanly, Retry on failed re-dispatches successfully, Re-embed
confirmation modal copy + delete-then-dispatch on the military-medicine
partial-stall row, and Delete flow untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-05-17 21:42:54 -07:00 committed by Jake Turner
parent 0617d54762
commit d850cb9588
9 changed files with 341 additions and 54 deletions

View File

@ -7,7 +7,7 @@ import app from '@adonisjs/core/services/app'
import { randomBytes } from 'node:crypto'
import { sanitizeFilename } from '../utils/fs.js'
import { basename } from 'node:path'
import { deleteFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
@inject()
@ -82,6 +82,15 @@ export default class RagController {
return response.status(200).json({ message: result.message })
}
public async embedFile({ request, response }: HttpContext) {
const { source, force } = await request.validateUsing(embedFileSchema)
const result = await this.ragService.embedSingleFile(source, force ?? false)
if (!result.success) {
return response.status(409).json({ error: result.message })
}
return response.status(202).json({ message: result.message })
}
public async getFailedJobs({ response }: HttpContext) {
const jobs = await EmbedFileJob.listFailedJobs()
return response.status(200).json(jobs)

View File

@ -20,7 +20,8 @@ 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 } from '../utils/kb_warning_decision.js'
import type { FileWarning, FileWarningsResult } from '../../types/rag.js'
import type { FileWarning, FileWarningsResult, StoredFileInfo } from '../../types/rag.js'
import type { KbIngestStateValue } from '../../types/kb_ingest_state.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'
@ -1051,7 +1052,7 @@ export class RagService {
}
}
public async getStoredFiles(): Promise<string[]> {
public async getStoredFiles(): Promise<StoredFileInfo[]> {
try {
await this._ensureCollection(
RagService.CONTENT_COLLECTION_NAME,
@ -1090,10 +1091,15 @@ export class RagService {
// in particular) have no row to attach to. The state machine is the
// authoritative "what's on disk?" view; Qdrant is "what made it into
// the vector store?". Both are needed to render the KB UI honestly.
const stateByPath = new Map<string, { state: KbIngestStateValue; chunks_embedded: number }>()
try {
const stateRows = await KbIngestState.query().select('file_path')
const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded')
for (const row of stateRows) {
sources.add(row.file_path)
stateByPath.set(row.file_path, {
state: row.state,
chunks_embedded: row.chunks_embedded,
})
}
} catch (error) {
// Non-fatal: if the state machine query fails for any reason we'd
@ -1104,7 +1110,14 @@ export class RagService {
)
}
return Array.from(sources)
return Array.from(sources).map((source) => {
const row = stateByPath.get(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
}
})
} catch (error) {
logger.error('Error retrieving stored files:', error)
return []
@ -1401,6 +1414,70 @@ export class RagService {
return { queuedCount, dedupedCount, failedPaths }
}
/**
* Dispatch an embed job for a single stored file. Wraps `_dispatchEmbedJobsFor`
* with the safety checks needed for a user-triggered per-row action:
* 1. The source must be known to the scanner OR have a state row prevents
* arbitrary path dispatch from the public API.
* 2. We refuse if any inflight job (waiting/active/delayed/paused) already
* targets this filePath. Otherwise a double-click or a rapid retry could
* enqueue duplicate jobs, producing duplicate chunks.
* 3. When `force` is true (Re-embed of an already-indexed file), we
* pre-delete the prior Qdrant points so the new run doesn't stack on
* top of the old ones. For force=false (Index of a never-embedded file),
* there's nothing to clear.
*/
public async embedSingleFile(
source: string,
force: boolean = false
): Promise<{ success: boolean; message: string }> {
const stateRow = await KbIngestState.query().where('file_path', source).first()
if (!stateRow) {
const knownFiles = await this._discoverKbFiles()
if (!knownFiles.includes(source)) {
return {
success: false,
message: 'File is not a tracked knowledge-base source.',
}
}
}
const { EmbedFileJob } = await import('#jobs/embed_file_job')
const { QueueService } = await import('#services/queue_service')
const queue = QueueService.getInstance().getQueue(EmbedFileJob.queue)
const inflight = await queue.getJobs(['waiting', 'active', 'delayed', 'paused'])
if (inflight.some((j) => j.data?.filePath === source)) {
return {
success: false,
message: 'A job for this file is already in progress. Wait for it to finish before re-queuing.',
}
}
if (force) {
try {
await this._deletePointsBySource(source)
} catch (err) {
logger.error(`[RAG] Failed to delete prior points for ${source}; aborting re-embed:`, err)
return {
success: false,
message: 'Failed to clear prior embeddings before re-embed.',
}
}
}
const result = await this._dispatchEmbedJobsFor([source], { force })
if (result.failedPaths.length > 0) {
return {
success: false,
message: 'Failed to dispatch embed job for this file.',
}
}
return {
success: true,
message: force ? 'Re-embed queued for this file.' : 'Indexing queued for this file.',
}
}
/**
* Delete all Qdrant points whose `source` payload matches the given path.
* Unlike deleteFileBySource(), this does NOT touch the file on disk used

View File

@ -12,6 +12,13 @@ export const deleteFileSchema = vine.compile(
})
)
export const embedFileSchema = vine.compile(
vine.object({
source: vine.string().minLength(1),
force: vine.boolean().optional(),
})
)
export const estimateBatchSchema = vine.compile(
vine.object({
files: vine

View File

@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import type { DynamicIconName } from '~/lib/icons'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import StyledTable from '~/components/StyledTable'
import { useNotifications } from '~/context/NotificationContext'
@ -10,6 +11,7 @@ import {
groupAndSortKbFiles,
type KbFileGroup,
} from '~/lib/kb_file_grouping'
import type { KbIngestStateValue } from '../../../types/kb_ingest_state'
import { IconX } from '@tabler/icons-react'
import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
@ -21,11 +23,82 @@ interface KnowledgeBaseModalProps {
onClose: () => void
}
/**
* Compact label for the per-row ingestion state. Files that exist in Qdrant
* with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883
* installs whose chunks are real, so we display them as "Indexed" rather than
* surfacing the absent-row detail. Admin-docs group has no pill (the "Managed
* by NOMAD" message in the action column carries the same signal).
*/
function renderStatePill(record: KbFileGroup): React.ReactNode {
if (record.bucket === 'admin_docs') return null
const effective: KbIngestStateValue = record.state ?? 'indexed'
const base = 'inline-flex items-center text-xs font-medium rounded px-2 py-0.5 border'
switch (effective) {
case 'indexed':
return (
<span className={`${base} text-green-700 bg-green-50 border-green-200 dark:text-green-300 dark:bg-green-950/40 dark:border-green-800`}>
Indexed
</span>
)
case 'pending_decision':
case 'browse_only':
return (
<span className={`${base} text-text-secondary bg-surface-secondary border-border-subtle`}>
Not Indexed
</span>
)
case 'failed':
return (
<span className={`${base} text-red-700 bg-red-50 border-red-200 dark:text-red-300 dark:bg-red-950/40 dark:border-red-800`}>
Failed
</span>
)
case 'stalled':
return (
<span className={`${base} text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:border-amber-800`}>
Stalled
</span>
)
}
}
type RowAction =
| { kind: 'index'; label: string; force: boolean; variant: 'primary'; icon: DynamicIconName }
| { kind: 'reembed'; label: string; force: true; variant: 'secondary'; icon: DynamicIconName }
/**
* Pick the single adaptive per-row action button. Returns null when no action
* makes sense for the current state (e.g. healthy indexed file with no
* warnings bulk Re-embed All covers that case). `hasWarnings` lets us
* surface a Re-embed affordance specifically when a file *looks* indexed but
* has zero chunks or a stalled-mid-ingestion warning attached.
*/
function pickRowAction(record: KbFileGroup, hasWarnings: boolean): RowAction | null {
if (record.bucket === 'admin_docs') return null
const effective: KbIngestStateValue = record.state ?? 'indexed'
switch (effective) {
case 'indexed':
return hasWarnings
? { kind: 'reembed', label: 'Re-embed', force: true, variant: 'secondary', icon: 'IconRefreshAlert' }
: null
case 'pending_decision':
return { kind: 'index', label: 'Index', force: false, variant: 'primary', icon: 'IconDownload' }
case 'browse_only':
return { kind: 'index', label: 'Index', force: true, variant: 'primary', icon: 'IconDownload' }
case 'failed':
case 'stalled':
return { kind: 'index', label: 'Retry', force: true, variant: 'primary', icon: 'IconRefresh' }
}
}
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const [isUploading, setIsUploading] = useState(false)
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
const [resetTyped, setResetTyped] = useState('')
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
@ -111,6 +184,25 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
})
const embedMutation = useMutation({
mutationFn: ({ source, force }: { source: string; force: boolean }) =>
api.embedSingleRAGFile(source, force),
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'File queued for embedding.',
})
setConfirmReembed(null)
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
queryClient.invalidateQueries({ queryKey: ['kbFileWarnings'] })
},
onError: (error: any) => {
addNotification({ type: 'error', message: error?.message || 'Failed to queue file.' })
setConfirmReembed(null)
},
})
const cleanupFailedMutation = useMutation({
mutationFn: () => api.cleanupFailedEmbedJobs(),
onSuccess: (data) => {
@ -466,32 +558,38 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
title: 'File Name',
render(record) {
const warnings = fileWarnings[record.source] ?? []
const pill = renderStatePill(record)
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1.5">
<span className="text-text-primary">
{record.displayName}
</span>
{warnings.map((w, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 self-start 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-2 py-0.5"
>
<span aria-hidden="true"></span>
{w.kind === 'zero_chunks' && (
<span>
Embedded 0 chunks this file has no text content.
AI Assistant cannot reference it.
{(pill || warnings.length > 0) && (
<div className="flex flex-wrap items-center gap-1.5">
{pill}
{warnings.map((w, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 self-start 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-2 py-0.5"
>
<span aria-hidden="true"></span>
{w.kind === 'zero_chunks' && (
<span>
Embedded 0 chunks this file has no text content.
AI Assistant cannot reference it.
</span>
)}
{w.kind === 'partial_stall' && (
<span>
Only {w.chunksEmbedded.toLocaleString()} of est.{' '}
{w.chunksExpected.toLocaleString()} chunks embedded
ingestion may have stalled.
</span>
)}
</span>
)}
{w.kind === 'partial_stall' && (
<span>
Only {w.chunksEmbedded.toLocaleString()} of est.{' '}
{w.chunksExpected.toLocaleString()} chunks embedded
ingestion may have stalled.
</span>
)}
</span>
))}
))}
</div>
)}
</div>
)
},
@ -538,14 +636,38 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
)
}
const warnings = fileWarnings[record.source] ?? []
const action = pickRowAction(record, warnings.length > 0)
const actionPendingForThisRow =
embedMutation.isPending && embedMutation.variables?.source === record.source
return (
<div className="flex justify-end">
<div className="flex justify-end items-center gap-2">
{action && (
<StyledButton
variant={action.variant}
size="sm"
icon={action.icon}
onClick={() => {
if (action.kind === 'reembed') {
setConfirmReembed({ source: record.source, displayName: record.displayName })
} else {
embedMutation.mutate({ source: record.source, force: action.force })
}
}}
disabled={qdrantOffline || deleteMutation.isPending || embedMutation.isPending}
loading={actionPendingForThisRow}
>
{action.label}
</StyledButton>
)}
<StyledButton
variant="danger"
size="sm"
icon="IconTrash"
onClick={() => setConfirmDeleteSource(record.source)}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || embedMutation.isPending}
loading={deleteMutation.isPending && confirmDeleteSource === record.source}
>Delete</StyledButton>
</div>
@ -654,6 +776,37 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
</StyledModal>
)}
{confirmReembed && (
<StyledModal
title='Re-embed this file?'
open={true}
confirmText={embedMutation.isPending ? 'Queuing…' : 'Re-embed'}
cancelText='Cancel'
confirmVariant='primary'
confirmLoading={embedMutation.isPending}
onConfirm={() =>
embedMutation.mutate({ source: confirmReembed.source, force: true })
}
onCancel={() => setConfirmReembed(null)}
>
<div className='text-text-primary text-sm space-y-3 text-left'>
<p>
This will delete the existing embeddings for{' '}
<strong>{confirmReembed.displayName}</strong> and queue
a fresh embedding job. The file on disk is not touched.
</p>
<div className='rounded border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-800 p-3 text-amber-900 dark:text-amber-200'>
<p className='font-semibold mb-1'>Heads up</p>
<ul className='list-disc pl-5 space-y-1'>
<li>For large ZIM archives this can take a long time, especially on CPU-only systems.</li>
<li>Search results that referenced this file will be incomplete until the new embedding finishes.</li>
<li>If a job for this file is already running, the re-embed will be refused wait for it to finish first.</li>
</ul>
</div>
</div>
</StyledModal>
)}
</div>
)
}

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, FileWarningsResult } from '../../types/rag'
import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'
import { catchInternal } from './util'
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
@ -470,11 +470,18 @@ class API {
async getStoredRAGFiles() {
return catchInternal(async () => {
const response = await this.client.get<{ files: string[] }>('/rag/files')
const response = await this.client.get<{ files: StoredFileInfo[] }>('/rag/files')
return response.data.files
})()
}
async embedSingleRAGFile(source: string, force: boolean = false) {
return catchInternal(async () => {
const response = await this.client.post<{ message: string }>('/rag/files/embed', { source, force })
return response.data
})()
}
async getKbFileWarnings() {
return catchInternal(async () => {
const response = await this.client.get<FileWarningsResult>('/rag/file-warnings')

View File

@ -1,9 +1,12 @@
import type { KbIngestStateValue } from '../../types/kb_ingest_state.js'
import type { StoredFileInfo } from '../../types/rag.js'
/**
* Knowledge-base files come back as a flat list of source paths from
* `/api/rag/files`. The UI groups them so the user sees the categories that
* matter to them ZIMs, uploaded documents, and a single rolled-up entry for
* Project NOMAD's bundled docs (rather than the 12+ individual markdown files
* those break into).
* Knowledge-base files come back as a list of `{source, state, chunksEmbedded}`
* objects from `/api/rag/files`. The UI groups them so the user sees the
* categories that matter to them ZIMs, uploaded documents, and a single
* rolled-up entry for Project NOMAD's bundled docs (rather than the 12+
* individual markdown files those break into).
*
* Bucket assignment is purely by path prefix; matching is done on `/` so the
* server-emitted absolute paths work regardless of which Linux mount the admin
@ -43,27 +46,33 @@ export interface KbFileGroup {
count: number
/** All member source paths — populated for collapsed groups, empty otherwise. */
members: string[]
/** Per-file ingestion state. `null` for the collapsed admin_docs group and
* for any source that exists in Qdrant but has no state row yet. */
state: KbIngestStateValue | null
/** Chunks currently embedded for this source; 0 for state-row-less or
* zero-chunk files. Always 0 for the collapsed admin_docs group. */
chunksEmbedded: number
}
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
/**
* Group raw source paths into rows for the Stored Files table.
* Group stored-file rows into table rows for the Stored Files panel.
*
* - Admin docs (`/app/docs/*`, README) collapse into a single
* "Project NOMAD documentation · N files" row.
* - ZIMs, uploads, and others stay as individual rows, sorted by bucket then
* alphabetically by filename so related items cluster naturally.
*/
export function groupAndSortKbFiles(sources: string[]): KbFileGroup[] {
const buckets: Record<KbFileBucket, string[]> = {
export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
const buckets: Record<KbFileBucket, StoredFileInfo[]> = {
zim: [],
upload: [],
admin_docs: [],
other: [],
}
for (const source of sources) {
buckets[classifyKbFile(source)].push(source)
for (const file of files) {
buckets[classifyKbFile(file.source)].push(file)
}
const groups: KbFileGroup[] = []
@ -78,20 +87,24 @@ export function groupAndSortKbFiles(sources: string[]): KbFileGroup[] {
source: '__admin_docs_group__',
displayName: `Project NOMAD documentation · ${members.length} file${members.length === 1 ? '' : 's'}`,
count: members.length,
members,
members: members.map((m) => m.source),
state: null,
chunksEmbedded: 0,
})
continue
}
for (const source of members.sort((a, b) =>
sourceToDisplayName(a).localeCompare(sourceToDisplayName(b))
for (const file of members.sort((a, b) =>
sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
)) {
groups.push({
bucket,
source,
displayName: sourceToDisplayName(source),
source: file.source,
displayName: sourceToDisplayName(file.source),
count: 1,
members: [],
state: file.state,
chunksEmbedded: file.chunksEmbedded,
})
}
}

View File

@ -143,6 +143,7 @@ router
router.get('/files', [RagController, 'getStoredFiles'])
router.get('/file-warnings', [RagController, 'getFileWarnings'])
router.delete('/files', [RagController, 'deleteFile'])
router.post('/files/embed', [RagController, 'embedFile'])
router.get('/active-jobs', [RagController, 'getActiveJobs'])
router.get('/failed-jobs', [RagController, 'getFailedJobs'])
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])

View File

@ -6,6 +6,14 @@ import {
groupAndSortKbFiles,
sourceToDisplayName,
} from '../../inertia/lib/kb_file_grouping.js'
import type { StoredFileInfo } from '../../types/rag.js'
/** Wrap source paths into the minimal StoredFileInfo shape that
* `groupAndSortKbFiles` now expects. State + chunk count are irrelevant to
* grouping/sorting behavior; the per-file state-pill rendering is exercised
* separately in the modal's component tests (added in the follow-up PR). */
const asInfos = (sources: string[]): StoredFileInfo[] =>
sources.map((source) => ({ source, state: null, chunksEmbedded: 0 }))
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
assert.equal(
@ -34,12 +42,12 @@ test('sourceToDisplayName returns the basename', () => {
})
test('groupAndSortKbFiles collapses all admin docs into a single row', () => {
const groups = groupAndSortKbFiles([
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/docs/getting-started.md',
'/app/docs/maps.md',
'/app/README.md',
])
]))
assert.equal(groups.length, 1)
assert.equal(groups[0].bucket, 'admin_docs')
@ -54,12 +62,12 @@ test('groupAndSortKbFiles collapses all admin docs into a single row', () => {
})
test('groupAndSortKbFiles orders buckets ZIM → upload → admin_docs → other', () => {
const groups = groupAndSortKbFiles([
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/unexpected/foo.txt',
'/app/storage/kb_uploads/upload.pdf',
'/app/storage/zim/devdocs.zim',
])
]))
assert.deepEqual(
groups.map((g) => g.bucket),
@ -68,11 +76,11 @@ test('groupAndSortKbFiles orders buckets ZIM → upload → admin_docs → other
})
test('groupAndSortKbFiles alphabetizes within a bucket', () => {
const groups = groupAndSortKbFiles([
const groups = groupAndSortKbFiles(asInfos([
'/app/storage/zim/wikipedia.zim',
'/app/storage/zim/devdocs.zim',
'/app/storage/zim/ifixit.zim',
])
]))
assert.deepEqual(
groups.map((g) => g.displayName),
@ -81,7 +89,7 @@ test('groupAndSortKbFiles alphabetizes within a bucket', () => {
})
test('groupAndSortKbFiles uses singular noun when only one admin doc exists', () => {
const groups = groupAndSortKbFiles(['/app/docs/release-notes.md'])
const groups = groupAndSortKbFiles(asInfos(['/app/docs/release-notes.md']))
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 1 file')
})
@ -90,10 +98,10 @@ test('groupAndSortKbFiles handles empty input', () => {
})
test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs group', () => {
const groups = groupAndSortKbFiles([
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/docs/maps.md',
])
]))
// The admin-docs row uses a synthetic source key (not a real path) so it
// can be used as a React key without colliding with any real file row.
assert.equal(groups[0].source, '__admin_docs_group__')

View File

@ -46,6 +46,18 @@ export type FileWarning =
| { kind: 'zero_chunks'; fileSizeBytes: number }
| { kind: 'partial_stall'; chunksEmbedded: number; chunksExpected: number }
/**
* Row returned by `GET /api/rag/files`. `state` is null for sources that exist
* in Qdrant but have no `kb_ingest_state` row (pre-RFC-883 installs where the
* scanner hasn't yet backfilled). `chunksEmbedded` mirrors the state-machine
* field; 0 for state-row-less or zero-chunk files.
*/
export type StoredFileInfo = {
source: string
state: import('./kb_ingest_state.js').KbIngestStateValue | null
chunksEmbedded: number
}
/**
* Result of computing per-file warnings. `ok: false` means the computation
* itself failed (Qdrant unreachable, DB outage, FS read error) distinct from