Привет, Хабр! Сегодня покажем, как буквально за пару вечеров собрать систему, которая расшифровывает звонки, анализирует речь операторов и присылает руководителю отчёт в 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 для более глубокого понимания. Такой пример мы разбирали в другой статье.