feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user