feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
224
src/lib/testService.js
Normal file
224
src/lib/testService.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { anthropicApi } from './api';
|
||||
import { storage } from './storage';
|
||||
|
||||
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 [];
|
||||
|
||||
// Deterministic hash for the user's current topic
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
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));
|
||||
|
||||
return { primaryTopic, reviewTopics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached quiz, or null.
|
||||
*/
|
||||
export function getCachedQuiz(userId, weekNumber) {
|
||||
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, []);
|
||||
|
||||
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
|
||||
|
||||
Topic: ${topic.label}
|
||||
Type: ${topic.type}
|
||||
Description: ${topic.description}
|
||||
|
||||
Return ONLY a JSON object with this structure:
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "unique-id-string",
|
||||
"question": "The question text",
|
||||
"topicLabel": "${topic.label}",
|
||||
"options": ["A) First option", "B) Second option", "C) Third option", "D) Fourth option"],
|
||||
"correctIndex": 0,
|
||||
"explanation": "A clear 1-2 sentence explanation of why the correct answer is correct."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Each question must have exactly 4 options.
|
||||
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D).
|
||||
- Mix difficulty: 4 easy, 4 medium, 2 hard.
|
||||
- Make questions specific and practical, not trivial.`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
|
||||
let newQuestions = [];
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
newQuestions = parsed.questions || [];
|
||||
newQuestions.forEach(q => {
|
||||
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to generate questions for topic', topic.label, e);
|
||||
throw new Error(`Could not generate questions for ${topic.label}`);
|
||||
}
|
||||
|
||||
bank = [...bank, ...newQuestions];
|
||||
storage.set(bankKey, 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, []);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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}`, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const { primaryTopic, reviewTopics } = 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 existingIds = new Set(questions.map(q => q.id));
|
||||
for (const eq of extraQs) {
|
||||
if (!existingIds.has(eq.id) && questions.length < 10) {
|
||||
questions.push(eq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
storage.set(cacheKey, 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);
|
||||
|
||||
// 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);
|
||||
|
||||
return { pointsEarned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a previously completed test result, or null.
|
||||
*/
|
||||
export function getTestResult(userId, weekNumber) {
|
||||
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
|
||||
}
|
||||
Reference in New Issue
Block a user