Перейти к содержанию

От свободного текста к контракту данных

Шаг 1

Во втором модуле мы создавали чат. Модель формировала обычный текст, который читал пользователь и для такого сценария свободный ответ подходит, так как одну и ту же мысль можно выразить разными словами, а человек все равно поймет ее смысл.

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

Почему свободный текст неудобен для программы?

Представим, что клиент написал:

Вчера получил заказ 8041 с кофемашиной. Устройство не включается, хотя я проверил две розетки.

Модель может вернуть такой ответ:

Клиент сообщает о технической неисправности кофемашины из заказа 8041.

И человеку тут все понятно: проблема связана с техникой, товар — кофемашина, номер заказа — 8041. Но Python не понимает смысл предложения так, как его понимаем мы, ведь код видит только строку символов и должен каким-то образом найти внутри нее нужные значения.

Конечно можно попробовать искать конкретную фразу:

if "технической неисправности" in model_text:
    category = "technical_issue"

Но проблема в том, что модель не обязана каждый раз использовать именно эти слова. И в следующий раз она может написать «техническая проблема», «устройство неисправно» или «товар не работает». При этом смысл ответа не изменится, но это условие перестанет выполняться.

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

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

Что такое контракт данных?

Контракт данных — это заранее согласованная форма результата. Он определяет, какие поля должны существовать, какие значения в них находятся и какие типы данных ожидает программа.

Например, вместо произвольного объяснения результат анализа обращения может выглядеть так:

{
  "category": "technical_issue",
  "order_id": "8041",
  "product": "coffee_machine",
  "urgency": "normal",
  "missing_fields": []
}

И с таким контрактом Python-коду не нужно искать слова внутри предложения. Он заранее знает, что категория находится в поле category, номер заказа в order_id, а недостающие сведения в списке missing_fields. Формулировки пользователя могут оставаться разными, но форма результата для программы остается одной и той же.

Похожий контракт уже есть у обычных Python-функций:

def calculate_total(price: int, quantity: int) -> int:
    ...

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

Что модель делает с сообщением?

При подготовке структурированного результата модель обычно выполняет одну или несколько задач. Классификация означает выбор варианта из заранее заданного набора. Например обращение можно отнести к technical_issue, delivery_problem, return_request или other.

То есть модель выполняет извлечение — это поиск фактов, которые уже присутствуют в сообщении. Из нашего примера можно извлечь номер заказа 8041 и название товара. При этом модель не должна придумывать серийный номер, дату покупки или другие отсутствующие сведения. А далее модель должна выполнить нормализацию, это значит что ей необходимо привести разные пользовательские формулировки к единому виду, понятному приложению. Например фразы «техническая проблема», «сломался товар» и «устройство не включается» могут привести к одной внутренней категории technical_issue.

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

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

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

Шаг 2

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

Может показаться, что достаточно написать в промпте «верни JSON», вызвать json.loads() и сразу использовать полученные данные. Но на практике результат нужно рассматривать внимательнее: правильная JSON-строка еще не означает, что внутри находятся нужные и достоверные сведения.

Три уровня проверки результата

У структурированного ответа есть три уровня корректности: синтаксический, структурный и смысловой. Они проверяются последовательно, потому что каждый следующий уровень отвечает на новый вопрос.

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

{
  'category': 'technical_issue',
  'order_id': '8041',
}

Похожая ошибка возникнет, если модель сначала напишет «Вот результат анализа», а затем добавит JSON. Весь ответ уже не будет одним JSON-документом. Можно пытаться вручную искать фигурные скобки, но так мы снова создадим ненадежный код для разбора текста.

Стандартная библиотека Python умеет проверить только этот первый уровень:

import json

payload = json.loads(model_text)

Если json.loads() отработал успешно, мы знаем лишь то, что строка записана по правилам JSON. Названия полей, типы и смысл значений эта функция не проверяет.

Структурная проверка определяет, соответствует ли объект нашему контракту. Рассмотрим синтаксически правильный JSON:

{
  "type": "поломка",
  "order": 8041,
  "priority": "обычная"
}

Разобрать его можно, но приложение ожидает поля category, order_id и urgency. Кроме того, номер заказа должен быть строкой, а категория — одним из заранее разрешенных технических значений. Для JSON-парсера объект правильный, а для нашего приложения может быть нет.

Именно здесь нужен настоящий контракт. JSON Schema — это машиночитаемое описание JSON-объекта: его полей, типов и ограничений. А структурированные ответы (Structured Outputs) — это уже механизм API, с помощью которого приложение передает поддерживаемую схему и запрашивает ответ в соответствии с ней. Синтаксис схемы и код запроса мы подробно разберем в следующем разделе.

Смысловая, или семантическая, проверка отвечает на самый важный вопрос, а верны ли сами данные? Объект может полностью соответствовать контракту и все равно содержать ошибку:

