Files
OpenMAIC/tests/quiz-answer-key.test.ts
T
Frank Zhuandwyuc fbc51fcbd6 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>
2026-09-23 12:46:40 +08:00

277 lines
9.1 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
answerIncludesOption,
gradeChoiceQuestions,
resolveAnswerKeyToValue,
} from '@/lib/quiz/grading';
import {
generateSceneContent,
normalizeQuizAnswer,
} from '../packages/@openmaic/generation/src/scene-generator';
import type { QuizQuestion } from '@/lib/types/stage';
const VECTOR_OPTIONS = [
{ value: 'A', label: '(6, 2)' },
{ value: 'B', label: '(2, -4)' },
{ value: 'C', label: '(6, -3)' },
{ value: 'D', label: '(6, -4)' },
];
function q(options: { value: string; label: string }[], answer?: string[]): QuizQuestion {
return {
id: 'qx',
type: 'single',
question: '?',
options,
answer: answer ?? ['A'], // 默认存储一个值键,供投影测试使用
hasAnswer: true,
points: 10,
};
}
describe('resolveAnswerKeyToValue: exact alignment only', () => {
test('exact option value resolves to itself', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), 'A')).toBe('A');
});
test('exact unique label resolves to that option value', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(6, 2)')).toBe('A');
});
test('unknown key stays unresolved', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(9, 9)')).toBe('(9, 9)');
});
test('ambiguous: two options sharing one label stays unresolved', () => {
const dup = [
{ value: 'A', label: 'same' },
{ value: 'B', label: 'same' },
];
expect(resolveAnswerKeyToValue(q(dup), 'same')).toBe('same');
});
});
describe('negative cases: formatting differences are NOT silently equivalent', () => {
test('case-differing key stays unresolved', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), 'a')).toBe('a');
});
test('whitespace-differing key stays unresolved', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(6,2)')).toBe('(6,2)');
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(6, 2)')).toBe('(6, 2)');
});
test('full-width wrapped key stays unresolved', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(B)')).toBe('(B)');
});
test('wrapped/prefixed letter key stays unresolved', () => {
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), '(B)')).toBe('(B)');
expect(resolveAnswerKeyToValue(q(VECTOR_OPTIONS), 'B.')).toBe('B.');
});
});
describe('answerIncludesOption: exact resolver projection', () => {
test('exact value and exact unique label both project to the option', () => {
const question = q(VECTOR_OPTIONS, ['(6, 2)']); // label 存储
expect(answerIncludesOption(question, 'A')).toBe(true); // label 解析后命中选项 A
expect(answerIncludesOption(question, 'B')).toBe(false);
});
test('case/whitespace/full-width/wrapper differences project to false', () => {
expect(answerIncludesOption(q(VECTOR_OPTIONS), 'a')).toBe(false);
expect(answerIncludesOption(q(VECTOR_OPTIONS), '(B)')).toBe(false);
expect(answerIncludesOption(q(VECTOR_OPTIONS), '(6,2)')).toBe(false);
});
test('ambiguous labels project to false for every option', () => {
const dup = [
{ value: 'A', label: 'same' },
{ value: 'B', label: 'same' },
];
const question = q(dup, ['same']); // 歧义存储:投影对任何选项都是 false
expect(answerIncludesOption(question, 'A')).toBe(false);
expect(answerIncludesOption(question, 'B')).toBe(false);
});
});
describe('gradeChoiceQuestions: consumer paths', () => {
test('persisted exact-label key grades correct in single-choice review', () => {
const question: QuizQuestion = {
id: 'q1',
type: 'single',
question: 'a+b=?',
options: VECTOR_OPTIONS,
answer: ['(6, 2)'], // 存储为 label(精确形态)
hasAnswer: true,
points: 10,
};
const results = gradeChoiceQuestions([question], { q1: 'A' });
expect(results[0].correct).toBe(true);
});
test('multiple-choice with exact-value and exact-label keys grades correct', () => {
const question: QuizQuestion = {
id: 'q2',
type: 'multiple',
question: 'select all',
options: VECTOR_OPTIONS,
answer: ['A', '(2, -4)'], // 值 + 精确 label 混合存储
hasAnswer: true,
points: 10,
};
const results = gradeChoiceQuestions([question], { q2: ['A', 'B'] });
expect(results[0].correct).toBe(true);
});
test('formatting-variant keys do NOT silently grade correct (negative)', () => {
const question: QuizQuestion = {
id: 'q3',
type: 'single',
question: '?',
options: VECTOR_OPTIONS,
answer: ['(6,2)'], // 无空格变体:与任何选项值/label 都不精确相等
hasAnswer: true,
points: 10,
};
const results = gradeChoiceQuestions([question], { q3: 'A' });
expect(results[0].correct).toBe(false);
});
test('compatibility resolution applies to the persisted key only (negative)', () => {
// 键是值键 A;提交的却是该选项的 label。只有持久化键才做兼容解析,
// 提交按原值比较 —— label 提交必须判错,否则别名提交会被当成另一选项。
const question: QuizQuestion = {
id: 'q4',
type: 'single',
question: '?',
options: VECTOR_OPTIONS,
answer: ['A'],
hasAnswer: true,
points: 10,
};
expect(gradeChoiceQuestions([question], { q4: '(6, 2)' })[0].correct).toBe(false);
// 同一道题,提交选项值本身仍判对。
expect(gradeChoiceQuestions([question], { q4: 'A' })[0].correct).toBe(true);
});
});
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']);
});
test('exact unique label resolves to the option value', () => {
expect(normalizeQuizAnswer({ answer: '(6, 2)' }, VECTOR_OPTIONS)).toEqual(['A']);
});
test('formatting variants stay unresolved (fail closed)', () => {
expect(normalizeQuizAnswer({ answer: '(6,2)' }, VECTOR_OPTIONS)).toEqual(['(6,2)']);
expect(normalizeQuizAnswer({ answer: '(B)' }, VECTOR_OPTIONS)).toEqual(['(B)']);
});
test('ambiguous keys stay unresolved', () => {
const dup = [
{ value: 'A', label: 'same' },
{ value: 'B', label: 'same' },
];
expect(normalizeQuizAnswer({ answer: 'same' }, dup)).toEqual(['same']);
});
});