Files
smart-speaker/app/core/cleaner.py

467 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.
import re
import pymorphy3
from num2words import num2words
from .roman import roman_to_int
# Инициализация морфологического анализатора (для определения падежей)
morph = pymorphy3.MorphAnalyzer()
# Карта предлогов и падежей.
# Помогает понять, в какой падеж ставить число после предлога.
PREPOSITION_CASES = {
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
"во": "loct",
"на": "accs", # На какое число? (Винительный) - для дат.
"о": "loct",
"об": "loct",
"обо": "loct",
"при": "loct",
"у": "gent", # У кого/чего? (Родительный)
"от": "gent",
"до": "gent",
"из": "gent",
"с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа).
"со": "gent",
"без": "gent",
"для": "gent",
"вокруг": "gent",
"после": "gent",
"к": "datv", # К кому/чему? (Дательный)
"ко": "datv",
"по": "datv",
"над": "ablt", # Над кем/чем? (Творительный)
"под": "ablt",
"перед": "ablt",
"за": "ablt",
"между": "ablt",
"около": "gent",
"против": "gent",
"вместо": "gent",
"кроме": "gent",
"из-за": "gent",
"сквозь": "accs",
"через": "accs",
"про": "accs",
}
# Соответствие падежей pymorphy и библиотеки num2words
PYMORPHY_TO_NUM2WORDS = {
"nomn": "nominative",
"gent": "genitive",
"datv": "dative",
"accs": "accusative",
"ablt": "instrumental",
"loct": "prepositional",
"voct": "nominative",
"gen2": "genitive",
"acc2": "accusative",
"loc2": "prepositional",
}
# Соответствие родов pymorphy и num2words
PYMORPHY_TO_GENDER = {
"masc": "m",
"femn": "f",
"neut": "n",
}
# Названия месяцев в родительном падеже (для поиска дат в тексте)
MONTHS_GENITIVE = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
# Леммы единиц времени (для корректного падежа числительных)
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
# Суффиксы порядковых числительных для формата "1968-й", "1968-го", "1968-му", "1968-м" и т.п.
_ORDINAL_SUFFIX_MAP = {
# Masculine
"ого": ("genitive", "m"),
"его": ("genitive", "m"),
"ому": ("dative", "m"),
"ему": ("dative", "m"),
"ым": ("instrumental", "m"),
"им": ("instrumental", "m"),
"ом": ("prepositional", "m"),
"ем": ("prepositional", "m"),
"ый": ("nominative", "m"),
"ий": ("nominative", "m"),
"й": ("nominative", "m"),
"го": ("genitive", "m"),
"му": ("dative", "m"),
"м": ("prepositional", "m"),
# Feminine
"ая": ("nominative", "f"),
"яя": ("nominative", "f"),
"ую": ("accusative", "f"),
"юю": ("accusative", "f"),
"ой": ("genitive", "f"),
"ей": ("genitive", "f"),
# Neuter
"ое": ("nominative", "n"),
"ее": ("nominative", "n"),
}
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:
num_val = int(number_str)
return num2words(num_val, lang="ru", to=context_type, case=case, gender=gender)
except Exception as e:
print(f"Error converting number {number_str}: {e}")
return number_str
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) # Слово "год", "году" и т.д. (опционально)
case = None
gender = None
if prep:
morph_case = get_case_from_preposition(prep.strip().lower())
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case)
suffix_key = suffix.lower()
suffix_case, suffix_gender = _ORDINAL_SUFFIX_MAP.get(suffix_key, (None, None))
if not case and suffix_case:
case = suffix_case
if year_word:
gender = "m"
elif suffix_gender:
gender = suffix_gender
if not case:
case = "nominative"
if not gender:
gender = "m"
words = convert_number(year_str, context_type="ordinal", case=case, gender=gender)
prefix = f"{prep} " if prep else ""
if year_word:
return f"{prefix}{words} {year_word}"
return f"{prefix}{words}"
text = re.sub(
rf"(?i)\b((?:{preps_list})\s+)?(\d{{3,4}})[-‑–—]"
r"(ого|его|ому|ему|ым|им|ом|ем|ый|ий|ая|яя|ую|юю|ой|ей|ое|ее|й|го|му|м)\b"
r"(?:\s+(год[а-я]*))?",
replace_year_suffix_match,
text,
)
# 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match):
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"
)
prefix = f"{prep} " if prep else ""
return f"{prefix}{words} {year_word}"
# Регулярка для годов
text = re.sub(
r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b",
replace_year_match,
text,
)
# 2. Обработка дат: "25 июня", "с 1 мая"
month_regex = "|".join(MONTHS_GENITIVE)
def replace_date_match(match):
prep = match.group(1)
day_str = match.group(2)
month_word = match.group(3)
# По умолчанию родительный падеж ("двадцать пятого июня")
case = "genitive"
if prep:
prep_clean = prep.strip().lower()
# Специфичные правила для дат
if prep_clean == "на":
case = "accusative" # на пятое мая
elif prep_clean == "по":
case = "accusative" # по пятое
elif prep_clean == "к":
case = "dative" # к пятому
elif prep_clean in ["с", "до", "от"]:
case = "genitive" # с пятого
else:
morph_case = get_case_from_preposition(prep_clean)
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 ""
return f"{prefix}{words} {month_word}"
# Конкатенация regex для месяцев (FIX: используем f-строку)
text = re.sub(
rf"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{{1,2}})\s+({month_regex})\b",
replace_date_match,
text,
)
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
def replace_cardinal_match(match):
prep = match.group(1)
num_str = match.group(2)
next_word = match.group(3)
case = "nominative"
gender = "m"
prep_clean = prep.strip().lower() if prep else None
if prep_clean:
morph_case = get_case_from_preposition(prep_clean)
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]
if "NOUN" in parsed.tag:
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"
words = convert_number(
num_str, context_type="cardinal", case=case, gender=gender
)
# Если конвертация вернула пустую строку (сбой?), возвращаем цифры
if not words:
words = num_str
prefix = f"{prep} " if prep else ""
# suffix removed (lookahead)
return f"{prefix}{words}"
# Регулярка теперь захватывает (опционально) следующее слово для определения рода
text = re.sub(
rf"(?i)(?<!\w)((?:{preps_list})\s+)?([+-]?\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b",
replace_cardinal_match,
text,
)
return text
def roman_numerals_to_words(text: str) -> str:
"""
Преобразует римские цифры в порядковые числительные с учетом
морфологии предыдущего слова.
Пример: "Ивана III" -> "Ивана третьего".
"""
if not text:
return ""
def replace_roman_match(match):
prev_word = match.group(1)
roman = match.group(2)
number = roman_to_int(roman)
if number is None:
return match.group(0)
case = "nominative"
gender = "m"
try:
parsed = morph.parse(prev_word)[0]
case_tag = parsed.tag.case
gender_tag = parsed.tag.gender
if case_tag:
case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
if gender_tag:
gender = PYMORPHY_TO_GENDER.get(gender_tag, "m")
except Exception:
pass
ordinal = convert_number(
str(number), context_type="ordinal", case=case, gender=gender
)
return f"{prev_word} {ordinal}"
return re.sub(
r"(?i)\b([А-Яа-яЁё]+)\s+([IVXLCDM]+)\b",
replace_roman_match,
text,
)
def clean_response(text: str, language: str = "ru") -> str:
"""
Основная функция очистки.
Убирает Markdown, ссылки, мусор и преобразует числа.
Args:
text: Сырой текст от AI.
language: Язык (для конвертации чисел, работает только для ru).
"""
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 тегов
text = re.sub(r"<[^>]+>", "", text)
# Удаление фразы "— это, скорее всего" в корректировках произношения
text = re.sub(
r"([—-])\s*это,\s*скорее\s*всего\b\s*,?\s*",
r"\1 ",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"[—-]\s*([.!?])", r"\1", text)
# Remove informal slang greetings at the beginning of sentences/responses
text = re.sub(
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
"",
text,
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)
return text.strip()