diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index f9921cd..c907040 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -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} /> diff --git a/src/components/admin/graph/GraphControls.jsx b/src/components/admin/graph/GraphControls.jsx index b89524e..567cbe1 100644 --- a/src/components/admin/graph/GraphControls.jsx +++ b/src/components/admin/graph/GraphControls.jsx @@ -1,13 +1,25 @@ -import { RotateCcw, RefreshCw, AlertCircle } from 'lucide-react'; +import { useState } from 'react'; +import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } 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, - * the snapshot restore row, and the R42 suggestions queue. + * 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, @@ -21,6 +33,10 @@ export default function GraphControls({ 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; @@ -38,13 +54,35 @@ export default function GraphControls({
+ {/* Scope selector */} +
+ + +
+

{selectedOption.hint}

+ {/* Restore row — only visible after a bulkSave has written a snapshot */} @@ -58,7 +96,7 @@ export default function GraphControls({ {isRestoring ? 'Restoring…' - : `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`} + : `↩ Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`} )} diff --git a/src/components/admin/graph/NodeDetailPanel.jsx b/src/components/admin/graph/NodeDetailPanel.jsx index 746eb63..356d3c5 100644 --- a/src/components/admin/graph/NodeDetailPanel.jsx +++ b/src/components/admin/graph/NodeDetailPanel.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon } from 'lucide-react'; +import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon, ArrowUpRight } from 'lucide-react'; import Button from '../../ui/Button'; const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by']; @@ -14,11 +14,12 @@ const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by']; * Props: * selectedNode — the currently selected topic object, or null * topics — full topic list (used for the relation-target selector) - * relations — full relation list (to show outgoing edges) - * onSave(topic) — called with the fully-merged updated topic - * onDelete() — called when the admin confirms deletion - * onAddRelation(rel) — called with { source, target, type } + * relations — full relation list (to show outgoing + incoming edges) + * onSave(topic) — called with the fully-merged updated topic + * onDelete() — called when the admin confirms deletion + * onAddRelation(rel) — called with { source, target, type } * onRemoveRelation(s,t,type) — called to drop a specific edge + * onJumpToNode(id) — pan canvas to node and select it */ export default function NodeDetailPanel({ selectedNode, @@ -28,6 +29,7 @@ export default function NodeDetailPanel({ onDelete, onAddRelation, onRemoveRelation, + onJumpToNode, }) { const [isEditing, setIsEditing] = useState(false); const [editData, setEditData] = useState({}); @@ -67,6 +69,7 @@ export default function NodeDetailPanel({ }; const outgoing = relations.filter(r => r.source === selectedNode?.id); + const incoming = relations.filter(r => r.target === selectedNode?.id); return (
@@ -218,8 +221,10 @@ export default function NodeDetailPanel({ {/* Relations ───────────────────────────────────────────────────── */}
-

Relations

- + {/* Outgoing relations */} +

+ Outgoing ({outgoing.length}) +

{outgoing.length === 0 && (

No outgoing relations.

@@ -231,18 +236,63 @@ export default function NodeDetailPanel({ key={idx} className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm" > -
+
{rel.type} {' → '} {target ? target.label : rel.target}
- +
+ {target && ( + + )} + +
+
+ ); + })} +
+ + {/* Incoming relations */} +

+ Incoming ({incoming.length}) +

+
+ {incoming.length === 0 && ( +

No incoming relations.

+ )} + {incoming.map((rel, idx) => { + const source = topics.find(t => t.id === rel.source); + return ( +
+
+ {source ? source.label : rel.source} + {' → '} + {rel.type} +
+ {source && ( + + )}
); })}