From 96c2a27c4c6863bb9764c5b925eafb925b2f3eff Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 18 Aug 2026 15:11:23 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B5=D0=B5=D1=80=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20=D0=B8=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Записи складываются в папку records и удаляются вместе с задачей по тому же сроку. На странице разбора появился плеер, а щелчок по времени слева от реплики перематывает запись - можно послушать спорное место, не таская ползунок. Настройка keep_records в config.toml. Если запись не сохранена, вместо пустого плеера показывается объяснение. Черновик с подсветкой расхождений пока остаётся отдельной командой: build/make_draft.py. Ложные расхождения по написанию больше не подсвечиваются. Co-Authored-By: Claude Opus 5 (1M context) --- app/config.py | 10 ++++++++ app/main.py | 58 +++++++++++++++++++++++++++++++++++++++++---- app/store.py | 19 +++++++++++---- app/view.py | 35 +++++++++++++++++++++++++-- build/make_draft.py | 7 +++++- tests/test_api.py | 45 ++++++++++++++++++++++++++++++++++- tests/test_store.py | 7 +++--- tests/test_view.py | 23 ++++++++++++++++++ 8 files changed, 188 insertions(+), 16 deletions(-) diff --git a/app/config.py b/app/config.py index 33ef516..777ee4b 100644 --- a/app/config.py +++ b/app/config.py @@ -75,6 +75,10 @@ normalize = true recover_gaps = true # Максимальный размер загружаемого файла, МБ max_upload_mb = 500 +# Хранить ли сами записи в папке records. Нужны, чтобы слушать разговор +# на странице разбора и сверять расшифровку с оригиналом. Удаляются вместе +# с задачей по тому же сроку. Гигабайт примерно на 30 часов записей. +keep_records = true # Сколько часов хранить результаты завершённых задач. Старые удаляются, # файл базы после этого сжимается. 336 часов - две недели. keep_results_hours = 336 @@ -119,6 +123,7 @@ class Settings: diarize: bool = False recover_gaps: bool = True max_upload_mb: int = 500 + keep_records: bool = True keep_results_hours: float = 336.0 webhook_url: str = "" webhook_secret: str = "" @@ -137,6 +142,10 @@ class Settings: def data_dir(self) -> Path: return self.base_dir / "data" + @property + def records_dir(self) -> Path: + return self.base_dir / "records" + @property def replacements_path(self) -> Path: return self.base_dir / "replacements.txt" @@ -207,6 +216,7 @@ def load_settings(config_path: Path | None = None) -> Settings: diarize=bool(proc.get("diarize", False)), recover_gaps=bool(proc.get("recover_gaps", True)), max_upload_mb=int(proc.get("max_upload_mb", 500)), + keep_records=bool(proc.get("keep_records", True)), keep_results_hours=float(proc.get("keep_results_hours", 336)), webhook_url=str(hook.get("url", "")), webhook_secret=str(hook.get("secret", "")), diff --git a/app/main.py b/app/main.py index 82f3f5d..3930689 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ from urllib.parse import quote from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.security import HTTPBearer from app.config import ConfigError, Settings, load_settings @@ -32,6 +32,10 @@ from app.version import __version__ log = logging.getLogger("talkscore-asr") +# Браузер выбирает проигрыватель по типу, а не по расширению. +MEDIA_TYPES = {".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", + ".ogg": "audio/ogg", ".opus": "audio/ogg", ".wma": "audio/x-ms-wma"} + try: settings: Settings = load_settings() except ConfigError as exc: @@ -98,7 +102,37 @@ def _process(job_id: str, worker: Pipeline, pool) -> None: _notify(job, {"job_id": job_id, "status": JobStatus.FAILED, "filename": job["filename"], "error": error}) finally: + _archive_or_drop(upload, job_id, job.get("filename", "")) + + +def _archive_or_drop(upload: Path, job_id: str, filename: str) -> None: + """Убирает загруженный файл: либо в архив записей, либо совсем. + + Запись нужна, чтобы слушать разговор на странице разбора и сверять + расшифровку с оригиналом. Расширение сохраняем от исходного имени - + по нему браузер поймёт, чем проигрывать. + """ + if not upload.exists(): + return + if not settings.keep_records: upload.unlink(missing_ok=True) + return + try: + settings.records_dir.mkdir(parents=True, exist_ok=True) + suffix = Path(filename).suffix.lower()[:8] or ".bin" + upload.replace(settings.records_dir / f"{job_id}{suffix}") + except OSError as exc: + # Место на диске или права: расшифровка уже готова, ронять её нельзя. + log.warning("не удалось сохранить запись %s: %s", job_id, exc) + upload.unlink(missing_ok=True) + + +def record_path(job_id: str) -> Path | None: + """Ищет сохранённую запись по идентификатору задачи.""" + for path in settings.records_dir.glob(f"{job_id}.*"): + if path.is_file(): + return path + return None def _notify(job: dict, payload: dict) -> None: @@ -121,8 +155,12 @@ def _worker_loop(index: int, worker: Pipeline, pool=None) -> None: # Уборкой занимается только первый воркер, чтобы не делать её хором. if index == 0 and time.time() - last_cleanup > 3600: removed = store.cleanup(settings.keep_results_hours) + for job_id in removed: + path = record_path(job_id) + if path is not None: + path.unlink(missing_ok=True) if removed: - log.info("удалено старых задач: %d", removed) + log.info("удалено старых задач: %d", len(removed)) last_cleanup = time.time() _worker_stop.wait(1.0) @@ -288,17 +326,29 @@ def jobs_index(request: Request): return HTMLResponse(render_index(store.recent(), token)) +@app.get("/v1/jobs/{job_id}/audio", include_in_schema=False) +def job_audio(job_id: str, request: Request): + """Отдаёт запись для плеера на странице разбора.""" + page_guard(request) + path = record_path(job_id) + if path is None: + raise HTTPException(status_code=404, detail="запись не сохранена") + # Плеер перематывает запросами с Range, FileResponse это умеет. + return FileResponse(path, media_type=MEDIA_TYPES.get(path.suffix, "audio/mpeg")) + + @app.get("/v1/jobs/{job_id}/view", include_in_schema=False) def job_view(job_id: str, request: Request): """Расшифровка в браузере: видно, что отдал сервис и что дала разметка.""" - page_guard(request) + token = page_guard(request) job = store.get(job_id) if job is None or job["status"] != JobStatus.DONE: raise HTTPException(status_code=404, detail="задача не найдена или не готова") payload = dict(job.get("result") or {}) payload["job_id"] = job_id payload["filename"] = job.get("filename", "") - return HTMLResponse(render_job(payload)) + payload["has_record"] = record_path(job_id) is not None + return HTMLResponse(render_job(payload, token)) @app.get("/v1/logs", dependencies=[Depends(guard)], summary="Журнал сервиса", diff --git a/app/store.py b/app/store.py index 7c6c8b2..e6522a1 100644 --- a/app/store.py +++ b/app/store.py @@ -158,19 +158,28 @@ class JobStore: self._conn.commit() return cur.rowcount > 0 - def cleanup(self, max_age_hours: float) -> int: - """Удаляет завершённые задачи старше указанного возраста.""" + def cleanup(self, max_age_hours: float) -> list[str]: + """Удаляет завершённые задачи старше срока. Возвращает их идентификаторы. + + Идентификаторы нужны вызывающему: рядом с записью в базе лежит файл + разговора, и удалять их надо вместе, иначе папка растёт вечно. + """ cutoff = time.time() - max_age_hours * 3600 with self._lock: - cur = self._conn.execute( + rows = self._conn.execute( + "SELECT id FROM jobs WHERE status IN (?,?) " + "AND COALESCE(finished_at, created_at) <= ?", + (JobStatus.DONE, JobStatus.FAILED, cutoff)).fetchall() + ids = [row["id"] for row in rows] + self._conn.execute( "DELETE FROM jobs WHERE status IN (?,?) AND COALESCE(finished_at, created_at) <= ?", (JobStatus.DONE, JobStatus.FAILED, cutoff), ) self._conn.commit() - removed = cur.rowcount + removed = len(ids) if removed: self.vacuum() - return removed + return ids def vacuum(self) -> None: """Возвращает системе место, освобождённое удалением. diff --git a/app/view.py b/app/view.py index fa486c9..b7ac86c 100644 --- a/app/view.py +++ b/app/view.py @@ -111,7 +111,7 @@ def render_index(jobs: list[dict], token: str) -> str: """ -def render_job(job: dict) -> str: +def render_job(job: dict, token: str = "") -> str: """Собирает самодостаточную страницу: без внешних файлов и запросов.""" result = job.get("result") or job turns = result.get("turns") or [] @@ -132,7 +132,8 @@ def render_job(job: dict) -> str: who = f"Г{speaker}" if speaker else "Г0" rows.append( f'
' - f'{timecode(turn["start"])}' + f'' + f'{timecode(turn["start"])}' f'{who}' f'{volume}' f'{html.escape(turn["text"])}{mark}' @@ -154,6 +155,18 @@ def render_job(job: dict) -> str: 'запись обработана версией сервиса старше 0.14.0. Отправьте её заново, ' 'чтобы собрать диалог по ролям.
') + # Плеер появляется, только если запись сохранена: иначе он показывал бы + # пустую полосу и выглядел бы поломкой. + if job.get("has_record"): + suffix = f"?token={quote(token)}" if token else "" + player = (f'' + '
Щелчок по времени слева от реплики перематывает запись.
') + else: + player = ('
Запись не сохранена: включите keep_records ' + 'в config.toml, чтобы слушать разговор прямо здесь.
') + payload = json.dumps( [{"n": s["n"], "text": s["text"], "start": s["start"]} for s in sentences], ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028") @@ -167,6 +180,9 @@ def render_job(job: dict) -> str: .turn {{ display:grid; grid-template-columns:52px 34px 58px 1fr; gap:8px; padding:5px 0; border-bottom:1px solid var(--line); }} .tc, .db {{ color:var(--dim); font-size:12px; font-variant-numeric:tabular-nums; }} + .tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; }} + .tc[data-at]:hover {{ color:var(--fg); }} + audio {{ width:100%; margin:4px 0 2px; }} .who {{ font-weight:600; font-size:13px; }} .sp1 .who {{ color:var(--a); }} .sp2 .who {{ color:var(--b); }} .gap {{ color:var(--warn); font-size:12px; padding:5px 0 5px 94px; }} @@ -179,6 +195,10 @@ def render_job(job: dict) -> str: .roles .line {{ display:grid; grid-template-columns:76px 1fr; gap:10px; padding:4px 0; border-bottom:1px solid var(--line); }} .who2 {{ font-weight:600; font-size:13px; }} + a.button {{ display:inline-block; font-size:13px; color:var(--fg); + border:1px solid var(--line); border-radius:4px; padding:5px 12px; + text-decoration:none; }} + a.button:hover {{ border-color:var(--dim); }} button.copy {{ font:13px inherit; color:var(--fg); background:transparent; border:1px solid var(--line); border-radius:4px; padding:3px 10px; margin-left:10px; cursor:pointer; font-weight:400; }} @@ -187,6 +207,7 @@ def render_job(job: dict) -> str:

Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}

