From 8727acd10de627554f07ec0a02a8704b8393ff00 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 18 Aug 2026 15:17:34 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=81=D0=BE=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B5=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три модели вместо одной считаются минуту и больше, плюс их загрузка при первом обращении - держать на этом HTTP-запрос нельзя. Первый запрос запускает сборку, страница обновляет себя сама и показывает ход работы. Отдаётся страницей, а не документом: библиотеки для .docx в поставке нет, а обновление возит только код. В Word переносится кнопкой копирования, которая кладёт в буфер разметку вместе с текстом. Тест поймал потерю: лишнее слово в конце реплики не отмечалось - индекс выходил за границу, и метка молча пропадала. Co-Authored-By: Claude Opus 5 (1M context) --- app/draft.py | 137 ++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 77 ++++++++++++++++++++++++- app/view.py | 97 ++++++++++++++++++++++++++++++- tests/test_draft.py | 52 +++++++++++++++++ 4 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 app/draft.py create mode 100644 tests/test_draft.py diff --git a/app/draft.py b/app/draft.py new file mode 100644 index 0000000..1647574 --- /dev/null +++ b/app/draft.py @@ -0,0 +1,137 @@ +"""Черновик расшифровки: подсвечены только места, где модели разошлись. + +Несколько моделей распознают одну запись. Проверено по эталону: там, где они +согласны, текст верен в 98 % случаев, где расходятся - в 80,6 %. Значит +слушать нужно четверть текста вместо всего. + +Отдаём страницей, а не документом: библиотеки для .docx в поставке нет, +а обновление возит только код. Страница копируется в Word кнопкой, которая +кладёт в буфер разметку вместе с текстом. +""" +import difflib +import logging +import re + +__all__ = ["EXTRA_MODELS", "normalize", "disputed", "build_draft"] + +log = logging.getLogger("talkscore-asr") + +# Модели подобраны разными по устройству: RNN-T молчит на трудных участках, +# CTC говорит хоть что-то. Их ошибки не совпадают, поэтому согласие значимо. +EXTRA_MODELS = ("gigaam-v3-e2e-ctc", "gigaam-v2-rnnt") +WORD_RE = re.compile(r"[\w-]+", re.UNICODE) +GAP_NOTICE_SEC = 3.0 + + +def normalize(word: str) -> str: + """Приводит слово к виду, в котором различия написания - не спор. + + «что-нибудь» и «что нибудь» одно и то же, подсвечивать такое значит + тратить внимание человека впустую. + """ + return word.lower().replace("ё", "е").replace("-", "") + + +def disputed(base: list[str], others: list[list[str]]) -> list[set[str]]: + """Для каждого слова базовой расшифровки - варианты остальных моделей. + + Пустое множество означает согласие: слушать это место не нужно. + """ + variants: list[set[str]] = [set() for _ in base] + for other in others: + matcher = difflib.SequenceMatcher(a=[normalize(w) for w in base], + b=[normalize(w) for w in other], + autojunk=False) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + replacement = " ".join(other[j1:j2]) or "—" + if i2 > i1: + for i in range(i1, i2): + variants[i].add(replacement) + elif variants: + # Вставку чужой модели относим к соседнему слову. В конце + # реплики соседа справа нет - помечаем последнее слово, + # иначе лишнее слово в конце потерялось бы молча. + variants[min(i1, len(variants) - 1)].add(f"+{replacement}") + return variants + + +def build_draft(pipeline, samples, duration: float, progress=None) -> dict: + """Прогоняет запись несколькими моделями и размечает расхождения. + + pipeline уже держит основную модель и детектор речи - второй копии + не заводим, она стоит около гигабайта памяти. + """ + import onnx_asr + + from app.pipeline import MAX_CHUNK_SEC, SAMPLE_RATE + from app.turns import chunk_ranges, merge_turns + + extra = {} + for name in EXTRA_MODELS: + if progress: + progress(f"загружаю модель {name}") + try: + extra[name] = onnx_asr.load_model(name, quantization="int8") + except Exception as exc: # noqa: BLE001 - без модели просто меньше сверок + log.warning("модель %s недоступна: %s", name, exc) + + if progress: + progress("распознаю") + turns_out = [] + turns = merge_turns(pipeline._vad_segments(samples), gap=0.8) + for index, turn in enumerate(turns): + if progress and index % 10 == 0: + progress(f"распознаю, реплика {index + 1} из {len(turns)}") + base_parts, other_parts = [], {name: [] for name in extra} + for piece in (turn.pieces or [turn]): + for start, stop in chunk_ranges(piece.start, piece.end, MAX_CHUNK_SEC): + audio = samples[int(start * SAMPLE_RATE):int(stop * SAMPLE_RATE)] + if len(audio) < SAMPLE_RATE * 0.2: + continue + base_parts.append(pipeline._recognize_safely(audio).strip()) + for name, model in extra.items(): + try: + other_parts[name].append(model.recognize(audio).strip()) + except Exception: # noqa: BLE001 - молчание модели тоже сведение + other_parts[name].append("") + text = " ".join(p for p in base_parts if p) + if not text: + continue + words = WORD_RE.findall(text) + variants = disputed( + words, [WORD_RE.findall(" ".join(p for p in parts if p)) + for parts in other_parts.values()]) + turns_out.append({ + "start": round(turn.start, 2), "end": round(turn.end, 2), + "words": [{"text": w, "variants": sorted(v)} + for w, v in zip(words, variants)], + }) + + total = sum(len(t["words"]) for t in turns_out) + agreed = sum(1 for t in turns_out for w in t["words"] if not w["variants"]) + return { + "turns": turns_out, + "duration_sec": round(duration, 1), + "models": 1 + len(extra), + "total_words": total, + "agreed_words": agreed, + "gaps": _gaps(turns_out, duration), + } + + +def _gaps(turns: list[dict], duration: float) -> list[dict]: + """Промежутки, где не распознала ни одна модель. + + Пропавшая реплика в тексте никак не проявляется: он остаётся связным. + Без явной отметки такую потерю не найти. + """ + out, previous = [], 0.0 + for turn in turns: + if turn["start"] - previous >= GAP_NOTICE_SEC: + out.append({"start": round(previous, 1), "end": round(turn["start"], 1)}) + previous = max(previous, turn["end"]) + if duration - previous >= GAP_NOTICE_SEC: + out.append({"start": round(previous, 1), "end": round(duration, 1)}) + return out diff --git a/app/main.py b/app/main.py index 3930689..3a39aba 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ import threading import time from contextlib import asynccontextmanager from pathlib import Path +from html import escape as html_escape from urllib.parse import quote from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile @@ -26,7 +27,7 @@ from app.pipeline import ModelsMissing, Pipeline, to_wav16k from app.security import (check_token, client_address, ip_allowed, parse_allowlist, token_matches) from app.store import JobStatus, JobStore -from app.view import render_index, render_job +from app.view import render_draft, render_index, render_job from app.webhook import deliver_async, secret_fingerprint from app.version import __version__ @@ -326,6 +327,80 @@ def jobs_index(request: Request): return HTMLResponse(render_index(store.recent(), token)) +# Черновик считается минуту и больше: три модели вместо одной, да ещё их +# загрузка при первом обращении. Держать на этом HTTP-запрос нельзя. +_drafts: dict[str, dict] = {} +_draft_lock = threading.Lock() + + +def _build_draft(job_id: str, audio: Path) -> None: + """Собирает черновик в фоне, складывая ход работы в общее состояние.""" + from app.draft import build_draft + from app.pipeline import read_wav, to_wav16k + + def progress(text: str) -> None: + with _draft_lock: + _drafts[job_id] = {"status": "building", "stage": text} + + try: + progress("готовлю звук") + with tempfile.TemporaryDirectory() as tmp: + wav = Path(tmp) / "audio.wav" + to_wav16k(audio, wav, pipeline.ffmpeg, settings.normalize) + samples = read_wav(wav) + result = build_draft(pipeline, samples, len(samples) / 16000, progress) + with _draft_lock: + _drafts[job_id] = {"status": "ready", "draft": result} + log.info("черновик по задаче %s готов: слов %d, согласны %d", + job_id, result["total_words"], result["agreed_words"]) + except Exception as exc: # noqa: BLE001 - причина уходит в ответ, а не в падение + log.exception("черновик по задаче %s не собрался", job_id) + with _draft_lock: + _drafts[job_id] = {"status": "failed", "error": f"{type(exc).__name__}: {exc}"} + + +@app.get("/v1/jobs/{job_id}/draft", include_in_schema=False) +def job_draft(job_id: str, request: Request, build: bool = False): + """Черновик для правки на слух. Первый запрос запускает сборку.""" + token = page_guard(request) + with _draft_lock: + state = dict(_drafts.get(job_id) or {}) + + if state.get("status") == "ready": + return HTMLResponse(render_draft(state["draft"], job_id, token)) + + audio = record_path(job_id) + if audio is None: + raise HTTPException(status_code=404, + detail="запись не сохранена, черновик собрать не из чего") + + if state.get("status") != "building" and (build or not state): + with _draft_lock: + _drafts[job_id] = {"status": "building", "stage": "запускаю"} + threading.Thread(target=_build_draft, args=(job_id, audio), + name=f"draft-{job_id[:8]}", daemon=True).start() + state = {"status": "building", "stage": "запускаю"} + + if state.get("status") == "failed": + raise HTTPException(status_code=500, detail=state.get("error", "не собрался")) + # Страница сама перезапросит себя: сборка занимает минуты. + return HTMLResponse(_draft_waiting(job_id, state.get("stage", ""), token), + status_code=202) + + +def _draft_waiting(job_id: str, stage: str, token: str) -> str: + suffix = f"?token={quote(token)}" if token else "" + return (f'' + f'' + f'Черновик собирается' + f'' + f'

