The Jerusalem PostThe alliance with America is changing, and Israel cannot afford to ignore it - opinionInquirerMunicipal employee shot dead in Bukidnon; manhunt on for suspectsDaily MaverickThe varsity research subsidy game is up — the education department must not back downInquirer EntertainmentBea Millan-Windorski flies to Puerto Rico ahead of 2026 Miss UniversePunch2026 set to be second-hottest year on record — EUColliderApple TV Officially Resurrects Christopher Nolan’s 169-Minute Sci-Fi MasterpieceCBS NewsDemocrats could dust off plans for court reforms after capturing CongressZDF heuteAktuelle Pressemitteilungen des ZDFThe Hollywood Reporter“Saviors of Cinema” and “Money Machine”: Tallinn Puts Spotlight on Kids, Youth Films and Social Media BansThe RegisterUK ministers seek powers to investigate when AI agents are let off the leashGIGAZINEAIのオープンモデル利用率が大きく拡大中、オープンウェイトモデルとクローズドウェイトモデルの違いは何か?BillboardInterscope Expands Into Brazil With Hip-Hop Artist BK, Supernova Entertainment Joint Venture
The Daily Newsstand · Free, Always
Friday, November 6, 2026

Модель размечает, код считает: как устроена ИИ-проверка ЕГЭ по обществознанию

Translate

Я учитель и эксперт ЕГЭ, около двух лет я работаю над Telegram-ботом, который проверяет ответы второй части ЕГЭ по обществознанию, и главное, чему научил меня этот проект: модели нельзя поручать ставить балл.

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

В этой части расскажу о том, как это устроено: путь одного ответа, распределение обязанностей между моделью и кодом по заданиям, два фрагмента кода, отказоустойчивость и цена. Насколько точно это всё работает и как я это измерял, расскажу во второй части.

Что вообще проверяем

ЕГЭ по обществознанию состоит из двух частей. Первая — задания 1–16 с коротким ответом, их проверяет машина. Вторая — задания 17–25 с развёрнутым ответом: ученик пишет текст, а оценивают его два независимых эксперта по критериям ФИПИ. Если их баллы сильно расходятся, работу смотрит третий.

Задание

Что делает ученик

Макс. балл

17–18

отвечает на вопросы к тексту, объясняет понятие и связь

2 + 2

19

приводит три конкретных примера к теоретическому положению

3

20

формулирует суждения по теме

3

21

анализирует график спроса и предложения

3

22

решает задачу-ситуацию

4

23

находит в Конституции РФ нормы, которые подтверждают положение

3

24

составляет сложный план по теме

4

25

обосновывает тезис, отвечает на вопрос, приводит три примера

6

Сложность для ИИ в том, что критерии очень формальные. Пример в задании 19 должен быть конкретным: «полиция охраняет порядок» — это функция, а не пример, за него ставится 0. Если в задании сказано «про Россию», пример про «государство Z» не засчитывается. План в задании 24 без трёх обязательных пунктов из эталона получает 0 баллов целиком, даже если всё остальное в нём верно. Модель, которая «в целом понимает обществознание», оценивает такие ответы слишком щедро. Отсюда и архитектура, описанная ниже.

Путь одного ответа

Первичный балл ставит модель, а всё, что идёт после неё, может только уменьшить его. Внутри бота все шаги — обычные await в одном процессе, очереди нет. Тот же эвалуатор вызывается из режима учителя, с сайта и из B2B API; в B2B перед ним стоит очередь на Redis.

Кто что считает в заданиях 17–25

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

Задание (макс.)

Что делает модель

Что считает код

Перепроверка

17 (2) — вопросы к тексту

всю оценку

обрезку балла до диапазона

—

18 (2) — понятие и связь

оценку понятия и объяснения

сверку признаков с каталогом (стемминг)

—

19 (3) — три примера

первичный балл через tool_use

регулярки фактических ошибок, требование «про Россию», штраф за лишние примеры

«конкретное действие органа»

20 (3) — суждения

оценку каждого суждения

арифметику штрафа за лишние суждения

дубли суждений

21 (3) — график спроса и предложения

только второй вопрос

первый и третий вопросы по ключевым словам, итог = число верных

—

22 (4) — задача

всю оценку

обрезку балла

—

23 (3) — Конституция

оценку по элементам

поиск якоря в Конституции, сверку итога с вердиктами по элементам

подтверждение якоря

24 (4) — план

замечания и обратную связь, на К1 не влияет

К1 и К2 целиком: разбор плана, леммы, сверка с эталонными планами

