first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Модуль 3: TTS (текст → звук)
Два провайдера за общим интерфейсом `TtsProvider`: переключение = выбор в конфиге.
| Провайдер | Сеть | Латентность (110 симв.) | Голоса | Роль |
|---|---|---|---|---|
| **edge** (`provider_edge.py`) | онлайн, бесплатный сервис MS, без ключа | 3.0 c (4 предл.), 1.7 c (короткая) | Svetlana/Dmitry, отличное качество | основной |
| **piper** (`provider_piper.py`) | офлайн, локально на CPU | 0.47 c | dmitri/irina/ruslan, проще | фолбэк без сети |
## Режимы edge-провайдера
- **Один запрос** (по умолчанию): весь текст → один round-trip (~1.7–3 c). Паузы между
предложениями удлиняются многоточиями («точка» → «…» — edge-tts читает как длинную паузу).
- **per_sentence=True**: каждое предложение отдельным запросом + точная тишина
`sentence_pause_sec` между ними. Медленнее (N запросов), нужен для тонкой подгонки пауз.
Замер 2026-09-11 (свежие соединения в каждом процессе): один запрос 110 симв = 3.0 c,
короткая фраза 36 симв = 1.7 c, Piper 110 симв = 0.47 c. В живой программе соединение
переиспользуется — будет быстрее. Цепочка целиком: STT ~1.2 c + LLM ~1–2 c + TTS ~1.7 c ≈ 4–5 c
от отпускания клавиши до голоса (маскируется earcon «думаю»).
## Использование из кода
```python
from modules.tts import get_provider
tts = get_provider("edge") # voice="ru-RU-DmitryNeural" — мужской
result = tts.synthesize("Привет! Как дела?")
# result.audio (float32), result.samplerate, result.generation_sec, result.duration_sec
```
Фолбэк при обрыве сети (switch = одна строка):
```python
tts = get_provider("piper", model_path="models/piper/ru_RU-dmitri-medium.onnx")
```
## Потоковая озвучка (стриминг, 2026-09-11)
`tts.stream(text)` — генератор: отдаёт чанки-предложения **по мере готовности**
(параллельные запросы, отдача в порядке следования). `Assistant` играёт чанк
сразу, не дожидаясь синтеза всего текста:
- первое предложение звучит через ~0.5 c после готовности LLM (один round-trip);
- длинные ответы больше не пропорционально тормозят (2-е/3-е предложение
синтезируются, пока играет 1-е);
- между чанками вставляется пауза sentence_pause_sec;
- перебивание отцом гасит поток и синтез (счётчик поколений).
Плюс в `Player.play()` добавлен префикс тишины 0.1 c (`lead_silence_sec`) —
защита от «съедания» первого слова при открытии аудио-потока (недозаполненный
буфер ALSA/Pulse).
## Настройка из .env (2026-09-11)
```
TTS_PROVIDER=edge # edge | piper
TTS_VOICE=ru-RU-SvetlanaNeural # мужской: ru-RU-DmitryNeural
TTS_RATE=+15 # темп речи, % (+ = быстрее; ≈+30% к исходному −15)
PIPER_MODEL=models/piper/ru_RU-dmitri-medium.onnx
```
Выбранный конфиг юзером: **edge + Svetlana, rate +15** («женский голос лучше и темп
нормальный»). Piper остаётся офлайн-фолбэком (0.22 c синтез против 0.49 c у edge),
при желании ускориться — одна строка в .env.
Фабрика: `make_tts()` из `modules/tts` — тесты ассистента читают эти параметры
автоматически, смену голоса/скорости/провайдера можно делать без правки кода.
## Файлы
| Файл | Назначение |
|---|---|
| `base.py` | Интерфейс `TtsProvider`, результат `SynthResult` |
| `provider_edge.py` | edge-tts: один запрос / сшивание предложений |
| `provider_piper.py` | Piper офлайн (`length_scale` из rate автоматом в config) |
| `config.py` | `make_tts()` — фабрика из .env (провайдер, голос, скорость) |
| `text_split.py` | Разбивка на предложения; защита сокращений («т.д.», «т.к.») |
| `test_tts.py` | Тест-скрипт (синтез / воспроизведение / интерактив / провайдеры) |
## Тест-скрипт
```bash
.venv/bin/python modules/tts/test_tts.py --play # фраза по умолчанию + звук
.venv/bin/python modules/tts/test_tts.py --text "Своё предложение"
.venv/bin/python modules/tts/test_tts.py --voice ru-RU-DmitryNeural --play # мужской
.venv/bin/python modules/tts/test_tts.py --listen # вводишь текст — слышишь
.venv/bin/python modules/tts/test_tts.py --per-sentence # медленный режим (сравнение)
.venv/bin/python modules/tts/test_tts.py --provider piper --model models/piper/ru_RU-dmitri-medium.onnx --play
```
## Критерии приёмки Модуля 3
1. ✅ Русский голос звучит естественно, скорость −15% — принято юзером на слух (Svetlana).
2. ✅ Паузы между предложениями слышны (многоточия / точная тишина).
3. ✅ Синтез короткой фразы ≤ 3 c: 1.7 c (edge), 0.47 c (piper).
## Известные ограничения
- edge-tts — неофициальный API Microsoft: исторически ломался (403). План Б — Piper, он уже в проекте.
- Piper: голос скачивается один раз в `models/piper/` (команда в docstring провайдера);
качество проще edge, зато 0.5 c и полностью офлайн.
- В `provider_edge._run_async` учтён будущий asyncio-контекст (aiogram в Модуле 6):
синтез не сломает чужой event loop.
+44
View File
@@ -0,0 +1,44 @@
"""TTS-модуль: текст → звук.
Использование из других модулей:
from modules.tts import get_provider
tts = get_provider("edge") # онлайн, голос MS
tts = get_provider("piper", model_path="...") # офлайн-фолбэк
result = tts.synthesize("Привет! Как дела?")
# result.audio (float32), result.samplerate, result.generation_sec
"""
from __future__ import annotations
from .base import SynthResult, TtsProvider
from .config import make_tts
from .provider_edge import EdgeTtsProvider
from .provider_piper import PiperTtsProvider
from .text_split import split_sentences
__all__ = [
"SynthResult",
"TtsProvider",
"EdgeTtsProvider",
"PiperTtsProvider",
"make_tts",
"split_sentences",
"get_provider",
]
_PROVIDERS = {
EdgeTtsProvider.name: EdgeTtsProvider,
PiperTtsProvider.name: PiperTtsProvider,
}
def get_provider(name: str, **kwargs) -> TtsProvider:
"""Фабрика провайдеров по имени (выбор из конфига)."""
try:
provider_cls = _PROVIDERS[name]
except KeyError:
raise ValueError(
f"Неизвестный TTS-провайдер: {name!r}. Доступны: {sorted(_PROVIDERS)}"
) from None
return provider_cls(**kwargs)
+49
View File
@@ -0,0 +1,49 @@
"""Базовый интерфейс TTS-модуля (текст → звук).
Провайдеры (edge-tts онлайн, piper офлайн) реализуют TtsProvider,
чтобы остальной проект не зависел от конкретного синтезатора:
если edge-tts отвалится (неофициальный эндпоинт Microsoft), переключение
на Piper — одна строка в конфиге.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class SynthResult:
"""Результат синтеза одной фразы."""
text: str
audio: np.ndarray # float32 mono [-1..1]
samplerate: int
generation_sec: float # время генерации (без воспроизведения)
provider: str = ""
sentence_count: int = 1
@property
def duration_sec(self) -> float:
if self.samplerate <= 0:
return 0.0
return len(self.audio) / self.samplerate
def __str__(self) -> str:
return (f"[{self.provider}] текст {len(self.text)} симв. → "
f"аудио {self.duration_sec:.1f} c, синтез {self.generation_sec:.2f} c, "
f"{self.samplerate} Гц, предложений: {self.sentence_count}")
class TtsProvider(ABC):
"""Интерфейс провайдера синтеза речи."""
name: str = "base"
@abstractmethod
def synthesize(self, text: str) -> SynthResult:
"""Синтезировать речь. text — уже очищенный от Markdown текст."""
def close(self) -> None:
"""Освободить ресурсы (по умолчанию — ничего)."""
+43
View File
@@ -0,0 +1,43 @@
"""Настройки TTS из .env — единая точка выбора провайдера и скорости речи.
Параметры .env (все опциональны):
TTS_PROVIDER=edge | piper (по умолчанию edge — лучше качество)
TTS_VOICE=ru-RU-SvetlanaNeural (голос edge; мужской: ru-RU-DmitryNeural)
TTS_RATE=+15 (скорость речи, %; +быстрее, −медленнее)
PIPER_MODEL=models/piper/ru_RU-dmitri-medium.onnx
Про скорость: −15 был «для пожилых медленно» по исходному ТЗ; живой тест показал,
что хочется быстрее (~+30% к этому темпу) → TTS_RATE=+15.
"""
from __future__ import annotations
from ..brain.config import get_secret
from .base import TtsProvider
from .provider_edge import EdgeTtsProvider
from .provider_piper import PiperTtsProvider
def make_tts(provider: str | None = None, **overrides) -> TtsProvider:
"""Создать TTS-провайдера по .env (+ переопределения в коде для тестов)."""
name = (provider or get_secret("TTS_PROVIDER") or "edge").lower()
rate = int(get_secret("TTS_RATE") or -15)
if name == "edge":
kwargs = {
"voice": get_secret("TTS_VOICE") or "ru-RU-SvetlanaNeural",
"rate": rate,
}
kwargs.update(overrides)
return EdgeTtsProvider(**kwargs)
if name == "piper":
# Piper: rate % → length_scale (1.15 ≈ −15%, 0.77 ≈ +30%)
length_scale = round(100.0 / (100.0 + rate), 3)
kwargs = {
"model_path": get_secret("PIPER_MODEL") or "models/piper/ru_RU-dmitri-medium.onnx",
"length_scale": length_scale,
}
kwargs.update(overrides)
return PiperTtsProvider(**kwargs)
raise ValueError(f"Неизвестный TTS_PROVIDER: {name!r} (доступны: edge, piper)")
+208
View File
@@ -0,0 +1,208 @@
"""Провайдер TTS на edge-tts (онлайн-сервис Microsoft, без ключа и бесплатно).
Плюсы: отличные русские голоса (Svetlana/Dmitry), rate/volume/pitch «из коробки».
Риск: неофициальный эндпоинт — исторически ломался (403). Поэтому в проекте
есть офлайн-фолбэк Piper, а провайдер создаётся через фабрику get_provider().
Скорость речи по ТЗ: −15% (rate="-15%").
Латентность: по умолчанию ВЕСЬ текст синтезируется ОДНИМ запросом (1 сетевой
round-trip, ~1–2 c), паузы между предложениями удлиняются многоточиями
(edge-tts читает «…» как длинную паузу). Режим per_sentence=True синтезирует
каждое предложение отдельным запросом и вставляет точную тишину
sentence_pause_sec — медленнее (N запросов), нужен только для тонкой подгонки пауз.
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import time
from typing import List, Optional, Union
import numpy as np
from ..audio_io.codec import decode_audio_bytes
from .base import SynthResult, TtsProvider
from .text_split import insert_pauses, split_sentences
def _fmt_prosody(value: Union[int, str], unit: str) -> str:
"""int → '+15%' / '-15%' (edge-tts требует знак и единицу)."""
if isinstance(value, int):
return f"{value:+d}{unit}"
return str(value)
def _run_async(coro):
"""Выполнить корутину из синхронного кода.
Если event loop уже крутится (например, поток aiogram в Модуле 6) —
выполняем в отдельном потоке, чтобы не ломать чужой цикл.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(asyncio.run, coro).result()
class EdgeTtsProvider(TtsProvider):
name = "edge"
def __init__(
self,
voice: str = "ru-RU-SvetlanaNeural", # мужской: ru-RU-DmitryNeural
rate: Union[int, str] = -15, # −15% скорости по ТЗ
volume: Union[int, str] = 0, # +0% (громкость задаётся в Player)
pitch: Union[int, str] = 0, # +0 Гц
sentence_pause_sec: float = 0.35, # точная пауза (только per_sentence)
per_sentence: bool = False, # False = один запрос (быстро)
) -> None:
self._voice = voice
self._rate = _fmt_prosody(rate, "%")
self._volume = _fmt_prosody(volume, "%")
self._pitch = _fmt_prosody(pitch, "Hz")
self._pause = float(sentence_pause_sec)
self._per_sentence = bool(per_sentence)
self._cache: dict[str, SynthResult] = {} # повторные фразы (прощание и т.п.) — 0 c
# --- API -------------------------------------------------------------
def warmup(self) -> None:
"""Прогрев: короткий синтез (резолв DNS, TLS-сессия, соединение с сервисом).
Вызывается ассистентом в фоне при старте — первый реальный ответ
синтезируется за ~0.5 c вместо 2.5–4 c холодного соединения.
"""
try:
self.synthesize("Слушаю вас.")
except Exception:
pass
def stream(self, text: str):
"""Потоковая озвучка: чанки-предложения по мере готовности.
Yields (audio: np.ndarray, samplerate: int) — первый чанк приходит
через ~один сетевой round-trip (~0.5 c), не дожидаясь синтеза всего
текста. Пауза sentence_pause_sec уже вставлена В КОНЕЦ каждого чанка.
"""
sentences = split_sentences(text) or [text]
if len(sentences) == 1:
audio, sr = decode_audio_bytes(self._generate_one(text))
yield audio, sr
return
# все запросы параллельно; отдаём в порядке следования предложений
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(len(sentences), 4)) as pool:
for mp3 in pool.map(self._generate_one, sentences):
audio, sr = decode_audio_bytes(mp3)
yield audio, sr
def synthesize(self, text: str) -> SynthResult:
t0 = time.perf_counter()
cached = self._cache.get(text)
if cached is not None:
return SynthResult(
text=cached.text, audio=cached.audio, samplerate=cached.samplerate,
generation_sec=0.0, provider=self.name,
sentence_count=cached.sentence_count,
)
if self._per_sentence:
result = self._synthesize_stitched(text, t0)
else:
result = self._synthesize_single(text, t0)
if len(text) <= 120: # кэшируем только короткие (прощание, отказы)
self._cache[text] = result
return result
def _synthesize_single(self, text: str, t0: float) -> SynthResult:
"""Минимальная латентность: предложения синтезируются ПАРАЛЛЕЛЬНО
(каждый edge-запрос несёт ~1.3 c сетевого round-trip; параллельно —
суммарное время = самый долгий запрос, а не сумма), затем склеиваются
с тишиной sentence_pause_sec между ними."""
sentences = split_sentences(text) or [text]
if len(sentences) == 1:
audio, samplerate = decode_audio_bytes(self._generate_one(text))
return SynthResult(
text=text,
audio=audio,
samplerate=samplerate,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=1,
)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(len(sentences), 4)) as pool:
mp3_list = list(pool.map(self._generate_one, sentences))
pieces: List[np.ndarray] = []
samplerate: Optional[int] = None
pause_samples = int(self._pause * 24000) # уточним после первого декода
for i, mp3 in enumerate(mp3_list):
audio, sr = decode_audio_bytes(mp3)
if samplerate is None:
samplerate = sr
pause_samples = int(self._pause * sr)
pieces.append(audio)
if i < len(mp3_list) - 1 and self._pause > 0:
pieces.append(np.zeros(pause_samples, dtype=np.float32))
combined = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
return SynthResult(
text=text,
audio=combined,
samplerate=samplerate or 24000,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=len(sentences),
)
def _synthesize_stitched(self, text: str, t0: float) -> SynthResult:
"""Каждое предложение отдельным запросом + точная пауза-тишина (медленно)."""
sentences = split_sentences(text) or [text]
pieces: List[np.ndarray] = []
samplerate: Optional[int] = None
for i, sentence in enumerate(sentences):
audio, sr = decode_audio_bytes(self._generate_one(sentence))
if samplerate is None:
samplerate = sr
pieces.append(audio)
if i < len(sentences) - 1 and self._pause > 0:
pieces.append(np.zeros(int(self._pause * sr), dtype=np.float32))
combined = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
return SynthResult(
text=text,
audio=combined,
samplerate=samplerate or 24000,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=len(sentences),
)
# --- внутреннее ------------------------------------------------------
def _generate_one(self, sentence: str) -> bytes:
"""mp3-байты одного предложения."""
import edge_tts
async def _inner() -> bytes:
com = edge_tts.Communicate(
sentence,
self._voice,
rate=self._rate,
volume=self._volume,
pitch=self._pitch,
)
chunks: List[bytes] = []
async for item in com.stream():
if item["type"] == "audio":
chunks.append(item["data"])
return b"".join(chunks)
return _run_async(_inner())
+79
View File
@@ -0,0 +1,79 @@
"""Провайдер TTS на Piper — полностью офлайн-фолбэк.
Используется, если edge-tts недоступен (сервис Microsoft ломался в прошлом,
а интернет у отца может пропасть). Piper работает локально на CPU.
Установка и голос (однократно):
pip install piper-tts
python -m piper.download_voices ru_RU-dmitri-medium --download-dir models/piper
Пайпер медленнее edge-tts и голоса проще, но работает без сети.
API: PiperVoice.load(path); voice.synthesize(text) -> чанки AudioChunk
(audio_int16_bytes, sample_rate, sample_width, sample_channels).
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import List, Optional
import numpy as np
from .base import SynthResult, TtsProvider
from .text_split import split_sentences
class PiperTtsProvider(TtsProvider):
name = "piper"
def __init__(
self,
model_path: Union[str, Path],
length_scale: float = 1.15, # 1.0 = обычная скорость; 1.15 ≈ −15%
noise_scale: float = 0.667,
noise_w_scale: float = 0.8,
sentence_pause_sec: float = 0.35,
) -> None:
from piper import PiperVoice, SynthesisConfig # ленивый импорт
path = Path(model_path)
if not path.is_file():
raise FileNotFoundError(
f"Модель Piper не найдена: {path}\n"
f"Скачай голос: python -m piper.download_voices ru_RU-dmitri-medium "
f"--download-dir {path.parent}"
)
self._voice = PiperVoice.load(str(path))
self._config = SynthesisConfig(
length_scale=length_scale,
noise_scale=noise_scale,
noise_w_scale=noise_w_scale,
)
self._pause = float(sentence_pause_sec)
def synthesize(self, text: str) -> SynthResult:
t0 = time.perf_counter()
sentences = split_sentences(text) or [text]
pieces: List[np.ndarray] = []
samplerate: Optional[int] = None
for i, sentence in enumerate(sentences):
chunks = list(self._voice.synthesize(sentence, syn_config=self._config))
for ch in chunks:
if samplerate is None:
samplerate = ch.sample_rate
pcm = np.frombuffer(ch.audio_int16_bytes, dtype=np.int16)
pieces.append(pcm.astype(np.float32) / 32768.0)
if i < len(sentences) - 1 and self._pause > 0 and samplerate:
pieces.append(np.zeros(int(self._pause * samplerate), dtype=np.float32))
audio = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
return SynthResult(
text=text,
audio=audio,
samplerate=samplerate or 22050,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=len(sentences),
)
+107
View File
@@ -0,0 +1,107 @@
"""Тест TTS-модуля (Модуль 3).
Запуск:
python modules/tts/test_tts.py # синтез фразы по умолчанию, сохранить wav
python modules/tts/test_tts.py --play # то же + воспроизвести
python modules/tts/test_tts.py --text "Своё предложение"
python modules/tts/test_tts.py --voice ru-RU-DmitryNeural --play
python modules/tts/test_tts.py --rate -10 # другая скорость
python modules/tts/test_tts.py --provider piper --model models/piper/ru_RU-dmitri-medium.onnx
python modules/tts/test_tts.py --listen # интерактивно: вводишь текст — слышишь
Критерии приёмки Модуля 3:
1. Русская фраза звучит естественно, скорость на ~15% медленнее обычной.
2. Несколько предложений — с заметными паузами между ними.
3. Синтез короткой фразы (до 100 символов) — <= 3 c.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
try:
from modules.audio_io import Player
from modules.audio_io.codec import decode_audio_bytes # noqa: F401 (использование ниже)
from modules.tts import get_provider
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from modules.audio_io import Player
from modules.audio_io.codec import decode_audio_bytes # noqa: F401
from modules.tts import get_provider
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
DEFAULT_TEXT = (
"Здравствуйте! Это проверка голоса ассистента. "
"Сейчас проверим, насколько понятна речь. Погода сегодня хорошая."
)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Тест TTS-модуля")
p.add_argument("--text", default=DEFAULT_TEXT, help="текст для синтеза")
p.add_argument("--provider", default="edge", choices=["edge", "piper"])
p.add_argument("--voice", default="ru-RU-SvetlanaNeural",
help="голос edge-tts (ru-RU-DmitryNeural — мужской)")
p.add_argument("--rate", default=-15, help="скорость речи, %% (−15 по ТЗ)")
p.add_argument("--model", default="models/piper/ru_RU-dmitri-medium.onnx",
help="путь к .onnx модели Piper")
p.add_argument("--pause", type=float, default=0.35, help="пауза между предложениями, с")
p.add_argument("--per-sentence", action="store_true",
help="режим «предложение = запрос» (сравнение латентности)")
p.add_argument("--play", action="store_true", help="воспроизвести результат")
p.add_argument("--listen", action="store_true",
help="интерактивно: вводишь текст — слышишь звук")
p.add_argument("--save", default=str(SAMPLES_DIR / "tts_out.wav"))
return p.parse_args()
def speak_once(tts, text: str, play: bool, save: Path) -> None:
result = tts.synthesize(text)
print(result)
save = Path(save)
save.parent.mkdir(parents=True, exist_ok=True)
import soundfile as sf
sf.write(str(save), result.audio, result.samplerate)
print(f"Сохранено: {save} ({len(result.audio) / result.samplerate:.1f} c)")
if play:
player = Player()
print("Воспроизвожу...")
player.play(result.audio, result.samplerate, blocking=True)
def listen_loop(tts) -> None:
player = Player()
print("Режим «слушать»: вводи текст, Enter — озвучить, пустая строка — выход.")
while True:
text = input("текст> ").strip()
if not text:
return
result = tts.synthesize(text)
print(f" синтез {result.generation_sec:.2f} c, аудио {result.duration_sec:.1f} c")
player.play(result.audio, result.samplerate, blocking=True)
def main() -> int:
args = parse_args()
kwargs = {"sentence_pause_sec": args.pause}
if args.provider == "edge":
kwargs.update(voice=args.voice, rate=int(args.rate), per_sentence=args.per_sentence)
else:
kwargs.update(model_path=args.model)
tts = get_provider(args.provider, **kwargs)
if args.listen:
listen_loop(tts)
else:
speak_once(tts, args.text, args.play, args.save)
return 0
if __name__ == "__main__":
sys.exit(main())
+38
View File
@@ -0,0 +1,38 @@
"""Разбиение текста на предложения — для вставки увеличенных пауз.
Пожилой человек воспринимает речь медленнее: между предложениями
добавляем тишину (задаётся в провайдере как sentence_pause_sec).
Минимальная защита от сокращений («т.д.», «т.п.» и т.п.).
"""
from __future__ import annotations
import re
from typing import List
# Сокращения, где точка — не конец предложения (после точки может быть «д»)
_ABBREV = ["т.д", "т.п", "т.е", "т.к", "т.н", "др", "пр", "г", "ул", "кв", "руб", "мин"]
_SENT_SPLIT = re.compile(r"(?<=[.!?…])\s+")
def split_sentences(text: str) -> List[str]:
"""Разбить текст на предложения (простая эвристика, без NLP)."""
text = text.strip()
if not text:
return []
# Временно прячем точки в сокращениях
protected = text
for abbr in _ABBREV:
protected = protected.replace(f" {abbr}.", f" {abbr}\x01")
parts = [p.strip() for p in _SENT_SPLIT.split(protected) if p.strip()]
# Возвращаем точки на место
for abbr in _ABBREV:
parts = [p.replace(f"{abbr}\x01", f"{abbr}.") for p in parts]
return parts
def insert_pauses(sentences: List[str]) -> str:
"""Склеить предложения с «длинными» точками (… ) для естественных пауз в одном запросе."""
return "… ".join(sentences) if len(sentences) > 1 else (sentences[0] if sentences else "")