惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
Netflix TechBlog - Medium
罗磊的独立博客
H
Help Net Security
I
Intezer
G
Google Developers Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
T
Troy Hunt's Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
U
Unit 42
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
N
News and Events Feed by Topic
J
Java Code Geeks
S
Security Affairs
T
The Blog of Author Tim Ferriss
Recent Commits to openclaw:main
Recent Commits to openclaw:main
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
D
Docker
The GitHub Blog
The GitHub Blog
F
Full Disclosure
N
News and Events Feed by Topic
Webroot Blog
Webroot Blog
S
Security @ Cisco Blogs
腾讯CDC
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
Blog — PlanetScale
Blog — PlanetScale
T
Threatpost
D
DataBreaches.Net
Recent Announcements
Recent Announcements
博客园 - 三生石上(FineUI控件)
MongoDB | Blog
MongoDB | Blog
博客园 - 【当耐特】
L
LINUX DO - 最新话题
Google Online Security Blog
Google Online Security Blog
S
Schneier on Security
S
Securelist
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Help Net Security
Help Net Security
P
Proofpoint News Feed
Project Zero
Project Zero
S
SegmentFault 最新的问题
H
Hackread – Cybersecurity News, Data Breaches, AI and More
MyScale Blog
MyScale Blog
Google DeepMind News
Google DeepMind News
宝玉的分享
宝玉的分享
Y
Y Combinator Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 叶小钗

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как сервисному бизнесу автоматизировать проверку качества обслуживания клиентов
Katner (Exol · 2026-05-21 · via Все публикации подряд на Хабре

Как сервисному бизнесу автоматизировать проверку качества обслуживания клиентов

Уровень сложностиСредний

Время на прочтение11 мин

Охват и читатели627

Туториал

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

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

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

Стек решения: Python 3.10+, Flask, requests, python-dotenv, SQLite, YCLIENTS API, голосовой робот и SMS API МТС Exolve, MWS Tables.

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

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

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

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

Если оценка услуги низкая, владельцу отправляется СМС, а результат записывается в таблицу. После этого по каждому визиту видно, дошёл ли клиент до конца опроса и какая была обратная связь.

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

Всё работает на одном Flask-сервисе, трёх внешних системах и локальной базе.

YCLIENTS хранит основные данные визита и присылает событие о его завершении. SQLite хранит текущее состояние опроса: статус, идентификатор звонка, ответы по шагам и отметку об отправке алерта. MWS Tables получают уже итог по визиту, когда опрос завершён.

App.py управляет статусами визита: принимает вебхуки, связывает их с нужной записью и запускает внешние вызовы. Основной ключ сценария — visit_id. Идентификатор звонка нужен, чтобы потом привязать колбэк голосового сценария к уже созданной записи.

Внешние вызовы вынесены в отдельные файлы: yclients_api.py читает визит из YCLIENTS, exolve_voice.py запускает звонок, sms_alerts.py отправляет СМС, tables_api.py пишет итог в таблицу.

Пререквизит

Нужен Python 3.10+: в коде используется синтаксис str | None. Токены, идентификатор таблицы и порог низкой оценки лежат в .env.

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Пример переменных окружения:

WEBHOOK_SECRET=change-me
YCLIENTS_BASE_URL=https://api.yclients.com/api/v1
YCLIENTS_PARTNER_TOKEN=***
YCLIENTS_USER_TOKEN=***
MWS_TABLES_BASE_URL=https://tables.mws.ru
MWS_TABLES_API_KEY=***
MWS_TABLE_ID=***
MWS_VIEW_ID=***
EXOLVE_API_KEY=***
EXOLVE_CAMPAIGN_ID=***
EXOLVE_CAMPAIGN_URL=https://api.exolve.ru/campaign/v1/Call
EXOLVE_SENDER=SalonBot
OWNER_ALERT_PHONE=79990001122
LOW_SCORE_THRESHOLD=2
DB_NAME=salon_quality.db

Для финальной записи результатов в MWS Tables заранее создайте таблицу quality_results_table со следующими колонками:

  • visit_id — строка

  • client_name — строка

  • master_name — строка

  • branch_name — строка

  • visit_at — дата/время

  • service_score — число

  • price_score — число

  • return_intent — число

  • survey_status — строка

  • alert_sent — логическое поле

В текущем коде секрет вебхука передаётся как параметр запроса token, поэтому сам URL вебхука тоже нельзя светить. Ключи API приложение читает через python-dotenv.

Шаг 1. Поднимаем конфиг и локальное состояние

Опрос не заканчивается одним событием, поэтому сервису нужна отдельная запись по каждому визиту. Состояние опроса сервис держит в Config и таблице surveys. В ней лежат контекст визита и технические поля опроса: status, call_id, три оценки и alert_sent. Первичный ключ на visit_id защищает от дублей.

# config.py

from dotenv import load_dotenv
import os


load_dotenv()


class Config:
   WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "super-secret-token")


   YCLIENTS_BASE_URL = os.environ.get("YCLIENTS_BASE_URL", "https://api.yclients.com/api/v1")
   YCLIENTS_PARTNER_TOKEN = os.environ.get("YCLIENTS_PARTNER_TOKEN", "***")
   YCLIENTS_USER_TOKEN = os.environ.get("YCLIENTS_USER_TOKEN", "***")


   MWS_TABLES_BASE_URL = os.environ.get("MWS_TABLES_BASE_URL", "https://tables.mws.ru")
   MWS_TABLES_API_KEY = os.environ.get("MWS_TABLES_API_KEY", "***")
   MWS_TABLE_ID = os.environ.get("MWS_TABLE_ID", "***")
   MWS_VIEW_ID = os.environ.get("MWS_VIEW_ID", "***")


   EXOLVE_API_KEY = os.environ.get("EXOLVE_API_KEY", "your_exolve_key")
   EXOLVE_CAMPAIGN_ID = os.environ.get("EXOLVE_CAMPAIGN_ID", "***")
   EXOLVE_CAMPAIGN_URL = os.environ.get("EXOLVE_CAMPAIGN_URL", "https://api.exolve.ru/campaign/v1/Call")
   EXOLVE_SENDER = os.environ.get("EXOLVE_SENDER", "SalonBot")
   OWNER_ALERT_PHONE = os.environ.get("OWNER_ALERT_PHONE", "79990001122")


   LOW_SCORE_THRESHOLD = int(os.environ.get("LOW_SCORE_THRESHOLD", 2))
   DB_NAME = os.environ.get("DB_NAME", "salon_quality.db")

