feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration
This commit is contained in:
@@ -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> → {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"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,8 @@ import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Tag from '../ui/Tag';
|
||||
import { storage } from '../../lib/storage';
|
||||
import * as db from '../../lib/db';
|
||||
import { pb } from '../../lib/pb';
|
||||
import { useApp } from '../../store/AppContext';
|
||||
|
||||
const TeamManager = () => {
|
||||
@@ -12,44 +13,32 @@ const TeamManager = () => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadUsers = () => {
|
||||
setUsers(storage.get('users:registry', []));
|
||||
const loadUsers = async () => {
|
||||
const members = await db.getTeamMembers();
|
||||
setUsers(members);
|
||||
};
|
||||
|
||||
useEffect(() => { loadUsers(); }, []);
|
||||
|
||||
const handleSave = (e) => {
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.pin) return;
|
||||
|
||||
let updatedUsers = [...users];
|
||||
|
||||
if (editingId) {
|
||||
updatedUsers = updatedUsers.map(u => u.id === editingId ? { ...u, ...formData } : u);
|
||||
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User updated successfully.');
|
||||
} else {
|
||||
const newUser = {
|
||||
id: `u_${Date.now()}`,
|
||||
name: formData.name,
|
||||
role: formData.role,
|
||||
pin: formData.pin,
|
||||
registeredAt: new Date().toISOString()
|
||||
};
|
||||
updatedUsers.push(newUser);
|
||||
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User added successfully.');
|
||||
}
|
||||
|
||||
storage.set('users:registry', updatedUsers);
|
||||
setUsers(updatedUsers);
|
||||
await loadUsers();
|
||||
setFormData({ name: '', role: 'user', pin: '' });
|
||||
setIsEditing(false);
|
||||
setEditingId(null);
|
||||
|
||||
// In a real app we'd trigger a global state update, but here the user has to refresh or re-login if they edit themselves.
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
};
|
||||
|
||||
@@ -59,20 +48,21 @@ const TeamManager = () => {
|
||||
setEditingId(user.id);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const handleDelete = async (id) => {
|
||||
if (id === state.currentUser?.id) {
|
||||
alert("You cannot delete yourself.");
|
||||
return;
|
||||
}
|
||||
if (confirm("Are you sure you want to delete this user?")) {
|
||||
const updated = users.filter(u => u.id !== id);
|
||||
storage.set('users:registry', updated);
|
||||
setUsers(updated);
|
||||
|
||||
// Also remove them from leaderboard
|
||||
const lb = storage.get('leaderboard:current', []);
|
||||
storage.set('leaderboard:current', lb.filter(e => e.userId !== id));
|
||||
|
||||
await db.deleteTeamMember(id);
|
||||
|
||||
// Also remove from leaderboard
|
||||
try {
|
||||
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
||||
await pb.collection('leaderboard').delete(entry.id);
|
||||
} catch { /* no leaderboard entry, nothing to do */ }
|
||||
|
||||
await loadUsers();
|
||||
setMessage('User deleted.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
@@ -92,27 +82,26 @@ const TeamManager = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
||||
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
||||
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
|
||||
</h2>
|
||||
|
||||
|
||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Jane Doe"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
required
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Jane Doe"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={e => setFormData({...formData, role: e.target.value})}
|
||||
className="w-full h-11 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
|
||||
>
|
||||
@@ -121,13 +110,13 @@ const TeamManager = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Login PIN"
|
||||
type="text"
|
||||
placeholder="e.g. 1234"
|
||||
value={formData.pin}
|
||||
onChange={e => setFormData({...formData, pin: e.target.value})}
|
||||
required
|
||||
<Input
|
||||
label="Login PIN"
|
||||
type="text"
|
||||
placeholder="e.g. 1234"
|
||||
value={formData.pin}
|
||||
onChange={e => setFormData({...formData, pin: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
@@ -143,7 +132,6 @@ const TeamManager = () => {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* List */}
|
||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{users.map(user => (
|
||||
@@ -160,14 +148,14 @@ const TeamManager = () => {
|
||||
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
|
||||
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={user.id === state.currentUser?.id}
|
||||
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user