Разбивка на предложения нужна LLM-разметке и была бы дублирована на стороне Talkscore. Логика живёт рядом с ASR, который и ставит пунктуацию, а сам вызов LLM остаётся на Talkscore: промпт меняется чаще, чем расшифровка, и переразметить сохранённый текст дешевле, чем гонять аудио заново. Документ интеграции дополнен итогами сравнения моделей, batch-ценами и проверками формата ответа. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
6.3 KiB
Python
124 lines
6.3 KiB
Python
"""Тесты постобработки текста: словарь замен и типографика."""
|
||
import pytest
|
||
|
||
from app.text import apply_replacements, load_replacements, normalize_typography, split_sentences
|
||
|
||
|
||
class TestReplacements:
|
||
def test_replaces_known_term(self):
|
||
rules = {"тэг менеджер": "Tag Manager"}
|
||
assert apply_replacements("открываем тэг менеджер", rules) == "открываем Tag Manager"
|
||
|
||
def test_replacement_is_case_insensitive_on_match(self):
|
||
rules = {"тэг менеджер": "Tag Manager"}
|
||
assert apply_replacements("Открываем Тэг Менеджер тут", rules) == "Открываем Tag Manager тут"
|
||
|
||
def test_replacement_keeps_surrounding_punctuation(self):
|
||
rules = {"джетур": "G-Tour"}
|
||
assert apply_replacements("бренд джетур, смотрите", rules) == "бренд G-Tour, смотрите"
|
||
|
||
def test_does_not_replace_inside_word(self):
|
||
"""«лид» не должен ломать «лидер»."""
|
||
rules = {"лед": "лид"}
|
||
assert apply_replacements("лидер рынка", rules) == "лидер рынка"
|
||
|
||
def test_multiword_replacement(self):
|
||
rules = {"гугл так менеджер": "Google Tag Manager"}
|
||
assert apply_replacements("это гугл так менеджер", rules) == "это Google Tag Manager"
|
||
|
||
def test_empty_rules_returns_original(self):
|
||
assert apply_replacements("текст без изменений", {}) == "текст без изменений"
|
||
|
||
def test_longest_rule_wins(self):
|
||
"""Более длинное правило применяется раньше короткого."""
|
||
rules = {"так менеджер": "Tag Manager", "гугл так менеджер": "Google Tag Manager"}
|
||
assert apply_replacements("гугл так менеджер", rules) == "Google Tag Manager"
|
||
|
||
|
||
class TestLoadReplacements:
|
||
def test_parses_simple_file(self, tmp_path):
|
||
f = tmp_path / "r.txt"
|
||
f.write_text("джетур = G-Tour\nтэг менеджер = Tag Manager\n", encoding="utf-8")
|
||
rules = load_replacements(f)
|
||
assert rules == {"джетур": "G-Tour", "тэг менеджер": "Tag Manager"}
|
||
|
||
def test_skips_comments_and_blanks(self, tmp_path):
|
||
f = tmp_path / "r.txt"
|
||
f.write_text("# комментарий\n\nджетур = G-Tour\n\n# ещё\n", encoding="utf-8")
|
||
assert load_replacements(f) == {"джетур": "G-Tour"}
|
||
|
||
def test_missing_file_returns_empty(self, tmp_path):
|
||
assert load_replacements(tmp_path / "нет.txt") == {}
|
||
|
||
def test_ignores_line_without_separator(self, tmp_path):
|
||
f = tmp_path / "r.txt"
|
||
f.write_text("мусор без разделителя\nджетур = G-Tour\n", encoding="utf-8")
|
||
assert load_replacements(f) == {"джетур": "G-Tour"}
|
||
|
||
|
||
class TestTypography:
|
||
@pytest.mark.parametrize("dash", ["—", "–", "‒", "―", "−"])
|
||
def test_replaces_all_dash_variants_with_hyphen(self, dash):
|
||
assert normalize_typography(f"слово {dash} слово") == "слово - слово"
|
||
|
||
def test_glues_dash_without_spaces(self):
|
||
"""GigaAM пишет «слово—слово» без пробелов."""
|
||
assert normalize_typography("это—тестирование") == "это - тестирование"
|
||
|
||
def test_adds_space_after_opening_quote(self):
|
||
"""GigaAM пишет «слово«цитата» без пробела перед кавычкой."""
|
||
assert normalize_typography("спрашивает:«По метрике") == "спрашивает: «По метрике"
|
||
|
||
def test_collapses_multiple_spaces(self):
|
||
assert normalize_typography("много пробелов") == "много пробелов"
|
||
|
||
def test_keeps_hyphen_in_compound_word(self):
|
||
assert normalize_typography("look-alike и из-за") == "look-alike и из-за"
|
||
|
||
|
||
class TestSplitSentences:
|
||
"""Разметку ролей делает LLM по предложениям: реплику целиком разметить нельзя."""
|
||
|
||
@staticmethod
|
||
def turn(text, start=0.0, end=10.0, loudness=-20.0):
|
||
return {"text": text, "start": start, "end": end,
|
||
"acoustics": {"loudness_db": loudness}}
|
||
|
||
def test_splits_on_sentence_endings(self):
|
||
out = split_sentences([self.turn("Первое. Второе! Третье? Четвёртое…")])
|
||
assert [s["text"] for s in out] == ["Первое.", "Второе!", "Третье?", "Четвёртое…"]
|
||
|
||
def test_numbering_is_continuous_across_turns(self):
|
||
out = split_sentences([self.turn("А. Б."), self.turn("В. Г.")])
|
||
assert [s["n"] for s in out] == [1, 2, 3, 4]
|
||
|
||
def test_turn_index_is_kept(self):
|
||
"""По нему собирают диалог обратно после разметки."""
|
||
out = split_sentences([self.turn("А. Б."), self.turn("В.")])
|
||
assert [s["turn"] for s in out] == [0, 0, 1]
|
||
|
||
def test_time_is_spread_inside_turn(self):
|
||
out = split_sentences([self.turn("А. Б. В.", start=0.0, end=30.0)])
|
||
assert [s["start"] for s in out] == [0.0, 10.0, 20.0]
|
||
|
||
def test_loudness_comes_from_turn(self):
|
||
out = split_sentences([self.turn("А. Б.", loudness=-17.5)])
|
||
assert all(s["loudness_db"] == -17.5 for s in out)
|
||
|
||
def test_turn_without_acoustics_gives_none(self):
|
||
"""Короткие фрагменты акустику не считают - поле должно быть пустым, не падать."""
|
||
out = split_sentences([{"text": "А.", "start": 0.0, "end": 1.0, "acoustics": {}}])
|
||
assert out[0]["loudness_db"] is None
|
||
|
||
def test_text_without_punctuation_stays_one_sentence(self):
|
||
out = split_sentences([self.turn("совсем без знаков препинания")])
|
||
assert len(out) == 1
|
||
|
||
def test_empty_text_is_skipped(self):
|
||
assert split_sentences([self.turn(" ")]) == []
|
||
|
||
def test_abbreviation_dots_do_not_split(self):
|
||
"""Точка без пробела после неё концом предложения не считается."""
|
||
out = split_sentences([self.turn("Нужен документ п.2.1 из списка.")])
|
||
assert len(out) == 1
|