import re from num2words import num2words import pymorphy3 _morph = pymorphy3.MorphAnalyzer() # Падеж по предлогу перед временем _PREP_CASE = { "с": "gent", "со": "gent", "до": "gent", "от": "gent", "после": "gent", "около": "gent", "к": "datv", "ко": "datv", "в": "accs", "во": "accs", "на": "accs", "через": "accs", "за": "accs", "перед": "ablt", "между": "ablt", "о": "loct", "об": "loct", "при": "loct", } def _inflect_num(n: int, case: str, gender: str = "masc") -> str: """Число → слова в нужном падеже (одиннадцать → одиннадцати).""" words = num2words(n, lang="ru", to="cardinal") if case == "nomn": return words parts = words.split() out = [] for w in parts: p = _morph.parse(w)[0] infl = p.inflect({case}) out.append(infl.word if infl else w) return " ".join(out) def _hours_word(n: int, case: str) -> str: """Правильная форма 'час': 1 час, 2-4 часа, 5+ часов — с учётом падежа.""" last2 = n % 100 last1 = n % 10 if 11 <= last2 <= 14: base = "часов" elif last1 == 1: base = "час" elif 2 <= last1 <= 4: base = "часа" else: base = "часов" if case in ("nomn", "accs"): return base p = _morph.parse(base)[0] infl = p.inflect({case, "plur" if base == "часов" else "sing"}) return infl.word if infl else base def _minutes_word(n: int, case: str) -> str: last2 = n % 100 last1 = n % 10 if 11 <= last2 <= 14: base = "минут" elif last1 == 1: base = "минута" elif 2 <= last1 <= 4: base = "минуты" else: base = "минут" if case in ("nomn", "accs"): return base p = _morph.parse(base)[0] infl = p.inflect({case}) return infl.word if infl else base def _format_time(h: int, mm: int, case: str) -> str: h_words = _inflect_num(h, case, gender="masc") out = f"{h_words} {_hours_word(h, case)}" if mm: m_words = _inflect_num(mm, case, gender="femn") out += f" {m_words} {_minutes_word(mm, case)}" return out # Единицы измерения со слэшем — раскрываем до чтения слэша UNIT_SLASH = [ (r'\bкм\s*/\s*ч\b', 'километров в час'), (r'\bм\s*/\s*с\b', 'метров в секунду'), (r'\bкм\s*/\s*с\b', 'километров в секунду'), (r'\bмб\s*/\s*с\b', 'мегабит в секунду'), (r'\bгб\s*/\s*с\b', 'гигабит в секунду'), (r'\bруб\s*/\s*мес\b', 'рублей в месяц'), (r'\bр\s*/\s*мес\b', 'рублей в месяц'), ] def clean_for_speech(text: str) -> str: text = re.sub(r'\*+', '', text) # убрать **жирный** text = re.sub(r'#+\s', '', text) # убрать ## заголовки text = re.sub(r'- ', '', text) # убрать тире списков text = re.sub(r'\[.*?\]\(.*?\)', '', text) # убрать ссылки text = re.sub(r'\n+', '. ', text) # переносы → точки # составные единицы со слэшем — до общей замены `/` for pat, repl in UNIT_SLASH: text = re.sub(pat, repl, text, flags=re.IGNORECASE) # знаки перед числом: "+9", "-3", "±2" text = re.sub(r'(^|\s)\+(\d)', r'\1плюс \2', text) text = re.sub(r'(^|\s)-(\d)', r'\1минус \2', text) text = re.sub(r'±(\d)', r'плюс-минус \1', text) # время "HH:MM" → слова в падеже по предшествующему предлогу def _time_repl(m): prep = (m.group(1) or "").lower() h, mm = int(m.group(2)), int(m.group(3)) if not (0 <= h <= 23 and 0 <= mm <= 59): return m.group(0) case = _PREP_CASE.get(prep, "nomn") words = _format_time(h, mm, case) return f"{prep} {words}" if prep else words text = re.sub( r'(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b', _time_repl, text, flags=re.IGNORECASE, ) # дроби и отношения "12/15" → "12 из 15", "5/10" → "5 из 10" text = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1 из \2', text) # одиночный слэш — как союз "или" text = text.replace('/', ' или ') # градусы и прочие символы text = re.sub(r'°\s*C\b', ' градусов', text, flags=re.IGNORECASE) text = re.sub(r'°\s*F\b', ' градусов Фаренгейта', text, flags=re.IGNORECASE) text = text.replace('°', ' градусов') text = text.replace('%', ' процентов') text = text.replace('№', ' номер ') text = text.replace('&', ' и ') text = text.replace('@', ' собака ') text = text.replace('×', ' на ') # распространённые сокращения в полную форму — иначе TTS буквоедит abbr = [ (r'\bт\.\s*е\.', 'то есть'), (r'\bт\.\s*к\.', 'так как'), (r'\bт\.\s*д\.', 'так далее'), (r'\bт\.\s*п\.', 'тому подобное'), (r'\bи\s*т\.\s*д\.', 'и так далее'), (r'\bи\s*т\.\s*п\.', 'и тому подобное'), (r'\bпо-\s*русски\b', 'по-русски'), ] for pat, repl in abbr: text = re.sub(pat, repl, text, flags=re.IGNORECASE) # схлопываем дубли пунктуации text = re.sub(r'([:;,!?])\s*\.', r'\1', text) text = re.sub(r'\.\s*\.+', '.', text) text = re.sub(r'\s+', ' ', text) # лишние пробелы text = re.sub(r'(\d+)\.(\s)', r'\1\2', text) return text.strip() def find_sentence_end(text: str, min_len: int = 60) -> int: """Ищет конец предложения, игнорируя ложные точки""" if len(text) < min_len: return -1 for match in re.finditer(r'[.!?]', text): pos = match.start() if pos < min_len: continue before_1 = text[max(0, pos-1):pos] # 1 символ до before_3 = text[max(0, pos-3):pos] # 3 символа до after_2 = text[pos+1:pos+3] # 2 символа после after_stripped = after_2.lstrip() # 1. Цифра.Цифра → "0.76", "3.14" if before_1.isdigit() and after_2[:1].isdigit(): continue # 2. Цифра. Цифра → "1. 2 ГБ" if before_1.isdigit() and after_stripped[:1].isdigit(): continue # 3. Аббревиатуры → "ГБ.", "МБ.", "км.", "шт.", "руб.", "млн.", "млрд." abbrevs = ["гб", "мб", "кб", "тб", "км", "см", "мм", "шт", "руб", "млн", "млрд", "тыс", "кг", "гр", "мл", "gb", "mb", "kb", "tb", "km", "ms", "kb"] if any(before_3.lower().endswith(a) for a in abbrevs): continue # 4. Одиночная заглавная буква → "А.", "В.", "США." (инициалы/аббр.) if len(before_3.strip()) == 1 and before_3.strip().isupper(): continue # 5. После точки строчная буква → "load avg. нормально" if after_stripped and after_stripped[0].islower(): continue # 6. Многоточие → "..." if text[pos:pos+3] == "...": continue # 7. Точка внутри URL или IP → "192.168.1.1", "example.com" if before_1.isdigit() or (after_2[:1].isdigit() and "." in before_3): continue # 8. Процент с точкой → "95.5%" if "%" in after_2[:2]: continue return pos return -1