Голосовой ИИ Агент

—

от автора

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

Голосового агента выдаёт не голос. Современный синтез звучит прилично, и на связке телефонной линии с её 8 кГц разница между хорошим и отличным TTS вообще теряется. Выдаёт другое: как он читает числа.

«Привезём 15.07.2026» в устах робота превращается во что угодно — от «пятнадцать точка ноль семь» до бодрого чтения даты как дроби. Телефон, прочитанный как одно десятизначное число, невозможно записать со слуха. И каждый такой момент — это секунда, в которую собеседник вспоминает, что говорит с программой.

При этом из всех маркеров робота этот — самый дешёвый в устранении. Ниже — библиотека на 155 строк с одной зависимостью, которая закрывает даты, телефоны, деньги, проценты и номера заказов для русского языка. Код целиком в статье, забирайте. А заодно — три ловушки, в которые я наступил, пока её писал, включая баг, который нашёл прямо в процессе подготовки этого текста.


Почему это отдельный слой, а не настройка движка

Первое решение, которое я принял: числа в текст не должны доезжать до синтеза вообще. Всё разворачивается в слова до того, как текст уйдёт в TTS.

Причины две.

Детерминизм. Как конкретный движок прочитает «1 490 ₽» — зависит от движка, его версии и фазы луны. Один читает суммы сносно, но спотыкается на датах. Другой наоборот. Проверять это на каждом обновлении движка я не хочу. Текст, в котором цифр нет, любой движок читает одинаково — словами.

Переносимость. Мой агент работал на синтезе Яндекса, потом появился вариант с открытой моделью. Слой нормализации не знает, какой движок стоит за ним, и переезжает без единой правки. Всё, что вшито в настройки конкретного TTS, при переезде пришлось бы делать заново.

Единственная зависимость — num2words, она переводит число в слова. Всё остальное — регулярки и работа с окончаниями.

Ловушка первая: num2words говорит в мужском роде

Наивный план был такой: num2words(15, to="ordinal") — и дата готова.

>>> num2words(15, lang="ru", to="ordinal")'пятнадцатый'

'пятнадцатый'

Мужской род. А дате нужен средний: «пятнадцатое июля». Году — родительный: «две тысячи двадцать шестого года». Таких форм в библиотеке нет, и это не претензия к ней: полное склонение порядковых числительных — это отдельная большая работа.

Зато нужные мне две формы получаются из мужского рода заменой окончания:

def _ordinal_neuter(n: int) -> str:    """Пятнадцатое, шестое, третье — для дат."""    w = num2words(n, lang="ru", to="ordinal")  # мужской род: «пятнадцатый»    parts = w.split()    last = parts[-1]    if last.endswith("третий"):        last = last[: -len("третий")] + "третье"    elif last.endswith("ий"):        last = last[:-2] + "ье"    elif last.endswith(("ый", "ой")):        last = last[:-2] + "ое"    parts[-1] = last    return " ".join(parts)

"""Пятнадцатое, шестое, третье — для дат."""

w = num2words(n, lang="ru", to="ordinal") # мужской род: «пятнадцатый»

parts = w.split()

last = parts[-1]

if last.endswith("третий"):

last = last[: -len("третий")] + "третье"

elif last.endswith("ий"):

last = last[:-2] + "ье"

elif last.endswith(("ый", "ой")):

last = last[:-2] + "ое"

parts[-1] = last

return " ".join(parts)

Обратите внимание на отдельную ветку для «третий». У него мягкая основа, и общее правило для «-ий» даёт «третье» неправильно — сначала я получал «третьье» там, где нужно «третье», и «двадцать третьего» ломалось тем же местом. Особый случай пришлось прописать руками, в обеих функциях — и для среднего рода, и для родительного падежа.

Менять окончание нужно только у последнего слова: в «две тысячи двадцать шестой» склоняется лишь «шестой». Поэтому parts[-1], а не замена по всей строке.

Ловушка вторая: порядок замен решает всё

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

Порядок обработки зафиксирован в шапке файла, и это не украшение, а самая дорогая строчка документации в проекте:

даты → телефоны → деньги → проценты → «№» → голые числа

Вот что происходит, если запустить голые числа первыми. Вход:

Привезём 15.07.2026, счёт на 1 490 ₽, заказ № 2214.

Выход:

Привезём пятнадцать.семь.две тысячи двадцать шесть, счёт на одна тысяча четыреста девяносто ₽, заказ № две тысячи двести четырнадцать.

Дата уничтожена: регулярка дат ищет \d{1,2}\.\d{2}\.\d{4}, а цифр в тексте уже нет — ей не за что зацепиться. Телефон постигает та же судьба, только звучит это ещё хуже.

Правило простое: от частного к общему. Самые структурированные форматы разбираются первыми, пока их цифры целы. Голые числа идут последними и подбирают всё, что осталось.

Ловушка третья: телефон — это не число

Телефон нельзя читать как число, но и «по одной цифре» — тоже не ответ: живой человек диктует группами. «Девятьсот, сто двадцать три, сорок пять, шестьдесят семь».

Проблема в группах с ведущим нулём. Группа «005», прочитанная числом, превращается в «пять» — и человек на том конце записывает неверный номер. Поэтому правило такое: группа с ведущим нулём читается по цифрам, остальные — числом.

