Привет, Хабр!

Чат-бот может быстро ответить на типовые вопросы о товаре, стоимости или доступных вариантах. Но в сложных продажах часть диалогов неизбежно доходит до вопросов, которые удобнее обсудить с человеком: подобрать условия, сравнить несколько предложений, уточнить детали оплаты или принять решение прямо сейчас.

В такой момент пользователю важно дать простой способ перейти от переписки к разговору, не заставляя его искать номер телефона, оставлять заявку и ждать обратного звонка.

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

Стек: FastAPI, Pydantic, requests, YandexGPT, Vite и Web SDK МТС Exolve.

Общая схема работы

Когда покупатель открывает чат на сайте туроператора, фронтенд передаёт идентификатор предложения бэкенду. Бэкенд создаёт уникальную сессию и привязывает к ней данные тура, чтобы нейросеть понимала контекст будущих вопросов.

Каждое сообщение в чате попадает в бэкенд. Сначала система проверяет триггеры: прямые запросы менеджера или ключевые слова о цене и датах. Если быстрые правила не находят совпадений, бэкенд пересылает запрос в YandexGPT вместе с параметрами тура.

Нейросеть возвращает размеченный JSON со статусом намерения. Бэкенд анализирует этот статус и выбирает действие: ответить текстом, уточнить детали или предложить звонок. Фронтенд выводит кнопку вызова только тогда, когда бэкенд подтверждает коммерческий интерес клиента.

После нажатия на кнопку фронтенд получает SIP-настройки и через МТС Exolve Web Voice SDK регистрирует устройство в сети. Браузер совершает прямой звонок менеджеру.

События звонка от МТС Exolve Web Voice SDK приходят на вебхук бэкенда. Система записывает их в ту же сессию, формируя полную историю лида: от первого вопроса в чате до разговора с сотрудником.

Поток данных:

  • Выбранный тур создаёт контекст сессии

  • Сообщение клиента обновляет состояние сессии

  • YandexGPT классифицирует запрос и определяет статус ответа

  • Бэкенд на основе статуса даёт фронтенду команду показать интерфейс звонка

  • МТС Exolve Web Voice SDK передаёт события звонка для сохранения в истории сессии

Архитектура решения

В проекте две исполняемые части: бэкенд на FastAPI и фронтенд на Vite. Бэкенд отвечает за решения с чатом, фронтенд — за интерфейс и браузерный звонок. Сервер решает, что предложить клиенту, а браузер запускает звонок.

Бэкенд хранит состояние диалога, берёт демо-данные тура, вызывает YandexGPT и возвращает фронтенду единый ответ для чата. В этом ответе важны текст, сессия и признак звонка: по флагу интерфейс понимает, что нужно открыть путь к менеджеру.

YandexGPT здесь разбирает вопрос по карточке тура. Модель должна вернуть ANSWER, NO_DATA или UNCLEAR. Это не полноценный ассистент с памятью и свободной логикой: память диалога лежит в сессии, а модель получает только данные тура и текущий вопрос.

МТС Exolve Web Voice SDK используется на стороне фронтенда. SDK регистрирует SIP-аккаунт, запускает вызов и сообщает браузеру события звонка. Бэкенд не получает эти события напрямую от МТС Exolve; он видит только статусы, которые отправляет фронтенд.

Главный технический ключ — сессия. Она связывает вопрос клиента, ответ бота, причину предложения звонка и дальнейшие события вызова.

Пререквизиты

Для запуска решения подготовьте доступы к внешним API и настройте среду разработки.

Внешние сервисы и ключи:

  • Yandex Cloud: для работы нейросети получите YC_API_KEY и YC_FOLDER_ID в консоли облака для классификации запросов

  • API Платформа МТС Exolve: в личном кабинете API Платформы создайте SIP ID, чтобы получить EXOLVE_SIP_LOGIN и EXOLVE_SIP_PASSWORD для регистрации фронтенда в сети

  • Номер менеджера: укажите EXOLVE_MANAGER_NUMBER в формате +7 для приёма вызовов из браузера

Системные требования:

  • Python версии 3.10 или выше для работы бэкенда на FastAPI

  • Node.js версии 18 или выше для сборки фронтенда на Vite

Настройка конфигурации:

Бэкенд считывает параметры доступа из файла .env. Разместите его в корневой папке Python-кода:

