Как проверяющий ИИ испортил правильные ответы: разбор генератора учебных заданий. Claude.. Claude. codex.. Claude. codex. llm.. Claude. codex. llm. Natural Language Processing.. Claude. codex. llm. Natural Language Processing. python.. Claude. codex. llm. Natural Language Processing. python. генерация контента.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект. образование.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект. образование. регрессионные тесты.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект. образование. регрессионные тесты. сезон код будущего.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект. образование. регрессионные тесты. сезон код будущего. тестирование.. Claude. codex. llm. Natural Language Processing. python. генерация контента. искусственный интеллект. образование. регрессионные тесты. сезон код будущего. тестирование. Тестирование IT-систем.

В задании на сортировку было слово «маши…ый». Генератор положил его в корзину «Две НН», и это верно. Проверка переложила слово в корзину «Одна Н». Вот ответ модели-арбитра, после которого это случилось, без правок, из сохранённого 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 сентября, для тем про Н/НН и НЕ в промпты добавлены выдержки из правил с источниками, объяснения автора проверяются отдельным запросом, и все три описанные правки на месте.

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

Игротеку мы разрабатываем в студии «Первый ИИ», где создаём сайты и ИИ-помощников для малого бизнеса.

Автор: eignatiev

Источник