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 (
{/* Main view: graph canvas or table */}
{/* View-mode toggle */}
{viewMode === 'graph' ? (
{topics.length === 0 ? (
No knowledge graph data yet. Upload source material in the Sources tab first.
) : ( )}
) : (
{topics.length === 0 ? (
No knowledge graph data yet. Upload source material in the Sources tab first.
) : ( )}
)}
{/* Sidebar */}
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} />
); }; export default KnowledgeGraph;