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>
134 lines
5.2 KiB
JavaScript
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>
|
|
);
|
|
}
|