Страница разбора расшифровки в браузере
По одному JSON не понять, чья ошибка: распознавание склеило реплики или
LLM неверно расставила роли. Страница показывает обе стороны и принимает
ответ модели, чтобы диалог собрался на глазах.
GET /v1/jobs/{id}/view - токен заголовком либо ссылкой ?token=ЗНАЧЕНИЕ.
Страница самодостаточна: ни одного внешнего запроса.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
020706a69a
commit
0dd531aaa1
+155
@@ -0,0 +1,155 @@
|
||||
"""Страница для разбора результата в браузере.
|
||||
|
||||
Когда диалог размечен неверно, по одному 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 => ({{'&':'&','<':'<','>':'>'}})[c])); }});
|
||||
flush(); out.innerHTML = html;
|
||||
}}
|
||||
box.addEventListener('input', draw);
|
||||
</script></body></html>"""
|
||||
Reference in New Issue
Block a user