«6 − 2 = 3»: как мы проверяем кодом задания, которые пишет языковая модель. Claude.. Claude. edtech.. Claude. edtech. llm.. Claude. edtech. llm. Natural Language Processing.. Claude. edtech. llm. Natural Language Processing. structured_output.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript. zod.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript. zod. валидация.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript. zod. валидация. искусственный интеллект.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript. zod. валидация. искусственный интеллект. Машинное обучение.. Claude. edtech. llm. Natural Language Processing. structured_output. TypeScript. zod. валидация. искусственный интеллект. Машинное обучение. нейросети.

Мы делаем учебную игру: человек пишет цель своими словами («таблица умножения», «дроби за 5 класс», «английский для поездки»), а игра строит под неё путь из локаций. В каждой локации — порция из пяти заданий. Готовых курсов нет: тема может быть любой, поэтому задания пишет языковая модель (Claude от Anthropic) — под цель, уровень и последние ответы игрока.

Придумывает задания модель хорошо, а считает плохо. В октябре мы разобрали 68 жалоб «Задание с ошибкой?» — игроки отправляют их кнопкой прямо из урока. Типичные случаи:

  • «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), так что форма гарантирована: поля на месте, типы верные.

Но схема ничего не говорит о смысле. Верного варианта может не оказаться среди вариантов. Пары могут повторяться. Раскладка по группам может противоречить собственному условию. Поэтому после модели стоят два слоя проверки: структурный и вычислительный.

Слой 1. Структура: выбрасываем то, что нельзя показать

Первая функция, 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; // две записи спорят — не показываем
}

Слой 2. Пересчитать то, что считается

Вторая функция, 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». Есть и побочный бонус: примеры на самой странице собираются с фиксированным зерном, поэтому поисковый робот и человек видят одно и то же, а сам тренажёр берёт случайное зерно.

Что код не ловит

Текстовые задачи, исторические даты, грамматика, «какое слово лишнее» — здесь калькулятор бессилен. На эти случаи остаются три линии защиты:

  1. инструкции модели перепроверять себя и выбрасывать спорное;

  2. двойная запись ответа там, где формат позволяет;

  3. кнопка «Задание с ошибкой?» в каждом уроке. Каждая жалоба попадает в лог, а повторяющиеся превращаются в новые правила. Шапка файла с проверками буквально начинается со списка жалоб, из которых он вырос.

Выводы

  1. Схема ответа гарантирует форму, но не правильность.

  2. Двойная запись ответа — дешёвая самопроверка модели. Расхождение означает «не показывать».

  3. Пересчитывайте то, что считается, но только если распознали задание целиком. Уверенный валидатор, «чинящий» верное, хуже модели.

  4. Выбросить лучше, чем угадать. Пустой ответ с повтором лучше сломанного экрана.

  5. Собирайте жалобы прямо в продукте: они дешевле любого набора тестов и показывают ошибки, о которых вы не догадывались.

Проект — Люмекс (lumexquest.ru). Если вы проверяете вывод языковых моделей иначе — особенно в текстовых задачах, где калькулятор не поможет, — расскажите в комментариях.

Автор: qa__s

Источник