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,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);
}