|
|
|
|
@@ -4,24 +4,25 @@ import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
|
|
|
|
import { getCurrentWeekContent } from './curriculumService';
|
|
|
|
|
import { shuffle, sample } from './random';
|
|
|
|
|
|
|
|
|
|
// ── System prompt ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
Emit questions through the emit_quiz_questions tool. Each question has exactly four options; correctIndex is 0-based. Tag every question with difficulty ('easy', 'medium' or 'hard'). For a typical 5-question batch, mix difficulty roughly 2 easy / 2 medium / 1 hard; scale the ratio proportionally for larger batches.
|
|
|
|
|
|
|
|
|
|
Distribute correctIndex roughly evenly across 0, 1, 2, and 3.
|
|
|
|
|
Distribute correctIndex roughly evenly across 0, 1, 2, and 3. Do not place the correct answer at the same position more than 4 out of 10 times.
|
|
|
|
|
|
|
|
|
|
Never use filler options such as "all of the above", "none of the above", or "both A and B". Every explanation must be a substantive sentence (≥ 20 characters) describing why the correct answer is correct.`;
|
|
|
|
|
|
|
|
|
|
// ── Quality helpers ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const BANNED_OPTION_PATTERNS = [
|
|
|
|
|
/all of the above/i,
|
|
|
|
|
/none of the above/i,
|
|
|
|
|
/both [a-d] and [a-d]/i,
|
|
|
|
|
/both a and b/i,
|
|
|
|
|
/both b and c/i,
|
|
|
|
|
/both c and d/i,
|
|
|
|
|
/both a and c/i,
|
|
|
|
|
/both b and d/i,
|
|
|
|
|
/both a and d/i,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function normalizeQuestionText(text) {
|
|
|
|
|
@@ -32,15 +33,16 @@ function normalizeQuestionText(text) {
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a batch of questions for quality. Returns an error string
|
|
|
|
|
* if the batch should be rejected, or null if acceptable.
|
|
|
|
|
*/
|
|
|
|
|
function dominantCorrectIndex(questions) {
|
|
|
|
|
if (!questions.length) return null;
|
|
|
|
|
const counts = [0, 0, 0, 0];
|
|
|
|
|
for (const q of questions) counts[q.correctIndex] = (counts[q.correctIndex] || 0) + 1;
|
|
|
|
|
const max = Math.max(...counts);
|
|
|
|
|
return max / questions.length > 0.8 ? { index: counts.indexOf(max), ratio: max / questions.length } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateBatchQuality(questions) {
|
|
|
|
|
for (const q of questions) {
|
|
|
|
|
if (!q.question || !Array.isArray(q.options) || q.options.length !== 4) {
|
|
|
|
|
return `Question is malformed (missing text or wrong option count).`;
|
|
|
|
|
}
|
|
|
|
|
const distinct = new Set(q.options.map((o) => o.trim().toLowerCase()));
|
|
|
|
|
if (distinct.size < 4) {
|
|
|
|
|
return `Question "${q.question}" has duplicate options.`;
|
|
|
|
|
@@ -57,13 +59,6 @@ function validateBatchQuality(questions) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── LLM call ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Call the LLM to generate quiz questions for a topic.
|
|
|
|
|
* The Zod schema in llmSchemas.js handles coercion and defaults,
|
|
|
|
|
* so we just extract the validated `questions` array here.
|
|
|
|
|
*/
|
|
|
|
|
async function callQuizModel(topic, count) {
|
|
|
|
|
const prompt = `Generate exactly ${count} multiple-choice quiz questions for this knowledge topic and emit them via the emit_quiz_questions tool:
|
|
|
|
|
|
|
|
|
|
@@ -71,7 +66,7 @@ Topic: ${topic.label}
|
|
|
|
|
Type: ${topic.type}
|
|
|
|
|
Description: ${topic.description}
|
|
|
|
|
|
|
|
|
|
Each option must start with "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial.`;
|
|
|
|
|
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Ensure correct answers are evenly distributed.`;
|
|
|
|
|
|
|
|
|
|
const result = await callLLM({
|
|
|
|
|
task: 'quiz.generate',
|
|
|
|
|
@@ -83,38 +78,66 @@ Each option must start with "A) ", "B) ", "C) ", "D) ". Make questions specific
|
|
|
|
|
maxTokens: 4096,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// After Zod validation in callLLM, emitted.questions is a valid array
|
|
|
|
|
const questions = result.toolUses[0]?.input?.questions;
|
|
|
|
|
if (!questions?.length) {
|
|
|
|
|
throw new Error(`LLM did not produce questions for "${topic.label}".`);
|
|
|
|
|
const emitted = result.toolUses[0]?.input;
|
|
|
|
|
if (!emitted) {
|
|
|
|
|
throw new Error(`LLM did not emit tool output for ${topic.label}`);
|
|
|
|
|
}
|
|
|
|
|
return questions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Topic selection ──────────────────────────────────────────
|
|
|
|
|
// Handle different shapes the LLM might return:
|
|
|
|
|
// 1. { questions: [...] } — expected
|
|
|
|
|
// 2. [...] — model returned array directly
|
|
|
|
|
// 3. { questions: { ... } } — model wrapped single question in object
|
|
|
|
|
let questions;
|
|
|
|
|
if (Array.isArray(emitted.questions)) {
|
|
|
|
|
questions = emitted.questions;
|
|
|
|
|
} else if (Array.isArray(emitted)) {
|
|
|
|
|
questions = emitted;
|
|
|
|
|
} else if (emitted.questions && typeof emitted.questions === 'object') {
|
|
|
|
|
// Single question wrapped as object instead of array
|
|
|
|
|
questions = [emitted.questions];
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Unexpected quiz output shape for ${topic.label}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!questions.length) {
|
|
|
|
|
throw new Error(`No questions generated for ${topic.label}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure each question has required fields (fill in defaults for missing ones)
|
|
|
|
|
return questions.map((q, i) => ({
|
|
|
|
|
id: q.id || `gen-${i}-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
question: q.question || '',
|
|
|
|
|
topicLabel: q.topicLabel || topic.label,
|
|
|
|
|
options: Array.isArray(q.options) ? q.options.slice(0, 4) : [],
|
|
|
|
|
correctIndex: typeof q.correctIndex === 'number' ? q.correctIndex : 0,
|
|
|
|
|
explanation: q.explanation || 'No explanation provided.',
|
|
|
|
|
difficulty: q.difficulty || 'medium',
|
|
|
|
|
})).filter(q => q.question && q.options.length === 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function selectTestTopics(userId, isoWeekNumber) {
|
|
|
|
|
const allTopics = await db.getTopics();
|
|
|
|
|
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
|
|
|
|
if (!topics.length) return { primaryTopic: null, reviewTopics: [] };
|
|
|
|
|
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
|
|
|
|
|
|
|
|
|
// Try curriculum-based selection first
|
|
|
|
|
try {
|
|
|
|
|
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
|
|
|
|
if (weekContent?.topics?.length > 0) {
|
|
|
|
|
|
|
|
|
|
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
|
|
|
|
const primaryTopic = weekContent.topics[0];
|
|
|
|
|
if (primaryTopic?.id) {
|
|
|
|
|
if (!primaryTopic?.id) {
|
|
|
|
|
console.warn('[Test] First curriculum topic has no id — falling back to hash');
|
|
|
|
|
} else {
|
|
|
|
|
const others = topics.filter(t => t.id !== primaryTopic.id);
|
|
|
|
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
|
|
|
|
return { primaryTopic, reviewTopics };
|
|
|
|
|
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
|
|
|
|
}
|
|
|
|
|
console.warn('[Test] First curriculum topic has no id — falling back to hash');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: deterministic hash-based assignment
|
|
|
|
|
// Fallback hash-based assignment
|
|
|
|
|
const str = `${userId}:${isoWeekNumber}`;
|
|
|
|
|
let hash = 0;
|
|
|
|
|
for (let i = 0; i < str.length; i++) {
|
|
|
|
|
@@ -123,18 +146,93 @@ async function selectTestTopics(userId, isoWeekNumber) {
|
|
|
|
|
}
|
|
|
|
|
const primaryIndex = Math.abs(hash) % topics.length;
|
|
|
|
|
const primaryTopic = topics[primaryIndex];
|
|
|
|
|
|
|
|
|
|
const others = topics.filter((_, i) => i !== primaryIndex);
|
|
|
|
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
|
|
|
|
|
|
|
|
|
return { primaryTopic, reviewTopics };
|
|
|
|
|
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Question bank management ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getCachedQuiz(userId, weekNumber) {
|
|
|
|
|
return db.getCachedQuiz(userId, weekNumber);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function forceGenerateTopicQuestions(topic, count = 5) {
|
|
|
|
|
const existingBank = await db.getQuizBank(topic.id);
|
|
|
|
|
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
|
|
|
|
|
|
|
|
|
|
let lastQualityError = null;
|
|
|
|
|
let candidates = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
let questions;
|
|
|
|
|
try {
|
|
|
|
|
questions = await callQuizModel(topic, count);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[quiz] LLM failed (attempt ${attempt + 1}):`, err.message);
|
|
|
|
|
lastQualityError = err.message;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const qualityError = validateBatchQuality(questions);
|
|
|
|
|
if (qualityError) {
|
|
|
|
|
lastQualityError = qualityError;
|
|
|
|
|
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dominant = dominantCorrectIndex(questions);
|
|
|
|
|
if (dominant && attempt < 2) {
|
|
|
|
|
console.warn(`[quiz] correctIndex dominated by ${dominant.index} (${Math.round(dominant.ratio * 100)}%) — re-rolling`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
candidates = questions;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!candidates) {
|
|
|
|
|
throw new Error(`Quality gate rejected the generated batch for ${topic.label}: ${lastQualityError || 'unbalanced answer distribution'}. Click "Generate" to try again.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const accepted = [];
|
|
|
|
|
for (const q of candidates) {
|
|
|
|
|
const key = normalizeQuestionText(q.question);
|
|
|
|
|
if (existingKeys.has(key)) {
|
|
|
|
|
console.debug('[quiz] dropped duplicate:', q.question);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
existingKeys.add(key);
|
|
|
|
|
accepted.push({
|
|
|
|
|
...q,
|
|
|
|
|
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!accepted.length) {
|
|
|
|
|
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const merged = [...existingBank, ...accepted];
|
|
|
|
|
await db.setQuizBank(topic.id, merged);
|
|
|
|
|
return accepted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getOrGenerateTopicQuestions(topic, count) {
|
|
|
|
|
let bank = await db.getQuizBank(topic.id);
|
|
|
|
|
|
|
|
|
|
if (bank.length < count) {
|
|
|
|
|
try {
|
|
|
|
|
await forceGenerateTopicQuestions(topic, 5);
|
|
|
|
|
bank = await db.getQuizBank(topic.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[quiz] Failed to generate questions for ${topic.label}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sample(bank, Math.min(count, bank.length));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getTopicQuestionBank(topicId) {
|
|
|
|
|
return db.getQuizBank(topicId);
|
|
|
|
|
}
|
|
|
|
|
@@ -143,84 +241,7 @@ export async function deleteQuestion(topicId, questionId) {
|
|
|
|
|
return db.deleteQuestionFromBank(topicId, questionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate new questions for a topic and merge them into the bank.
|
|
|
|
|
* Retries up to 3 times on LLM or quality failures.
|
|
|
|
|
*/
|
|
|
|
|
export async function forceGenerateTopicQuestions(topic, count = 5) {
|
|
|
|
|
const existingBank = await db.getQuizBank(topic.id);
|
|
|
|
|
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
|
|
|
|
|
|
|
|
|
|
let lastError = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
let questions;
|
|
|
|
|
try {
|
|
|
|
|
questions = await callQuizModel(topic, count);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[quiz] LLM call failed (attempt ${attempt + 1}):`, err.message);
|
|
|
|
|
lastError = err.message;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const qualityError = validateBatchQuality(questions);
|
|
|
|
|
if (qualityError) {
|
|
|
|
|
console.warn(`[quiz] Batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
|
|
|
|
lastError = qualityError;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Batch passed quality — deduplicate against existing bank
|
|
|
|
|
const accepted = [];
|
|
|
|
|
for (const q of questions) {
|
|
|
|
|
const key = normalizeQuestionText(q.question);
|
|
|
|
|
if (existingKeys.has(key)) {
|
|
|
|
|
console.debug('[quiz] dropped duplicate:', q.question);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
existingKeys.add(key);
|
|
|
|
|
accepted.push({
|
|
|
|
|
...q,
|
|
|
|
|
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!accepted.length) {
|
|
|
|
|
lastError = `All generated questions for "${topic.label}" were duplicates.`;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const merged = [...existingBank, ...accepted];
|
|
|
|
|
await db.setQuizBank(topic.id, merged);
|
|
|
|
|
return accepted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`Quiz generation failed for "${topic.label}": ${lastError || 'unknown error'}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get questions from the bank for a topic, generating if needed.
|
|
|
|
|
* Never throws — returns whatever is available (possibly []).
|
|
|
|
|
*/
|
|
|
|
|
async function getOrGenerateTopicQuestions(topic, count) {
|
|
|
|
|
let bank = await db.getQuizBank(topic.id);
|
|
|
|
|
|
|
|
|
|
if (bank.length < count) {
|
|
|
|
|
try {
|
|
|
|
|
await forceGenerateTopicQuestions(topic, 5);
|
|
|
|
|
bank = await db.getQuizBank(topic.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[quiz] Could not populate bank for "${topic.label}":`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sample(bank, Math.min(count, bank.length));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Weekly quiz assembly ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|
|
|
|
// Return cached quiz if it has valid questions
|
|
|
|
|
if (!force) {
|
|
|
|
|
const cached = await db.getCachedQuiz(userId, weekNumber);
|
|
|
|
|
if (cached?.questions?.length > 0) return cached;
|
|
|
|
|
@@ -231,48 +252,32 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|
|
|
|
|
|
|
|
|
const questions = [];
|
|
|
|
|
|
|
|
|
|
// 1. Pull up to 3 from primary topic
|
|
|
|
|
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 3);
|
|
|
|
|
questions.push(...primaryQs);
|
|
|
|
|
|
|
|
|
|
// 2. Fill remaining slots from review topics
|
|
|
|
|
for (const rt of reviewTopics) {
|
|
|
|
|
if (questions.length >= 5) break;
|
|
|
|
|
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
|
|
|
|
questions.push(...rtQs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. If still short, pull more from primary
|
|
|
|
|
if (questions.length < 5) {
|
|
|
|
|
const needed = 5 - questions.length;
|
|
|
|
|
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
|
|
|
|
|
const existingIds = new Set(questions.map(q => q.id));
|
|
|
|
|
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
|
|
|
|
|
for (const eq of extraQs) {
|
|
|
|
|
if (!existingIds.has(eq.id) && questions.length < 5) {
|
|
|
|
|
questions.push(eq);
|
|
|
|
|
existingIds.add(eq.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Last resort: try a fresh generation if we still have 0 questions
|
|
|
|
|
if (questions.length === 0) {
|
|
|
|
|
try {
|
|
|
|
|
const freshQs = await callQuizModel(primaryTopic, 5);
|
|
|
|
|
const qualityError = validateBatchQuality(freshQs);
|
|
|
|
|
if (!qualityError) {
|
|
|
|
|
questions.push(...freshQs.slice(0, 5));
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('[quiz] Last-resort generation also failed:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (questions.length === 0) {
|
|
|
|
|
throw new Error('Could not generate any questions for this week\'s test. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shuffled = shuffle(questions).slice(0, 5);
|
|
|
|
|
|
|
|
|
|
if (shuffled.length === 0) {
|
|
|
|
|
throw new Error('Could not assemble enough questions for this week\'s test. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const quiz = {
|
|
|
|
|
questions: shuffled,
|
|
|
|
|
meta: {
|
|
|
|
|
@@ -287,8 +292,6 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|
|
|
|
return quiz;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Result management ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function saveTestResult(userId, weekNumber, result) {
|
|
|
|
|
await db.saveQuizResult(userId, weekNumber, result);
|
|
|
|
|
|
|
|
|
|
|