fix(generation): reject invalid quiz option shapes and constrain values at the source (Closes #1375) (#1651)

* fix(generation): enforce quiz option value/label contract

When a model puts choice content in value and a bare A-Z letter in label,
swap those fields so persisted quiz options keep a letter as the selection
identity and the content as the label. Correct objects, plain strings, and
answer-key normalization stay as they were.

Closes #1375

* fix(generation): rewrite swapped quiz letter keys to option values

When a bare label is moved onto the option value, a raw answer token that
exactly equals that original label is rewritten to the uppercased letter
before exact alignment. The stored key is then the letter QuizView submits,
so that submission grades correct. A stored "a" on an already-correct
value "A" is left unresolved.

Closes #1375

* fix(generation): reject invalid quiz option shapes instead of swapping

Stop rewriting quiz options when a model puts content in value and a
letter in label. The quiz prompt now requires value to be one ASCII
letter A-Z and label to be the option content. A choice question that
still breaks that contract, or whose answer does not name an option
value, is invalid model output and regenerates.

Closes #1375

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
This commit is contained in:
Frank Zhu
2026-09-23 12:46:40 +08:00
committed by GitHub
co-authored by wyuc
parent d3e882241c
commit fbc51fcbd6
7 changed files with 584 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openmaic/generation",
"version": "0.3.11",
"version": "0.3.12",
"description": "Pure generation pipeline contracts and packaged prompt assets for MAIC consumers.",
"type": "module",
"main": "./dist/index.js",
@@ -891,7 +891,8 @@ async function generateQuizContent(
log.debug(`Got ${generatedQuestions.length} questions for: ${outline.title}`);
// Ensure each question has an ID and normalize options format
// Ensure each question has an ID and normalize options format.
// Plain strings become letter/content pairs. Object fields stay as written.
const questions: QuizQuestion[] = generatedQuestions.map((q) => {
const isText = q.type === 'short_answer';
const options = isText ? undefined : normalizeQuizOptions(q.options);
@@ -906,17 +907,72 @@ async function generateQuizContent(
};
});
const contractFailure = findQuizOptionsContractFailure(questions);
if (contractFailure) {
log.error(`Quiz option contract failed for "${outline.title}": ${contractFailure}`);
onFailure?.({ code: 'invalid-model-output' });
return null;
}
return { questions };
}
const QUIZ_OPTION_VALUE = /^[A-Z]$/;
/**
* Reason a built quiz breaks the choice-option contract, or null when it holds.
*
* Choice questions (everything except `short_answer`) need a non-empty option
* list whose `value`s are single ASCII letters A-Z, and every answer entry
* must equal one of those values exactly. Short-answer questions are skipped.
*/
export function findQuizOptionsContractFailure(questions: readonly QuizQuestion[]): string | null {
for (let index = 0; index < questions.length; index += 1) {
const question = questions[index];
if (!question || question.type === 'short_answer') continue;
const where = question.id ? `question ${index + 1} (${question.id})` : `question ${index + 1}`;
const options = question.options;
if (!options || options.length === 0) {
return `${where}: choice question has no options`;
}
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
const value = options[optionIndex]?.value;
if (typeof value !== 'string' || !QUIZ_OPTION_VALUE.test(value)) {
const shown = JSON.stringify(value);
return `${where}: option ${optionIndex + 1} value ${shown} is not a single letter A-Z`;
}
}
const answer = question.answer;
if (!answer || answer.length === 0) {
return `${where}: answer key does not reference an option value`;
}
const values = new Set(options.map((option) => option.value));
for (const entry of answer) {
if (!values.has(entry)) {
return `${where}: answer ${JSON.stringify(entry)} does not match an option value`;
}
}
}
return null;
}
type NormalizedQuizOption = { value: string; label: string };
/**
* Normalize quiz options from AI response.
* AI may generate plain strings ["OptionA", "OptionB"] or QuizOption objects.
* This normalizes to QuizOption[] format: { value: "A", label: "OptionA" }
* Plain strings become { value: "A", label: "OptionA" }. Object `value` and
* `label` are kept as provided — a letter in `label` with content in `value`
* is not swapped.
*/
function normalizeQuizOptions(
export function normalizeQuizOptions(
options: unknown[] | undefined,
): { value: string; label: string }[] | undefined {
): NormalizedQuizOption[] | undefined {
if (!options || !Array.isArray(options)) return undefined;
return options.map((opt, index) => {
@@ -88,6 +88,33 @@ Open-ended question requiring a written response. No options or predefined answe
- Avoid "all of the above" or "none of the above" options
- Randomize correct answer position
### Option field contract
`value` and `label` are different fields. Never reverse them.
- `value` MUST be a single ASCII uppercase letter A-Z. It is a JSON Schema enum: the selection key, never the option text.
- `label` MUST be the option content text the learner reads. It must not be a bare letter standing in for that content.
- `answer` MUST be an array of those `value` letters, such as `["A"]` or `["A", "C"]`. Do not put option content in `answer`.
- Invalid: `{ "value": "(6, 2)", "label": "A" }`. Content belongs in `label`; the letter belongs in `value`.
Each choice option binds to this schema (`value` cannot be free text):
```json
{
"type": "object",
"required": ["label", "value"],
"additionalProperties": false,
"properties": {
"label": { "type": "string", "description": "Option content text shown to the learner." },
"value": {
"type": "string",
"enum": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"],
"description": "Selection key. One ASCII uppercase letter. Never the option content."
}
}
}
```
### Difficulty Guidelines
| Difficulty | Description |
@@ -98,7 +125,7 @@ Open-ended question requiring a written response. No options or predefined answe
## Output Format
Output a JSON array of question objects. Every question must have `analysis` and `points`:
Output a JSON array of question objects. Every question must have `analysis` and `points`. Choice option `value` is the enum A-Z above; `label` is content; `answer` copies `value` letters only:
```json
[
@@ -6,5 +6,5 @@ Question Count: {{questionCount}}, Difficulty: {{difficulty}}, Question Types: {
## Language Directive
{{languageDirective}}
Output a JSON array directly (no explanation, no code blocks, no LaTeX). Use the exact object shape from the system prompt — `options` as `{ "label", "value" }` objects with single-letter values (A, B, C, ...) and `answer` as an array of the correct option VALUES:
Output a JSON array directly (no explanation, no code blocks, no LaTeX). Each choice option MUST be `{ "label": "<content text>", "value": "<one ASCII uppercase letter A-Z>" }`. `value` is an enum A-Z and is never the option content; `label` is the content text and is never just the letter. Never reverse them (invalid: `{ "value": "(6, 2)", "label": "A" }`). `answer` MUST be an array of those `value` letters, never the content text:
[{"id":"q1","type":"single","question":"Question text","options":[{"label":"Option A content","value":"A"},{"label":"Option B content","value":"B"},{"label":"Option C content","value":"C"},{"label":"Option D content","value":"D"}],"answer":["A"]}]
@@ -694,6 +694,33 @@ Open-ended question requiring a written response. No options or predefined answe
- Avoid "all of the above" or "none of the above" options
- Randomize correct answer position
### Option field contract
\`value\` and \`label\` are different fields. Never reverse them.
- \`value\` MUST be a single ASCII uppercase letter A-Z. It is a JSON Schema enum: the selection key, never the option text.
- \`label\` MUST be the option content text the learner reads. It must not be a bare letter standing in for that content.
- \`answer\` MUST be an array of those \`value\` letters, such as \`["A"]\` or \`["A", "C"]\`. Do not put option content in \`answer\`.
- Invalid: \`{ "value": "(6, 2)", "label": "A" }\`. Content belongs in \`label\`; the letter belongs in \`value\`.
Each choice option binds to this schema (\`value\` cannot be free text):
\`\`\`json
{
"type": "object",
"required": ["label", "value"],
"additionalProperties": false,
"properties": {
"label": { "type": "string", "description": "Option content text shown to the learner." },
"value": {
"type": "string",
"enum": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"],
"description": "Selection key. One ASCII uppercase letter. Never the option content."
}
}
}
\`\`\`
### Difficulty Guidelines
| Difficulty | Description |
@@ -704,7 +731,7 @@ Open-ended question requiring a written response. No options or predefined answe
## Output Format
Output a JSON array of question objects. Every question must have \`analysis\` and \`points\`:
Output a JSON array of question objects. Every question must have \`analysis\` and \`points\`. Choice option \`value\` is the enum A-Z above; \`label\` is content; \`answer\` copies \`value\` letters only:
\`\`\`json
[
@@ -754,7 +781,7 @@ Question Count: 1, Difficulty: easy, Question Types: single
## Language Directive
Teach in English.
Output a JSON array directly (no explanation, no code blocks, no LaTeX). Use the exact object shape from the system prompt — \`options\` as \`{ "label", "value" }\` objects with single-letter values (A, B, C, ...) and \`answer\` as an array of the correct option VALUES:
Output a JSON array directly (no explanation, no code blocks, no LaTeX). Each choice option MUST be \`{ "label": "<content text>", "value": "<one ASCII uppercase letter A-Z>" }\`. \`value\` is an enum A-Z and is never the option content; \`label\` is the content text and is never just the letter. Never reverse them (invalid: \`{ "value": "(6, 2)", "label": "A" }\`). \`answer\` MUST be an array of those \`value\` letters, never the content text:
[{"id":"q1","type":"single","question":"Question text","options":[{"label":"Option A content","value":"A"},{"label":"Option B content","value":"B"},{"label":"Option C content","value":"C"},{"label":"Option D content","value":"D"}],"answer":["A"]}]",
},
"slide": {
@@ -0,0 +1,366 @@
import { describe, expect, it } from 'vitest';
import type { GenerationLogger, SceneContentFailure } from '@openmaic/generation';
import {
findQuizOptionsContractFailure,
generateSceneContent,
normalizeQuizOptions,
} from '../src/scene-generator.js';
import { quizOutline } from './scene-fixtures.js';
const correctOptions = [
{ value: 'A', label: '(6, 2)' },
{ value: 'B', label: '(2, -4)' },
{ value: 'C', label: '(6, -3)' },
{ value: 'D', label: '(6, -4)' },
];
const swappedOptions = [
{ value: '(6, 2)', label: 'A' },
{ value: '(2, -4)', label: 'B' },
{ value: '(6, -3)', label: 'C' },
{ value: '(6, -4)', label: 'D' },
];
describe('normalizeQuizOptions', () => {
it('leaves a swapped letter label and content value unchanged', () => {
expect(normalizeQuizOptions(swappedOptions)).toEqual(swappedOptions);
});
it('does not uppercase or swap a lowercase letter label', () => {
expect(
normalizeQuizOptions([
{ value: '4', label: 'a' },
{ value: 'Yes', label: 'b' },
]),
).toEqual([
{ value: '4', label: 'a' },
{ value: 'Yes', label: 'b' },
]);
});
it('keeps a letter label when it disagrees with the index', () => {
expect(normalizeQuizOptions([{ value: '(6, 2)', label: 'C' }])).toEqual([
{ value: '(6, 2)', label: 'C' },
]);
});
it('leaves an already-correct letter value and content label unchanged', () => {
expect(
normalizeQuizOptions([
{ value: 'A', label: '(6, 2)' },
{ value: 'b', label: 'A is prime' },
{ value: 'C', label: 'C' },
]),
).toEqual([
{ value: 'A', label: '(6, 2)' },
{ value: 'b', label: 'A is prime' },
{ value: 'C', label: 'C' },
]);
});
it('does not rewrite when both sides are letters or both sides are content', () => {
expect(
normalizeQuizOptions([
{ value: 'A', label: 'B' },
{ value: 'red', label: 'blue' },
{ value: '(6, 2)', label: 'A ' },
{ value: '(6, 2)', label: 'B' },
{ value: '', label: 'A' },
]),
).toEqual([
{ value: 'A', label: 'B' },
{ value: 'red', label: 'blue' },
{ value: '(6, 2)', label: 'A ' },
{ value: '(6, 2)', label: 'B' },
{ value: '', label: 'A' },
]);
});
it('still maps plain strings to an index letter and the string as label', () => {
expect(normalizeQuizOptions(['The caller', 'The package'])).toEqual([
{ value: 'A', label: 'The caller' },
{ value: 'B', label: 'The package' },
]);
});
it('keeps index-letter and text fallbacks when fields are missing or not strings', () => {
expect(
normalizeQuizOptions([
'plain',
{ value: '(6, 2)', label: 'D' },
{ value: 'C', label: 'already content' },
{ label: 'only label' },
{ label: 'B' },
{ value: 'only value' },
{ text: 'from text' },
{ value: 42, label: 'A' },
null,
7,
]),
).toEqual([
{ value: 'A', label: 'plain' },
{ value: '(6, 2)', label: 'D' },
{ value: 'C', label: 'already content' },
{ value: 'D', label: 'only label' },
{ value: 'E', label: 'B' },
{ value: 'only value', label: 'only value' },
{ value: 'G', label: 'from text' },
{ value: 'H', label: 'A' },
{ value: 'I', label: 'null' },
{ value: 'J', label: '7' },
]);
});
it('returns undefined when options are missing or not an array', () => {
expect(normalizeQuizOptions(undefined)).toBeUndefined();
expect(normalizeQuizOptions(null as unknown as undefined)).toBeUndefined();
expect(normalizeQuizOptions('A' as unknown as undefined)).toBeUndefined();
});
});
describe('findQuizOptionsContractFailure', () => {
it('accepts letter values whose answers name those values', () => {
expect(
findQuizOptionsContractFailure([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: correctOptions,
answer: ['A'],
},
{
id: 'q2',
type: 'multiple',
question: 'Select the matching points',
options: correctOptions.slice(0, 2),
answer: ['A', 'B'],
},
{
id: 'q3',
type: 'short_answer',
question: 'Explain the pair',
},
]),
).toBeNull();
});
it('rejects a swapped value, a missing option list, and an answer that misses every value', () => {
expect(
findQuizOptionsContractFailure([
{
id: 'q-swap',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: swappedOptions,
answer: ['A'],
},
]),
).toBe('question 1 (q-swap): option 1 value "(6, 2)" is not a single letter A-Z');
expect(
findQuizOptionsContractFailure([
{ id: 'q-empty', type: 'single', question: '?', options: [], answer: ['A'] },
]),
).toBe('question 1 (q-empty): choice question has no options');
expect(
findQuizOptionsContractFailure([{ id: 'q-missing', type: 'single', question: '?' }]),
).toBe('question 1 (q-missing): choice question has no options');
expect(
findQuizOptionsContractFailure([
{
id: 'q-none',
type: 'multiple',
question: '?',
options: correctOptions.slice(0, 2),
},
]),
).toBe('question 1 (q-none): answer key does not reference an option value');
expect(
findQuizOptionsContractFailure([
{
id: 'q-key',
type: 'single',
question: '?',
options: correctOptions.slice(0, 2),
answer: ['a'],
},
]),
).toBe('question 1 (q-key): answer "a" does not match an option value');
});
});
function recordingLogger(): { logger: GenerationLogger; errors: string[] } {
const errors: string[] = [];
const logger: GenerationLogger = {
debug() {},
info() {},
warn() {},
error(message: string) {
errors.push(message);
},
};
return { logger, errors };
}
describe('generateSceneContent quiz option contract', () => {
it('rejects a swapped option shape as invalid model output', async () => {
const failures: SceneContentFailure[] = [];
const { logger, errors } = recordingLogger();
const content = await generateSceneContent(
quizOutline(),
async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: swappedOptions,
answer: ['(6, 2)'],
},
{
id: 'q2',
type: 'multiple',
question: 'Select the matching points',
options: [
{ value: '(0, 1)', label: 'A' },
{ value: '(1, 0)', label: 'B' },
],
correctAnswer: 'A',
},
]),
{ onFailure: (failure) => failures.push(failure), logger },
);
expect(content).toBeNull();
expect(failures).toEqual([{ code: 'invalid-model-output' }]);
expect(errors).toEqual([
'Quiz option contract failed for "Dependency Injection Check": question 1 (q1): option 1 value "(6, 2)" is not a single letter A-Z',
]);
});
it('rejects a lowercase letter key instead of rewriting it onto the value', async () => {
const failures: SceneContentFailure[] = [];
const content = await generateSceneContent(
quizOutline(),
async () =>
JSON.stringify([
{
id: 'q-lower',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: [
{ value: '(6, 2)', label: 'a' },
{ value: '(2, -4)', label: 'b' },
],
answer: ['a'],
},
]),
{ onFailure: (failure) => failures.push(failure) },
);
expect(content).toBeNull();
expect(failures).toEqual([{ code: 'invalid-model-output' }]);
});
it('persists a correct option shape unchanged', async () => {
const failures: SceneContentFailure[] = [];
const content = await generateSceneContent(
quizOutline(),
async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: correctOptions,
answer: ['A'],
analysis: 'A is the point (6, 2).',
points: 10,
},
{
id: 'q2',
type: 'short_answer',
question: 'Describe the point.',
commentPrompt: 'Mention both coordinates.',
analysis: 'Both numbers.',
points: 5,
},
]),
{ onFailure: (failure) => failures.push(failure) },
);
expect(failures).toEqual([]);
expect(content).toMatchObject({
questions: [
{
id: 'q1',
type: 'single',
options: correctOptions,
answer: ['A'],
analysis: 'A is the point (6, 2).',
points: 10,
},
{
id: 'q2',
type: 'short_answer',
answer: undefined,
hasAnswer: false,
},
],
});
});
it('still accepts an exact content answer once it aligns to the option value', async () => {
const content = await generateSceneContent(quizOutline(), async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: correctOptions.slice(0, 2),
answer: ['(6, 2)'],
},
]),
);
expect(content).toMatchObject({
questions: [
{
id: 'q1',
options: correctOptions.slice(0, 2),
answer: ['A'],
},
],
});
});
it('rejects an answer that does not name an option value', async () => {
const failures: SceneContentFailure[] = [];
const content = await generateSceneContent(
quizOutline(),
async () =>
JSON.stringify([
{
id: 'q-already',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: correctOptions.slice(0, 2),
answer: ['a'],
},
]),
{ onFailure: (failure) => failures.push(failure) },
);
expect(content).toBeNull();
expect(failures).toEqual([{ code: 'invalid-model-output' }]);
});
});
+99 -1
View File
@@ -5,7 +5,10 @@ import {
gradeChoiceQuestions,
resolveAnswerKeyToValue,
} from '@/lib/quiz/grading';
import { normalizeQuizAnswer } from '../packages/@openmaic/generation/src/scene-generator';
import {
generateSceneContent,
normalizeQuizAnswer,
} from '../packages/@openmaic/generation/src/scene-generator';
import type { QuizQuestion } from '@/lib/types/stage';
const VECTOR_OPTIONS = [
@@ -154,6 +157,101 @@ describe('gradeChoiceQuestions: consumer paths', () => {
});
});
function coordinateQuizOutline() {
return {
id: 'quiz-1',
type: 'quiz' as const,
title: 'Coordinates',
description: 'Pick a point.',
keyPoints: ['Pairs'],
order: 1,
quizConfig: {
questionCount: 1,
difficulty: 'easy' as const,
questionTypes: ['single' as const],
},
};
}
describe('generateSceneContent quiz option contract', () => {
test('rejects a swapped value/label shape instead of repairing it', async () => {
const failures: { code: string }[] = [];
const content = await generateSceneContent(
coordinateQuizOutline(),
async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: [
{ value: '(6, 2)', label: 'a' },
{ value: '(2, -4)', label: 'b' },
],
answer: ['a'],
},
]),
{ onFailure: (failure) => failures.push(failure) },
);
expect(content).toBeNull();
expect(failures).toEqual([{ code: 'invalid-model-output' }]);
});
test('persists a correct letter value and grades that submission', async () => {
const content = await generateSceneContent(coordinateQuizOutline(), async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: [
{ value: 'A', label: '(6, 2)' },
{ value: 'B', label: '(2, -4)' },
],
answer: ['A'],
},
]),
);
expect(content && 'questions' in content).toBe(true);
if (!content || !('questions' in content)) return;
const question = content.questions[0];
expect(question.options).toEqual([
{ value: 'A', label: '(6, 2)' },
{ value: 'B', label: '(2, -4)' },
]);
expect(question.answer).toEqual(['A']);
expect(gradeChoiceQuestions([question], { q1: 'A' })[0].correct).toBe(true);
expect(gradeChoiceQuestions([question], { q1: 'a' })[0].correct).toBe(false);
expect(gradeChoiceQuestions([question], { q1: '(6, 2)' })[0].correct).toBe(false);
});
test('rejects a lowercase key that does not equal an option value', async () => {
const failures: { code: string }[] = [];
const content = await generateSceneContent(
coordinateQuizOutline(),
async () =>
JSON.stringify([
{
id: 'q1',
type: 'single',
question: 'Which coordinate is (6, 2)?',
options: [
{ value: 'A', label: '(6, 2)' },
{ value: 'B', label: '(2, -4)' },
],
answer: ['a'],
},
]),
{ onFailure: (failure) => failures.push(failure) },
);
expect(content).toBeNull();
expect(failures).toEqual([{ code: 'invalid-model-output' }]);
});
});
describe('normalizeQuizAnswer (generation): narrowed exact alignment', () => {
test('exact value passes through', () => {
expect(normalizeQuizAnswer({ answer: 'A' }, VECTOR_OPTIONS)).toEqual(['A']);