Files
learning-platform/src/components/admin/graph/GraphControls.jsx
RaymondVerhoef 9395ea11fe
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 46s
On Pull Request to Main / publish (pull_request) Successful in 1m26s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m49s
fix: bescherm excluded en locked topics tegen AI-deletion/merge
Topics met learning_relevance="exclude" of relevance_locked=true werden
door de Full Analysis stilletjes verwijderd: het model zag exclude als
"irrelevant" en stelde ze voor in actions.deletions / actions.merges,
en analyzeGraph paste die zonder filter toe. Het locked-vlaggetje werd
in full-scope payload niet eens meegestuurd, dus zelfs een nieuwe prompt
kon niet helpen.

Defense-in-depth fixes:

1. Pure graphGuard.filterAiActions strips elke deletion/merge waarvan de
   target excluded of locked is, vóór bulkSave. Merges waarin een
   protected topic juist de keepId is (canonical survivor) blijven door.

2. SYSTEM_PROMPTS.full krijgt een expliciete "PROTECTED TOPICS" sectie
   die exclude en locked topics als nooit-te-verwijderen markeert.

3. relevance_locked wordt nu ook in de full-scope compactTopics payload
   meegestuurd, zodat het model de vlag überhaupt ziet.

4. UI-feedback: GraphControls toont een ShieldCheck banner met aantal
   geblokkeerde acties na de analyze, en het excluded-aantal naast de
   "Show Excluded Nodes" toggle (visible/hidden).

5. 13 nieuwe Vitest cases dekken protected detection en alle drop/keep
   paden van filterAiActions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:22:12 +02:00

134 lines
5.2 KiB
JavaScript

import { useState } from 'react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown, ShieldCheck } from 'lucide-react';
import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue';
const SCOPE_OPTIONS = [
{ value: 'full', label: 'Full Analysis', tier: 'Opus', hint: 'Merges · Deletions · Relations · Relevance' },
{ value: 'relations', label: 'Relations Only', tier: 'Sonnet', hint: 'Suggest missing logical connections' },
{ value: 'relevance', label: 'Relevance Only', tier: 'Sonnet', hint: 'Re-score learning_relevance for all unlocked topics' },
];
/**
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button
* (with scope selector), the snapshot restore row, and the R42 suggestions queue.
*
* Kept intentionally thin — all async logic lives in the KnowledgeGraph
* orchestrator and is injected via props.
*
* The scope selector is local state here; it produces a scope string that is
* forwarded to onAnalyze(scope). Scope options map to different AI tiers:
* full → reasoning (Opus) — expensive full quality pass
* relations / relevance → standard (Sonnet) — targeted cheaper sub-task
*/
export default function GraphControls({
showExcludeNodes,
onShowExcludeChange,
excludedCount = 0,
onAnalyze,
isAnalyzing,
analyzeError,
analyzeNotice,
disabled,
onApplied,
snapshotMeta,
onRestore,
isRestoring,
}) {
const [scope, setScope] = useState('full');
const selectedOption = SCOPE_OPTIONS.find(o => o.value === scope) ?? SCOPE_OPTIONS[0];
const snapshotLabel = snapshotMeta
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: null;
return (
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3">
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
<input
type="checkbox"
checked={showExcludeNodes}
onChange={e => onShowExcludeChange(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
<span>
Show Excluded Nodes (Reference Material)
{excludedCount > 0 && (
<span className="ml-1 text-xs text-fg-muted/80">
· {excludedCount} {showExcludeNodes ? 'visible' : 'hidden'}
</span>
)}
</span>
</label>
<div className="space-y-2">
{/* Scope selector */}
<div className="relative">
<select
value={scope}
onChange={e => setScope(e.target.value)}
disabled={isAnalyzing || isRestoring || disabled}
className="w-full border border-bg-warm rounded-[var(--r-sm)] pl-3 pr-8 py-1.5 text-xs bg-paper appearance-none disabled:opacity-40 disabled:cursor-not-allowed"
title={selectedOption.hint}
>
{SCOPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>
{o.label} {o.tier}
</option>
))}
</select>
<ChevronDown
size={12}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-muted"
/>
</div>
<p className="text-xs text-fg-muted leading-snug">{selectedOption.hint}</p>
<Button
onClick={() => onAnalyze(scope)}
disabled={isAnalyzing || isRestoring || disabled}
className="w-full flex justify-center items-center gap-2"
>
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
{isAnalyzing ? 'Analyzing…' : 'Analyze & Optimize Graph'}
</Button>
{/* Restore row — only visible after a bulkSave has written a snapshot */}
{snapshotMeta && (
<button
onClick={onRestore}
disabled={isAnalyzing || isRestoring}
className="w-full flex items-center justify-center gap-1.5 text-xs text-fg-muted hover:text-teal disabled:opacity-40 disabled:cursor-not-allowed py-1 transition-colors"
title={`Restore graph to state from ${snapshotLabel} (${snapshotMeta.topicCount} topics, ${snapshotMeta.relationCount} relations)`}
>
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
{isRestoring
? 'Restoring…'
: `↩ Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
</button>
)}
{analyzeError && (
<p className="text-xs text-red-600 flex items-start gap-1">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
{analyzeError}
</p>
)}
{analyzeNotice && (
<p
className="text-xs text-teal flex items-start gap-1 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] p-2"
title="The AI suggested removing excluded/locked topics. Those suggestions were dropped client-side before saving."
>
<ShieldCheck size={14} className="shrink-0 mt-0.5" />
{analyzeNotice}
</p>
)}
</div>
<SuggestionsQueue onApplied={onApplied} />
</div>
);
}