Привет, Хабр! В этом материале сделаем сервис, который разгрузит диспетчера сервисного центра по ремонту бытовой техники. Голосовой робот будет принимать входящий звонок и собирать детали обращения. На каждое обращение в Битрикс24 будет создаваться карточка с назначенным мастером.

Стек проекта: Python 3.10+, FastAPI, Pydantic, SQLite, HTTPX, Битрикс24, Яндекс Геокодер, голосовой робот и SMS API от МТС Exolve.

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

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

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

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

По выбранной заявке клиент может узнать статус, перенести дату и время визита мастера, добавить комментарий или отменить заявку. При недоступности Битрикс24 проверить актуальное состояние заявки невозможно, и обращение передаётся оператору.

Когда клиент создаёт новую заявку, недоступность CRM не помешает. Робот собирает и сохраняет данные локально. Карточка в CRM появляется позже — после подтверждения заявки клиентом или фонового повтора.

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

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

После подтверждения заявки без признаков опасности созданная карточка в CRM предлагается свободному мастеру нужной специализации. Клиент в этот же момент, ещё во время разговора, получает первое СМС — подтверждение того, что заявка принята. При недоступности Битрикс24 клиент вместо этого получает СМС, что обращение сохранено и с ним свяжется диспетчер.

Дальше мастер в Битрикс24 принимает или отклоняет новую заявку, предлагает другое время или просит уточнить детали поломки. В последних двух случаях заявка переводится на диспетчера, а клиент получает СМС с просьбой уточнить детали. 

Ответ мастера сервис получает через вебхук в Битрикс24 и по нему отправляет клиенту вторую СМС — с именем мастера и подтверждённым временем выезда. В конце звонка в карточку в CRM добавляется запись разговора, расшифровка и краткая сводка.

Весь путь данных от звонка до передачи мастеру и записи итогов выглядит так:

Звонок → локальная запись разговора и поиск в CRM → сбор данных → проверка опасных признаков и зоны выезда → подтверждение → Битрикс24 → мастер → СМС и сводка звонка

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

Сервис — это одно приложение на FastAPI с локальной базой SQLite. Модуль main.py принимает события от голосового робота и ответы мастеров из Битрикс24. Диалог с клиентом ведёт внешний голосовой робот.

Каждый ответ клиента последовательно сохраняется в SQLite с текущим этапом диалога, чтобы продолжить с нужного вопроса — например, перейти к адресу после выяснения неисправности. Шаги идут один за другим:

  1. intent — что нужно клиенту: новая заявка, существующая заявка, консультация или оператор

  2. appliance — тип техники

  3. issue — описание неисправности

  4. address — адрес визита

  5. time — удобное время

  6. confirm — подтверждение заявки клиентом

  7. completed — заявка оформлена и передана мастеру

Схема ниже показывает распределение ответственности:

Голосовая платформа
│ события звонка, распознанные ответы, call_id
▼
FastAPI ───────────────► SQLite: calls + requests + masters
│ ▲ │
│ └───── команда перевода ─────┘
├──────────────► Bitrix24: контакт, карточка, ответ мастера
├──────────────► Яндекс Геокодер: адрес и координаты
└──────────────► SMS API МТС Exolve: подтверждение клиенту

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

Модуль recognition.py дополнительно определяет цель обращения и тип техники, извлекает бренд и код ошибки, распознаёт согласие клиента и признаки опасной ситуации. Названные клиентом день и период визита, например «завтра вечером», преобразуются в дату и интервал времени. Затем результаты распознавания робота и собственного разбора текста объединяются.

Результаты о звонках и заявках хранятся в двух таблицах SQLite.

  • Таблица calls содержит технический контекст разговора: текущий этап диалога, исходные ответы клиента, причину перевода на оператора, ссылки на запись звонка и его расшифровку

  • Таблица requests содержит данные заявки: технику, неисправность, адрес, выбранный интервал визита, состояние назначения мастера и идентификатор карточки в Битрикс24

Идентификатор call_id связывает обе таблицы и защищает от дублей.

Все обращения к внешним сервисам — Битрикс24, SMS API МТС Exolve и Яндекс Геокодеру — собраны в модуле integrations.py.

Поверх этого services.py собирает поля будущей карточки CRM и подбирает мастера под заявку. Мастер ищется локально в таблице masters. Это справочник, который вы заполняете сами при внедрении: специализация мастера, бренды, районы работы, график, лимит заявок в день.

Помимо основного потока событий, раз в минуту запускается фоновая задача. Она находит заявки, где предложение мастеру было без ответа дальше тайм‑аута, и предлагает их следующему мастеру. Также фоновая задача повторно отправляет в Битрикс24 уже подтверждённые клиентом заявки, у которых не создалась карточка — например, из‑за временной недоступности CRM.

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

