"""Страница для разбора результата в браузере. Когда диалог размечен неверно, по одному 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])}

Черновик {html.escape(job_id[:8])}

Моделей{draft.get("models", 1)}
Слов{total}
Согласны{agreed} ({share:.0f} %)
Проверить{total - agreed}
Пропусков{len(draft.get("gaps", []))}
{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]}

Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}

{_stats_row(job)}
{player}

Черновик для правки на слух

Несколько моделей распознают запись, подсвечиваются только расхождения между ними. Там, где все согласны, текст верен в 98 % случаев - слушать нужно четверть текста вместо всего. Сборка занимает несколько минут.
собрать черновик

Что отдал сервис

Реплики так, как их нарезал сервис. Г0 значит, что разделение по голосам выключено и говорящий неизвестен - это норма.
{"".join(rows) or "
реплик нет
"}

Разметка ролей

{roles_block}
"""