Черновик собирается по кнопке фоновой задачей
Три модели вместо одной считаются минуту и больше, плюс их загрузка при первом обращении - держать на этом HTTP-запрос нельзя. Первый запрос запускает сборку, страница обновляет себя сама и показывает ход работы. Отдаётся страницей, а не документом: библиотеки для .docx в поставке нет, а обновление возит только код. В Word переносится кнопкой копирования, которая кладёт в буфер разметку вместе с текстом. Тест поймал потерю: лишнее слово в конце реплики не отмечалось - индекс выходил за границу, и метка молча пропадала. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d1ea57bbc9
commit
8727acd10d
+96
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user