{_stats_row(job)}
+{player}

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

Реплики так, как их нарезал сервис. Г0 значит, что разделение по голосам выключено и говорящий неизвестен - это норма.
@@ -263,4 +284,14 @@ async function copyAs(src, button) {{ }} document.querySelectorAll('button.copy').forEach(b => b.addEventListener('click', () => copyAs(b.dataset.src, b))); + +// Перемотка по щелчку на времени: слушать нужное место, не таская ползунок. +const player = document.getElementById('player'); +if (player) {{ + document.querySelectorAll('.tc[data-at]').forEach(el => + el.addEventListener('click', () => {{ + player.currentTime = parseFloat(el.dataset.at); + player.play(); + }})); +}} """ diff --git a/build/make_draft.py b/build/make_draft.py index afaedb2..8223b4b 100644 --- a/build/make_draft.py +++ b/build/make_draft.py @@ -29,7 +29,12 @@ def timecode(seconds: float) -> str: def key(word: str) -> str: - return word.lower().replace("ё", "е") + """Приводит слово к виду, в котором различия написания не считаются спором. + + «что-нибудь» и «что нибудь» - одно слово, и подсвечивать такое значит + тратить внимание человека впустую. + """ + return word.lower().replace("ё", "е").replace("-", "") def disputed(base: list[str], others: list[list[str]]) -> list[set[str]]: diff --git a/tests/test_api.py b/tests/test_api.py index d814a0b..401c8f0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ """Тесты HTTP-слоя. Пайплайн подменяется заглушкой: модели тут не нужны.""" import sys import types +from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -384,7 +385,7 @@ class TestRetention: "turns": [{"text": "текст " * 500}]}) store._update(job_id, finished_at=_time.time() - 400 * 3600) before = (tmp_path / "jobs.db").stat().st_size - assert store.cleanup(336) == 50 + assert len(store.cleanup(336)) == 50 assert (tmp_path / "jobs.db").stat().st_size < before def test_duration_appears_after_processing(self, tmp_path): @@ -426,3 +427,45 @@ class TestDurationBackfill: with sqlite3.connect(path) as conn: conn.execute("UPDATE jobs SET result='не json' WHERE id=?", (job_id,)) assert JobStore(path).recent()[0]["duration_sec"] == 0.0 + + +class TestRecords: + """Записи нужны, чтобы слушать разговор и сверять расшифровку.""" + + def test_archive_keeps_extension(self, tmp_path, monkeypatch): + """По расширению браузер выбирает проигрыватель.""" + import app.main as m + + monkeypatch.setattr(m.settings, "keep_records", True) + monkeypatch.setattr(m.settings, "base_dir", tmp_path) + upload = tmp_path / "up" + upload.write_bytes(b"audio-bytes") + m._archive_or_drop(upload, "abc123", "разговор.MP3") + assert (tmp_path / "records" / "abc123.mp3").read_bytes() == b"audio-bytes" + + def test_archive_disabled_deletes_file(self, tmp_path, monkeypatch): + import app.main as m + + monkeypatch.setattr(m.settings, "keep_records", False) + monkeypatch.setattr(m.settings, "base_dir", tmp_path) + upload = tmp_path / "up" + upload.write_bytes(b"audio-bytes") + m._archive_or_drop(upload, "abc123", "a.mp3") + assert not upload.exists() + assert not (tmp_path / "records").exists() + + def test_failed_archive_does_not_break_job(self, tmp_path, monkeypatch): + """Расшифровка уже готова - нехватка места не должна её ронять.""" + import app.main as m + + monkeypatch.setattr(m.settings, "keep_records", True) + monkeypatch.setattr(m.settings, "base_dir", tmp_path) + upload = tmp_path / "up" + upload.write_bytes(b"audio-bytes") + monkeypatch.setattr(Path, "replace", + lambda *a: (_ for _ in ()).throw(OSError("нет места"))) + m._archive_or_drop(upload, "abc123", "a.mp3") + assert not upload.exists() + + def test_missing_record_gives_404(self, client): + assert client.get("/v1/jobs/" + "0" * 32 + "/audio").status_code == 404 diff --git a/tests/test_store.py b/tests/test_store.py index d8e087b..1510f81 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -75,12 +75,13 @@ class TestCleanup: def test_removes_old_finished_jobs(self, store): job_id = store.create(filename="a.wav", duration_sec=1.0) store.mark_done(job_id, result={}) - removed = store.cleanup(max_age_hours=0) - assert removed == 1 + # Возвращаются идентификаторы: рядом с записью в базе лежит файл + # разговора, и удалять их надо вместе. + assert store.cleanup(max_age_hours=0) == [job_id] assert store.get(job_id) is None def test_keeps_running_jobs_regardless_of_age(self, store): job_id = store.create(filename="a.wav", duration_sec=1.0) store.mark_running(job_id) - assert store.cleanup(max_age_hours=0) == 0 + assert store.cleanup(max_age_hours=0) == [] assert store.get(job_id) is not None diff --git a/tests/test_view.py b/tests/test_view.py index 19cb2e6..386806f 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -157,3 +157,26 @@ class TestStatsLayout: def test_stats_are_laid_out_in_a_row(self): assert ".stats { display:flex" in render_job(job()) + + +class TestPlayer: + """Плеер и перемотка по времени: слушать спорное место, не таская ползунок.""" + + def test_player_appears_when_record_saved(self): + page = render_job({**job(), "has_record": True}, token="tok") + assert "