снятие К2 через три прогона

25 (6) — обоснование и примеры

оценку К1–К3

«одно предложение → К1 = 0», проверку диапазонов

конкретность примеров в К3

Во всех заданиях, кроме одного, код может балл модели только понизить. Исключение составляет задание 20: тут код пересчитывает штраф за лишние суждения и может балл поднять, если модель обнулила ответ там, где по правилу снимается один балл. Модель делала это стабильно.

Закономерность видна по объёму кода: задание 17 укладывается в 942 строки, а 24 и 25 занимают по 6,8–7,2 тысячи. Чем сложнее критерии, тем больше кода вокруг модели, а не длиннее промпт.

Как выглядит «разметка»

Модель не пишет в ответ «ставлю 2 балла, потому что…». Она заполняет JSON-схему через tool_use, и каждое поле в ней потом проверяет код. Вот сокращённая схема для задания 19:

TASK19_PRIMARY_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "score": {"type": "integer", "minimum": 0, "maximum": 3},
        "total_examples": {"type": "integer", "minimum": 0},
        "task_requirements": {
            "type": "object",
            "properties": {
                "requires_russia": {"type": "boolean"},
                "requires_structure": {"type": "boolean"},
            },
        },
        "valid_examples": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "number": {"type": "integer"},
                    "text_snippet": {"type": "string"},   # цитата из ответа
                    "is_about_russia": {"type": ["boolean", "null"]},
                    "why_valid": {"type": "string"},
                    "illustrates_thesis": {"type": "boolean"},
                },
            },
        },
        "invalid_examples": {"type": "array"},
    },
}

Самое полезное поле здесь illustrates_thesis. Модель часто засчитывает пример, который конкретен и фактически верен, но иллюстрирует не то, о чём спрашивали: например, рассказывает о выборах, когда просили пример источника информирования о правовой системе. Отдельный флаг заставляет её ответить на этот вопрос явно, а код потом ограничивает балл числом примеров, где флаг стоит в true.

Задание 19: перепроверка, которая только понижает

В задании 19 ученик приводит три конкретных примера. Часть формулировок требует показать действие государственного органа, и тут модель систематически засчитывала лишнее: пересказ функции органа или рассказ о том, что сделал гражданин. Решение — второй вызов модели с отдельной схемой, поверх которого решает код.

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

def _task_requires_organ_actions(self, task_text: str) -> bool:
    """Conservative default — при отсутствии явных positive markers
    возвращаем False: лучше пропустить recheck (маленький overscore-риск),
    чем применить его ложно (−2 балла underscore).
    """
    text = (task_text or "").lower()
    organ_patterns = (
        r"принуждать\s+к\s+соблюдению",
        r"правоохранительн\w*",
        r"институт\w*\s+принуждения",
        r"контрольно-надзорн\w*",
        r"обеспечива\w*\s+соблюдение",
        r"правоприменительн\w*",
    )
    if any(re.search(p, text) for p in organ_patterns):
        return True
    return False  # перераспределение доходов, факторы, источники информации — мимо

Второе решение: один сигнал не довод. Модель ставит каждому примеру четыре флага, а отклоняет пример уже код, и только при двух согласных негативных сигналах. Балл при этом может только уменьшиться:

# High-confidence reject: пример отклоняется ТОЛЬКО при ≥2 независимых
# negative signal'ах. Один-единственный флаг слишком шумный.
strong_rejects = []
for e in examples_list:
    if not isinstance(e, dict) or e.get("passes_recheck") is True:
        continue
    negative_signals = sum([
        e.get("organ_is_actor") is False,
        e.get("has_concrete_action_verb") is False,
        e.get("is_abstract_function") is True,
        e.get("is_citizen_narrative") is True,
    ])
    if negative_signals >= 2:
        strong_rejects.append(e.get("number"))

n_strong_passed = len(valid_examples) - len(strong_rejects)
if 0 <= n_strong_passed < len(valid_examples):
    new_score = min(original_score, n_strong_passed)  # только вниз
    if new_score < original_score:
        primary_result["score"] = new_score
        primary_result["penalty_applied"] = True

Оба решения об одном: защита от завышений сама рождает занижения, и область её срабатывания важна так же, как сама проверка. О том, как это сказалось на цифрах, расскажу во второй части.

Задание 24: балл ставит код, модель только спорит

Задание 24 требует составить сложный план по теме. Первый критерий проверяет, есть ли в плане обязательные пункты, и это почти механическая сверка. Поэтому в боте К1 и К2 целиком считает код, а вызовы модели влияют только на текст разбора.

