chore: sync local changes

This commit is contained in:
2026-03-01 12:55:17 +03:00
parent 27ee32be38
commit f1bc254c6b
8 changed files with 192 additions and 292 deletions

View File

@@ -1,24 +1,13 @@
"""
Response cleaner module.
Removes markdown formatting and special characters from AI responses.
Handles complex number-to-text conversion for Russian language.
"""
# Модуль очистки текста перед озвучкой.
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
# Это критически важно для качественного русского TTS.
"""Text cleaner for TTS."""
import re
import pymorphy3
from num2words import num2words
from .roman import roman_to_int
# Инициализация морфологического анализатора (для определения падежей)
morph = pymorphy3.MorphAnalyzer()
# Карта предлогов и падежей.
# Помогает понять, в какой падеж ставить число после предлога.
# Предлоги и падежи
PREPOSITION_CASES = {
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
"во": "loct",
@@ -55,7 +44,7 @@ PREPOSITION_CASES = {
"про": "accs",
}
# Соответствие падежей pymorphy и библиотеки num2words
# Соответствие падежей
PYMORPHY_TO_NUM2WORDS = {
"nomn": "nominative",
"gent": "genitive",
@@ -69,14 +58,14 @@ PYMORPHY_TO_NUM2WORDS = {
"loc2": "prepositional",
}
# Соответствие родов pymorphy и num2words
# Роды
PYMORPHY_TO_GENDER = {
"masc": "m",
"femn": "f",
"neut": "n",
}
# Названия месяцев в родительном падеже (для поиска дат в тексте)
# Месяца
MONTHS_GENITIVE = [
"января",
"февраля",
@@ -92,10 +81,10 @@ MONTHS_GENITIVE = [
"декабря",
]
# Леммы единиц времени (для корректного падежа числительных)
# Время
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
# Суффиксы порядковых числительных для формата "1968-й", "1968-го", "1968-му", "1968-м" и т.п.
# Суффиксы порядковых
_ORDINAL_SUFFIX_MAP = {
# Masculine
"ого": ("genitive", "m"),
@@ -126,20 +115,15 @@ _ORDINAL_SUFFIX_MAP = {
def get_case_from_preposition(prep_token):
"""Определяет падеж по предлогу."""
"""Падеж по предлогу."""
if not prep_token:
return None
return PREPOSITION_CASES.get(prep_token.lower())
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
"""
Обертка над num2words для конвертации числа в строку.
cardinal - количественное (один, два)
ordinal - порядковое (первый, второй)
"""
"""Число в слова."""
try:
# Обработка дробей (замена запятой на точку)
if "." in number_str or "," in number_str:
num_val = float(number_str.replace(",", "."))
else:
@@ -152,20 +136,18 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
def numbers_to_words(text: str) -> str:
"""
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
"""
"""Замена цифр на слова."""
if not text:
return ""
preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys()))
# 0. Обработка короткой записи годов с суффиксом: "1968-й", "1968-го", "1968-му", "1968-м", "в 1968-м году"
# Года с суффиксом
def replace_year_suffix_match(match):
prep = match.group(1) # Предлог (в, во, о...)
year_str = match.group(2) # Само число
suffix = match.group(3) # Суффикс порядкового числительного
year_word = match.group(4) # Слово "год", "году" и т.д. (опционально)
prep = match.group(1)
year_str = match.group(2)
suffix = match.group(3)
year_word = match.group(4)
case = None
gender = None
@@ -191,7 +173,9 @@ def numbers_to_words(text: str) -> str:
if not gender:
gender = "m"
words = convert_number(year_str, context_type="ordinal", case=case, gender=gender)
words = convert_number(
year_str, context_type="ordinal", case=case, gender=gender
)
prefix = f"{prep} " if prep else ""
if year_word:
@@ -206,25 +190,23 @@ def numbers_to_words(text: str) -> str:
text,
)
# 1. Обработка годов: "в 1999 году", "2024 год"
# Года
def replace_year_match(match):
prep = match.group(1) # Предлог (в, с, к...)
year_str = match.group(2) # Само число
year_word = match.group(3) # Слово "год", "году" и т.д.
prep = match.group(1)
year_str = match.group(2)
year_word = match.group(3)
# Определяем падеж слова "год" через pymorphy
# Падеж
parsed = morph.parse(year_word)[0]
case_tag = parsed.tag.case
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
# FIX: Pymorphy часто определяет "год" как accs (винительный), что для num2words
# превращается в родительный (для одушевленных?), давая "2024 года".
# Если предлога нет, принудительно ставим именительный.
# Без предлога - именительный
if not prep and year_word.lower().startswith("год"):
nw_case = "nominative"
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
# Конвертируем
words = convert_number(
year_str, context_type="ordinal", case=nw_case, gender="m"
)
@@ -239,7 +221,7 @@ def numbers_to_words(text: str) -> str:
text,
)
# 2. Обработка дат: "25 июня", "с 1 мая"
# Даты
month_regex = "|".join(MONTHS_GENITIVE)
def replace_date_match(match):
@@ -247,7 +229,7 @@ def numbers_to_words(text: str) -> str:
day_str = match.group(2)
month_word = match.group(3)
# По умолчанию родительный падеж ("двадцать пятого июня")
# По умолчанию родительный
case = "genitive"
if prep:
@@ -266,7 +248,7 @@ def numbers_to_words(text: str) -> str:
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
# Средний род для дат
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
prefix = f"{prep} " if prep else ""
@@ -279,7 +261,7 @@ def numbers_to_words(text: str) -> str:
text,
)
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
# Остальные числа
def replace_cardinal_match(match):
prep = match.group(1)
num_str = match.group(2)
@@ -294,7 +276,7 @@ def numbers_to_words(text: str) -> str:
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
# Если есть следующее слово, проверяем его род (для "2 минуты" -> "две")
# Проверяем род
if next_word:
word_clean = next_word.strip()
parsed = morph.parse(word_clean)[0]
@@ -302,21 +284,19 @@ def numbers_to_words(text: str) -> str:
morph_gender = parsed.tag.gender
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
# Спец-случай: "на 1 час" -> "на один час" (не "одного")
# Для неодушевленных муж./ср. рода в винительном падеже
# числительные должны совпадать с именительным.
if (
prep_clean == "на"
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):
case = "nominative"
# Спец-случай: "на 1 час"
if (
prep_clean == "на"
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):
case = "nominative"
words = convert_number(
num_str, context_type="cardinal", case=case, gender=gender
)
# Если конвертация вернула пустую строку (сбой?), возвращаем цифры
# Если конвертация не удалась - возвращаем цифры
if not words:
words = num_str
@@ -336,11 +316,7 @@ def numbers_to_words(text: str) -> str:
def roman_numerals_to_words(text: str) -> str:
"""
Преобразует римские цифры в порядковые числительные с учетом
морфологии предыдущего слова.
Пример: "Ивана III" -> "Ивана третьего".
"""
"""Римские в слова."""
if not text:
return ""
@@ -380,63 +356,53 @@ def roman_numerals_to_words(text: str) -> str:
def clean_response(text: str, language: str = "ru") -> str:
"""
Основная функция очистки.
Убирает Markdown, ссылки, мусор и преобразует числа.
Args:
text: Сырой текст от AI.
language: Язык (для конвертации чисел, работает только для ru).
"""
"""Очистка текста для TTS."""
if not text:
return ""
# Удаление ссылок на источники [1], [citation needed]
# Удаление ссылок
text = re.sub(r"\x5B\d+\x5D", "", text)
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE)
# Удаление жирного шрифта **text** и __text__
# Удаление жирного
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Удаление курсива *text* и _text_
# Удаление курсива
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Удаление зачеркнутого ~~text~~
# Удаление зачеркнутого
text = re.sub(r"~~(.+?)~~", r"\1", text)
# Удаление заголовков Markdown (# Header)
# Заголовки
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
# Удаление картинок ![alt](url) -> удаляем полностью
# Картинки
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
# Удаление ссылок [text](url) -> оставляем только text
# \x5B = [, \x5D = ]
# Ссылки
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
# Удаление inline кода `code`
# Код
text = re.sub(r"`([^`]+)`", r"\1", text)
# Удаление блоков кода ```code```
text = re.sub(r"```[\s\S]*?```", "", text)
# Удаление маркеров списков (-, *, 1.)
# Списки
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
# Удаление цитат >
# Цитаты
text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
# Удаление горизонтальных линий ---
# Линии
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
# Удаление HTML тегов
# HTML теги
text = re.sub(r"<[^>]+>", "", text)
# Удаление фразы "— это, скорее всего" в корректировках произношения
# Корректировки
text = re.sub(
r"([—-])\s*это,\s*скорее\s*всего\b\s*,?\s*",
r"\1 ",
@@ -445,7 +411,7 @@ def clean_response(text: str, language: str = "ru") -> str:
)
text = re.sub(r"[—-]\s*([.!?])", r"\1", text)
# Remove informal slang greetings at the beginning of sentences/responses
# Удаление сленга
text = re.sub(
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
"",
@@ -453,13 +419,13 @@ def clean_response(text: str, language: str = "ru") -> str:
flags=re.IGNORECASE | re.MULTILINE,
)
# Convert Roman numerals and Arabic digits to words for Russian.
# Числа в слова
if language == "ru":
text = roman_numerals_to_words(text)
if re.search(r"\d", text):
text = numbers_to_words(text)
# Remove extra whitespace
# Чистка пробелов
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r" +", " ", text)