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

245
src/lib/db.js Normal file
View File

@@ -0,0 +1,245 @@
import { pb } from './pb';
// ── Topics ──────────────────────────────────────────────────────────────────
export async function getTopics() {
return pb.collection('topics').getFullList({ sort: 'created' });
}
export async function saveTopics(topics) {
const existing = await pb.collection('topics').getFullList({ fields: 'id' });
await Promise.all(existing.map(r => pb.collection('topics').delete(r.id)));
return Promise.all(topics.map(t => pb.collection('topics').create({
id: t.id,
label: t.label,
type: t.type,
description: t.description,
})));
}
export async function upsertTopic(topic) {
try {
await pb.collection('topics').getOne(topic.id);
return pb.collection('topics').update(topic.id, topic);
} catch {
return pb.collection('topics').create({ id: topic.id, ...topic });
}
}
// ── Relations ────────────────────────────────────────────────────────────────
export async function getRelations() {
return pb.collection('relations').getFullList();
}
export async function saveRelations(relations) {
const existing = await pb.collection('relations').getFullList({ fields: 'id' });
await Promise.all(existing.map(r => pb.collection('relations').delete(r.id)));
return Promise.all(relations.map(r => pb.collection('relations').create(r)));
}
export async function addRelation(relation) {
return pb.collection('relations').create(relation);
}
export async function removeRelation(source, target, type) {
const records = await pb.collection('relations').getFullList({
filter: `source="${source}" && target="${target}" && type="${type}"`,
});
return Promise.all(records.map(r => pb.collection('relations').delete(r.id)));
}
// ── Content ──────────────────────────────────────────────────────────────────
export async function getContent(topicId) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return r.data;
} catch { return null; }
}
export async function setContent(topicId, data) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('content').update(r.id, { data });
} catch {
return pb.collection('content').create({ topic_id: topicId, data });
}
}
export async function deleteContent(topicId) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('content').delete(r.id);
} catch { /* no record, nothing to do */ }
}
// ── Quiz Banks ───────────────────────────────────────────────────────────────
export async function getQuizBank(topicId) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return r.questions || [];
} catch { return []; }
}
export async function setQuizBank(topicId, questions) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('quiz_banks').update(r.id, { questions });
} catch {
return pb.collection('quiz_banks').create({ topic_id: topicId, questions });
}
}
export async function deleteQuestionFromBank(topicId, questionId) {
const questions = await getQuizBank(topicId);
return setQuizBank(topicId, questions.filter(q => q.id !== questionId));
}
// ── Sources ──────────────────────────────────────────────────────────────────
export async function getSources() {
return pb.collection('sources').getFullList({ sort: '-created' });
}
export async function addSource(source) {
return pb.collection('sources').create(source);
}
export async function updateSourceStatus(id, status, error = '') {
return pb.collection('sources').update(id, { status, error });
}
export async function deleteSource(id) {
return pb.collection('sources').delete(id);
}
// ── Team Members ─────────────────────────────────────────────────────────────
export async function getTeamMembers() {
return pb.collection('team_members').getFullList({ sort: 'created' });
}
export async function addTeamMember(member) {
return pb.collection('team_members').create(member);
}
export async function updateTeamMember(id, data) {
return pb.collection('team_members').update(id, data);
}
export async function deleteTeamMember(id) {
return pb.collection('team_members').delete(id);
}
// ── Quiz Results ─────────────────────────────────────────────────────────────
export async function getQuizResult(userId, weekNumber) {
try {
return await pb.collection('quiz_results').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
export async function saveQuizResult(userId, weekNumber, result) {
return pb.collection('quiz_results').create({
user_id: userId,
week_number: weekNumber,
score: result.score,
total: result.total,
percentage: result.percentage,
time_used: result.timeUsed,
completed_at: result.completedAt,
breakdown: result.breakdown,
points_earned: result.pointsEarned,
});
}
// ── Quiz Cache ────────────────────────────────────────────────────────────────
export async function getCachedQuiz(userId, weekNumber) {
try {
return await pb.collection('quiz_cache').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
export async function setCachedQuiz(userId, weekNumber, quiz) {
try {
const r = await pb.collection('quiz_cache').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return pb.collection('quiz_cache').update(r.id, { questions: quiz.questions, meta: quiz.meta });
} catch {
return pb.collection('quiz_cache').create({
user_id: userId, week_number: weekNumber, questions: quiz.questions, meta: quiz.meta,
});
}
}
// ── Learn Progress ────────────────────────────────────────────────────────────
export async function getLearnDone(userId, weekNumber) {
try {
const r = await pb.collection('learn_progress').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return r.done;
} catch { return false; }
}
export async function setLearnDone(userId, weekNumber) {
try {
const r = await pb.collection('learn_progress').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return pb.collection('learn_progress').update(r.id, { done: true });
} catch {
return pb.collection('learn_progress').create({ user_id: userId, week_number: weekNumber, done: true });
}
}
// ── Leaderboard ───────────────────────────────────────────────────────────────
export async function getLeaderboard() {
return pb.collection('leaderboard').getFullList({ sort: '-points' });
}
export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) {
try {
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
return pb.collection('leaderboard').update(r.id, {
points: (r.points || 0) + pointsDelta,
tests_completed: (r.tests_completed || 0) + testsCompletedDelta,
});
} catch {
return pb.collection('leaderboard').create({
user_id: userId,
name,
points: pointsDelta,
tests_completed: testsCompletedDelta,
learnings_completed: 0,
});
}
}
// ── Settings ──────────────────────────────────────────────────────────────────
export async function getSetting(key, defaultValue = null) {
try {
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
return r.value ?? defaultValue;
} catch { return defaultValue; }
}
export async function setSetting(key, value) {
try {
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
return pb.collection('settings').update(r.id, { value: String(value) });
} catch {
return pb.collection('settings').create({ key, value: String(value) });
}
}

View File

@@ -1,5 +1,5 @@
import { anthropicApi } from './api';
import { storage } from './storage';
import * as db from './db';
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, and processes, and return them as a structured JSON Knowledge Graph.
@@ -25,32 +25,15 @@ ALWAYS return a valid JSON object in the following format:
}
Return JSON only. No markdown blocks or other text.`;
/**
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
* @param {string} textContent De te analyseren tekst.
* @param {string} sourceName Naam van de bron (voor tracking).
*/
export async function processSourceText(textContent, sourceName) {
// 1. Sla de bron eerst op als "processing"
const sourceId = `src_${Date.now()}`;
const newSource = {
id: sourceId,
name: sourceName,
status: 'processing',
date: new Date().toISOString()
};
const sources = storage.get('admin:sources', []);
storage.set('admin:sources', [newSource, ...sources]);
const rec = await db.addSource({ name: sourceName, status: 'processing' });
const sourceId = rec.id;
try {
// 2. Roep Anthropic API aan
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
// 3. Parse JSON (veilig)
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
let extractedData;
try {
// Zoek naar JSON in de response (indien het toch in markdown was verpakt)
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
@@ -58,36 +41,23 @@ export async function processSourceText(textContent, sourceName) {
throw new Error('AI response was not valid JSON.');
}
// 4. Deduplicatie en opslag van Topics en Relaties
mergeKnowledgeGraph(extractedData);
// 5. Markeer bron als voltooid
updateSourceStatus(sourceId, 'completed');
await mergeKnowledgeGraph(extractedData);
await db.updateSourceStatus(sourceId, 'completed');
return { success: true, data: extractedData };
} catch (error) {
// Markeer bron als mislukt
updateSourceStatus(sourceId, 'failed', error.message);
await db.updateSourceStatus(sourceId, 'failed', error.message);
throw error;
}
}
function updateSourceStatus(sourceId, status, errorMsg = '') {
const sources = storage.get('admin:sources', []);
const updated = sources.map(s =>
s.id === sourceId ? { ...s, status, error: errorMsg } : s
);
storage.set('admin:sources', updated);
}
async function mergeKnowledgeGraph(newData) {
const existingTopics = await db.getTopics();
const existingRelations = await db.getRelations();
function mergeKnowledgeGraph(newData) {
const existingTopics = storage.get('kb:topics', []);
const existingRelations = storage.get('kb:relations', []);
// Simpele deduplicatie op ID
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
if (newData.topics && Array.isArray(newData.topics)) {
for (const t of newData.topics) {
if (!topicsMap.has(t.id)) {
@@ -96,11 +66,9 @@ function mergeKnowledgeGraph(newData) {
}
}
// Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
const newRelations = [...existingRelations];
if (newData.relations && Array.isArray(newData.relations)) {
for (const r of newData.relations) {
// Simpele check om exacte duplicaten te voorkomen
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
if (!isDup) {
newRelations.push(r);
@@ -108,6 +76,6 @@ function mergeKnowledgeGraph(newData) {
}
}
storage.set('kb:topics', Array.from(topicsMap.values()));
storage.set('kb:relations', newRelations);
await db.saveTopics(Array.from(topicsMap.values()));
await db.saveRelations(newRelations);
}

View File

@@ -1,5 +1,5 @@
import { anthropicApi } from './api';
import { storage } from './storage';
import * as db from './db';
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You write training material for employees based on knowledge topics.
@@ -33,12 +33,8 @@ const CONTENT_SCHEMA = `{
}
}`;
/**
* Get the assigned topic for a user for a given week using round-robin.
* hash(userId + weekNumber) % topicCount
*/
export function getAssignedTopic(userId, weekNumber) {
const topics = storage.get('kb:topics', []);
export async function getAssignedTopic(userId, weekNumber) {
const topics = await db.getTopics();
if (!topics || topics.length === 0) return null;
const str = `${userId}:${weekNumber}`;
@@ -51,43 +47,24 @@ export function getAssignedTopic(userId, weekNumber) {
return topics[index];
}
/**
* Returns the cache key for a topic's content.
*/
export function getContentCacheKey(topicId) {
return `kb:content:${topicId}`;
export async function getCachedContent(topicId) {
return db.getContent(topicId);
}
/**
* Returns cached content for a topic, or null if none exists.
*/
export function getCachedContent(topicId) {
return storage.get(getContentCacheKey(topicId), null);
export async function getAllGeneratedContent() {
const topics = await db.getTopics();
const results = await Promise.all(
topics.map(async topic => {
const content = await db.getContent(topic.id);
return { topic, content, hasContent: !!content };
})
);
return results.filter(item => item.hasContent);
}
/**
* List all topics that have generated content.
*/
export function getAllGeneratedContent() {
const topics = storage.get('kb:topics', []);
return topics
.map(topic => ({
topic,
content: getCachedContent(topic.id),
hasContent: !!getCachedContent(topic.id),
}))
.filter(item => item.hasContent);
}
/**
* Generate a complete learning module for a topic.
* Uses cached version if available (unless force = true).
*/
export async function generateLearningContent(topic, force = false) {
const cacheKey = getContentCacheKey(topic.id);
if (!force) {
const cached = storage.get(cacheKey);
const cached = await db.getContent(topic.id);
if (cached) {
console.log(`[Learn] Cache hit for topic: ${topic.id}`);
return cached;
@@ -115,17 +92,12 @@ Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the inf
throw new Error('AI could not generate valid learning content. Please try again.');
}
storage.set(cacheKey, content);
await db.setContent(topic.id, content);
return content;
}
/**
* Refine existing content for a topic using a natural language instruction.
* Sends current content + refinement prompt to AI, saves new version.
*/
export async function refineLearningContent(topic, refinementInstruction) {
const cacheKey = getContentCacheKey(topic.id);
const existing = storage.get(cacheKey);
const existing = await db.getContent(topic.id);
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
@@ -146,22 +118,16 @@ Apply the refinement and return the complete updated JSON object using the same
throw new Error('AI could not process the refinement. Please try a different instruction.');
}
storage.set(cacheKey, content);
await db.setContent(topic.id, content);
return content;
}
/**
* Delete cached content for a topic, forcing a fresh generation next time.
*/
export function deleteCachedContent(topicId) {
storage.remove(getContentCacheKey(topicId));
export async function deleteCachedContent(topicId) {
return db.deleteContent(topicId);
}
/**
* Generate a new custom topic metadata on the fly from user input.
*/
export async function generateCustomTopic(label) {
const prompt = `A user wants to learn about "${label}".
const prompt = `A user wants to learn about "${label}".
Create a short description (2-3 sentences) and categorize it.
Return ONLY a JSON object with this structure:
@@ -172,7 +138,7 @@ Return ONLY a JSON object with this structure:
}`;
const responseText = await anthropicApi.generateContent(
"You are a knowledge graph AI categorizing topics.",
"You are a knowledge graph AI categorizing topics.",
prompt
);
@@ -185,9 +151,6 @@ Return ONLY a JSON object with this structure:
throw new Error('Could not process custom topic. Please try again.');
}
// Add to global knowledge base so others can see it too
const topics = storage.get('kb:topics', []);
storage.set('kb:topics', [...topics, newTopic]);
await db.upsertTopic(newTopic);
return newTopic;
}