Черновик собирается: {html_escape(stage)}

' + f'

Три модели вместо одной, это занимает несколько ' + f'минут. Страница обновится сама.

' + f'

вернуться к разбору

' + f'') + + @app.get("/v1/jobs/{job_id}/audio", include_in_schema=False) def job_audio(job_id: str, request: Request): """Отдаёт запись для плеера на странице разбора.""" diff --git a/app/view.py b/app/view.py index b7ac86c..e05cd6f 100644 --- a/app/view.py +++ b/app/view.py @@ -9,7 +9,7 @@ import html import json from urllib.parse import quote -__all__ = ["render_job", "render_index"] +__all__ = ["render_job", "render_index", "render_draft"] GAP_NOTICE_SEC = 3.0 @@ -111,6 +111,92 @@ def render_index(jobs: list[dict], token: str) -> str: """ +def render_draft(draft: dict, job_id: str, token: str = "") -> str: + """Черновик для правки на слух: выделено то, что стоит проверить.""" + suffix = f"?token={quote(token)}" if token else "" + gaps = {round(g["start"], 1): g for g in draft.get("gaps", [])} + rows = [] + for turn in draft["turns"]: + gap = gaps.pop(round(turn["start"], 1), None) or next( + (g for k, g in list(gaps.items()) if k < turn["start"]), None) + if gap is not None: + gaps.pop(round(gap["start"], 1), None) + rows.append( + f'
[{timecode(gap["start"])} - {timecode(gap["end"])}] ' + f'ничего не распознано, {gap["end"] - gap["start"]:.0f} с - ' + f'послушайте, не потерялась ли реплика
') + parts = [f'' + f'{timecode(turn["start"])} '] + for word in turn["words"]: + if not word["variants"]: + parts.append(html.escape(word["text"]) + " ") + else: + options = " | ".join(html.escape(v) for v in word["variants"]) + parts.append(f'{html.escape(word["text"])}' + f'[{options}] ') + rows.append(f'
{"".join(parts)}
') + + total = draft.get("total_words", 0) + agreed = draft.get("agreed_words", 0) + share = agreed / total * 100 if total else 0 + return f""" + + +Черновик {html.escape(job_id[:8])} +
+

