Черновик собирается по кнопке фоновой задачей

Три модели вместо одной считаются минуту и больше, плюс их загрузка при
первом обращении - держать на этом HTTP-запрос нельзя. Первый запрос
запускает сборку, страница обновляет себя сама и показывает ход работы.

Отдаётся страницей, а не документом: библиотеки для .docx в поставке нет,
а обновление возит только код. В Word переносится кнопкой копирования,
которая кладёт в буфер разметку вместе с текстом.

Тест поймал потерю: лишнее слово в конце реплики не отмечалось - индекс
выходил за границу, и метка молча пропадала.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-18 15:17:34 +05:00
co-authored by Claude Opus 5
parent d1ea57bbc9
commit 8727acd10d
4 changed files with 361 additions and 2 deletions
+96 -1
View File
@@ -9,7 +9,7 @@ import html
import json
from urllib.parse import quote
__all__ = ["render_job", "render_index"]
__all__ = ["render_job", "render_index", "render_draft"]
GAP_NOTICE_SEC = 3.0
@@ -111,6 +111,92 @@ def render_index(jobs: list[dict], token: str) -> str:
</div></body></html>"""
def render_draft(draft: dict, job_id: str, token: str = "") -> str:
"""Черновик для правки на слух: выделено то, что стоит проверить."""
suffix = f"?token={quote(token)}" if token else ""
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"]:
if not word["variants"]:
parts.append(html.escape(word["text"]) + " ")
else:
options = " | ".join(html.escape(v) for v in word["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>')
total = draft.get("total_words", 0)
agreed = draft.get("agreed_words", 0)
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; }}
audio {{ width:100%; margin:4px 0 10px; }}
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">Жёлтым - расхождения между моделями, в скобках варианты
(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный
текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки,
где не распознала ни одна модель. Щелчок по времени перематывает запись.</div>
<audio id="player" controls preload="none" src="/v1/jobs/{html.escape(job_id)}/audio{suffix}"></audio>
{"".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
@@ -167,6 +253,9 @@ def render_job(job: dict, token: str = "") -> str:
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")
@@ -208,6 +297,12 @@ def render_job(job: dict, token: str = "") -> str:
<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>