diff --git a/src/lib/__tests__/llmSchemas.test.js b/src/lib/__tests__/llmSchemas.test.js index ae79799..deb65c1 100644 --- a/src/lib/__tests__/llmSchemas.test.js +++ b/src/lib/__tests__/llmSchemas.test.js @@ -135,7 +135,7 @@ describe('quizQuestionsSchema', () => { ).toThrow(); }); - it('rejects a missing or unknown difficulty', () => { + it('falls back to medium for a missing or unknown difficulty', () => { const base = { id: 'q', question: 'q', @@ -144,10 +144,12 @@ describe('quizQuestionsSchema', () => { correctIndex: 0, explanation: 'because', }; - expect(() => quizQuestionsSchema.parse({ questions: [base] })).toThrow(); - expect(() => - quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }), - ).toThrow(); + // Missing difficulty should default to 'medium' + const parsed1 = quizQuestionsSchema.parse({ questions: [base] }); + expect(parsed1.questions[0].difficulty).toBe('medium'); + // Unknown difficulty should also default to 'medium' + const parsed2 = quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }); + expect(parsed2.questions[0].difficulty).toBe('medium'); }); }); diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index 6e84f29..63ae60f 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -112,20 +112,32 @@ export const learningAllSchema = z.object({ infographic: infographicBodySchema, }); -const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']); +const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']).catch('medium'); const quizQuestionSchema = z.object({ - id: z.string().min(1), + id: z.string().min(1).catch(`gen-${Math.random().toString(36).slice(2, 8)}`), question: z.string().min(1), - topicLabel: z.string().min(1), + topicLabel: z.string().min(1).catch('General'), options: z.array(z.string().min(1)).length(4), - correctIndex: z.number().int().min(0).max(3), - explanation: z.string().min(1), + correctIndex: z.preprocess( + (v) => (typeof v === 'number' ? Math.round(v) : v), + z.number().int().min(0).max(3), + ), + explanation: z.string().min(1).catch('See the correct answer above.'), difficulty: quizDifficultyEnum, }); export const quizQuestionsSchema = z.object({ - questions: z.array(quizQuestionSchema).min(1), + questions: z.preprocess( + (v) => { + // If the model returned an array directly, wrap it + if (Array.isArray(v)) return v; + // If it returned a single object, wrap in array + if (v && typeof v === 'object' && !Array.isArray(v)) return [v]; + return v; + }, + z.array(quizQuestionSchema).min(1), + ), }); export const customTopicSchema = z.object({ diff --git a/src/lib/testService.js b/src/lib/testService.js index 5577e67..38fbadf 100644 --- a/src/lib/testService.js +++ b/src/lib/testService.js @@ -79,10 +79,40 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and }); const emitted = result.toolUses[0]?.input; - if (!emitted?.questions?.length) { - throw new Error(`Could not generate questions for ${topic.label}`); + if (!emitted) { + throw new Error(`LLM did not emit tool output for ${topic.label}`); } - return emitted.questions; + + // 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) {