feat: phase 4 of AI pipeline hardening — quiz & content quality
- src/lib/random.js: Fisher–Yates shuffle/sample/pickInt; replace every
biased .sort(() => 0.5 - Math.random()) site in testService.
- testService: debias correctIndex via prompt + runtime re-roll (up to 2x
when one position holds >50%); quality gate rejecting <4 distinct
options, banned filler ("all of the above" etc) and explanations
shorter than 20 chars; dedup new questions against the existing bank
via normalised question text.
- Quiz schema/tool/prompt require difficulty ('easy'|'medium'|'hard');
db.getQuizBank defaults legacy records to 'medium' on read.
- learningService.generateCustomTopic: kebab-case slug ID from the
polished label with collision suffixes; default learning_relevance
'standard' when the model omits it.
- Tests for random helpers, dedup/quality-gate behaviour and the
extended quiz schema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,7 +108,7 @@ describe('learning schemas', () => {
|
||||
});
|
||||
|
||||
describe('quizQuestionsSchema', () => {
|
||||
it('accepts a quiz with four options and a valid correctIndex', () => {
|
||||
it('accepts a quiz with four options, a valid correctIndex and a difficulty', () => {
|
||||
const parsed = quizQuestionsSchema.parse({
|
||||
questions: [
|
||||
{
|
||||
@@ -118,10 +118,12 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 2,
|
||||
explanation: 'C describes the buddy system best.',
|
||||
difficulty: 'easy',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.questions[0].options).toHaveLength(4);
|
||||
expect(parsed.questions[0].difficulty).toBe('easy');
|
||||
});
|
||||
|
||||
it('rejects three options or an out-of-range correctIndex', () => {
|
||||
@@ -135,6 +137,7 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C'],
|
||||
correctIndex: 0,
|
||||
explanation: 'e',
|
||||
difficulty: 'medium',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -149,11 +152,27 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 4,
|
||||
explanation: 'e',
|
||||
difficulty: 'medium',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a missing or unknown difficulty', () => {
|
||||
const base = {
|
||||
id: 'q',
|
||||
question: 'q',
|
||||
topicLabel: 't',
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 0,
|
||||
explanation: 'because',
|
||||
};
|
||||
expect(() => quizQuestionsSchema.parse({ questions: [base] })).toThrow();
|
||||
expect(() =>
|
||||
quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customTopicSchema', () => {
|
||||
|
||||
48
src/lib/__tests__/random.test.js
Normal file
48
src/lib/__tests__/random.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shuffle, sample, pickInt } from '../random';
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('returns a new array containing the same elements', () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const out = shuffle(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect([...out].sort()).toEqual([...input].sort());
|
||||
expect(input).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('handles empty and single-element arrays', () => {
|
||||
expect(shuffle([])).toEqual([]);
|
||||
expect(shuffle([42])).toEqual([42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sample', () => {
|
||||
it('returns up to n unique elements from the source array', () => {
|
||||
const out = sample([1, 2, 3, 4, 5], 3);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(new Set(out).size).toBe(3);
|
||||
for (const v of out) expect([1, 2, 3, 4, 5]).toContain(v);
|
||||
});
|
||||
|
||||
it('returns the full shuffled array when n exceeds length', () => {
|
||||
const out = sample([1, 2, 3], 10);
|
||||
expect(out).toHaveLength(3);
|
||||
expect([...out].sort()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns an empty array when n is zero or negative', () => {
|
||||
expect(sample([1, 2, 3], 0)).toEqual([]);
|
||||
expect(sample([1, 2, 3], -2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickInt', () => {
|
||||
it('returns an integer in the inclusive range', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = pickInt(2, 5);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(2);
|
||||
expect(v).toBeLessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
112
src/lib/__tests__/testService.test.js
Normal file
112
src/lib/__tests__/testService.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const bankStore = new Map();
|
||||
const callLLMMock = vi.fn();
|
||||
|
||||
vi.mock('../pb', () => ({ pb: { collection: () => ({}) } }));
|
||||
|
||||
vi.mock('../db', () => ({
|
||||
getQuizBank: vi.fn(async (topicId) => bankStore.get(topicId) || []),
|
||||
setQuizBank: vi.fn(async (topicId, qs) => { bankStore.set(topicId, qs); }),
|
||||
getTopics: vi.fn(async () => []),
|
||||
deleteQuestionFromBank: vi.fn(),
|
||||
getCachedQuiz: vi.fn(),
|
||||
setCachedQuiz: vi.fn(),
|
||||
getQuizResult: vi.fn(),
|
||||
saveQuizResult: vi.fn(),
|
||||
getTeamMembers: vi.fn(async () => []),
|
||||
upsertLeaderboardEntry: vi.fn(),
|
||||
getCurriculum: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm', () => ({ callLLM: (...args) => callLLMMock(...args) }));
|
||||
vi.mock('../curriculumService', () => ({
|
||||
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
|
||||
getQuarterForWeek: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
import { forceGenerateTopicQuestions } from '../testService';
|
||||
|
||||
const topic = { id: 'onboarding', label: 'Onboarding', type: 'concept', description: 'Onboarding for new joiners.' };
|
||||
|
||||
function makeQuestion(i, overrides = {}) {
|
||||
return {
|
||||
id: `q-${i}`,
|
||||
question: `Sample question ${i}?`,
|
||||
topicLabel: 'Onboarding',
|
||||
options: ['A) one', 'B) two', 'C) three', 'D) four'],
|
||||
correctIndex: i % 4,
|
||||
explanation: 'This is a substantive explanation for the correct answer.',
|
||||
difficulty: 'medium',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function llmEmits(questions) {
|
||||
callLLMMock.mockResolvedValueOnce({ toolUses: [{ name: 'emit_quiz_questions', input: { questions } }] });
|
||||
}
|
||||
|
||||
describe('forceGenerateTopicQuestions', () => {
|
||||
let debugSpy, warnSpy;
|
||||
beforeEach(() => {
|
||||
bankStore.clear();
|
||||
callLLMMock.mockReset();
|
||||
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); });
|
||||
|
||||
it('persists a well-formed batch and assigns topic-scoped ids', async () => {
|
||||
llmEmits([0, 1, 2, 3, 4].map((i) => makeQuestion(i)));
|
||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||
expect(out).toHaveLength(5);
|
||||
for (const q of out) expect(q.id.startsWith('onboarding-')).toBe(true);
|
||||
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'] }),
|
||||
);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i);
|
||||
});
|
||||
|
||||
it('rejects a batch where an explanation is too short', async () => {
|
||||
const bad = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { explanation: 'Because.' }));
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i);
|
||||
});
|
||||
|
||||
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
|
||||
bankStore.set('onboarding', [
|
||||
{ ...makeQuestion(99), id: 'old-1', question: 'What is the BUDDY system???' },
|
||||
]);
|
||||
llmEmits([
|
||||
makeQuestion(0, { question: 'what is the buddy system!' }),
|
||||
makeQuestion(1, { question: 'Brand new question one?' }),
|
||||
makeQuestion(2, { question: 'Brand new question two?' }),
|
||||
makeQuestion(3, { question: 'Brand new question three?' }),
|
||||
makeQuestion(4, { question: 'Brand new question four?' }),
|
||||
]);
|
||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||
expect(out).toHaveLength(4);
|
||||
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user