Результаты, обработанные до 0.14.0, не содержат массива sentences. Пустой блок разметки выглядел бы поломкой страницы - теперь он говорит, что нужно отправить запись заново. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
8.9 KiB
Python
167 lines
8.9 KiB
Python
"""Страница для разбора результата в браузере.
|
||
|
||
Когда диалог размечен неверно, по одному 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> в тексте
|
||
# закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку.
|
||
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>')
|
||
|
||
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>
|
||
{roles_block}
|
||
<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 => ({{'&':'&','<':'<','>':'>'}})[c])); }});
|
||
flush(); out.innerHTML = html;
|
||
}}
|
||
if (box) box.addEventListener('input', draw);
|
||
</script></body></html>"""
|