From 74ba5d3dc0ba8a155a10a3c464024e88cf393a02 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Thu, 14 May 2026 16:53:10 +0200 Subject: [PATCH] feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration --- docker-compose.prod.yml | 19 +- package-lock.json | 8 +- package.json | 1 + src/components/admin/KnowledgeGraph.jsx | 150 +++++++-------- src/components/admin/TeamManager.jsx | 88 ++++----- src/lib/db.js | 245 ++++++++++++++++++++++++ src/lib/extractionPipeline.js | 60 ++---- src/lib/learningService.js | 83 +++----- src/lib/pb.js | 6 + src/lib/testService.js | 132 ++++--------- src/pages/Admin/index.jsx | 43 ++--- src/pages/Dashboard.jsx | 85 ++++---- src/pages/Leaderboard.jsx | 78 ++++---- src/pages/Leren.jsx | 35 ++-- src/store/AppContext.jsx | 69 ++----- 15 files changed, 590 insertions(+), 512 deletions(-) create mode 100644 src/lib/db.js create mode 100644 src/lib/pb.js diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 596d2c8..c32116c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,6 +5,17 @@ services: expose: - "80" + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + restart: always + expose: + - "8090" + ports: + - "8090:8090" # Remove after initial setup via admin UI + volumes: + - pb_data:/pb/pb_data + command: ["serve", "--http=0.0.0.0:8090"] + caddy: image: caddy:2-alpine restart: always @@ -17,21 +28,25 @@ services: - /bin/sh - -c - | - # Build Caddyfile on container start CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';') cat > /etc/caddy/Caddyfile < { 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 )} - {/* Node Info Panel */}
- {/* Global Action */}
-
)}
- + {selectedNode ? ( isEditing ? (
- setEditData({...editData, label: e.target.value})} + setEditData({...editData, label: e.target.value})} />
-