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

Pydantic и валидация AI-ответов

Шаг 1

В предыдущем разделе мы научились получать от модели JSON с постоянным набором полей. После json.loads() он превращался в обычный словарь. Данные уже имеют структуру, но приложение пока не знает, какие ключи обязательны, какие значения допустимы и что считать ошибкой.

Что такое Pydantic?

Pydantic — это библиотека Python для описания структуры данных, их проверки и преобразования с помощью аннотаций типов. Разработчик создаёт класс, наследует его от BaseModel и объявляет поля почти так же, как обычные атрибуты Python.

Основная задача Pydantic — безопасно принимать данные из внешних источников и превращать их в удобные для работы Python-объекты. Это могут быть HTTP-запросы, JSON-файлы, переменные окружения, записи из базы данных, сообщения из очередей или ответы LLM. Вместо работы с обычными словарями приложение получает объект с известной структурой и гарантированными типами полей.

При создании модели Pydantic автоматически проверяет входные данные, приводит их к нужным типам, если это возможно, и сообщает обо всех найденных ошибках через ValidationError. Например, строка "42" может быть преобразована в число 42, а при отсутствии обязательного поля или неверном типе будет выброшено понятное исключение.

Благодаря этому код становится более надёжным и понятным. Разработчику не приходится вручную проверять каждый параметр, а описание модели одновременно служит и документацией структуры данных. Именно поэтому Pydantic стал стандартом де-факто во многих современных Python-проектах, включая FastAPI.

В первом модуле мы установили pydantic-settings для чтения и проверки настроек приложения. Этот пакет использует Pydantic и автоматически устанавливает его как свою зависимость. Поэтому в нашем проекте ничего дополнительно устанавливать не нужно.

Если вы создадите новый проект, в котором еще нет pydantic-settings или другой зависимости с Pydantic, активируйте его виртуальное окружение и выполните:

pip install pydantic

В корне проекта, рядом с main.py, создайте pydantic_demo.py. Этот файл не является частью основного приложения и не обращается к LLM. Мы будем запускать его вручную после каждого изменения, чтобы проверить работу Pydantic.

Добавьте в pydantic_demo.py:

from pydantic import BaseModel, ConfigDict, Field, ValidationError

class SupportTicket(BaseModel):
    model_config = ConfigDict(extra="forbid")

    ticket_id: int
    title: str = Field(min_length=3, max_length=100)
    priority: int = Field(ge=1, le=5)
    comment: str | None = None

print("1. Правильные данные")

ticket = SupportTicket(
    ticket_id="42",
    title="Не включается монитор",
    priority=3,
)

print(ticket)
print(ticket.ticket_id)
print(type(ticket.ticket_id))
print(ticket.comment)

Обратите внимание, что класс SupportTicket наследуется от BaseModel. Именно это превращает обычный Python-класс в Pydantic-модель.

Каждое поле модели описывается с помощью аннотации типа:

ticket_id: int
title: str
priority: int

Аннотация сообщает Pydantic, какое значение ожидается в каждом поле. ticket_id должен содержать целое число, title — строку, а priority — целое число.

Если значение можно привести к нужному типу, Pydantic попробует сделать это автоматически. Например, строка "42" может быть преобразована в число 42. Если преобразование невозможно, модель не будет создана и возникнет ValidationError.

Функция Field() позволяет добавить к полю дополнительные ограничения. Например:

title: str = Field(min_length=3, max_length=100)

Здесь min_length=3 означает, что строка должна содержать не менее трёх символов, а max_length=100 ограничивает её максимальную длину.

Для числовых полей можно задавать допустимый диапазон:

priority: int = Field(ge=1, le=5)

Параметр ge=1 означает «больше или равно 1», а le=5 — «меньше или равно 5». Поэтому значения 1, 3 и 5пройдут проверку, а 0 или 6 приведут к ошибке валидации.

А настройка:

model_config = ConfigDict(extra="forbid")

запрещает передавать поля, которые не описаны в модели. Например, если вместо priority случайно написать prioroty, Pydantic не проигнорирует опечатку, а сразу сообщит об ошибке.

Это особенно полезно при работе с данными из внешних источников. Если формат HTTP-запроса, файла или ответа LLM неожиданно изменится, приложение узнает об этом сразу, а не продолжит работать с неполными или неправильными данными.

Какими бывают типы полей?

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

ticket_id: int
title: str
comment: str | None

Pydantic поддерживает обычные типы Python, коллекции, типы из стандартной библиотеки, перечисления и другие Pydantic-модели.

Тип поля Что хранит Пример значения
str Текст "кофемашина"
int Целое число 42
float Число с дробной частью 4.5
bool Логическое значение True или False
str | None Строку или отсутствие значения "8041" или None
list[str] Упорядоченный список строк ["order_id", "product"]
set[str] Набор уникальных строк {"urgent", "delivery"}
dict[str, str] Словарь со строковыми ключами и значениями {"source": "email"}
date и datetime Дату и дату со временем 2026-08-05
UUID Универсальный идентификатор "550e8400-e29b-41d4-a716-446655440000"
Decimal Точное десятичное число Денежную сумму
StrEnum или Literal Один вариант из ограниченного набора "low", "normal" или "high"
Другая модель Вложенный объект Товар с собственными полями

Коллекции тоже описывают тип своих элементов. list[str] означает не просто список, а список, в котором каждое значение должно быть строкой. dict[str, int] требует строковые ключи и целочисленные значения.

Типы из стандартной библиотеки позволяют не хранить все как текст. Например строку с корректной датой Pydantic может превратить в datetime, а строку с UUID — в объект UUID. Но при этом, если значение не соответствует объявленному типу, модель не создастся. Например, "завтра" нельзя преобразовать в datetime, а "abc" — в UUID.

Можно объединять несколько допустимых типов через символ |. Уже знакомая запись str | None разрешает строку или None. Объединения из большого количества несвязанных типов усложняют контракт, поэтому их стоит использовать только при реальной необходимости.

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

Ограниченный набор строк удобно описывать через StrEnum или Literal. В следующем шаге мы изучим это и создадим перечисления категорий и срочности для SupportRequestAnalysis.

Давайте запустим файл pydantic_demo.py и посмотрим на результат работы:

1. Правильные данные
ticket_id=42 title='Не включается монитор' priority=3 comment=None
42
<class 'int'>
None

Мы передали ticket_id="42" как строку, но получили число 42, а значит, как мы и говорили выше, Pydantic выполнил преобразование. Поле comment не передано, поэтому используется значение по умолчанию None.

Теперь мы можем обращаться к данным через атрибуты:

print(ticket.title)
print(ticket.priority)

Добавьте эти строки в самый конец файла и запустите его повторно. Еще одно из преимуществ в том, что сама IDE подсказывает имена атрибутов, потому что знает класс SupportTicket.

Так же обязательно нужно различать обязательность поля, возможность передать None и значение по умолчанию:

Объявление Нужно передать? Разрешен None?
name: str Да Нет
name: str | None Да Да
name: str | None = None Нет Да
name: str = "unknown" Нет Нет

str | None разрешает два значения, но не делает поле необязательным. Если нет = None, ключ все равно нужно передать.