{
  "category": "technical_issue",
  "order_id": "9999",
  "urgency": "critical"
}

Все поля находятся на своих местах и имеют допустимые типы. Но пользователь называл заказ 8041, а не 9999. Критическая срочность тоже может быть необоснованной, ведь неисправная кофемашина создает проблему, но из сообщения не следует, что существует угроза жизни или безопасности.

Схема способна потребовать строку или ограничить срочность значениями low, normal и critical. Однако она не может доказать, что модель выбрала правильное значение. Поэтому после структурной проверки обычный код сопоставляет результат с исходным сообщением, данными из базы и правилами продукта.

Чем Structured Outputs отличается от JSON mode?

JSON mode и Structured Outputs — официальные названия двух режимов работы API. Оба позволяют получить корректный JSON, но JSON mode контролирует только формат строки, а Structured Outputs дополнительно требует, чтобы результат соответствовал переданной JSON Schema.

JSON mode помогает получить синтаксически корректный JSON, но не закрепляет конкретный набор полей. Модель может вернуть type вместо category или число вместо ожидаемой строки.

Structured Outputs добавляет соответствие поддерживаемой JSON Schema. Это делает форму результата предсказуемой для кода. Но и этот механизм не гарантирует истинность каждого значения, ведь модель все еще может неверно понять сообщение или извлечь то, чего в нем не было.

Поэтому структурированный ответ нельзя считать готовым бизнес-решением. Например, поле refund_requested: true может правильно передавать просьбу клиента о возврате. Но оно не означает, что возврат одобрен. Разрешение определит обычный код после проверки заказа, срока покупки и политики компании.

То есть на практике обычно реализуют следующий подход:

При этом структурированный ответ не нужно применять в каждой AI-функции. Ведь сначала следует понять, кто использует результат и требуется ли модели разбираться в естественном языке.

Если ответ читает человек, обычно достаточно свободного текста. Так работают объяснения, пересказы, черновики писем и наш чат поддержки.

Если существуют точные правила, лучше использовать обычный Python-код. Арифметика, проверка диапазона, поиск записи в базе, применение политики возврата и изменение состояния системы не требуют языковой модели. Такой код быстрее, дешевле, предсказуемее и проще тестируется.

Например, номер заказа фиксированного формата можно найти регулярным выражением:

import re

match = re.search(r"\b\d{4}\b", user_text)
order_id = match.group() if match is not None else None

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

А уже структурированный AI-результат нужен, когда вход написан свободным человеческим языком, модель должна понять его смысл, а выход затем использует программа. Например клиент может десятками способов описать задержку доставки. Модель распознает смысл этих формулировок, а контракт позволяет коду всегда получить одну категорию delivery_problem.

Как это будет работать в AI Support Desk?

А теперь давайте поговорим про наш проект, в нем проекте все три подхода будут работать вместе. Обычный код сначала проверит входные данные. Затем модель проанализирует свободное обращение и вернет структурированный результат. Python проверит значения, применит бизнес-правила и выберет дальнейший сценарий. А пользователь при необходимости получит обычный человекочитаемый ответ.

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

Давайте в следующем разделе мы превратим эту общую идею в рабочий контракт, где изучим JSON Schema, обязательные и необязательные поля, допустимые значения, вложенные объекты и ограничения, которые поддерживает API.

Шаг 3

Почему поиск фиксированной фразы в свободном ответе модели ненадёжен?

Правильный ответ
  • ✅ Модель может выразить тот же смысл другими словами

Шаг 4

Что в первую очередь определяет контракт данных для AI-функции?

Правильный ответ
  • ✅ Ожидаемые поля, их типы и допустимые значения

Шаг 5

Выберите все корректные соответствия между уровнем проверки и вопросом, на который он отвечает.

Правильный ответ
  • ✅ Смысловой: согласуются ли значения с исходным сообщением и правилами продукта?
  • ✅ Структурный: есть ли нужные поля и допустимые типы?
  • ✅ Синтаксический: можно ли разобрать строку как JSON?

Шаг 6

Для какой задачи структурированный AI-ответ действительно уместен?

Правильный ответ
  • ✅ Определить по свободному обращению категорию и вернуть её в фиксированном поле

Шаг 7

Преобразование JSON-объекта в словарь

Ответ модели приходит в программу как текст. Даже если строка является корректным JSON, на верхнем уровне в ней может находиться массив, число или другое значение. В этой задаче разрешён только JSON-объект, который после разбора представлен Python-словарём.

Задача

Реализуйте функцию parse_json_object(model_text). Она должна проверить тип входного значения, разобрать JSON и вернуть словарь.