К1: леммы и синонимы заголовков

Пункты плана лемматизируются pymorphy2 и сверяются с эталонными планами банка (185 тем). Порог совпадений зависит от длины ключевого пункта. Самая досадная ошибка такой схемы — синонимы: ученик пишет «Признаки», в эталоне «Особенности», и верный план получает ноль. Поэтому «головы» заголовков приводятся к одной форме:

_HEAD_SYNONYM_GROUPS = (
    frozenset({'признак', 'особенность', 'черта', 'характеристика', 'свойство'}),
    frozenset({'вид', 'тип', 'типология', 'классификация', 'разновидность'}),
    frozenset({'этап', 'стадия'}),
)
_HEAD_CANON = {lemma: min(group) for group in _HEAD_SYNONYM_GROUPS for lemma in group}

Две осторожности не дают синонимам завышать балл. Синонимичный проход идёт только по пунктам, которые не совпали буквально, и один пункт ученика закрывает не больше одного ключевого. А отличительное слово, кроме «головы», всё равно должно стоять в заголовке: голая «Классификация» не закроет «Типы партийных систем». Проверка качества простая: все 185 эталонных планов, поданные как ответ, должны получать максимум по К1. Сейчас получают все 185, а с заменёнными на синонимы заголовками до правки было 147.

К2: четыре фильтра, прежде чем снять балл

К2 ставится автоматически при полном К1. Снять его может только фактическая ошибка в плане, и тут модель была самым опасным источником занижений: одиночный запрос «найди ошибки» снимал балл даже с эталонных планов. Теперь находка проходит четыре фильтра:

  1. тип ошибки — из закрытого списка (сейчас в нём один тип: несуществующий вид или функция);

  2. дословная цитата длиной не менее 8 символов, которая действительно есть в плане;

  3. два независимых прогона указали на один и тот же фрагмент;

  4. отдельный прогон-верификатор подтвердил, что названного вида или функции в школьном курсе нет.

Четвёртый фильтр появился последним, и он самый неочевидный. Согласие двух прогонов ловит не только ошибку ученика, но и общий ложный стереотип модели: на эталонных планах оба прогона дружно находили одну и ту же несуществующую ошибку. Два одинаковых ответа одной модели — это не два независимых мнения.

def _deductible_findings(findings, plan_text):
    """Цитата обязательна именно дословная — пересказанную «ошибку» нельзя
    ни показать ученику, ни сверить между прогонами."""
    plan = _normalise_quote(plan_text)
    kept = []
    for finding in findings:
        if finding.get("error_type") not in K2_DEDUCTIBLE_ERROR_TYPES:
            continue
        quote = _normalise_quote(finding.get("quote"))
        if len(quote) < 8 or quote not in plan:
            continue
        kept.append(finding)
    return kept

Замечания, не прошедшие фильтры, не выбрасываются: они попадают в обратную связь, но на балл не влияют. Оценка и замечания разведены.

Когда ИИ недоступен: лучше честный отказ, чем выдуманный балл

Самая неприятная ошибка в такой системе незаметна. Модель не ответила или вернула нечитаемый JSON, код тихо подставил ноль, и ученик видит обычный разбор с нулём баллов. У меня так работало задание 25: 16 таких нулей на 110 кейсах, и половина из них совпадала с эталоном и считалась в метриках «точным попаданием».

Сейчас в большинстве заданий любой сбой сводится к одному флагу ai_unavailable:

  • нет свободного слота за 90 секунд (на процесс — не больше 6 одновременных запросов);

  • сетевая ошибка после трёх попыток с экспоненциальной паузой;

  • отказ модели (stop_reason: refusal);

  • невалидный JSON после ремонта и одного повтора со строгой инструкцией.

С этим флагом балл не показывается вообще. Ученик видит «Проверка временно недоступна… Попытка не списана», администратор получает алерт. Важен и порядок: бесплатная проверка списывается после доставки результата. Раньше было наоборот, и любой сбой отправки сообщения в Telegram сжигал ученику попытку.

В ноябрьском прогоне на 393 кейсах не было ни одного тихого нуля, отказа модели или ошибки разбора. Но честно скажу, что не везде доведено до конца: в заданиях 21 и 23 и на пути задания 24 для сайта при сбое ещё ставится упрощённый балл без флага. И автоматического переключения на резервного провайдера нет: YandexGPT включается руками, переменной окружения и рестартом.

Структурированный ответ без принуждения

