Страница разбора расшифровки в браузере

По одному JSON не понять, чья ошибка: распознавание склеило реплики или
LLM неверно расставила роли. Страница показывает обе стороны и принимает
ответ модели, чтобы диалог собрался на глазах.

GET /v1/jobs/{id}/view - токен заголовком либо ссылкой ?token=ЗНАЧЕНИЕ.
Страница самодостаточна: ни одного внешнего запроса.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-17 13:58:08 +05:00
co-authored by Claude Opus 5
parent 020706a69a
commit 0dd531aaa1
3 changed files with 259 additions and 8 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Страница для разбора результата в браузере.
Когда диалог размечен неверно, по одному JSON не понять, чья это ошибка:
распознавание склеило реплики или LLM неверно расставила роли. Страница
показывает обе стороны - что отдал сервис и что получилось после разметки,
- и позволяет вставить ответ модели, чтобы увидеть результат сразу.
"""
import html
import json
__all__ = ["render_job"]
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'<div class="stat"><span class="k">{html.escape(k)}</span>'
f'<span class="v">{html.escape(v)}</span></div>' for k, v in items)
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'<div class="gap">пропуск {gap:.0f} с '
f'[{timecode(previous_end)} - {timecode(turn["start"])}]</div>')
previous_end = turn["end"]
loudness = (turn.get("acoustics") or {}).get("loudness_db")
volume = f"{loudness:.0f} дБ" if loudness is not None else ""
mark = '<span class="tag">второй проход</span>' if turn.get("recovered") else ""
speaker = turn.get("speaker", 0)
who = f"Г{speaker}" if speaker else "Г0"
rows.append(
f'<div class="turn sp{speaker % 3}">'
f'<span class="tc">{timecode(turn["start"])}</span>'
f'<span class="who">{who}</span>'
f'<span class="db">{volume}</span>'
f'<span class="text">{html.escape(turn["text"])}{mark}</span>'
f'</div>')
# Внутри <script> символ < опасен: последовательность </script> в тексте
# закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку.
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"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Расшифровка {html.escape(str(job.get("job_id", "")))[:8]}</title>
<style>
: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; }}
.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; }}
.turn {{ display:grid; grid-template-columns:52px 34px 58px 1fr; gap:8px;
padding:5px 0; border-bottom:1px solid var(--line); }}
.tc, .db {{ color:var(--dim); font-size:12px; font-variant-numeric:tabular-nums; }}
.who {{ font-weight:600; font-size:13px; }}
.sp1 .who {{ color:var(--a); }} .sp2 .who {{ color:var(--b); }}
.gap {{ color:var(--warn); font-size:12px; padding:5px 0 5px 94px; }}
.tag {{ color:var(--warn); font-size:11px; margin-left:8px;
border:1px solid var(--warn); border-radius:3px; padding:0 4px; }}
textarea {{ width:100%; min-height:70px; font:12px/1.4 ui-monospace,monospace;
background:transparent; color:var(--fg); border:1px solid var(--line);
border-radius:4px; padding:8px; }}
.roles .m {{ color:var(--a); }} .roles .c {{ color:var(--b); }}
.roles .line {{ display:grid; grid-template-columns:76px 1fr; gap:10px;
padding:4px 0; border-bottom:1px solid var(--line); }}
.who2 {{ font-weight:600; font-size:13px; }}
.hint {{ color:var(--dim); font-size:13px; margin:6px 0 14px; }}
.bad {{ color:var(--warn); }}
h2 {{ font-size:15px; margin:26px 0 8px; font-weight:600; }}
</style></head><body><div class="wrap">
<h1>Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}</h1>
<div class="stats">{_stats_row(job)}</div>
<h2>Что отдал сервис</h2>
<div class="hint">Реплики так, как их нарезал сервис. Г0 значит, что разделение
по голосам выключено и говорящий неизвестен - это норма.</div>
{"".join(rows) or "<div class=hint>реплик нет</div>"}
<h2>Разметка ролей</h2>
<div class="hint">Вставьте ответ модели - строку из букв M и C, по одной
на предложение. Диалог соберётся ниже, и станет видно, где ошибка:
в тексте от сервиса или в расстановке ролей.</div>
<textarea id="roles" placeholder="MMCMMCC..." spellcheck="false"></textarea>
<div id="check" class="hint"></div>
<div id="out" class="roles"></div>
</div>
<script>
const S = {payload};
const box = document.getElementById('roles');
const out = document.getElementById('out');
const check = document.getElementById('check');
function tc(s) {{ const m = Math.floor(s/60), r = Math.floor(s%60);
return String(m).padStart(2,'0')+':'+String(r).padStart(2,'0'); }}
function draw() {{
const letters = (box.value.toUpperCase().match(/[MC]/g) || []);
if (!letters.length) {{ out.innerHTML=''; check.textContent=''; return; }}
if (letters.length !== S.length) {{
check.innerHTML = '<span class="bad">букв ' + letters.length +
', а предложений ' + S.length + ' - разметка сдвинута, показываю как есть</span>';
}} else {{ check.textContent = 'букв ' + letters.length + ', совпадает с числом предложений'; }}
let html = '', prev = null, buf = [], start = 0;
const flush = () => {{ if (!buf.length) return;
const cls = prev === 'M' ? 'm' : 'c';
const who = prev === 'M' ? 'МЕНЕДЖЕР' : 'КЛИЕНТ';
html += '<div class="line"><span class="who2 '+cls+'">'+tc(start)+' '+who+
'</span><span>'+buf.join(' ')+'</span></div>'; buf = []; }};
S.forEach((s, i) => {{ const r = letters[i] || 'M';
if (r !== prev) {{ flush(); prev = r; start = s.start; }}
buf.push(s.text.replace(/[&<>]/g, c => ({{'&':'&amp;','<':'&lt;','>':'&gt;'}})[c])); }});
flush(); out.innerHTML = html;
}}
box.addEventListener('input', draw);
</script></body></html>"""