From 0dd531aaa157399bfe6b01aaff06f0c818f0c3a3 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Mon, 17 Aug 2026 13:58:08 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По одному JSON не понять, чья ошибка: распознавание склеило реплики или LLM неверно расставила роли. Страница показывает обе стороны и принимает ответ модели, чтобы диалог собрался на глазах. GET /v1/jobs/{id}/view - токен заголовком либо ссылкой ?token=ЗНАЧЕНИЕ. Страница самодостаточна: ни одного внешнего запроса. Co-Authored-By: Claude Opus 5 (1M context) --- app/main.py | 39 +++++++++--- app/view.py | 155 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_view.py | 73 +++++++++++++++++++++ 3 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 app/view.py create mode 100644 tests/test_view.py diff --git a/app/main.py b/app/main.py index f78bd0f..d508b45 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 JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.security import HTTPBearer from app.config import ConfigError, Settings, load_settings @@ -25,6 +25,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_job from app.webhook import deliver_async, secret_fingerprint from app.version import __version__ @@ -227,6 +228,21 @@ def guard(request: Request) -> None: raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") +def page_guard(request: Request) -> str: + """Пускает к странице для браузера и возвращает токен для ссылок на ней. + + Токен принимается заголовком и параметром ?token=: браузер, открывая + страницу по ссылке, заголовок не подставит. + """ + ip_guard(request) + supplied = request.query_params.get("token") + if check_token(request.headers.get("authorization"), settings.token): + return supplied or "" + if token_matches(supplied, settings.token): + return supplied or "" + raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") + + def docs_guard(request: Request) -> str: """Пускает к документации и возвращает токен для ссылки на схему. @@ -238,13 +254,7 @@ def docs_guard(request: Request) -> str: if not settings.docs: # 404, а не 403: подтверждать существование страницы незачем. raise HTTPException(status_code=404, detail="Not Found") - ip_guard(request) - supplied = request.query_params.get("token") - if check_token(request.headers.get("authorization"), settings.token): - return supplied or "" - if token_matches(supplied, settings.token): - return supplied or "" - raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") + return page_guard(request) @app.get("/docs", include_in_schema=False) @@ -270,6 +280,19 @@ def openapi_schema(request: Request) -> JSONResponse: return JSONResponse(schema) +@app.get("/v1/jobs/{job_id}/view", include_in_schema=False) +def job_view(job_id: str, request: Request): + """Расшифровка в браузере: видно, что отдал сервис и что дала разметка.""" + 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)) + + @app.get("/v1/logs", dependencies=[Depends(guard)], summary="Журнал сервиса", description="Последние записи журнала: удобно посмотреть причину сбоя, " "не заходя на машину. Уровень можно отфильтровать параметром level.") diff --git a/app/view.py b/app/view.py new file mode 100644 index 0000000..b83b2c0 --- /dev/null +++ b/app/view.py @@ -0,0 +1,155 @@ +"""Страница для разбора результата в браузере. + +Когда диалог размечен неверно, по одному JSON не понять, чья это ошибка: +распознавание склеило реплики или LLM неверно расставила роли. Страница +показывает обе стороны - что отдал сервис и что получилось после разметки, +- и позволяет вставить ответ модели, чтобы увидеть результат сразу. +""" +import html +import json + +__all__ = ["render_job"] + +GAP_NOTICE_SEC = 3.0 + + +def timecode(seconds: float) -> str: + return f"{int(seconds) // 60:02d}:{int(seconds) % 60:02d}" + + +def _stats_row(job: dict) -> str: + result = job.get("result") or job + stats = result.get("stats") or {} + timing = result.get("timing") or {} + duration = result.get("duration_sec", 0) + speech = stats.get("speech_sec", 0) + items = [ + ("Длительность", timecode(duration)), + ("Реплик", str(stats.get("turns_count", len(result.get("turns", []))))), + ("Предложений", str(len(result.get("sentences", [])))), + ("Речи распознано", f"{speech:.0f} с ({speech / duration * 100:.0f} %)" + if duration else "-"), + ("Режим", "диаризация" if stats.get("diarized") else "детектор речи"), + ("Обработка", f"{timing.get('diarization_sec', 0):.0f} + " + f"{timing.get('asr_sec', 0):.0f} с"), + ] + return "".join( + f'
{html.escape(k)}' + f'{html.escape(v)}
' for k, v in items) + + +def render_job(job: dict) -> str: + """Собирает самодостаточную страницу: без внешних файлов и запросов.""" + result = job.get("result") or job + turns = result.get("turns") or [] + sentences = result.get("sentences") or [] + + rows = [] + previous_end = 0.0 + for turn in turns: + gap = turn["start"] - previous_end + if gap >= GAP_NOTICE_SEC: + rows.append(f'
пропуск {gap:.0f} с ' + f'[{timecode(previous_end)} - {timecode(turn["start"])}]
') + previous_end = turn["end"] + loudness = (turn.get("acoustics") or {}).get("loudness_db") + volume = f"{loudness:.0f} дБ" if loudness is not None else "" + mark = 'второй проход' if turn.get("recovered") else "" + speaker = turn.get("speaker", 0) + who = f"Г{speaker}" if speaker else "Г0" + rows.append( + f'
' + f'{timecode(turn["start"])}' + f'{who}' + f'{volume}' + f'{html.escape(turn["text"])}{mark}' + f'
') + + # Внутри в тексте + # закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку. + payload = json.dumps( + [{"n": s["n"], "text": s["text"], "start": s["start"]} for s in sentences], + ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028") + + return f""" + + +Расшифровка {html.escape(str(job.get("job_id", "")))[:8]} +
+

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

