Липкий плеер, рабочий клик по времени, переключатель шума, режим двух моделей

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

Плеер приклеен к верху на обеих страницах: спорное место слушают, не
прокручивая наверх.

Черновик подсвечивал слово при несогласии любой модели - жёлтым выходил
почти весь текст. Теперь по умолчанию нужно несогласие большинства: подсветки
втрое меньше (152 слова против 385), но в чистой части 95,4 % верных вместо
98 %. Строгий режим доступен ссылкой; порог применяется при показе, поэтому
переключение не требует пересборки.

Режим ensemble: вторая модель подставляется там, где основная промолчала.
Задаётся настройкой и параметром запроса - одну запись можно прогнать в обоих
режимах и сравнить на странице разбора.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-18 15:49:20 +05:00
co-authored by Claude Opus 5
parent 664d505446
commit bea5d3f877
9 changed files with 209 additions and 47 deletions
+32 -12
View File
@@ -111,9 +111,15 @@ def render_index(jobs: list[dict], token: str) -> str:
</div></body></html>"""
def render_draft(draft: dict, job_id: str, token: str = "") -> str:
"""Черновик для правки на слух: выделено то, что стоит проверить."""
def render_draft(draft: dict, job_id: str, token: str = "",
strict: bool = False) -> str:
"""Черновик для правки на слух: выделено то, что стоит проверить.
strict помечает слово при несогласии любой модели: подсветки втрое
больше, но пропущенных ошибок меньше - 2 % против 4,6 %.
"""
suffix = f"?token={quote(token)}" if token else ""
need = 1 if strict else max(1, draft.get("compared", 1))
gaps = {round(g["start"], 1): g for g in draft.get("gaps", [])}
rows = []
for turn in draft["turns"]:
@@ -128,16 +134,25 @@ def render_draft(draft: dict, job_id: str, token: str = "") -> str:
parts = [f'<span class="tc" data-at="{turn["start"]:.1f}">'
f'{timecode(turn["start"])}</span> ']
for word in turn["words"]:
if not word["variants"]:
votes = word.get("votes") or {}
variants = sorted(votes) if sum(votes.values()) >= need else []
if not variants:
parts.append(html.escape(word["text"]) + " ")
else:
options = " | ".join(html.escape(v) for v in word["variants"])
options = " | ".join(html.escape(v) for v in 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>')
other = "" if strict else "&strict=1"
label = ("показывать только при несогласии большинства"
if strict else "показывать все расхождения")
mode_switch = (f'<a href="/v1/jobs/{html.escape(job_id)}/draft'
f'{suffix or "?"}{other if suffix else other.lstrip("&")}">{label}</a>. ')
total = draft.get("total_words", 0)
agreed = draft.get("agreed_words", 0)
agreed = sum(1 for t in draft["turns"] for w in t["words"]
if sum((w.get("votes") or {}).values()) < need)
share = agreed / total * 100 if total else 0
return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
@@ -149,7 +164,9 @@ def render_draft(draft: dict, job_id: str, token: str = "") -> str:
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; }}
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
audio {{ width:100%; margin:0; }}
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; }}
@@ -163,11 +180,12 @@ def render_draft(draft: dict, job_id: str, token: str = "") -> str:
<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">Жёлтым - расхождения между моделями, в скобках варианты
<div class="hint">{mode_switch}Жёлтым - расхождения между моделями, в скобках варианты
(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный
текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки,
где не распознала ни одна модель. Щелчок по времени перематывает запись.</div>
<audio id="player" controls preload="none" src="/v1/jobs/{html.escape(job_id)}/audio{suffix}"></audio>
<div class="sticky"><audio id="player" controls preload="none"
src="/v1/jobs/{html.escape(job_id)}/audio{suffix}"></audio></div>
{"".join(rows)}
</div>
<script>
@@ -185,7 +203,7 @@ document.querySelectorAll('button.copy').forEach(b => b.addEventListener('click'
(!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 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({{
@@ -245,9 +263,9 @@ def render_job(job: dict, token: str = "") -> str:
# пустую полосу и выглядел бы поломкой.
if job.get("has_record"):
suffix = f"?token={quote(token)}" if token else ""
player = (f'<audio id="player" controls preload="none" '
player = (f'<div class="sticky"><audio id="player" controls preload="none" '
f'src="/v1/jobs/{html.escape(str(job.get("job_id", "")))}/audio{suffix}">'
'</audio>'
'</audio></div>'
'<div class="hint">Щелчок по времени слева от реплики перематывает запись.</div>')
else:
player = ('<div class="hint">Запись не сохранена: включите keep_records '
@@ -271,7 +289,9 @@ def render_job(job: dict, token: str = "") -> str:
.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; }}
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
audio {{ width:100%; margin:0; }}
.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; }}