- BrainTools - https://www.braintools.ru -
Мы делаем учебную игру: человек пишет цель своими словами («таблица умножения», «дроби за 5 класс», «английский для поездки»), а игра строит под неё путь из локаций. В каждой локации — порция из пяти заданий. Готовых курсов нет: тема может быть любой, поэтому задания пишет языковая модель (Claude от Anthropic) — под цель, уровень и последние ответы игрока.
Придумывает задания модель хорошо, а считает плохо. В октябре мы разобрали 68 жалоб «Задание с ошибкой [1]?» — игроки отправляют их кнопкой прямо из урока. Типичные случаи:
«6 − 2 = 3»;
«7,4 + 2,65 = 10,05» помечено как неверное;
«после 8 идёт 10»;
«28 меньше 25»;
«9 × 5 — чётное»;
в ряду «16, __, 18» ключом стоит 16;
верное «72 ÷ 3 = 24» модель не признаёт.
Для обучающей игры хуже всего, когда ребёнок отвечает верно, а видит «ошибка». После этого он перестаёт верить и игре, и себе. Ниже — как устроена защита: что мы отсекаем по структуре, что пересчитываем сами и какие решения оказались неочевидными.
Задания бывают семи видов: выбор одного варианта, несколько верных, «впиши ответ», «верно или неверно», сопоставление пар, порядок и раскладка по группам. Модель отвечает JSON по схеме (zod + structured outputs), так что форма гарантирована: поля на месте, типы верные.
Но схема ничего не говорит о смысле. Верного варианта может не оказаться среди вариантов. Пары могут повторяться. Раскладка по группам может противоречить собственному условию. Поэтому после модели стоят два слоя проверки: структурный и вычислительный.
Первая функция, sanitize(), ничего не считает — она смотрит, можно ли вообще показать задание человеку. Многие правила в ней появились после конкретных жалоб.
Ключа нет среди вариантов. Раньше мы в таком случае дописывали ответ к вариантам. Однажды ключ пришёл испорченным, «verno», и игроки увидели в уроке кнопку с этим словом. Теперь такое задание просто выбрасывается.
Неверная разметка «выбери все». Если модель отмечала меньше двух верных вариантов, раньше мы превращали задание в выбор одного. Но в условии оставалось «Choose all…», и игрок, выбравший другой верный вариант, получал ошибку. Теперь такое задание выбрасывается: для «нескольких верных» нужно минимум четыре варианта и от двух до n − 1 верных.
Модель передумала посреди условия. Встречались условия вроде «…wait, we stop at 11!» — модель начала одно задание, а закончила другое. Ловим явные оговорки: wait, или wait!, oops, hmm, «подожди,». Но не просто слово wait: «Wait time is 5 minutes» — нормальное условие.
Пустые кнопки. Вариант из неразрывного пробела или пробела нулевой ширины (U+200B) проходит проверку «строка не пустая», а на экране получается пустая кнопка. Поэтому сначала чистим невидимые символы, потом проверяем.
Пары с одинаковым текстом в двух колонках («cat — кошка» и «кот — cat»). Старые версии игры при совпадении гасили обе карточки сразу, последняя пара становилась несводимой, и урок вставал. Теперь такое задание выбрасывается.
Для «порядка» и «групп» мы просим модель записать ответ дважды. Первый раз — в структуре задания: элементы в верном порядке или карточки внутри корзин. Второй — отдельной строкой в поле answer: цепочкой «a → b → c» или раскладкой «Группа 1: x, y | Группа 2: z».
Если записи говорят об одних и тех же элементах, но расходятся в порядке или раскладке, мы не знаем, какая из них верная. Тогда задание выбрасываем: лучше потерять одно задание, чем засчитать верный ответ ошибкой. Если же самопроверка не пришла или её не удалось сопоставить, пишем это в лог и не судим. Стоит такая проверка одной лишней строки в ответе модели.
// order: answer — та же цепочка, записанная моделью отдельно
const chain = answer.split(/→|->/).map((x) => x.trim()).filter(Boolean);
if (chain.length === items.length) {
const sameSet = chain.every((c) => items.some((i) => norm(i) === norm(c)));
const sameOrder = chain.every((c, k) => norm(c) === norm(items[k]));
if (sameSet && !sameOrder) return null; // две записи спорят — не показываем
}
Вторая функция, verifyTask(), пересчитывает задания, где ответ можно получить без модели: арифметика, сравнения, ряды, «чётное или нет». Главное правило в шапке файла:
Трогаем только то, что распознали целиком. Если в задании есть хоть что-то непонятное — слова, переменные, единицы, — оставляем его как есть. Лучше не проверить, чем «исправить» верное.
Это правило важнее всех остальных. Валидатор, который уверенно «чинит» верные задания, опаснее модели.
В основе — парсер рекурсивного спуска на полсотни строк. Он понимает цифры, четыре действия и скобки, а всё остальное возвращает как null, «не наше дело». Перед разбором текст нормализуется:
пробелы убираем;
все виды минуса и тире (−, –, —) → -;
×, x, · → *; ÷ и : → /;
десятичная запятая между цифрами: «7,4» → 7.4.
С запятой есть ловушка. «1,000» в английском задании — это тысяча, а не одна целая ноль тысячных. Если видим запятую и ровно три цифры после неё, пересчитывать отказываемся.
export function evalExpr(raw: string): number | null {
if (/d,d{3}(?!d)/.test(raw)) return null; // «1,000» — тысячи, не дробь
const s = normalize(raw); // пробелы, минусы, ×, ÷, десятичная запятая
if (!/^[d.+-*/()]+$/.test(s)) return null; // есть слова — не считаем
// expr := term (('+'|'-') term)* ; term := num (('*'|'/') num)* ; num := '(' expr ')' | '-' num | число
// деление на ноль и незаконченный разбор → null
}
«Верно или неверно». «Верно ли: 7 × 9 = 64» делим по единственному знаку (=, <, >, ≤, ≥, ≠), считаем обе части и сами ставим ключ. Если знаков два («2 < x < 5») — не трогаем.
Группы. Правило корзины берём из её названия: «меньше 25», «After 4», «Чётные», «Odd result». Если в названии числа нет, а в вопросе оно одно («before 4 or after 4»), берём его оттуда. Каждая карточка должна подходить ровно в одну корзину, иначе задание неоднозначно и мы его выбрасываем. Если какая-то корзина после раскладки пустая — тоже. Если раскладка модели отличается от правила, пересобираем её.
Выбор и «несколько верных» по условию. «Выбери числа больше 23», «equal to 24»: условие должно быть ровно одно. Двойные («больше 5 и меньше 9») пропускаем — проще не проверить, чем ошибиться в разборе.
Порядок. «По возрастанию», «from smallest to largest» — сортируем сами. Если среди элементов есть равные значения, верный порядок не единственный, и задание выбрасывается. Мелочь, которая стоила нам бага: w в регулярках JavaScript не видит кириллицу, поэтому русские формы («от меньшего к большему», «по убыванию») перечислены явно.
Ряды с пропуском. «16, __, 18» или «2, 4, ?, 8»: если известные числа идут с постоянным шагом, пропуск вычисляется. Но когда известных чисел всего два, ряд может оказаться и не арифметическим: «3, __, 12» с ответом 6 — это умножение на 2. Поэтому в таких рядах ключ, который объясняется геометрической прогрессией, не трогаем, как и ряды, где в условии упомянуто умножение. Сами правим только маленькие целые шаги.
Пары «пример — ответ». Если обе стороны пары считаются, они должны быть равны, а ответы не должны повторяться. Модель находчиво обходила запрет на повтор: чтобы не писать «2,3» дважды, она делала пару «4,1 − 1,8 ↔ 2,3 + 0,02». Теперь пересчитывается и правая сторона.
«Сколько будет…». «Сколько будет 5,6 − 2,8?», «What is 7 × 8?», «12 + 5 = ?» — выражение достаём из условия и пересчитываем. В выборе верным должен оказаться ровно один вариант. Во «впиши» ключ исправляем, но с одним исключением: если в выражении есть дробь, ответ «7/12» в десятичный не переписываем, а выбрасываем задание. Ребёнок в пятом классе ждёт дробь.
Всё это обёрнуто в try/catch: проверка не имеет права уронить порцию. Каждое исправление пишется в лог одной строкой — «ключ “3” → “4”», и по логам видно, где модель ошибается чаще всего.
Самое неочевидное решение касалось не проверок, а того, что делать, когда после них ничего не осталось.
Сначала здесь стоял мягкий запасной вариант: если проверка выбросила все задания, отдаём исходные «как пришли» — лучше сомнительное задание, чем никакого. На деле получалось худшее из возможного. До человека доезжало ровно то, что проверку не прошло: условие пустое, вариантов нет, на экране рамка с пустотой внутри.
Теперь пустая порция — это отказ, а не ответ: сервер возвращает 500. У клиента на это есть повтор, и со второй попытки модель обычно собирает нормальную порцию. Человек ждёт дольше, зато не видит сломанного урока.
Вызов модели — это 7–8 секунд ожидания и деньги. Когда мы посчитали, оказалось, что на 371 построенный путь пришлось всего 146 разных целей: люди часто жмут на готовые примеры на экране цели.
Поэтому кэшируется всё, что не зависит от человека: начало пути (цель + язык), продолжение к конкретному началу и первая порция заданий нового пути, пока истории ответов ещё нет. На один ключ держим несколько вариантов — три для плана, четыре для заданий, — чтобы у всех не был один и тот же путь. Пока вариантов меньше предела, строим новый; дальше отдаём случайный готовый мгновенно. Запись в кэше живёт 30 дней. Всё, что идёт после первой порции, подстраивается под ответы и строится заново, мимо кэша.
У кэша нашёлся и второй смысл: если модель недоступна, игра отдаёт готовый вариант, даже когда их меньше предела.
Для самых частых целей — таблица умножения на каждое число от 2 до 9, счёт до 20, дроби пятого класса, первые английские слова — мы сделали тренажёры, где задания собирает код. Модель там не вызывается вовсе: задания появляются мгновенно и всегда верны.
Генератор детерминирован по зерну (простой PRNG на 32-битных операциях). Неверные варианты — правдоподобные: соседние строки таблицы, ±1, ±10. Каждое четвёртое задание — обратное: «7 × ? = 56». Есть и побочный бонус: примеры на самой странице собираются с фиксированным зерном, поэтому поисковый робот и человек видят одно и то же, а сам тренажёр берёт случайное зерно.
Текстовые задачи, исторические даты, грамматика, «какое слово лишнее» — здесь калькулятор бессилен. На эти случаи остаются три линии защиты:
инструкции модели перепроверять себя и выбрасывать спорное;
двойная запись ответа там, где формат позволяет;
кнопка «Задание с ошибкой?» в каждом уроке. Каждая жалоба попадает в лог, а повторяющиеся превращаются в новые правила. Шапка файла с проверками буквально начинается со списка жалоб, из которых он вырос.
Схема ответа гарантирует форму, но не правильность.
Двойная запись ответа — дешёвая самопроверка модели. Расхождение означает «не показывать».
Пересчитывайте то, что считается, но только если распознали задание целиком. Уверенный валидатор, «чинящий» верное, хуже модели.
Выбросить лучше, чем угадать. Пустой ответ с повтором лучше сломанного экрана.
Собирайте жалобы прямо в продукте: они дешевле любого набора тестов и показывают ошибки, о которых вы не догадывались.
Проект — Люмекс (lumexquest.ru). Если вы проверяете вывод языковых моделей иначе — особенно в текстовых задачах, где калькулятор не поможет, — расскажите в комментариях.
Автор: qa__s
Источник [2]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/37671
URLs in this post:
[1] ошибкой: http://www.braintools.ru/article/4192
[2] Источник: https://habr.com/ru/articles/1100788/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1100788
Нажмите здесь для печати.