mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +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>
85 lines
2.5 KiB
TypeScript
85 lines
2.5 KiB
TypeScript
import { DateTime } from 'luxon'
|
|
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
|
import type { KbIngestStateValue } from '../../types/kb_ingest_state.js'
|
|
|
|
const LAST_ERROR_MAX_LEN = 1024
|
|
|
|
/**
|
|
* Tracks the per-file decision and outcome of AI knowledge-base ingestion.
|
|
*
|
|
* The row exists for any embeddable file the scanner has seen and is independent
|
|
* of `installed_resources` (which only covers curated downloads). Replaces the
|
|
* earlier "any chunks in qdrant ⇒ embedded" binary check, which conflated
|
|
* partially-stalled ingestions with fully-indexed files. See RFC #883.
|
|
*/
|
|
export default class KbIngestState extends BaseModel {
|
|
static table = 'kb_ingest_state'
|
|
static namingStrategy = new SnakeCaseNamingStrategy()
|
|
|
|
@column({ isPrimary: true })
|
|
declare id: number
|
|
|
|
@column()
|
|
declare file_path: string
|
|
|
|
@column()
|
|
declare state: KbIngestStateValue
|
|
|
|
@column()
|
|
declare chunks_embedded: number
|
|
|
|
@column()
|
|
declare collection: string | null
|
|
|
|
@column()
|
|
declare active: boolean
|
|
|
|
@column()
|
|
declare last_error: string | null
|
|
|
|
@column.dateTime({ autoCreate: true })
|
|
declare created_at: DateTime
|
|
|
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
|
declare updated_at: DateTime
|
|
|
|
static async getOrCreate(filePath: string, collection?: string): Promise<KbIngestState> {
|
|
return this.firstOrCreate(
|
|
{ file_path: filePath },
|
|
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null, active: true }
|
|
)
|
|
}
|
|
|
|
static async markIndexed(filePath: string, chunksEmbedded: number, collection?: string): Promise<void> {
|
|
const row = await this.getOrCreate(filePath, collection)
|
|
row.state = 'indexed'
|
|
row.chunks_embedded = chunksEmbedded
|
|
row.last_error = null
|
|
if (collection) row.collection = collection
|
|
await row.save()
|
|
}
|
|
|
|
static async markFailed(filePath: string, errorMessage: string): Promise<void> {
|
|
const row = await this.getOrCreate(filePath)
|
|
row.state = 'failed'
|
|
row.last_error = errorMessage.slice(0, LAST_ERROR_MAX_LEN)
|
|
await row.save()
|
|
}
|
|
|
|
static async markBrowseOnly(filePath: string): Promise<void> {
|
|
const row = await this.getOrCreate(filePath)
|
|
row.state = 'browse_only'
|
|
await row.save()
|
|
}
|
|
|
|
static async markStalled(filePath: string): Promise<void> {
|
|
const row = await this.getOrCreate(filePath)
|
|
row.state = 'stalled'
|
|
await row.save()
|
|
}
|
|
|
|
static async remove(filePath: string): Promise<void> {
|
|
await this.query().where('file_path', filePath).delete()
|
|
}
|
|
}
|