Структурированные ответы и JSON Schema¶
Шаг 1¶
В предыдущем разделе мы выяснили, зачем приложению нужен контракт данных. Теперь превратим эту идею в конкретную схему для нашего AI Support Desk. Схема будет описывать результат анализа обращения: какие поля должна вернуть модель, какие типы данных в них допустимы и какие значения может использовать бэкенд в дальнейшем.
Начнем именно со схемы, а не с вызова API. Если сначала отправить запрос модели, а затем пытаться придумать структуру под получившийся ответ, контракт снова начнет зависеть от случайной формулировки. Правильнее сначала определить, какие данные действительно нужны следующему шагу программы.
Что такое JSON Schema?¶
JSON Schema — это описание допустимой структуры JSON-документа. Обычный JSON содержит конкретные данные, а схема объясняет правила, которым эти данные должны соответствовать.
Например, это конкретный результат:
А это небольшая схема для такого результата:
{
"type": "object",
"properties": {
"category": {"type": "string"},
"order_id": {"type": "string"}
},
"required": ["category", "order_id"],
"additionalProperties": false
}
Поле type: object говорит, что на верхнем уровне ожидается JSON-объект. В properties перечислены разрешенные поля и правила для каждого из них. Список required указывает поля, которые должны присутствовать в результате. Значение additionalProperties: false запрещает ключи, которых нет в properties.
Получается, что схема описывает не один заранее записанный ответ. Она разрешает множество результатов, если все они соответствуют одним правилам. Номер заказа может меняться, но он всегда будет находиться в поле order_id.
JSON Schema использует типы, похожие на знакомые типы Python. string соответствует строке, integer — целому числу, number — числу, boolean — значениям true и false, array — списку, object — объекту с вложенными полями, а null обозначает отсутствие значения.
Номер заказа мы намеренно будем хранить строкой, а не числом. Идентификатор не участвует в вычислениях, зато у него теоретически могут появиться ведущие нули или буквенный префикс. Запись "0041" сохраняет исходное значение, а число 41 уже теряет два символа.
Для списка нужно дополнительно описать его элементы. Например поле со списком недостающих данных выглядит так:
Здесь array обозначает список, а items сообщает, что каждый элемент этого списка должен быть строкой.
Объекты можно вкладывать друг в друга. В таком случае значением одного поля становится не строка или число, а другой объект со своими полями. Например, поле product может содержать название товара и его серийный номер:
{
"type": "object",
"properties": {
"category": {
"type": "string"
},
"product": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"serial_number": {
"type": ["string", "null"]
}
},
"required": ["name", "serial_number"],
"additionalProperties": false
}
},
"required": ["category", "product"],
"additionalProperties": false
}
Однако вложенность нужна только тогда, когда она помогает приложению. Для первой схемы мы оставим плоскую структуру: ее проще прочитать, передать модели и проверить. Усложнять контракт ради демонстрации всех возможностей JSON Schema не нужно.
В строгом режиме структурированного ответа все поля схемы должны входить в required. При этом возможно у вас возник вопрос, ведь номер заказа присутствует не в каждом обращении, значит поле как будто должно быть необязательным.
Здесь важно разделить две разные ситуации. Поле может отсутствовать в объекте целиком, а может присутствовать со значением null. Для предсказуемого контракта удобнее второй вариант:
Чтобы разрешить и строку, и отсутствие значения, указываются два допустимых типа:
Теперь ключ order_id будет возвращаться всегда. Если номер найден, Python получит строку. Если клиент его не указал, приложение получит None после преобразования JSON в словарь. И код больше не должен угадывать, забыла ли модель поле или данных действительно нет.
Так же, если мы говорим про наш проект. То например одного типа string недостаточно для категории. Строкой является и technical_issue, и поломка, и случайное значение unknown_problem_42. Но наш код будет уметь обрабатывать только заранее известные категории.
Для этого существует ключ enum, который задает конечный набор разрешенных значений:
{
"type": "string",
"enum": [
"technical_issue",
"delivery_problem",
"return_request",
"payment_problem",
"other"
]
}
Модель по-прежнему анализирует смысл свободного сообщения, но результат выбора ограничен пятью вариантами. Python-код может надежно сравнить значение с technical_issue и не учитывать десятки русских формулировок одного смысла.
Думаю мы достаточно подробно обсудили что такое JSON Schema и основной синтаксис, поэтому давайте создадим схему обращения для нашего проекта.
Создайте в каталоге app новый пакет schemas и пустой файл app/schemas/__init__.py. Рядом создайте файл app/schemas/support_request.py:
SUPPORT_REQUEST_SCHEMA_NAME = "support_request"
SUPPORT_REQUEST_SCHEMA = {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Категория обращения клиента.",
"enum": [
"technical_issue",
"delivery_problem",
"return_request",
"payment_problem",
"other",
],
},
"order_id": {
"type": ["string", "null"],
"description": (
"Номер заказа из обращения или null, "
"если клиент его не указал."
),
},
"product": {
"type": ["string", "null"],
"description": (
"Название товара из обращения или null, "
"если товар не указан."
),
},
"urgency": {
"type": "string",
"description": "Срочность обращения.",
"enum": ["low", "normal", "high"],
},
"missing_fields": {
"type": "array",
"description": (
"Сведения, которых не хватает для обработки."
),
"items": {
"type": "string",
"enum": [
"order_id",
"product",
"problem_description",
],
},
},
},
"required": [
"category",
"order_id",
"product",
"urgency",
"missing_fields",
],
"additionalProperties": False,
}
Схему мы записали в виде обычного Python-словаря. При выполнении запроса библиотека openai автоматически преобразует этот словарь в JSON и передаст его провайдеру вместе с остальными параметрами. В константе SUPPORT_REQUEST_SCHEMA_NAME хранится короткое техническое название схемы — support_request, а SUPPORT_REQUEST_SCHEMA содержит ее полное описание: поля, типы и допустимые значения.
Мы намеренно описали только те поля, которые действительно понадобятся приложению для анализа обращения: категорию проблемы, номер заказа, название товара, срочность и список недостающих сведений. Другие данные, например адрес клиента или дату покупки, пока добавлять не будем, поскольку наш код еще не использует их. Чем меньше ненужных полей содержит схема, тем проще ее понимать, поддерживать и обрабатывать.
Мы подготовили правила результата, но еще не отправляли их провайдеру. В следующем шаге мы добавим в LLMClient отдельный метод структурированной генерации и разберем, как схема попадает в запрос через response_format.
Шаг 2¶
В прошлом шаге мы создали JSON Schema для результата анализа обращения. Пока это только Python-словарь в нашем проекте. Модель еще ничего о нем не знает, потому что схема не передается автоматически только из-за того, что мы создали файл.
Теперь свяжем схему с вызовом модели. Для этого расширим LLMClient отдельным методом generate_structured(). Обычный generate() продолжит возвращать свободный текст для чата и резюме, а новый метод будет использоваться там, где backend ожидает JSON по контракту.
Как схема попадает к модели?¶
В Chat Completions API схема передается через аргумент response_format. Он сообщает провайдеру, в каком формате модель должна вернуть результат:
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"strict": True,
"schema": schema,
},
}
Внешнее поле type определяет режим формирования ответа. Значение "json_schema" означает, что мы хотим получить не просто корректный JSON, а результат, соответствующий переданной JSON Schema.
Поле json_schema содержит настройки самой схемы. Внутри него находятся три параметра.
name— техническое название схемы. В нашем случае в параметрschema_nameбудет передано значение"support_request". Это название помогает обозначить контракт внутри запроса, но не становится отдельным полем результата.strictвключает строгий режим. При значенииTrueуспешный ответ должен соответствовать поддерживаемой структуре схемы: содержать обязательные поля, использовать разрешенные типы и не добавлять запрещенные ключи. Строгий режим контролирует форму результата, но не гарантирует, что модель правильно поняла смысл обращения.schemaсодержит непосредственно JSON Schema. В нашем коде параметрschemaполучит словарьSUPPORT_REQUEST_SCHEMA, который мы создали в предыдущей лекции. Именно в нем описаны поляcategory,order_id,product,urgencyиmissing_fields, их типы и допустимые значения.
Таким образом, аргументы запроса отвечают за разные части задачи. messages содержит developer-инструкцию и обращение пользователя, поэтому объясняет модели, что именно нужно проанализировать. response_format описывает, в какой технической форме должен быть возвращен результат.
При этом всем, настройки подключения менять не нужно. В курсе мы используем оригинальный OpenAI-совместимый адрес https://api.proxyapi.ru/openai/v1, поэтому официальный SDK передает response_format через ProxyAPI в том же формате. А используемая в проекте gpt-4.1 подходит для этого сценария, но если вы используете другую модель, то должны проверить, что она поддерживает Structured Outputs.
Мы разобрали, как схема передается через response_format, а теперь перейдем от общей схемы запроса к практике. Дальше будем изменять наш проект AI Support Desk и сначала подготовим общие типы в LLMClient, а затем добавим отдельный метод для структурированной генерации.
Откройте app/llm/client.py. В начало файла добавьте импорт Any и тип ответа библиотеки:
После существующего Message добавьте псевдоним для схемы:
Здесь Any используется потому, что JSON Schema является вложенной структурой и ее значениями могут быть строки, списки, логические значения и другие словари. Позже Pydantic позволит описывать доменный результат точнее, но мы идем этапами и сейчас клиенту нужно лишь передать готовую схему SDK.
В LLMResult добавьте поле refusal:
@dataclass(frozen=True)
class LLMResult:
text: str | None
refusal: str | None
model: str
finish_reason: str | None
prompt_tokens: int | None
completion_tokens: int | None
total_tokens: int | None
response_id: str
Отказ модели — это не обычный структурированный результат, ведь если запрос нарушает правила безопасности, модель может не заполнять нашу схему, а вернуть объяснение в отдельном поле refusal. Если не сохранить это поле в LLMResult, прикладной код не сможет отличить осознанный отказ от пустого ответа.
При этом добавление данного поля не ломает существующие SupportService и ChatService, так как они продолжают читать text и остальные знакомые метаданные. Изменить нужно только место, где создается сам объект LLMResult.
Сейчас LLMClient умеет выполнять только обычную текстовую генерацию через метод generate(). В следующей части лекции мы добавим второй способ обращения к модели — структурированную генерацию. В новом запросе появится аргумент response_format, через который мы передадим JSON Schema.
Несмотря на различия в параметрах будущего запроса, библиотека openai в обоих случаях вернет объект ChatCompletion. Из него нужно будет одинаково читать текст, отказ модели, причину завершения, статистику токенов, название модели и ID ответа. И если оставить существующий разбор внутри _generate_once(), то при добавлении структурированной генерации такой же код придется скопировать в новый метод. Поэтому сначала подготовим общую обработку ответа и вынесем преобразование ChatCompletion в LLMResult в отдельный метод _to_result().
После метода _generate_once() добавьте:
@staticmethod
def _to_result(response: ChatCompletion) -> LLMResult:
if not response.choices:
raise LLMProviderError(
"Провайдер вернул ответ без вариантов продолжения."
)
choice = response.choices[0]
usage = response.usage
return LLMResult(
text=choice.message.content,
refusal=choice.message.refusal,
model=response.model,
finish_reason=choice.finish_reason,
prompt_tokens=(
usage.prompt_tokens if usage is not None else None
),
completion_tokens=(
usage.completion_tokens if usage is not None else None
),
total_tokens=(
usage.total_tokens if usage is not None else None
),
response_id=response.id,
)
Кратко разберем данный код. В нем мы сначала _to_result() проверяем список response.choices. Обычно в нем находится хотя бы один вариант ответа модели. Если список пустой, получить текст и метаданные невозможно, поэтому создается LLMProviderError.
После проверки мы берем первый вариант:
И отдельно сохраняем объект со статистикой токенов:
Затем из ответа SDK создается знакомый нашему приложению LLMResult. В него попадает обычный текст из choice.message.content или сообщение об отказе из choice.message.refusal.
Теперь изменим _generate_once(). Сам сетевой запрос оставляем прежним, но после его завершения передаем полученный response в новый метод _to_result():
def _generate_once(self, messages: list[Message]) -> LLMResult:
response = self._client.chat.completions.create(
model=self._settings.model,
messages=messages,
temperature=self._settings.temperature,
max_completion_tokens=self._settings.max_output_tokens,
)
return self._to_result(response)
Раньше _generate_once() одновременно выполнял две задачи: отправлял запрос и вручную преобразовывал ответ SDK в LLMResult. Теперь обязанности разделены:
Метод _generate_once() теперь отвечает только за параметры обычной генерации. Метод _to_result() отвечает за единообразное чтение ответа.
Благодаря этому следующему методу структурированной генерации не придется повторять весь разбор ChatCompletion. Он отправит запрос с JSON Schema, получит ответ SDK и также передаст его в _to_result(). В результате обычная и структурированная генерация будут возвращать приложению один и тот же тип LLMResult, а общая логика обработки останется в одном месте.
Давайте начнем с внутреннего метода _generate_structured_once(). Он будет непосредственно обращаться к SDK и передавать модели JSON Schema. После _generate_once() добавьте:
def _generate_structured_once(
self,
messages: list[Message],
schema_name: str,
schema: JsonSchema,
) -> LLMResult:
response = self._client.chat.completions.create(
model=self._settings.model,
messages=messages,
temperature=self._settings.temperature,
max_completion_tokens=self._settings.max_output_tokens,
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"strict": True,
"schema": schema,
},
},
)
return self._to_result(response)
По своей общей структуре новый метод похож на существующий _generate_once(). Он использует ту же модель, принимает список сообщений и устанавливает уже знакомые параметры генерации. А главное отличие находится в аргументе response_format:
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"strict": True,
"schema": schema,
},
}
Значение "json_schema" включает режим Structured Outputs. Параметр schema_name передает техническое название схемы, а schema — словарь с ее полным описанием. Значение strict=True требует, чтобы успешный ответ соответствовал поддерживаемой структуре схемы.
После завершения сетевого запроса мы не разбираем объект response повторно. Вместо этого передаем его в созданный ранее метод:
Теперь добавим публичную точку входа и рядом с существующим generate() создайте метод generate_structured():
def generate_structured(
self,
messages: list[Message],
schema_name: str,
schema: JsonSchema,
) -> LLMResult:
try:
return self._generate_structured_once(
messages=messages,
schema_name=schema_name,
schema=schema,
)
except openai.APIError as error:
raise self._convert_error(error) from error
Этот метод будут вызывать остальные части приложения. Они передадут ему сообщения, техническое название схемы и ее описание. Внутри try запускается созданный нами _generate_structured_once(). Если запрос завершится успешно, метод вернет готовый LLMResult. Если SDK создаст ошибку API, выполнение перейдет в except. Уже существующий _convert_error() преобразует техническое исключение библиотеки openai в одну из понятных приложению ошибок LLMClientError.
Таким образом, структурированная генерация проходит через те же уровни, что и обычная. Публичный generate_structured() управляет обработкой ошибок, внутренний _generate_structured_once() формирует запрос с JSON Schema, а общий _to_result() преобразует ответ SDK в LLMResult.
После этих изменений LLMClient поддерживает два явных сценария:
возвращает обычный текстовый результат, а
запрашивает результат, соответствующий переданной JSON Schema. Оба сценария возвращают приложению одинаковый тип LLMResult и используют общую обработку ответов и ошибок.
Теперь LLMClient поддерживает два способа генерации. Метод generate() используется для обычных текстовых ответов, а generate_structured() позволяет передать JSON Schema и запросить структурированный результат.
При этом общая логика не дублируется. Оба способа используют один метод _to_result(), возвращают одинаковый объект LLMResult и преобразуют ошибки SDK через уже существующий _convert_error().
Сам новый метод мы пока не вызывали. В следующем шаге мы создадим отдельный демонстрационный сценарий, передадим ему схему обращения и посмотрим, какие данные действительно возвращаются.
Шаг 3¶
В предыдущем шаге мы добавили в LLMClient метод generate_structured(). Теперь клиент умеет передавать JSON Schema вместе с запросом, но эта возможность пока не используется остальными частями приложения. А в этом шаге мы создадим небольшой демонстрационный сценарий. Он попросит ввести обращение клиента, отправит его модели вместе со схемой и выведет полученный структурированный результат.
Сейчас main.py является точкой входа для уже работающего чата. Он загружает настройки, создает LLMClient, собирает сервисы истории и суммаризации, а затем запускает консольный диалог через run_console(). Но структурированный анализ обращения пока не встроен в этот пользовательский сценарий. У нас еще нет отдельного сервиса анализа, типизированной модели результата и решения о том, как чат должен использовать полученные поля. Поэтому пока, временно, мы создадим новую точку входа, ведь если подключить незавершенный механизм прямо к main.py, в одном сценарии смешаются работающий чат и учебная проверка новой возможности.
Так мы сможем проверить новую возможность, не затрагивая уже работающий чат. В следующих разделах добавим Pydantic-модель и прикладной сервис анализа обращений. И вот уже когда механизм станет законченным, подключим его к основному приложению через main.py.
Какие результаты может вернуть структурированная генерация?¶
Перед написанием кода давайте разберемся, а какие результаты мы можем получить? Наличие JSON Schema не означает, что каждый запрос обязательно завершится готовым JSON. При успешной генерации finish_reason обычно содержит "stop", поле refusal равно None, а в text находится JSON, соответствующий переданной схеме. Только после этих проверок результат можно преобразовывать с помощью json.loads().
Модель также может отказаться выполнять запрос из-за ограничений безопасности. Тогда объяснение отказа будет находиться в поле refusal, а обычного структурированного результата может не быть. Еще одна ситуация возникает, если модели не хватило разрешенного количества выходных токенов. В этом случае finish_reason будет равен "length". Такой результат нельзя использовать, даже если значение text начинается как правильный JSON, то ответ мог оборваться до последних полей или закрывающей фигурной скобки.
Возможны и другие причины завершения, например фильтрация содержимого. Поэтому сначала необходимо проверить общий исход генерации и только потом разбирать text.
И все эти варианты можно отобразить на следующей схеме:
То есть по сути, структурированный ответ гарантирует структуру успешно завершенного ответа, но не отменяет сетевые ошибки, отказы модели и незавершенную генерацию. Поэтому мы сначала проверим refusal и finish_reason, а уже затем преобразуем text в Python-словарь.
В корне проекта, рядом с main.py, создайте файл structured_demo.py и добавьте в него следующий код:
import json
from pydantic import ValidationError
from app.config import Settings
from app.llm.client import LLMClient, LLMResult, Message
from app.llm.errors import LLMClientError
from app.schemas.support_request import (
SUPPORT_REQUEST_SCHEMA,
SUPPORT_REQUEST_SCHEMA_NAME,
)
DEVELOPER_INSTRUCTION = """
Проанализируй обращение клиента службы поддержки.
Используй только сведения из обращения.
Не придумывай номер заказа, товар и другие отсутствующие факты.
Определи категорию, срочность и недостающие сведения.
""".strip()
def build_messages(user_text: str) -> list[Message]:
return [
{
"role": "developer",
"content": DEVELOPER_INSTRUCTION,
},
{
"role": "user",
"content": (
"<customer_request>\n"
f"{user_text}\n"
"</customer_request>"
),
},
]
def print_result(result: LLMResult) -> None:
if result.refusal is not None:
print(f"Модель отказалась отвечать: {result.refusal}")
return
if result.finish_reason == "length":
print("Ответ остановлен из-за ограничения длины.")
return
if result.finish_reason != "stop":
print(
"Генерация завершилась с неожиданной причиной: "
f"{result.finish_reason}"
)
return
if result.text is None:
print("Модель не вернула структурированный результат.")
return
try:
payload = json.loads(result.text)
except json.JSONDecodeError:
print("Провайдер вернул строку с некорректным JSON.")
return
print("\nСтруктурированный результат:")
print(json.dumps(payload, 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}")
def main() -> None:
try:
settings = Settings()
except ValidationError as error:
print("Ошибка конфигурации:")
for issue in error.errors():
field = ".".join(str(part) for part in issue["loc"])
print(f"- {field}: {issue['msg']}")
return
user_text = input("Введите обращение клиента: ").strip()
if not user_text:
print("Обращение не должно быть пустым.")
return
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,
)
except LLMClientError as error:
print(f"Не удалось проанализировать обращение: {error}")
return
finally:
llm_client.close()
print_result(result)
if __name__ == "__main__":
main()
Давайте разберемся в данном коде. Многие моменты вам уже очень знакомы, поэтому сразу перейдем к функции print_result(), она реализует последовательность проверок, которую мы разобрали выше. При обнаружении неподходящего исхода она печатает понятное сообщение и сразу завершает работу с помощью return.
Сначала проверяется refusal, затем остановка по длине и остальные необычные значения finish_reason. После успешного stop функция убеждается, что поле text действительно заполнено.
Только после этого вызывается json.loads(result.text). В строгом режиме и при совместимой модели строка должна соответствовать схеме. Обработка JSONDecodeError остается защитой на случай нарушения ожидаемого протокола со стороны провайдера или несовместимого API.
json.loads() превращает JSON-строку в обычные объекты Python. Верхний объект становится словарем, массив missing_fields — списком, а значение null превращается в None. После этого json.dumps() используется только для красивого вывода словаря в консоль.
Теперь разберем функцию main(), она сначала загружает настройки и выводит уже знакомые сообщения об ошибках конфигурации. Затем она читает обращение пользователя и отклоняет пустую строку до создания сетевого запроса.
После этого создается общий LLMClient и вызывается новый метод generate_structured(). Мы передаем ему сообщения, техническое имя схемы и сам словарь JSON Schema. Ошибки клиента преобразуются в понятное сообщение, а блок finally закрывает HTTP-клиент независимо от результата запроса. Если запрос завершился без технической ошибки, полученный LLMResult передается в print_result(). Именно там отделяются успешный JSON, отказ модели и незавершенная генерация.
Перейдем к практическому тестированию нашего проекта, запустите его и введите следующий запрос:
В результате вы получите:
Структурированный результат:
{
"category": "technical_issue",
"order_id": "8041",
"product": "кофемашина",
"urgency": "normal",
"missing_fields": []
}
Модель: gpt-4.1-2025-04-14
Причина завершения: stop
Всего токенов: 788
ID ответа: chatcmpl-E9VPnNcUs07UECckzOmyq9z2M7ZNj
Запустите файл еще раз и не указывайте номер заказа:
Сейчас мы получим:
Структурированный результат:
{
"category": "return_request",
"order_id": null,
"product": "монитор",
"urgency": "normal",
"missing_fields": [
"order_id"
]
}
Модель: gpt-4.1-2025-04-14
Причина завершения: stop
Всего токенов: 774
ID ответа: chatcmpl-E9VQLP5jGKPByshstesGNYhew9ZFG
То есть при корректном анализе поле order_id будет содержать null, а в missing_fields появится order_id. Сам ключ при этом не исчезнет, потому что схема требует возвращать его в каждом успешном результате.
Наконец, попробуйте обращение другой категории:
Сейчас мы получим:
Структурированный результат:
{
"category": "payment_problem",
"order_id": "7718",
"product": null,
"urgency": "normal",
"missing_fields": [
"product"
]
}
Модель: gpt-4.1-2025-04-14
Причина завершения: stop
Всего токенов: 783
ID ответа: chatcmpl-E9VR3uN86L3QgZ8oqncMgSEaq6PVD
В этом случае мы видим, что значение category изменилось, но названия и типы полей останутся прежними.
Давайте подведем итоги этого раздела. В нем мы описали контракт через JSON Schema, передали его модели с помощью response_format и получили JSON с постоянной структурой. Также мы обработали общие исходы LLM-вызова: техническую ошибку, отказ модели и незавершенную генерацию. Но результат пока остается обычным словарем. IDE не подсказывает названия полей, Python не создает отдельный объект, а смысловые правила вроде «номер заказа должен действительно находиться во входном тексте» еще не проверяются.
Именно поэтому, в следующем разделе, мы добавим Pydantic-модели, превратим ответ в типизированный объект и отделим ошибку структуры от семантической ошибки.
Шаг 4¶
Что означает additionalProperties: false в схеме объекта?
Правильный ответ
- ✅ Объект не должен содержать поля, не перечисленные в properties
Шаг 5¶
Как в JSON Schema разрешить полю order_id содержать строку или null?
Правильный ответ
- ✅ {"type": ["string", "null"]}
Шаг 6¶
Какие элементы нужны для настройки строгого структурированного ответа модели через JSON Schema?
Правильный ответ
- ✅ strict: true
- ✅ Имя схемы и сама схема
- ✅ response_format.type = "json_schema"
Шаг 7¶
В каком порядке безопаснее обрабатывать результат структурированной генерации?
Правильный ответ
- ✅ Проверить отказ и причину завершения, убедиться в наличии текста, затем разобрать JSON
Шаг 8¶
JSON Schema для обращения в поддержку¶
Чтобы получать от модели предсказуемые данные, приложение передаёт ей JSON Schema с описанием допустимого результата. Схема определяет названия полей, их типы, обязательность и разрешённые значения. В этой задаче вы опишете результат анализа обращения в поддержку с категорией проблемы, номером заказа и списком недостающих сведений.
Задача¶
Реализуйте функцию build_support_schema(). Функция должна создать и вернуть обычный Python-словарь, представляющий JSON Schema. Вызывать модель, разбирать её ответ или проверять конкретное обращение в этой задаче не нужно.
На верхнем уровне схемы должны находиться:
type— тип всего результата;properties— словарь с описанием каждого поля;required— список обязательных полей;additionalProperties— разрешение или запрет неизвестных полей.
Требования¶
- Не изменяйте имя функции
build_support_schema. Функция не принимает аргументов. - Верните Python-словарь, а не JSON-строку.
- На верхнем уровне задайте
type="object". - В словаре
propertiesопишите ровно три поля:category,order_idиmissing_fields. - Для
categoryзадайтеtype="string"и списокenumсо значениями"technical_issue","delivery_problem","other"именно в таком порядке. - Для
order_idзадайте список допустимых типов["string", "null"]. Значение"null"означает, что номер заказа может отсутствовать. - Для
missing_fieldsзадайтеtype="array". - Через
itemsукажите, что каждый элементmissing_fieldsявляется строкой. - Внутри
itemsзадайте списокenumсо значениями"order_id"и"problem_description"именно в таком порядке. - В список
requiredдобавьте"category","order_id"и"missing_fields"именно в таком порядке. Поле остаётся обязательным, даже если его допустимым значением может бытьnull. - Запретите любые другие поля с помощью
additionalProperties=False.
Пример результата¶
schema = build_support_schema()
print(schema["type"])
print(schema["required"])
print(schema["properties"]["order_id"])
print(schema["properties"]["missing_fields"]["items"]["enum"])
Результат:
object
['category', 'order_id', 'missing_fields']
{'type': ['string', 'null']}
['order_id', 'problem_description']
Заготовка функции уже находится в редакторе. Дополнительные импорты и сторонние библиотеки не нужны.
Правильный ответ
def build_support_schema() -> dict:
return {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"technical_issue",
"delivery_problem",
"other",
],
},
"order_id": {
"type": ["string", "null"],
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"enum": [
"order_id",
"problem_description",
],
},
},
},
"required": [
"category",
"order_id",
"missing_fields",
],
"additionalProperties": False,
}
Шаг 9¶
Формирование запроса со строгой JSON Schema¶
В Structured Outputs схема передаётся модели внутри параметра response_format. Клиент в этой задаче фейковый: он не отправляет запрос в сеть, а только записывает полученные аргументы. Нужно правильно собрать вложенную структуру параметров и вернуть ответ клиента.
Задача¶
Реализуйте функцию request_structured(client, model, messages, schema_name, schema). Она должна проверить название модели и схемы, один раз вызвать client.chat.completions.create() и вернуть полученный объект ответа.
Требования¶
- Не изменяйте имя функции и набор аргументов.
- Удалите внешние пробелы из строк
modelиschema_name. - Если после очистки
modelилиschema_nameявляется пустой строкой, выбросьтеValueErrorдо обращения к клиенту. - Вызовите
client.chat.completions.create()ровно один раз. - Передайте в аргумент
modelочищенное название модели. - Передайте в аргумент
messagesисходный список сообщений без изменений. - Передайте аргумент
response_formatв виде словаря сtype="json_schema". - Внутри
response_formatпо ключуjson_schemaразместите вложенный словарь. - Во вложенном словаре задайте
name, равный очищенномуschema_name,strict=Trueиschema, равный исходному аргументуschema. - Не разбирайте и не изменяйте ответ. Верните тот же объект, который вернул метод
create().
Пример результата¶
answer = request_structured(
client,
" fake-model ",
messages,
" support_request ",
schema,
)
print(client.calls[0]["model"])
print(client.calls[0]["response_format"]["type"])
print(client.calls[0]["response_format"]["json_schema"]["name"])
Результат:
Проверяющая система предоставляет фейковый client, список messages и словарь schema. Настоящий API-ключ и сетевой доступ не нужны.
Правильный ответ
def request_structured(client, model, messages, schema_name, schema):
model = model.strip()
schema_name = schema_name.strip()
if not model or not schema_name:
raise ValueError
return client.chat.completions.create(
model=model,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"strict": True,
"schema": schema,
},
},
)
Шаг 10¶
Проверка и разбор структурированного ответа¶
Структурированный ответ модели нельзя сразу передавать в json.loads(). Сначала приложение должно убедиться, что модель не отказалась отвечать, генерация завершилась штатно и в результате действительно есть текст. После этого JSON нужно разобрать и проверить, что на верхнем уровне получен объект, то есть Python-словарь.
Задача¶
Реализуйте функцию parse_structured_result(result). Она должна проверить переданный результат модели, преобразовать поле result.text из JSON в Python-объект и вернуть словарь с данными.
Объект result уже создан проверяющей системой и содержит три поля: refusal, finish_reason и text.
Требования¶
- Не изменяйте имя функции. Она принимает один аргумент
result. - До разбора JSON проверьте поля
refusal,finish_reasonиtext. - Если
result.refusalне равенNone, выбросьтеValueError. Текст отказа разбирать нельзя. - Допускайте только
result.finish_reason == "stop". Для любого другого значения, включая"length"и"tool_calls", выбросьтеValueError. result.textдолжен быть непустой строкой. ДляNone, значения другого типа или пустой строки выбросьтеValueError.- Преобразуйте
result.textс помощьюjson.loads(). Некорректный JSON должен приводить кValueError. - Проверьте результат разбора. На верхнем уровне должен находиться JSON-объект, представленный типом
dict. - Если на верхнем уровне получен список, строка, число, логическое значение или
None, выбросьтеValueError, даже если исходная строка является корректным JSON. - Если все проверки пройдены, верните полученный словарь без изменений.
Пример результата¶
Пусть result.refusal равен None, result.finish_reason равен "stop", а result.text содержит '{"category": "other"}'.
Результат:
Строка '["other"]' является корректным JSON, но содержит массив. Для неё функция должна выбросить ValueError.
Импорт json, объект result и заготовка функции уже предоставлены. Обращаться к настоящей модели не нужно.
Правильный ответ
import json
def parse_structured_result(result) -> dict:
if result.refusal is not None:
raise ValueError
if result.finish_reason != "stop":
raise ValueError
if not isinstance(result.text, str) or not result.text.strip():
raise ValueError
try:
data = json.loads(result.text)
except json.JSONDecodeError:
raise ValueError
if not isinstance(data, dict):
raise ValueError
return data