Требования

  • Не изменяйте имя функции. Она принимает один аргумент model_text.
  • Если model_text не является строкой, выбросьте TypeError.
  • Передайте строку в json.loads().
  • Если строка содержит некорректный JSON, функция должна выбросить ValueError.
  • После разбора проверьте тип результата. Допускается только dict.
  • Если JSON содержит на верхнем уровне список, строку, число, логическое значение или null, выбросьте ValueError.
  • Для JSON-объекта верните полученный словарь без изменения ключей и значений.

Пример результата

result = parse_json_object(
    '{"category": "delivery_problem", "order_id": "8041"}'
)
print(result)
print(type(result).__name__)

Результат:

{'category': 'delivery_problem', 'order_id': '8041'}
dict

Строка '[1, 2]' является корректным JSON, но должна привести к ValueError, потому что верхний уровень — массив.

Импорт json и заготовка функции уже находятся в редакторе.

Правильный ответ
import json

def parse_json_object(model_text: str) -> dict:
    if not isinstance(model_text, str):
        raise TypeError

    try:
        data = json.loads(model_text)
    except json.JSONDecodeError:
        raise ValueError

    if not isinstance(data, dict):
        raise ValueError

    return data

Шаг 8

Поиск нарушений в словаре ответа

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

Задача

Реализуйте функцию find_contract_issues(payload). Она должна проверить словарь и вернуть список строк с найденными проблемами. Если проблем нет, верните пустой список.

Требования

  • Не изменяйте имя функции. Она принимает один аргумент payload.
  • Если payload не является словарём, выбросьте TypeError.
  • Проверяемый контракт содержит три обязательных поля: category, order_id и urgency.
  • Сначала проверьте наличие полей в порядке category, order_id, urgency.
  • Для каждого отсутствующего поля добавьте строку "missing:имя_поля".
  • После проверки наличия проверьте только те поля, которые присутствуют в словаре.
  • Добавьте "invalid:category", если значение category не является строкой.
  • Добавьте "invalid:order_id", если значение order_id не является строкой и не равно None.
  • Добавьте "invalid:urgency", если значение не равно одной из строк: "low", "normal", "high".
  • Сообщения о некорректных значениях также добавляйте в порядке category, order_id, urgency.
  • Не изменяйте исходный словарь payload.

Пример результата

print(find_contract_issues({
    "category": 7,
    "order_id": 8041,
    "urgency": "critical",
}))

Результат:

['invalid:category', 'invalid:order_id', 'invalid:urgency']

Для словаря {"category": "other", "order_id": None} результатом должен быть список ['missing:urgency'].

Заготовка функции уже находится в редакторе. Дополнительные импорты не нужны.

Правильный ответ
def find_contract_issues(payload: dict) -> list[str]:
    if not isinstance(payload, dict):
        raise TypeError

    issues = []
    fields = ("category", "order_id", "urgency")

    for field in fields:
        if field not in payload:
            issues.append(f"missing:{field}")

    if "category" in payload and not isinstance(payload["category"], str):
        issues.append("invalid:category")

    if "order_id" in payload:
        order_id = payload["order_id"]
        if order_id is not None and not isinstance(order_id, str):
            issues.append("invalid:order_id")

    if "urgency" in payload and payload["urgency"] not in {"low", "normal", "high"}:
        issues.append("invalid:urgency")

    return issues

Шаг 9

Проверка номера заказа по исходному сообщению

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

Задача

Реализуйте функцию order_id_is_grounded(order_id, user_text). Она должна вернуть логическое значение, показывающее, подтверждается ли номер заказа пользовательским текстом.

Требования

  • Не изменяйте имя функции и её аргументы.
  • order_id должен быть строкой или None. Для другого типа выбросьте TypeError.
  • user_text должен быть строкой. Для другого типа выбросьте TypeError.
  • Если order_id is None, верните True: отсутствующий номер не требуется искать в тексте.
  • Найдите в user_text все непрерывные группы цифр. Для этого используйте уже импортированный модуль re.
  • Сравнивайте order_id с каждой найденной группой целиком.
  • Верните True, если найдено полное совпадение, иначе верните False.
  • Частичное совпадение не считается подтверждением: номер "804" не совпадает с группой "8041".

Пример результата

print(order_id_is_grounded("8041", "Где заказ 8041?"))
print(order_id_is_grounded("804", "Где заказ 8041?"))
print(order_id_is_grounded(None, "Номер не указан"))

Результат:

True
False
True

Импорт re и заготовка функции уже находятся в редакторе.

Правильный ответ
import re

def order_id_is_grounded(order_id: str | None, user_text: str) -> bool:
    if order_id is not None and not isinstance(order_id, str):
        raise TypeError

    if not isinstance(user_text, str):
        raise TypeError

    if order_id is None:
        return True

    numbers = re.findall(r"\d+", user_text)

    return order_id in numbers