Привет! Сегодня покажем, как буквально за пару вечеров собрать систему, которая расшифровывает звонки, анализирует речь операторов и присылает руководителю отчёт в 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 appInfrastructure;
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 appRepository;
use appInfrastructureDatabase;
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 appRepository;
use appInfrastructureDatabase;
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 AppService;
use appRepositoryCallRepository;
use appRepositoryOperatorRepository;
/**
* Обрабатывает завершённые звонки: получает аналитические данные из 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 AppService;
use GuzzleHttpClient;
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 AppService;
use AppRepositoryCallRepository;
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 AppService;
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-urlencodedrn",
'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 DotenvDotenv;
use AppRepositoryCallRepository;
use AppServiceReportService;
use AppServiceReportSenderService;
$dotenv = DotenvDotenv::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 для более глубокого понимания. Такой пример мы разбирали в другой статье.
Автор: Katner