Ниже минимальная схема таблицы, в которой сервис хранит состояние опроса.

# database.py

def init_db():
   with get_conn() as conn:
       conn.execute("""
       CREATE TABLE IF NOT EXISTS surveys (
           visit_id TEXT PRIMARY KEY,
           client_name TEXT,
           client_phone TEXT,
           master_name TEXT,
           branch_name TEXT,
           visit_at TEXT,
           status TEXT NOT NULL,
           call_id TEXT,
           q1_service_score INTEGER,
           q2_price_score INTEGER,
           q3_return_intent INTEGER,
           alert_sent INTEGER NOT NULL DEFAULT 0,
           created_at INTEGER NOT NULL,
           updated_at INTEGER NOT NULL
       )
       """)
       conn.commit()

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

Сервис использует такие статусы:

  • NEW — визит создан локально, опрос еще не запущен

  • CALL_STARTED — звонок запущен

  • Q1_ANSWERED — получен ответ на первый вопрос

  • Q2_ANSWERED — на второй

  • Q3_ANSWERED — и на третий

  • COMPLETED — опрос завершен

  • NO_ANSWER — клиент не ответил

  • FAILED — опрос завершился технической ошибкой

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

  • NEW -> CALL_STARTED -> Q1_ANSWERED -> Q2_ANSWERED -> Q3_ANSWERED -> COMPLETED

Если клиент не ответил:

  • NEW -> CALL_STARTED -> NO_ANSWER