Это пригодится в структурированных ответах, ведь ключ order_id должен присутствовать в каждом результате, но если номер неизвестен, модель вернет null, который Pydantic преобразует в None.

Теперь давайте попробуем добавить ошибочные данные, для этого добавьте в конец pydantic_demo.py:

print("\n2. Ошибочные данные")

try:
    SupportTicket(
        ticket_id="не число",
        title="Я",
        priority=10,
        unknown_field="лишнее значение",
    )
except ValidationError as error:
    print(error)

Запустите файл. Pydantic сообщит сразу о нескольких проблемах:

  • ticket_id нельзя преобразовать в число;
  • title короче трех символов;
  • priority больше пяти;
  • unknown_field запрещен.

Чтобы получить отдельные сведения о каждой проблеме, оставьте строку print("\n2. Ошибочные данные"), а предыдущий блок try/except замените:

try:
    SupportTicket(
        ticket_id="не число",
        title="Я",
        priority=10,
        unknown_field="лишнее значение",
    )
except ValidationError as error:
    for issue in error.errors(include_url=False):
        print(
            f"{issue['loc']}: "
            f"{issue['msg']} ({issue['type']})"
        )

loc содержит путь к полю, msg — описание, а type — технический тип ошибки. В реальном приложении эти данные можно отправлять в логи и метрики.

Давайте теперь проверим строгий режим. Обычно Pydantic выполняет разумные преобразования, но иногда нужно требовать точный тип. Добавьте ниже:

class StrictTicket(BaseModel):
    ticket_id: int = Field(strict=True)

print("\n3. Строгий режим")

try:
    StrictTicket(ticket_id="42")
except ValidationError as error:
    print(error)

Строка "42" больше не принимается. Если заменить ее на число 42, объект создастся.

Строгий режим можно включить для всей модели через ConfigDict(strict=True). Не нужно делать это автоматически: иногда преобразование строки в число является ожидаемой частью разбора внешних данных.

А теперь разберем другие способы создания объекта. Добавьте ниже:

print("\n4. model_validate()")

ticket_from_dict = SupportTicket.model_validate(
    {
        "ticket_id": 43,
        "title": "Повреждена упаковка",
        "priority": 2,
    }
)

print(ticket_from_dict)

print("\n5. model_validate_json()")

ticket_from_json = SupportTicket.model_validate_json(
    """
    {
      "ticket_id": 44,
      "title": "Не работает кофемашина",
      "priority": 4,
      "comment": "Устройство не включается"
    }
    """
)

print(ticket_from_json)

Конструктор удобен для значений, уже находящихся в Python-коде. model_validate() обычно получает словарь. model_validate_json() принимает JSON-строку, сам разбирает ее и сразу проверяет результат. В нашем проекте AI Support Desk особенно полезен model_validate_json(), потому что LLMResult.text содержит именно строку.

Теперь попробуем преобразовать эти объекты обратно, для этого добавьте:

print("\n6. Сериализация")

print(ticket.model_dump())
print(ticket.model_dump_json(indent=2))

model_dump() возвращает Python-словарь, а model_dump_json() — JSON-строку. Прикладной код обычно работает с атрибутами объекта, а сериализация нужна на внешней границе, например при сохранении или возврате ответа.

Теперь попробуем получить JSON Schema, ниже добавьте:

print("\n7. JSON Schema")

schema = SupportTicket.model_json_schema()
print(schema)

В схему попадут поля, их типы, обязательность и ограничения Field(). Позже именно эту возможность используем для наших структурированных ответов.

Продолжаем дополнять тот же файл. Добавьте в конец:

class ProductInfo(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    serial_number: str | None

class ProductRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    order_id: str
    product: ProductInfo

print("\n8. Вложенная модель")

request = ProductRequest.model_validate(
    {
        "order_id": "8041",
        "product": {
            "name": "кофемашина",
            "serial_number": None,
        },
    }
)

print(request)
print(request.product.name)

Pydantic самостоятельно превратит внутренний словарь в объект ProductInfo. При ошибке путь в ValidationError сможет указать конкретное вложенное поле.

Давайте подведем итоги данного шага. В нем мы проверили создание объекта, преобразование типов, обязательные поля, ValidationError, строгий режим, разные способы разбора данных, сериализацию, JSON Schema и вложенность.

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

Шаг 2

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

Теперь продолжим работать в том же файле и познакомимся с валидаторами.

Что такое собственный валидатор?

В прошлом шаге мы использовали готовые возможности Pydantic и задавали простые правила проверки через Field(). Например, можно ограничить диапазон чисел (ge=1, le=5), длину строки (min_length, max_length), указать регулярное выражение и другие стандартные ограничения.

Однако на практике часто встречаются правила, которые нельзя выразить только параметрами Field(). Например, проверить, что название начинается с определённого префикса, e-mail принадлежит нужному домену, пароль содержит хотя бы одну цифру и заглавную букву или что дата окончания больше даты начала. В таких случаях используются собственные валидаторы.

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

Если значение не соответствует требованиям, внутри валидатора обычно выбрасывается ValueError. Но Pydantic перехватывает это исключение, добавляет информацию о том, в каком поле произошла ошибка, и формирует общий объект ValidationError. Благодаря этому все ошибки валидации обрабатываются одинаково, независимо от того, возникли они из-за встроенных ограничений Field() или из-за пользовательского валидатора.

Давайте очистим наш файл pydantic_demo.py и в начало этого файла добавьте импорт Any и Self:

from typing import Any, Self

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    ValidatorFunctionWrapHandler,
    field_validator,
    model_validator,
)

И давайте начнем с проверки готового значения, после импортов добавьте:

class CustomerMessage(BaseModel):
    text: str

    @field_validator("text")
    @classmethod
    def validate_text(cls, value: str) -> str:
        cleaned = value.strip()
        if len(cleaned) < 3:
            raise ValueError(
                "Сообщение должно содержать минимум три символа."
            )
        return cleaned

print("\n1. Проверка готового поля")

message = CustomerMessage(
    text="  Не работает экран  "
)

print(message)
print(repr(message.text))

@field_validator("text") связывает метод с полем text. Сначала Pydantic проверяет, что значение является строкой, а затем передает его в validate_text(). А уже этот метод удаляет пробелы, проверяет длину очищенной строки и возвращает ее. После запуска repr(message.text) показывает, что пробелов по краям больше нет. При этом обратите внимание, в валидаторе поля параметр mode не указан, а значит по умолчанию используется "after", то есть проверка после стандартной обработки Pydantic.

Теперь добавьте ошибочный пример:

try:
    CustomerMessage(text="  Я  ")
except ValidationError as error:
    print(error)

Исходная строка содержит пять символов вместе с пробелами. Но валидатор сначала очищает ее и обнаруживает, что текст слишком короткий.

Иногда значение сначала нужно привести к удобной форме, а уже потом проверять. Добавьте ниже:

class Tags(BaseModel):
    values: list[str]

    @field_validator("values", mode="before")
    @classmethod
    def prepare_tags(cls, value: Any) -> Any:
        if isinstance(value, str):
            return [
                item.strip()
                for item in value.split(",")
                if item.strip()
            ]
        return value

print("\n2. Подготовка поля")

tags = Tags(values="hardware, monitor")
print(tags)
print(tags.values)

