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 }],
}));
vi.mock('../curriculumService', () => ({
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
getQuarterForWeek: vi.fn(() => 1),
getCurrentWeekContent: vi.fn(async () => null),
}));
import { forceGenerateTopicQuestions } from '../testService';
@@ -67,17 +66,6 @@ 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'] }),
@@ -85,7 +73,7 @@ describe('forceGenerateTopicQuestions', () => {
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 () => {
@@ -93,7 +81,7 @@ describe('forceGenerateTopicQuestions', () => {
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 () => {
@@ -112,4 +100,19 @@ 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);
});
});