# AI-ассистент для сделки

**Сложность:** продвинутый | **Скоупы:** crm, task | **Стек:** cURL / JavaScript

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

## Что понадобится

- API-ключ Вайбкод со скоупами `crm` и `task`
- Node.js 18 или новее

Доступ к моделям платформы даёт скоуп `vibe:ai` — проверьте, что он есть у ключа. В форме создания ключа он отмечен заранее. Внешняя учётная запись у поставщика моделей нужна только если вы выбираете свою модель вместо платформенной (шаг 3).

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_API_KEY` — ваш API-ключ.

## Как устроено решение

1. Читаем карточку сделки — название, стадию, сумму, ответственного.
2. Читаем историю: звонки, встречи и письма, привязанные к этой сделке.
3. Собираем короткое текстовое описание и отдаём модели, требуя ответ строго в JSON.
4. Из полей ответа создаём задачу и привязываем её к сделке.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Карточка сделки

[`GET /v1/deals/:id`](/docs/entities/deals/get) отдаёт сделку целиком. Модели нужна малая часть полей, поэтому берём их выборочно.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/deals/741"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: deal } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "id": 741,
    "title": "Поставка оборудования",
    "stageId": "NEW",
    "stageSemanticId": "P",
    "amount": 1010,
    "currency": "RUB",
    "assignedById": 29,
    "createdAt": "2026-03-28T12:55:01.000Z",
    "closed": false
  }
}
```

Поле `assignedById` — ответственный за сделку. На него же будет назначена задача из шага 4, отдельно выбирать сотрудника не нужно.

Идентификатор стадии `stageId` понятен модели хуже, чем название стадии на портале. Названия стадий отдаёт `GET /v1/statuses` с фильтром `entityId=DEAL_STAGE` — подставляйте в текст название, а не код.

## Шаг 2. История сделки

