feat: phase 4 of AI pipeline hardening — quiz & content quality
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m31s

- 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:
RaymondVerhoef
2026-05-20 19:22:10 +02:00
parent c82e4fc3a1
commit 66e0c275da
9 changed files with 407 additions and 74 deletions

View File

@@ -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', () => {

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

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