# Генерация ответа

Отправьте новое сообщение пользователя и отобразите блоки `response.contents`. Для пошагового запуска используйте [API-интеграцию](../api.md); для параметров доступа и идентификаторов — [обзор API](overview.md).

## `POST /shopping/generate`

```http
POST https://shopping-assistant-api.gravityfield.ai/shopping/generate
Content-Type: application/json
```

Endpoint принимает новое сообщение пользователя и возвращает текстовые блоки, товарные карточки, кнопки действий и быстрые ответы. Готовый UI чата API не возвращает.

Для всех сообщений одного диалога используйте одинаковые `threadId` и `resourceId`. В `resourceId` передавайте `user.uid`, полученный через Gravity Field API V2. Историю диалога повторно отправлять не нужно: Shopping Assistant сохраняет контекст по этим идентификаторам.

Для обычной интеграции используйте роль `user`.

### Контракт запроса

```ts
type ShoppingTextContentPart = {
  type: "text";
  text: string;
};

type ShoppingImageContentPart = {
  type: "image";
  source:
    | {
        type: "url";
        url: string;
      }
    | {
        type: "base64";
        mediaType: "image/jpeg" | "image/png" | "image/webp";
        data: string;
      };
};

type ShoppingGenerateRequest = {
  threadId: string;
  resourceId: string;
  trafficType?: "real" | "test" | "debug";
  messages: Array<{
    role: "user";
    content:
      | string
      | Array<ShoppingTextContentPart | ShoppingImageContentPart>;
  }>;
  runtimeContext: {
    tenant_id: string;
    ctx?: {
      type?: string;
      data?: unknown;
      lng?: string | null;
      location?: string | null;
      attributes?: {
        storeId?: string;
        [key: string]: unknown;
      };
    };
  };
};
```

### Поля запроса