+
{_stats_row(job)}
+ +

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

+
Реплики так, как их нарезал сервис. Г0 значит, что разделение +по голосам выключено и говорящий неизвестен - это норма.
+{"".join(rows) or "
реплик нет
"} + +

Разметка ролей

+
Вставьте ответ модели - строку из букв M и C, по одной +на предложение. Диалог соберётся ниже, и станет видно, где ошибка: +в тексте от сервиса или в расстановке ролей.
+ +
+
+
+""" diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..de295c6 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,73 @@ +"""Страница разбора: по ней смотрят, чья ошибка - распознавания или разметки.""" +import json +import re + +from app.view import render_job + + +def job(**over): + base = { + "job_id": "abc12345", "filename": "call.mp3", "duration_sec": 120.0, + "turns": [ + {"speaker": 0, "start": 0.0, "end": 4.0, "text": "Первая реплика.", + "acoustics": {"loudness_db": -18.0}}, + {"speaker": 0, "start": 30.0, "end": 34.0, "text": "Вторая <реплика>.", + "acoustics": {}, "recovered": True}, + ], + "sentences": [ + {"n": 1, "text": "Первая реплика.", "start": 0.0, "loudness_db": -18.0, "turn": 0}, + {"n": 2, "text": "Вторая <реплика>.", "start": 30.0, "loudness_db": None, "turn": 1}, + ], + "stats": {"turns_count": 2, "speech_sec": 8.0, "diarized": False}, + "timing": {"diarization_sec": 1.0, "asr_sec": 2.0}, + } + base.update(over) + return base + + +class TestPage: + def test_is_self_contained(self): + """Страница открывается локально: внешних файлов и запросов быть не должно.""" + page = render_job(job()) + assert "" not in page + + def test_marks_gap_between_turns(self): + page = render_job(job()) + assert "пропуск" in page and "00:04" in page + + def test_marks_recovered_turn(self): + assert "второй проход" in render_job(job()) + + def test_shows_mode(self): + assert "детектор речи" in render_job(job()) + assert "диаризация" in render_job(job(stats={"diarized": True, "turns_count": 2})) + + def test_sentences_go_into_script(self): + """По ним страница собирает диалог из вставленной строки ролей.""" + page = render_job(job()) + payload = re.search(r"const S = (\[.*?\]);", page, re.S).group(1) + assert len(json.loads(payload)) == 2 + + def test_closing_tag_in_text_cannot_break_out(self): + """Строка в расшифровке закрыла бы тег и сломала страницу.""" + evil = 'а тут ' + page = render_job(job( + turns=[{"speaker": 0, "start": 0.0, "end": 1.0, "text": evil, "acoustics": {}}], + sentences=[{"n": 1, "text": evil, "start": 0.0, "loudness_db": None, "turn": 0}])) + assert "") == 1 + + def test_survives_empty_result(self): + page = render_job({"job_id": "x", "turns": [], "sentences": [], "duration_sec": 0}) + assert "реплик нет" in page