"""Страница для разбора результата в браузере. Когда диалог размечен неверно, по одному JSON не понять, чья это ошибка: распознавание склеило реплики или LLM неверно расставила роли. Страница показывает обе стороны - что отдал сервис и что получилось после разметки, - и позволяет вставить ответ модели, чтобы увидеть результат сразу. """ import html import json from urllib.parse import quote __all__ = ["render_job", "render_index"] 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); }""" 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 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'{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. Отправьте её заново, ' 'чтобы собрать диалог по ролям.
') 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)}

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

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

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

{roles_block}
"""