Files
talkscore-asr/app/text.py
T
Vladimir BryzgalovandClaude Opus 5 d4a24af6f0 Готовые предложения в ответе для разметки ролей
Разбивка на предложения нужна LLM-разметке и была бы дублирована на стороне
Talkscore. Логика живёт рядом с ASR, который и ставит пунктуацию, а сам
вызов LLM остаётся на Talkscore: промпт меняется чаще, чем расшифровка,
и переразметить сохранённый текст дешевле, чем гонять аудио заново.

Документ интеграции дополнен итогами сравнения моделей, batch-ценами
и проверками формата ответа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:38:34 +05:00

96 lines
4.7 KiB
Python

"""Постобработка распознанного текста.
Две задачи: чинить систематические ошибки модели на английских терминах
(GigaAM обучена только на русском и коверкает их предсказуемым образом)
и приводить типографику к принятому виду.
"""
import re
from pathlib import Path
__all__ = ["apply_replacements", "load_replacements", "normalize_typography",
"split_sentences"]
# Все виды тире, которые встречаются в выводе моделей, включая минус.
_DASHES = "‐‑‒–—―−"
_DASH_RE = re.compile(f"\\s*[{_DASHES}]\\s*")
# Кавычка-ёлочка, приклеенная к предыдущему слову без пробела.
_GLUED_QUOTE_RE = re.compile(r"(?<=[^\s])(«)")
_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
def load_replacements(path: Path) -> dict[str, str]:
"""Читает словарь замен из файла вида `что искать = на что заменить`.
Пустые строки и строки, начинающиеся с #, игнорируются, как и строки
без разделителя - чтобы опечатка в файле не роняла сервис.
"""
if not Path(path).is_file():
return {}
rules: dict[str, str] = {}
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
src, dst = line.split("=", 1)
src, dst = src.strip(), dst.strip()
if src:
rules[src.lower()] = dst
return rules
def apply_replacements(text: str, rules: dict[str, str]) -> str:
"""Заменяет термины по словарю, не трогая совпадения внутри слов.
Длинные правила применяются первыми, иначе «так менеджер» съел бы часть
«гугл так менеджер» и более точное правило уже не сработало бы.
"""
if not rules:
return text
for src in sorted(rules, key=len, reverse=True):
pattern = re.compile(rf"(?<!\w){re.escape(src)}(?!\w)", re.IGNORECASE)
text = pattern.sub(rules[src].replace("\\", r"\\"), text)
return text
def normalize_typography(text: str) -> str:
"""Приводит типографику к принятому виду: тире в дефис, отбивка кавычек."""
text = _DASH_RE.sub(" - ", text)
text = _GLUED_QUOTE_RE.sub(r" \1", text)
text = _MULTISPACE_RE.sub(" ", text)
return text.strip()
# Конец предложения: точка, восклицательный, вопросительный или многоточие,
# после которых идёт пробел. Пунктуацию расставляет сама модель GigaAM.
_SENTENCE_END_RE = re.compile(r"(?<=[.!?…])\s+")
def split_sentences(turns: list[dict]) -> list[dict]:
"""Режет реплики на предложения для последующей разметки ролей.
Разделение по голосам на записях с одним микрофоном обычно не работает,
и роли расставляет LLM по смыслу текста. Ей нужны именно предложения:
в одну реплику диаризация складывает и вопрос одного человека, и ответ
другого, поэтому разметить реплику целиком нельзя.
Точного времени начала у предложения нет - внутри реплики оно делится
поровну. Для сборки диалога обратно этого достаточно, для точной привязки
к звуку - нет.
"""
out: list[dict] = []
for index, turn in enumerate(turns):
parts = [p.strip() for p in _SENTENCE_END_RE.split(turn["text"]) if p.strip()]
if not parts:
continue
span = (turn["end"] - turn["start"]) / len(parts)
loudness = (turn.get("acoustics") or {}).get("loudness_db")
for position, text in enumerate(parts):
out.append({
"n": len(out) + 1,
"text": text,
"start": round(turn["start"] + position * span, 2),
"loudness_db": loudness,
"turn": index,
})
return out