diff --git a/app/main.py b/app/main.py
index 6112d9c..82f3f5d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -26,7 +26,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.view import render_index, render_job
from app.webhook import deliver_async, secret_fingerprint
from app.version import __version__
@@ -281,6 +281,13 @@ def openapi_schema(request: Request) -> JSONResponse:
return JSONResponse(schema)
+@app.get("/v1/jobs/view", include_in_schema=False)
+def jobs_index(request: Request):
+ """Список задач со ссылками на разбор."""
+ token = page_guard(request)
+ return HTMLResponse(render_index(store.recent(), token))
+
+
@app.get("/v1/jobs/{job_id}/view", include_in_schema=False)
def job_view(job_id: str, request: Request):
"""Расшифровка в браузере: видно, что отдал сервис и что дала разметка."""
diff --git a/app/store.py b/app/store.py
index c47a563..74bfbad 100644
--- a/app/store.py
+++ b/app/store.py
@@ -146,6 +146,18 @@ class JobStore:
self._conn.commit()
return cur.rowcount
+ def recent(self, limit: int = 200) -> list[dict]:
+ """Последние задачи без результатов: для списка нужны только заголовки.
+
+ Результат каждой задачи весит сотни килобайт, и тянуть их ради списка
+ значит держать в памяти десятки мегабайт ради нескольких строк.
+ """
+ with self._lock:
+ rows = self._conn.execute(
+ "SELECT id, filename, status, created_at, duration_sec, error "
+ "FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
+ return [dict(row) for row in rows]
+
def stats(self) -> dict:
with self._lock:
rows = self._conn.execute(
diff --git a/app/view.py b/app/view.py
index caf339d..fcddbdf 100644
--- a/app/view.py
+++ b/app/view.py
@@ -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'{html.escape(v)}' 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'{title}'
+ if status == "done" else title)
+ note = html.escape(str(job.get("error") or "")[:80])
+ rows.append(
+ f'
'
+ f'| {when} | '
+ f'{cell} | '
+ f'{timecode(job.get("duration_sec") or 0)} | '
+ f'{STATUS_RU.get(status, status)} | '
+ f'{note} |
')
+
+ body = ("".join(rows) or
+ '| задач пока нет |
')
+ return f"""
+
+
+Задачи talkscore-asr
+
+
Задачи
+
Готовые открываются по клику. Показаны последние {len(jobs)}.
+
+
"""
+
+
def render_job(job: dict) -> str:
"""Собирает самодостаточную страницу: без внешних файлов и запросов."""
result = job.get("result") or job
@@ -90,21 +158,7 @@ def render_job(job: dict) -> str:
Расшифровка {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)}
diff --git a/build/compare_reference.py b/build/compare_reference.py
new file mode 100644
index 0000000..bbbc9c6
--- /dev/null
+++ b/build/compare_reference.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""Сравнивает расшифровку с эталоном, выписанным человеком на слух.
+
+До сих пор качество мерили косвенно: числом слов и совпадением моделей между
+собой. Это ловит крупные сдвиги и слепо к систематическим мелким потерям -
+именно так пропустили обрыв концов фраз. Эталон даёт прямую меру.
+
+ uv run python build/compare_reference.py эталон.txt расшифровка.json
+"""
+import argparse
+import difflib
+import json
+import re
+import sys
+from pathlib import Path
+
+# Пунктуация и регистр к делу не относятся: сравниваем, какие слова сказаны.
+WORD_RE = re.compile(r"[\w-]+", re.UNICODE)
+
+
+def words(text: str) -> list[str]:
+ return [w.lower().replace("ё", "е") for w in WORD_RE.findall(text)]
+
+
+def from_reference(path: Path) -> list[str]:
+ """Читает эталон, отбрасывая пометки говорящих и тайм-коды."""
+ out = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ # Строки вида "МЕНЕДЖЕР: текст" или "[01:23] КЛИЕНТ: текст".
+ line = re.sub(r"^\s*\[?\d{1,2}:\d{2}\]?\s*", "", line)
+ line = re.sub(r"^\s*(МЕНЕДЖЕР|КЛИЕНТ|М|К|M|C|Г\d)\s*[:\-]\s*", "", line,
+ flags=re.IGNORECASE)
+ out.extend(words(line))
+ return out
+
+
+def from_result(path: Path) -> list[str]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ result = data.get("result") or data
+ return words(" ".join(t["text"] for t in result.get("turns", [])))
+
+
+def report(reference: list[str], actual: list[str], show: int) -> dict:
+ """Считает ошибки по операциям редактирования: пропуски, лишние, замены."""
+ matcher = difflib.SequenceMatcher(a=reference, b=actual, autojunk=False)
+ missing: list[str] = []
+ extra: list[str] = []
+ swapped: list[tuple[str, str]] = []
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "delete":
+ missing.extend(reference[i1:i2])
+ elif tag == "insert":
+ extra.extend(actual[j1:j2])
+ elif tag == "replace":
+ swapped.extend(zip(reference[i1:i2], actual[j1:j2]))
+ # Хвосты неравной длины - это тоже пропуск или лишнее.
+ missing.extend(reference[i1 + (j2 - j1):i2])
+ extra.extend(actual[j1 + (i2 - i1):j2])
+
+ errors = len(missing) + len(extra) + len(swapped)
+ wer = errors / max(1, len(reference))
+ print(f"эталон {len(reference)} слов, распознано {len(actual)}")
+ print(f"пропущено {len(missing)}, лишних {len(extra)}, перепутано {len(swapped)}")
+ print(f"WER {wer * 100:.1f} % (доля верных слов {(1 - wer) * 100:.1f} %)\n")
+ if missing[:show]:
+ print("не распознано:", " ".join(missing[:show]))
+ if swapped[:show]:
+ print("услышано иначе:",
+ ", ".join(f"{a}->{b}" for a, b in swapped[:show]))
+ return {"wer": wer, "missing": len(missing), "extra": len(extra),
+ "swapped": len(swapped), "reference": len(reference)}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("reference", type=Path)
+ ap.add_argument("result", type=Path)
+ ap.add_argument("--show", type=int, default=40, help="сколько примеров печатать")
+ args = ap.parse_args()
+ report(from_reference(args.reference), from_result(args.result), args.show)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_api.py b/tests/test_api.py
index 61cfb3c..91230f6 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -343,3 +343,19 @@ class TestDocsAccess:
assert "openapi.json?token=token%20s%20probelom%20i-slashem" in page
# Незакодированный пробел разорвал бы адрес, и схема не загрузилась бы.
assert "openapi.json?token=token s" not in page
+
+
+class TestJobsIndexRoute:
+ """/v1/jobs/view легко перехватывается маршрутом /v1/jobs/{job_id}."""
+
+ def test_index_is_not_shadowed_by_job_route(self, client):
+ response = client.get(f"/v1/jobs/view?token={TOKEN}")
+ assert response.status_code == 200
+ assert "Задачи" in response.text
+
+ def test_index_needs_token(self, client):
+ response = client.get("/v1/jobs/view", headers={"Authorization": ""})
+ assert response.status_code == 401
+
+ def test_unknown_job_still_404(self, client):
+ assert client.get("/v1/jobs/" + "0" * 32).status_code == 404
diff --git a/tests/test_view.py b/tests/test_view.py
index c88ac9f..e645c69 100644
--- a/tests/test_view.py
+++ b/tests/test_view.py
@@ -80,3 +80,49 @@ class TestPage:
def test_page_with_sentences_has_input(self):
assert "