Список задач в браузере и сравнение с эталоном

GET /v1/jobs/view - список последних задач со ссылками на разбор. Токен
подставляется в ссылки, иначе по каждой пришлось бы дописывать его руками.
Отдельный тест следит, чтобы маршрут не перехватывался /v1/jobs/{job_id}.

build/compare_reference.py считает WER относительно эталона, выписанного
на слух. Косвенные метрики ловят крупные сдвиги и слепы к систематическим
мелким потерям - так и пропустили обрыв концов фраз.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-17 18:39:21 +05:00
co-authored by Claude Opus 5
parent 7c88480ac9
commit f3640f2941
6 changed files with 237 additions and 20 deletions
+70 -19
View File
@@ -7,8 +7,9 @@
"""
import html
import json
from urllib.parse import quote
__all__ = ["render_job"]
__all__ = ["render_job", "render_index"]
GAP_NOTICE_SEC = 3.0
@@ -38,6 +39,73 @@ def _stats_row(job: dict) -> str:
f'<span class="v">{html.escape(v)}</span></div>' for k, v in items)
_CSS = """ :root { --bg:#fbfaf7; --fg:#22201d; --dim:#8a8580; --line:#e6e2db;
--a:#2f6f4f; --b:#7a4b8a; --warn:#b06a2c; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg:#1a1917; --fg:#e8e4dd; --dim:#8a8580;
--line:#33302c; --a:#7fc0a0; --b:#c79ad6; --warn:#d99a5c; } }
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif; }
.wrap { max-width:900px; margin:0 auto; }
h1 { font-size:19px; margin:0 0 14px; font-weight:600; }
h2 { font-size:15px; margin:26px 0 8px; font-weight:600; }
.hint { color:var(--dim); font-size:13px; margin:6px 0 14px; }
.bad { color:var(--warn); }"""
STATUS_RU = {"done": "готово", "failed": "ошибка",
"running": "в работе", "queued": "в очереди"}
def render_index(jobs: list[dict], token: str) -> str:
"""Список задач со ссылками на разбор.
Токен подставляется в ссылки: иначе по каждой пришлось бы дописывать его
руками, а ради этого список и заводится.
"""
import time as _time
suffix = f"?token={quote(token)}" if token else ""
rows = []
for job in jobs:
status = job.get("status", "")
when = _time.strftime("%d.%m %H:%M", _time.localtime(job.get("created_at", 0)))
title = html.escape(str(job.get("filename") or job["id"][:8]))
cell = (f'<a href="/v1/jobs/{job["id"]}/view{suffix}">{title}</a>'
if status == "done" else title)
note = html.escape(str(job.get("error") or "")[:80])
rows.append(
f'<tr class="st-{html.escape(status)}">'
f'<td class="dim">{when}</td>'
f'<td>{cell}</td>'
f'<td class="dim">{timecode(job.get("duration_sec") or 0)}</td>'
f'<td class="status">{STATUS_RU.get(status, status)}</td>'
f'<td class="dim err">{note}</td></tr>')
body = ("".join(rows) or
'<tr><td colspan="5" class="dim">задач пока нет</td></tr>')
return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Задачи talkscore-asr</title>
<style>{_CSS}
table {{ width:100%; border-collapse:collapse; }}
td {{ padding:6px 10px 6px 0; border-bottom:1px solid var(--line);
vertical-align:top; }}
td.dim {{ color:var(--dim); font-size:13px; font-variant-numeric:tabular-nums;
white-space:nowrap; }}
td.err {{ white-space:normal; }}
.status {{ font-size:13px; }}
.st-failed .status {{ color:var(--warn); }}
.st-done .status {{ color:var(--a); }}
a {{ color:inherit; }}
</style></head><body><div class="wrap">
<h1>Задачи</h1>
<div class="hint">Готовые открываются по клику. Показаны последние {len(jobs)}.</div>
<table>{body}</table>
</div></body></html>"""
def render_job(job: dict) -> str:
"""Собирает самодостаточную страницу: без внешних файлов и запросов."""
result = job.get("result") or job
@@ -90,21 +158,7 @@ def render_job(job: dict) -> str:
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Расшифровка {html.escape(str(job.get("job_id", "")))[:8]}</title>
<style>
:root {{ --bg:#fbfaf7; --fg:#22201d; --dim:#8a8580; --line:#e6e2db;
--a:#2f6f4f; --b:#7a4b8a; --warn:#b06a2c; }}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{ --bg:#1a1917; --fg:#e8e4dd; --dim:#8a8580;
--line:#33302c; --a:#7fc0a0; --b:#c79ad6; --warn:#d99a5c; }} }}
* {{ box-sizing:border-box; }}
body {{ margin:0; padding:24px; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif; }}
.wrap {{ max-width:900px; margin:0 auto; }}
h1 {{ font-size:19px; margin:0 0 14px; font-weight:600; }}
.stats {{ display:flex; flex-wrap:wrap; gap:6px 22px; padding:12px 0;
border-top:1px solid var(--line); border-bottom:1px solid var(--line);
margin-bottom:18px; }}
.stat .k {{ color:var(--dim); font-size:13px; margin-right:6px; }}
.stat .v {{ font-variant-numeric:tabular-nums; }}
{_CSS}
.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; }}
@@ -120,9 +174,6 @@ 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; }}
.hint {{ color:var(--dim); font-size:13px; margin:6px 0 14px; }}
.bad {{ color:var(--warn); }}
h2 {{ font-size:15px; margin:26px 0 8px; font-weight:600; }}
</style></head><body><div class="wrap">
<h1>Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}</h1>
<div class="stats">{_stats_row(job)}</div>