Если звонок завершился ошибкой:

  • NEW -> CALL_STARTED -> FAILED

Шаг 2. Принимаем закрытый визит из YCLIENTS

Вебхук YCLIENTS сообщает, что визит закрыт. Дальше сервис проверяет token, смотрит на attendance == 1 и отдельно забирает карточку визита через get_visit(). В локальную БД он пишет уже нормализованные данные.

Пример входящего вебхука:

{
 "id": "98765",
 "company_id": "12345",
 "attendance": 1
}

Ниже сам обработчик этого вебхука.

# app.py

@app.route("/webhook/yclients/visit", methods=["POST"])
def yclients_webhook():
   if request.args.get("token") != Config.WEBHOOK_SECRET:
       return "Forbidden", 403


   data = request.get_json(silent=True) or {}
   visit_id = str(data.get("id") or "")
   company_id = str(data.get("company_id") or "")


   if not visit_id or not company_id:
       return "Bad Request: visit_id or company_id missing", 400


   if data.get("attendance") != 1:
       return jsonify({"status": "ignored_status"}), 200


   visit_raw = get_visit(company_id, visit_id)
   visit = normalize_visit(visit_raw)


   if not visit["client_phone"]:
      return jsonify({"status": "invalid_phone"}), 200


   created = create_survey_if_new(visit)
   if not created:
      return jsonify({"status": "duplicate"}), 200

После первичной проверки сервис отдельно нормализует ответ YCLIENTS и дальше работает уже с одной и той же структурой визита.

# yclients_api.py

def normalize_phone(phone: str | None) -> str | None:
   if not phone:
       return None
   digits = "".join(ch for ch in str(phone) if ch.isdigit())
   if len(digits) == 11 and digits.startswith("8"):
       digits = "7" + digits[1:]
   return digits if len(digits) == 11 and digits.startswith("7") else None


def normalize_visit(visit: dict) -> dict:
   return {
       "visit_id": str(visit.get("id")),
       "client_name": visit.get("client", {}).get("name"),
       "client_phone": normalize_phone(visit.get("client", {}).get("phone")),
       "master_name": visit.get("staff", {}).get("name"),
       "branch_name": visit.get("company", {}).get("title"),
       "visit_at": visit.get("datetime")
   }

После нормализации в локальную базу попадает карточка визита с клиентом, мастером, филиалом и временем посещения. Повторный вебхук не создаёт дубль, потому что запись привязана к visit_id. Если телефон не проходит нормализацию, сервис возвращает invalid_phone. Завершённым визит считает только по attendance == 1.

Шаг 3. Запускаем голосовой опрос через МТС Exolve

Когда визит уже записан локально, сервис запускает по этому клиенту голосовую кампанию в МТС Exolve.

Скриншот схемы голосового робота МТС Exolve

Скриншот схемы голосового робота МТС Exolve

В запрос уходит не только телефон, но и initialData.visit_id. Этот ключ нужен, чтобы потом связать колбэк с нужным визитом. В ответе от МТС Exolve сервис дополнительно сохраняет call_id как идентификатор звонка для обратного маршрута вебхука и переводит запись в статус CALL_STARTED.

# exolve_voice.py

def start_quality_campaign(phone: str, visit_id: str) -> dict:
   headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"}
   payload = {
       "campaign_id": Config.EXOLVE_CAMPAIGN_ID,
       "params": {
           "destination": phone,
           "initialData": {"visit_id": visit_id}
       }
   }
   resp = requests.post(Config.EXOLVE_CAMPAIGN_URL, headers=headers, json=payload, timeout=20)
   resp.raise_for_status()
   return resp.json()

Дальше сервис сохраняет идентификатор звонка в SQLite и переводит визит в статус CALL_STARTED.

# app.py

response = start_quality_campaign(visit["client_phone"], visit["visit_id"])
call_id = response.get("call_id") or response.get("id")
update_survey_status(visit["visit_id"], "CALL_STARTED", call_id=call_id)
return jsonify({"status": "accepted"}), 200

