В нашей компании анализируются звонки менеджеров отдела продаж для оценки их эффективности, устранения недочётов и улучшения сервиса. На сегодняшний день это составляет немалый массив ручной работы, для облегчения которой мы задумали привлечь технологии искусственного интеллекта. Идея следующая: забираем записи звонков, распознаём речь (преобразовываем в текст), подключаем LLM для анализа текста, знакомимся с выводами, при необходимости (например, возникновении каких-то аномалий) контролируем происходящее вручную.
Распознавание аудио решили делать через сервис Speech2Text, пример использования API которого я и покажу в этой статье.
В черновом варианте получаем примерно следующую схему работы (нас сейчас интересует прямоугольник с подписью Speech2Text connector):
Реализовывать взаимодействие будем в C# ASP.NET Core приложении. Очевидно, непосредственно обработка данных выполняется в backend, а для удобного управления можно будет использовать пользовательский интерфейс (frontend). Штош, приступим!
Служба взаимодействия с сервисом описывается следующим интерфейсом:
/// <summary> /// Interface for Speech2Text interaction service. /// </summary> public interface ISpeechToText { /// <summary> /// Creates a task on the Speech2Text server. /// </summary> /// <param name="payload">StreamContent containing the audio file. /// Will be disposed after use.</param> /// <returns>Task ID on the server or NULL in case of error.</returns> Task<string?> SendTaskAsync(StreamContent payload); /// <summary> /// Checks the status of a task on the Speech2Text server. /// </summary> /// <param name="taskId">Required task ID received from the server when creating the task.</param> /// <returns>JobStatus.Decoding if the task is in progress; /// JobStatus.Decoded if the task was successfully processed; /// JobStatus.FailedToDecode if processing failed; /// null if the server response was not received or recognized, or in case of other errors. /// </returns> Task<JobStatus?> GetTaskStatusAsync(string taskId); /// <summary> /// Gets the processing result of a task from the Speech2Text server. /// </summary> /// <param name="taskId">Required task ID received from the server when creating the task.</param> /// <returns>A string containing the processing result, /// or null in case of an error.</returns> Task<string?> GetTaskResultAsync(string taskId); }
Настройки подключения, такие как адрес сервера и ключ API, будем сохранять в appsettings.json и передавать их, используя инъекцию зависимостей, в чём нам поможет Options pattern. Опишем модель для маппинга настроек:
public class SpeechToTextApiSettings { public static string SectionName { get; } = "Speech2textApi"; public string BaseUrl { get; set; } = string.Empty; public string TaskUrl { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty; /// <summary> /// HttpClient timeout in minutes /// </summary> public int Timeout { get; set; } = 1; }
и добавим сами настройки в appsettings.json(danger: ключ API здесь лежит на виду, вы знаете, что с этим делать):
"Speech2textApi": { "BaseUrl": "https://speech2text.ru/api/recognitions", "TaskUrl": "https://speech2text.ru/api/recognitions/task/file", "ApiKey": "MY-API-KEY-HERE", "Timeout": 1 }
Заготовка реализации службы SpeechToText будет выглядеть следующим образом:
public class SpeechToText : ISpeechToText { private readonly ILogger<SpeechToText> _logger; private readonly HttpClient _httpClient; private readonly SpeechToTextApiSettings _settings; public SpeechToText( HttpClient httpClient, IOptions<SpeechToTextApiSettings> settings, ILogger<SpeechToText> logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings)); if (string.IsNullOrWhiteSpace(_settings.BaseUrl)) { throw new ArgumentNullException(nameof(_settings.BaseUrl), "Base URL can't be empty"); } if (string.IsNullOrWhiteSpace(_settings.TaskUrl)) { throw new ArgumentNullException(nameof(_settings.TaskUrl), "Task URL can't be empty"); } _httpClient.BaseAddress = new Uri(_settings.BaseUrl); _httpClient.Timeout = TimeSpan.FromMinutes(_settings.Timeout); _httpClient.DefaultRequestHeaders.Authorization = new("Bearer", _settings.ApiKey); _httpClient.DefaultRequestHeaders.Accept.Add( new("application/json")); } public async Task<string?> SendTaskAsync(StreamContent payload) { throw new NotImplementedException(); } public async Task<JobStatus?> GetTaskStatusAsync(string taskId) { throw new NotImplementedException(); } public async Task<string?> GetTaskResultAsync(string taskId) { throw new NotImplementedException(); } }
Здесь мы получаем параметры конфигурации и соответствующим образом настраиваем HttpClient. Используем Bearer Authentication, добавив соответствующий заголовок (строка 28). Кроме того, мы хотим ответ в формате JSON, поэтому добавляем и такой заголовок (строка 30).
Для логирования я использую nLog, впрочем, это сейчас несущественно, поэтому я уберу все вызовы логгера из приводимого здесь кода, чтобы не захламлять его.
Отправка файла на сервер производится POST запросом. В ответ, сервер вернёт идентификатор задания, который нам нужно сохранить – именно по этому идентификатору мы впоследствии найдём задание и узнаем, выполнена ли транскрибация записи. Как именно узнаем? Всё просто, ответ сервера будет содержать статус. Сразу опишем возможные статусы:
/// <summary> /// Speech2Text server status codes. /// </summary> public enum SpeechToTextStatuses { /// <summary> /// Content is received. /// </summary> Received = 80, /// <summary> /// Task in progress. /// </summary> Processing = 100, /// <summary> /// Completed successfully. /// </summary> Completed = 200, /// <summary> /// Error while processing. /// </summary> Error = 501 }
Теперь мы готовы написать реализацию метода отправки задания:
public async Task<string?> SendTaskAsync(StreamContent payload) { try { using var content = new MultipartFormDataContent(); content.Add(payload, "file", "audio.mp3"); content.Add(new StringContent("ru"), "lang"); content.Add(new StringContent("2"), "speakers"); using var response = await _httpClient.PostAsync(_settings.TaskUrl, content); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { return null; } using JsonDocument doc = JsonDocument.Parse(responseText); var root = doc.RootElement; if (root.TryGetProperty("id", out var taskId) && taskId.GetString() is string taskIdValue && root.TryGetProperty("status", out var taskStatus) && taskStatus.TryGetProperty("code", out var taskCode) && taskCode.TryGetInt32(out int taskCodeValue)) { if (taskCodeValue == (int)SpeechToTextStatuses.Received || taskCodeValue == (int)SpeechToTextStatuses.Processing) { return taskIdValue; } } return null; } catch (Exception ex) { return null; } finally { payload?.Dispose(); } }
Сервер принимает дополнительные параметры распознавания: lang для указания языка и speakers для количества говорящих (либо max_speakers и min_speakers, если участников диалога может быть переменное количество). Эти параметры необязательны, но я их установил, потому что у меня все записи однотипные. Разумеется, лучше избегать hardcoded values и следовало бы передавать аргументом некий DTO, содержащий не только само содержимое файла, но и эти дополнительные параметры.
Ещё есть интересный параметр multi_channel, который я не использовал. Устанавливается в 1, если файл содержит стереозвук, в котором один собеседник в одном канале, а другой – во втором.
Я не стал описывать модель для парсинга ответа сервера, потому что мне нужен только один параметр. Вообще, полный ответ выглядит так:
{ "id": "EUmFNuJzxuc0fAf8pjHaq29RDwF3Wuj0", "created": null, "options": { "lang": "ru", "speakers": 2, "multi_channel": null }, "file_meta": { "mime": "audio/mpeg", "format": "MPEG Audio", "audio_format": "MPEG Audio", "channels": 1, "duration": "00:01:14" }, "resource": { "type": "file", "name": "audio.mp3" }, "status": { "code": 100, "description": "В очереди на распознание" }, "payment": { "source": 1, "price": 0 }, "result": null }
Поставленное в очередь задание будет обрабатываться в течение некоторого времени. По завершению обработки, код статуса изменится на 200 – успешно завершено или 501 – ошибка транскрибации. Периодически опрашиваем сервер, вызывая соответствующий метод, код которого будет таким:
public async Task<JobStatus?> GetTaskStatusAsync(string taskId) { if (string.IsNullOrWhiteSpace(taskId)) { return null; } try { string uriString = $"{_settings.BaseUrl.TrimEnd('/')}/{taskId}"; using var response = await _httpClient.GetAsync(uriString); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { return null; } using JsonDocument doc = JsonDocument.Parse(responseText); var root = doc.RootElement; if (root.TryGetProperty("status", out var taskStatus) && taskStatus.TryGetProperty("code", out var taskCode) && taskCode.TryGetInt32(out int taskCodeValue)) { switch (taskCodeValue) { case (int)SpeechToTextStatuses.Received: case (int)SpeechToTextStatuses.Processing: return JobStatus.Decoding; case (int)SpeechToTextStatuses.Completed: return JobStatus.Decoded; case (int)SpeechToTextStatuses.Error: return JobStatus.FailedToDecode; default: return JobStatus.FailedToDecode; } } return null; } catch (Exception ex) { return null; } }
Здесь мы проверяем ответ сервера и соответствующим образом устанавливаем статус задания в нашей локальной БД.
Дождавшись завершения задачи, заберём результат с сервера:
public async Task<string?> GetTaskResultAsync(string taskId) { if (string.IsNullOrWhiteSpace(taskId)) { return null; } try { string uriString = $"{_settings.BaseUrl.TrimEnd('/')}/{taskId}/result/txt"; using var response = await _httpClient.GetAsync(uriString); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { return null; } return string.IsNullOrWhiteSpace(responseText) ? null : responseText; } catch (Exception ex) { return null; } }
Здесь у нас имеет значение младший сегмент пути URL (кстати, как правильно это называется?), определяющий формат возвращаемого результата. Возможные варианты: raw, txt, srt, vtt, json и xml. Как видно из кода, я использую текстовое представление и получаю результат в следующем виде:
Спикер 1:
0:00:00 — Да, Алексей, здравствуйте.
Спикер 2:
0:00:03 — Я там вам письмо написал, вы видели?
Спикер 1:
0:00:06 — …
Взаимодействие со службой происходит в цикле, находящемся внутри фоновой задачи. Между итерациями цикла обязательно делаем задержку, чтоб не DoSить сервер запросами. Упрощённо и сокращённо это выглядит примерно так, как в коде ниже. Здесь мы выбираем задания из локальной БД, основываясь на их статусах, делаем запросы к серверу и соответствующим образом меняем статусы, тем самым обеспечивая продвижение задания по конвейеру обработки.
public class ProcessingPipeline : BackgroundService { // ... protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { // enumerate file names foreach (var file in _filesProcessor.GetMp3Files()) { // create jobs for newly added files } // enumerate executing jobs foreach (var job in await _jobsRepository.GetDecodingJobs()) { if (await _speechToText.GetTaskStatusAsync(job.TaskId) is JobStatus status) { // change statuses of completed tasks } } // enumerate new jobs foreach (var job in await _jobsRepository.GetNewJobs()) { // create server task and change job status if (_filesProcessor.ReadMp3FileToHttpStream(job.FileName) is StreamContent stream) { var result = await _speechToText.SendTaskAsync(stream); if (result is null) { job.Status = JobStatus.FailedToDecode; } else { job.Status = JobStatus.Decoding; job.TaskId = result; } } } } catch (OperationCanceledException) { break; } catch (Exception ex) { // _logger } await Task.Delay(_settings.Interval_ms, stoppingToken); } } }
Я не рассматриваю в подробностях конвейер обработки, взаимодействие с БД и прочее, поскольку это за рамками статьи (тем не менее, можем поговорить и об этом, если будет интересно).
Вот таким нехитрым способом можно производить распознавание аудиозаписей. Со своей стороны хочу поблагодарить сервис за предоставленный тестовый доступ. На данный момент, я столкнулся с определёнными особенностями (аудиозаписи не очень хорошего качества, есть много терминов, аббревиатур и технического жаргона), из-за которых транскрибация не всегда так хороша, как того хотелось бы. Но, надеюсь, мы сможем это решить.
Задавайте вопросы, указывайте на ошибки, спасибо за внимание)
ссылка на оригинал статьи https://habr.com/ru/articles/926918/
Добавить комментарий