Тут параметр mode="before" означает, что функция-валидатор запускается до стандартного разбора. А на вход может прийти объект любого типа, поэтому параметр имеет тип Any. Далее строка разделяется по запятой и превращается в список. После возврата Pydantic продолжает работу и проверяет, что результат соответствует list[str].

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

Давайте разберем все значения параметра mode:

Что делает проверка Значение mode
Работает после стандартной обработки "after"
Подготавливает данные до стандартной обработки "before"
Полностью заменяет стандартную проверку "plain"
Выполняется вокруг стандартной обработки "wrap"

Первые два варианта встречаются чаще всего, а остальные два нужны для специальных случаев. Давайте рассмотрим им:

class LegacyRecord(BaseModel):
    code: int

    @field_validator("code", mode="plain")
    @classmethod
    def parse_code(cls, value: Any) -> int:
        if isinstance(value, int):
            return value
        if isinstance(value, str) and value.isdigit():
            return int(value)
        raise ValueError("Код должен быть целым числом.")

print("\n3. Полная проверка поля")

record = LegacyRecord(code="42")
print(record)
print(type(record.code))

После функции Pydantic больше не сверяет результат с code: int. Поэтому parse_code() сама рассматривает допустимые варианты и гарантирует возврат числа. Правда такой механизм используется редко.

Далее добавьте:

class ShortMessage(BaseModel):
    text: str = Field(max_length=20)

    @field_validator("text", mode="wrap")
    @classmethod
    def validate_text(
        cls,
        value: Any,
        handler: ValidatorFunctionWrapHandler,
    ) -> str:
        try:
            return handler(value)
        except ValidationError as error:
            first_error = error.errors()[0]
            if first_error["type"] == "string_too_long":
                raise ValueError(
                    "Сообщение длиннее 20 символов."
                ) from error
            raise

print("\n4. Проверка вокруг поля")

try:
    ShortMessage(
        text="Это сообщение намного длиннее двадцати символов."
    )
except ValidationError as error:
    print(error)

Здесь используется валидатор в режиме wrap. Такой валидатор как бы оборачивает стандартную проверку Pydantic и получает возможность выполнить код до неё, после неё или перехватить возникшую ошибку. Функция принимает три параметра: cls — класс модели, value — исходное значение поля, а handler — функция, которая запускает дальнейшую стандартную проверку Pydantic.

Строка return handler(value) передаёт значение во встроенный механизм валидации. В нашем случае Pydantic проверяет, что text является строкой и что её длина не превышает 20 символов.

Проверка нескольких полей вместе

Отдельные поля могут соответствовать типам и при этом противоречить друг другу. Добавьте:

class OrderState(BaseModel):
    order_id: str | None
    order_id_is_missing: bool

    @model_validator(mode="after")
    def check_order_id(self) -> Self:
        if self.order_id is None and not self.order_id_is_missing:
            raise ValueError(
                "Отсутствующий номер должен быть отмечен."
            )

        if self.order_id is not None and self.order_id_is_missing:
            raise ValueError(
                "Заполненный номер не может считаться отсутствующим."
            )

        return self

print("\n5. Проверка всей модели")

try:
    OrderState(
        order_id="8041",
        order_id_is_missing=True,
    )
except ValidationError as error:
    print(error)

Тут уже @model_validator() относится ко всей модели. Значение mode="after" означает, что все поля уже проверены, а self является готовым объектом. То есть метод сравнивает два значения и обязан вернуть self. Если заменить order_id_is_missing=True на False, объект создастся успешно.

По аналогии можно проверить сырые данных всей модели:

class PublicPayload(BaseModel):
    text: str

    @model_validator(mode="before")
    @classmethod
    def reject_secret(cls, data: Any) -> Any:
        if isinstance(data, dict) and "api_key" in data:
            raise ValueError(
                "Поле api_key запрещено."
            )
        return data

print("\n6. Проверка до создания модели")

try:
    PublicPayload(
        text="Обычный текст",
        api_key="secret",
    )
except ValidationError as error:
    print(error)

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

И перед тем, как переходить к нашему проекту, давайте поговорим о самом важном, как выбрать подходящую проверку? Обычно разработчик двигается от самого простого механизма:

  1. Сначала выберите точный тип или вложенную модель.
  2. Используйте Field(), если правило выражается длиной или диапазоном.
  3. Для одного готового значения добавьте проверку после стандартной обработки.
  4. Для подготовки сырого значения — проверку до стандартной обработки.
  5. Для связи нескольких полей — проверку всей модели после ее создания.
  6. Полную замену и оборачивание оставьте для редких случаев.
  7. Если нужны база данных, исходный запрос или внешний сервис, вынесите правило из Pydantic-модели.

Переходим к нашему проекту

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

Создайте пакет app/models, пустой __init__.py и файл app/models/support_request.py:

from enum import StrEnum

from pydantic import BaseModel, ConfigDict, Field, field_validator

class SupportCategory(StrEnum):
    TECHNICAL_ISSUE = "technical_issue"
    DELIVERY_PROBLEM = "delivery_problem"
    RETURN_REQUEST = "return_request"
    PAYMENT_PROBLEM = "payment_problem"
    OTHER = "other"

