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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user