| Поле | Обязательное | Назначение |
| :--- | :--- | :--- |
| `threadId` | Да | ID диалога. Должен оставаться одинаковым для всех сообщений одной чат-сессии. |
| `resourceId` | Да | `uid` пользователя Gravity Field, сохраненный на этапе API-интеграции. |
| `trafficType` | Нет | `real` для боевого трафика, `test` для тестовых сценариев или [`debug`](#режим-отладки) для проверки интеграции на фиксированном ответе без обращения к LLM. Без поля тип трафика остаётся неизвестным. |
| `messages` | Да | Текущее сообщение пользователя. Обычно передается массив с одним элементом. `content` может быть строкой или массивом блоков `text` и `image`. |
| `messages[].id` | Нет | ID нового сообщения пользователя. Для повтора сохраните исходный ID; для нового сообщения создайте новый. |
| `runtimeContext.tenant_id` | Да | ID секции Gravity Field. |
| `runtimeContext.ctx` | Нет | Контекст текущего экрана или страницы. |
| `runtimeContext.ctx.attributes.storeId` | Для сценария наличия в выбранном магазине | Стабильный ID точки из справочника магазинов. Передавайте, если покупатель выбрал магазин в интерфейсе. |

### Как передать изображение

Чтобы отправить изображение, передайте в `content` массив блоков. В одном пользовательском сообщении поддерживается **только одно изображение**. Вместе с ним можно передать текстовый блок с пояснением:

```json
{
  "threadId": "thread-8f2b1c",
  "resourceId": "demo-user",
  "trafficType": "test",
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "Найдите похожий диван в более светлом цвете"
        },
        {
          "type": "image",
          "source": {
            "type": "base64",
            "mediaType": "image/jpeg",
            "data": "/9j/4AAQSkZJRgABAQ..."
          }
        }
      ]
    }
  ],
  "runtimeContext": {
    "tenant_id": "demo-section"
  }
}
```

В поле `data` передавайте только Base64-содержимое файла, без префикса `data:image/jpeg;base64,`. Значение `mediaType` должно соответствовать фактическому формату файла.

Изображение также можно передать по URL. Замените блок `image` в примере выше:

```json
{
  "type": "image",
  "source": {
    "type": "url",
    "url": "https://cdn.example.com/user-photo.webp"
  }
}
```

Текст необязателен: для запроса только по изображению передайте в `content` массив с одним блоком `image`.

### Ограничения для изображения

| Ограничение | Требование |
| :--- | :--- |
| Количество | Не более одного изображения в одном пользовательском сообщении. |
| Форматы | JPEG, PNG или WebP: `image/jpeg`, `image/png`, `image/webp`. |
| Размер файла | Не более 5 МиБ до Base64-кодирования. |
| Разрешение | Не более 16 000 000 пикселей: ширина × высота. |
| Base64 | Только корректный raw Base64 без `data:`-префикса. MIME-тип должен совпадать с содержимым файла. |
| URL | Публичный URL по HTTPS без логина и пароля. URL должен возвращать поддерживаемое изображение с корректным `Content-Type`; допускается не более трех перенаправлений, таймаут загрузки - 10 секунд. |

После проверки Shopping Assistant учитывает EXIF-ориентацию, уменьшает изображение до размера не более 2048 × 2048 пикселей без увеличения и преобразует его в JPEG для обработки.

### Как заполнять `ctx`

`runtimeContext.ctx` использует модель контекста API V2. Передавайте актуальный контекст, согласованный с `/visit` и `/choose`. Поддерживаемые типы и формат `data` описаны в разделе [Контекст API V2](/Integration/api_integration/v2/context.md).

Если чат открыт на карточке товара, передайте SKU товара в контексте:

```json
{
  "runtimeContext": {
    "tenant_id": "demo-section",
    "ctx": {
      "type": "PRODUCT",
      "data": ["1066109"]
    }
  }
}
```

Если покупатель выбрал магазин, передавайте его `storeId` в `runtimeContext.ctx.attributes.storeId` вместе с каждым запросом `/shopping/generate`, в том числе после перехода на другую страницу:

```json
{
  "runtimeContext": {
    "tenant_id": "demo-section",
    "ctx": {
      "type": "PRODUCT",
      "data": ["1066109"],
      "attributes": {
        "storeId": "kzn-014"
      }
    }
  }
}
```

Используйте точный `storeId` из справочника магазинов. Если магазин не выбран, не передавайте поле с пустой строкой; ассистент уточнит точку по сообщению пользователя или задаст вопрос. `ctx.lng` обозначает зону обслуживания для фида и поиска и не заменяет `storeId`. Передача `storeId` сама по себе не включает проверку наличия: для неё требуется подключить [API наличия товаров в магазинах](../data/store_availability_api.md) и согласовать обработку этого поля в коннекторе.

### Режим отладки

Используйте `trafficType: "debug"`, чтобы проверить вызов endpoint-а и отображение ответа в клиентском интерфейсе без обращения к LLM и расхода токенов. В этом режиме Shopping Assistant возвращает одну и ту же заглушку независимо от текста сообщения и настроек секции.

Запрос по-прежнему должен соответствовать контракту: передайте непустые `threadId`, `resourceId`, `runtimeContext.tenant_id` и хотя бы одно сообщение пользователя.

```bash
curl --request POST \
  --url 'https://shopping-assistant-api.gravityfield.ai/shopping/generate' \
  --header 'Content-Type: application/json' \
  --data '{
    "threadId": "debug-thread-1",
    "resourceId": "debug-user-1",
    "trafficType": "debug",
    "messages": [
      {
        "role": "user",
        "content": "Проверка интеграции"
      }
    ],
    "runtimeContext": {
      "tenant_id": "demo-section"
    }
  }'
```

Ответ содержит текст, два тестовых товара, три быстрых ответа и тестовое tracking-событие:

```json
{
  "threadId": "debug-thread-1",
  "resourceId": "debug-user-1",
  "assistantMessageId": "shopping:assistant:8a21...",
  "response": {
    "contents": [
      {
        "type": "message",
        "value": "Debug response"
      },
      {
        "type": "products",
        "title": "Debug products",
        "shelf_id": "debug-products",
        "source_query": "debug",
        "value": [
          {
            "sku": "debug-product-1",
            "name": "Debug product 1",
            "brand": "Debug brand",
            "price": 100
          },
          {
            "sku": "debug-product-2",
            "name": "Debug product 2",
            "brand": "Debug brand",
            "price": 200
          }
        ]
      }
    ],
    "ui": {
      "suggests": [
        "Debug suggestion 1",
        "Debug suggestion 2",
        "Debug suggestion 3"
      ]
    }
  },
  "responseLatencyMs": 100,
  "events": [
    {
      "type": "visible_impression",
      "url": "https://runtime.test/widget"
    }
  ]
}
```

Значения `responseLatencyMs` и `events` в этом ответе являются частью заглушки: они не отражают фактическую задержку и не фиксируют аналитику. Debug-запрос не запускает генерацию и обработку изображений, не сохраняет сообщения и не появляется в истории. Не отправляйте оценку такого ответа через `/shopping/feedback`.

После проверки интеграции замените `debug` на `test` или `real`.

---

### Минимальный пример

#### Запрос

```bash
curl --request POST \
  --url 'https://shopping-assistant-api.gravityfield.ai/shopping/generate' \
  --header 'Content-Type: application/json' \
  --data '{
    "threadId": "thread-8f2b1c",
    "resourceId": "demo-user",
    "trafficType": "test",
    "messages": [
      {
        "role": "user",
        "content": "Помогите выбрать корм для взрослой кошки"
      }
    ],
    "runtimeContext": {
      "tenant_id": "demo-section"
    }
  }'
```

#### Ответ

```json
{
  "threadId": "thread-8f2b1c",
  "resourceId": "demo-user",
  "assistantMessageId": "shopping:assistant:8a21...",
  "response": {
    "contents": [
      {
        "type": "message",
        "value": "Подберу корм, но сначала уточню пару деталей. Кошка стерилизована и есть ли особенности по здоровью?"
      }
    ],
    "ui": {
      "suggests": [
        "Стерилизована, без особенностей",
        "Не стерилизована, чувствительное пищеварение",
        "Пожилая кошка, нужен мягкий корм"
      ]
    }
  }
}
```

---

### Пример ответа с товарами

```json
{
  "threadId": "thread-8f2b1c",
  "resourceId": "demo-user",
  "response": {
    "contents": [
      {
        "type": "message",
        "value": "Вот несколько подходящих вариантов. Смотрите на возраст, стерилизацию и чувствительность пищеварения."
      },
      {
        "type": "products",
        "title": "Подходящие корма для взрослых кошек",
        "categories": [
          "Зоотовары",
          "Корма для кошек"
        ],
        "value": [
          {
            "sku": "1066109",
            "name": "Сухой корм для взрослых кошек",
            "price": 1299,
            "features": "Для взрослых кошек; повседневный рацион",
            "is_stm": false,
            "brand": "Demo Brand",
            "url": "https://example.com/product/1066109",
            "image_url": "https://example.com/product/1066109.jpg",
            "events": [
              {
                "type": "visible_impression",
                "urls": [
                  "https://evs-01.gravityfield.ai/v2/engagement?...",
                  "https://evs-02.gravityfield.ai/v2/engagement?..."
                ]
              },
              {
                "type": "click",
                "urls": [
                  "https://evs-01.gravityfield.ai/v2/engagement?..."
                ]
              }
            ]
          },
          {
            "sku": "1023814",
            "name": "Корм для стерилизованных кошек",
            "price": 1599,
            "features": "Для стерилизованных кошек; контроль веса",
            "is_stm": true
          }
        ]
      },
      {
        "type": "message",
        "value": "Если кошка стерилизована, лучше начать со второго варианта. Если нет, подойдет первый."
      }
    ],
    "ui": {
      "suggests": [
        "Показать варианты дешевле",
        "Нужен влажный корм",
        "У кошки чувствительное пищеварение"
      ]
    }
  },
  "events": [
    {
      "type": "WRIMP",
      "urls": [
        "https://evs-01.gravityfield.ai/v2/engagement?...",
        "https://evs-02.gravityfield.ai/v2/engagement?..."
      ]
    }
  ]
}
```

---

### Контракт ответа

```ts
type ShoppingGenerateResponse = {
  threadId: string;
  resourceId: string;
  assistantMessageId?: string;
  messageFormat?: "plain_text" | "markdown";
  response: {
    contents: Array<
      | { type: "message"; value: string }
      | { type: "products"; value: Product[]; title?: string; categories?: string[]; shelf_id?: string; source_query?: string }
      | { type: "button"; value: string; action: string; url?: string }
    >;
    ui?: {
      suggests: string[];
    };
  };
  responseLatencyMs?: number;
  events?: EngagementEvent[];
};

type Product = {
  sku?: string;
  category_id?: string;
  name: string;
  price?: number | null;
  features?: string;
  is_stm?: boolean;
  brand?: string;
  url?: string;
  image_url?: string;
  events?: EngagementEvent[];
};

type EngagementEvent =
  | { type: string; urls: string[] }
  | { type: string; url: string };
```

Поле `assistantMessageId` содержит ID созданного ответа ассистента. Сохраните его вместе с сообщением в UI: этот ID нужен для отправки оценки через `/shopping/feedback`.

`messageFormat` задаёт формат текста. Для `plain_text` отображайте текст без интерпретации HTML. Для `markdown` используйте Markdown-рендерер с безопасной обработкой ссылок и HTML; если поле отсутствует, уточните формат подключённой секции. `responseLatencyMs` — длительность ответа сервиса, а не время отображения на устройстве покупателя.

Кнопка перехода к оператору приходит в массиве `response.contents` в следующем формате:

```json
{
  "type": "button",
  "value": "Позвать оператора",
  "action": "open_support_chat"
}
```

Поле `value` содержит текст кнопки, а `action: "open_support_chat"` обозначает переход к оператору. Shopping Assistant API только возвращает идентификатор действия. Сам переход выполняется клиентским каналом после нажатия кнопки.

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

Поле `categories` в блоке `products` содержит путь категории товарного блока: от общей категории к более конкретной. Например, `["Диваны и кресла", "Диваны"]`. Поле опционально и появляется в ответе после согласования с командой сопровождения Gravity Field.

Для товарных карточек считайте `sku` основным идентификатором товара. Поля `price`, `brand`, `url`, `image_url` и другие данные могут использоваться для быстрого отображения карточки, но актуализация цены, изображения, наличия, открытие PDP и добавление в корзину остаются на стороне клиентского канала или клиентского каталога.

`category_id` — необязательный идентификатор категории товара. Поле `categories` у блока `products` содержит путь категории и не заменяет этот идентификатор. Для кнопки с `action: "follow_url"` поле `url` содержит HTTP(S)-адрес перехода; `open_support_chat` передаётся обработчику действий приложения.

### Типы событий `EngagementEvent`

`EngagementEvent` содержит готовые tracking URL для учета показов и кликов по рекомендациям. Поле `type` определяет, при каком действии нужно вызвать эти URL. Само получение ответа API не означает, что пользователь увидел подборку или товар.

Корневой `events[]` может содержать события рекомендательного блока и ссылки оценки ответа. Массив `events[]` внутри товара в блоке `products` относится к конкретной карточке.

| `type` | Что означает | Когда отправлять |
| :--- | :--- | :--- |
| `IMP` | Показ рекомендательного блока: блок отрисован в интерфейсе. | После первого фактического рендера блока, если событие вернулось в корневом `events[]`. Попадание в область видимости для этого события не требуется. |
| `WRIMP` | Видимый показ рекомендательного блока. | Когда блок виден пользователю как минимум на 50% в течение не менее 1 секунды. Используйте событие из корневого `events[]`. |
| `visible_impression` | Видимый показ конкретной товарной карточки; в корневом `events[]` — видимый показ всего блока. | Когда карточка стала видима как минимум на 50%. Для события всего блока применяйте условие видимости `WRIMP`. |
| `click` | Клик по конкретному товару из подборки. | Когда пользователь нажал на товарную карточку. Используйте `events[]` этой карточки. |
| `like` | Положительная оценка ответа. | Только при нажатии «Нравится». Вызовите готовый `url` методом GET. |
| `dislike` | Отрицательная оценка ответа. | Только при нажатии «Не нравится». Вызовите готовый `url` методом GET. |

Обрабатывайте только события, которые пришли в ответе, и сохраняйте регистр `type`: `WRIMP` и `visible_impression` — разные значения. Поле оставлено строковым в контракте, поскольку набор событий зависит от рекомендательной кампании.

При наступлении нужного действия найдите **все** события с соответствующим `type` на нужном уровне. Для каждого события выполните `GET`-запрос по **каждому** адресу из `urls[]`; если вместо массива пришло поле `url`, вызовите этот единственный адрес. Используйте URL как есть: параметры трекинга уже включены в него. Tracking URL фиксирует событие; для перехода на страницу товара используйте `Product.url`.

Отправляйте показ и видимый показ один раз для одного фактического показа блока или карточки, без повторов при каждой перерисовке. `click` отправляйте при каждом клике пользователя. Ошибка tracking-запроса не должна блокировать интерфейс.

Ссылки `like` и `dislike` не являются событиями показа. Не отправляйте весь массив при рендеринге. См. [Оценка ответа](feedback.md).

Пример обработки `events[]` и события воронки диалога описаны в разделе [Трекинг событий](../tracking.md#tracking-товарных-подборок).

### Ошибки и повтор запроса

| HTTP-статус | Причина | Что делать клиенту |
| :--- | :--- | :--- |
| `400` | Некорректный JSON, тело запроса не соответствует контракту, передано больше одного изображения или некорректный Base64/URL. | Проверить payload перед повторной отправкой. |
| `403` | IP не разрешён для секции. | Проверить условия доступа с командой Gravity Field. |
| `409` | `MESSAGE_ID_CONFLICT`: ID сообщения уже использован для другого запроса. | Для повтора передать исходные данные и ID; для нового сообщения создать новый ID. |
| `413` | Изображение превышает лимит 5 МиБ или 16 000 000 пикселей. | Попросить пользователя выбрать или подготовить изображение меньшего размера. |
| `415` | Формат изображения не поддерживается или MIME-тип не соответствует содержимому файла. | Разрешить только JPEG, PNG и WebP и проверить MIME-тип. |
| `422` | Файл изображения поврежден или его невозможно обработать. | Попросить пользователя выбрать другое изображение. |
| `500` | Техническая ошибка генерации ответа. | Показать ошибку и предложить повторить запрос. |
| `502` | Ответ ассистента не прошел структурную валидацию. | Показать сообщение вроде "Не удалось получить ответ, попробуйте еще раз". |
| `504` | Shopping Assistant не успел загрузить изображение по URL. | Проверить доступность URL и повторить запрос. |

Для повтора того же сообщения сохраняйте `messages[].id`, `threadId`, `resourceId` и исходные данные запроса. Без стабильного ID повтор после потери ответа может создать второе сообщение.

Показывайте индикатор загрузки до получения ответа, не создавайте новый `threadId` при повторе того же пользовательского сообщения и защищайтесь от двойной отправки одинакового сообщения при нестабильной сети.

---
