Стандартная ситуация: клиенты звонят в нерабочее время, попадают на автоответчик и диктуют на автоответчик заказ.

Поставленная задача - в получающихся аудиофайлах вытаскивать смыслы и фиксировать заказ в CRM. Задача решена.

Перевод аудио в текст (транскрибация)

Применили Yandex Speech Kit.

Код Yandex Speech Kit
// $token – берется в своем аккаунте
// $audioFileName – ссылка на закачанный в сеть аудиофайл в формате ogg
$file = fopen($audioFileName, 'rb');

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://stt.api.cloud.yandex.net/speech/v1/stt:recognize?lang=ru-RU&format=oggopus");
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Api-Key ' . $token, 'Transfer-Encoding: chunked'));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);

curl_setopt($ch, CURLOPT_INFILE, $file);
$res = curl_exec($ch);
curl_close($ch);
$decodedResponse = json_decode($res, true);
if (isset($decodedResponse["result"])) {
$text = urlencode($decodedResponse["result"]);
} else {
echo "Error code: " . $decodedResponse["error_code"] . "\r\n";
echo "Error message: " . $decodedResponse["error_message"] . "\r\n";
}

fclose($file);

На этом этапе есть текст в переменной $text.

Вытаскивание смыслов

Создаем массивы значений.

В одном массиве – слова, которые ищем, во втором – соответствующее корректное наименование.

Например, для наименований товара:

$name_dataset = array("мягк\D{2}", "природн\D{2}" );
$name_dataset_correct = array("Сестрица Мягкая", "Природная" );

И перебором ищем выражения в строке $string:

$name = '';
foreach ($name_dataset as $key => $find) {
    $pattern = "/$find/ui";  
    if (preg_match($pattern, $string, $matches_quantity_1)) {
        $name = $name_dataset_correct[$key];
        }
    }

 if ($name == '') {$name = "(не определено)";}

С количеством и интервалами доставки аналогично:

Код для количества и интервалов
$quantity = '';
$quantity_dataset = array("бутыл\D+ воды", "бутылоч\D{1,2}", "бутыл\D{1,2}", "шту\D+ воды", "штуче\D{0,2}к", "штук\D{0,1}");
foreach ($quantity_dataset as $find) {
    $pattern = "/\d+ $find/ui";  
    if (preg_match($pattern, $string_new, $matches_quantity_1)) {        
        preg_match("/\d+/", $matches_quantity_1[0], $matches_quantity_2);
        $quantity = $matches_quantity_2[0];
        } 
    }   
 if ($quantity == '') {$quantity = "(не определено)";}
 
 $interval = "";
 $interval_dataset = array("к утру", "утром", "\D{0,2} утро", "к вечеру", "вечером", "\D{0,2} вечер", "до обеда", "после обеда", 
 "\D{1,2} 1 половин\D{1} дня", "\D{1,2} 1 половин\D{1}", "1 половин\D{1} \D{0,3}", "\D{1,2} 2 половин\D{1} дня", "\D{1,2} 2 половин\D{1}", 
 "2 половин\D{1} \D{0,3}", "с 15 часов", "с 15 до 18", "с 18 до 21", "с 18 до 22", "с 18 часов", "с 18", "после 18 часов", "после 18", "18 0 0"); 
    
 $interval_dataset_correct = array("утро", "утро", "утро", "с 18 до 22-00", "с 18 до 22-00", "с 18 до 22-00", "до обеда", "после обеда", 
 "первая половина дня", "первая половина дня", "первая половина дня", "вторая половна дня", "вторая половна дня", "вторая половна дня", 
 "с 15 до 18-00", "с 15 до 18-00", "с 18 до 22-00", "с 18 до 22-00", "с 18 до 22-00", "с 18 до 22-00", "с 18 до 22-00", "с 18 до 22-00", 
 "с 18 до 22-00");

$interval = "";
foreach ($interval_dataset as $key => $find) {
    $pattern = "/$find/ui";
    if (preg_match($pattern, $string_new, $matches_quantity_1)) {
        $interval = $interval." ".$interval_dataset_correct[$key];
        } 
    }

 if ($interval == '') {$interval = "(не определено)";}

На этом этапе уже есть наименование товара ($name), количество ($quantity) и желаемый интервал доставки ($interval).

Также можно вытащить и различные кодовые или стоп-слова:

Код для стоп-слов и кодовых команда
$stop_dataset = array("отмен\D{1,3}", "2 заказа", "двойной заказ"); 
$stop_dataset_real = array("отмена", "Двойной заказ", "Двойной заказ");

$stop = false;
foreach ($stop_dataset as $key => $find) {
    $pattern = "/$find/ui";  
    if (preg_match($pattern, $string, $matches_quantity_1)) {
        $stop_world = $stop_dataset_real[$key];       
        } 
    else { }
    }