Звонки, встречи и письма по сделке возвращает [`POST /v1/activities/search`](/docs/entities/activities/search). Привязку задаёт пара `ownerTypeId` и `ownerId`: `2` — сделка, `3` — контакт, `4` — компания, `1` — лид.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/activities/search" \
  -d '{
    "filter": { "ownerTypeId": 2, "ownerId": 741 },
    "select": ["id", "subject", "typeId", "createdAt", "completed"],
    "sort": { "createdAt": "desc" },
    "limit": 20
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/activities/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { ownerTypeId: 2, ownerId: dealId },
    select: ['id', 'subject', 'typeId', 'createdAt', 'completed'],
    sort: { createdAt: 'desc' },
    limit: 20,
  }),
})
const { data: activities } = await res.json()
```

```json
{
  "success": true,
  "data": [
    {
      "id": 3631,
      "subject": "Звонок по условиям поставки",
      "typeId": 1,
      "createdAt": "2026-07-02T06:10:32.000Z",
      "completed": true
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 903 }
}
```

Значения `typeId`: `1` — звонок, `2` — встреча, `3` — задача, `6` — письмо. Число записей — в `meta.total`, признак «есть ещё» — `meta.hasMore`.

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

## Шаг 3. Разбор моделью

Модели платформы вызываются тем же ключом, что и CRM — [`POST /v1/ai/chat/completions`](/docs/ai/chat/completions). Список доступных идентификаторов отдаёт [`GET /v1/ai/models`](/docs/ai/models/list).

Ответ нужен машине, а не человеку, поэтому запрашиваем [гарантированный JSON](/docs/ai/chat/json): `response_format` со значением `json_object` заставляет модель вернуть корректный JSON, а структуру полей описываем в системном сообщении.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/ai/chat/completions" \
  -d '{
    "model": "bitrix/bitrixgpt-5.5",
    "messages": [
      {
        "role": "system",
        "content": "Ты помогаешь менеджеру по продажам. Верни JSON: {\"analysis\": \"две-три фразы о ситуации\", \"taskTitle\": \"краткое название задачи\", \"taskDescription\": \"что именно сделать\", \"priority\": \"high|normal|low\", \"deadlineDays\": число}"
      },
      {
        "role": "user",
        "content": "Сделка: Поставка оборудования. Стадия: Новая. Сумма: 1010 RUB.\nИстория:\n- 02.07 звонок по условиям поставки (завершён)"
      }
    ],
    "response_format": { "type": "json_object" },
    "temperature": 0.3
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'bitrix/bitrixgpt-5.5',
    messages: [
      { role: 'system', content: SYSTEM_PROMPT },
      { role: 'user', content: describe(deal, activities) },
    ],
    response_format: { type: 'json_object' },
    temperature: 0.3,
  }),
})
const completion = await res.json()
const advice = JSON.parse(completion.choices[0].message.content)
```

```json
{
  "id": "chatcmpl-9b8b1933947bb4fa",
  "object": "chat.completion",
  "model": "bitrix/bitrixgpt-5.5",
  "choices": [
    {
      "index": 0,
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "{\"analysis\":\"Сделка на начальной стадии. Требуется уточнение деталей заказа и переход к коммерческому предложению.\",\"taskTitle\":\"Подготовить и отправить коммерческое предложение\",\"taskDescription\":\"Сформировать предложение по условиям, обсуждённым в звонке 02.07, и отправить клиенту на согласование.\",\"priority\":\"normal\",\"deadlineDays\":2}"
      }
    }
  ],
  "usage": { "prompt_tokens": 145, "completion_tokens": 93, "total_tokens": 238 }
}
```

Ответ приходит в формате, совместимом с OpenAI, — без обёртки `{ success, data }`, которая есть у остальных эндпоинтов Вайбкод. Текст лежит в `choices[0].message.content` строкой, её надо разобрать через `JSON.parse`.

Свою модель у внешнего поставщика подключают тем же кодом: формат запроса и ответа совпадает, меняются адрес и ключ в заголовке. Как подключить собственный ключ поставщика — [свои ключи](/docs/ai/credentials).

Опишите в системном сообщении каждое поле, которое собираетесь читать. Поле, которого нет в описании, модель вернёт под другим именем или не вернёт вовсе, и `advice.taskTitle` окажется `undefined` уже на этапе создания задачи.

## Шаг 4. Задача из ответа модели

[`POST /v1/tasks`](/docs/entities/tasks/create) создаёт задачу. Привязку к сделке задаёт поле `ufCrmTask` — массив строк с префиксом сущности: `D_` для сделки, `C_` для контакта, `CO_` для компании, `L_` для лида.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks" \
  -d '{
    "title": "Подготовить и отправить коммерческое предложение",
    "description": "Сформировать предложение по условиям, обсуждённым в звонке, и отправить клиенту на согласование.",
    "responsibleId": 29,
    "priority": 1,
    "deadline": "2026-07-23T17:00:00+02:00",
    "ufCrmTask": ["D_741"]
  }'
```

### JavaScript

```javascript
const priorityMap = { high: 2, normal: 1, low: 0 }
const deadline = new Date(Date.now() + (advice.deadlineDays ?? 3) * 86400_000)

