From a653812cd81d6637ef8e3cccd7cabb3ae6ed8d8a Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 25 May 2026 22:17:26 +0200 Subject: [PATCH] feat: implement micro-learning generation service and UI components for interactive topic-based learning --- .../micro_learning/MicroLearningContainer.jsx | 9 +- .../micro_learning/MicroLearningSelector.jsx | 66 ++-- src/hooks/useMicroLearnings.js | 12 +- src/lib/__tests__/testService.test.js | 37 ++- src/lib/db.js | 6 +- src/lib/llmSchemas.js | 2 +- src/lib/microLearningService.js | 25 +- src/lib/testService.js | 281 +++++++++--------- src/pages/Dashboard.jsx | 25 +- src/pages/Testen.jsx | 27 +- 10 files changed, 227 insertions(+), 263 deletions(-) diff --git a/src/components/micro_learning/MicroLearningContainer.jsx b/src/components/micro_learning/MicroLearningContainer.jsx index 0a78d42..97a320c 100644 --- a/src/components/micro_learning/MicroLearningContainer.jsx +++ b/src/components/micro_learning/MicroLearningContainer.jsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import ConceptExplainer from './ConceptExplainer'; import ScenarioQuiz from './ScenarioQuiz'; import FlashcardSet from './FlashcardSet'; - +import ReflectionPrompt from './ReflectionPrompt'; import { useMicroLearningCompletions } from '../../hooks/useMicroLearningCompletions'; export default function MicroLearningContainer({ microLearning, sessionWeek, onCompletedSuccessfully }) { @@ -13,7 +13,7 @@ export default function MicroLearningContainer({ microLearning, sessionWeek, onC const handleComplete = async () => { if (completed) return; // Prevent double recording - + const record = await recordCompletion({ microLearningId: microLearning.id, topicId: microLearning.topic_id, @@ -42,7 +42,8 @@ export default function MicroLearningContainer({ microLearning, sessionWeek, onC return ; case 'flashcard_set': return ; - + case 'reflection_prompt': + return ; default: return
Unknown micro learning type.
; } @@ -51,7 +52,7 @@ export default function MicroLearningContainer({ microLearning, sessionWeek, onC return (
{renderComponent()} - + {completed && (
diff --git a/src/components/micro_learning/MicroLearningSelector.jsx b/src/components/micro_learning/MicroLearningSelector.jsx index 055eefb..55821d7 100644 --- a/src/components/micro_learning/MicroLearningSelector.jsx +++ b/src/components/micro_learning/MicroLearningSelector.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { Loader, BookOpen, Target, Layers, CheckCircle } from 'lucide-react'; +import React, { useState } from 'react'; +import { Loader, BookOpen, Target, Layers, MessageCircle } from 'lucide-react'; import { useMicroLearnings } from '../../hooks/useMicroLearnings'; import MicroLearningContainer from './MicroLearningContainer'; import Card from '../ui/Card'; @@ -23,28 +23,19 @@ const TYPES = [ description: 'Test your recall with a set of quick flashcards.', icon: Layers, }, + { + key: 'reflection_prompt', + label: 'Reflection Prompt', + description: 'Connect the topic to your own professional experience.', + icon: MessageCircle, + }, ]; export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) { - const { getOrGenerate, checkExistingTypes } = useMicroLearnings(); + const { getOrGenerate } = useMicroLearnings(); const [selectedML, setSelectedML] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [existingTypes, setExistingTypes] = useState({}); - - // Check which types already have generated content - useEffect(() => { - if (!topicId) return; - let cancelled = false; - - checkExistingTypes(topicId).then((result) => { - if (!cancelled) setExistingTypes(result); - }).catch((err) => { - console.warn('[MicroLearningSelector] Could not check existing types:', err); - }); - - return () => { cancelled = true; }; - }, [topicId]); const handleSelection = async (type) => { setLoading(true); @@ -52,8 +43,6 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom try { const record = await getOrGenerate(topicId, type); setSelectedML(record); - // Mark this type as existing after successful generation - setExistingTypes(prev => ({ ...prev, [type]: true })); } catch (err) { console.error('[MicroLearningSelector] Generation failed:', err); setError(err.message || 'Failed to generate content. Please try again.'); @@ -108,7 +97,7 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom ); } - // Type selection menu + // Type selection menu — always shows all 4 types return (
@@ -116,30 +105,21 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom

Select how you want to engage with this topic.

- {TYPES.map(({ key, label, description, icon: Icon }) => { - const exists = existingTypes[key]; - return ( -
handleSelection(key)} - > - {exists && ( -
- - Generated -
- )} -
-
- -
-

{label}

+ {TYPES.map(({ key, label, description, icon: Icon }) => ( +
handleSelection(key)} + > +
+
+
-

{description}

+

{label}

- ); - })} +

{description}

+
+ ))}
); diff --git a/src/hooks/useMicroLearnings.js b/src/hooks/useMicroLearnings.js index c0f4ff7..c55dfd6 100644 --- a/src/hooks/useMicroLearnings.js +++ b/src/hooks/useMicroLearnings.js @@ -1,4 +1,4 @@ -import { getOrGenerateMicroLearning, regenerateMicroLearning, getExistingTypes } from '../lib/microLearningService'; +import { getOrGenerateMicroLearning, regenerateMicroLearning } from '../lib/microLearningService'; export function useMicroLearnings() { /** @@ -16,13 +16,5 @@ export function useMicroLearnings() { return regenerateMicroLearning(topicId, type); }; - /** - * Check which micro learning types already exist for a given topic. - * Returns { concept_explainer: true, scenario_quiz: false, ... } - */ - const checkExistingTypes = async (topicId) => { - return getExistingTypes(topicId); - }; - - return { getOrGenerate, regenerate, checkExistingTypes }; + return { getOrGenerate, regenerate }; } diff --git a/src/lib/__tests__/testService.test.js b/src/lib/__tests__/testService.test.js index fa791d4..d79b6b0 100644 --- a/src/lib/__tests__/testService.test.js +++ b/src/lib/__tests__/testService.test.js @@ -24,7 +24,8 @@ vi.mock('../llm', () => ({ cachedSystem: (text) => [{ type: 'text', text }], })); vi.mock('../curriculumService', () => ({ - getCurrentWeekContent: vi.fn(async () => null), + getCurriculumTopic: vi.fn(async () => ({ topic: null })), + getQuarterForWeek: vi.fn(() => 1), })); import { forceGenerateTopicQuestions } from '../testService'; @@ -53,8 +54,8 @@ describe('forceGenerateTopicQuestions', () => { beforeEach(() => { bankStore.clear(); callLLMMock.mockReset(); - debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => { }); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); }); afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); }); @@ -66,6 +67,17 @@ describe('forceGenerateTopicQuestions', () => { 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 () => { const bad = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }), @@ -73,7 +85,7 @@ describe('forceGenerateTopicQuestions', () => { llmEmits(bad); llmEmits(bad); llmEmits(bad); - await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|failed/i); + await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i); }); it('rejects a batch where an explanation is too short', async () => { @@ -81,7 +93,7 @@ describe('forceGenerateTopicQuestions', () => { llmEmits(bad); llmEmits(bad); llmEmits(bad); - await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|failed/i); + await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i); }); it('drops duplicates whose normalized text matches an existing bank entry', async () => { @@ -100,19 +112,4 @@ describe('forceGenerateTopicQuestions', () => { expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined(); 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); - }); }); diff --git a/src/lib/db.js b/src/lib/db.js index e2f1d23..9e7f745 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -41,10 +41,10 @@ export async function upsertTopic(topic) { await pb.collection('topics').getOne(topic.id); return await pb.collection('topics').update(topic.id, topic); } catch { - return await pb.collection('topics').create({ - id: topic.id, + return await pb.collection('topics').create({ + id: topic.id, learning_relevance: 'standard', - ...topic + ...topic }); } } diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index 76ff716..63ae60f 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -115,7 +115,7 @@ export const learningAllSchema = z.object({ const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']).catch('medium'); 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), topicLabel: z.string().min(1).catch('General'), options: z.array(z.string().min(1)).length(4), diff --git a/src/lib/microLearningService.js b/src/lib/microLearningService.js index 1633e2f..d76e522 100644 --- a/src/lib/microLearningService.js +++ b/src/lib/microLearningService.js @@ -15,6 +15,7 @@ import { EMIT_CONCEPT_EXPLAINER_TOOL, EMIT_SCENARIO_QUIZ_TOOL, EMIT_FLASHCARD_SET_TOOL, + EMIT_REFLECTION_PROMPT_TOOL, } from './llmTools'; import * as db from './db'; @@ -52,6 +53,15 @@ Mix three question types: - Relationships: "How does X relate to Y?" Keep answers concise — one or two sentences maximum.`, }, + reflection_prompt: { + tool: EMIT_REFLECTION_PROMPT_TOOL, + tier: 'fast', + maxTokens: 1024, + instructions: `Generate a reflection prompt. +The question must be open-ended and cannot be answered with a fact. +It must require the employee to think about their own professional context — their team, their role, their past experience. +The model answer should show depth and specificity (3–5 sentences). It is not a rubric — it is an example of thoughtful reflection.`, + }, }; const SYSTEM_PROMPT = `You are an expert learning content writer for Respellion, an internal IT company. @@ -135,21 +145,6 @@ export async function deleteAllForTopic(topicId) { } } -// ── Public helpers ──────────────────────────────────────────────────────────── - -/** - * Check which micro learning types already exist for a given topic. - * Returns an object like { concept_explainer: true, scenario_quiz: false, ... } - */ -export async function getExistingTypes(topicId) { - const types = Object.keys(MICRO_LEARNING_TYPES); - const result = {}; - for (const type of types) { - result[type] = !!(await findExisting(topicId, type)); - } - return result; -} - // ── Internal helpers ────────────────────────────────────────────────────────── async function findExisting(topicId, type) { diff --git a/src/lib/testService.js b/src/lib/testService.js index 096a4ad..38fbadf 100644 --- a/src/lib/testService.js +++ b/src/lib/testService.js @@ -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); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index e929e70..1db4420 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -67,15 +67,15 @@ const Dashboard = () => { console.warn('[Dashboard] Could not load curriculum data:', e.message); } - setDashData({ - topic, - learnDone, - testResult, - top3, - myRank, - myPoints, - activity, - yearProgress, + setDashData({ + topic, + learnDone, + testResult, + top3, + myRank, + myPoints, + activity, + yearProgress, hasCurriculum: curriculumExists, theme: topic?.theme || '', }); @@ -178,7 +178,7 @@ const Dashboard = () => {

Testing

-

Weekly knowledge test

+

Weekly test — 5 questions

{testResult ? Score: {testResult.percentage}% : To Do}
@@ -207,9 +207,8 @@ const Dashboard = () => { ) : ( activity.slice(0, 5).map((act, i) => (
-
+
{act.type === 'test' ? 'T' : 'L'}
diff --git a/src/pages/Testen.jsx b/src/pages/Testen.jsx index e425c51..dd06e73 100644 --- a/src/pages/Testen.jsx +++ b/src/pages/Testen.jsx @@ -182,7 +182,7 @@ const Testen = () => {

Weekly Test

Week {weekNumber}

- Test your knowledge with AI-generated questions. You have 5 minutes to complete the test. Good luck! + Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!

{error && ( @@ -196,9 +196,9 @@ const Testen = () => {
- Up to 5 questions + 5 questions 5 minutes - 2 pts each + 20 pts max