Files
talkscore-asr/app/view.py
T
Vladimir BryzgalovandClaude Opus 5 0dd531aaa1 Страница разбора расшифровки в браузере
По одному JSON не понять, чья ошибка: распознавание склеило реплики или
LLM неверно расставила роли. Страница показывает обе стороны и принимает
ответ модели, чтобы диалог собрался на глазах.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:58:08 +05:00

156 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Страница для разбора результата в браузере.
Когда диалог размечен неверно, по одному 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>"""