From c940c984adbaf8ca2d772ecd3a41b4d769fc774b Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 11 May 2026 22:32:47 +0200 Subject: [PATCH] feat: implement interactive knowledge graph visualization and AI-driven graph optimization dashboard --- docker-compose.prod.yml | 2 +- src/components/admin/KnowledgeGraph.jsx | 191 +++++++++++++++++++++++- src/lib/extractionPipeline.js | 5 +- src/lib/learningService.js | 2 +- src/pages/Dashboard.jsx | 8 +- src/pages/Leaderboard.jsx | 7 +- src/pages/Leren.jsx | 26 +--- 7 files changed, 207 insertions(+), 34 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e98ea7e..596d2c8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,7 +20,7 @@ services: # Build Caddyfile on container start CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';') cat > /etc/caddy/Caddyfile < { @@ -11,6 +12,9 @@ const KnowledgeGraph = () => { const [selectedNode, setSelectedNode] = useState(null); const [isEditing, setIsEditing] = useState(false); const [editData, setEditData] = useState({}); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analyzeError, setAnalyzeError] = useState(null); + const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' }); // Load data into state so it can update const [topics, setTopics] = useState([]); @@ -61,8 +65,8 @@ const KnowledgeGraph = () => { svg.call(zoom); const color = d3.scaleOrdinal() - .domain(['concept', 'role', 'process', 'fact']) - .range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']); + .domain(['concept', 'role', 'process']) + .range(['#1F5560', '#5C1DD8', '#AFC0FF']); const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id(d => d.id).distance(100)) @@ -166,6 +170,110 @@ const KnowledgeGraph = () => { } }; + const addRelation = () => { + if (!newRelData.target) return; + const rawRelations = storage.get('kb:relations', []); + const isDup = rawRelations.some(r => r.source === selectedNode.id && r.target === newRelData.target && r.type === newRelData.type); + if (isDup) return; + + const updated = [...rawRelations, { source: selectedNode.id, target: newRelData.target, type: newRelData.type }]; + storage.set('kb:relations', updated); + setRelations(updated); + setNewRelData({ target: '', type: 'related_to' }); + }; + + const removeRelation = (sourceId, targetId, type) => { + const rawRelations = storage.get('kb:relations', []); + const updated = rawRelations.filter(r => !(r.source === sourceId && r.target === targetId && r.type === type)); + storage.set('kb:relations', updated); + setRelations(updated); + }; + + const analyzeGraph = 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; + + setIsAnalyzing(true); + setAnalyzeError(null); + + try { + const currentTopics = storage.get('kb:topics', []); + const currentRelations = storage.get('kb:relations', []); + + const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph. +Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add. + +Rules: +1. Identify topics that mean exactly the same thing. Choose one to keep, and one to delete. +2. Identify topics that are too vague, irrelevant, or malformed to delete. +3. Identify missing logical relations (depends_on, part_of, related_to) if two topics are conceptually linked but missing a relation. +4. Return ONLY a valid JSON object describing the ACTIONS to take. Do not return the entire graph. Do not wrap in markdown blocks.`; + + const userPrompt = `Here is the current knowledge graph: +${JSON.stringify({ topics: currentTopics, relations: currentRelations }, null, 2)} + +Analyze this graph and return ONLY the optimized JSON object with this EXACT structure: +{ + "merges": [ { "keepId": "id_to_keep", "deleteId": "id_to_delete" } ], + "deletions": [ "id_to_delete_completely" ], + "newRelations": [ { "source": "id1", "target": "id2", "type": "depends_on" } ] +}`; + + const responseText = await anthropicApi.generateContent(systemPrompt, userPrompt); + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned invalid format.'); + + const actions = JSON.parse(jsonMatch[0]); + + let updatedTopics = [...currentTopics]; + let updatedRelations = [...currentRelations]; + + // Process Merges + if (actions.merges && Array.isArray(actions.merges)) { + for (const merge of actions.merges) { + updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); + // Redirect relations + updatedRelations = updatedRelations.map(r => { + if (r.source === merge.deleteId) return { ...r, source: merge.keepId }; + if (r.target === merge.deleteId) return { ...r, target: merge.keepId }; + return r; + }); + } + } + + // Process Deletions + if (actions.deletions && Array.isArray(actions.deletions)) { + updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id)); + updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target)); + } + + // Process New Relations + if (actions.newRelations && Array.isArray(actions.newRelations)) { + for (const newRel of actions.newRelations) { + // Prevent exact duplicates + const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type); + if (!isDup) { + updatedRelations.push(newRel); + } + } + } + + // Final cleanup to remove self-referencing relations that might occur after merges + updatedRelations = updatedRelations.filter(r => r.source !== r.target); + + storage.set('kb:topics', updatedTopics); + storage.set('kb:relations', updatedRelations); + + setTopics(updatedTopics); + setRelations(updatedRelations); + setSelectedNode(null); + + } catch (e) { + setAnalyzeError(e.message || 'Analysis failed. Please try again.'); + } finally { + setIsAnalyzing(false); + } + }; + return (
@@ -180,6 +288,24 @@ const KnowledgeGraph = () => { {/* Node Info Panel */}
+ {/* Global Action */} +
+ + {analyzeError && ( +

+ + {analyzeError} +

+ )} +
+

Node Details

{selectedNode && !isEditing && ( @@ -215,7 +341,6 @@ const KnowledgeGraph = () => { -
@@ -251,6 +376,64 @@ const KnowledgeGraph = () => {

ID

{selectedNode.id}

+ +
+

Relations

+ + {/* Existing Relations where selectedNode is source */} +
+ {relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => { + const targetId = rel.target.id || rel.target; + const targetTopic = topics.find(t => t.id === targetId); + return ( +
+
+ {rel.type} → {targetTopic ? targetTopic.label : targetId} +
+ +
+ ); + })} +
+ + {/* Add New Relation */} +
+

Add Relation

+ + + +
+
) ) : ( diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index 0558957..2e4f249 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -2,7 +2,8 @@ import { anthropicApi } from './api'; import { storage } from './storage'; const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency. -You receive a source text. Your task is to extract core concepts, roles, processes, and facts, and return them as a structured JSON Knowledge Graph. +You receive a source text. Your task is to extract core concepts, roles, and processes, and return them as a structured JSON Knowledge Graph. +Facts should be integrated into the descriptions of the other labels and NOT be extracted as unique topics. ALWAYS return a valid JSON object in the following format: { @@ -10,7 +11,7 @@ ALWAYS return a valid JSON object in the following format: { "id": "unique-slug", "label": "Topic title", - "type": "concept | role | process | fact", + "type": "concept | role | process", "description": "A concise, clear explanation of max 3 sentences." } ], diff --git a/src/lib/learningService.js b/src/lib/learningService.js index 11457c5..46d769b 100644 --- a/src/lib/learningService.js +++ b/src/lib/learningService.js @@ -167,7 +167,7 @@ Create a short description (2-3 sentences) and categorize it. Return ONLY a JSON object with this structure: { "label": "Polished topic title", - "type": "concept", // one of: concept, role, process, fact + "type": "concept", // one of: concept, role, process "description": "Short description" }`; diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 6720b28..1c1bf60 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -17,7 +17,13 @@ const Dashboard = () => { const learnDone = storage.get(`user:${currentUser?.id}:week:${weekNumber}:learn_done`, false); const testResult = getTestResult(currentUser?.id, weekNumber); - const leaderboard = storage.get('leaderboard:current', []).sort((a, b) => b.points - a.points); + const allUsers = storage.get('users:registry', []); + const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); + + const leaderboard = storage.get('leaderboard:current', []) + .filter(u => nonAdminIds.includes(u.userId)) + .sort((a, b) => b.points - a.points); + const top3 = leaderboard.slice(0, 3); const myRank = leaderboard.findIndex(u => u.userId === currentUser?.id) + 1; const myPoints = leaderboard.find(u => u.userId === currentUser?.id)?.points || 0; diff --git a/src/pages/Leaderboard.jsx b/src/pages/Leaderboard.jsx index d26e2c8..c44c334 100644 --- a/src/pages/Leaderboard.jsx +++ b/src/pages/Leaderboard.jsx @@ -19,9 +19,12 @@ const Leaderboard = () => { useEffect(() => { // Re-evaluate badges before displaying let data = storage.get('leaderboard:current', []); - - // Supplement with users that haven't scored yet const allUsers = storage.get('users:registry', []); + + // Filter out admins from the leaderboard entirely + const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); + data = data.filter(entry => nonAdminIds.includes(entry.userId)); + const userMap = new Map(data.map(u => [u.userId, u])); allUsers.forEach(user => { diff --git a/src/pages/Leren.jsx b/src/pages/Leren.jsx index 0edab6c..e857b84 100644 --- a/src/pages/Leren.jsx +++ b/src/pages/Leren.jsx @@ -185,14 +185,14 @@ const Leren = () => { } // ── Overview ────────────────────────────────────────────── - const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id); + const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact'); return (

Learning Station

- You must complete at least 1 topic per week. Feel free to explore more or create your own! + You must complete at least 1 topic per week. Feel free to explore more from the library!

@@ -229,27 +229,7 @@ const Leren = () => {
)} - {/* Create Custom Topic */} -
-

Explore Something New

- -
-
- setCustomTopicQuery(e.target.value)} - /> -
- -
-

- The AI will instantly build a new learning module for anything you type. -

-
-
+ {/* Other Available Topics */} {otherTopics.length > 0 && (