feat(KB): guardrail modal at 50GB / 10%-free thresholds (RFC #883 §7)
One-time confirmation step gating bulk indexing actions that would consume a substantial amount of disk for embedding storage. Fires only when the user has policy=Always (i.e., the system would auto-index) AND the estimate trips either: - GUARDRAIL_ABSOLUTE_BYTES = 50 GB embedding cost, OR - GUARDRAIL_FREE_DISK_RATIO = 10% of current free disk space Under policy=Manual the guardrail is silent because the user has already opted out of automatic ingestion — the files would just queue as pending_decision either way. Pieces - inertia/lib/kb_guardrail.ts: pure decision helper with two constants and an evaluateGuardrail() that returns a verdict + reasons. No I/O on the helper itself so the logic is trivially testable - inertia/components/KbGuardrailModal.tsx: confirmation dialog. Headless UI Transition + Dialog, amber 'large operation' header, plain-English estimate summary, [Cancel] / [Proceed anyway] footer. z-[60] so it layers above the tier modal underneath instead of replacing it - inertia/components/TierSelectionModal.tsx integration: handleSubmit now evaluates the guardrail when policy=Always and embedEstimate is available; if it trips, we stash the verdict in state and render the guardrail modal as an overlay. Confirm runs finalizeSubmit (which is the pre-existing onSelectTier + onClose path); Cancel just closes the guardrail and leaves the tier modal as-is so the user can change their tier choice or flip the policy The disk-free signal comes from the existing useSystemInfo hook + getPrimaryDiskInfo helper. Passing freeBytes=0 (unknown) skips the relative-disk check, so the modal still works on hosts whose disk introspection failed — just relies on the absolute 50 GB threshold Tests - 9 cases in tests/unit/kb_guardrail.spec.ts: standard small batch (no trip), exact absolute threshold trips, over-absolute trips, over 10% free trips, both-at-once trips with two reasons, freeBytes=0 skip, freeBytes=0 + over-absolute trip, exact-10% boundary trips, just- under-both safe. All green. Stacks on feat/kb-tier-estimate-on-disk (#897) — consumes that PR's estimate endpoint to compute the verdict input. Auto-rebases to rc when #897 merges. Pairs with #894 (policy toggle) and #899 (JIT prompt): together the three PRs cover the 'how do I avoid surprising the user with auto- indexing they didn't ask for?' arc. Out of scope (deferred) - 6 hr time threshold (RFC §7): needs a per-host chunks-per-second metric we don't capture yet; would be a follow-up after Phase 4 self-calibration (RFC §15) lands - Wider integration (KbPolicyPromptBanner 'Index now' button, manual KB-modal sync): TierSelectionModal is the dominant bulk-decision surface and the right place to land this first
This commit is contained in:
parent
7a681d04ab
commit
cf3a924b9f
|
|
@ -0,0 +1,109 @@
|
|||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
import StyledButton from './StyledButton'
|
||||
import type { GuardrailVerdict } from '~/lib/kb_guardrail'
|
||||
|
||||
/**
|
||||
* One-time confirmation modal for bulk indexing actions that trip the
|
||||
* disk-usage thresholds in `lib/kb_guardrail.ts`. The caller (e.g.
|
||||
* TierSelectionModal) decides whether to show the modal by evaluating the
|
||||
* guardrail BEFORE submit; this component just presents the verdict and
|
||||
* passes the user's choice back via `onConfirm` / `onCancel`.
|
||||
*/
|
||||
interface KbGuardrailModalProps {
|
||||
isOpen: boolean
|
||||
verdict: GuardrailVerdict
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function KbGuardrailModal({
|
||||
isOpen,
|
||||
verdict,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: KbGuardrailModalProps) {
|
||||
// The primary number to surface — every triggered reason carries the same
|
||||
// estimateBytes, so just grab the first one. `0` is a defensive fallback
|
||||
// for the (impossible-by-construction) "open with empty verdict" case.
|
||||
const estimateBytes = verdict.reasons[0]?.estimateBytes ?? 0
|
||||
const freeReason = verdict.reasons.find((r) => r.kind === 'over_free_disk')
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[60]" onClose={onCancel}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
|
||||
<div className="bg-amber-50 dark:bg-amber-950/30 px-6 py-4 border-b border-amber-200 dark:border-amber-800 flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<IconAlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-300 flex-shrink-0 mt-0.5" />
|
||||
<Dialog.Title className="text-lg font-semibold text-text-primary">
|
||||
Confirm large AI indexing operation
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-text-muted hover:text-text-primary transition-colors flex-shrink-0"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-3">
|
||||
<p className="text-text-primary text-sm">
|
||||
Indexing this batch for the AI Assistant will use approximately{' '}
|
||||
<strong>{formatBytes(estimateBytes, 1)}</strong> of disk space for embeddings, on top of the raw downloads.
|
||||
</p>
|
||||
|
||||
{freeReason && (
|
||||
<p className="text-text-secondary text-sm">
|
||||
That's more than 10% of your remaining free disk space ({formatBytes(freeReason.freeBytes, 1)} free). Embedding can take several hours and is hard to interrupt cleanly once started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-text-secondary text-sm">
|
||||
If you'd rather review per-item before indexing, cancel here and switch your Auto-index setting to <strong>Manual</strong> from the Knowledge Base panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
|
||||
<StyledButton variant="outline" size="md" onClick={onCancel}>
|
||||
Cancel
|
||||
</StyledButton>
|
||||
<StyledButton variant="primary" size="md" onClick={onConfirm}>
|
||||
Proceed anyway
|
||||
</StyledButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@ import api from '~/lib/api'
|
|||
import classNames from 'classnames'
|
||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||
import StyledButton from './StyledButton'
|
||||
import KbGuardrailModal from './KbGuardrailModal'
|
||||
import { evaluateGuardrail, type GuardrailVerdict } from '~/lib/kb_guardrail'
|
||||
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
|
||||
|
||||
/**
|
||||
* Filename for the embed-estimate registry lookup. Strips the URL path so
|
||||
|
|
@ -81,6 +85,17 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
queryKey: ['ingestPolicy'],
|
||||
queryFn: () => api.getSetting('rag.defaultIngestPolicy'),
|
||||
})
|
||||
|
||||
// System info for the disk-free side of the guardrail. Shared queryKey with
|
||||
// the home / easy-setup pages so we don't refetch when the user already has
|
||||
// a fresh copy in cache from a sibling component.
|
||||
const { data: systemInfo } = useSystemInfo({ enabled: true })
|
||||
|
||||
// Open state for the guardrail modal — separate from the tier modal so the
|
||||
// user sees the warning as an overlay without losing their tier selection
|
||||
// underneath. Cancel returns to the tier modal as-is; Proceed closes both
|
||||
// and runs the original onSelectTier path.
|
||||
const [guardrailVerdict, setGuardrailVerdict] = useState<GuardrailVerdict | null>(null)
|
||||
const ingestPolicy: 'Always' | 'Manual' =
|
||||
ingestPolicySetting?.value === 'Manual' ? 'Manual' : 'Always'
|
||||
|
||||
|
|
@ -99,16 +114,49 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!localSelectedSlug) return
|
||||
// Compute disk-free bytes from system info; 0 means "unknown", which the
|
||||
// guardrail helper treats as "skip the relative-disk check".
|
||||
const freeBytes = useMemo<number>(() => {
|
||||
const primary = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
|
||||
if (!primary) return 0
|
||||
return Math.max(0, primary.totalSize - primary.totalUsed)
|
||||
}, [systemInfo])
|
||||
|
||||
const selectedTier = category.tiers.find(t => t.slug === localSelectedSlug)
|
||||
/**
|
||||
* Runs the original onSelectTier-then-onClose flow. Pulled out of
|
||||
* handleSubmit so the guardrail modal's confirm path can call it after
|
||||
* the user has consented to the large operation.
|
||||
*/
|
||||
const finalizeSubmit = () => {
|
||||
if (!localSelectedSlug || !category) return
|
||||
const selectedTier = category.tiers.find((t) => t.slug === localSelectedSlug)
|
||||
if (selectedTier) {
|
||||
onSelectTier(category, selectedTier)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!localSelectedSlug || !category) return
|
||||
|
||||
// Guardrail only runs when we have an estimate AND the global policy
|
||||
// would auto-index this batch. Under Manual the user has already opted
|
||||
// out of automatic ingestion, so the bulk-disk warning would be a false
|
||||
// alarm — the files would just queue as pending_decision.
|
||||
if (ingestPolicy === 'Always' && embedEstimate) {
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: embedEstimate.totalBytes,
|
||||
freeBytes,
|
||||
})
|
||||
if (verdict.trips) {
|
||||
setGuardrailVerdict(verdict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
finalizeSubmit()
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
|
|
@ -307,6 +355,17 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{guardrailVerdict && (
|
||||
<KbGuardrailModal
|
||||
isOpen={true}
|
||||
verdict={guardrailVerdict}
|
||||
onConfirm={() => {
|
||||
setGuardrailVerdict(null)
|
||||
finalizeSubmit()
|
||||
}}
|
||||
onCancel={() => setGuardrailVerdict(null)}
|
||||
/>
|
||||
)}
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Auto-index guardrail thresholds and pure decision logic (RFC #883 §7).
|
||||
*
|
||||
* The guardrail fires when a user is about to commit to a bulk indexing
|
||||
* action (curated tier change, large multi-file upload, etc.) that would
|
||||
* use a substantial amount of disk for embedding storage. It's a one-time
|
||||
* confirmation step at scary thresholds — it doesn't fire for ordinary
|
||||
* everyday operations. After the user confirms once for a given batch
|
||||
* the action proceeds as it would have without the guardrail.
|
||||
*
|
||||
* Thresholds are intentionally conservative to avoid surprise consumption
|
||||
* of a user's storage. Tweak both constants if the field experience
|
||||
* suggests we're nagging users too aggressively.
|
||||
*/
|
||||
|
||||
/** Absolute upper bound: estimates at or above this trip the guardrail. */
|
||||
export const GUARDRAIL_ABSOLUTE_BYTES = 50 * 1024 * 1024 * 1024 // 50 GB
|
||||
|
||||
/** Relative-to-free-disk bound: estimates >= 10% of free disk trip too. */
|
||||
export const GUARDRAIL_FREE_DISK_RATIO = 0.1
|
||||
|
||||
export type GuardrailReason =
|
||||
| {
|
||||
kind: 'over_absolute'
|
||||
estimateBytes: number
|
||||
thresholdBytes: number
|
||||
}
|
||||
| {
|
||||
kind: 'over_free_disk'
|
||||
estimateBytes: number
|
||||
freeBytes: number
|
||||
thresholdBytes: number
|
||||
}
|
||||
|
||||
export type GuardrailVerdict = {
|
||||
trips: boolean
|
||||
reasons: GuardrailReason[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a bulk indexing action should be gated behind the
|
||||
* guardrail modal. Caller passes the precomputed embedding-storage
|
||||
* estimate (from `KbRatioRegistry.estimateBatch` in #891 / #897) and
|
||||
* the free-disk figure from system info. Pass `freeBytes = 0` to skip
|
||||
* the relative-disk check when free space isn't known.
|
||||
*/
|
||||
export function evaluateGuardrail(input: {
|
||||
estimateBytes: number
|
||||
freeBytes: number
|
||||
}): GuardrailVerdict {
|
||||
const reasons: GuardrailReason[] = []
|
||||
|
||||
if (input.estimateBytes >= GUARDRAIL_ABSOLUTE_BYTES) {
|
||||
reasons.push({
|
||||
kind: 'over_absolute',
|
||||
estimateBytes: input.estimateBytes,
|
||||
thresholdBytes: GUARDRAIL_ABSOLUTE_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.freeBytes > 0) {
|
||||
const relativeThreshold = input.freeBytes * GUARDRAIL_FREE_DISK_RATIO
|
||||
if (input.estimateBytes >= relativeThreshold) {
|
||||
reasons.push({
|
||||
kind: 'over_free_disk',
|
||||
estimateBytes: input.estimateBytes,
|
||||
freeBytes: input.freeBytes,
|
||||
thresholdBytes: relativeThreshold,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { trips: reasons.length > 0, reasons }
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import * as assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
GUARDRAIL_ABSOLUTE_BYTES,
|
||||
GUARDRAIL_FREE_DISK_RATIO,
|
||||
evaluateGuardrail,
|
||||
} from '../../inertia/lib/kb_guardrail.js'
|
||||
|
||||
const GB = 1024 * 1024 * 1024
|
||||
|
||||
test('small batch does not trip the guardrail', () => {
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 1 * GB, // 1 GB
|
||||
freeBytes: 500 * GB,
|
||||
})
|
||||
assert.equal(verdict.trips, false)
|
||||
assert.deepEqual(verdict.reasons, [])
|
||||
})
|
||||
|
||||
test('batch at exactly the absolute threshold trips', () => {
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: GUARDRAIL_ABSOLUTE_BYTES,
|
||||
freeBytes: 1000 * GB,
|
||||
})
|
||||
assert.equal(verdict.trips, true)
|
||||
assert.equal(verdict.reasons.length, 1)
|
||||
assert.equal(verdict.reasons[0].kind, 'over_absolute')
|
||||
})
|
||||
|
||||
test('batch over the absolute threshold trips with over_absolute reason', () => {
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 60 * GB,
|
||||
freeBytes: 1000 * GB,
|
||||
})
|
||||
const overAbsolute = verdict.reasons.find((r) => r.kind === 'over_absolute')
|
||||
assert.ok(overAbsolute, 'should include over_absolute reason')
|
||||
assert.equal(verdict.trips, true)
|
||||
})
|
||||
|
||||
test('batch over 10% of free disk trips with over_free_disk reason', () => {
|
||||
// 5 GB estimate against 40 GB free disk -> 5 > 4 (10% of 40)
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 5 * GB,
|
||||
freeBytes: 40 * GB,
|
||||
})
|
||||
const overFree = verdict.reasons.find((r) => r.kind === 'over_free_disk')
|
||||
assert.ok(overFree, 'should include over_free_disk reason')
|
||||
assert.equal(verdict.trips, true)
|
||||
})
|
||||
|
||||
test('batch can trip BOTH thresholds simultaneously', () => {
|
||||
// 100 GB estimate, 200 GB free
|
||||
// - over absolute (100 > 50)
|
||||
// - over 10% of free (100 > 20)
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 100 * GB,
|
||||
freeBytes: 200 * GB,
|
||||
})
|
||||
assert.equal(verdict.trips, true)
|
||||
assert.equal(verdict.reasons.length, 2)
|
||||
assert.ok(verdict.reasons.some((r) => r.kind === 'over_absolute'))
|
||||
assert.ok(verdict.reasons.some((r) => r.kind === 'over_free_disk'))
|
||||
})
|
||||
|
||||
test('freeBytes = 0 skips the relative-disk check', () => {
|
||||
// 100 MB estimate, no free-disk signal: only the absolute check runs,
|
||||
// and 100 MB is well below the 50 GB absolute threshold
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 100 * 1024 * 1024,
|
||||
freeBytes: 0,
|
||||
})
|
||||
assert.equal(verdict.trips, false)
|
||||
})
|
||||
|
||||
test('freeBytes = 0 still trips the absolute check at 50 GB', () => {
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 100 * GB,
|
||||
freeBytes: 0,
|
||||
})
|
||||
assert.equal(verdict.trips, true)
|
||||
assert.equal(verdict.reasons.length, 1)
|
||||
assert.equal(verdict.reasons[0].kind, 'over_absolute')
|
||||
})
|
||||
|
||||
test('relative-disk threshold computed from GUARDRAIL_FREE_DISK_RATIO constant', () => {
|
||||
// Estimate exactly equal to 10% of free -> trips (>=)
|
||||
const free = 100 * GB
|
||||
const exactlyTenPercent = free * GUARDRAIL_FREE_DISK_RATIO
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: exactlyTenPercent,
|
||||
freeBytes: free,
|
||||
})
|
||||
const overFree = verdict.reasons.find((r) => r.kind === 'over_free_disk')
|
||||
assert.ok(overFree, 'should trip at exactly the threshold')
|
||||
})
|
||||
|
||||
test('batch just under both thresholds does not trip', () => {
|
||||
// 4 GB estimate vs 50 GB free -> 10% of 50 = 5 GB, so 4 < 5
|
||||
// Also well below 50 GB absolute
|
||||
const verdict = evaluateGuardrail({
|
||||
estimateBytes: 4 * GB,
|
||||
freeBytes: 50 * GB,
|
||||
})
|
||||
assert.equal(verdict.trips, false)
|
||||
})
|
||||
Loading…
Reference in New Issue