YC_FOLDER_ID=
YC_API_KEY=
EXOLVE_SIP_LOGIN=
EXOLVE_SIP_PASSWORD=
EXOLVE_MANAGER_NUMBER=

Фронтенд использует адрес API бэкенда для отправки сообщений и получения статусов:

VITE_API_BASE=http://127.0.0.1:8000

Установка компонентов:

Разверните виртуальное окружение и установите зависимости бэкенда:

cd habr/exolve/codebase/may2026-web-chat-sdk-callback-yandexgpt-tour-consultant
python -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements.txt

Установите пакеты фронтенда, включая @exolve/web-voice-sdk для работы со звонками:

cd habr/exolve/codebase/may2026-web-chat-sdk-callback-yandexgpt-tour-consultant/frontend
npm install

Шаг 1. Создаём сессию по туру

Для работы нейросети нужен контекст: страна, отель, даты и стоимость. Чтобы бэкенд не запрашивал эти данные при каждом сообщении и мог связать будущий звонок с конкретным лидом, мы создаём сессию сразу при открытии чата.

# database.py

from typing import Any
SESSIONS: dict[str, dict[str, Any]] = {}
TOURS_DB = {
    "tour_101": {
        "страна": "Турция",
        "отель": "Rixos Premium",
        "даты": "10.08 - 20.08",
        "цена": "250 000 руб",
        "питание": "Ultra All Inclusive",
        "описание": "Семейный тур",
    }
}

В памяти приложения хранятся две разные сущности: каталог и состояние сессий.

  • TOURS_DB — баз данных с параметрами туров: страна, отель, даты, цена, питание и описание. Эти факты бэкенд передаёт в YandexGPT, чтобы нейросеть отвечала строго по карточке и не придумывала лишнего

  • SESSIONS — состояние конкретного диалога. Здесь хранятся история сообщений, уровень интереса, счётчик уточнений, причина предложения звонка и события вызова

Такое разделение позволяет не смешивать контекст с историей действий.

# main.py

@app.post("/api/chat/init", response_model=ChatResponse)
def init_chat(req: InitRequest) -> ChatResponse:
    tour_data = TOURS_DB.get(req.tour_id)
    if tour_data is None:
        raise HTTPException(status_code=404, detail="Tour not found")

    session_id = str(uuid.uuid4())
    SESSIONS[session_id] = {
        "tour_id": req.tour_id,
        "tour_data": tour_data,
        "lead_score": 0,
        "unclear_count": 0,
        "history": [],
        "call_status": "not_offered",
        "call_events": [],
        "escalation_reason": None,
    }
    return ChatResponse(
        session_id=session_id,
        reply="Здравствуйте! Чем помочь?",
        show_call_button=False,
        lead_score=0,
    )

Если идентификатор тура найден, бэкенд создаёт UUID сессии и возвращает приветствие. Если тура нет, обработчик сразу отдаёт 404.

Шаг 2. Обрабатываем прямой запрос менеджера

Если клиент явно просит менеджера, бэкенд исключает обращение к нейросети. Полученного сигнала достаточно: сохраняем причину, повышаем интерес и возвращаем кнопку звонка.

# main.py

def offer_call(session: dict, reason: str) -> None:
    session["call_status"] = "offered"
    session["escalation_reason"] = reason

На этом шаге звонок ещё не запускается. Бэкенд только помечает в сессии, что звонок предложен, и сохраняет причину.

# main.py

manager_keywords = ("менеджер", "оператор", "позвон", "связаться", "человек")
if any(keyword in user_msg for keyword in manager_keywords):
    session["lead_score"] = min(session["lead_score"] + 3, 10)
    offer_call(session, "user_asked")
    reply = "Соединяю вас с менеджером. Звонок пройдет прямо в браузере."
    session["history"].append(
        {"bot": reply, "button_shown": True, "reason": "user_asked"}
    )
    return ChatResponse(
        session_id=req.session_id,
        reply=reply,
        show_call_button=True,
        lead_score=session["lead_score"],
        call_reason="user_asked",
    )

