mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
* feat(kb): add per-file active/inactive toggle for search inclusion Implements the per-file toggle agreed on in #1119, in place of disabling ZIM auto-discovery by default. Any indexed file (ZIM, upload, or other) can now be excluded from RAG search results instantly, without deleting or re-embedding its vectors. - Vectors stay in Qdrant permanently; each point's payload carries an active boolean. Toggling flips this payload in place, both directions, with no re-embedding cost. - Search filter is a denylist (must_not: active == false), not an allowlist, so a point missing the field entirely is still found by default -- search correctness never depends on backfill timing. - embedAndStoreText() looks up a file's existing active state before writing new points, so retries, force re-embeds, and replaced-content reindexing never silently reactivate a file the user explicitly toggled off. - Applies uniformly to all non-admin_docs buckets (ZIM, uploads, other), matching how the rest of this pipeline already treats buckets, rather than special-casing ZIM. New POST /api/rag/files/active endpoint and KB panel toggle switch, following the existing updateFileCollection pattern. Real vector deletion for genuinely deleted/replaced files remains #1170's separate concern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(kb): group personal KB uploads by collection with bulk toggle Groups personal KB uploads into collapsible per-collection header rows (with an "Uncategorized" bucket for files with no collection) to reduce clutter in the file table. Each collection header also exposes a bulk active/inactive toggle to flip every file in that collection at once. Also fixes three pre-existing bugs found in the collection-assignment dropdown while building this: the popover was clipped by table cell truncation, clipped again by the table wrapper's overflow, and the combobox query was stale-filtering results on open. Refs #1193 * fix(kb): widen the modal for the Active column and coerce active to a boolean Both found by browser-verifying on NOMAD3. The Active column adds ~57px, which put the table back over the modal width and reintroduced the exact defect #1198 fixed: the Delete button falls off the right edge at every viewport. Measured on the box at 1018px of table against 961px of usable width at max-w-5xl. Widened to 6xl. MySQL returns tinyint(1) as 0/1 rather than a boolean, and the raw 1 reached the DOM as aria-checked="1". That is not a valid ARIA value, so a screen reader is told nothing useful about whether the toggle is on. Coerced in the service so every consumer sees a real boolean, with a !! guard in Switch as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(kb): validate the bulk collection toggle's input before sanitising sanitizeCollectionName ran before the type check, so a form-encoded request sending collection[] as an array reached .trim() on a non-string and produced an uncaught TypeError, surfacing as a 500. The vision controller already guards this the same way. Also documents why requiring `active` to be a real boolean is load-bearing rather than mere input hygiene: a cross-site HTML form can only send the string "true", so that check is what keeps a bulk Qdrant write across every point in the index out of reach of a form POST from another origin. Worth a comment, because coercing it would look like a harmless usability fix. Found in a security review of the release PRs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV --------- Co-authored-by: John Cortright <jcortright@zscaler.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import * as assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
|
|
import {
|
|
classifyKbFile,
|
|
groupAndSortKbFiles,
|
|
sourceToDisplayName,
|
|
UNCATEGORIZED_COLLECTION_KEY,
|
|
} 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).
|
|
* Metadata fields default to nullish/false — individual tests that exercise
|
|
* sort-by-size or sort-by-uploadedAt override as needed. */
|
|
const asInfos = (sources: string[]): StoredFileInfo[] =>
|
|
sources.map((source) => ({
|
|
source,
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: sourceToDisplayName(source),
|
|
size: null,
|
|
uploadedAt: null,
|
|
isUserUpload: classifyKbFile(source) === 'upload',
|
|
collection: null,
|
|
active: true,
|
|
}))
|
|
|
|
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(
|
|
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')
|
|
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(
|
|
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),
|
|
['zim', 'upload', 'admin_docs', 'other']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles alphabetizes within a bucket', () => {
|
|
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),
|
|
['devdocs.zim', 'ifixit.zim', 'wikipedia.zim']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles uses singular noun when only one admin doc exists', () => {
|
|
const groups = groupAndSortKbFiles(asInfos(['/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(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__')
|
|
})
|
|
|
|
/** Sized fixtures for the sort tests. `size` and `uploadedAt` are set so the
|
|
* three sort keys (name, size, uploadedAt) produce visibly different orders —
|
|
* if a test passed for name it had better fail for size. */
|
|
const sized: StoredFileInfo[] = [
|
|
{
|
|
source: '/app/storage/kb_uploads/charlie.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'charlie.txt',
|
|
size: 100,
|
|
uploadedAt: '2026-01-01T00:00:00Z',
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/alpha.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'alpha.txt',
|
|
size: 300,
|
|
uploadedAt: '2026-03-01T00:00:00Z',
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/bravo.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'bravo.txt',
|
|
size: 200,
|
|
uploadedAt: '2026-02-01T00:00:00Z',
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
]
|
|
|
|
// These fixtures are all `collection: null`, so they land in a single
|
|
// "Uncategorized" header row that must be expanded (via
|
|
// UNCATEGORIZED_COLLECTION_KEY) to see the sorted member rows.
|
|
const expandUncategorized = new Set([UNCATEGORIZED_COLLECTION_KEY])
|
|
const fileRowsOf = (groups: ReturnType<typeof groupAndSortKbFiles>) =>
|
|
groups.filter((g) => !g.isCollectionHeader)
|
|
|
|
test('groupAndSortKbFiles sorts by size ascending', () => {
|
|
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'asc' }, expandUncategorized)
|
|
assert.deepEqual(
|
|
fileRowsOf(groups).map((g) => g.displayName),
|
|
['charlie.txt', 'bravo.txt', 'alpha.txt']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles sorts by size descending', () => {
|
|
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'desc' }, expandUncategorized)
|
|
assert.deepEqual(
|
|
fileRowsOf(groups).map((g) => g.displayName),
|
|
['alpha.txt', 'bravo.txt', 'charlie.txt']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles sorts by uploadedAt ascending', () => {
|
|
const groups = groupAndSortKbFiles(
|
|
sized,
|
|
{ key: 'uploadedAt', direction: 'asc' },
|
|
expandUncategorized
|
|
)
|
|
assert.deepEqual(
|
|
fileRowsOf(groups).map((g) => g.displayName),
|
|
['charlie.txt', 'bravo.txt', 'alpha.txt']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
|
|
const groups = groupAndSortKbFiles(
|
|
sized,
|
|
{ key: 'uploadedAt', direction: 'desc' },
|
|
expandUncategorized
|
|
)
|
|
assert.deepEqual(
|
|
fileRowsOf(groups).map((g) => g.displayName),
|
|
['alpha.txt', 'bravo.txt', 'charlie.txt']
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
|
|
const withMissing: StoredFileInfo[] = [
|
|
...sized,
|
|
{
|
|
source: '/app/storage/kb_uploads/zzz_missing.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'zzz_missing.txt',
|
|
size: null,
|
|
uploadedAt: null,
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
]
|
|
// Missing-size files sort last regardless of direction so the "real" data
|
|
// owns the top of the view either way.
|
|
const asc = groupAndSortKbFiles(
|
|
withMissing,
|
|
{ key: 'size', direction: 'asc' },
|
|
expandUncategorized
|
|
)
|
|
assert.equal(fileRowsOf(asc).at(-1)?.displayName, 'zzz_missing.txt')
|
|
const desc = groupAndSortKbFiles(
|
|
withMissing,
|
|
{ key: 'size', direction: 'desc' },
|
|
expandUncategorized
|
|
)
|
|
assert.equal(fileRowsOf(desc).at(-1)?.displayName, 'zzz_missing.txt')
|
|
})
|
|
|
|
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
|
|
const mixed: StoredFileInfo[] = [
|
|
{
|
|
source: '/app/storage/zim/big.zim',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'big.zim',
|
|
size: 999,
|
|
uploadedAt: '2026-01-01T00:00:00Z',
|
|
isUserUpload: false,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/small.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'small.txt',
|
|
size: 1,
|
|
uploadedAt: '2026-09-01T00:00:00Z',
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/docs/release-notes.md',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'release-notes.md',
|
|
size: 50,
|
|
uploadedAt: '2026-05-01T00:00:00Z',
|
|
isUserUpload: false,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
]
|
|
// Even if size-desc would put zim first naturally, sort runs *within* a
|
|
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
|
|
// order. This is the invariant that lets the per-bucket grouping stay
|
|
// legible while still giving the user a sortable view.
|
|
for (const direction of ['asc', 'desc'] as const) {
|
|
for (const key of ['name', 'size', 'uploadedAt'] as const) {
|
|
const groups = groupAndSortKbFiles(mixed, { key, direction })
|
|
assert.deepEqual(
|
|
groups.map((g) => g.bucket),
|
|
['zim', 'upload', 'admin_docs'],
|
|
`bucket order changed for ${key}/${direction}`
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('groupAndSortKbFiles emits null metadata + isUserUpload=false for the admin_docs group', () => {
|
|
const groups = groupAndSortKbFiles(asInfos(['/app/docs/release-notes.md', '/app/README.md']))
|
|
assert.equal(groups[0].bucket, 'admin_docs')
|
|
assert.equal(groups[0].size, null)
|
|
assert.equal(groups[0].uploadedAt, null)
|
|
assert.equal(groups[0].isUserUpload, false)
|
|
})
|
|
|
|
/** Fixtures for the KB-collection grouping tests: two named collections
|
|
* ("diy" with one file, "recipes" with a mixed active/inactive pair) plus an
|
|
* untagged file that should fall into the "Uncategorized" header. */
|
|
const collectioned: StoredFileInfo[] = [
|
|
{
|
|
source: '/app/storage/kb_uploads/recipe_active.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'recipe_active.txt',
|
|
size: 10,
|
|
uploadedAt: null,
|
|
isUserUpload: true,
|
|
collection: 'recipes',
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/recipe_off.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'recipe_off.txt',
|
|
size: 20,
|
|
uploadedAt: null,
|
|
isUserUpload: true,
|
|
collection: 'recipes',
|
|
active: false,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/diy_guide.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'diy_guide.txt',
|
|
size: 30,
|
|
uploadedAt: null,
|
|
isUserUpload: true,
|
|
collection: 'diy',
|
|
active: true,
|
|
},
|
|
{
|
|
source: '/app/storage/kb_uploads/loose_note.txt',
|
|
state: null,
|
|
chunksEmbedded: 0,
|
|
fileName: 'loose_note.txt',
|
|
size: 40,
|
|
uploadedAt: null,
|
|
isUserUpload: true,
|
|
collection: null,
|
|
active: true,
|
|
},
|
|
]
|
|
|
|
test('groupAndSortKbFiles collapses upload files into per-collection header rows by default', () => {
|
|
const groups = groupAndSortKbFiles(collectioned)
|
|
// Nothing expanded -> just the three headers, no individual file rows.
|
|
assert.equal(groups.length, 3)
|
|
assert.ok(groups.every((g) => g.isCollectionHeader))
|
|
})
|
|
|
|
test('groupAndSortKbFiles orders KB-collection headers alphabetically with Uncategorized last', () => {
|
|
const groups = groupAndSortKbFiles(collectioned)
|
|
assert.deepEqual(
|
|
groups.map((g) => g.collection),
|
|
['diy', 'recipes', null]
|
|
)
|
|
})
|
|
|
|
test('groupAndSortKbFiles labels the null-collection header "Uncategorized" with a file count', () => {
|
|
const groups = groupAndSortKbFiles(collectioned)
|
|
const header = groups.find((g) => g.collection === null)
|
|
assert.equal(header?.displayName, 'Uncategorized · 1 file')
|
|
})
|
|
|
|
test('groupAndSortKbFiles computes all-active, all-inactive, and mixed aggregate state per header', () => {
|
|
const groups = groupAndSortKbFiles(collectioned)
|
|
const diy = groups.find((g) => g.collection === 'diy')
|
|
const recipes = groups.find((g) => g.collection === 'recipes')
|
|
assert.equal(diy?.collectionActiveState, 'all-active')
|
|
assert.equal(recipes?.collectionActiveState, 'mixed')
|
|
})
|
|
|
|
test('groupAndSortKbFiles expands only the requested collection into member rows', () => {
|
|
const groups = groupAndSortKbFiles(
|
|
collectioned,
|
|
{ key: 'name', direction: 'asc' },
|
|
new Set(['recipes'])
|
|
)
|
|
const recipesIdx = groups.findIndex((g) => g.collection === 'recipes' && g.isCollectionHeader)
|
|
assert.equal(groups[recipesIdx + 1]?.displayName, 'recipe_active.txt')
|
|
assert.equal(groups[recipesIdx + 2]?.displayName, 'recipe_off.txt')
|
|
// diy and Uncategorized stay collapsed -- their next entry is another header.
|
|
const diyIdx = groups.findIndex((g) => g.collection === 'diy')
|
|
assert.equal(groups[diyIdx + 1]?.isCollectionHeader, true)
|
|
})
|
|
|
|
test('groupAndSortKbFiles uses UNCATEGORIZED_COLLECTION_KEY to expand null-collection files', () => {
|
|
const groups = groupAndSortKbFiles(
|
|
collectioned,
|
|
{ key: 'name', direction: 'asc' },
|
|
new Set([UNCATEGORIZED_COLLECTION_KEY])
|
|
)
|
|
const fileRows = groups.filter((g) => !g.isCollectionHeader)
|
|
assert.deepEqual(
|
|
fileRows.map((g) => g.displayName),
|
|
['loose_note.txt']
|
|
)
|
|
})
|