Если встречены такие слова, то можно отправить команду на отмену заказа или, например, "тревожное сообщение" в телеграм.

Стандартизация адреса

Для улучшения содержания CRM адрес стандартизируется и в CRM отправляется уже стандартизированный адрес. Применили dadata.

    $token = "ВАШ_API_КЛЮЧ";
    $secret = "ВАШ_СЕКРЕТНЫЙ_КЛЮЧ";
    $city="ЗАДАЕМ ГОРОД";

    $dadata = new Dadata($token, $secret);
    $dadata->init();
    $result = $dadata->clean("address", $city." ".$string_new);
    $address = $result[0]["result"];
    $quality_cod = result[0]["qc"];
    $dadata->close();

Вот такой простой код из документации стандартизирует адрес.
Отправляется строка $string_new, возвращается строка с адресом.
Адрес сохраняется в переменной $address.
При необходимости можно использовать и код качества стандартизации ($quality_cod).

Код применяемого класса копируется полностью из документации:

Код class Dadata
class TooManyRequests extends Exception
{
}

class Dadata
{
    private $clean_url = "https://cleaner.dadata.ru/api/v1/clean";
    private $suggest_url = "https://suggestions.dadata.ru/suggestions/api/4_1/rs";
    private $token;
    private $secret;
    private $handle;

    public function __construct($token, $secret)
    {
        $this->token = $token;
        $this->secret = $secret;
    }

    /*
     * Initialize connection.
     */
     
    public function init()
    {
        $this->handle = curl_init();
        curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($this->handle, CURLOPT_HTTPHEADER, array(
            "Content-Type: application/json",
            "Accept: application/json",
            "Authorization: Token " . $this->token,
            "X-Secret: " . $this->secret,
        ));
        curl_setopt($this->handle, CURLOPT_POST, 1);
    }

    /*
     * Clean service.
     * See for details:
     *   - https://dadata.ru/api/clean/address
     *   - https://dadata.ru/api/clean/phone
     *   - https://dadata.ru/api/clean/passport
     *   - https://dadata.ru/api/clean/name
     * 
     * (!) This is a PAID service. Not included in free or other plans.
     */
     
    public function clean($type, $value)
    {
        $url = $this->clean_url . "/$type";
        $fields = array($value);
        return $this->executeRequest($url, $fields);
    }


    /*
     * Close connection.
     */
     
    public function close()
    {
        curl_close($this->handle);
    }

    private function executeRequest($url, $fields)
    {
        curl_setopt($this->handle, CURLOPT_URL, $url);
        if ($fields != null) {
            curl_setopt($this->handle, CURLOPT_POST, 1);
            curl_setopt($this->handle, CURLOPT_POSTFIELDS, json_encode($fields));
        } else {
            curl_setopt($this->handle, CURLOPT_POST, 0);
        }
        $result = $this->exec();
        $result = json_decode($result, true);
        return $result;
    }

