Files
talkscore-asr/app/view.py
T
Vladimir BryzgalovandClaude Opus 5 96c2a27c4c Плеер на странице разбора и хранение записей
Записи складываются в папку records и удаляются вместе с задачей по тому же
сроку. На странице разбора появился плеер, а щелчок по времени слева от
реплики перематывает запись - можно послушать спорное место, не таская
ползунок.

Настройка keep_records в config.toml. Если запись не сохранена, вместо
пустого плеера показывается объяснение.

Черновик с подсветкой расхождений пока остаётся отдельной командой:
build/make_draft.py. Ложные расхождения по написанию больше не подсвечиваются.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:11:23 +05:00

298 lines
15 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
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'<div class="stat"><span class="k">{html.escape(k)}</span>'
f'<span class="v">{html.escape(v)}</span></div>' 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'<a href="/v1/jobs/{job["id"]}/view{suffix}">{title}</a>'
if status == "done" else title)
note = html.escape(str(job.get("error") or "")[:80])
rows.append(
f'<tr class="st-{html.escape(status)}">'
f'<td class="dim">{when}</td>'
f'<td>{cell}</td>'
f'<td class="dim">{timecode(job.get("duration_sec") or 0)}</td>'
f'<td class="status">{STATUS_RU.get(status, status)}</td>'
f'<td class="dim err">{note}</td></tr>')
body = ("".join(rows) or
'<tr><td colspan="5" class="dim">задач пока нет</td></tr>')
return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Задачи talkscore-asr</title>
<style>{_CSS}
table {{ width:100%; border-collapse:collapse; }}
td {{ padding:6px 10px 6px 0; border-bottom:1px solid var(--line);
vertical-align:top; }}
td.dim {{ color:var(--dim); font-size:13px; font-variant-numeric:tabular-nums;
white-space:nowrap; }}
td.err {{ white-space:normal; }}
.status {{ font-size:13px; }}
.st-failed .status {{ color:var(--warn); }}
.st-done .status {{ color:var(--a); }}
a {{ color:inherit; }}
</style></head><body><div class="wrap">
<h1>Задачи</h1>
<div class="hint">Готовые открываются по клику. Показаны последние {len(jobs)}.</div>
<table>{body}</table>
</div></body></html>"""
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'<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" data-at="{turn["start"]:.1f}">'
f'{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> в тексте
# закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку.
if sentences:
roles_block = (
'<div class="hint">Вставьте ответ модели - строку из букв M и C, '
'по одной на предложение. Диалог соберётся ниже, и станет видно, '
'где ошибка: в тексте от сервиса или в расстановке ролей.</div>'
'<textarea id="roles" placeholder="MMCMMCC..." spellcheck="false"></textarea>')
else:
# Массив предложений появился в 0.14.0. Без него собирать диалог не из
# чего, и молчаливо пустой блок выглядел бы поломкой страницы.
roles_block = (
'<div class="hint bad">В этом результате нет разбивки на предложения: '
'запись обработана версией сервиса старше 0.14.0. Отправьте её заново, '
'чтобы собрать диалог по ролям.</div>')
# Плеер появляется, только если запись сохранена: иначе он показывал бы
# пустую полосу и выглядел бы поломкой.
if job.get("has_record"):
suffix = f"?token={quote(token)}" if token else ""
player = (f'<audio id="player" controls preload="none" '
f'src="/v1/jobs/{html.escape(str(job.get("job_id", "")))}/audio{suffix}">'
'</audio>'
'<div class="hint">Щелчок по времени слева от реплики перематывает запись.</div>')
else:
player = ('<div class="hint">Запись не сохранена: включите keep_records '
'в config.toml, чтобы слушать разговор прямо здесь.</div>')
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>
{_CSS}
.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; }}
.tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; }}
.tc[data-at]:hover {{ color:var(--fg); }}
audio {{ width:100%; margin:4px 0 2px; }}
.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; }}
a.button {{ display:inline-block; font-size:13px; color:var(--fg);
border:1px solid var(--line); border-radius:4px; padding:5px 12px;
text-decoration:none; }}
a.button:hover {{ border-color:var(--dim); }}
button.copy {{ font:13px inherit; color:var(--fg); background:transparent;
border:1px solid var(--line); border-radius:4px; padding:3px 10px;
margin-left:10px; cursor:pointer; font-weight:400; }}
button.copy:hover {{ border-color:var(--dim); }}
</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>
{player}
<h2>Что отдал сервис <button class="copy" data-src="plain">скопировать для Word</button></h2>
<div class="hint">Реплики так, как их нарезал сервис. Г0 значит, что разделение
по голосам выключено и говорящий неизвестен - это норма.</div>
{"".join(rows) or "<div class=hint>реплик нет</div>"}
<h2>Разметка ролей</h2>
{roles_block}
<div id="check" class="hint"></div>
<div id="out" class="roles"></div>
<button class="copy" id="copyroles" data-src="roles" hidden>скопировать диалог для Word</button>
</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;
const rolesButton = document.getElementById('copyroles');
if (rolesButton) rolesButton.hidden = !html;
}}
if (box) box.addEventListener('input', draw);
// Word вставляет из буфера разметку, если она есть: имена говорящих
// останутся полужирными, а абзацы - абзацами. Простой текст кладём рядом,
// для редакторов, которые разметку не берут.
function collect(src) {{
if (src === 'roles') {{
const lines = [...out.querySelectorAll('.line')].map(el => [
el.querySelector('.who2').textContent.trim(),
el.lastElementChild.textContent.trim()]);
return lines;
}}
return [...document.querySelectorAll('.turn')].map(el => [
el.querySelector('.tc').textContent.trim() + ' ' +
el.querySelector('.who').textContent.trim(),
el.querySelector('.text').textContent.trim()]);
}}
function esc(t) {{ return t.replace(/[&<>]/g, c => ({{'&':'&amp;','<':'&lt;','>':'&gt;'}}[c])); }}
async function copyAs(src, button) {{
const rows = collect(src);
if (!rows.length) return;
const plain = rows.map(([who, text]) => who + ': ' + text).join('\\n\\n');
const rich = '<div>' + rows.map(([who, text]) =>
'<p><b>' + esc(who) + ':</b> ' + esc(text) + '</p>').join('') + '</div>';
try {{
await navigator.clipboard.write([new ClipboardItem({{
'text/html': new Blob([rich], {{type: 'text/html'}}),
'text/plain': new Blob([plain], {{type: 'text/plain'}})}})]);
}} catch (e) {{
// Старые браузеры и небезопасное соединение: кладём хотя бы текст.
await navigator.clipboard.writeText(plain);
}}
const was = button.textContent;
button.textContent = 'скопировано';
setTimeout(() => {{ button.textContent = was; }}, 1500);
}}
document.querySelectorAll('button.copy').forEach(b =>
b.addEventListener('click', () => copyAs(b.dataset.src, b)));
// Перемотка по щелчку на времени: слушать нужное место, не таская ползунок.
const player = document.getElementById('player');
if (player) {{
document.querySelectorAll('.tc[data-at]').forEach(el =>
el.addEventListener('click', () => {{
player.currentTime = parseFloat(el.dataset.at);
player.play();
}}));
}}
</script></body></html>"""