feat: implement quiz generation service with topic selection, LLM integration, and question bank management
This commit is contained in:
@@ -24,8 +24,7 @@ vi.mock('../llm', () => ({
|
|||||||
cachedSystem: (text) => [{ type: 'text', text }],
|
cachedSystem: (text) => [{ type: 'text', text }],
|
||||||
}));
|
}));
|
||||||
vi.mock('../curriculumService', () => ({
|
vi.mock('../curriculumService', () => ({
|
||||||
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
|
getCurrentWeekContent: vi.fn(async () => null),
|
||||||
getQuarterForWeek: vi.fn(() => 1),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { forceGenerateTopicQuestions } from '../testService';
|
import { forceGenerateTopicQuestions } from '../testService';
|
||||||
@@ -67,17 +66,6 @@ describe('forceGenerateTopicQuestions', () => {
|
|||||||
expect(bankStore.get('onboarding')).toHaveLength(5);
|
expect(bankStore.get('onboarding')).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-rolls when one correctIndex dominates the batch, then accepts on the third try', async () => {
|
|
||||||
const allZero = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { correctIndex: 0 }));
|
|
||||||
llmEmits(allZero);
|
|
||||||
llmEmits(allZero);
|
|
||||||
llmEmits(allZero);
|
|
||||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
|
||||||
expect(callLLMMock).toHaveBeenCalledTimes(3);
|
|
||||||
expect(out).toHaveLength(5);
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('correctIndex dominated'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a batch containing a banned "all of the above" option', async () => {
|
it('rejects a batch containing a banned "all of the above" option', async () => {
|
||||||
const bad = [0, 1, 2, 3, 4].map((i) =>
|
const bad = [0, 1, 2, 3, 4].map((i) =>
|
||||||
makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }),
|
makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }),
|
||||||
@@ -85,7 +73,7 @@ describe('forceGenerateTopicQuestions', () => {
|
|||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i);
|
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|failed/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a batch where an explanation is too short', async () => {
|
it('rejects a batch where an explanation is too short', async () => {
|
||||||
@@ -93,7 +81,7 @@ describe('forceGenerateTopicQuestions', () => {
|
|||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
llmEmits(bad);
|
llmEmits(bad);
|
||||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i);
|
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|failed/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
|
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
|
||||||
@@ -112,4 +100,19 @@ describe('forceGenerateTopicQuestions', () => {
|
|||||||
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
|
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
|
||||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries on LLM failure and succeeds on a later attempt', async () => {
|
||||||
|
callLLMMock.mockRejectedValueOnce(new Error('API timeout'));
|
||||||
|
llmEmits([0, 1, 2, 3, 4].map((i) => makeQuestion(i)));
|
||||||
|
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||||
|
expect(callLLMMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(out).toHaveLength(5);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('LLM call failed'), expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws after 3 consecutive LLM failures', async () => {
|
||||||
|
callLLMMock.mockRejectedValue(new Error('schema validation'));
|
||||||
|
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/failed.*schema validation/i);
|
||||||
|
expect(callLLMMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const learningAllSchema = z.object({
|
|||||||
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']).catch('medium');
|
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']).catch('medium');
|
||||||
|
|
||||||
const quizQuestionSchema = z.object({
|
const quizQuestionSchema = z.object({
|
||||||
id: z.string().min(1).catch(`gen-${Math.random().toString(36).slice(2, 8)}`),
|
id: z.string().min(1).catch(() => `gen-${Math.random().toString(36).slice(2, 8)}`),
|
||||||
question: z.string().min(1),
|
question: z.string().min(1),
|
||||||
topicLabel: z.string().min(1).catch('General'),
|
topicLabel: z.string().min(1).catch('General'),
|
||||||
options: z.array(z.string().min(1)).length(4),
|
options: z.array(z.string().min(1)).length(4),
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
|||||||
import { getCurrentWeekContent } from './curriculumService';
|
import { getCurrentWeekContent } from './curriculumService';
|
||||||
import { shuffle, sample } from './random';
|
import { shuffle, sample } from './random';
|
||||||
|
|
||||||
|
// ── System prompt ────────────────────────────────────────────
|
||||||
|
|
||||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
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.
|
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||||
Always write in clear, professional English.
|
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.
|
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. Do not place the correct answer at the same position more than 4 out of 10 times.
|
Distribute correctIndex roughly evenly across 0, 1, 2, and 3.
|
||||||
|
|
||||||
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.`;
|
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 = [
|
const BANNED_OPTION_PATTERNS = [
|
||||||
/all of the above/i,
|
/all of the above/i,
|
||||||
/none of the above/i,
|
/none of the above/i,
|
||||||
/both a and b/i,
|
/both [a-d] and [a-d]/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) {
|
function normalizeQuestionText(text) {
|
||||||
@@ -33,16 +32,15 @@ function normalizeQuestionText(text) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dominantCorrectIndex(questions) {
|
/**
|
||||||
if (!questions.length) return null;
|
* Validate a batch of questions for quality. Returns an error string
|
||||||
const counts = [0, 0, 0, 0];
|
* if the batch should be rejected, or null if acceptable.
|
||||||
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) {
|
function validateBatchQuality(questions) {
|
||||||
for (const q of 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()));
|
const distinct = new Set(q.options.map((o) => o.trim().toLowerCase()));
|
||||||
if (distinct.size < 4) {
|
if (distinct.size < 4) {
|
||||||
return `Question "${q.question}" has duplicate options.`;
|
return `Question "${q.question}" has duplicate options.`;
|
||||||
@@ -59,6 +57,13 @@ function validateBatchQuality(questions) {
|
|||||||
return null;
|
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) {
|
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:
|
const prompt = `Generate exactly ${count} multiple-choice quiz questions for this knowledge topic and emit them via the emit_quiz_questions tool:
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ Topic: ${topic.label}
|
|||||||
Type: ${topic.type}
|
Type: ${topic.type}
|
||||||
Description: ${topic.description}
|
Description: ${topic.description}
|
||||||
|
|
||||||
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Ensure correct answers are evenly distributed.`;
|
Each option must start with "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial.`;
|
||||||
|
|
||||||
const result = await callLLM({
|
const result = await callLLM({
|
||||||
task: 'quiz.generate',
|
task: 'quiz.generate',
|
||||||
@@ -78,66 +83,38 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
|
|||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emitted = result.toolUses[0]?.input;
|
// After Zod validation in callLLM, emitted.questions is a valid array
|
||||||
if (!emitted) {
|
const questions = result.toolUses[0]?.input?.questions;
|
||||||
throw new Error(`LLM did not emit tool output for ${topic.label}`);
|
if (!questions?.length) {
|
||||||
|
throw new Error(`LLM did not produce questions for "${topic.label}".`);
|
||||||
}
|
}
|
||||||
|
return questions;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Topic selection ──────────────────────────────────────────
|
||||||
|
|
||||||
async function selectTestTopics(userId, isoWeekNumber) {
|
async function selectTestTopics(userId, isoWeekNumber) {
|
||||||
const allTopics = await db.getTopics();
|
const allTopics = await db.getTopics();
|
||||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
if (!topics.length) return { primaryTopic: null, reviewTopics: [] };
|
||||||
|
|
||||||
|
// Try curriculum-based selection first
|
||||||
try {
|
try {
|
||||||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||||
|
if (weekContent?.topics?.length > 0) {
|
||||||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
|
||||||
const primaryTopic = weekContent.topics[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 others = topics.filter(t => t.id !== primaryTopic.id);
|
||||||
const reviewTopics = sample(others, Math.min(5, others.length));
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
return { primaryTopic, reviewTopics };
|
||||||
}
|
}
|
||||||
|
console.warn('[Test] First curriculum topic has no id — falling back to hash');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback hash-based assignment
|
// Fallback: deterministic hash-based assignment
|
||||||
const str = `${userId}:${isoWeekNumber}`;
|
const str = `${userId}:${isoWeekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -146,93 +123,18 @@ async function selectTestTopics(userId, isoWeekNumber) {
|
|||||||
}
|
}
|
||||||
const primaryIndex = Math.abs(hash) % topics.length;
|
const primaryIndex = Math.abs(hash) % topics.length;
|
||||||
const primaryTopic = topics[primaryIndex];
|
const primaryTopic = topics[primaryIndex];
|
||||||
|
|
||||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||||
const reviewTopics = sample(others, Math.min(5, others.length));
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||||
|
|
||||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
return { primaryTopic, reviewTopics };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Question bank management ────────────────────────────────
|
||||||
|
|
||||||
export async function getCachedQuiz(userId, weekNumber) {
|
export async function getCachedQuiz(userId, weekNumber) {
|
||||||
return db.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) {
|
export async function getTopicQuestionBank(topicId) {
|
||||||
return db.getQuizBank(topicId);
|
return db.getQuizBank(topicId);
|
||||||
}
|
}
|
||||||
@@ -241,7 +143,84 @@ export async function deleteQuestion(topicId, questionId) {
|
|||||||
return db.deleteQuestionFromBank(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) {
|
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
||||||
|
// Return cached quiz if it has valid questions
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = await db.getCachedQuiz(userId, weekNumber);
|
const cached = await db.getCachedQuiz(userId, weekNumber);
|
||||||
if (cached?.questions?.length > 0) return cached;
|
if (cached?.questions?.length > 0) return cached;
|
||||||
@@ -252,32 +231,48 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|||||||
|
|
||||||
const questions = [];
|
const questions = [];
|
||||||
|
|
||||||
|
// 1. Pull up to 3 from primary topic
|
||||||
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 3);
|
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 3);
|
||||||
questions.push(...primaryQs);
|
questions.push(...primaryQs);
|
||||||
|
|
||||||
|
// 2. Fill remaining slots from review topics
|
||||||
for (const rt of reviewTopics) {
|
for (const rt of reviewTopics) {
|
||||||
if (questions.length >= 5) break;
|
if (questions.length >= 5) break;
|
||||||
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
||||||
questions.push(...rtQs);
|
questions.push(...rtQs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. If still short, pull more from primary
|
||||||
if (questions.length < 5) {
|
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 existingIds = new Set(questions.map(q => q.id));
|
||||||
|
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
|
||||||
for (const eq of extraQs) {
|
for (const eq of extraQs) {
|
||||||
if (!existingIds.has(eq.id) && questions.length < 5) {
|
if (!existingIds.has(eq.id) && questions.length < 5) {
|
||||||
questions.push(eq);
|
questions.push(eq);
|
||||||
|
existingIds.add(eq.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shuffled = shuffle(questions).slice(0, 5);
|
// 4. Last resort: try a fresh generation if we still have 0 questions
|
||||||
|
if (questions.length === 0) {
|
||||||
if (shuffled.length === 0) {
|
try {
|
||||||
throw new Error('Could not assemble enough questions for this week\'s test. Please try again.');
|
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);
|
||||||
|
|
||||||
const quiz = {
|
const quiz = {
|
||||||
questions: shuffled,
|
questions: shuffled,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -292,6 +287,8 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|||||||
return quiz;
|
return quiz;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Result management ────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveTestResult(userId, weekNumber, result) {
|
export async function saveTestResult(userId, weekNumber, result) {
|
||||||
await db.saveQuizResult(userId, weekNumber, result);
|
await db.saveQuizResult(userId, weekNumber, result);
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const Dashboard = () => {
|
|||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl">Testing</h3>
|
<h3 className="text-xl">Testing</h3>
|
||||||
<p className="text-fg-muted text-sm mt-1">Weekly test — 5 questions</p>
|
<p className="text-fg-muted text-sm mt-1">Weekly knowledge test</p>
|
||||||
</div>
|
</div>
|
||||||
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
|
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const Testen = () => {
|
|||||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
|
||||||
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p>
|
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p>
|
||||||
<p className="text-fg-muted mb-8 max-w-md mx-auto">
|
<p className="text-fg-muted mb-8 max-w-md mx-auto">
|
||||||
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
Test your knowledge with AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -196,9 +196,9 @@ const Testen = () => {
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-center gap-6 text-sm text-fg-muted">
|
<div className="flex items-center justify-center gap-6 text-sm text-fg-muted">
|
||||||
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 5 questions</span>
|
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> Up to 5 questions</span>
|
||||||
<span className="flex items-center gap-1.5"><Clock size={14} /> 5 minutes</span>
|
<span className="flex items-center gap-1.5"><Clock size={14} /> 5 minutes</span>
|
||||||
<span className="flex items-center gap-1.5"><Trophy size={14} /> 20 pts max</span>
|
<span className="flex items-center gap-1.5"><Trophy size={14} /> 2 pts each</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={startQuiz} className="text-lg px-8 py-3">
|
<Button onClick={startQuiz} className="text-lg px-8 py-3">
|
||||||
Start Test <ArrowRight size={20} className="ml-2" />
|
Start Test <ArrowRight size={20} className="ml-2" />
|
||||||
@@ -215,7 +215,7 @@ const Testen = () => {
|
|||||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
<p className="font-medium text-lg">AI is generating your test...</p>
|
<p className="font-medium text-lg">AI is generating your test...</p>
|
||||||
<p className="text-sm text-fg-muted mt-2">Preparing 5 questions based on your learning topics.</p>
|
<p className="text-sm text-fg-muted mt-2">Preparing questions based on your learning topics.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user