feat: implement interactive knowledge graph visualization and AI-driven graph optimization dashboard

This commit is contained in:
RaymondVerhoef
2026-05-11 22:32:47 +02:00
parent 8192422263
commit c940c984ad
7 changed files with 207 additions and 34 deletions

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X } from 'lucide-react';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import { storage } from '../../lib/storage';
import { anthropicApi } from '../../lib/api';
import Button from '../ui/Button';
const KnowledgeGraph = () => {
@@ -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 (
<div className="relative w-full h-full flex flex-col md:flex-row">
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
@@ -180,6 +288,24 @@ const KnowledgeGraph = () => {
{/* Node Info Panel */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
{/* Global Action */}
<div className="mb-6 pb-4 border-b border-bg-warm">
<Button
onClick={analyzeGraph}
disabled={isAnalyzing || topics.length === 0}
className="w-full flex justify-center items-center gap-2"
>
<RefreshCw size={16} className={isAnalyzing ? "animate-spin" : ""} />
{isAnalyzing ? 'Analyzing Graph...' : 'Analyze & Optimize Graph'}
</Button>
{analyzeError && (
<p className="mt-2 text-xs text-red-600 flex items-start gap-1">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
{analyzeError}
</p>
)}
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-teal">Node Details</h3>
{selectedNode && !isEditing && (
@@ -215,7 +341,6 @@ const KnowledgeGraph = () => {
<option value="concept">Concept</option>
<option value="role">Role</option>
<option value="process">Process</option>
<option value="fact">Fact</option>
</select>
</div>
<div>
@@ -251,6 +376,64 @@ const KnowledgeGraph = () => {
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">ID</p>
<p className="text-xs font-mono text-fg-muted break-all">{selectedNode.id}</p>
</div>
<div className="pt-4 border-t border-bg-warm">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
{/* Existing Relations where selectedNode is source */}
<div className="space-y-2 mb-4">
{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 (
<div key={idx} className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm">
<div className="text-xs">
<span className="font-mono text-teal">{rel.type}</span> &rarr; {targetTopic ? targetTopic.label : targetId}
</div>
<button
onClick={() => removeRelation(selectedNode.id, targetId, rel.type)}
className="text-fg-muted hover:text-red-500 p-1"
title="Remove Relation"
>
<X size={14} />
</button>
</div>
);
})}
</div>
{/* Add New Relation */}
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
<p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.type}
onChange={e => setNewRelData({...newRelData, type: e.target.value})}
>
<option value="related_to">related_to</option>
<option value="depends_on">depends_on</option>
<option value="part_of">part_of</option>
<option value="executed_by">executed_by</option>
</select>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.target}
onChange={e => setNewRelData({...newRelData, target: e.target.value})}
>
<option value="">-- Select Target Topic --</option>
{topics.filter(t => t.id !== selectedNode.id).map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<Button
onClick={addRelation}
disabled={!newRelData.target}
className="w-full py-1 text-xs mt-1"
>
<Plus size={14} className="mr-1 inline" /> Add
</Button>
</div>
</div>
</div>
)
) : (

View File

@@ -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."
}
],

View File

@@ -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"
}`;

View File

@@ -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;

View File

@@ -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 => {

View File

@@ -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 (
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<p className="text-fg-muted text-lg">
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!
</p>
</div>
@@ -229,27 +229,7 @@ const Leren = () => {
</div>
)}
{/* Create Custom Topic */}
<div className="mb-12">
<h2 className="text-xl font-bold mb-4">Explore Something New</h2>
<Card className="border border-bg-warm bg-paper">
<form onSubmit={handleCreateCustom} className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="What do you want to learn about today? (e.g. Advanced Git Workflows)"
value={customTopicQuery}
onChange={e => setCustomTopicQuery(e.target.value)}
/>
</div>
<Button type="submit" disabled={!customTopicQuery.trim() || isLoading} className="flex-shrink-0">
<Plus size={18} className="mr-2" /> Generate Topic
</Button>
</form>
<p className="text-xs text-fg-muted mt-3 flex items-center gap-1">
<BookOpen size={12}/> The AI will instantly build a new learning module for anything you type.
</p>
</Card>
</div>
{/* Other Available Topics */}
{otherTopics.length > 0 && (