Понадобится Python 3.10+ и доступ к внешним сервисам:

  • Голосовой робот — он отправляет HTTP‑запросы на наш сервис с заголовком X‑API‑Key

  • Битрикс24: нужен URL входящего вебхука и ID смарт‑процесса — сущности в CRM Битрикс24 для хранения однотипных заявок со своими полями и стадиями. Вы создаёте его в разделе CRM и храните в нём заявки на ремонт. Также понадобятся названия его стадий, например «Новая» и «Мастер назначен».

  • МТС Exolve — API‑ключ для отправки СМС

  • Яндекс Геокодер — API‑ключ для проверки адреса. Без него сервис не сможет проверить адрес и передаст звонок оператору

Подготовьте окружение и установите зависимости:

cd habr/exolve/codebase/jul2026-ai-reception-bitrix24-main
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env

В переменных окружения минимально обязательны ключ вызовов робота и секрет входящего вебхука Битрикс24:

ROBOTS_API_KEY=replace-with-strong-random-key
BITRIX_WEBHOOK_SECRET=replace-with-incoming-webhook-secret
DATABASE_PATH=ai_reception.db
BITRIX_WEBHOOK_URL=https://example.bitrix24.ru/rest/1/webhook-token
BITRIX_ENTITY_TYPE_ID=123
EXOLVE_API_KEY=...
EXOLVE_SMS_SENDER=...
YANDEX_GEOCODER_KEY=...

Шаг 1. Создаём локальную запись звонка и получаем данные из CRM

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

Модуль main.py приводит номер телефона к единому формату и ищет по нему контакт в Битрикс24, а если находит — загружает его незавершённые заявки. Затем в SQLite создаётся запись звонка и черновик будущей заявки — в таблицах calls и requests соответственно, обе под одним call_id. Для защиты от дублей стандартная SQL‑конструкция INSERT OR IGNORE вставляет строку, только если такой call_id ещё нет в таблице.

В записи звонка сохраняется номер телефона, флаг, был ли доступен Битрикс24 в момент звонка, и ссылки на запись и расшифровку разговора. Если контакт клиента найден в Битрикс24, туда же добавляется имя клиента.

with db() as conn:
    conn.execute(
        "INSERT OR IGNORE INTO calls (call_id, phone, client_name, service_number, source, "
        "crm_ok, active_requests, last_address, recording_url, transcript_url, started_at, collected) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (p.call_id, phone, name, p.service_number, p.source, int(crm_ok),
         json.dumps(active, ensure_ascii=False), last_address,
         p.recording_url, p.transcript_url, now,
         json.dumps(collected_init, ensure_ascii=False)),
    )
    conn.execute(
        "INSERT OR IGNORE INTO requests (call_id, created_at, updated_at) VALUES (?, ?, ?)",
        (p.call_id, now, now),
    )

При недоступности CRM во время звонка, это сохраняется в записи звонка флагом crm_ok = false, но не останавливает робота. Дальше сервис действует по сценарию из общей схемы, описанной выше. Карточку в Битрикс24 сервис создаёт при подтверждении заявки, а если первая попытка не удалась — фоновая задача повторяет её позже.

Шаг 2. Распознаём опасные сообщения и передаём звонок оператору

При упоминании клиентом дыма, запаха гари, искрения, газа, протечки или перегрева, звонок передаётся оператору немедленно — независимо от того, на каком шаге анкеты это произошло.

Сперва на опасность проверяет робот. После этого сервис разбирает исходный текст ответа клиента и объединяет оба результата. За это отвечает функция handle_danger — она вызывается на каждом шаге анкеты. Когда находится хотя бы один признак, то заявке присваивается срочный статус, а клиенту отправляется предупреждение — «Не пользуйтесь техникой и не включайте её повторно. Если это безопасно, отключите её от питания» — и командует роботу передать звонок оператору. 

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

def handle_danger(conn, call_id, normalized_danger_signs, *texts):
    signs = list(normalized_danger_signs or [])
    for sign in detect_danger(*texts):
        if sign not in signs:
            signs.append(sign)
    if not signs:
        return None
    req = get_request(conn, call_id)
    if req.get("request_type") == "new_request":
        merged_signs = sorted(set(req["danger_signs"]) | set(signs))
        update_request(conn, call_id, status="urgent", danger_signs=merged_signs)
    update_call(conn, call_id, transfer_reason="danger_signs")
    push_to_crm(conn, call_id)
    return to_operator(
        conn,
        call_id,
        "danger_signs",
        "Не пользуйтесь техникой и не включайте её повторно. "
        "Если это безопасно, отключите её от питания. Соединяю вас с оператором.",
        transfer="emergency_operator",
        urgent=True,
    )

