Как проверяющий ИИ испортил правильные ответы: разбор генератора учебных заданий

В задании на сортировку было слово «маши…ый». Генератор положил его в корзину «Две НН», и это верно. Проверка переложила слово в корзину «Одна Н». Вот ответ модели-арбитра, после которого это случилось, без правок, из сохранённого JSON:
{
"n": 4,
"a": "Одна Н",
"why": "Прилагательное от «машина» с суффиксом -н-: корень оканчивается на н? Нет — «машин-» + -н- даёт «машинный»? В школьной норме пишется «машинный» (две НН), но по данному заданию основа «машин»+н… — фактически верно «машинный»."
}
Ответ в поле a неверный, а в объяснении модель сама приходит к «машинный». Код читал только a.
Случай из испытаний Игротеки, конструктора учебных игр по русскому языку, который мы делаем в «Первом ИИ». Учитель вводит тему, модель собирает задания для десяти игр: викторины, сортировки, «Прожектора», «Детектора лжи» и других. Ключ, то есть правильный ответ, по которому игра засчитывает клик, здесь важнее любой формулировки: игру выводят на доску, и с неверным ключом класс за правильный ответ увидит «неверно».
7 сентября мы нарочно дали генератору трудную тему: Н и НН в разных частях речи для 9 класса, с краткими формами, наречиями и исключениями. Все роли внутри продукта, от генератора до арбитра, играла claude-opus-5, это видно по журналу каждого вызова. Ответы сохранялись в файлы, цитаты ниже оттуда.
Код генератора и проверок при этом пишет не она, а ИИ-помощники Codex и Claude Code, они же проверяют правки друг друга. Я ставлю задачи и решаю, что выкатывать.
Сразу про итог. Тема из этого прогона в каталог не попала, ученики её не видели. Но остановил её не «машинный», и это важная часть истории.
Как была устроена проверка 7 сентября
Схема относится к той версии кода, на которой шло испытание. С тех пор она менялась.
тема учителя
→ генерация: один запрос, JSON на 10 игр
→ валидатор: структура, запрещённые символы, дубли
брак → ремонт сломанных игр или пересборка темы
→ два решателя вслепую: два параллельных запроса без ключей
→ хоть один разошёлся с ключом автора → арбитр, один запрос на все спорные пункты
задания с выбором варианта: ответ арбитра записывается в ключ
текстовые задания: вердикт «сломано» отправляет игру в ремонт
→ были сломанные → ремонт и вся проверка заново
→ остались сломанные или брак → тема не сохраняется, иначе → каталог
Валидатор здесь обычный код без модели. «Независимые» решатели это два отдельных запроса к той же модели с теми же настройками: у них разные роли в начале промпта («строгий учитель» и «методист, который принимает чужие материалы»), второму задания и варианты показаны в обратном порядке, ключа автора и ответа соседа не видит ни один. Арбитр получает только спорные пункты, тоже без ключа и голосов. Независимы они только по информации, ошибаться одинаково им ничто не мешает.
«Машинный»: у арбитра было право записи
В первом круге решатели по «маши…ый» разошлись: один ответил «Две НН», второй «Одна Н». Пункт стал спорным и ушёл арбитру, тот вернул ответ из начала статьи, и сработала вот эта ветка (сокращено, убраны тексты журнала):
elif a == d["was"]:
kept.append(...)
else:
set_label(g, d["key"], d["i"], a)
if d["key"] == "auction":
g["auction"]["data"][d["i"]]["why"] = r["why"]
fixed.append(...)
Норма тут однозначная. Основа «машин-» кончается на Н, суффикс -н- с Н начинается, на стыке пишется НН. Это общий принцип из § 95 полного академического справочника «Правила русской орфографии и пунктуации» под редакцией Лопатина, там же «длинный» от «длина» и «старинный» от «старина».
Всего в первом круге арбитр переписал три ключа. Ещё один показательный случай: «ране…ый в плечо боец». Автор выбрал «раненный», и это верно, в § 98 справочника прямо стоят «раненный в ногу солдат» и «раненый солдат». Оба решателя ответили «раненый», арбитр тоже. Три запроса согласились и ошиблись вместе. Для игры «Аукцион» код заодно заменил объяснение автора на объяснение арбитра.
После первого круга одно правило в «Детекторе лжи» признали сломанным, игру пересобрали, и проверка пошла заново по всей теме. Для второго круга испорченный ключ уже выглядел авторским. Оба решателя ответили «Одна Н», совпали с ним, и спорным пункт больше не считался. Ошибку внёс арбитр, а узаконили её решатели.
С «раненным» во втором круге вышло наоборот. Голоса разошлись, арбитр ответил «раненный» и вернул верный ключ. Объяснение он при этом написал со стрелкой «→», а валидатор запрещает этот символ в текстах заданий. Этот форматный отказ плюс два так и не починенных правила в «Детекторе лжи» и не пустили тему в каталог. По логике той версии кода, окажись правила исправны, а объяснение без стрелки, тема сохранилась бы с «маши…ый» в корзине «Одна Н». Так что отказ этого прогона ничего не говорит о том, сколько смысловых ошибок проверка ловит.
Процесс тут виноват больше модели. Один запрос арбитра перевешивал автора и решателя, его поле a без сверки с объяснением становилось ключом, а при повторной проверке испорченный ключ уже считался авторским. Согласие трёх ответов одной и той же модели ничего не гарантировало.
Что поменяли
Арбитру оставили право сказать «с этим пунктом что-то не так» и забрали право исправлять. Если он разошёлся с автором, ключ остаётся как был, а пункт уходит в пересборку с нейтральной просьбой:
else:
stat["sick"].append(f"{d['key']}[{d['i']}]: арбитр разошёлся с автором; "
"ключ не изменён. Собери другой пример, независимо проверь его ответ")
Ситуация | Версия 7 сентября | После правки |
|---|---|---|
Арбитр не согласен с автором | ответ арбитра записывается в ключ | ключ не меняется, пункт уходит в пересборку |
Объяснение арбитра при расхождении | в «Аукционе» заменяет объяснение автора | лежит только в сыром журнале, в задание на ремонт не попадает |
Последняя строка важна: иначе ошибочное рассуждение арбитра стало бы инструкцией для генератора.
Чем проверили
Регрессионный тест test_recorded_wrong_arbitration_never_changes_author_key повторяет сбой на тестовой теме: решатели и арбитр отвечают «Одна Н», арбитр с доводом «По данному заданию одна Н, но фактически верно машинный». Тема должна остаться без изменений, пункт попасть в пересборку, а довод арбитра туда не попасть. На версии до правки тест падает: ключ меняется с 1 на 0. Сохранённые ответы первого круга мы ещё прогнали через обе версии кода без запросов к модели. Старая переписывает три ключа, новая ни одного и отправляет «маши…ый» в пересборку.
Позже, в живом прогоне следующего этапа, арбитр опять ответил поперёк собственного объяснения. В «Прожекторе», где ловят слова с НН, автор пометил «ветре…ый» как «пропустить». Это верно: «ветреный» пишется с одной Н как исключение (§ 97, примечание). Арбитр ответил так:
{"n": 2, "a": "ловить", "why": "«Ветреный» — исключение с одной Н… но в задании требуется НН: на самом деле пишется одна Н, поэтому ловить не следует."}
Новый код ключ не тронул и отправил пункт в пересборку. Старый записал бы «ловить».
Чего эта правка не ловит
Если автор ошибётся так же, как три запроса с «раненным» в первом круге, решатели с ним согласятся, и пункт не станет спорным. Когда прав арбитр, а ошибся автор, пункт теперь тоже пересобирается, а не исправляется, и генератор ремонта старый пример не видит, так что новый может оказаться не лучше. Объяснение арбитра по-прежнему уходит в задание на ремонт, когда он объявляет задание сломанным, и в текстовом арбитраже. И это один прогон одной темы, статистики из него не выведешь.
Пара, которую модель починила в уме
В другом прогоне той же темы генератор собрал пару для игры «Соедини пары». Слева слово с пропуском, справа оно же с раскрытой буквой:
["маши..а вязка", "машинная вязка"]
Слева не хватает буквы «я», и никакая вставка вместо точек не даст «машинная». Пары тогда проверялись одним логическим полем «верна ли», проверка шла в два круга, и все четыре ответа решателей пару одобрили. Чтобы понять, откуда берётся «да», мы отправили её арбитру контрольным запросом вместе с тремя другими пунктами, без ключа, с вопросом «Верна ли и однозначна пара». Ответ по паре (сокращено, убраны пустые поля):
{
"n": 3,
"broken": false,
"answer": "да",
"why": "От существительного «машина» прилагательное образуется суффиксом -н-: машин- + -н- = машинный, поэтому «маши..ая вязка» однозначно соответствует «машинная вязка»; других вариантов заполнения нет."
}
В объяснении модель цитирует «маши…ая вязка», хотя в задании стояло «маши…а». Проверено исправленное в уме условие, и для него всё сходится. Человек так же пробегает глазами опечатку в знакомой фразе. Ученику же достанется то, что написано.
Сверка кодом вместо доверия
Раскрывает ли правая часть пропуск, можно проверить механически, и доверять это модели незачем. Решатели теперь указывают тип связи в паре: «раскрытие» (справа то же слово с заполненным пропуском) или «другая» (проверочное слово, правило, значение). Если оба назвали пару раскрытием, код подставляет на место точек любую строку и требует точного совпадения всего остального:
def pair_expands(left, right):
pattern = re.escape(tidy_text(left)).replace(r'\.\.', '.*')
return re.fullmatch(pattern, tidy_text(right)) is not None
Шаблон маши.*а вязка с «машинная вязка» не совпадает, и пара уходит в пересборку без арбитра. Если раскрытием её назвал только один решатель, спор решает арбитр. Если никто, сверка не применяется: «л…сной» и «лес» законная пара с проверочным словом.
Одну и ту же повреждённую пару с одобрением решателей мы прогнали через код до и после правки, подставив ответы модели заглушкой. Старая версия пропускает её без замечаний, новая отклоняет. Тест test_wrong_pair_cannot_be_accepted_by_boolean_alone закрепляет этот случай, а соседние тесты следят, чтобы не отклонялись законные пары и пропуски, где вставка пустая, пробел или запятая. В сохранённых ответах последнего живого прогона оба принятых ответа решателей снова назвали пару верным раскрытием. Остановил её код с пометкой «правая часть меняет буквы вне пропуска; собери новую пару».
Где сверка слепа
Она держится на метке типа связи, а метку ставит модель. Назовут оба решателя повреждённую пару «другой», и до кода дело не дойдёт. Проверяется только текст вне пропуска. Что вставлено внутрь и есть ли такое слово, сверка не знает.
Колесо и оценки, съехавшие на соседний номер
«Колесо» (выпадает пункт, ученик вслух называет написание и объясняет) до этого этапа слепая проверка не смотрела вовсе, и однажды туда пролезло английское traditional. Колесо добавили в лист решателей, и первый же живой прогон показал новую ошибку. Три строки из принятого ответа одного решателя: номер, пункт темы под этим номером и то, что модель под этим номером написала:
i=5 пункт «изране..ый» ok=false why: нет пропуска и слова «путнинца» не существует: искажено, вне темы
i=6 пункт «путни..ца» ok=true why: нежданный — слово-исключение, НН
i=7 пункт «нежда..ый» ok=true why: изранённый — приставка, НН
Оценки съехали на чужие номера, а проверка формы этого не заметила: пунктов сколько нужно, номера не повторяются. Пункт «изране…ый» ушёл к арбитру как подозрительный, и арбитр его оправдал. Пункт «путни…ца», который второй решатель и арбитр признали непригодным для темы, от этого решателя получил «ok», и сдвинься номера у второго, он прошёл бы без спора. Почему модель перепутала номера, не установить: промпты вызовов не сохранялись, только ответы.
Поле, которое нужно переписать дословно
Решатель теперь переписывает пункт в поле w, а код сравнивает его с пунктом под номером i:
if any(not isinstance(r.get('w'), str)
or tidy_text(r['w']) != tidy_text(g['wheel']['data'][r['i']]) for r in wheel):
return False
При несовпадении весь ответ решателя отбраковывается и запрашивается заново, всего до трёх попыток, потом проверка останавливается с ошибкой. Тест test_wheel_cannot_attach_verdict_to_another_item меняет местами два w и ждёт отказа. В следующем живом прогоне у обоих принятых ответов каждое w совпало со своим номером.
Сверка подтверждает, что оценка стоит рядом с правильным текстом, но не доказывает, что модель рассуждала о нём. Регистр она не сглаживает (в темах про ударение заглавная буква отмечает ударный слог), так что лишние повторные запросы возможны.
Как повторить без новых запросов к модели
Воспроизведение на сохранённых ответах не требует обращений к модели, и приём переносится на любую цепочку с проверяющей моделью. В испытаниях вызов API оборачивался, и каждый ответ писался в файл с номером вызова и именем шага. Для разбора функция вызова подменяется той, что отдаёт сохранённые ответы через тот же валидатор, что и живые. Дальше одна запись прогоняется через старую и новую версию кода, и сравниваются данные, а не текст отчёта. Сокращённый пример, не копия рабочего файла:
saved = {
"verdict": [load("call-04.json"), load("call-05.json")],
"final": [load("call-06.json")],
"text_final": [load("call-07.json")],
}
def replay_call(prompt, schema, name, ok):
answer = saved[name].pop(0)
assert ok(answer), f"сохранённый ответ не прошёл валидатор: {name}"
return answer, 0.0
review.call = replay_call
topic = gen.tidy(load("call-03.json"))
before = copy.deepcopy(topic)
result = review.run(topic)
# дальше сравниваем ключи topic и before, смотрим result["sick"]
Порядок двух решателей на результат не влияет: пункт спорный, если с ключом разошёлся хоть один. Клиент API на время прогона лучше заменить заглушкой, которая падает при создании. Для прогона с парой и колесом итоговая версия кода на сохранённых ответах дала тот же список отклонённых пунктов, что и живой прогон. Предел у приёма важный: запись фиксирует ответы модели в тот день, а новый промпт или другая модель ответят иначе, и для них нужен новый живой прогон.
Что проверяющей модели теперь можно
Соседние темы на Хабре уже разобраны: Sber AI сравнивает оценки своей модели-судьи Pollux с оценками экспертов, а в статье «LLM-судье нельзя верить на слово» последнее слово у детерминированной проверки, которая сверяет ответ с эталоном. У нас для произвольного нового задания независимого эталона нет: ключ пишет модель. Код проверяет отдельные свойства задания, но полную проверку смысла не заменяет. Поэтому полномочия поделены так.
Проверяющая модель в Игротеке может объявить пункт спорным или сломанным и отправить игру в пересборку, а переписать ключ или объяснение автора больше не может. Если арбитр выбирает другой ответ из вариантов, ключ остаётся прежним, игра уходит в пересборку, и в этой ветке генератор получает нейтральное сообщение о расхождении без рассуждений арбитра. В сообщениях о сломанном задании и в текстовом арбитраже объяснения пока передаются дальше. Механическую сверку согласие модели не отменяет, будь то раскрытие пропуска, привязка оценки к пункту или запрещённые символы.
Главное ограничение осталось: правильность написания по-прежнему определяет модель, и когда автор, решатели и арбитр ошибаются одинаково, спорить некому. Метку «раскрытие» тоже ставит модель, а каждая пересборка стоит новых запросов и может принести новую ошибку. Прошедшие тесты подтверждают поведение кода на заданных случаях, о качестве следующего урока они ничего не говорят.
С 7 сентября проверка выросла. В коде, который мы прочитали 17 сентября, для тем про Н/НН и НЕ в промпты добавлены выдержки из правил с источниками, объяснения автора проверяются отдельным запросом, и все три описанные правки на месте.
За пределами орфографии картина та же: проверка договора может прочитать пропущенное «не» так, как было задумано, а пакетный разбор кода может приписать замечание соседнему файлу. Проверяющая модель полезна, пока её ответ поднимает вопрос. Когда ответ сам записывается в данные, в цепочке появляется второй автор, и проверять приходится уже его.
Игротеку мы разрабатываем в студии «Первый ИИ», где создаём сайты и ИИ-помощников для малого бизнеса.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.