feat(KB): group admin docs into single row in Stored Files (RFC #883 §9)

Project NOMAD's bundled docs (`/app/docs/*.md` and `README.md`) each
embed as their own KB source — currently rendering as 12+ individual
rows that swamp user-uploaded content in the Stored Files table.
Collapse them into one informational row:

> Project NOMAD documentation · 12 files · Managed by NOMAD

The admin-docs row hides the Delete button (those files would be
re-embedded on the next sync anyway, so deleting is a footgun). User
uploads and ZIMs keep their existing per-row Delete UX.

Also adds deterministic sort: ZIMs → user uploads → admin docs → other,
alphabetical within each bucket. Pure frontend change — `/api/rag/files`
response shape unchanged.

Decision logic extracted to `kb_file_grouping.ts` with 9 unit tests
covering bucket classification, sort order, count noun pluralization,
and empty-input handling.
This commit is contained in:
Chris Sherwood 2026-05-14 12:26:58 -07:00 committed by Jake Turner
parent 4d6b140a1f
commit c64ec97de4
3 changed files with 222 additions and 8 deletions

View File

@ -6,6 +6,10 @@ import StyledSectionHeader from '~/components/StyledSectionHeader'
import StyledTable from '~/components/StyledTable'
import { useNotifications } from '~/context/NotificationContext'
import api from '~/lib/api'
import {
groupAndSortKbFiles,
type KbFileGroup,
} from '~/lib/kb_file_grouping'
import { IconX } from '@tabler/icons-react'
import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
@ -17,11 +21,6 @@ interface KnowledgeBaseModalProps {
onClose: () => void
}
function sourceToDisplayName(source: string): string {
const parts = source.split(/[/\\]/)
return parts[parts.length - 1]
}
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
@ -362,7 +361,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
</div>
<StyledTable<{ source: string }>
<StyledTable<KbFileGroup>
className="font-semibold"
rowLines={true}
columns={[
@ -370,13 +369,28 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
accessor: 'source',
title: 'File Name',
render(record) {
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
return (
<span className="text-text-primary">{record.displayName}</span>
)
},
},
{
accessor: 'source',
title: '',
render(record) {
// Admin docs are auto-discovered and managed by NOMAD itself —
// deleting one would just be re-embedded on the next sync, so
// we surface them as informational only and hide Delete.
if (record.bucket === 'admin_docs') {
return (
<div className="flex justify-end">
<span className="text-sm text-text-muted italic">
Managed by NOMAD
</span>
</div>
)
}
const isConfirming = confirmDeleteSource === record.source
const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source
if (isConfirming) {
@ -417,7 +431,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
},
]}
data={storedFiles.map((source) => ({ source }))}
data={groupAndSortKbFiles(storedFiles)}
loading={isLoadingFiles}
/>
</div>

View File

@ -0,0 +1,100 @@
/**
* 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).
*
* 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
* container uses.
*/
export type KbFileBucket = 'zim' | 'upload' | 'admin_docs' | 'other'
const ADMIN_DOCS_PREFIXES = ['/app/docs/', '/app/README.md']
const ZIM_PREFIX = '/app/storage/zim/'
const UPLOADS_PREFIX = '/app/storage/kb_uploads/'
export function classifyKbFile(source: string): KbFileBucket {
if (
ADMIN_DOCS_PREFIXES.some((p) =>
p.endsWith('/') ? source.startsWith(p) : source === p
)
) {
return 'admin_docs'
}
if (source.startsWith(ZIM_PREFIX)) return 'zim'
if (source.startsWith(UPLOADS_PREFIX)) return 'upload'
return 'other'
}
export function sourceToDisplayName(source: string): string {
const parts = source.split(/[/\\]/)
return parts[parts.length - 1] || source
}
export interface KbFileGroup {
bucket: KbFileBucket
/** Source path used as the row's stable React key. For collapsed admin docs
* this is a synthetic marker; individual file paths live in `members`. */
source: string
displayName: string
/** Number of underlying files this row represents (1 for non-collapsed). */
count: number
/** All member source paths — populated for collapsed groups, empty otherwise. */
members: string[]
}
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
/**
* Group raw source paths into rows for the Stored Files table.
*
* - 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[]> = {
zim: [],
upload: [],
admin_docs: [],
other: [],
}
for (const source of sources) {
buckets[classifyKbFile(source)].push(source)
}
const groups: KbFileGroup[] = []
for (const bucket of BUCKET_SORT_ORDER) {
const members = buckets[bucket]
if (members.length === 0) continue
if (bucket === 'admin_docs') {
groups.push({
bucket,
source: '__admin_docs_group__',
displayName: `Project NOMAD documentation · ${members.length} file${members.length === 1 ? '' : 's'}`,
count: members.length,
members,
})
continue
}
for (const source of members.sort((a, b) =>
sourceToDisplayName(a).localeCompare(sourceToDisplayName(b))
)) {
groups.push({
bucket,
source,
displayName: sourceToDisplayName(source),
count: 1,
members: [],
})
}
}
return groups
}

View File

@ -0,0 +1,100 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
classifyKbFile,
groupAndSortKbFiles,
sourceToDisplayName,
} from '../../inertia/lib/kb_file_grouping.js'
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
assert.equal(
classifyKbFile('/app/storage/zim/devdocs_en_python_2026-02.zim'),
'zim'
)
assert.equal(
classifyKbFile('/app/storage/kb_uploads/federalist.txt-8cc4ec95aa8f.txt'),
'upload'
)
assert.equal(classifyKbFile('/app/docs/release-notes.md'), 'admin_docs')
assert.equal(classifyKbFile('/app/README.md'), 'admin_docs')
assert.equal(classifyKbFile('/unexpected/path/file.txt'), 'other')
})
test('classifyKbFile does not match /app/READMEs that are not the bundled one', () => {
assert.equal(classifyKbFile('/app/README.md.bak'), 'other')
})
test('sourceToDisplayName returns the basename', () => {
assert.equal(
sourceToDisplayName('/app/storage/zim/devdocs_en_python_2026-02.zim'),
'devdocs_en_python_2026-02.zim'
)
assert.equal(sourceToDisplayName('/app/docs/release-notes.md'), 'release-notes.md')
})
test('groupAndSortKbFiles collapses all admin docs into a single row', () => {
const groups = groupAndSortKbFiles([
'/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')
assert.equal(groups[0].count, 4)
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 4 files')
assert.deepEqual(groups[0].members.sort(), [
'/app/README.md',
'/app/docs/getting-started.md',
'/app/docs/maps.md',
'/app/docs/release-notes.md',
])
})
test('groupAndSortKbFiles orders buckets ZIM → upload → admin_docs → other', () => {
const groups = groupAndSortKbFiles([
'/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),
['zim', 'upload', 'admin_docs', 'other']
)
})
test('groupAndSortKbFiles alphabetizes within a bucket', () => {
const groups = groupAndSortKbFiles([
'/app/storage/zim/wikipedia.zim',
'/app/storage/zim/devdocs.zim',
'/app/storage/zim/ifixit.zim',
])
assert.deepEqual(
groups.map((g) => g.displayName),
['devdocs.zim', 'ifixit.zim', 'wikipedia.zim']
)
})
test('groupAndSortKbFiles uses singular noun when only one admin doc exists', () => {
const groups = groupAndSortKbFiles(['/app/docs/release-notes.md'])
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 1 file')
})
test('groupAndSortKbFiles handles empty input', () => {
assert.deepEqual(groupAndSortKbFiles([]), [])
})
test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs group', () => {
const groups = groupAndSortKbFiles([
'/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__')
})