Привет, Хабр!
Чат-бот может быстро ответить на типовые вопросы о товаре, стоимости или доступных вариантах. Но в сложных продажах часть диалогов неизбежно доходит до вопросов, которые удобнее обсудить с человеком: подобрать условия, сравнить несколько предложений, уточнить детали оплаты или принять решение прямо сейчас.
В такой момент пользователю важно дать простой способ перейти от переписки к разговору, не заставляя его искать номер телефона, оставлять заявку и ждать обратного звонка.
В статье соберём чат, который отвечает на вопросы по выбранному туристическому направлению, оценивает контекст диалога и при подходящем сценарии предлагает позвонить менеджеру прямо из браузера.
Стек: 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-настроек.
В итоге
Система отвечает на вопросы по данным из карточки выбранного клиентом путешествия и предлагает соединить с менеджером, когда бот не находит ответа или фиксирует коммерческий интерес. Бэкенд определяет причину эскалации, а фронтенд отображает кнопку вызова и возвращает статусы соединения в сессию. Это помогает увеличить продажи, немного снизив барьер к первому контакту с менеджером.
Возможности для развития
Если звонок сорвался или менеджер не ответил, система ставит задачу на обратный звонок
Направляйте клиента к профильному специалисту по конкретной стране, бюджету или формату отдыха
Отслеживание воронку от первого сообщения до оплаты
Собирать и добавлять в карточки туров вопросы, на которые бот не смог ответить
Используйте историю диалога, чтобы предлагать подходящие отели и услуги на основе интересов пользователя
Оценивать качество разговоров звонков, чтобы понимать, как менеджеры отрабатывают возражения и предлагают альтернативы
Код в репозитории.
