"""Страница для разбора результата в браузере.
Когда диалог размечен неверно, по одному JSON не понять, чья это ошибка:
распознавание склеило реплики или LLM неверно расставила роли. Страница
показывает обе стороны - что отдал сервис и что получилось после разметки,
- и позволяет вставить ответ модели, чтобы увидеть результат сразу.
"""
import html
import json
from urllib.parse import quote
__all__ = ["render_job", "render_index", "render_draft"]
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)
_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); }
.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; }"""
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_draft(draft: dict, job_id: str, token: str = "",
strict: bool = False) -> str:
"""Черновик для правки на слух: выделено то, что стоит проверить.
strict помечает слово при несогласии любой модели: подсветки втрое
больше, но пропущенных ошибок меньше - 2 % против 4,6 %.
"""
suffix = f"?token={quote(token)}" if token else ""
need = 1 if strict else max(1, draft.get("compared", 1))
gaps = {round(g["start"], 1): g for g in draft.get("gaps", [])}
rows = []
for turn in draft["turns"]:
gap = gaps.pop(round(turn["start"], 1), None) or next(
(g for k, g in list(gaps.items()) if k < turn["start"]), None)
if gap is not None:
gaps.pop(round(gap["start"], 1), None)
rows.append(
f'
[{timecode(gap["start"])} - {timecode(gap["end"])}] '
f'ничего не распознано, {gap["end"] - gap["start"]:.0f} с - '
f'послушайте, не потерялась ли реплика
')
parts = [f''
f'{timecode(turn["start"])} ']
for word in turn["words"]:
votes = word.get("votes") or {}
variants = sorted(votes) if sum(votes.values()) >= need else []
if not variants:
parts.append(html.escape(word["text"]) + " ")
else:
options = " | ".join(html.escape(v) for v in variants)
parts.append(f'{html.escape(word["text"])}'
f'[{options}] ')
rows.append(f'
{"".join(parts)}
')
other = "" if strict else "&strict=1"
label = ("показывать только при несогласии большинства"
if strict else "показывать все расхождения")
mode_switch = (f'{label}. ')
total = draft.get("total_words", 0)
agreed = sum(1 for t in draft["turns"] for w in t["words"]
if sum((w.get("votes") or {}).values()) < need)
share = agreed / total * 100 if total else 0
return f"""
Черновик {html.escape(job_id[:8])}
{mode_switch}Жёлтым - расхождения между моделями, в скобках варианты
(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный
текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки,
где не распознала ни одна модель. Щелчок по времени перематывает запись.
{"".join(rows)}
"""
def render_job(job: dict, token: str = "") -> 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''
f'{timecode(turn["start"])}'
f'{who}'
f'{volume}'
f'{html.escape(turn["text"])}{mark}'
f'
')
# Внутри в тексте
# закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку.
if sentences:
roles_block = (
'
Вставьте ответ модели - строку из букв M и C, '
'по одной на предложение. Диалог соберётся ниже, и станет видно, '
'где ошибка: в тексте от сервиса или в расстановке ролей.
'
'')
else:
# Массив предложений появился в 0.14.0. Без него собирать диалог не из
# чего, и молчаливо пустой блок выглядел бы поломкой страницы.
roles_block = (
'
В этом результате нет разбивки на предложения: '
'запись обработана версией сервиса старше 0.14.0. Отправьте её заново, '
'чтобы собрать диалог по ролям.
')
# Плеер появляется, только если запись сохранена: иначе он показывал бы
# пустую полосу и выглядел бы поломкой.
if job.get("has_record"):
suffix = f"?token={quote(token)}" if token else ""
player = (f''
'
Щелчок по времени слева от реплики перематывает запись.
')
else:
player = ('
Запись не сохранена: включите keep_records '
'в config.toml, чтобы слушать разговор прямо здесь.
')
job_id_esc = html.escape(str(job.get("job_id", "")))
token_suffix = f"?token={quote(token)}" if token else ""
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]}
Несколько моделей распознают запись, подсвечиваются только
расхождения между ними. Там, где все согласны, текст верен в 98 % случаев -
слушать нужно четверть текста вместо всего. Сборка занимает несколько минут.