feat: implement quiz generation service with topic selection, LLM integration, and question bank management

This commit is contained in:
RaymondVerhoef
2026-05-25 22:08:18 +02:00
parent 3c04bab1b9
commit 7164317908
5 changed files with 163 additions and 163 deletions

View File

@@ -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);
});
}); });

View File

@@ -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),

View File

@@ -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,57 +123,56 @@ 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 getTopicQuestionBank(topicId) {
return db.getQuizBank(topicId);
}
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) { export async function forceGenerateTopicQuestions(topic, count = 5) {
const existingBank = await db.getQuizBank(topic.id); const existingBank = await db.getQuizBank(topic.id);
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question))); const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
let lastQualityError = null; let lastError = null;
let candidates = null;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
let questions; let questions;
try { try {
questions = await callQuizModel(topic, count); questions = await callQuizModel(topic, count);
} catch (err) { } catch (err) {
console.warn(`[quiz] LLM failed (attempt ${attempt + 1}):`, err.message); console.warn(`[quiz] LLM call failed (attempt ${attempt + 1}):`, err.message);
lastQualityError = err.message; lastError = err.message;
continue; continue;
} }
const qualityError = validateBatchQuality(questions); const qualityError = validateBatchQuality(questions);
if (qualityError) { if (qualityError) {
lastQualityError = qualityError; console.warn(`[quiz] Batch rejected (attempt ${attempt + 1}): ${qualityError}`);
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`); lastError = qualityError;
continue; continue;
} }
const dominant = dominantCorrectIndex(questions); // Batch passed quality — deduplicate against existing bank
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 = []; const accepted = [];
for (const q of candidates) { for (const q of questions) {
const key = normalizeQuestionText(q.question); const key = normalizeQuestionText(q.question);
if (existingKeys.has(key)) { if (existingKeys.has(key)) {
console.debug('[quiz] dropped duplicate:', q.question); console.debug('[quiz] dropped duplicate:', q.question);
@@ -210,14 +186,22 @@ export async function forceGenerateTopicQuestions(topic, count = 5) {
} }
if (!accepted.length) { if (!accepted.length) {
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`); lastError = `All generated questions for "${topic.label}" were duplicates.`;
continue;
} }
const merged = [...existingBank, ...accepted]; const merged = [...existingBank, ...accepted];
await db.setQuizBank(topic.id, merged); await db.setQuizBank(topic.id, merged);
return accepted; 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) { async function getOrGenerateTopicQuestions(topic, count) {
let bank = await db.getQuizBank(topic.id); let bank = await db.getQuizBank(topic.id);
@@ -226,22 +210,17 @@ async function getOrGenerateTopicQuestions(topic, count) {
await forceGenerateTopicQuestions(topic, 5); await forceGenerateTopicQuestions(topic, 5);
bank = await db.getQuizBank(topic.id); bank = await db.getQuizBank(topic.id);
} catch (err) { } catch (err) {
console.warn(`[quiz] Failed to generate questions for ${topic.label}:`, err.message); console.warn(`[quiz] Could not populate bank for "${topic.label}":`, err.message);
} }
} }
return sample(bank, Math.min(count, bank.length)); return sample(bank, Math.min(count, bank.length));
} }
export async function getTopicQuestionBank(topicId) { // ── Weekly quiz assembly ─────────────────────────────────────
return db.getQuizBank(topicId);
}
export async function deleteQuestion(topicId, questionId) {
return db.deleteQuestionFromBank(topicId, questionId);
}
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);
} }
} }
} }
// 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); 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 = { 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);

View File

@@ -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>

View File

@@ -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>
); );
} }