feat: implement interactive knowledge graph visualization and AI-driven graph optimization dashboard
This commit is contained in:
@@ -20,7 +20,7 @@ services:
|
||||
# Build Caddyfile on container start
|
||||
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
|
||||
cat > /etc/caddy/Caddyfile <<EOF
|
||||
${CLEAN_DOMAIN} {
|
||||
$${CLEAN_DOMAIN} {
|
||||
reverse_proxy /* frontend:80
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -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> → {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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user