feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration
This commit is contained in:
245
src/lib/db.js
Normal file
245
src/lib/db.js
Normal 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) });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
6
src/lib/pb.js
Normal 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);
|
||||
@@ -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