# Shopping Assistant API

На этой странице описаны контракты Shopping Assistant API: отправка сообщений, обработка структурированного ответа, восстановление истории и tracking товарных подборок.

Перед работой с endpoint-ами выполните шаги из [гайда по API-интеграции](./integration.md). Настройка авторизации, пользователей, контекста и API-кампании выполняется через [Gravity Field API V2](/Integration/api_integration/v2/).

## `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";
  messages: Array<{
    role: "user";
    content:
      | string
      | Array<ShoppingTextContentPart | ShoppingImageContentPart>;
  }>;
  runtimeContext: {
    tenant_id: string;
    ctx?: {
      type?: string;
      data?: unknown;
      lng?: string | null;
      location?: string | null;
    };
  };
};
```

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

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

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

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

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

В поле `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": "670ccaae56afcafaf808c146",
    "ctx": {
      "type": "PRODUCT",
      "data": ["1066109"]
    }
  }
}
```

---

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

### Запрос

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

### Ответ

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

---

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

```json
{
  "threadId": "thread-8f2b1c",
  "resourceId": "665f0a000000000000000001",
  "response": {
    "contents": [
      {
        "type": "message",
        "value": "Вот несколько подходящих вариантов. Смотрите на возраст, стерилизацию и чувствительность пищеварения."
      },
      {
        "type": "products",
        "title": "Подходящие корма для взрослых кошек",
        "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;
  response: {
    contents: Array<
      | { type: "message"; value: string }
      | { type: "products"; value: Product[]; title?: string }
      | { type: "button"; value: string; action: string }
    >;
    ui?: {
      suggests: string[];
    };
  };
  events?: EngagementEvent[];
};

type Product = {
  sku?: 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[];
};
```

Для кнопок клиентская сторона должна обработать `action`. Например, `open_support_chat` можно связать с открытием клиентского чата поддержки.

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

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

---

## `POST /shopping/history`

Чтобы восстановить сообщения после перезагрузки страницы или повторного открытия чата, вызовите:

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

Endpoint возвращает только публичные пользовательские сообщения и ответы Shopping Assistant. Внутренние system-сообщения, вызовы инструментов и служебные данные в ответ не попадают.

### Запрос истории

```bash
curl --request POST \
  --url 'https://shopping-assistant-api.gravityfield.ai/shopping/history' \
  --header 'Content-Type: application/json' \
  --data '{
    "tenant_id": "670ccaae56afcafaf808c146",
    "resourceId": "665f0a000000000000000001",
    "threadId": "thread-8f2b1c",
    "page": 1,
    "limit": 50
  }'
```

| Поле | Обязательное | Назначение |
| :--- | :--- | :--- |
| `tenant_id` | Да | ID секции Gravity Field. Должен совпадать с `runtimeContext.tenant_id`, который использовался при создании диалога. |
| `resourceId` | Да | `uid` пользователя Gravity Field, которому принадлежит диалог. |
| `threadId` | Да | ID диалога, историю которого нужно получить. |
| `page` | Нет | Номер страницы, начиная с `1`. По умолчанию `1`. |
| `limit` | Нет | Количество сообщений на странице: от `1` до `100`. По умолчанию `50`. |

История доступна только при точном совпадении `tenant_id`, `resourceId` и `threadId`. Если диалог не найден или не принадлежит указанной секции или пользователю, API возвращает одинаковый ответ `404`.

### Ответ

```json
{
  "threadId": "thread-8f2b1c",
  "resourceId": "665f0a000000000000000001",
  "items": [
    {
      "id": "shopping:user:4f8c...",
      "role": "user",
      "content": "Помогите выбрать корм для взрослой кошки",
      "createdAt": "2026-07-28T10:00:00.000Z"
    },
    {
      "id": "shopping:assistant:8a21...",
      "role": "assistant",
      "createdAt": "2026-07-28T10:00:01.250Z",
      "threadId": "thread-8f2b1c",
      "resourceId": "665f0a000000000000000001",
      "response": {
        "contents": [
          {
            "type": "message",
            "value": "Кошка стерилизована и есть ли особенности по здоровью?"
          }
        ],
        "ui": {
          "suggests": [
            "Стерилизована, без особенностей",
            "Не стерилизована",
            "Есть особенности по здоровью"
          ]
        }
      },
      "responseLatencyMs": 1250
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "hasMore": false
  }
}
```

Элементы в `items` отсортированы по времени создания от ранних к поздним в пределах страницы:

- для `role: "user"` поле `content` имеет тот же формат, что в запросе `/shopping/generate`: строка для текста или массив блоков `text` и `image`;
- для `role: "assistant"` поле `response` имеет тот же контракт, что ответ `/shopping/generate`; дополнительно могут присутствовать `responseLatencyMs` и `events`;
- если `pagination.hasMore` равно `true`, запросите следующую страницу, увеличив `page` на `1`.

Для сообщения с изображением history endpoint возвращает блок `image` с Base64. Для отображения превью соберите data URL из `source.mediaType` и `source.data`, но не передавайте это сообщение повторно в `/shopping/generate`.

```js
const previewUrl =
  `data:${image.source.mediaType};base64,${image.source.data}`;
```

Ответ содержит заголовок `Cache-Control: no-store`. Не сохраняйте историю в общем или публичном кеше на клиентской стороне.

### Ошибки получения истории

| HTTP-статус | Причина | Что делать клиенту |
| :--- | :--- | :--- |
| `400` | Некорректный JSON, отсутствует обязательное поле или `page`/`limit` выходит за допустимый диапазон. | Проверить payload перед повторной отправкой. |
| `404` | Диалог не найден или связка `tenant_id`, `resourceId` и `threadId` не совпадает. Код ответа: `SHOPPING_HISTORY_NOT_FOUND`. | Начать новый диалог или проверить сохраненные идентификаторы. |
| `500` | Историю не удалось загрузить. | Показать ошибку и предложить повторить запрос. |

---

## Tracking из ответа Shopping Assistant

В ответе Shopping Assistant tracking-события могут находиться на двух уровнях:

| Уровень | Где искать | Когда использовать |
| :--- | :--- | :--- |
| Рекомендательный блок | `events[]` в корне ответа | Для событий всего блока, например видимого показа `WRIMP`. |
| Товарная карточка | `response.contents[type="products"].value[].events[]` | Для `visible_impression` и `click` конкретного товара. |

При наступлении события обработайте **все** элементы `events[]` с соответствующим `type`. Для каждого найденного элемента выполните `GET`-запрос по **каждому** URL из `urls[]`. Не ограничивайтесь первым элементом `events[]` или первым URL: один факт взаимодействия может требовать нескольких tracking-запросов.

```js
async function sendTrackingEvents(events, type) {
  const urls = events
    .filter((event) => event.type === type)
    .flatMap((event) => event.urls);

  await Promise.allSettled(
    urls.map((url) => fetch(url, { method: "GET", keepalive: true }))
  );
}

// Весь рекомендательный блок стал видимым.
sendTrackingEvents(result.events ?? [], "WRIMP");

// Стала видимой конкретная карточка товара.
sendTrackingEvents(product.events ?? [], "visible_impression");
```

Отправляйте событие видимого показа один раз для одного фактического показа блока или карточки. Ошибка tracking-запроса не должна блокировать отображение рекомендаций или действие пользователя. Общие правила работы с tracking URL описаны в разделе [Передача взаимодействий пользователя с кампаниями](/Integration/api_integration/v2/personalization/engagement.md).

---

## Ошибки `/shopping/generate` и повтор запроса

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

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