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

280 lines
10 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
# Инициализация морфологического анализатора (для определения падежей)
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",
}
# Соответствие падежей 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",
}
# Названия месяцев в родительном падеже (для поиска дат в тексте)
MONTHS_GENITIVE = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
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 ""
# 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match):
full_str = match.group(0)
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")
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
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 для месяцев (ВАЖНО: month_regex должен быть вставлен в строку)
text = re.sub(
r"(?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)
case = "nominative"
if prep:
morph_case = get_case_from_preposition(prep.strip())
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
words = convert_number(num_str, context_type="cardinal", case=case)
prefix = f"{prep} " if prep else ""
return f"{prefix}{words}"
text = re.sub(
r"(?i)\b((?:в|на|о|об|обо|при|у|от|до|из|с|со|без|для|вокруг|после|к|ко|по|над|под|перед|за|между)\s+)?(\d+(?:[.,]\d+)?)\b",
replace_cardinal_match,
text,
)
return 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)
# Удаление ссылок [text](url) -> оставляем только text
# \x5B = [, \x5D = ]
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
# Удаление картинок ![alt](url) -> удаляем полностью
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", 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)
# Remove informal slang greetings at the beginning of sentences/responses
text = re.sub(
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
"",
text,
flags=re.IGNORECASE | re.MULTILINE,
)
# Convert numbers to words only for Russian, and only if digits exist
if language == "ru" and 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()