Отдельный случай — когда у клиента несколько незавершённых заявок и он ещё не сказал, по какой из них звонит. Тогда взять конкретный номер заявки негде для срочного комментария, поэтому звонок передаётся оператору вместе со списком заявок клиента, а разбираться, по какой из них сработала тревога, будет человек.

Для звонков без признаков опасности намерение клиента определяет функция classify_intent. Она разбирает текст ответа и решает, что нужно клиенту — новая заявка, действие по существующей заявке, консультация или сразу оператор. Если ответ не удалось разобрать, робот переспрашивает дважды, а на третий раз fallback_or_transfer сохраняет причину и передаёт звонок оператору.

Шаг 3. Собираем информацию, проверяем адрес и время

Когда во время разговора с роботом стал известен тип техники, выбирается список уточняющих вопросов — общий, для конкретного бренда или для конкретной модели. Из ответов клиента извлекается бренд, модель и код ошибки, а затем формирует короткое описание неисправности для мастера. В Битрикс24 передаётся рабочая формулировка вроде «Стиральная машина Samsung: не сливает воду; код ошибки 5C».

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

После этого сразу проверяется, входит ли адрес в стандартную зону выезда. Если адрес не проходит проверку, разговор передаётся оператору для ручного уточнения.

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

def finalize_address(conn, call_id: str, address: dict):
    update_request(conn, call_id, address=address)
    if not in_service_area(address):
        update_request(conn, call_id, status="manual_assignment")
        return to_operator(
            conn,
            call_id,
            "outside_service_area",
            "Этот адрес находится за пределами стандартной зоны выезда. "
            "Соединяю вас с оператором для уточнения.",
        )
    update_call(conn, call_id, stage="time")
    return reply(call_id, "time", "В какой день вам удобно принять мастера?")

При адресе за пределами зоны выезда заявке присваивается статус «требует ручного назначения». Звонок переводится на оператора, а клиент получается объяснение, что адрес за пределами стандартной зоны выезда и вопрос уточнит оператор.

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

Время можно назвать двумя способами: робот пришлёт готовые даты, либо назвать свободную фразу вроде «завтра утром», «в пятницу вечером» или дату в формате дд.мм.

Время в прошлом, интервал, в котором конец раньше начала, и визит вне рабочего окна с 09:00 до 20:00 отклоняются. Так мастер не получит заявку на время, когда приехать невозможно. При ошибке робот объясняет, что нужно уточнить, и диалог продолжается.

Шаг 4. Создаём карточку и предлагаем заявку мастеру

Собрав все данные, робот проговаривает клиенту итоговую заявку целиком — технику, неисправность, адрес, время визита — и запрашивает подтверждение. Как только клиент подтверждает заявку, создаётся или обновляется карточка в смарт‑процессе Битрикс24.

За это отвечает функция push_to_crm. Она находит контакт клиента в Битрикс24 или создаёт новый, если его ещё нет, заполняет поля карточки собранными за разговор данными и сохраняет номер созданной карточки в локальной базе. Если Битрикс24 в этот момент временно не отвечает, push_to_crm записывает ошибку в поле crm_error.

def push_to_crm(conn, call_id: str) -> str | None:
    call, req = get_call(conn, call_id), get_request(conn, call_id)
    if not bitrix.enabled or req.get("bitrix_item_id"):
        return req.get("bitrix_item_id")
    try:
        if not call["collected"].get("contact_id"):
            contact = bitrix.find_contact(call["phone"])
            contact_id = str(contact["ID"]) if contact else bitrix.create_contact(
                call["phone"], call.get("client_name"))
            merge_collected(conn, call_id, contact_id=contact_id)
        item_id = bitrix.create_item(bitrix_fields(call, req, None))
        update_request(conn, call_id, bitrix_item_id=item_id, crm_error=None)
        return item_id
    except Exception as exc:
        update_request(conn, call_id, crm_error=str(exc)[:300])
        return None

Когда карточка создана, find_master подбирает исполнителя из локального справочника masters в SQLite. Мастер должен уметь чинить эту технику и этот бренд, работать в районе клиента, в нужный день быть свободным в выбранное время и не быть перегруженным другими заявками. В базе сохраняется, кому и когда предлагалась эта заявка.

Если мастер не ответил за MASTER_RESPONSE_TIMEOUT_MINUTES, фоновая задача вызывает offer_next_master и предлагает заявку следующему подходящему мастеру. Когда подходящих кандидатов не остаётся, заявка уходит диспетчеру на ручное назначение.