def _phone_group(g: str) -> str:    """Группа цифр телефона: с ведущим нулём — по цифрам, иначе числом."""    if g.startswith("0"):        return " ".join(DIGITS[d] for d in g)    return _num(int(g))

"""Группа цифр телефона: с ведущим нулём — по цифрам, иначе числом."""

if g.startswith("0"):

return " ".join(DIGITS[d] for d in g)

return _num(int(g))

Живой прогон:

Запасной: 8 901 005-10-07 → Запасной: восемь девятьсот один ноль ноль пять десять ноль семь

Склонение: рубль, рубля, рублей

Числительное тянет за собой существительное: один рубль, два рубля, пять рублей, и отдельное исключение для одиннадцати–четырнадцати, где «одиннадцать рублей», хотя число кончается на единицу.

def plural(n: int, one: str, few: str, many: str) -> str:    """Русское склонение по числу: 1 рубль / 2 рубля / 5 рублей."""    n = abs(n) % 100    if 11 <= n <= 14:        return many    n %= 10    if n == 1:        return one    if 2 <= n <= 4:        return few    return many

"""Русское склонение по числу: 1 рубль / 2 рубля / 5 рублей."""

n = abs(n) % 100

if 11 <= n <= 14:

return many

n %= 10

if n == 1:

return one

if 2 <= n <= 4:

return few

return many

Функция банальная, но именно её отсутствие слышно сразу: «двадцать один рублей» — и всё, собеседник понял, кто звонит.

Ударения — опционально и с мягкой деградацией

Отдельная боль русского синтеза — омографы: за́мок и замо́к, му́ка и мука́. Для них есть RUAccent, но это тяжёлая модель, и тащить её обязательной зависимостью в 155-строчную библиотеку я не стал. Подключение устроено так: если пакет установлен — ударения расставляются, если нет — текст проходит как есть, без ошибки. Пять строк try/except, и библиотека остаётся лёгкой у тех, кому омографы не критичны.

Чего библиотека не делает — включая баг, найденный при подготовке статьи

Честный раздел.

Падежи от контекста. Готовя примеры для этой статьи, я прогнал фразу «Скидка 21% действует до 03.03.2026» и получил:

Скидка двадцать один процент действует до третье марта.

Дата разворачивается в именительном падеже, а после «до» нужен родительный — «до третьего марта». Тот же дефект в «счёт на одна тысяча четыреста девяносто рублей». Библиотека не разбирает синтаксис вокруг числа и падеж по предлогу не согласует. Для реплик агента это в основном терпимо — на слух режет меньше, чем цифры, — но это дефект, я его знаю, и теперь знаете вы. Лечится либо разбором предлога перед числом (ещё десяток регулярок), либо морфологией через pymorphy — и это уже не 155 строк.

Артикулы и смешанные обозначения. «ШКФ-Норд-3», «1С» — не трогаются вообще. Кстати, в Python они и не могли пострадать: \b здесь юникодный, и между «1» и «С» границы слова нет. Если вы пишете такой же слой на JavaScript — там \b кириллицу буквой не считает, и поведение будет другим. Я на этом уже обжигался, в другом месте и больно.

Время, диапазоны, дроби. «14:30», «10–15 дней», «2,5 кг» — не разбираются. В репликах моего агента они пока не встречались; появятся — добавятся по той же схеме, каждый со своим местом в порядке замен.

Весь конвейер

def normalize(text: str) -> str:    text = RE_DATE.sub(date_repl, text)      # 15.07.2026 → пятнадцатое июля ...    text = RE_PHONE.sub(phone_repl, text)    # +7 900 123-45-67 → плюс семь ...    text = RE_MONEY.sub(money_repl, text)    # 1 490 ₽ → ... рублей    text = RE_PERCENT.sub(percent_repl, text)  # 21% → двадцать один процент    text = RE_NUMBER_SIGN.sub(nsign_repl, text)  # № 2214 → номер ...    text = RE_BARE.sub(bare_repl, text)      # всё, что осталось    return text

text = RE_DATE.sub(date_repl, text) # 15.07.2026 → пятнадцатое июля ...

text = RE_PHONE.sub(phone_repl, text) # +7 900 123-45-67 → плюс семь ...

text = RE_MONEY.sub(money_repl, text) # 1 490 ₽ → ... рублей

text = RE_PERCENT.sub(percent_repl, text) # 21% → двадцать один процент

text = RE_NUMBER_SIGN.sub(nsign_repl, text) # № 2214 → номер ...

text = RE_BARE.sub(bare_repl, text) # всё, что осталось

return text

Контрольный прогон целиком:

Вход: Привезём 15.07.2026, счёт на 1 490 ₽, заказ № 2214. Вопросы — по телефону +7 900 123-45-67.

Выход: Привезём пятнадцатое июля две тысячи двадцать шестого года, счёт на одна тысяча четыреста девяносто рублей, заказ номер две тысячи двести четырнадцать. Вопросы — по телефону плюс семь девятьсот сто двадцать три сорок пять шестьдесят семь.

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

Код открыт, лицензия MIT, единственная зависимость — num2words. Наверняка существуют решения серьёзнее — полноценная inverse text normalization это отдельная дисциплина. Мне был нужен слой, который я могу целиком удержать в голове, перенести между движками синтеза одним копированием файла и починить за пять минут в проде. Если вам нужен такой же — берите, порядок замен уже выстрадан.

ссылка на оригинал статьи https://habr.com/ru/articles/1090760/