Files
learning-platform/src/components/admin/KnowledgeGraph.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

524 lines
22 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import * as db from '../../lib/db';
import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { Network, Table2 } from 'lucide-react';
import { useGraphData } from '../../hooks/useGraphData';
import { filterAiActions } from '../../lib/graphGuard';
import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel';
import GraphTable from './graph/GraphTable';
// ── Edge visual style per relation type ────────────────────────────────────────
// stroke — line colour
// dash — SVG stroke-dasharray (null = solid)
const EDGE_STYLE = {
related_to: { stroke: '#94A3B8', dash: '6,3' }, // slate, dashed
depends_on: { stroke: '#F97316', dash: null }, // orange, solid
part_of: { stroke: '#10B981', dash: null }, // emerald, solid
executed_by: { stroke: '#C084FC', dash: '3,3' }, // violet, dotted
};
const NODE_RADIUS = 22; // circle r (20) + white stroke buffer
// ── System prompts per analysis scope ─────────────────────────────────────────
const SYSTEM_PROMPTS = {
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
so R42 can still answer questions about them. They are intentionally outside
the theme-learning flow. Do not propose deleting or merging them away.
• Topics with relevance_locked=true are admin-pinned. Do not change their
learning_relevance and do not delete or merge them.
These topics may appear as the keepId in a merge (others fold into them), and
may appear as source/target of newRelations, but their own row must survive.
Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Do not return the entire graph — only the actions to take.`,
relations: `You are analyzing a knowledge graph for Respellion's learning platform.
Your ONLY task: identify missing logical relations between existing topics.
Suggest new relations using: depends_on, part_of, related_to, executed_by.
Only suggest connections that are clearly implied — do not invent loose associations.
Emit ONLY newRelations. Set merges=[], deletions=[], relevanceUpdates=[].`,
relevance: `You are analyzing a knowledge graph for Respellion's learning platform.
Your ONLY task: re-score the learning_relevance for each topic.
Relevance levels:
core — foundational knowledge every employee must master
standard — important, covered in the normal learning flow
peripheral — supplementary or nice-to-know, lower priority
exclude — operational/administrative, not relevant to learning (e.g. printer guides, HR forms)
Do NOT change topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Emit ONLY relevanceUpdates. Set merges=[], deletions=[], newRelations=[].`,
};
const SCOPE_CONFIRM = {
full: 'Send the entire graph to the AI (Opus) for a full quality pass — merges, deletions, missing relations, and relevance scoring. This may take a minute.',
relations: 'Ask the AI (Sonnet) to suggest missing logical relations between existing topics.',
relevance: 'Ask the AI (Sonnet) to re-score learning relevance for all unlocked topics.',
};
/**
* Knowledge-graph admin view.
*
* Orchestrator: owns the D3 canvas, selected-node cursor, and AI analysis flow.
* Data lives in useGraphData; panel UI lives in GraphControls / NodeDetailPanel.
*/
const KnowledgeGraph = () => {
const svgRef = useRef(null);
const wrapperRef = useRef(null);
const zoomRef = useRef(null); // D3 zoom behaviour — shared with panToNode
const nodesRef = useRef([]); // live node positions (mutated by D3 in-place)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null);
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null);
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
const [isRestoring, setIsRestoring] = useState(false);
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
const {
topics, relations, snapshotMeta,
reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
} = useGraphData();
// ── Canvas sizing ────────────────────────────────────────────────────────────
useEffect(() => {
if (!wrapperRef.current) return;
const measure = () => {
const { width, height } = wrapperRef.current.getBoundingClientRect();
setDimensions({ width, height });
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
// ── D3 force graph ───────────────────────────────────────────────────────────
useEffect(() => {
if (viewMode !== 'graph') return;
if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions;
d3.select(svgRef.current).selectAll('*').remove();
const filteredTopics = showExcludeNodes
? topics
: topics.filter(t => t.learning_relevance !== 'exclude');
const filteredIds = new Set(filteredTopics.map(t => t.id));
const filteredRelations = relations.filter(
r => filteredIds.has(r.source) && filteredIds.has(r.target),
);
// Spread every datum so D3 simulation mutations don't touch React state.
const nodes = filteredTopics.map(d => ({ ...d }));
const links = filteredRelations.map(d => ({ ...d }));
nodesRef.current = nodes; // D3 mutates x/y in-place — ref always has latest positions
const svg = d3
.select(svgRef.current)
.attr('viewBox', [0, 0, width, height])
.attr('width', width)
.attr('height', height);
// ── Arrowhead markers — one per relation type ──────────────────────────────
const defs = svg.append('defs');
Object.entries(EDGE_STYLE).forEach(([type, style]) => {
defs.append('marker')
.attr('id', `kb-arrow-${type}`)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10) // tip of arrow at line endpoint
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5Z')
.attr('fill', style.stroke)
.attr('opacity', 0.85);
});
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', event => g.attr('transform', event.transform));
svg.call(zoom);
zoomRef.current = zoom; // expose to panToNode
const color = d3
.scaleOrdinal()
.domain(['concept', 'role', 'process'])
.range(['#1F5560', '#5C1DD8', '#AFC0FF']);
const simulation = d3
.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40));
// ── Edges — coloured and typed ─────────────────────────────────────────────
const link = g
.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', d => EDGE_STYLE[d.type]?.stroke ?? '#94A3B8')
.attr('stroke-opacity', 0.7)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', d => EDGE_STYLE[d.type]?.dash ?? null)
.attr('marker-end', d => `url(#kb-arrow-${d.type})`);
// ── Nodes ──────────────────────────────────────────────────────────────────
const node = g
.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.call(
d3.drag()
.on('start', event => {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
})
.on('drag', event => {
event.subject.fx = event.x;
event.subject.fy = event.y;
})
.on('end', event => {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}),
);
node
.append('circle')
.attr('r', 20)
.attr('fill', d => color(d.type) || '#1F5560')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('stroke-dasharray', d => (d.learning_relevance === 'exclude' ? '4,4' : 'none'))
.attr('opacity', d => {
if (d.learning_relevance === 'exclude') return 0.3;
if (d.learning_relevance === 'peripheral') return 0.5;
return 1;
})
.on('click', (_event, d) => setSelectedNode(d));
node
.append('text')
.text(d => d.label)
.attr('x', 25)
.attr('y', 5)
.attr('font-size', '12px')
.attr('fill', 'var(--color-fg)')
.attr('class', 'font-mono select-none pointer-events-none');
// ── Tick — offset line endpoints to circle edge so arrows land cleanly ─────
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dist = Math.hypot(dx, dy) || 1;
return d.target.x - (dx / dist) * NODE_RADIUS;
})
.attr('y2', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dist = Math.hypot(dx, dy) || 1;
return d.target.y - (dy / dist) * NODE_RADIUS;
});
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
return () => simulation.stop();
}, [dimensions, topics, relations, showExcludeNodes, viewMode]);
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
const panToNode = useCallback((nodeId) => {
const n = nodesRef.current.find(nd => nd.id === nodeId);
if (!n || !svgRef.current || !zoomRef.current) return;
const { width, height } = dimensions;
const scale = 2;
d3.select(svgRef.current)
.transition()
.duration(500)
.call(
zoomRef.current.transform,
d3.zoomIdentity
.translate(width / 2 - n.x * scale, height / 2 - n.y * scale)
.scale(scale),
);
}, [dimensions]);
// ── Node navigation (jump from relation row) ─────────────────────────────────
const handleJumpToNode = useCallback((nodeId) => {
panToNode(nodeId);
const topic = topics.find(t => t.id === nodeId);
if (topic) setSelectedNode(topic);
}, [panToNode, topics]);
// ── Node mutation handlers ───────────────────────────────────────────────────
const handleNodeSave = useCallback(async (updatedTopic) => {
await updateTopic(updatedTopic);
setSelectedNode(updatedTopic);
}, [updateTopic]);
const handleNodeDelete = useCallback(async () => {
if (!selectedNode) return;
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
await deleteTopic(selectedNode.id);
setSelectedNode(null);
}
}, [selectedNode, deleteTopic]);
// ── Snapshot restore ─────────────────────────────────────────────────────────
const handleRestore = useCallback(async () => {
if (!confirm(
`Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze run. The snapshot will be cleared after restoring.`,
)) return;
setIsRestoring(true);
try {
await restoreSnapshot();
setSelectedNode(null);
} finally {
setIsRestoring(false);
}
}, [restoreSnapshot, snapshotMeta]);
// ── AI graph analysis (scoped) ───────────────────────────────────────────────
const analyzeGraph = useCallback(async (scope = 'full') => {
if (!confirm(SCOPE_CONFIRM[scope])) return;
setIsAnalyzing(true);
setAnalyzeError(null);
setAnalyzeNotice(null);
try {
const [currentTopics, currentRelations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
const tier = scope === 'full' ? 'reasoning' : 'standard';
// For full + relevance scopes the model needs to see relevance_locked so
// it can honor the "do not touch protected topics" rule. (relations-scope
// only emits newRelations, so the flag is irrelevant there.)
const sendLocked = scope === 'full' || scope === 'relevance';
const compactTopics = currentTopics.map(t => ({
id: t.id,
label: t.label,
type: t.type,
learning_relevance: t.learning_relevance,
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}),
}));
const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type,
}));
const llmResult = await callLLM({
task: `graph.analyze.${scope}`,
tier,
system: [{ type: 'text', text: SYSTEM_PROMPTS[scope], cache_control: { type: 'ephemeral' } }],
user: `Here is the current knowledge graph:\n${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`,
tools: [EMIT_GRAPH_ACTIONS_TOOL],
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
maxTokens: scope === 'full' ? 4096 : 2048,
});
const rawActions = llmResult.toolUses[0]?.input;
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.');
// ── Safety guard ─────────────────────────────────────────────────────
// Strip merges/deletions that target excluded or locked topics. Excluded
// topics are reference material kept on purpose for R42; locked topics
// are admin-pinned. The AI may suggest removing them anyway — we drop
// those suggestions before they reach bulkSave.
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
const droppedCount = dropped.deletions.length + dropped.merges.length;
if (droppedCount > 0) {
// Visible feedback so the admin knows the AI tried to touch protected rows.
console.warn(
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
dropped,
);
setAnalyzeNotice(
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
);
}
let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations];
// Full scope: merges + deletions
if (scope === 'full') {
for (const merge of actions.merges ?? []) {
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
updatedRelations = updatedRelations.map(r => ({
...r,
source: r.source === merge.deleteId ? merge.keepId : r.source,
target: r.target === merge.deleteId ? merge.keepId : r.target,
}));
}
if (actions.deletions?.length) {
const toDelete = new Set(actions.deletions);
updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id));
updatedRelations = updatedRelations.filter(
r => !toDelete.has(r.source) && !toDelete.has(r.target),
);
}
}
// Full + relations scope: new relations
if (scope === 'full' || scope === 'relations') {
for (const rel of actions.newRelations ?? []) {
const dup = updatedRelations.some(
r => r.source === rel.source && r.target === rel.target && r.type === rel.type,
);
if (!dup) updatedRelations.push(rel);
}
}
// Full + relevance scope: relevance updates (respect locked topics)
if (scope === 'full' || scope === 'relevance') {
for (const update of actions.relevanceUpdates ?? []) {
const idx = updatedTopics.findIndex(t => t.id === update.id);
if (idx !== -1 && !updatedTopics[idx].relevance_locked) {
updatedTopics[idx] = { ...updatedTopics[idx], learning_relevance: update.learning_relevance };
}
}
}
// Drop edges that now reference non-existent nodes or are self-loops.
const finalIds = new Set(updatedTopics.map(t => t.id));
updatedRelations = updatedRelations.filter(
r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target),
);
await bulkSave(updatedTopics, updatedRelations);
setSelectedNode(null);
} catch (e) {
setAnalyzeError(e.message || 'Analysis failed. Please try again.');
} finally {
setIsAnalyzing(false);
}
}, [bulkSave]);
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="relative w-full h-full flex flex-col md:flex-row">
{/* Main view: graph canvas or table */}
<div className="flex-1 flex flex-col border-r border-bg-warm min-w-0">
{/* View-mode toggle */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-bg-warm bg-paper">
<button
onClick={() => setViewMode('graph')}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
viewMode === 'graph' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
}`}
title="Force-directed graph view"
>
<Network size={13} /> Graph
</button>
<button
onClick={() => setViewMode('table')}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
viewMode === 'table' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
}`}
title="Sortable table with bulk edit"
>
<Table2 size={13} /> Table
</button>
</div>
{viewMode === 'graph' ? (
<div
ref={wrapperRef}
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing"
>
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first.
</div>
) : (
<svg ref={svgRef} className="w-full h-full" />
)}
</div>
) : (
<div className="flex-1 min-h-[400px] overflow-hidden">
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first.
</div>
) : (
<GraphTable
topics={topics}
relations={relations}
onBulkUpdate={bulkUpdateTopics}
onSelectNode={setSelectedNode}
/>
)}
</div>
)}
</div>
{/* Sidebar */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
<GraphControls
showExcludeNodes={showExcludeNodes}
onShowExcludeChange={setShowExcludeNodes}
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
onAnalyze={analyzeGraph}
isAnalyzing={isAnalyzing}
analyzeError={analyzeError}
analyzeNotice={analyzeNotice}
disabled={topics.length === 0}
onApplied={reload}
snapshotMeta={snapshotMeta}
onRestore={handleRestore}
isRestoring={isRestoring}
/>
<NodeDetailPanel
selectedNode={selectedNode}
topics={topics}
relations={relations}
onSave={handleNodeSave}
onDelete={handleNodeDelete}
onAddRelation={addRelation}
onRemoveRelation={removeRelation}
onJumpToNode={handleJumpToNode}
/>
</div>
</div>
);
};
export default KnowledgeGraph;