feat: enhance KnowledgeGraph with edge styles, node navigation, and AI analysis scopes; update GraphControls and NodeDetailPanel for improved relation management

This commit is contained in:
RaymondVerhoef
2026-05-27 21:09:05 +02:00
parent 47a738fde3
commit ce276f0296
3 changed files with 296 additions and 117 deletions

View File

@@ -7,18 +7,64 @@ import { useGraphData } from '../../hooks/useGraphData';
import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel';
// ── 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.
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).
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.
*
* This component is the orchestrator: it owns the D3 canvas, the selected-node
* cursor, and the AI analysis flow. All data fetching lives in useGraphData;
* all panel UI lives in GraphControls and NodeDetailPanel.
*
* Line-count target: stay below 200 lines. Move any growing logic to a hook.
* 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 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);
@@ -61,9 +107,10 @@ const KnowledgeGraph = () => {
r => filteredIds.has(r.source) && filteredIds.has(r.target),
);
// Spread every node/link so D3's simulation mutations don't touch React state.
// 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)
@@ -71,13 +118,31 @@ const KnowledgeGraph = () => {
.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');
svg.call(
d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', event => g.attr('transform', event.transform)),
);
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()
@@ -91,15 +156,19 @@ const KnowledgeGraph = () => {
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40));
// ── Edges — coloured and typed ─────────────────────────────────────────────
const link = g
.append('g')
.attr('stroke', 'var(--color-bg-warm)')
.attr('stroke-opacity', 0.6)
.selectAll('line')
.data(links)
.join('line')
.attr('stroke-width', 2);
.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')
@@ -107,16 +176,16 @@ const KnowledgeGraph = () => {
.join('g')
.call(
d3.drag()
.on('start', (event) => {
.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) => {
.on('drag', event => {
event.subject.fx = event.x;
event.subject.fy = event.y;
})
.on('end', (event) => {
.on('end', event => {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
@@ -146,35 +215,65 @@ const KnowledgeGraph = () => {
.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 => d.target.x)
.attr('y2', d => d.target.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]);
// ── 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 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.`,
)
) {
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);
}
@@ -183,12 +282,9 @@ const KnowledgeGraph = () => {
// ── 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 & Optimize run. The snapshot will be cleared after restoring.`,
)
)
return;
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();
@@ -198,52 +294,42 @@ const KnowledgeGraph = () => {
}
}, [restoreSnapshot, snapshotMeta]);
// ── AI graph analysis ────────────────────────────────────────────────────────
// ── AI graph analysis (scoped) ───────────────────────────────────────────────
const analyzeGraph = useCallback(async () => {
if (
!confirm(
'This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?',
)
)
return;
const analyzeGraph = useCallback(async (scope = 'full') => {
if (!confirm(SCOPE_CONFIRM[scope])) return;
setIsAnalyzing(true);
setAnalyzeError(null);
try {
// Fetch fresh data so analysis reflects any concurrent edits.
const [currentTopics, currentRelations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
const systemPrompt = `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.
const tier = scope === 'full' ? 'reasoning' : 'standard';
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).
Do not return the entire graph — only the actions to take.`;
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({
id, label, type, learning_relevance,
// For relevance scope, include relevance_locked so the model can skip those.
const compactTopics = currentTopics.map(t => ({
id: t.id,
label: t.label,
type: t.type,
learning_relevance: t.learning_relevance,
...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
}));
const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type,
}));
const llmResult = await callLLM({
task: 'graph.analyze',
tier: 'reasoning',
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
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: 4096,
maxTokens: scope === 'full' ? 4096 : 2048,
});
const actions = llmResult.toolUses[0]?.input;
@@ -252,42 +338,46 @@ Do not return the entire graph — only the actions to take.`;
let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations];
// Apply merges: remap all edges from the deleted twin to the surviving one.
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,
}));
}
// Apply deletions.
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),
);
}
// Add new relations (skip duplicates).
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);
}
// Apply relevance updates (skip locked topics).
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 };
// 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),
);
}
}
// Drop any edges that now reference non-existent nodes or are self-loops.
// 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),
@@ -342,6 +432,7 @@ Do not return the entire graph — only the actions to take.`;
onDelete={handleNodeDelete}
onAddRelation={addRelation}
onRemoveRelation={removeRelation}
onJumpToNode={handleJumpToNode}
/>
</div>
</div>