feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration

This commit is contained in:
RaymondVerhoef
2026-05-14 16:53:10 +02:00
parent 42d7209773
commit 74ba5d3dc0
15 changed files with 590 additions and 512 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import { storage } from '../../lib/storage';
import * as db from '../../lib/db';
import { anthropicApi } from '../../lib/api';
import Button from '../ui/Button';
@@ -16,13 +16,12 @@ const KnowledgeGraph = () => {
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([]);
const [relations, setRelations] = useState([]);
useEffect(() => {
setTopics(storage.get('kb:topics', []));
setRelations(storage.get('kb:relations', []));
db.getTopics().then(setTopics);
db.getRelations().then(setRelations);
}, []);
useEffect(() => {
@@ -58,9 +57,7 @@ const KnowledgeGraph = () => {
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
.on('zoom', (event) => { g.attr('transform', event.transform); });
svg.call(zoom);
@@ -141,65 +138,62 @@ const KnowledgeGraph = () => {
setIsEditing(true);
};
const saveEdit = () => {
const updatedTopics = topics.map(t =>
t.id === selectedNode.id ? { ...t, ...editData } : t
);
storage.set('kb:topics', updatedTopics);
setTopics(updatedTopics);
const saveEdit = async () => {
await db.upsertTopic({ ...selectedNode, ...editData });
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
setTopics(updated);
setSelectedNode({ ...selectedNode, ...editData });
setIsEditing(false);
};
const deleteNode = () => {
const deleteNode = async () => {
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
const updatedTopics = topics.filter(t => t.id !== selectedNode.id);
const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id);
storage.set('kb:topics', updatedTopics);
storage.set('kb:relations', updatedRelations);
await db.saveTopics(updatedTopics);
await db.saveRelations(updatedRelations);
await db.deleteContent(selectedNode.id);
await db.setQuizBank(selectedNode.id, []);
setTopics(updatedTopics);
setRelations(updatedRelations);
setSelectedNode(null);
setIsEditing(false);
// Clear associated content to keep it clean
storage.remove(`kb:content:${selectedNode.id}`);
storage.remove(`quiz:bank:${selectedNode.id}`);
}
};
const addRelation = () => {
const addRelation = async () => {
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);
const isDup = relations.some(r => (r.source.id || r.source) === selectedNode.id && (r.target.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);
const newRel = { source: selectedNode.id, target: newRelData.target, type: newRelData.type };
await db.addRelation(newRel);
setRelations([...relations, newRel]);
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 removeRelation = async (sourceId, targetId, type) => {
await db.removeRelation(sourceId, targetId, type);
setRelations(relations.filter(r => !(
(r.source.id || r.source) === sourceId &&
(r.target.id || r.target) === targetId &&
r.type === type
)));
};
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.
const currentTopics = await db.getTopics();
const currentRelations = await db.getRelations();
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:
@@ -221,17 +215,15 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
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 };
@@ -240,33 +232,27 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
}
}
// 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);
}
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);
await db.saveTopics(updatedTopics);
await db.saveRelations(updatedRelations);
setTopics(updatedTopics);
setRelations(updatedRelations);
setSelectedNode(null);
} catch (e) {
setAnalyzeError(e.message || 'Analysis failed. Please try again.');
} finally {
@@ -286,13 +272,11 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
)}
</div>
{/* 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}
<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" : ""} />
@@ -319,23 +303,23 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
</div>
)}
</div>
{selectedNode ? (
isEditing ? (
<div className="space-y-4 flex-1">
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Label</label>
<input
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.label}
onChange={e => setEditData({...editData, label: e.target.value})}
<input
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.label}
onChange={e => setEditData({...editData, label: e.target.value})}
/>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Type</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.type}
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.type}
onChange={e => setEditData({...editData, type: e.target.value})}
>
<option value="concept">Concept</option>
@@ -345,10 +329,10 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
<textarea
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg min-h-[100px]"
value={editData.description}
onChange={e => setEditData({...editData, description: e.target.value})}
<textarea
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg min-h-[100px]"
value={editData.description}
onChange={e => setEditData({...editData, description: e.target.value})}
/>
</div>
<div className="flex gap-2 pt-2">
@@ -364,9 +348,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
{selectedNode.type}
</span>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
@@ -376,11 +358,10 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
<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;
@@ -390,7 +371,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
<div className="text-xs">
<span className="font-mono text-teal">{rel.type}</span> &rarr; {targetTopic ? targetTopic.label : targetId}
</div>
<button
<button
onClick={() => removeRelation(selectedNode.id, targetId, rel.type)}
className="text-fg-muted hover:text-red-500 p-1"
title="Remove Relation"
@@ -402,10 +383,9 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
})}
</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
<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})}
@@ -415,7 +395,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
<option value="part_of">part_of</option>
<option value="executed_by">executed_by</option>
</select>
<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})}
@@ -425,8 +405,8 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<Button
onClick={addRelation}
<Button
onClick={addRelation}
disabled={!newRelData.target}
className="w-full py-1 text-xs mt-1"
>