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)}.
+{body}
+
""" + + 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 ".mp3", "status": "failed", + "created_at": 1755300500, "duration_sec": 0.0, "error": "нет модели"}, + ] + + def test_done_job_is_a_link(self): + from app.view import render_index + + page = render_index(self.jobs(), "tok") + assert f'href="/v1/jobs/{"a" * 32}/view?token=tok"' in page + + def test_failed_job_is_not_a_link(self): + """Разбирать нечего: результата нет.""" + from app.view import render_index + + page = render_index(self.jobs(), "tok") + assert f'/v1/jobs/{"b" * 32}/view' not in page + + def test_error_is_shown(self): + from app.view import render_index + + assert "нет модели" in render_index(self.jobs(), "tok") + + def test_filename_is_escaped(self): + from app.view import render_index + + page = render_index(self.jobs(), "tok") + assert "<bad>.mp3" in page and "" not in page + + def test_token_is_encoded_in_links(self): + from app.view import render_index + + page = render_index(self.jobs(), "token s probelom") + assert "token%20s%20probelom" in page + + def test_empty_list_says_so(self): + from app.view import render_index + + assert "задач пока нет" in render_index([], "tok")