const res = await fetch(`${VIBE_URL}/v1/tasks`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: advice.taskTitle,
    description: `${advice.taskDescription}\n\nОценка ситуации: ${advice.analysis}`,
    responsibleId: deal.assignedById,
    priority: priorityMap[advice.priority] ?? 1,
    deadline: deadline.toISOString(),
    ufCrmTask: [`D_${dealId}`],
  }),
})
const { data: task } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "id": 3937,
    "title": "Подготовить и отправить коммерческое предложение",
    "status": 2,
    "priority": 1,
    "responsibleId": 29,
    "deadline": "2026-07-23T15:00:00.000Z",
    "ufCrmTask": ["D_741"],
    "createdDate": "2026-07-21T09:46:20.000Z"
  }
}
```

Числовые поля задачи возвращаются числами — `"id": 3937`, `"priority": 1`, `"responsibleId": 29`. Сравнение через `===` с числом работает без приведения типа.

Даты в ответе приходят в UTC с суффиксом `Z`, независимо от того, в какой записи вы их отправили. Момент времени тот же, запись другая: `2026-07-23T17:00:00+02:00` вернётся как `2026-07-23T15:00:00.000Z`. Сравнивайте даты через `Date`, а не построчно.

Строка вместо массива в `ufCrmTask` не даёт ошибки — значение не сохраняется, и задача остаётся без привязки к сделке. Передавайте `["D_741"]`, а не `"D_741"`.

Название длиннее 250 символов не отклоняется, а молча обрезается до 250 — отказа в ответе не будет. Модель об этой границе не знает, поэтому просите её в системном сообщении держать `taskTitle` коротким, а если точность названия важна, сверяйте `title` в ответе на создание задачи.

## Ограничения

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

**Дубль на повторном запуске.** Задача создаётся при каждом запуске. Повторный прогон по той же сделке поставит вторую такую же, поэтому «Полный код» перед созданием ищет уже стоящие задачи по привязке `ufCrmTask`.

**Сверка по метке.** Сверять при этом надо не название, а постоянную метку в его начале и состояние задачи. Название сочиняет модель, и на повторном прогоне оно будет другим — сверка по названию дубль пропустит. Метка отличает задачи сценария от тех, что менеджер поставил руками, а состояние отсекает уже закрытые: `1`-`4` — задача открыта, `5`-`7` — завершена, отложена или отклонена.

**Не больше 50 задач.** Поиск берёт до 50 задач сделки. У сделки с большим числом задач проверка станет неполной — сузьте выборку фильтром по состоянию или увеличьте `limit`.

**Данные сделки уходят в модель.** В текст запроса уходят название, сумма и темы дел по сделке. Решите до запуска, какие поля допустимо отправлять модели, и не добавляйте в описание содержимое писем и комментариев целиком.

**Секунды на ответ.** Время ответа модели измеряется секундами и растёт вместе с длиной истории. Ограничивайте выборку дел параметром `limit` и не запускайте разбор синхронно внутри обработчика веб-интерфейса.

**Расход квоты.** Запросы к моделям платформы расходуют месячную квоту компании. Сколько уже израсходовано и какие модели бесплатны — [расход и лимиты](/docs/ai/consumption).

Полный перечень кодов — [Ошибки](/docs/errors).

## Полный код

```javascript
// deal-assistant.js — разбор сделки моделью и постановка задачи
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const MODEL = process.env.VIBE_MODEL ?? 'bitrix/bitrixgpt-5.5'

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const ACTIVITY_TYPE = { 1: 'звонок', 2: 'встреча', 3: 'задача', 6: 'письмо' }
const PRIORITY = { high: 2, normal: 1, low: 0 }
// Постоянная метка в начале названия. По ней сценарий узнаёт СВОИ задачи на
// повторном прогоне и не трогает те, что менеджер поставил руками.
const TASK_TAG = '[AI]'

const SYSTEM_PROMPT = `Ты помогаешь менеджеру по продажам. Верни JSON:
{"analysis": "две-три фразы о ситуации",
 "taskTitle": "краткое название задачи",
 "taskDescription": "что именно сделать",
 "priority": "high|normal|low",
 "deadlineDays": число дней на выполнение}`

async function getDeal(dealId) {
  const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, { headers })
  const body = await res.json()
  if (!body.success) throw new Error(body.error?.message ?? 'сделка не прочитана')
  return body.data
}

