""" 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", "около": "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 = {"час", "минута", "секунда"} 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 "" # 0. Обработка короткой записи годов с суффиксом: "1968-м", "в 1968-м году" def replace_year_suffix_match(match): prep = match.group(1) # Предлог (в, во, о...) year_str = match.group(2) # Само число year_word = match.group(3) # Слово "год", "году" и т.д. (опционально) # Суффикс "-м/-ом" обычно соответствует предложному падежу words = convert_number( year_str, context_type="ordinal", case="prepositional", gender="m" ) prefix = f"{prep} " if prep else "" if year_word: return f"{prefix}{words} {year_word}" return f"{prefix}{words}" text = re.sub( r"(?i)\b((?:в|во|о|об|обо|при)\s+)?(\d{3,4})[-‑–—](?:м|ом)\b(?:\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}" # Регулярка теперь захватывает (опционально) следующее слово для определения рода preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys())) text = re.sub( rf"(?i)(? 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"(? удаляем полностью 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 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()