На этом шаге сервис создаёт звонок и сохраняет два ключа: идентификатор визита и идентификатор звонка. Если вызов к МТС Exolve не проходит из-за сетевой ошибки или не пройденной авторизации, вебхук YCLIENTS не завершается штатно. Ограничение здесь прямое: вызов к МТС Exolve идёт синхронно внутри вебхука.

Шаг 4. Принимаем ответы DTMF и ведём состояние опроса

Во втором вебхуке сервис получает не итог опроса целиком, а отдельные шаги: вопрос, цифру ответа и статус звонка. q1_service_score хранит оценку услуги, q2_price_score — оценку цены, q3_return_intent — готовность вернуться. Отдельные ветки NO_ANSWER и FAILED нужны, чтобы отличать отсутствие ответа от технического сбоя.

Маршрут /webhook/exolve/survey снова проверяет token, приводит тело запроса к внутреннему контракту через normalize_callback, ищет опрос по call_id или visit_id, преобразует dtmf_digit в число и сохраняет ответ в одно из трёх полей состояния.

Пример колбэка, который отправляет голосовой сценарий:

{
 "visit_id": "98765",
 "call_id": "call_001",
 "current_step": "q2",
 "dtmf_digit": "4",
 "result_status": "COMPLETED"
}

Дальше сервис приводит такой колбэк к короткому внутреннему формату.

# exolve_voice.py

def normalize_callback(payload: dict) -> dict:
   return {
       "call_id": payload.get("call_id"),
       "visit_id": payload.get("visit_id"),
       "step": payload.get("current_step"),
       "digit": payload.get("dtmf_digit"),
       "result_status": payload.get("result_status")
   }

После нормализации у обработчика остаются пять полей: шаг, цифра, статус, visit_id и call_id.

# app.py

if cb.get("result_status") == "NO_ANSWER":
   update_survey_status(visit_id, "NO_ANSWER")
   return jsonify({"status": "processed"}), 200


if cb.get("result_status") == "FAILED":
   update_survey_status(visit_id, "FAILED")
   return jsonify({"status": "processed"}), 200 


if step == "q1":
   update_survey_status(visit_id, "Q1_ANSWERED", q1_service_score=digit)
   return jsonify({"status": "step_saved"}), 200




if step == "q2":
   update_survey_status(visit_id, "Q2_ANSWERED", q2_price_score=digit)
   return jsonify({"status": "step_saved"}), 200 


if step == "q3":
   update_survey_status(visit_id, "Q3_ANSWERED", q3_return_intent=digit)
   finalize_survey(visit_id)
   return jsonify({"status": "step_saved"}), 200

На этом шаге сервис либо сохраняет ответ, либо переводит опрос в терминальный статус. Прикладные ошибки здесь простые: 404, если визит не найден, и 400, если пришёл неверный шаг или цифра. Код проверяет только int, но не диапазон оценки.

Шаг 5. Финализируем опрос, отправляем СМС и пишем результат в таблицу

Когда опрос заканчивается, сервис читает запись из SQLite, при низкой оценке отправляет СМС владельцу и затем пишет итог по визиту в таблицу.

Скриншот из MWS Tables

Скриншот из MWS Tables

Функция finalize_survey читает запись из БД и сначала проверяет первую оценку. Если q1_service_score меньше или равен LOW_SCORE_THRESHOLD, сервис вызывает send_low_score_alert через SMS API МТС Exolve. Затем из локальной записи сервис собирает итог по визиту и отправляет его в таблицу.

# app.py

def finalize_survey(visit_id: str):
   survey = get_survey(visit_id)
   if not survey:
       return


   is_alert_sent = False


   if survey["q1_service_score"] is not None and survey["q1_service_score"] <= Config.LOW_SCORE_THRESHOLD:
   try:
       send_low_score_alert(
           survey["master_name"],
           survey["client_name"],
           survey["q1_service_score"]
       )
       is_alert_sent = True
   except Exception:
       is_alert_sent = False


   record = {
       "visit_id": survey["visit_id"],
       "client_name": survey["client_name"],
       "master_name": survey["master_name"],
       "branch_name": survey["branch_name"],
       "visit_at": survey["visit_at"],
       "service_score": survey["q1_service_score"],
       "price_score": survey["q2_price_score"],
       "return_intent": survey["q3_return_intent"],
       "survey_status": "COMPLETED",
       "alert_sent": is_alert_sent
   }