async function getActivities(dealId) {
  const res = await fetch(`${VIBE_URL}/v1/activities/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { ownerTypeId: 2, ownerId: Number(dealId) },
      select: ['id', 'subject', 'typeId', 'createdAt', 'completed'],
      sort: { createdAt: 'desc' },
      limit: 20,
    }),
  })
  const body = await res.json()
  if (!body.success) throw new Error(body.error?.message ?? 'история не прочитана')
  return body.data
}

function describe(deal, activities) {
  const history = activities.length === 0
    ? 'По сделке нет ни одного дела.'
    : activities
        .map(a => {
          const date = new Date(a.createdAt).toLocaleDateString('ru-RU')
          const type = ACTIVITY_TYPE[a.typeId] ?? 'дело'
          return `- ${date} ${type}: ${a.subject} (${a.completed ? 'завершено' : 'в работе'})`
        })
        .join('\n')

  return [
    `Сделка: ${deal.title}`,
    `Стадия: ${deal.stageId}`,
    `Сумма: ${deal.amount} ${deal.currency}`,
    '',
    'История:',
    history,
  ].join('\n')
}

async function ask(deal, activities) {
  const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: MODEL,
      messages: [
        { role: 'system', content: SYSTEM_PROMPT },
        { role: 'user', content: describe(deal, activities) },
      ],
      response_format: { type: 'json_object' },
      temperature: 0.3,
    }),
  })

  const completion = await res.json()
  if (completion.error) throw new Error(completion.error.message)

  const content = completion.choices?.[0]?.message?.content
  if (!content) throw new Error('модель вернула пустой ответ')

  const advice = JSON.parse(content)
  if (!advice.taskTitle) throw new Error('в ответе модели нет поля taskTitle')
  return advice
}

// Повторный прогон по той же сделке иначе поставит менеджеру вторую такую же
// задачу: связь ufCrmTask допускает сколько угодно задач на одну сделку.
// Прогон по 300 сделкам дважды дал бы 600 задач.
async function hasOpenAiTask(dealId) {
  const res = await fetch(`${VIBE_URL}/v1/tasks/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { ufCrmTask: `D_${dealId}` },
      select: ['id', 'title', 'status'],
      limit: 50,
    }),
  })
  const body = await res.json()
  if (!body.success) throw new Error(body.error?.message ?? 'поиск задач не выполнен')
  // Сверка идёт по постоянной метке и состоянию, а НЕ по названию: название
  // сочиняет модель, на повторном прогоне оно будет другим, и сверка по нему
  // пропустит дубль. Открытыми считаются состояния 1-4 (новая, ждёт
  // выполнения, выполняется, ждёт контроля); 5-7 — завершена, отложена,
  // отклонена.
  return body.data.some(task => task.title.startsWith(TASK_TAG) && task.status < 5)
}

async function createTask(dealId, deal, advice) {
  const days = Number(advice.deadlineDays) || 3
  const deadline = new Date(Date.now() + days * 86400_000)

  const res = await fetch(`${VIBE_URL}/v1/tasks`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      title: `${TASK_TAG} ${String(advice.taskTitle)}`,
      description: `${advice.taskDescription ?? ''}\n\nОценка ситуации: ${advice.analysis ?? ''}`.trim(),
      responsibleId: deal.assignedById,
      priority: PRIORITY[advice.priority] ?? 1,
      deadline: deadline.toISOString(),
      ufCrmTask: [`D_${dealId}`],
    }),
  })

  const body = await res.json()
  if (!body.success) throw new Error(body.error?.message ?? 'задача не создана')
  return body.data
}

async function main() {
  const dealId = process.argv[2]
  if (!dealId) {
    console.log('Запуск: node deal-assistant.js <ID сделки>')
    process.exit(1)
  }

  const deal = await getDeal(dealId)
  const activities = await getActivities(dealId)
  console.log(`${deal.title}: дел в истории — ${activities.length}`)

  // Проверка идёт ДО обращения к модели: повторный прогон по той же сделке
  // не должен платить за разбор, результат которого будет отброшен.
  if (await hasOpenAiTask(dealId)) {
    console.log(`По сделке ${dealId} уже стоит открытая задача ассистента — пропускаем`)
    return
  }

  const advice = await ask(deal, activities)
  console.log(`Оценка: ${advice.analysis}`)

  const task = await createTask(dealId, deal, advice)
  console.log(`Задача ${task.id}: ${task.title}`)
}

main().catch(error => {
  console.error('разбор прерван:', error.message)
  process.exitCode = 1
})
```

## Смотрите также

- [Создать чат-комплишен](/docs/ai/chat/completions)
- [Гарантированный JSON-ответ](/docs/ai/chat/json)
- [Получить сделку](/docs/entities/deals/get)
- [Поиск дел](/docs/entities/activities/search)
- [Создать задачу](/docs/entities/tasks/create)
