Липкий плеер, рабочий клик по времени, переключатель шума, режим двух моделей
Клик по времени не работал: в скрипте страницы черновика снова оказался настоящий перевод строки вместо экранированного, и весь скрипт не выполнялся. Тот же огрех, что был в странице разбора. Проверка парности кавычек теперь охватывает обе страницы. Плеер приклеен к верху на обеих страницах: спорное место слушают, не прокручивая наверх. Черновик подсвечивал слово при несогласии любой модели - жёлтым выходил почти весь текст. Теперь по умолчанию нужно несогласие большинства: подсветки втрое меньше (152 слова против 385), но в чистой части 95,4 % верных вместо 98 %. Строгий режим доступен ссылкой; порог применяется при показе, поэтому переключение не требует пересборки. Режим ensemble: вторая модель подставляется там, где основная промолчала. Задаётся настройкой и параметром запроса - одну запись можно прогнать в обоих режимах и сравнить на странице разбора. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
664d505446
commit
bea5d3f877
+32
-12
@@ -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; }}
|
||||
|
||||
Reference in New Issue
Block a user