6
src/lib/pb.js Normal file
View File

@@ -0,0 +1,6 @@
import PocketBase from 'pocketbase';
const pbUrl = import.meta.env.VITE_PB_URL ||
(typeof window !== 'undefined' ? window.location.origin + '/pb' : 'http://localhost:8090');
export const pb = new PocketBase(pbUrl);

View File

@@ -1,21 +1,15 @@
import { anthropicApi } from './api';
import { storage } from './storage';
import * as db from './db';
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
You generate multiple-choice questions to test employee knowledge on specific topics.
Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
/**
* Select topics for the weekly test:
* - 50% from the user's assigned learning topic this week
* - 50% from random other topics (review)
*/
function selectTestTopics(userId, weekNumber) {
const topics = storage.get('kb:topics', []);
if (!topics || topics.length === 0) return [];
async function selectTestTopics(userId, weekNumber) {
const topics = await db.getTopics();
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
// Deterministic hash for the user's current topic
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -25,7 +19,6 @@ function selectTestTopics(userId, weekNumber) {
const primaryIndex = Math.abs(hash) % topics.length;
const primaryTopic = topics[primaryIndex];
// Pick up to 5 "review" topics (random, different from primary)
const others = topics.filter((_, i) => i !== primaryIndex);
const shuffled = others.sort(() => 0.5 - Math.random());
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
@@ -33,19 +26,12 @@ function selectTestTopics(userId, weekNumber) {
return { primaryTopic, reviewTopics };
}
/**
* Retrieve cached quiz, or null.
*/
export function getCachedQuiz(userId, weekNumber) {
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
export async function getCachedQuiz(userId, weekNumber) {
return db.getCachedQuiz(userId, weekNumber);
}
/**
* Exported helper for admin: manually trigger generation for a topic.
*/
export async function forceGenerateTopicQuestions(topic, count = 10) {
const bankKey = `quiz:bank:${topic.id}`;
let bank = storage.get(bankKey, []);
let bank = await db.getQuizBank(topic.id);
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
@@ -88,77 +74,52 @@ Rules:
}
bank = [...bank, ...newQuestions];
storage.set(bankKey, bank);
await db.setQuizBank(topic.id, bank);
return newQuestions;
}
/**
* Ensure a topic has enough questions in its bank, generating more if needed.
* Returns exactly `count` questions.
*/
async function getOrGenerateTopicQuestions(topic, count) {
const bankKey = `quiz:bank:${topic.id}`;
let bank = storage.get(bankKey, []);
let bank = await db.getQuizBank(topic.id);
// If we don't have enough questions, ask AI to generate a batch of 10
if (bank.length < count) {
await forceGenerateTopicQuestions(topic, 10);
bank = storage.get(bankKey, []); // reload
bank = await db.getQuizBank(topic.id);
}
// Shuffle and pick `count` questions
const shuffled = [...bank].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, shuffled.length));
}
/**
* Admin Helper: get question bank for a topic
*/
export function getTopicQuestionBank(topicId) {
return storage.get(`quiz:bank:${topicId}`, []);
export async function getTopicQuestionBank(topicId) {
return db.getQuizBank(topicId);
}
/**
* Admin Helper: delete a single question
*/
export function deleteQuestion(topicId, questionId) {
const bankKey = `quiz:bank:${topicId}`;
const bank = storage.get(bankKey, []);
storage.set(bankKey, bank.filter(q => q.id !== questionId));
export async function deleteQuestion(topicId, questionId) {
return db.deleteQuestionFromBank(topicId, questionId);
}
/**
* Generate 10 MCQ questions for a user's weekly test.
* Caches the result so the same quiz is served on retry.
* Pulls from topic-specific question banks, generating more if banks are low.
*/
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
const cacheKey = `quiz:${userId}:week:${weekNumber}`;
if (!force) {
const cached = storage.get(cacheKey);
const cached = await db.getCachedQuiz(userId, weekNumber);
if (cached) return cached;
}
const { primaryTopic, reviewTopics } = selectTestTopics(userId, weekNumber);
const { primaryTopic, reviewTopics } = await selectTestTopics(userId, weekNumber);
if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
const questions = [];
// Get 5 questions for the primary topic
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
questions.push(...primaryQs);
// Get 1 question for each of the up to 5 review topics
for (const rt of reviewTopics) {
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
questions.push(...rtQs);
}
// If there are fewer than 10 questions (e.g. no review topics yet), pad with more from primary
if (questions.length < 10) {
const needed = 10 - questions.length;
// We already took 5, so let's try to get enough extra unique questions
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
const existingIds = new Set(questions.map(q => q.id));
for (const eq of extraQs) {
if (!existingIds.has(eq.id) && questions.length < 10) {
@@ -167,58 +128,33 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
}
}
// Shuffle the final 10 questions
questions.sort(() => 0.5 - Math.random());
const quiz = { questions };
quiz.meta = {
userId,
weekNumber,
generatedAt: new Date().toISOString(),
primaryTopic: primaryTopic.label,
const quiz = {
questions,
meta: {
userId,
weekNumber,
generatedAt: new Date().toISOString(),
primaryTopic: primaryTopic.label,
},
};
storage.set(cacheKey, quiz);
await db.setCachedQuiz(userId, weekNumber, quiz);
return quiz;
}
/**
* Save a completed test result.
*/
export function saveTestResult(userId, weekNumber, result) {
const key = `quiz:result:${userId}:week:${weekNumber}`;
storage.set(key, result);
export async function saveTestResult(userId, weekNumber, result) {
await db.saveQuizResult(userId, weekNumber, result);
// Update leaderboard points
const leaderboard = storage.get('leaderboard:current', []);
const entry = leaderboard.find(e => e.userId === userId);
const pointsEarned = result.score * 2; // 2 pts per correct answer
if (entry) {
entry.points += pointsEarned;
entry.testsCompleted = (entry.testsCompleted || 0) + 1;
} else {
const users = storage.get('users:registry', []);
const user = users.find(u => u.id === userId);
leaderboard.push({
userId,
name: user?.name || 'Unknown',
points: pointsEarned,
testsCompleted: 1,
learningsCompleted: 0,
});
}
// Sort desc
leaderboard.sort((a, b) => b.points - a.points);
storage.set('leaderboard:current', leaderboard);
const pointsEarned = result.score * 2;
const members = await db.getTeamMembers();
const member = members.find(m => m.id === userId);
await db.upsertLeaderboardEntry(userId, member?.name || 'Unknown', pointsEarned, 1);
return { pointsEarned };
}
/**
* Get a previously completed test result, or null.
*/
export function getTestResult(userId, weekNumber) {
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
export async function getTestResult(userId, weekNumber) {
return db.getQuizResult(userId, weekNumber);
}