Логика работы этого участка:

  • Поиск триггеров. Бэкенд проверяет входящий текст на наличие ключевых слов. Если есть совпадение, выполнение функции прерывается до обращения к YandexGPT

  • Скоринг лида. Бэкенд увеличивает lead_score на три пункта. Прямой запрос менеджера — более сильный сигнал, чем уточнение цены или дат

  • Формирование контракта. Бэкенд возвращает объект ChatResponse, где флаг show_call_button установлен в значение true

  • Реакция фронтенда. Получив такой ответ, интерфейс отображает кнопку вызова, которая инициирует звонок через МТС Exolve Web Voice SDK

Благодаря такому подходу мы сохраняем контекст диалога в истории сессии, даже если автоматизированная консультация была прервана по инициативе пользователя.

Шаг 3. Скоринг коммерческого интереса

Клиент может не просить звонок напрямую, но вопросы о цене, датах, наличии мест или бронировании показывают предметный интерес. В применяется простая эвристика: при обнаружении коммерческих ключевых слов бэкенд увеличивает счётчик интереса. Когда система фиксирует несколько таких сигналов, чат предлагает созвониться с менеджером.

# main.py

commercial_keywords = (
    "цен",
    "дат",
    "отел",
    "бронь",
    "оплат",
    "мест",
    "налич",
    "номер",
    "вылет",
    "аэропорт",
    "ребен",
    "ребён",
    "дет",
    "куп",
    "оформ",
)
matched_keywords = sum(keyword in user_msg for keyword in commercial_keywords)
if matched_keywords:
    session["lead_score"] = min(session["lead_score"] + matched_keywords, 10)

Бэкенд ищет вхождения подстрок в сообщении пользователя. При каждом совпадении значение lead_score в сессии увеличивается на один пункт. Это позволяет системе количественно оценить вовлечённость клиента. Текстовые ответы продолжают приходить через YandexGPT, но при достижении порога текстовый ответ дополняется предложением поговорить с менеджером.

# main.py

elif session["lead_score"] >= 2:
    response.reply = (
        f"{reply_text}\n\nПохоже, вам интересен этот тур. "
        "Соединить вас с менеджером прямо сейчас?"
    )
    response.show_call_button = True
    response.call_reason = "high_lead_score"
    offer_call(session, response.call_reason)

Как работает триггер звонка:

  • Если счётчик интереса равен двум или выше, система переводит консультацию в звонок

  • Бэкенд добавляет к ответу нейросети прямой вопрос о соединении

  • Флаг show_call_button передаёт фронтенду команду отобразить кнопку вызова через МТС Exolve Web Voice SDK

Шаг 4. Ограничиваем модель данными тура

Чтобы избежать галлюцинаций, мы передаём в YandexGPT только параметры конкретного тура. Нейросеть работает в режиме классификатора: он не придумывает детали, а ищет ответы в предоставленной карточке или сообщает о нехватке данных.

# yandex_gpt.py

SYSTEM_PROMPT = """
Ты — строгий анализатор запросов. У тебя есть данные тура: {tour_data}

Оцени сообщение пользователя и верни валидный JSON-объект:
{{
  "status": "ANSWER",
  "reply": "Сухой ответ только на основе данных тура"
}}

Допустимые статусы:
- ANSWER: вопрос понятен и ответ есть в данных тура;
- NO_DATA: вопрос понятен, но ответа в данных тура нет;
- UNCLEAR: сообщение бессмысленно или не содержит конкретного вопроса.

Для NO_DATA и UNCLEAR оставь reply пустым. Не придумывай факты.
""".strip()

Промпт задаёт структуру ответа. Нейросеть возвращает JSON с одним из трёх статусов:

  • ANSWER — ответ найден в данных тура

  • NO_DATA — вопрос понятен, но в карточке нет нужной информации

  • UNCLEAR — сообщение не содержит конкретного вопроса

# schemas.py

class LLMResponse(BaseModel):
    status: Literal["ANSWER", "NO_DATA", "UNCLEAR"]
    reply: str = ""

Для обработки ответа используем Pydantic. Это гарантирует, что бэкенд не примет некорректный JSON или статус, который не предусмотрен логикой приложения

# yandex_gpt.py

payload = {
    "modelUri": f"gpt://{settings.YC_FOLDER_ID}/yandexgpt-lite/latest",
    "completionOptions": {
        "stream": False,
        "temperature": 0.1,
        "maxTokens": "700",
    },
    "jsonObject": True,
    "messages": [
        {"role": "system", "text": prompt},
        {"role": "user", "text": user_message},
    ],
}