class Urgency(StrEnum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"

class MissingField(StrEnum):
    ORDER_ID = "order_id"
    PRODUCT = "product"
    PROBLEM_DESCRIPTION = "problem_description"

class SupportRequestAnalysis(BaseModel):
    model_config = ConfigDict(extra="forbid")

    category: SupportCategory
    order_id: str | None = Field(description="Номер заказа или null, если он не указан.")
    product: str | None = Field(description="Название товара или null, если оно не указано.")
    urgency: Urgency
    missing_fields: list[MissingField] = Field(
        description="Сведения, которых не хватает для обработки обращения.")

    @field_validator("order_id")
    @classmethod
    def validate_order_id(cls, value: str | None) -> str | None:
        if value is None:
            return None

        cleaned = value.strip()
        if not cleaned:
            raise ValueError(
                "Номер заказа не должен быть пустой строкой."
            )
        if not cleaned.isdigit():
            raise ValueError(
                "Номер заказа должен содержать только цифры."
            )
        return cleaned

    @field_validator("missing_fields")
    @classmethod
    def validate_missing_fields(
            cls,
            value: list[MissingField],
    ) -> list[MissingField]:
        if len(value) != len(set(value)):
            raise ValueError(
                "Список missing_fields не должен содержать повторы."
            )
        return value

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

Так как Pydantic умеет генерировать JSON Schema, удалим написанную нами схему и добавим ее генерацию. Для этого замените содержимое app/schemas/support_request.py:

from app.models.support_request import SupportRequestAnalysis

SUPPORT_REQUEST_SCHEMA_NAME = "support_request"
SUPPORT_REQUEST_SCHEMA = SupportRequestAnalysis.model_json_schema()

Теперь Pydantic-модель является единым источником контракта и из нее строится JSON Schema для провайдера, и она же будет проверять полученный ответ.

Продолжим в следующем шаге, где применим нашу модель к настоящему ответу LLM.

Шаг 3

В предыдущем шаге мы описали результат анализа с помощью SupportRequestAnalysis и начали строить JSON Schema из этой модели. Однако наш демонстрационный файл structured_demo.py пока заканчивает обработку вызовом json.loads().

А json.loads() отвечает только на один вопрос, является ли строка корректным JSON. Он не проверяет, что category входит в разрешенное перечисление, order_id содержит цифры, а missing_fields не имеет повторов. Более того, после разбора мы по-прежнему получаем обычный словарь.

В этом шаге мы заменим общий разбор JSON на валидацию Pydantic. И успешным результатом станет объект SupportRequestAnalysis, а отказ модели, незавершенная генерация и ошибка данных будут обрабатываться как разные ситуации.

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

Кроме того, некоторые проверки существуют только в Python-коде валидаторы полей и не обязаны попадать в JSON Schema. Поэтому ответ нужно проверять еще раз на границе приложения.

Такая повторная проверка не является бесполезным дублированием, и получается что JSON Schema управляет формой генерации, а Pydantic решает, можно ли безопасно впустить полученные данные в Python-приложение.

Начнем с разделения возможных ошибок. Для этого откройте structured_demo.py и к импортам из приложения добавьте модель:

from app.models.support_request import SupportRequestAnalysis

После DEVELOPER_INSTRUCTION добавьте классы ошибок:

class StructuredResultError(RuntimeError):
    pass

class ModelRefusalError(StructuredResultError):
    pass

class IncompleteStructuredResultError(StructuredResultError):
    pass

class StructuredResultValidationError(StructuredResultError):
    pass

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

  • ModelRefusalError означает, что провайдер ответил, но модель отказалась выполнять запрос. IncompleteStructuredResultError обозначает оборванную генерацию из-за ограничения длины. StructuredResultValidationError говорит, что содержимое ответа не прошло проверку Pydantic. Базовый StructuredResultError пригодится для остальных неожиданных исходов структурированной генерации.

Почему не оставить одно сообщение «не удалось получить ответ»? Эти ситуации требуют разных действий, ведь при отказе можно показать пользователю безопасное объяснение. При length разработчику нужно проверить лимит выходных токенов. А ошибка валидации указывает на нарушение контракта или проблему совместимости. Если объединить их в одну ошибку, приложение потеряет эту информацию.

Теперь создадим функцию разбора результата и после build_messages() добавьте новую функцию:

def parse_result(
    result: LLMResult,
) -> SupportRequestAnalysis:
    if result.refusal is not None:
        raise ModelRefusalError(result.refusal)

    if result.finish_reason == "length":
        raise IncompleteStructuredResultError(
            "Модели не хватило разрешенного количества токенов."
        )

    if result.finish_reason != "stop":
        raise StructuredResultError(
            "Неожиданная причина завершения генерации: "
            f"{result.finish_reason}."
        )

    if result.text is None:
        raise StructuredResultValidationError(
            "Модель не вернула структурированный результат."
        )

    try:
        return SupportRequestAnalysis.model_validate_json(result.text)
    except ValidationError as error:
        raise StructuredResultValidationError(
            "Ответ модели не соответствует контракту."
        ) from error

Данная функция получает LLMResult, который уже вернул наш универсальный LLM-клиент. Она не выполняет сетевой запрос и ничего не знает о настройках ProxyAPI. Ее задача только превратить технический результат интеграции в проверенный объект приложения. И порядок проверок здесь важен. Сначала рассматривается refusal, потому что отказ модели является отдельным допустимым исходом протокола. Затем функция отклоняет ответ, оборванный по length, и другие неожиданные причины завершения.

Только при finish_reason="stop" мы переходим к полю text. Если текста нет, проверять нечего, поэтому создается StructuredResultValidationError.

А далее мы выполняем SupportRequestAnalysis.model_validate_json(), который принимает JSON-строку и выполняет сразу два шага. Сначала Pydantic разбирает JSON, затем проверяет полученные значения по модели.

При успешной проверке возвращается не dict, а полноценный экземпляр класса SupportRequestAnalysis:

analysis.category
analysis.order_id
analysis.product
analysis.urgency
analysis.missing_fields

Если JSON поврежден, отсутствует обязательное поле, появилось неизвестное поле или сработал наш валидатор поля, Pydantic выбросит ValidationError. Мы перехватываем его и создаем прикладную ошибку StructuredResultValidationError.

Конструкция raise ... from error сохраняет исходную ошибку как причину новой. Пользователю можно показать короткое и понятное сообщение, а разработчик при отладке сможет добраться до подробностей Pydantic.

Теперь давайте изменим вывод результата. Старая функция print_result() сама проверяла исход генерации и вызывала json.loads(). Теперь эта работа выполняется в parse_result(). Поэтому замените функцию:

def print_result(
    analysis: SupportRequestAnalysis,
    result: LLMResult,
) -> None:
    print("\nПроверенный результат:")
    print(
        json.dumps(
            analysis.model_dump(mode="json"),
            ensure_ascii=False,
            indent=2,
        )
    )
    print(f"\nМодель: {result.model}")
    print(f"Причина завершения: {result.finish_reason}")
    print(f"Всего токенов: {result.total_tokens}")
    print(f"ID ответа: {result.response_id}")

model_dump(mode="json") превращает Pydantic-модель в словарь, подготовленный для JSON. В частности, элементы StrEnum становятся своими строковыми значениями. После этого json.dumps() нужен только для читаемого вывода с отступами и русскими символами.

Внутри приложения необязательно постоянно превращать модель обратно в словарь. Прикладной код сможет работать с analysis.order_id и другими атрибутами напрямую. А уже сериализация понадобится только на внешней границе, например при выводе в консоль, сохранении или возврате HTTP-ответа.

И в заключении данного шага, давайте подключим данный разбор к самому сценарию. Для этого измените блок сетевого вызова в main(). Сначала LLMClient получает технический результат, а следующей строкой parse_result() превращает его в доменный объект:

    llm_client = LLMClient(settings)
    try:
        result = llm_client.generate_structured(
            messages=build_messages(user_text),
            schema_name=SUPPORT_REQUEST_SCHEMA_NAME,
            schema=SUPPORT_REQUEST_SCHEMA,
        )
        analysis = parse_result(result)
    except LLMClientError as error:
        print(f"Не удалось обратиться к модели: {error}")
        return
    except ModelRefusalError as error:
        print(f"Модель отказалась анализировать обращение: {error}")
        return
    except IncompleteStructuredResultError as error:
        print(f"Получен незавершенный ответ: {error}")
        return
    except StructuredResultValidationError as error:
        print(f"Ответ не прошел проверку: {error}")
        return
    except StructuredResultError as error:
        print(f"Не удалось обработать результат: {error}")
        return
    finally:
        llm_client.close()

    print_result(analysis, result)

Первый except относится к интеграции с провайдером: сети, ключу, балансу и HTTP-ответам. Следующие обработчики относятся уже к содержательному результату генерации.

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

Блок finally закрывает HTTP-клиент независимо от того, завершился код успехом или вышел через return из одного из обработчиков. Если ошибок нет, после finally выполняется print_result(analysis, result).

То есть данную логику можно отобразить на схеме:

Запускаем проект

Теперь все части сценария соединены. Приложение отправляет обращение модели вместе с JSON Schema, проверяет общий результат генерации и преобразует полученный JSON в объект SupportRequestAnalysis.

Запустите structured_demo.py и введите обращение, содержащее все основные сведения:

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

Программа должна вывести приблизительно такой результат:

Проверенный результат:
{
  "category": "technical_issue",
  "order_id": "8041",
  "product": "кофемашина",
  "urgency": "normal",
  "missing_fields": []
}

Модель: gpt-4.1-2025-04-14
Причина завершения: stop
Всего токенов: 676
ID ответа: chatcmpl-E9pLY2K5Z5kcNOmmvjWfWtxNEDpXl

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

Главное нам сейчас, это проверить постоянную форму результата. В нем всегда должны присутствовать поля category, order_id, product, urgency и missing_fields. Категория и срочность должны содержать только значения из соответствующих перечислений, а недостающие сведения находиться в списке.

Если программа дошла до вывода Проверенный результат, значит успешно прошла вся цепочка:

Сначала ProxyAPI принял схему и вернул структурированный ответ. Затем parse_result() убедилась, что модель не отказалась от запроса и завершила генерацию с причиной stop. После этого Pydantic разобрал JSON и создал типизированный объект.

Запустите structured_demo.py еще раз и отправьте менее полное обращение:

Привезли монитор с треснутой подставкой. Хочу заменить товар.

В этом тексте есть товар и описание проблемы, но нет номера заказа. Поэтому ожидаем приблизительно такой результат:

Проверенный результат:
{
  "category": "return_request",
  "order_id": null,
  "product": "монитор",
  "urgency": "normal",
  "missing_fields": [
    "order_id"
  ]
}

Модель: gpt-4.1-2025-04-14
Причина завершения: stop
Всего токенов: 667
ID ответа: chatcmpl-E9pNt0J0qTTR4nSTHqe85DkEkKkcM

Категория может зависеть от того, как модель поняла намерение клиента. Например, поврежденный товар можно отнести к проблеме доставки или запросу на возврат. Но order_id должен быть равен null, потому что клиент не указал номер заказа.

Этот запуск показывает разницу между отсутствующим полем и ошибочной структурой. Поле order_id не исчезает из JSON. Оно остается частью контракта, но получает значение null, а его техническое имя добавляется в missing_fields.

Какая проблема еще остается?

Теперь приложение надежно проверяет форму ответа, но пока не проверяет все связи между полученными значениями.

Представим ситуацию, когда клиент написал:

Кофемашина из заказа 8041 не включается.

При этом модель вернула:

{
  "category": "technical_issue",
  "order_id": "9999",
  "product": "кофемашина",
  "urgency": "normal",
  "missing_fields": []
}

Этот объект успешно пройдет Pydantic-валидацию. Поле order_id содержит строку из цифр, категория и срочность входят в разрешенные перечисления, а все обязательные поля находятся на своих местах.

Однако номер 9999 отсутствует в обращении клиента. Модель вернула правильную структуру, но неправильное значение.

Возможна и другая ситуация:

{
  "category": "technical_issue",
  "order_id": null,
  "product": "кофемашина",
  "urgency": "normal",
  "missing_fields": []
}

Здесь order_id равен null, но не отмечен как недостающий. Каждое поле по отдельности соответствует своему типу, однако вместе они образуют противоречивый результат.

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

Шаг 4

В прошлом шаге мы получили JSON от модели и превратили его в SupportRequestAnalysis. Теперь Python знает, что category содержит разрешенную категорию, order_id является строкой или None, а missing_fields — списком допустимых значений.

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

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

Кофемашина из заказа 8041 не включается.

А модель вернула:

{
  "category": "technical_issue",
  "order_id": "9999",
  "product": "кофемашина",
  "urgency": "normal",
  "missing_fields": []
}

Этот JSON правильно записан и полностью соответствует Pydantic-модели. Номер заказа является строкой и состоит из цифр. Тем не менее клиент указал 8041, а модель вернула 9999. Структура правильная, но сами данные ошибочны.

Возможно и другое противоречие:

{
  "category": "technical_issue",
  "order_id": null,
  "product": "кофемашина",
  "urgency": "normal",
  "missing_fields": []
}

Здесь order_id равен null, но модель не добавила order_id в missing_fields. Каждое поле по отдельности допустимо, но вместе они описывают противоречивое состояние.

Что такое семантическая проверка?

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

Во время семантической проверки приложение задает вопросы другого уровня:

  • согласуются ли значения разных полей между собой
  • не противоречат ли они известным правилам
  • соответствуют ли они исходному обращению пользователя

Иными словами, если Pydantic проверяет «из чего состоит ответ», то семантическая проверка оценивает «имеет ли этот ответ смысл».

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

В каталоге app/models создайте файл support_request_validation.py:

import re

from app.models.support_request import (
    MissingField,
    SupportRequestAnalysis,
)

class SupportRequestSemanticError(ValueError):
    def __init__(self, issues: tuple[str, ...]) -> None:
        self.issues = issues
        super().__init__(" ".join(issues))

def validate_support_request(
        analysis: SupportRequestAnalysis,
        user_text: str,
) -> None:
    issues: list[str] = []

    order_id_is_missing = (
            MissingField.ORDER_ID in analysis.missing_fields
    )
    if analysis.order_id is None and not order_id_is_missing:
        issues.append(
            "order_id равен null, но не указан "
            "в missing_fields."
        )
    if analysis.order_id is not None and order_id_is_missing:
        issues.append(
            "order_id заполнен, но указан "
            "в missing_fields."
        )

    product_is_missing = (
            MissingField.PRODUCT in analysis.missing_fields
    )
    if analysis.product is None and not product_is_missing:
        issues.append(
            "product равен null, но не указан "
            "в missing_fields."
        )
    if analysis.product is not None and product_is_missing:
        issues.append(
            "product заполнен, но указан "
            "в missing_fields."
        )

    if analysis.order_id is not None:
        order_ids = set(re.findall(r"\b\d+\b", user_text))
        if analysis.order_id not in order_ids:
            issues.append(
                "order_id отсутствует в исходном обращении."
            )

    if issues:
        raise SupportRequestSemanticError(tuple(issues))

Тут функция validate_support_request() получает два значения: уже созданную Pydantic-модель и исходный текст клиента. Сначала функция согласует order_id и product с missing_fields. Если значение отсутствует, оно должно быть отмечено как недостающее. Если значение заполнено, такой отметки быть не должно.

Затем регулярное выражение \b\d+\b ищет в обращении отдельные последовательности цифр. Если модель вернула order_id, которого нет среди этих чисел, код фиксирует ошибку.

Функция не возвращает True или False. Если проблем нет, она просто завершается. Если найдено одно или несколько противоречий, они собираются в issues, а затем поднимается SupportRequestSemanticError.

Следующий фрагмент не нужно добавлять в файлы проекта, мы просто разберем это как пример взаимодействия:

from app.models.support_request import (
    SupportCategory,
    SupportRequestAnalysis,
    Urgency,
)
from app.models.support_request_validation import (
    validate_support_request,
)

user_text = "Кофемашина из заказа 8041 не включается."

analysis = SupportRequestAnalysis(
    category=SupportCategory.TECHNICAL_ISSUE,
    order_id="9999",
    product="кофемашина",
    urgency=Urgency.NORMAL,
    missing_fields=[],
)

validate_support_request(analysis, user_text)

В этом коде Pydantic успешно создаст analysis: все поля имеют правильные типы. Ошибка появится только на последней строке:

SupportRequestSemanticError:
order_id отсутствует в исходном обращении.

Так мы наглядно увидели границу между двумя уровнями. Pydantic подтвердил форму объекта, а validate_support_request() сопоставила номер с исходным текстом.

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

Зачем нужен исправляющий запрос?

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

Можно было бы автоматически заменить неподтвержденный номер на None, но тогда пришлось бы самостоятельно изменять остальные связанные поля. Нужно добавить order_id в missing_fields, проверить другие значения и решить, не повлияла ли ошибка на выбранную категорию. Чем больше таких исправлений выполняет обычный код, тем легче получить формально согласованный, но по-прежнему неверный результат.

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

В repair-запрос приложение добавляет конкретные проблемы, найденные Python-кодом:

Предыдущий результат не прошел проверку.
Исправь перечисленные противоречия:
- order_id отсутствует в исходном обращении.

Таким образом, при первом запросе модель самостоятельно анализирует обращение, а при втором получает обратную связь от приложения и исправляет только обнаруженное противоречие.

После repair-запроса результат нельзя считать правильным автоматически. Новый ответ снова проходит весь путь, а именно проверку причины завершения, Pydantic-валидацию и семантическую проверку. Модель могла исправить номер заказа, но создать другое противоречие. Поэтому каждая попытка проверяется как самостоятельный ответ.

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

Без ограничения приложение могло бы попасть в дорогостоящий цикл:

ответ с ошибкой
→ repair
→ новый ответ с ошибкой
→ еще один repair
→ снова ошибка
→ ...

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

первоначальный запрос + один repair-запрос

Если первый результат оказался правильным, дополнительного обращения не будет, а repair_attempts останется равным 0. Если понадобилось исправление и второй результат прошел проверки, значение будет равно 1.

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

Теперь давайте реализуем это и подключим проверку к structured_demo.py, для этого к импортам добавьте:

from app.models.support_request_validation import (
    SupportRequestSemanticError,
    validate_support_request,
)

Рядом с DEVELOPER_INSTRUCTION задайте предел:

MAX_REPAIR_ATTEMPTS = 1

Значение 1 означает, что после первого ответа может быть выполнен не более одного исправляющего запроса. Бесконечно просить модель «попробовать еще раз» нельзя: это увеличивает задержку и расходы, но не гарантирует успех.

После build_messages() добавьте:

def build_repair_messages(
    user_text: str,
    issues: tuple[str, ...],
) -> list[Message]:
    messages = build_messages(user_text)
    issue_text = "\n".join(f"- {issue}" for issue in issues)

    messages[0] = {
        "role": "developer",
        "content": (
            f"{DEVELOPER_INSTRUCTION}\n\n"
            "Предыдущий результат не прошел проверку.\n"
            "Исправь перечисленные противоречия:\n"
            f"{issue_text}"
        ),
    }
    return messages

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

Теперь добавим функцию управления первой и repair-попыткой, для этого после parse_result() добавьте:

def request_analysis(
    llm_client: LLMClient,
    user_text: str,
) -> tuple[SupportRequestAnalysis, LLMResult, int]:
    messages = build_messages(user_text)

    for repair_attempts in range(MAX_REPAIR_ATTEMPTS + 1):
        result = llm_client.generate_structured(
            messages=messages,
            schema_name=SUPPORT_REQUEST_SCHEMA_NAME,
            schema=SUPPORT_REQUEST_SCHEMA,
        )
        analysis = parse_result(result)

        try:
            validate_support_request(analysis, user_text)
        except SupportRequestSemanticError as error:
            if repair_attempts == MAX_REPAIR_ATTEMPTS:
                raise

            messages = build_repair_messages(
                user_text,
                error.issues,
            )
            continue

        return analysis, result, repair_attempts

    raise RuntimeError("Недостижимое состояние repair flow.")

В цикле range(MAX_REPAIR_ATTEMPTS + 1) дает два прохода. На первом repair_attempts равен 0: приложение отправляет обычный запрос. Если ответ прошел parse_result() и семантическую проверку, он сразу возвращается.

Если validate_support_request() нашла противоречие, код собирает repair-сообщения и переходит на второй проход. На нем repair_attempts равен 1. Если исправленный ответ снова неверен, ошибка выходит наружу. Третьей попытки не будет.

Важно, что repair запускается только для SupportRequestSemanticError. Ошибка API-ключа, недостаток баланса, недоступность ProxyAPI, отказ модели или finish_reason="length" не исчезнут от того, что мы повторим тот же сценарий.

Теперь перейдем к функции main(), в ней нам больше не нужен вызов generate_structured() и parse_result() напрямую. В structured_demo.py внутри main() сейчас находится:

    llm_client = LLMClient(settings)
    try:
        result = llm_client.generate_structured(
            messages=build_messages(user_text),
            schema_name=SUPPORT_REQUEST_SCHEMA_NAME,
            schema=SUPPORT_REQUEST_SCHEMA,
        )
        analysis = parse_result(result)

Замените его на:

    llm_client = LLMClient(settings)
    try:
        analysis, result, repair_attempts = request_analysis(
            llm_client,
            user_text,
        )

Раньше main() самостоятельно отправляла запрос и разбирала ответ. Теперь этим занимается request_analysis(): она выполняет первый запрос, семантическую проверку и при необходимости один repair-запрос.

К блоку except добавьте обработку семантической ошибки перед базовым StructuredResultError:

    except SupportRequestSemanticError as error:
        print("Результат не прошел смысловую проверку:")
        for issue in error.issues:
            print(f"- {issue}")
        return

И финальной правкой будет функция print_result(), ведь теперь успешный сценарий возвращает еще и число repair-попыток. Добавьте этот аргумент:

def print_result(
        analysis: SupportRequestAnalysis,
        result: LLMResult,
        repair_attempts: int,
) -> None:

В конце функции выведите:

    print(f"Исправляющих запросов: {repair_attempts}")

И измените последний вызов в main():

    print_result(analysis, result, repair_attempts)

Поле repair_attempts нужно не для пользователя, а для наблюдаемости. Если после смены модели или промпта доля repair-запросов резко выросла, это признак ухудшения качества. В реальном сервисе в метрики также передают тип ошибки, модель, версию промпта и response_id. Исходный текст клиента без необходимости логировать не стоит.

Перейдем к тестированию, запустите structured_demo.py и введите полное обращение:

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

Если модель сразу вернет согласованные данные, консоль покажет:

Но главная тут строчка:

Исправляющих запросов: 0

Это нормальный и желаемый результат. Намеренно вызвать repair при работе с настоящей моделью может не получиться, ведь короткие и однозначные обращения вместе с ясной инструкцией обычно дают корректный ответ с первой попытки. Даже при использовании разных моделей и более сложных формулировок противоречие может так и не возникнуть — и это не означает, что механизм не нужен.

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

Давайте подведем итоги. В этом разделе мы намеренно работали через отдельный файл structured_demo.py. Основной main.py продолжал запускать чат, а новый файл позволил изолированно изучить другой сценарий, а именно получение структурированных данных от модели. Это сделано было специально, ведь благодаря этому нам не пришлось одновременно разбираться с историей диалога, сессиями, потоковой генерацией и новым контрактом ответа.

При этом structured_demo.py не является отдельным проектом. Он использует те же настройки, LLMClient, ProxyAPI и модель. Мы последовательно прошли весь путь и передали JSON Schema, получили структурированный ответ, обработали отказ и незавершенную генерацию, преобразовали JSON в Pydantic-модель, добавили семантическую проверку и одну ограниченную repair-попытку.

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

Шаг 5

Что делает ConfigDict(extra="forbid") в Pydantic-модели?

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

Шаг 6

Какой метод сразу проверяет JSON-строку по Pydantic-модели?

Правильный ответ
  • ✅ Model.model_validate_json(text)

Шаг 7

Выберите все утверждения, которые верно описывают пользовательские валидаторы Pydantic.

Правильный ответ
  • ✅ model_validator может проверять согласованность нескольких полей
  • ✅ Валидатор может выбросить ValueError с понятной причиной
  • ✅ field_validator подходит для нормализации отдельного поля

Шаг 8

Модель вернула допустимый order_id, которого нет в тексте клиента. Какая проверка должна обнаружить проблему?

Правильный ответ
  • ✅ Смысловая проверка результата относительно исходного текста

Шаг 9

Изучите фрагмент кода:

class ProductInfo(BaseModel):
    name: str

class Order(BaseModel):
    product: ProductInfo

order = Order.model_validate({"product": {"name": "Клавиатура"}})

Какое значение будет храниться в order.product?

Правильный ответ
  • ✅ Pydantic проверит словарь и создаст вложенный объект ProductInfo

Шаг 10

Изучите валидатор:

@field_validator("age", mode="before")
@classmethod
def normalize_age(cls, value):
    return value.strip()

На каком этапе метод normalize_age получает входное значение?

Правильный ответ
  • ✅ До стандартной проверки значения по объявленному типу поля

Шаг 11

Строгая Pydantic-модель заявки поддержки

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

Задача

Создайте класс SupportTicket, наследующийся от BaseModel. Объявите в нём четыре поля и настройте ограничения с помощью Field и строгого типа int.

Требования

  • Не изменяйте имя класса SupportTicket.
  • Задайте model_config = ConfigDict(extra="forbid"), чтобы модель отклоняла любые неизвестные поля.
  • Объявите обязательное поле ticket_id типа int.
  • Задайте для ticket_id ограничения gt=0 и strict=True через Field. Строка "42" не должна автоматически преобразовываться в число.
  • Объявите обязательное поле title: str длиной от 3 до 100 символов включительно.
  • Объявите обязательное поле priority: int со значением от 1 до 5 включительно.
  • Объявите поле comment: str | None со значением по умолчанию None.
  • Не добавляйте собственные методы валидации: все ограничения этой задачи можно задать в объявлениях полей.

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

ticket = SupportTicket(
    ticket_id=42,
    title="Не включается монитор",
    priority=3,
)

print(ticket)

Результат:

ticket_id=42 title='Не включается монитор' priority=3 comment=None

Значения ticket_id=0, priority=6, слишком короткий title или дополнительное поле должны приводить к ValidationError.

BaseModel, ConfigDict и Field уже импортированы.

Правильный ответ
from pydantic import BaseModel, ConfigDict, Field

class SupportTicket(BaseModel):
    model_config = ConfigDict(extra="forbid")

    ticket_id: int = Field(gt=0, strict=True)
    title: str = Field(min_length=3, max_length=100)
    priority: int = Field(ge=1, le=5)
    comment: str | None = None

Шаг 12

Согласование номера заказа и списка недостающих полей

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

Задача

Создайте Pydantic-модель OrderAnalysis и добавьте валидатор всей модели, который проверяет согласованность order_id и missing_fields.

Требования

  • Не изменяйте имя класса OrderAnalysis.
  • Запретите дополнительные поля через ConfigDict(extra="forbid").
  • Объявите обязательное поле order_id: str | None. Значение по умолчанию не задавайте.
  • Объявите поле missing_fields: list[str].
  • Для значения по умолчанию используйте Field(default_factory=list). У каждого экземпляра модели должен быть собственный пустой список.
  • Добавьте метод с декоратором @model_validator(mode="after").
  • Если self.order_id is None, строка "order_id" должна присутствовать в self.missing_fields.
  • Если self.order_id содержит строку, "order_id" не должна присутствовать в self.missing_fields.
  • При любом противоречии выбросьте ValueError.
  • Если данные согласованы, верните self из валидатора.

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

missing = OrderAnalysis(
    order_id=None,
    missing_fields=["order_id"],
)
filled = OrderAnalysis(
    order_id="8041",
    missing_fields=[],
)

print(missing)
print(filled)

Обе модели корректны. Сочетание order_id=None и missing_fields=[] должно приводить к ValidationError.

BaseModel, ConfigDict, Field, model_validator и Self уже импортированы.

Правильный ответ
from typing import Self
from pydantic import BaseModel, ConfigDict, Field, model_validator

class OrderAnalysis(BaseModel):
    model_config = ConfigDict(extra="forbid")

    order_id: str | None
    missing_fields: list[str] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_order_id_consistency(self):
        if self.order_id is None:
            if "order_id" not in self.missing_fields:
                raise ValueError
        else:
            if "order_id" in self.missing_fields:
                raise ValueError

        return self

Шаг 13

Вложенная Pydantic-модель товара

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

Задача

Создайте две модели: ProductInfo для сведений о товаре и ProductRequest для обращения, содержащего номер заказа и товар.

Требования

  • Не изменяйте имена классов ProductInfo и ProductRequest.
  • Оба класса должны наследоваться от BaseModel.
  • В каждой модели задайте model_config = ConfigDict(extra="forbid").
  • В ProductInfo объявите обязательное поле name: str.
  • В ProductInfo объявите обязательное поле serial_number: str | None. Не задавайте ему значение по умолчанию: ключ должен присутствовать, даже если его значение равно None.
  • В ProductRequest объявите обязательное поле order_id: str.
  • В ProductRequest объявите обязательное поле product: ProductInfo.
  • ProductRequest должен принимать в product готовый объект ProductInfo или словарь с полями name и serial_number.
  • После проверки вложенный словарь должен превратиться в объект ProductInfo.
  • Дополнительные поля должны отклоняться как во внешней, так и во вложенной модели.

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

request = ProductRequest.model_validate({
    "order_id": "8041",
    "product": {
        "name": "кофемашина",
        "serial_number": None,
    },
})

print(type(request.product).__name__)
print(request.product.name)

Результат:

ProductInfo
кофемашина

Если во вложенном словаре отсутствует serial_number, модель должна завершить проверку с ValidationError.

BaseModel и ConfigDict уже импортированы.

Правильный ответ
from pydantic import BaseModel, ConfigDict

class ProductInfo(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    serial_number: str | None

class ProductRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    order_id: str
    product: ProductInfo

Шаг 14

Очистка пользовательского сообщения валидатором поля

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

Задача

Создайте Pydantic-модель CustomerMessage с обязательным полем text. Добавьте валидатор, который удаляет внешние пробелы и сохраняет в модели очищенную строку.

Требования

  • Не изменяйте имя класса CustomerMessage и поля text.
  • Класс должен наследоваться от BaseModel.
  • Объявите обязательное поле text: str без значения по умолчанию.
  • Создайте метод-валидатор поля с декораторами @field_validator("text") и @classmethod.
  • Валидатор должен получить строку, удалить внешние пробелы с помощью strip() и сохранить результат в отдельной переменной.
  • Если длина очищенной строки меньше трёх символов, выбросьте ValueError.
  • Если длина допустима, верните очищенную строку. Именно возвращённое значение должно сохраниться в message.text.
  • Нестроковое значение должно быть отклонено стандартной проверкой Pydantic.

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

message = CustomerMessage(
    text="  Не работает экран  "
)
print(message.text)
print(repr(message.text))

Результат:

Не работает экран
'Не работает экран'

Значения " " и " a " после очистки должны привести к ValidationError.

BaseModel и field_validator уже импортированы.

Правильный ответ
from pydantic import BaseModel, field_validator

class CustomerMessage(BaseModel):
    text: str

    @field_validator("text")
    @classmethod
    def validate_text(cls, value: str) -> str:
        cleaned = value.strip()

        if len(cleaned) < 3:
            raise ValueError

        return cleaned

Шаг 15

Преобразование строки тегов в список

Поле values может прийти в двух формах: готовым списком строк или одной строкой с тегами через запятую. Предварительный валидатор приводит строку к списку до стандартной проверки Pydantic. Второй валидатор проверяет уже готовый список и запрещает пустой результат.

Задача

Создайте модель Tags с обязательным полем values: list[str] и двумя валидаторами этого поля: предварительным для преобразования входа и обычным для проверки непустого списка.

Требования

  • Не изменяйте имя класса Tags и поля values.
  • Класс должен наследоваться от BaseModel.
  • Объявите обязательное поле values: list[str].
  • Добавьте первый метод с декораторами @field_validator("values", mode="before") и @classmethod.
  • Аргумент предварительного валидатора должен иметь тип Any, потому что он получает значение до проверки типа поля.
  • Если входное значение является строкой, разделите его по запятым.
  • Удалите внешние пробелы у каждого полученного элемента и исключите элементы, которые после очистки стали пустыми.
  • Верните подготовленный список из предварительного валидатора.
  • Если входное значение не является строкой, верните его без изменений. Дальнейшую проверку типа выполнит Pydantic.
  • Добавьте второй метод с @field_validator("values"). Он получает уже проверенный list[str].
  • Если список пуст, выбросьте ValueError. Непустой список верните без изменений.

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

from_string = Tags(
    values=" hardware, monitor,  "
)
from_list = Tags(
    values=["delivery", "urgent"]
)

print(from_string.values)
print(from_list.values)

Результат:

['hardware', 'monitor']
['delivery', 'urgent']

Строка " , ", пустой список и список с нестроковым элементом должны приводить к ValidationError.

Any, BaseModel и field_validator уже импортированы.

Правильный ответ
from typing import Any
from pydantic import BaseModel, field_validator

class Tags(BaseModel):
    values: list[str]

    @field_validator("values", mode="before")
    @classmethod
    def prepare_values(cls, value: Any) -> Any:
        if isinstance(value, str):
            return [
                item.strip()
                for item in value.split(",")
                if item.strip()
            ]

        return value

    @field_validator("values")
    @classmethod
    def validate_values(cls, value: list[str]) -> list[str]:
        if not value:
            raise ValueError

        return value

Шаг 16

Проверка ответа с одной повторной попыткой

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

Задача

Реализуйте функцию request_valid_analysis(client, messages, schema_name, schema, validator). Сначала получите структурированный ответ от клиента и проверьте его. Если validator вернёт непустой список проблем, добавьте сообщение с описанием этих проблем и выполните ровно один повторный запрос.

Требования

  • Не изменяйте имя функции request_valid_analysis и количество её аргументов.
  • Для первого запроса вызовите client.generate_structured() с именованными аргументами messages=messages, schema_name=schema_name и schema=schema.
  • Клиент возвращает объект с полями refusal, finish_reason и text. Ответ можно обрабатывать только при refusal is None, finish_reason == "stop" и непустом строковом text. Если хотя бы одно условие не выполнено, выбросьте ValueError без повторного запроса.
  • Преобразуйте text из JSON в словарь payload. Если JSON разобрать нельзя или результат не является объектом, выбросьте ValueError без повторного запроса.
  • Передайте payload в validator(payload). Валидатор возвращает список строк: пустой список означает, что данные корректны, непустой содержит описания найденных проблем.
  • Если после первого запроса список проблем пуст, верните кортеж (payload, 0). Число 0 означает, что повторный запрос не потребовался.
  • Если валидатор нашёл проблемы, создайте новый список сообщений и не изменяйте исходный список messages.
  • Добавьте в новый список одно сообщение вида {"role": "developer", "content": "Исправь ошибки: - ..."}. Каждую строку из списка проблем поместите на отдельную строку после символов - .
  • Выполните второй вызов client.generate_structured(). Передайте новый список в аргумент messages, а значения schema_name и schema оставьте прежними.
  • Проверьте второй ответ по тем же правилам. Если его payload прошёл проверку валидатором, верните (payload, 1).
  • Если второй ответ некорректен или валидатор снова вернул проблемы, выбросьте ValueError. Третий запрос выполнять нельзя.

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

Первый ответ модели содержит {"order_id": "9999"}, и валидатор возвращает проблему номер придуман. Второй ответ содержит корректный номер заказа:

payload, repair_attempts = request_valid_analysis(
    client, messages, "support", schema, validator
)

print(payload)
print(repair_attempts)

Результат:

{'order_id': '8041'}
1

Проверяющая система передаёт фейковый объект client с заранее подготовленными ответами, список messages, схему и функцию validator. Настоящий API-ключ и сетевой доступ не нужны.

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

import json

def request_valid_analysis(client, messages, schema_name, schema, validator):
    result = client.generate_structured(
        messages=messages,
        schema_name=schema_name,
        schema=schema,
    )

    if (
        result.refusal is not None
        or result.finish_reason != "stop"
        or not isinstance(result.text, str)
        or not result.text.strip()
    ):
        raise ValueError

    try:
        payload = json.loads(result.text)
    except json.JSONDecodeError:
        raise ValueError

    if not isinstance(payload, dict):
        raise ValueError

    issues = validator(payload)

    if not issues:
        return payload, 0

    repair_message = {
        "role": "developer",
        "content": "Исправь ошибки:\n" + "\n".join(
            f"- {issue}" for issue in issues
        ),
    }

    repaired_messages = [
        *messages,
        repair_message,
    ]

    result = client.generate_structured(
        messages=repaired_messages,
        schema_name=schema_name,
        schema=schema,
    )

    if (
        result.refusal is not None
        or result.finish_reason != "stop"
        or not isinstance(result.text, str)
        or not result.text.strip()
    ):
        raise ValueError

    try:
        payload = json.loads(result.text)
    except json.JSONDecodeError:
        raise ValueError

    if not isinstance(payload, dict):
        raise ValueError

    issues = validator(payload)

    if issues:
        raise ValueError

    return payload, 1