Files
talkscore-asr/app/view.py
T
Vladimir BryzgalovandClaude Opus 5 bea5d3f877 Липкий плеер, рабочий клик по времени, переключатель шума, режим двух моделей
Клик по времени не работал: в скрипте страницы черновика снова оказался
настоящий перевод строки вместо экранированного, и весь скрипт не выполнялся.
Тот же огрех, что был в странице разбора. Проверка парности кавычек теперь
охватывает обе страницы.

Плеер приклеен к верху на обеих страницах: спорное место слушают, не
прокручивая наверх.

Черновик подсвечивал слово при несогласии любой модели - жёлтым выходил
почти весь текст. Теперь по умолчанию нужно несогласие большинства: подсветки
втрое меньше (152 слова против 385), но в чистой части 95,4 % верных вместо
98 %. Строгий режим доступен ссылкой; порог применяется при показе, поэтому
переключение не требует пересборки.

Режим ensemble: вторая модель подставляется там, где основная промолчала.
Задаётся настройкой и параметром запроса - одну запись можно прогнать в обоих
режимах и сравнить на странице разбора.

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

413 lines
22 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", "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'<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_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'<div class="gap">[{timecode(gap["start"])} - {timecode(gap["end"])}] '
f'ничего не распознано, {gap["end"] - gap["start"]:.0f} с - '
f'послушайте, не потерялась ли реплика</div>')
parts = [f'<span class="tc" data-at="{turn["start"]:.1f}">'
f'{timecode(turn["start"])}</span> ']
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'<mark>{html.escape(word["text"])}</mark>'
f'<span class="alt">[{options}]</span> ')
rows.append(f'<div class="turn draft">{"".join(parts)}</div>')
other = "" if strict else "&strict=1"
label = ("показывать только при несогласии большинства"
if strict else "показывать все расхождения")
mode_switch = (f'<a href="/v1/jobs/{html.escape(job_id)}/draft'
f'{suffix or "?"}{other if suffix else other.lstrip("&")}">{label}</a>. ')
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"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Черновик {html.escape(job_id[:8])}</title>
<style>{_CSS}
.turn.draft {{ display:block; padding:6px 0; border-bottom:1px solid var(--line); }}
.tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; margin-right:6px; }}
mark {{ background:#ffe89a; color:#22201d; padding:0 2px; border-radius:2px; }}
.alt {{ color:var(--dim); font-size:13px; margin-left:3px; }}
.gap {{ color:var(--warn); font-size:13px; padding:6px 0; font-weight:600; }}
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
audio {{ width:100%; margin:0; }}
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; }}
</style></head><body><div class="wrap">
<h1>Черновик {html.escape(job_id[:8])}
<button class="copy" data-src="plain">скопировать для Word</button></h1>
<div class="stats">
<div class="stat"><span class="k">Моделей</span><span class="v">{draft.get("models", 1)}</span></div>
<div class="stat"><span class="k">Слов</span><span class="v">{total}</span></div>
<div class="stat"><span class="k">Согласны</span><span class="v">{agreed} ({share:.0f} %)</span></div>
<div class="stat"><span class="k">Проверить</span><span class="v">{total - agreed}</span></div>
<div class="stat"><span class="k">Пропусков</span><span class="v">{len(draft.get("gaps", []))}</span></div>
</div>
<div class="hint">{mode_switch}Жёлтым - расхождения между моделями, в скобках варианты
(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный
текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки,
где не распознала ни одна модель. Щелчок по времени перематывает запись.</div>
<div class="sticky"><audio id="player" controls preload="none"
src="/v1/jobs/{html.escape(job_id)}/audio{suffix}"></audio></div>
{"".join(rows)}
</div>
<script>
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();
}}));
}}
document.querySelectorAll('button.copy').forEach(b => b.addEventListener('click', async () => {{
const rows = [...document.querySelectorAll('.turn.draft')].map(el => {{
const time = el.querySelector('.tc').textContent.trim();
const text = [...el.childNodes].filter(n => !n.classList ||
(!n.classList.contains('tc'))).map(n => n.textContent).join('').trim();
return [time, text];
}});
const plain = rows.map(([t, x]) => t + ' ' + x).join('\\n\\n');
const rich = rows.map(([t, x]) => '<p><b>' + t + '</b> ' + x + '</p>').join('');
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); }}
b.textContent = 'скопировано';
}}));
</script></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'<div class="sticky"><audio id="player" controls preload="none" '
f'src="/v1/jobs/{html.escape(str(job.get("job_id", "")))}/audio{suffix}">'
'</audio></div>'
'<div class="hint">Щелчок по времени слева от реплики перематывает запись.</div>')
else:
player = ('<div class="hint">Запись не сохранена: включите keep_records '
'в config.toml, чтобы слушать разговор прямо здесь.</div>')
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"""<!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); }}
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
audio {{ width:100%; margin:0; }}
.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>Черновик для правки на слух</h2>
<div class="hint">Несколько моделей распознают запись, подсвечиваются только
расхождения между ними. Там, где все согласны, текст верен в 98 % случаев -
слушать нужно четверть текста вместо всего. Сборка занимает несколько минут.</div>
<a class="button" href="/v1/jobs/{job_id_esc}/draft{token_suffix}">собрать черновик</a>
<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>"""