response = requests.post(
    url,
    headers=headers,
    json=payload,
    timeout=15.0,
)
raw_text = response.json()["result"]["alternatives"][0]["message"]["text"]
return LLMResponse.model_validate_json(raw_text)

Технические особенности реализации:

  • JSON-режим — параметр jsonObject: True просит модель возвращать структурированные

  • Температура 0.1 — минимальное значение делает ответы нейросети максимально сухими и предсказуемыми

  • Синхронный запрос — вызов выполняется с таймаутом 15 секунд

Шаг 5. Разбираем три исхода модели

На этом этапе бэкенд анализирует результат работы YandexGPT. Сетевые ошибки и недоступность API сервис считает техническим сбоем и сразу предлагает соединить с менеджером. Валидные ответы нейросети система распределяет по сценариям: ответ по фактам из карточки (ANSWER), сообщение о нехватке данных (NO_DATA) или просьба уточнить вопрос (UNCLEAR).

# main.py

try:
    llm_response = call_yandex_gpt(session["tour_data"], message)
    status = llm_response.status
    reply_text = llm_response.reply.strip()
except requests.RequestException:
    offer_call(session, "api_error")
    reply = "Произошла техническая заминка. Лучше соединю вас с менеджером."
    session["history"].append(
        {"bot": reply, "button_shown": True, "reason": "api_error"}
    )
    return ChatResponse(
        session_id=req.session_id,
        reply=reply,
        show_call_button=True,
        lead_score=session["lead_score"],
        call_reason="api_error",
    )
except LLMResponseError:
    status = "UNCLEAR"
    reply_text = ""

Сетевой сбой сразу переводит диалог менеджеру: клиент не должен ждать, пока сервис восстановит связь с внешним API. Если YandexGPT прислал ответ, но JSON имеет другую структуру или статус не прошёл валидацию, бэкенд присваивает запросу статус UNCLEAR. Так система сохраняет сессию и позволяет пользователю продолжить диалог, несмотря на разовую ошибку формата.

# main.py

if status == "UNCLEAR":
    session["unclear_count"] += 1
    unclear_count = session["unclear_count"]
    if unclear_count == 1:
        response.reply = "Можете переформулировать вопрос?"
    elif unclear_count == 2:
        response.reply = (
            "Не понимаю. Уточните, вас интересует цена, даты, отель "
            "или бронирование?"
        )
    elif unclear_count >= 3:
        response.reply = (
            "Все ещё не пойму. Я могу соединить вас с менеджером. Соединить?"
        )
        response.show_call_button = True
        response.call_reason = "unclear_3"
        offer_call(session, response.call_reason)

Переменная unclear_count предотвращает зацикливание диалога, когда бот не понимает вопрос. При первой ошибке распознавания система просит пользователя уточнить вопрос, при второй — предлагает темы на выбор, а после третьей — выводит кнопку звонка менеджеру.

# main.py

elif status == "NO_DATA":
    response.reply = (
        "Это лучше уточнить у менеджера. Хотите пообщаться голосом? "
        "Звонок будет прямо в браузере."
    )
    response.show_call_button = True
    response.call_reason = "no_data"
    offer_call(session, response.call_reason)
else:
    session["unclear_count"] = 0
    if not reply_text:
        response.reply = "Лучше уточнить это у менеджера."
        response.show_call_button = True
        response.call_reason = "empty_llm_reply"
        offer_call(session, response.call_reason)
    elif session["lead_score"] >= 2:
        response.reply = (
            f"{reply_text}\n\nПохоже, вам интересен этот тур. "
            "Соединить вас с менеджером прямо сейчас?"
        )
        response.show_call_button = True
        response.call_reason = "high_lead_score"
        offer_call(session, response.call_reason)
    else:
        response.reply = reply_text

Если модель вернула пустой ответ, диалог тоже переводится к менеджеру. На выходе остаётся один формат ответа: текст для чата и признак, показывать ли кнопку. Фронтенд не разбирает внутренние причины.

Шаг 6. Передаём фронтенду решение

Бэкенд определяет уместность звонка. Фронтенд управляет интерфейсом: отображает кнопку и запускает SDK после клика.

# schemas.py

