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/