feat: implement micro-learning generation service and UI components for interactive topic-based learning
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 59s
On Push to Main / deploy-dev (push) Successful in 1m33s

This commit is contained in:
RaymondVerhoef
2026-05-25 22:17:26 +02:00
parent f16438c1bc
commit a653812cd8
10 changed files with 227 additions and 263 deletions

View File

@@ -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 }) {
@@ -42,7 +42,8 @@ export default function MicroLearningContainer({ microLearning, sessionWeek, onC
return <ScenarioQuiz {...props} />;
case 'flashcard_set':
return <FlashcardSet {...props} />;
case 'reflection_prompt':
return <ReflectionPrompt {...props} />;
default:
return <div>Unknown micro learning type.</div>;
}

View File

@@ -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 (
<Card className="w-full">
<div className="mb-6 pb-4 border-b border-bg-warm">
@@ -116,30 +105,21 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
<p className="text-sm text-fg-muted mt-1">Select how you want to engage with this topic.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{TYPES.map(({ key, label, description, icon: Icon }) => {
const exists = existingTypes[key];
return (
<div
key={key}
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all group relative"
onClick={() => handleSelection(key)}
>
{exists && (
<div className="absolute top-3 right-3 flex items-center gap-1.5 text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full text-xs font-medium">
<CheckCircle size={14} />
<span>Generated</span>
</div>
)}
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0 group-hover:bg-teal/20 transition-colors">
<Icon size={20} className="text-teal" />
</div>
<h3 className="font-bold text-lg text-teal">{label}</h3>
{TYPES.map(({ key, label, description, icon: Icon }) => (
<div
key={key}
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all group"
onClick={() => handleSelection(key)}
>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0 group-hover:bg-teal/20 transition-colors">
<Icon size={20} className="text-teal" />
</div>
<p className="text-sm text-fg-muted">{description}</p>
<h3 className="font-bold text-lg text-teal">{label}</h3>
</div>
);
})}
<p className="text-sm text-fg-muted">{description}</p>
</div>
))}
</div>
</Card>
);

View File

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

View File

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

View File

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

View File

@@ -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 (35 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) {

View File

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

View File

@@ -178,7 +178,7 @@ const Dashboard = () => {
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weekly knowledge test</p>
<p className="text-fg-muted text-sm mt-1">Weekly test 5 questions</p>
</div>
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div>
@@ -207,9 +207,8 @@ const Dashboard = () => {
) : (
activity.slice(0, 5).map((act, i) => (
<div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${
act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
}`}>
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
}`}>
{act.type === 'test' ? 'T' : 'L'}
</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>
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p>
<p className="text-fg-muted mb-8 max-w-md mx-auto">
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!
</p>
{error && (
@@ -196,9 +196,9 @@ const Testen = () => {
<div className="space-y-3">
<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} /> Up to 5 questions</span>
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 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"><Trophy size={14} /> 2 pts each</span>
<span className="flex items-center gap-1.5"><Trophy size={14} /> 20 pts max</span>
</div>
<Button onClick={startQuiz} className="text-lg px-8 py-3">
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">
<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="text-sm text-fg-muted mt-2">Preparing questions based on your learning topics.</p>
<p className="text-sm text-fg-muted mt-2">Preparing 5 questions based on your learning topics.</p>
</div>
);
}
@@ -233,9 +233,8 @@ const Testen = () => {
animate={{ scale: 1, opacity: 1 }}
className="text-center mb-10"
>
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${
isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
}`}>
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
}`}>
<span className="text-4xl font-bold">{result.percentage}%</span>
</div>
<h1 className="text-3xl font-bold mb-2">
@@ -281,9 +280,8 @@ const Testen = () => {
{result.breakdown.map((item, i) => (
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
<div className="flex items-start gap-3 mb-3">
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
}`}>
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
}`}>
{i + 1}
</div>
<div className="flex-1">
@@ -299,13 +297,12 @@ const Testen = () => {
return (
<div
key={oi}
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
isCorrect
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${isCorrect
? 'border-teal bg-teal/5 text-teal font-medium'
: wasSelected
? 'border-red-300 bg-red-50 text-red-700'
: 'border-bg-warm text-fg-muted'
}`}
? 'border-red-300 bg-red-50 text-red-700'
: 'border-bg-warm text-fg-muted'
}`}
>
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}