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, активируйте его виртуальное окружение и выполните:
В корне проекта, рядом с 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-модель.
Каждое поле модели описывается с помощью аннотации типа:
Аннотация сообщает Pydantic, какое значение ожидается в каждом поле. ticket_id должен содержать целое число, title — строку, а priority — целое число.
Если значение можно привести к нужному типу, Pydantic попробует сделать это автоматически. Например, строка "42" может быть преобразована в число 42. Если преобразование невозможно, модель не будет создана и возникнет ValidationError.
Функция Field() позволяет добавить к полю дополнительные ограничения. Например:
Здесь min_length=3 означает, что строка должна содержать не менее трёх символов, а max_length=100 ограничивает её максимальную длину.
Для числовых полей можно задавать допустимый диапазон:
Параметр ge=1 означает «больше или равно 1», а le=5 — «меньше или равно 5». Поэтому значения 1, 3 и 5пройдут проверку, а 0 или 6 приведут к ошибке валидации.
А настройка:
запрещает передавать поля, которые не описаны в модели. Например, если вместо priority случайно написать prioroty, Pydantic не проигнорирует опечатку, а сразу сообщит об ошибке.
Это особенно полезно при работе с данными из внешних источников. Если формат HTTP-запроса, файла или ответа LLM неожиданно изменится, приложение узнает об этом сразу, а не продолжит работать с неполными или неправильными данными.
Какими бывают типы полей?¶
Тип поля записывается после двоеточия. Он объясняет Pydantic, какое значение должно находиться в готовом объекте после успешной проверки:
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.
Теперь мы можем обращаться к данным через атрибуты:
Добавьте эти строки в самый конец файла и запустите его повторно. Еще одно из преимуществ в том, что сама 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 содержит именно строку.
Теперь попробуем преобразовать эти объекты обратно, для этого добавьте:
model_dump() возвращает Python-словарь, а model_dump_json() — JSON-строку. Прикладной код обычно работает с атрибутами объекта, а сериализация нужна на внешней границе, например при сохранении или возврате ответа.
Теперь попробуем получить JSON 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.
Теперь добавьте ошибочный пример:
Исходная строка содержит пять символов вместе с пробелами. Но валидатор сначала очищает ее и обнаруживает, что текст слишком короткий.
Иногда значение сначала нужно привести к удобной форме, а уже потом проверять. Добавьте ниже:
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 вообще не описано в модели а проверка нужна именно для того, чтобы не просто проигнорировать его, а явно сообщить, что передавать такое поле запрещено.
И перед тем, как переходить к нашему проекту, давайте поговорим о самом важном, как выбрать подходящую проверку? Обычно разработчик двигается от самого простого механизма:
- Сначала выберите точный тип или вложенную модель.
- Используйте
Field(), если правило выражается длиной или диапазоном. - Для одного готового значения добавьте проверку после стандартной обработки.
- Для подготовки сырого значения — проверку до стандартной обработки.
- Для связи нескольких полей — проверку всей модели после ее создания.
- Полную замену и оборачивание оставьте для редких случаев.
- Если нужны база данных, исходный запрос или внешний сервис, вынесите правило из 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 и к импортам из приложения добавьте модель:
После 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:
Если 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 и введите обращение, содержащее все основные сведения:
Программа должна вывести приблизительно такой результат:
Проверенный результат:
{
"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.
Какая проблема еще остается?¶
Теперь приложение надежно проверяет форму ответа, но пока не проверяет все связи между полученными значениями.
Представим ситуацию, когда клиент написал:
При этом модель вернула:
{
"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 создал объект, значит, результат можно использовать. Однако типы могут быть правильными, а значения — противоречивыми или не связанными с исходным обращением.
Представим, что клиент написал:
А модель вернула:
{
"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: все поля имеют правильные типы. Ошибка появится только на последней строке:
Так мы наглядно увидели границу между двумя уровнями. Pydantic подтвердил форму объекта, а validate_support_request() сопоставила номер с исходным текстом.
Но обнаружить противоречие это только половина задачи, ведь пока приложение умеет лишь сообщить об ошибке, поэтому далее разберемся, как исправляющий запрос помогает модели сформировать новый корректный результат.
Зачем нужен исправляющий запрос?¶
Семантическая проверка умеет обнаружить противоречие, но сама по себе не создает правильный результат. Например она может определить, что модель вернула номер заказа 9999, которого нет в исходном обращении. Однако функция проверки не всегда может понять, какое значение следует поставить вместо него.
Можно было бы автоматически заменить неподтвержденный номер на None, но тогда пришлось бы самостоятельно изменять остальные связанные поля. Нужно добавить order_id в missing_fields, проверить другие значения и решить, не повлияла ли ошибка на выбранную категорию. Чем больше таких исправлений выполняет обычный код, тем легче получить формально согласованный, но по-прежнему неверный результат.
Поэтому при некоторых семантических ошибках мы разрешим приложению выполнить один исправляющий запрос, или repair-запрос. Это не простая повторная отправка тех же сообщений. Если отправить модели абсолютно тот же запрос, она может снова вернуть такой же результат.
В repair-запрос приложение добавляет конкретные проблемы, найденные Python-кодом:
Предыдущий результат не прошел проверку.
Исправь перечисленные противоречия:
- order_id отсутствует в исходном обращении.
Таким образом, при первом запросе модель самостоятельно анализирует обращение, а при втором получает обратную связь от приложения и исправляет только обнаруженное противоречие.
После repair-запроса результат нельзя считать правильным автоматически. Новый ответ снова проходит весь путь, а именно проверку причины завершения, Pydantic-валидацию и семантическую проверку. Модель могла исправить номер заказа, но создать другое противоречие. Поэтому каждая попытка проверяется как самостоятельный ответ.
И тут есть важный момент, сколько повторных попыток можно разрешать, ведь повторные запросы увеличивают время ожидания и расходуют токены. Если модель не смогла исправить результат после точного объяснения ошибки, дальнейшие попытки могут снова возвращать похожие данные.
Без ограничения приложение могло бы попасть в дорогостоящий цикл:
Поэтому обычно задают одну дополнительную попытку после первой генерации. Максимально приложение обратится к модели два раза:
Если первый результат оказался правильным, дополнительного обращения не будет, а repair_attempts останется равным 0. Если понадобилось исправление и второй результат прошел проверки, значение будет равно 1.
Если вторая генерация снова содержит противоречие, приложение прекращает попытки и возвращает SupportRequestSemanticError. Дальше результат можно передать разработчику, оператору или другому предусмотренному сценарию обработки.
Теперь давайте реализуем это и подключим проверку к structured_demo.py, для этого к импортам добавьте:
from app.models.support_request_validation import (
SupportRequestSemanticError,
validate_support_request,
)
Рядом с DEVELOPER_INSTRUCTION задайте предел:
Значение 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:
В конце функции выведите:
И измените последний вызов в main():
Поле repair_attempts нужно не для пользователя, а для наблюдаемости. Если после смены модели или промпта доля repair-запросов резко выросла, это признак ухудшения качества. В реальном сервисе в метрики также передают тип ошибки, модель, версию промпта и response_id. Исходный текст клиента без необходимости логировать не стоит.
Перейдем к тестированию, запустите structured_demo.py и введите полное обращение:
Если модель сразу вернет согласованные данные, консоль покажет:
Но главная тут строчка:
Это нормальный и желаемый результат. Намеренно вызвать 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_id=0, priority=6, слишком короткий title или дополнительное поле должны приводить к ValidationError.
BaseModel, ConfigDict и Field уже импортированы.
Правильный ответ
Шаг 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)
Результат:
Если во вложенном словаре отсутствует serial_number, модель должна завершить проверку с ValidationError.
BaseModel и ConfigDict уже импортированы.
Правильный ответ
Шаг 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 уже импортированы.
Правильный ответ
Шаг 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)
Результат:
Строка " , ", пустой список и список с нестроковым элементом должны приводить к 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)
Результат:
Проверяющая система передаёт фейковый объект 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