    private function exec()
    {
        $result = curl_exec($this->handle);
        $info = curl_getinfo($this->handle);
        if ($info['http_code'] == 429) {
            throw new TooManyRequests();
        } elseif ($info['http_code'] != 200) {
            throw new Exception('Request failed with http code ' . $info['http_code'] . ': ' . 
            $result);
        }
        return $result;
    }
public function __construct($token, $secret)
{
    $this->token = $token;
    $this->secret = $secret;
}

/*
 * Initialize connection.
 */
 
public function init()
{
    $this->handle = curl_init();
    curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($this->handle, CURLOPT_HTTPHEADER, array(
        "Content-Type: application/json",
        "Accept: application/json",
        "Authorization: Token " . $this->token,
        "X-Secret: " . $this->secret,
    ));
    curl_setopt($this->handle, CURLOPT_POST, 1);
}

/*
 * Clean service.
 * See for details:
 *   - https://dadata.ru/api/clean/address
 *   - https://dadata.ru/api/clean/phone
 *   - https://dadata.ru/api/clean/passport
 *   - https://dadata.ru/api/clean/name
 * 
 * (!) This is a PAID service. Not included in free or other plans.
 */
 
public function clean($type, $value)
{
    $url = $this->clean_url . "/$type";
    $fields = array($value);
    return $this->executeRequest($url, $fields);
}


/*
 * Close connection.
 */
 
public function close()
{
    curl_close($this->handle);
}

private function executeRequest($url, $fields)
{
    curl_setopt($this->handle, CURLOPT_URL, $url);
    if ($fields != null) {
        curl_setopt($this->handle, CURLOPT_POST, 1);
        curl_setopt($this->handle, CURLOPT_POSTFIELDS, json_encode($fields));
    } else {
        curl_setopt($this->handle, CURLOPT_POST, 0);
    }
    $result = $this->exec();
    $result = json_decode($result, true);
    return $result;
}

private function exec()
{
    $result = curl_exec($this->handle);
    $info = curl_getinfo($this->handle);
    if ($info['http_code'] == 429) {
        throw new TooManyRequests();
    } elseif ($info['http_code'] != 200) {
        throw new Exception('Request failed with http code ' . $info['http_code'] . ': ' . 
        $result);
    }
    return $result;
}

Итак, на этом этапе есть адрес, который удобно фиксировать в CRM.

Улучшаем стандартизацию адреса

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

Например, вырезаем обычные слова приветствия и прощания:

$hallo_resize = array("здравствуй\D{0,2}", "приветству\D{1,2}", "привет", "добрый день", "добро\D{1,2} утр\D{1}", "доброго дня", 
"добр\D{2,3} вечер\D{0,1}", "добр\D{2,3} вечер\D{2}", "до свидания", "всего доброго", "всего самого доброго", "всего наилучшего", "всего лучшего", 
"всего самого лучшего", "всего хорошего", всего самого хорошего", "спасибо.");

foreach ($hallo_resize as $value) {
    $pattern = "/$value/ui";
    $string_new = preg_replace($pattern, '', $string_new);
    }

Аналогично, прочие различные слова:

Код для вырезания слов
$another_resize = array("\bнужно\b", "мне бы", "\bмне\b", "спасибо", "\bадрес\D{0,1}\b", "\b\Dоставк\D{1,2}\b", "сделать", 
    "\bзаказ\D{0,2}ать\b", "по заказ\D{0,2}у", "про заказ\D{0,2}", "\bзаказикD{0,1}\b", "\bзаказD{0,1}\b",
    "желательно", "прошу", "примите", "пожалуйста", "хочу", "\bя\b", "\bвы\b",  "\bмы\b",  "\bвсе\b", "алло", "буду дома", "время", 
    "\bвот\b", "\bуже\b", "\bвам\b", "\bзвон\D{1,2}\b", "\bживой\b", 
    "\bголос\b", "узна\D{1,2}", "\bприня\D{1,2}\b", "\bмой\b", "\bможно\b", "\bуслышать\b", "\bавтоответчик\D{1,2}\b");
   
 foreach ($another_resize as $value) {
    $pattern = "/$value/ui";
    $string_new = preg_replace($pattern, '', $string_new);
    }

Именно из-за такой обработки, как видно на представленных фрагментах кода, текст из аудио хранится в переменной $text, а на стандартизацию отправляется переработанная строка $string_new,

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

И совсем хорошо перед отправкой на стандартизацию вырезать выражения, которые не относятся к адресу, но могли бы относится раньше к смыслам

Код для вырезания слов
   $finresize_dataset = array(
    "\d{1,2} числ\D{1,2}", 
    "с \d{1,2} часов", "с \d{1,2}\b", "до \d{1,2} часов", "до \d{1,2}\b", "к \d{1,2} часам", "к \d{1,2}\b", "после \d{1,2} часов", "после \d{1,2}\b",
    "\D{0,2} сегодня", "\D{0,2} завтра",
    "\D{1,2} понедельник\D{0,1}", "\D{1,2} вторник\D{0,1}", "\D{1,2} сред\D{1}", "\D{1,2} четверг\D{0,1}", "\D{1,2} пятниц\D{1}", "\D{1,2} суббот\D{1}", "\D{1,2} воскресень\D{1}", 
    "\D{1,2} воскресени\D{1}", 
    "понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье", "воскресение",
    "\d{1,2} января", "\d{1,2} февраля", "\d{1,2} марта", "\d{1,2} апреля", "\d{1,2} мая", "\d{1,2} июня", "\d{1,2} июля", "\d{1,2} августа", "\d{1,2} сентября", 
    "\d{1,2} октября", "\d{1,2} ноября", "\d{1,2} декабря",
    "\bна\b", "\bв\b", "\bс\b", "\bк\b", "\bиз-за\b", "\bиз\b", "\bза\b",  "\bот\b", "\bперед\b", "\bи\b",  "\bили\b",  "\bда\b",  "\bнет\b", "\bкак\b", "\bтак\b", "\bвот\b", 
    "\bтолько\b", "\bнисколько\b", "\bсколько\b", "\bстолько\b", "\bто\b", "\bводу\b"
    );  
    
 foreach ($finresize_dataset as $value) {
    $string_new = preg_replace("/$value/ui", '', $string_new);
    }

Итоги

Было. Операторы приходили на работу, и в перерывах между входящими звонками слушали аудиозаписи с автоответчика и вручную набивали заказы в CRM.

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

Итог. Снижение расходов на обработку заказов, полученных в нерабочее время.

Комментарии (9)


  1. kolabaister
    27.08.2022 21:38
    +2

    А ivr как то помогает стандартизировать ответы или просто обрабатывается поток сознания?

    Вам звонят заказывать только воду?

    Встав перед аналогичной задачей я покрутил- покрутил записи и решил просто расшифровывать их и показывать сотруднику в виде текста с возможностью прослушать. Слишком все неоднородно.


    1. AnatolyBelov Автор
      27.08.2022 22:49

      В данном случае это телефон для заказа воды.
      Если в тексте нет заданных кодовых слов, то идет вытаскивание из фразы смыслов для формирования заказа.


      1. boopiz
        28.08.2022 14:54
        +1

        Вот вам примеры заказов (с такой же штукой развлекаюсь) : Source
        "Как заказать.Скиньте ссылку"

        Source:
        "Добрый день. Счёт оплатили. Прошу организовать доставку на ближайшее время."

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

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

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

        в конце убирается всякий шум типа междометий, предлогов и тд. и получается что то слегка свернутое типа

        Source
        Добрый день. Можно на 12.04.2022 доставочку воды заказать. ООО "Рога и копыта" Карла- Маркса 000 оф.9.
        Вода 19 л.-1 шт.

        после нормализации

        Norm:
        /[GRT]/ {BY} 12/04/2022 {DLV} {TARGET1} / " " 000 /9/ {TARGET1} 19 /1 /

        далее уже можно формировать семантическую сетку по целям и как то приближенно работать с ней.

        и избавьиесь от циклов. регулярки могут делать все в один проход.


    1. AnatolyBelov Автор
      27.08.2022 22:53

      Имеет смысл разделить на несколько ситуаций.
      Если робот "уверенно" вытащил смыслы, то фиксировать заказ.
      Если робот что-то не вытащил, или "не уверен", то именно в этом случае давать сотруднику полученный текст и запись послушать.


    1. AnatolyBelov Автор
      27.08.2022 23:01

      Постепенно отходим от ivr, пытаемся переводить на ветку сценария в зависимости от сказанного, как при человеческом диалоге.
      Если, например, позвонивший сказал "Здравствуйте заказ примите", то пошел на одну ветку, а если "а как узнать", "а сколько стоит", "а как оплачивать" - то это на другу. ветку.
      Меню у робота "как бы есть", только он его не произносит, а переключает в зависимости от фразы позвонившего и того, что смог распознать.


      1. kolabaister
        27.08.2022 23:51

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


        1. AnatolyBelov Автор
          28.08.2022 00:29

          Очень зависит от структуры диалога.
          Принять заказ или провести подробную консультацию - пока очень разные ситуации.
          И дело, скорее не в распознавании, а в сценарии.

          По моему опыту, само распознавание происходит достаточно корректно.
          Пробовали и с посторонними шумами - хруст, скрип, шелест, журчание воды, шум мотора - все в целом ок, распознается хорошо.
          Другое дело, сам сценарий. Действительно, пользователи могут быть не довольны неестественностью робота и даже некомпетентностью. Например, робот немного провисает при ответе, так как ему нужна определенная пауза, чтобы определить, что человек закончил фразу. И если человек в диалоге еще и перебивает, то робот по факту может отработать только часть фразы, и получается некорректно. Также робот не может ответить на вопрос, которому не обучен. В этом случае он либо зависнет, либо переключит на оператора, либо "извините, мы перезвоним". Конечно, для пользователя это неудобно.

          При этом заметили, что если пользователь понимает, что общается с роботом, и у него цель - сделать заказ, а не сломать робота, то пользователь понимает ситуацию, говорит то, что спрашивает робот, и даже старается помедленнее и четче, экономя непосредственно свое время, чтобы потом не повторять.Видно, что по мере привыкания, а также при постепенном улучшении сценария, конверсия обращений даже диалогового звонка в заказ повышается. И пользователи уже сами выбирают робота, а не оператора, потому что оператора нужно ждать, а робот отвечает сразу, он многоканальный. В итоге "провести опрос" и распознать наименование воды, объем тары, количество бутылей, день, интервал доставки роботы могут уже достаточно стабильно.


  1. sukhe
    28.08.2022 10:58

    Распознать речь сейчас в целом не проблема. А вот вытащить оттуда смысл — не всегда получается. Поэтому проще сделать как у банков/мобильных операторов:
    — хотите сделать заказ — нажмите 1
    — хотите отменить заказ — нажмите 2
    — хотите узнать статус заказа — нажмите 3 и введите номер заказа
    Да, не так модно и удобно, зато гораздо надёжней.


    1. AnatolyBelov Автор
      28.08.2022 14:53

      С одной стороны - да.
      С другой - если от нажатий и переходить к голосу, то даже уже возможно как минимум предлагать выбрать по меню голосом, а не нажатием.
      — ... — скажите "заказ"
      — ... — скажите "отмена"
      — ... — скажите "узнать" и сообщите номер заказа

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