На этом месте app.py уже собрал финальный record. Дальше один клиент отправляет СМС владельцу, а второй пишет итог по визиту в таблицу.

# sms_alerts.py

headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"}
payload = {"number": Config.EXOLVE_SENDER, "destination": Config.OWNER_ALERT_PHONE, "text": text}
resp = requests.post(url, headers=headers, json=payload, timeout=20)
resp.raise_for_status()

В таблицу уходит уже итог по визиту: оценки, статус опроса и отметка об алерте.

# tables_api.py

headers = {"Authorization": f"Bearer {Config.MWS_TABLES_API_KEY}", "Content-Type": "application/json"}
payload = {
   "records": [{"fields": record}],
   "fieldKey": "name",
}
resp = requests.post(url, params=params, headers=headers, json=payload, timeout=20)
resp.raise_for_status()

На этом шаге сервис отправляет СМС владельцу и пишет строку в таблицу. SQLite остаётся хранилищем состояния, а команда смотрит итог уже в Таблицах. Если любой вызов в этом блоке падает, визит переходит в FAILED. Общей транзакции между СМС, записью в таблицу и локальным статусом здесь нет.

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

Чтобы прогнать сценарий целиком, понадобятся токены YCLIENTS, МТС Exolve и идентификаторы таблицы. После заполнения .env достаточно поднять Flask-приложение и либо закрыть тестовый визит в YCLIENTS, либо отправить вебхук вручную.

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
flask --app app run --port 5000

Если проверяем первый маршрут вручную, отправляем запрос на вебхук YCLIENTS. При валидном секрете и корректных внешних токенах ожидаем {"status":"accepted"}.

curl -X POST "http://127.0.0.1:5000/webhook/yclients/visit?token=super-secret-token" \
 -H "Content-Type: application/json" \
 -d '{"id":"98765","company_id":"12345","attendance":1}'

Когда запись уже создана, колбэк от голосового сценария можно эмулировать отдельно. Для локальной проверки удобно отправлять visit_id без call_id, тогда маршрут ищет опрос напрямую по визиту. После первого вебхука в SQLite уже должна лежать строка со статусом CALL_STARTED и сохранённым call_id, если внешний вызов кампании отработал штатно.

curl -X POST "http://127.0.0.1:5000/webhook/exolve/survey?token=super-secret-token" \
 -H "Content-Type: application/json" \
 -d '{"visit_id":"98765","current_step":"q1","dtmf_digit":"2","result_status":"COMPLETED"}'

После колбэка с q1 строка должна перейти в Q1_ANSWERED и получить q1_service_score. После колбэка с q3 сервис либо завершит опрос статусом COMPLETED и создаст запись в таблице, либо пометит визит как FAILED, если финализация не прошла. Проверять в итоге нужно три точки: переходы status в SQLite, СМС на OWNER_ALERT_PHONE при низкой оценке и итоговую запись в таблице после третьего шага.

Если приходят 4xx, смотрим на token, company_id, visit_id и формат цифры. Если получаем 5xx, причина обычно в сетевой ошибке при вызове YCLIENTS, МТС Exolve или Таблиц.

В итоге

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

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

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

  • Добавить повторный контакт по клиентам, которые не ответили на первый звонок

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

  • После низкой оценки не только слать СМС владельцу, но и ставить задачу на обратный звонок или разбор кейса

  • Смотреть результаты отдельно по филиалам, мастерам, услугам и времени визита

  • Сделать разные сценарии для новых и постоянных клиентов

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

  • Запускать после низкой оценки отдельный сценарий удержания: быстрый звонок, извинение или повторный визит

  • Интегрироваться с CRM и другими системами, если команда уже работает в ней

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