Разбивка на предложения нужна LLM-разметке и была бы дублирована на стороне Talkscore. Логика живёт рядом с ASR, который и ставит пунктуацию, а сам вызов LLM остаётся на Talkscore: промпт меняется чаще, чем расшифровка, и переразметить сохранённый текст дешевле, чем гонять аудио заново. Документ интеграции дополнен итогами сравнения моделей, batch-ценами и проверками формата ответа. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
96 lines
4.7 KiB
Python
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
|