Черновик {html.escape(job_id[:8])} +

+
+
Моделей{draft.get("models", 1)}
+
Слов{total}
+
Согласны{agreed} ({share:.0f} %)
+
Проверить{total - agreed}
+
Пропусков{len(draft.get("gaps", []))}
+
+
Жёлтым - расхождения между моделями, в скобках варианты +(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный +текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки, +где не распознала ни одна модель. Щелчок по времени перематывает запись.
+ +{"".join(rows)} +
+""" + + def render_job(job: dict, token: str = "") -> str: """Собирает самодостаточную страницу: без внешних файлов и запросов.""" result = job.get("result") or job @@ -167,6 +253,9 @@ def render_job(job: dict, token: str = "") -> str: player = ('
Запись не сохранена: включите keep_records ' 'в config.toml, чтобы слушать разговор прямо здесь.
') + job_id_esc = html.escape(str(job.get("job_id", ""))) + token_suffix = f"?token={quote(token)}" if token else "" + payload = json.dumps( [{"n": s["n"], "text": s["text"], "start": s["start"]} for s in sentences], ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028") @@ -208,6 +297,12 @@ def render_job(job: dict, token: str = "") -> str:
{_stats_row(job)}
{player} +

Черновик для правки на слух

+
Несколько моделей распознают запись, подсвечиваются только +расхождения между ними. Там, где все согласны, текст верен в 98 % случаев - +слушать нужно четверть текста вместо всего. Сборка занимает несколько минут.
+собрать черновик +

Что отдал сервис

Реплики так, как их нарезал сервис. Г0 значит, что разделение по голосам выключено и говорящий неизвестен - это норма.
diff --git a/tests/test_draft.py b/tests/test_draft.py new file mode 100644 index 0000000..87a105d --- /dev/null +++ b/tests/test_draft.py @@ -0,0 +1,52 @@ +"""Черновик: подсвечиваем только то, что стоит проверить на слух.""" +from app.draft import _gaps, disputed, normalize + + +class TestNormalize: + def test_hyphen_and_space_are_not_a_dispute(self): + """«что-нибудь» и «что нибудь» - одно слово, подсвечивать незачем.""" + assert normalize("что-нибудь") == normalize("чтонибудь") + + def test_case_and_yo_ignored(self): + assert normalize("Ещё") == normalize("еще") + + +class TestDisputed: + def test_full_agreement_gives_no_marks(self): + base = ["мы", "работаем", "с", "девяти"] + assert disputed(base, [base, base]) == [set()] * 4 + + def test_replacement_is_marked_with_variant(self): + variants = disputed(["девяти"], [["девять"]]) + assert variants[0] == {"девять"} + + def test_silence_of_other_model_is_marked(self): + """Пропуск у одной модели - тоже повод послушать.""" + assert disputed(["ага"], [[]])[0] == {"—"} + + def test_extra_word_marked_with_plus(self): + variants = disputed(["да"], [["да", "конечно"]]) + assert any(v.startswith("+") for v in variants[0]) + + def test_one_model_disagreeing_is_enough(self): + variants = disputed(["девяти"], [["девяти"], ["девять"]]) + assert variants[0] == {"девять"} + + +class TestGaps: + @staticmethod + def turn(start, end): + return {"start": start, "end": end, "words": []} + + def test_finds_gap_between_turns(self): + gaps = _gaps([self.turn(0, 5), self.turn(20, 25)], 25) + assert {"start": 5.0, "end": 20.0} in gaps + + def test_short_pause_is_not_a_gap(self): + assert _gaps([self.turn(0, 5), self.turn(6, 25)], 25) == [] + + def test_tail_of_recording_counts(self): + assert _gaps([self.turn(0, 5)], 30) == [{"start": 5.0, "end": 30.0}] + + def test_overlapping_turns_do_not_make_negative_gap(self): + assert _gaps([self.turn(0, 30), self.turn(10, 20)], 30) == []