feat: implement micro-learning generation service and UI components for interactive topic-based learning
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,20 +105,12 @@ 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 (
|
||||
{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 relative"
|
||||
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)}
|
||||
>
|
||||
{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" />
|
||||
@@ -138,8 +119,7 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">{description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}".`);
|
||||
}
|
||||
return questions;
|
||||
const emitted = result.toolUses[0]?.input;
|
||||
if (!emitted) {
|
||||
throw new Error(`LLM did not emit tool output for ${topic.label}`);
|
||||
}
|
||||
|
||||
// ── 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,56 +146,57 @@ 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 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) {
|
||||
const existingBank = await db.getQuizBank(topic.id);
|
||||
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
|
||||
|
||||
let lastError = null;
|
||||
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 call failed (attempt ${attempt + 1}):`, err.message);
|
||||
lastError = err.message;
|
||||
console.warn(`[quiz] LLM failed (attempt ${attempt + 1}):`, err.message);
|
||||
lastQualityError = err.message;
|
||||
continue;
|
||||
}
|
||||
|
||||
const qualityError = validateBatchQuality(questions);
|
||||
if (qualityError) {
|
||||
console.warn(`[quiz] Batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
||||
lastError = qualityError;
|
||||
lastQualityError = qualityError;
|
||||
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Batch passed quality — deduplicate against existing bank
|
||||
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 questions) {
|
||||
for (const q of candidates) {
|
||||
const key = normalizeQuestionText(q.question);
|
||||
if (existingKeys.has(key)) {
|
||||
console.debug('[quiz] dropped duplicate:', q.question);
|
||||
@@ -186,8 +210,7 @@ export async function forceGenerateTopicQuestions(topic, count = 5) {
|
||||
}
|
||||
|
||||
if (!accepted.length) {
|
||||
lastError = `All generated questions for "${topic.label}" were duplicates.`;
|
||||
continue;
|
||||
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`);
|
||||
}
|
||||
|
||||
const merged = [...existingBank, ...accepted];
|
||||
@@ -195,13 +218,6 @@ export async function forceGenerateTopicQuestions(topic, count = 5) {
|
||||
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);
|
||||
|
||||
@@ -210,17 +226,22 @@ async function getOrGenerateTopicQuestions(topic, count) {
|
||||
await forceGenerateTopicQuestions(topic, 5);
|
||||
bank = await db.getQuizBank(topic.id);
|
||||
} catch (err) {
|
||||
console.warn(`[quiz] Could not populate bank for "${topic.label}":`, err.message);
|
||||
console.warn(`[quiz] Failed to generate questions for ${topic.label}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return sample(bank, Math.min(count, bank.length));
|
||||
}
|
||||
|
||||
// ── Weekly quiz assembly ─────────────────────────────────────
|
||||
export async function getTopicQuestionBank(topicId) {
|
||||
return db.getQuizBank(topicId);
|
||||
}
|
||||
|
||||
export async function deleteQuestion(topicId, questionId) {
|
||||
return db.deleteQuestionFromBank(topicId, questionId);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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,8 +207,7 @@ 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>
|
||||
|
||||
@@ -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,8 +233,7 @@ 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>
|
||||
@@ -281,8 +280,7 @@ 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>
|
||||
@@ -299,8 +297,7 @@ 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'
|
||||
|
||||
Reference in New Issue
Block a user