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'