Привет, Хабр! Сегодня покажем, как буквально за пару вечеров собрать систему, которая расшифровывает звонки, анализирует речь операторов и присылает руководителю отчёт в Telegram.
Например, в кол-центре с 15 операторами такая сводка поможет руководителю быстро понять, кто перегружен, где чаще звучит негатив, а кто просто слишком много говорит. Не надо слушать записи — отчёт сам всё рассказывает.
📊 Отчёт за 19 июля
🎧 Оператор дня: Иван Иванов (emotionScore: 0.42)
🥵 Больше всего негатива: Юлия Тестова (33%)
🗣️ Средняя скорость речи: 132 слов/мин
🤯 Самый «говорящий»: Андрей Максимов (74% времени)
🚨 Перебиваний в среднем: 2,7 на звонок
Как работает система
Решение написано на PHP и MySQL, использует cron и подключается к API МТС Exolve. Для каждого завершённого звонка автоматически собираются:
-
Расшифровка диалога — кто и что сказал
-
Параметры анализа речи — эмоции, соотношение продолжительности речи и тишины, скорость, перебивания и другие показатели
На основе этих данных система:
-
Сохраняет информацию в базу данных
-
Раз в сутки запускает скрипт
-
Рассчитывает метрики по каждому оператору:
-
уровень эмоциональности — emotionScore
-
среднюю скорость речи
-
речевой баланс — кто говорил больше
-
количество перебиваний
-
-
Анализирует общий уровень негатива
-
Отправляет итоговый отчёт в Telegram
Теперь разберём подробнее.
Создание и настройка проекта
Определим структуру проекта, настроим автозагрузку через Composer, подключим библиотеки для работы с .env и HTTP-запросами, пропишем переменные окружения, создадим базу данных и таблицы для хранения операторов и звонков.
Структура, зависимости, .env и создание БД ▼
Скрытый текст
Структура проекта
voice_analytics/ ├── app/ │ ├── Infrastructure/ │ │ └── Database.php # Обёртка над PDO. Подключение к MySQL, Singleton │ ├── Repository/ │ │ ├── CallRepository.php # Работа с таблицей звонков (сохранение, метрики) │ │ └── OperatorRepository.php # Поиск оператора по номеру │ ├── Service/ │ │ ├── CallService.php # Логика обработки звонков │ │ ├── ExolveApiService.php # Интеграция с внешним API Exolve │ │ ├── ReportService.php # Генерация метрик по операторам │ │ └── ReportSenderService.php # Отправка отчёта в Telegram ├── cron/ │ └── send_report.sh # Shell-скрипт для запуска генерации и отправки отчёта ├── artisan.php # Точка входа CLI-приложения ├── dump.sql # SQL-дамп: структура базы данных ├── composer.json # Composer-зависимости и автозагрузка └── .env # Конфигурация окружения (БД, ключи API, Telegram)
Создание проекта и установка зависимостей
Создаём структуру проекта, инициализируем Composer и устанавливаем библиотеки для работы с переменными окружения и API-запросами.
Структура и инициализация Composer:
mkdir voice_analytics cd voice_analytics composer init composer require vlucas/phpdotenv guzzlehttp/guzzle
Composer.json:
{ "autoload": { "psr-4": { "App\\": "app/" } }, "require": { "vlucas/phpdotenv": "^5.6", "guzzlehttp/guzzle": "^7.9" } }
Генерация автозагрузки:
composer dump-autoload
Конфигурация переменных окружения
Создаём файл .env в корне проекта и заполняем переменные доступа к MySQL, API-ключ приложения МТС Exolve, токен для доступа к телеграм-боту и идентификатор чата, куда будет приходить отчёт.
DB_HOST=ваш хост DB_PORT=ваш порт DB_NAME=voice_analytics DB_USER=root DB_PASS=root EXOLVE_API_KEY=ваш_ключ TELEGRAM_BOT_TOKEN=токен_вашего_бота TELEGRAM_CHAT_ID=id_вашего_чата
Создание базы данных и таблицы
Для хранения расшифровок звонков и расчёта метрик нам понадобятся две таблицы: одна — с операторами, другая — с параметрами каждого звонка. Используем минимальную, но достаточную структуру. Подключаемся к своей MySQL и выполняем четыре команды:
CREATE DATABASE voice_analytics CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE voice_analytics; -- Таблица операторов CREATE TABLE operators ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE ); -- Таблица звонков CREATE TABLE calls ( id INT AUTO_INCREMENT PRIMARY KEY, call_id VARCHAR(100) NOT NULL UNIQUE, operator_id INT, call_date DATETIME NOT NULL, emotion_score DECIMAL(4, 2), speech_rate DECIMAL(5, 2), speech_duration_operator INT DEFAULT 0, speech_duration_client INT DEFAULT 0, interruptions INT DEFAULT 0, sentiment VARCHAR(20), transcript_text TEXT, FOREIGN KEY (operator_id) REFERENCES operators (id) ON DELETE SET NULL );
Как работает приложение
В этой части разберём ключевые модули, которые собирают аналитику по звонкам и отправляют отчёт. Код разделён по слоям:
-
Infrastructure — подключение к базе данных
-
Repository — доступ к таблицам звонков и операторов
-
Service — логика обработки звонков, взаимодействие с API и генерация отчётов
-
artisan.php — точка входа для запуска вручную или через cron
-
cron/ — обёртка для автоматического запуска отчёта по расписанию
Теперь пройдёмся по каждому компоненту.
Подключение к базе данных
Файл: app/Infrastructure/Database.php
Компонент Database создаёт и переиспользует подключение к MySQL через PDO. Используется шаблон Singleton, все параметры берутся из .env.
<?php namespace app\Infrastructure; use PDO; use PDOException; use RuntimeException; final class Database { private static ?PDO $pdo = null; private function __construct() { } public static function getConnection(): PDO { if (self::$pdo === null) { $host = getenv('DB_HOST') ?? null; $port = getenv('DB_PORT') ?? '3306'; $db = getenv('DB_NAME') ?? null; $user = getenv('DB_USER') ?? null; $pass = getenv('DB_PASS') ?? null; if (!$host || !$db || !$user) { throw new RuntimeException('Конфигурация базы данных отсутствует в переменных среды.'); } $dsn = sprintf( 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $db ); try { self::$pdo = new PDO( $dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ] ); } catch (PDOException $e) { throw new RuntimeException('Подключение к базе данных не удалось: ' . $e->getMessage()); } } return self::$pdo; } }
Работа с данными
Состоит из двух компонентов: один отвечает за сохранение аналитики звонков и расчёт метрик для отчётов, другой — за поиск оператора по номеру телефона. Простая обвязка поверх базы, без лишней логики.
Сохранение звонков и расчёт метрик
Файл: app/Repository/CallRepository.php
Это репозиторий для работы с таблицей calls в базе данных. В нём хранятся данные по каждому завершённому звонку и собираются метрики по операторам за последние 24 часа: эмоции, перебивания, речевой баланс и доля негатива.
<?php namespace app\Repository; use app\Infrastructure\Database; use DateTimeImmutable; use DateTimeZone; use Exception; use PDO; class CallRepository { private PDO $pdo; public function __construct() { $this->pdo = Database::getConnection(); } public function saveCall(array $data): bool { $stmt = $this->pdo->prepare(" INSERT INTO calls ( call_id, operator_id, call_date, emotion_score, speech_rate, speech_duration_operator, speech_duration_client, interruptions, sentiment, transcript_text ) VALUES ( :call_id, :operator_id, :call_date, :emotion_score, :speech_rate, :speech_duration_operator, :speech_duration_client, :interruptions, :sentiment, :transcript_text ) "); return $stmt->execute([ ':call_id' => $data['call_id'], ':operator_id' => $data['operator_id'], ':call_date' => $data['call_date'], ':emotion_score' => $data['emotion_score'], ':speech_rate' => $data['speech_rate'], ':speech_duration_operator' => $data['speech_duration_operator'], ':speech_duration_client' => $data['speech_duration_client'], ':interruptions' => $data['interruptions'], ':sentiment' => $data['sentiment'], ':transcript_text' => is_array($data['transcript_text']) ? json_encode($data['transcript_text']) : $data['transcript_text'], ]); } /** * Получение метрик по операторам за заданную дату * @throws Exception */ public function getOperatorMetrics(): array { $now = new DateTimeImmutable('now', new DateTimeZone('Europe/Moscow')); $yesterday = $now->modify('-1 day')->format('Y-m-d H:i:s'); $sql = " SELECT o.id, o.name, COUNT(c.id) AS calls_count, IFNULL(AVG(c.emotion_score), 0) AS avg_emotion_score, IFNULL(AVG(c.interruptions), 0) AS avg_interruptions, IFNULL(AVG( CASE WHEN (c.speech_duration_operator + c.speech_duration_client) > 0 THEN c.speech_duration_operator / (c.speech_duration_operator + c.speech_duration_client) * 100 ELSE 0 END ), 0) AS avg_speech_balance, IFNULL(AVG(c.speech_rate), 0) AS avg_speech_rate, IFNULL( SUM( CASE WHEN c.emotion_score < :negativeScore OR c.sentiment = :negativeSentiment THEN 1 ELSE 0 END ) / COUNT(c.id) * 100, 0 ) AS negative_calls_percent FROM operators o LEFT JOIN calls c ON o.id = c.operator_id AND c.call_date >= :yesterday GROUP BY o.id "; $stmt = $this->pdo->prepare($sql); $stmt->execute([ ':negativeScore' => -0.4, ':negativeSentiment' => 'negative', ':yesterday' => $yesterday, ]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } public function exists(string $callId): bool { $stmt = $this->pdo->prepare("SELECT 1 FROM calls WHERE call_id = :call_id LIMIT 1"); $stmt->execute([':call_id' => $callId]); return (bool) $stmt->fetchColumn(); } }
Поиск оператора по номеру
Файл: app/Repository/OperatorRepository.php
Простая обвязка над таблицей operators. Находит ID оператора по номеру телефона. Используется при обработке звонков.
<?php namespace app\Repository; use app\Infrastructure\Database; class OperatorRepository { protected Database $pdo; public function __construct() { $this->pdo = Database::getConnection(); } public function getOperatorIdByPhone(string $phone): ?int { if (!$phone) { return null; } $stmt = $this->pdo->prepare("SELECT id FROM operators WHERE phone = ?"); $stmt->execute([$phone]); $row = $stmt->fetch(); return $row['id'] ?? null; } }
Обработка звонков
Файл: app/Service/CallService.php
Компонент вызывается при завершении разговора: получает данные по звонкам из телеком API, извлекает нужные параметры и сохраняет всё в базу. Используется в вебхук-телефонии или в ручной обработке.
<?php namespace App\Service; use app\Repository\CallRepository; use app\Repository\OperatorRepository; /** * Обрабатывает завершённые звонки: получает аналитические данные из Exolve API и сохраняет в базу. * * Метод handleCall вызывается при завершении звонка (например, через webhook от системы телефонии). */ class CallService { public function __construct( private ExolveApiService $api, private CallRepository $callRepository, private OperatorRepository $operatorRepository ) {} /** * Обрабатывает завершение звонка и сохраняет аналитику. */ public function handleCall(string $callId): bool { $data = $this->api->getSpeechAnalytics($callId); if (empty($data['speech_analytic'])) { error_log("CallService: Пустой ответ по $callId"); return false; } $analytic = $data['speech_analytic']; $operatorPhone = $analytic['from'] ?? ''; $operatorId = $this->operatorRepository->getOperatorIdByPhone($operatorPhone); $speakerStats = $analytic['conversation_statistics']['speaker_statistics'] ?? []; $operatorSpeech = 0.0; $clientSpeech = 0.0; $speechRate = null; foreach ($speakerStats as $speaker) { $duration = $this->parseDuration($speaker['total_speech_duration'] ?? '0s'); if ($speaker['channel_tag'] === '0') { $clientSpeech = $duration; } elseif ($speaker['channel_tag'] === '1') { $operatorSpeech = $duration; $speechRate = $speaker['speech_speed']['avg'] ?? null; } } $transcript = $analytic['transcription']['phrases'] ?? []; $transcriptJson = json_encode($transcript, JSON_UNESCAPED_UNICODE); if (json_last_error() !== JSON_ERROR_NONE) { error_log("CallService: Ошибка сериализации транскрипта для $callId: " . json_last_error_msg()); $transcriptJson = null; } $emotionScore = $this->calculateEmotionScore($analytic['conversation_summary']['quiz'] ?? []); $interruptions = array_sum(array_map( fn($speaker) => (int)($speaker['total_interrupts_count'] ?? 0), $analytic['interrupts_statistics']['speaker_interrupts'] ?? [] )); if ($this->callRepository->exists($callId)) { error_log("CallService: запись с call_id=$callId уже существует, пропуск"); return false; } return $this->callRepository->saveCall([ 'call_id' => $callId, 'operator_id' => $operatorId, 'call_date' => $analytic['start_time'] ?? date('Y-m-d H:i:s'), 'emotion_score' => $emotionScore, 'speech_rate' => $speechRate, 'speech_duration_operator' => $operatorSpeech, 'speech_duration_client' => $clientSpeech, 'interruptions' => $interruptions, 'sentiment' => $this->extractFirstStatement($analytic['summarization']['statements'] ?? []), 'transcript_text' => $transcriptJson, ]); } /** * Возвращает первое утверждение (summary) из блока statements. */ private function extractFirstStatement(array $statements): ?string { return $statements[0]['response'] ?? null; } /** * Переводит строковую длительность (например, "12s") в float-секунды. */ private function parseDuration(string $duration): float { if (preg_match('/([\d.]+)/', $duration, $matches)) { return (float)$matches[1]; } return 0.0; } /** * Вычисляет условный эмоциональный индекс на основе ответов в quiz. */ private function calculateEmotionScore(array $quiz): ?float { $positive = [ 'Оператор был вежливым?', 'Оператор был эмпатичным?', 'Оператор был уверенным?', 'Клиент остался доволен?', ]; $negative = [ 'Оператор был раздражен?', 'Оператор хамил?', 'Клиент ушел раздраженным?', 'Клиент хамил?', ]; $score = 0; $total = 0; foreach ($quiz as $item) { $question = preg_replace('/^\d+\.\s*/', '', $item['request'] ?? ''); $answer = mb_strtolower($item['response'] ?? ''); if (in_array($question, $positive, true)) { $score += str_contains($answer, 'да') ? 1 : 0; $total++; } elseif (in_array($question, $negative, true)) { $score += str_contains($answer, 'да') ? 0 : 1; $total++; } } return $total > 0 ? round($score / $total, 2) : null; } }
Получение речевой аналитики
Файл: app/Service/ExolveApiService.php
Сервис инкапсулирует взаимодействие с API МТС Exolve: отправляет HTTP-запрос с call_id, получает JSON-ответ и возвращает его в виде массива. Использует Guzzle, добавляет заголовки и обрабатывает ошибки.
<?php namespace App\Service; use GuzzleHttp\Client; use RuntimeException; class ExolveApiService { private Client $client; private string $apiKey; public function __construct() { $this->apiKey = getenv('EXOLVE_API_KEY') ?? ''; if (empty($this->apiKey)) { throw new RuntimeException('EXOLVE_API_KEY не задан.'); } $this->client = new Client([ 'base_uri' => 'https://api.exolve.ru', 'headers' => [ 'Authorization' => 'Bearer ' . $this->apiKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'timeout' => 10.0, ]); } public function getSpeechAnalytics(string $callId): ?array { $response = $this->client->post('/statistics/call-record/v1/GetSpeechAnalytic', [ 'json' => ['call_id' => $callId] ]); if ($response->getStatusCode() !== 200) { throw new \RuntimeException("Exolve API вернул код: " . $response->getStatusCode()); } $data = json_decode($response->getBody()->getContents(), true); if (!is_array($data)) { throw new \RuntimeException("Некорректный JSON от Exolve API"); } return $data; } }
Формирование отчёта по метрикам
Файл: app/Service/ReportService.php
Компонент получает агрегированные метрики из базы и формирует итоговый отчёт: сколько было звонков, каков уровень эмоций, перебиваний, речевой баланс и доля негатива по каждому оператору. Этот текст будет отправлен в Telegram.
<?php namespace App\Service; use App\Repository\CallRepository; class ReportService { public function __construct( private CallRepository $callRepository, ) {} public function getMetrics(): array { return $this->callRepository->getOperatorMetrics(); } public function formatReport(array $metrics): string { $lines = []; foreach ($metrics as $m) { $lines[] = "Оператор: {$m['name']}"; $lines[] = "Звонков: {$m['calls_count']}"; $lines[] = "Средний emotionScore клиента: " . number_format($m['avg_emotion_score'], 2); $lines[] = "Среднее количество перебиваний: " . number_format($m['avg_interruptions'], 2); $lines[] = "Речевой баланс (% времени говорит оператор): " . number_format($m['avg_speech_balance'], 2) . "%"; $lines[] = "Средняя скорость речи: " . number_format($m['avg_speech_rate'], 2); $lines[] = "Доля негативных звонков: " . number_format($m['negative_calls_percent'], 2) . "%"; $lines[] = "-------------------------------"; } return implode("\n", $lines); } }
Отправка отчёта
Файл: app/Service/ReportSenderService.php
Сервис использует Telegram Bot API для отправки отчёта в указанный чат. Берёт токен и chat_id из .env, собирает HTTP-запрос и отправляет сообщение через file_get_contents.
<?php namespace App\Service; class ReportSenderService { private string $botToken; private string $chatId; public function __construct() { $this->botToken = getenv('TELEGRAM_BOT_TOKEN'); $this->chatId = getenv('TELEGRAM_CHAT_ID'); } public function sendMessage(string $text): bool { $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $url = "https://api.telegram.org/bot{$this->botToken}/sendMessage"; $data = [ 'chat_id' => $this->chatId, 'text' => $text, 'parse_mode' => 'HTML', ]; $options = [ 'http' => [ 'header' => "Content-type: application/x-www-form-urlencoded\r\n", 'method' => 'POST', 'content' => http_build_query($data), 'timeout' => 5, ], ]; $context = stream_context_create($options); $result = file_get_contents($url, false, $context); return $result !== false; } }
Точка входа и запуск по расписанию
Скрипт artisan.php собирает все компоненты: загружает переменные окружения, получает метрики, формирует отчёт и отправляет его в Telegram. Для автоматизации создаём shell-обёртку и настраиваем запуск по cron — отчёт будет приходить каждый день в 20:00.
Скрипт artisan.php — это самостоятельная точка входа, которая:
-
Загружает необходимые классы через autoload.php
-
Создаёт репозиторий и сервисы
-
Получает метрики за последние 24 часа
-
Формирует отчёт
-
Отправляет его в Telegram через бот
-
Выводит результат в консоль
<?php require_once __DIR__ . '/vendor/autoload.php'; use Dotenv\Dotenv; use App\Repository\CallRepository; use App\Service\ReportService; use App\Service\ReportSenderService; $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); $dotenv->load(); // Создаём репозиторий и сервисы $callRepository = new CallRepository(); $reportService = new ReportService($callRepository); $reportSender = new ReportSenderService(); try { // Получаем метрики за последние 24 часа $metrics = $callRepository->getOperatorMetrics(); // Формируем текст отчёта $reportText = $reportService->formatReport($metrics); // Отправляем в Telegram $sent = $reportSender->sendMessage($reportText); if ($sent) { echo "Отчёт успешно отправлен.\n"; } else { echo "Ошибка при отправке отчёта.\n"; } } catch (Throwable $e) { echo "Ошибка: " . $e->getMessage() . "\n"; }
Создаём файл cron/report.sh со следующим содержимым:
#!/bin/bash php /var/www/html/artisan.php
Чтобы запускать скрипт ежедневно в 20:00, добавляем следующую строку в crontab пользователя www-data. Открываем редактор crontab и добавляем строку:
0 20 * * * /var/www/html/cron/report.sh
Теперь отчёт формируется и отправляется в Telegram автоматически каждый день в 20:00.
Скриншот отчёта в Telegram

Заключение
Теперь каждый завершённый звонок обрабатывается автоматически, аналитика по речи сохраняется в базу, а в 20:00 формируется отчёт с ключевыми метриками по каждому оператору. Готовый отчёт уходит в Telegram. Всё работает фоном и требует минимум поддержки.
Решение полезное и простое в установке: никакой тяжёлой инфраструктуры, только PHP, MySQL и cron. Это экономит время руководителя и даёт объективную картину по каждому оператору. Код — в гитхабе.
Идеи для развития
-
Создать аналитику по каждому оператору, построить лидерборд и вывести на экран или рассылать всем операторам.
-
Связать с метриками успеха продаж, оценки клиентов или повторных обращений на ту же тему. Так получится выявить результативные паттерны поведения.
-
Добавить анализ расшифровок через внешнюю LLM для более глубокого понимания. Такой пример мы разбирали в другой статье.
ссылка на оригинал статьи https://habr.com/ru/articles/940790/
Добавить комментарий