class ChatResponse(BaseModel):
    session_id: str
    reply: str
    show_call_button: bool
    lead_score: int
    call_reason: str | None = None

Фронтенд получает такой ответ после каждого сообщения. Для интерфейса главное — нужно ли показывать кнопку звонка.

# app.js

async function sendMessage() {
  const text = userInput.value.trim();
  if (!text || !sessionId || sendBtn.disabled) return;

  appendMessage("Вы", text, "user");
  userInput.value = "";
  setChatBusy(true);

  try {
    const data = await apiRequest("/api/chat/message", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ session_id: sessionId, message: text }),
    });
    appendMessage("Бот", data.reply, "bot");
    callBtn.style.display = data.show_call_button ? "block" : "none";
  } catch (error) {
    console.error(error);
    appendMessage("Система", "Ошибка отправки сообщения.", "bot");
  } finally {
    setChatBusy(false);
    userInput.focus();
  }
}

Фронтенд отправляет сообщение, получает ответ и переключает видимость кнопки. Если API вернёт ошибку, логика звонка не сработает.

Шаг 7. Запускаем звонок из браузера

После клика фронтенд скрывает поле ввода, фиксирует статус clicked и запрашивает SIP-настройки. Бэкенд передаёт авторизационные данные, а браузер делает вызов через МТС Exolve Web Voice SDK.

# main.py

@app.get("/api/demo-web-call-config")
def get_demo_call_config() -> dict[str, str]:
    settings = get_settings()
    if not settings.exolve_is_configured():
        raise HTTPException(status_code=503, detail="Exolve is not configured")

    return {
        "LOGIN": settings.EXOLVE_SIP_LOGIN,
        "PASSWORD": settings.EXOLVE_SIP_PASSWORD,
        "MANAGER_NUMBER": settings.EXOLVE_MANAGER_NUMBER,
    }

Если в настройках нет переменных МТС Exolve, бэкенд вернёт ошибку 503. Если параметры заполнены, фронтенд получит логин, пароль и номер менеджера.

# app.js

async function initSdk() {
  communicator = new Communicator();
  await communicator.initialize({
    debug: true,
    enableSecureConnection: true,
    maxLines: 1,
    ringtoneEnabled: true,
  });
  callClient = communicator.client;

  callClient.on(RegistrationEvent.Registered, () => {
    isRegistered = true;
    if (!callRequested || !currentCallConfig) return;
    callRequested = false;
    statusDiv.textContent = "Установка соединения...";
    callClient.makeCall(currentCallConfig.MANAGER_NUMBER);
  });

  sdkInitialized = true;
}

SDK может ещё не пройти регистрацию в момент нажатия кнопки. В таком случае код сохраняет состояние и запускает вызов только после события об успешной регистрации.

# app.js

async function startCall() {
  callBtn.style.display = "none";
  inputArea.style.display = "none";
  await updateCallStatus("clicked");
  statusDiv.textContent = "Инициализация телефонии...";

  try {
    currentCallConfig = await apiRequest("/api/demo-web-call-config");
    if (!sdkInitialized) await initSdk();

    if (isRegistered) {
      callRequested = false;
      statusDiv.textContent = "Установка соединения...";
      callClient.makeCall(currentCallConfig.MANAGER_NUMBER);
    } else {
      callRequested = true;
      callClient.registerAccount(
        currentCallConfig.LOGIN,
        currentCallConfig.PASSWORD,
      );
    }
  } catch (error) {
    console.error("Сбой Web Voice SDK", error);
    statusDiv.textContent = `Техническая ошибка вызова: ${error.message}`;
    await updateCallStatus("failed");
    resetCallUi(true);
  }
}

Если регистрация уже есть, звонок начинается сразу. В ином случае фронтенд сначала регистрирует SIP ID. При любой ошибке система переведёт сессию в статус failed и вернёт кнопку вызова.

Шаг 8. Сохраняем статусы звонка

Бэкенд должен фиксировать результат: нажал ли клиент кнопку, начался ли разговор или произошла ошибка. Фронтенд ловит события SDK и отправляет короткие статусы на сервер вместе с ID сессии.

# schemas.py

CallStatus = Literal[
    "not_offered",
    "offered",
    "clicked",
    "started",
    "connected",
    "ended",
    "failed",
]


class CallStatusRequest(BaseModel):
    session_id: str = Field(min_length=1, max_length=100)
    status: CallStatus