Отдельная мелочь, которая пригодится тем, кто работает с новыми версиями Claude. На текущей модели принудительный tool_choice возвращает ошибку 400, так что инструмент передаётся с {"type": "auto"}, а в конец запроса дописывается просьба вызвать инструмент. Если модель всё же ответила текстом, JSON достаётся из текста и при нужде чинится. Температура на новых моделях тоже не передаётся, вместо неё задаётся уровень усилия (effort: medium).

Цена и скорость: около двух центов и девяти секунд на ответ

В ноябре полный прогон на 393 ответах сделал 497 вызовов модели и стоил 8,87 $. В среднем одна проверка — 1,26 вызова, 0,023 $ и 9 секунд работы модели.

Задание

Вызовов на ответ

$ на ответ

Время модели на ответ, с

Доля входа из кэша

17

1,03

0,018

6,2

0%

18

1,10

0,026

8,4

41%

19

1,23

0,029

8,4

63%

20

1,38

0,021

9,5

66%

21 и 22

1,02

0,013

5,5

0%

23

1,85

0,026

11,3

44%

24*

1,24

0,010

5,6

73%

25

1,38

0,031

12,9

61%

Всего

1,26

0,023

9,0

54%

* Задание 24 здесь измерено на пути сайта, где модель размечает пункты плана. В боте вызовов больше, его цену отдельно я не мерил.

Два наблюдения. Дороже всего не самая длинная оценка, а перепроверки: у 23-го почти два вызова на ответ. И кэш промпта снижает цену заметно: в задании 19 из кэша берётся две трети входа, а в 17, 21 и 22 он не срабатывает совсем — похоже, их системные промпты слишком короткие для кэширования.

За скобками таблицы остаются OCR фотографий и доставка в Telegram. Время «от сообщения до балла» в проде я пока не измерял.

Стек, масштаб и что бы я сделал иначе

Бот написан на Python 3.10 и python-telegram-bot 22. Сайт и B2B API — FastAPI, очередь для B2B — arq поверх Redis, данные — SQLite в режиме WAL и PostgreSQL для B2B. Лемматизация — pymorphy2, ретраи — tenacity, модель — Claude Sonnet 5.5. Всё живёт на одном сервере в нескольких systemd-юнитах за nginx.

По объёму: ядро — около 33 тысяч строк, задания 17–25 — ещё столько же, тестов — около 1,8 тысячи без учёта других предметов. Реализацию я в большой степени делегирую Claude Code. Моя часть — критерии, разметка эталонов, спецификации правок и решения по спорным случаям.

Почему Claude и что с данными учеников

Это первый вопрос, который мне задают, поэтому отвечу сразу. Бот начинал на YandexGPT, и до конца июля 2026 года проверка работала на нём. Переключился я по трём причинам, и все они видны в цифрах. На одних и тех же 202 ответах Claude совпадал с экспертом в 65,8% случаев, YandexGPT в 54,5%. YandexGPT иногда отказывался отвечать на совершенно обычные экзаменационные задания («Я не могу обсуждать эту тему»), и такой отказ превращался в ноль баллов. И для структурированного ответа у Claude есть серверная JSON-схема, а с YandexGPT мне приходилось разбирать ответ эвристиками. Подробно об этом сравнении во второй части. YandexGPT остался в резерве.

Что уходит в модель: текст задания, ответ ученика и эталоны из банка заданий. Имени, юзернейма и Telegram ID в запросе нет. В режиме ученика текст ответа после проверки не сохраняется: в базе остаются только балл и длина ответа. Из-за этого, кстати, я не могу измерить точность на реальных ответах пользователей, но этот компромисс я считаю правильным.

Почему не дообучение

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

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

Что бы я сделал иначе, если бы начинал сначала:

  1. Один конвейер для всех заданий с первого дня. Часть заданий идёт через общий движок проверки, часть — своим хендлером. В итоге одну и ту же логику — учёт попыток, обработку сбоев — приходится поддерживать в нескольких местах, и она неизбежно расходится.

  2. Регрессионный набор раньше первого промпта. Бот в первой версии запустился летом 2025 года, а систематические замеры появились только весной 2026-го. Первый же прогон показал, что бот завышает балл примерно каждому третьему ответу.

  3. Хранить историю проверок. Сейчас бот сохраняет только балл и длину ответа. Это хорошо для приватности, но из-за этого точность на настоящих ответах учеников измерить нельзя. Нужен был анонимный журнал с согласием пользователя.

Во второй части расскажу, как я измерял точность всего этого и сколько раз обманывал себя метриками.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.