211 lines
8.1 KiB
Python
211 lines
8.1 KiB
Python
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, flags=re.UNICODE)
|
||
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
|