Мастер работает с предложенной заявкой прямо в Битрикс24. Для этого в CRM заранее настраивается действие или бизнес‑процесс, который отправляет выбранный мастером ответ на /api/v1/bitrix/master‑response. Сервис поддерживает четыре действия:

  • accept — мастер берёт заявку в работу, а клиент получается СМС с подтверждением визита

  • not_specialization — это не его специализация, заявка ищет следующего мастера

  • propose_time — мастер предлагает другое время

  • need_info — мастеру нужны дополнительные данные

В двух последних случаях карточка уходит диспетчеру на ручной разбор диспетчеру.

Шаг 5. Отправляем уведомления и сохраняем итог звонка

Уведомления рассылаются через SMS API МТС Exolve. Первое СМС уходит сразу после подтверждения заявки клиентом — ещё во время самого разговора. Второе приходит позже, когда мастер принимает заявку в Битрикс24 — это может произойти после того, как звонок завершился. В обоих случаях send_sms перехватывает ошибку МТС Exolve и пишет её только в лог. Ошибка не прерывает ни обработку текущего запроса от робота, ни, тем более, завершённый звонок.

def send_sms(phone: str | None, text: str) -> bool:
    if not phone or not EXOLVE_API_KEY or not EXOLVE_SMS_SENDER:
        return False
    try:
        httpx.post(
            "https://api.exolve.ru/messaging/v1/SendSMS",
            headers={"Authorization": f"Bearer {EXOLVE_API_KEY}"},
            json={"number": EXOLVE_SMS_SENDER, "destination": phone, "text": text},
            timeout=15,
        ).raise_for_status()
        return True
    except Exception:
        logger.exception("Не удалось отправить SMS")
        return False

Когда звонок завершается, робот отправляет запрос /api/v1/robots/calls/finished со ссылками на аудиозапись и текстовую расшифровку разговора. Сервис сохраняет их у себя и строит краткую сводку по собранным полям заявки: неисправности, адресу, времени, опасным признакам и причине перевода на оператора, если он был.

В карточку в Битрикс24 обработчик завершения звонка добавляет ссылки на запись, сводку и текст расшифровки отдельным комментарием. Если карточки ещё нет и она появится позже через фоновую повторную отправку, то фоновая задача добавит ссылки и актуальную сводку в её поля при создании. А вот текст расшифровки отдельным комментарием фоновая задача не добавит. Это делает только обработчик завершения звонка, который срабатывает один раз.

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

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

Загрузите переменные окружения и запустите приложение из каталога проекта:

set -a && source .env && set +a
uvicorn main:app --host 0.0.0.0 --port 8000

Проверьте доступность без вызова внешних систем:

curl http://localhost:8000/health

Ответ покажет, заданы ли настройки CRM, СМС и геокодера. Эта проверка смотрит только на наличие нужных переменных окружения, а не делает реальных запросов к внешним сервисам:

{"status":"ok","crm":true,"sms":true,"geocoder":true}

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

Если ключи настроены, подтверждение заявки на шаге confirm создаст карточку в Битрикс24 и отправит клиенту СМС.

curl -X POST http://localhost:8000/api/v1/robots/calls/start \
-H "Content-Type: application/json" \
-H "X-API-Key: $ROBOTS_API_KEY" \
-d '{"call_id":"demo-call-001","client_phone":"+79990000000","source":"test"}'

В ответе придёт next_step: «intent» и первая реплика робота. Теперь проверим развилку: объявим новое обращение и передадим фразу с опасным признаком. В реальном звонке normalized_intent отдаёт голосовая платформа. Мы передаём его явно, чтобы проверить обработчик напрямую.

curl -X POST http://localhost:8000/api/v1/robots/calls/intent \
-H "Content-Type: application/json" \
-H "X-API-Key: $ROBOTS_API_KEY" \
-d '{"call_id":"demo-call-001","normalized_intent":"new_request","raw_text":"Стиральная машина не работает, пахнет гарью и идёт дым"}'

Ожидаемый результат — next_step: «operator», transfer_to: «emergency_operator» и признак urgent. Сервис сохраняет заявку со статусом urgent в SQLite, а если Битрикс24 настроен, пытается создать срочную карточку до передачи звонка.

Для обычной заявки после intent последовательно вызывайте appliance, issue, address, time и confirm с тем же call_id.

Если получаете 401, проверьте заголовки. Код 404 означает, что для call_id ещё не создана сессия.

В итоге

Получилось решение для сервисного центра, которое принимает обращения по телефону, пока диспетчер занят. Робот уточняет у клиента неисправность, адрес и удобное время визита и сохраняет ответы по ходу разговора. Если часть данных собрать не удалось, сотрудник видит в карточке в CRM, что осталось уточнить. Опасные ситуации, обращения за пределами зоны выезда и вопросы, которые робот не смог обработать, передаются оператору.

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

Код на гитхабе.

Комментарии (0)