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'Черновик собирается: {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: