AI23 октября 2026 г.5 мин

AI вернул JSON, который ломает таблицу: проверка контракта

Проверка AI-данных перед записью в таблицу: JSON Schema, обязательные поля, типы, пограничные примеры и отдельные правила бизнес-смысла.

Обложка статьи: AI вернул JSON, который ломает таблицу: проверка контракта

AI вернул сумму расходов как строку «12 500 ₽», пропустил ID кампании и добавил красивое объяснение в поле статуса. JSON при этом может успешно открываться. Проблема возникает позже: импорт ожидает число, формула получает текст, а строку не с чем связать.

Контракт описывает, какой результат принимает следующий шаг. Его нужно строить от таблицы или API-получателя, а затем передавать модели. В схеме AI-агента это отдельная граница между генерацией и действием.

Пять проверок до записи

  1. Ответ завершён. Проверьте статус API и причину остановки: обрезанный ответ нельзя считать готовым.
  2. Синтаксис разбирается. Парсер должен прочитать весь ожидаемый документ, а не первый похожий фрагмент внутри пояснений.
  3. Структура соответствует схеме. Есть нужные поля, правильные типы и допустимые значения.
  4. Смысл подходит задаче. ID существует, период верный, сумма не превышает известный итог, отсутствующие данные не выдуманы.
  5. Поля правильно попадают в получателя. Пробный импорт показывает, какие колонки и строки будут изменены, до фактической записи.

В документации Claude Structured outputs перечислены исключения, включая отказ и достижение лимита токенов. В таких случаях ответ может не соответствовать схеме. Даже обычный HTTP 200 не закрывает эту проверку. Поддерживаемые возможности схемы нужно сверять с конкретным провайдером.

Компактная схема для отчётной строки

Допустим, таблица принимает ID кампании, расходы в рублях, целое число лидов, статус готовности и необязательное по смыслу примечание. Само поле примечания присутствует всегда; когда текста нет, его значение равно null. Ниже пример локального контракта JSON Schema, а не готовая конфигурация каждого AI API.

{ "type": "object", "properties": { "campaign_id": { "type": "string", "minLength": 1 }, "spend_rub": { "type": "number", "minimum": 0 }, "leads": { "type": "integer", "minimum": 0 }, "status": { "enum": [ "ready", "review" ] }, "note": { "type": [ "string", "null" ] } }, "required": [ "campaign_id", "spend_rub", "leads", "status", "note" ], "additionalProperties": false }

Сквозной условный пример: в исходном отчёте для известной кампании campaign-42 за согласованный период указаны расходы 12 500 ₽ и 25 лидов. Допустимый ответ ниже сохраняет эти значения. Он проходит схему; затем получатель отдельно сверяет ID и период с исходным отчётом.

{"campaign_id":"campaign-42","spend_rub":12500,"leads":25,"status":"ready","note":null}

Пробный импорт должен показать ровно строку campaign-42 и эти значения, без создания второй записи. Контрольная арифметика: 12 500 / 25 = 500 ₽ за лид. Это учебный CPL для проверки полей и единиц, а не результат кампании. Если получатель показывает 12,5 ₽ или 25 000 лидов, контракт генерации мог пройти, но преобразование данных сломалось.

По правилам JSON Schema перечисление в properties не делает поля обязательными: это задаёт required. Параметр additionalProperties запрещает лишние поля при значении false. Отсутствие поля и значение null — разные состояния; тип null разрешается явно.

Есть и ограничение поставщика: в текущей документации Claude Structured outputs отдельно предупреждают о возможном отличии регистра строк enum и const даже при обычном завершении. Наш локальный контракт всё равно принимает только ready и review. Если нужен нечувствительный к регистру адаптер, задайте его явно до проверки, сохраните исходное значение и не объединяйте категории, различающиеся только регистром.

Здесь все имена зафиксированы. Замена spend_rub на budget может выглядеть логично человеку, но нарушит связь с таблицей. Если нужно поддержать старое имя, преобразуйте его в отдельном входном слое и затем проверяйте единый контракт. Не разбрасывайте догадки о названиях по всем формулам.

На чём проверить контракт

Набор проверок для учебной схемы
ВходРезультат проверкиДействие
spend_rub: «12500»Строка вместо числаОтклонить либо преобразовать по заранее описанному правилу
leads: 2.5Дробь вместо целогоОтклонить; выяснить источник дробного значения
campaign_id отсутствуетНет обязательного поляНе записывать строку
note: nullРазрешённое пустое значениеПередать пустоту по правилу получателя
status: «READY»Нет точного значения в enumРазобрать несовпадение; не менять регистр молча
campaign_id: «неизвестная кампания»Схема может пройтиПроверить ID по справочнику и остановить запись

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

Если лидов ноль, CPL не следует вычислять делением на ноль или заменять нулём. Отдельное бизнес-правило должно определить состояние «не рассчитывается». Если расходов нет в источнике, ноль тоже не является безопасной догадкой: нулевое значение и неизвестность означают разное.

Проверьте не только ответ, но и получателя

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

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

Уточнение инструкции по структуре промпта помогает уменьшить число ошибок. Настройки вроде top-p не заменяют контракт. Завершённая проверка здесь — это строка, которая проходит все уровни и корректно отображается у получателя, а не просто ответ без красного сообщения парсера.

Ещё по теме