CallStatus ограничивает допустимые значения, но не проверяет порядок переходов. Бэкенд запишет любой входящий статус, даже если он нарушает логику процесса.

# app.js

async function updateCallStatus(status) {
  if (!sessionId) return;
  try {
    await apiRequest("/api/chat/call_status", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ session_id: sessionId, status }),
    });
  } catch (error) {
    console.error("Сбой обновления статуса звонка", error);
  }
}

Эта функция отправляет в бэкенд только привязку к сессии и новый статус. В этом проекте мы не сохраняем аудиозаписи или технические данные о линии.

# app.js

callClient.on(CallEvent.New, () => void updateCallStatus("started"));
callClient.on(CallEvent.Connected, () => {
  statusDiv.textContent = "Разговор с менеджером установлен.";
  void updateCallStatus("connected");
});
callClient.on(CallEvent.Disconnected, () => {
  statusDiv.textContent = "Звонок завершён.";
  void updateCallStatus("ended");
  resetCallUi(false);
});
callClient.on(CallEvent.Error, () => {
  statusDiv.textContent = "Ошибка вызова.";
  void updateCallStatus("failed");
  resetCallUi(true);
});

Теперь записываем статус в серверное состояние. Иначе после закрытия вкладки останется только событие интерфейса, которое бэкенд уже не увидит.

# main.py

@app.post("/api/chat/call_status")
def update_call_status(req: CallStatusRequest) -> dict[str, str]:
    session = SESSIONS.get(req.session_id)
    if session is None:
        raise HTTPException(status_code=404, detail="Session not found")

    session["call_status"] = req.status
    session["call_events"].append({"status": req.status, "ts": time.time()})
    return {"status": "ok"}

Обработчик обновляет текущий статус и добавляет событие в историю звонка. Если сессия не найдена, бэкенд вернёт ошибку 404.

Запуск и проверка

Бэкенд запускаем из корня проекта, где лежит пакет backend:

cd habr/exolve/codebase/may2026-web-chat-sdk-callback-yandexgpt-tour-consultant
uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000

Фронтенд запускаем из каталога frontend:

cd habr/exolve/codebase/may2026-web-chat-sdk-callback-yandexgpt-tour-consultant/frontend
npm run dev

Проверить создание сессии можно через curl:

curl -X POST "http://127.0.0.1:8000/api/chat/init" \
  -H "Content-Type: application/json" \
  -d '{"tour_id":"tour_101"}'

В ответе должны быть session_id, приветствие, show_call_button=false и lead_score=0.

После этого можно отправить сообщение с запросом звонка менеджеру:

curl -X POST "http://127.0.0.1:8000/api/chat/message" \
  -H "Content-Type: application/json" \
  -d '{"session_id":"PASTE_SESSION_ID","message":"Хочу связаться с менеджером"}'

В ответе должны появиться show_call_button=true и call_reason="user_asked". Для проверки сценариев с моделью нужны настройки YandexGPT из .env. Для звонка через браузер — SIP-настройки МТС Exolve.

Отдельно проверяем конфиг звонка:

curl "http://127.0.0.1:8000/api/demo-web-call-config"

Если переменные МТС Exolve не заполнены, бэкенд вернёт 503 — звонковая часть не стартует без SIP-настроек.

В итоге

Система отвечает на вопросы по данным из карточки выбранного клиентом путешествия и предлагает соединить с менеджером, когда бот не находит ответа или фиксирует коммерческий интерес. Бэкенд определяет причину эскалации, а фронтенд отображает кнопку вызова и возвращает статусы соединения в сессию. Это помогает увеличить продажи, немного снизив барьер к первому контакту с менеджером.

Возможности для развития

  • Если звонок сорвался или менеджер не ответил, система ставит задачу на обратный звонок

  • Направляйте клиента к профильному специалисту по конкретной стране, бюджету или формату отдыха

  • Отслеживание воронку от первого сообщения до оплаты

  • Собирать и добавлять в карточки туров вопросы, на которые бот не смог ответить

  • Используйте историю диалога, чтобы предлагать подходящие отели и услуги на основе интересов пользователя

  • Оценивать качество разговоров звонков, чтобы понимать, как менеджеры отрабатывают возражения и предлагают альтернативы

Код в репозитории.