Записи складываются в папку records и удаляются вместе с задачей по тому же сроку. На странице разбора появился плеер, а щелчок по времени слева от реплики перематывает запись - можно послушать спорное место, не таская ползунок. Настройка keep_records в config.toml. Если запись не сохранена, вместо пустого плеера показывается объяснение. Черновик с подсветкой расхождений пока остаётся отдельной командой: build/make_draft.py. Ложные расхождения по написанию больше не подсвечиваются. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
15 KiB
Python
298 lines
15 KiB
Python
"""Страница для разбора результата в браузере.
|
||
|
||
Когда диалог размечен неверно, по одному 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 => ({{'&':'&','<':'<','>':'>'}})[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 => ({{'&':'&','<':'<','>':'>'}}[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>"""
|