// Admin ASR Correction tab — manage deterministic typo correction rules // (wrong→correct, global / per-show), preview match count before saving, and // trigger a backfill (dry-run preview → confirm → run). Part of EQ2a. const AdminAsrCorrectionTab = ({ lang }) => { const t = lang === 'zh'; const [rows, setRows] = React.useState(null); const [shows, setShows] = React.useState([]); const [error, setError] = React.useState(null); const [toast, setToast] = React.useState(null); // new-rule form const [wrong, setWrong] = React.useState(''); const [correct, setCorrect] = React.useState(''); const [scope, setScope] = React.useState('show'); const [showId, setShowId] = React.useState(''); const [note, setNote] = React.useState(''); const [adding, setAdding] = React.useState(false); const [matchCount, setMatchCount] = React.useState(null); // backfill const [backfillScope, setBackfillScope] = React.useState(''); // '' = all shows const [preview, setPreview] = React.useState(null); const [previewing, setPreviewing] = React.useState(false); const [backfilling, setBackfilling] = React.useState(false); // EQ2e F6: detect over a show's existing episodes (dry-run estimate → confirm) const [detectScope, setDetectScope] = React.useState(''); // show_id (required) const [detectEstimate, setDetectEstimate] = React.useState(null); const [detectEstimating, setDetectEstimating] = React.useState(false); const [detectStarting, setDetectStarting] = React.useState(false); // EQ2e F8: a single active background job (detect or apply) we poll + can cancel const [activeJob, setActiveJob] = React.useState(null); // { taskId, label, status } const [cancelling, setCancelling] = React.useState(false); // EQ2e F8: batch restore existing episodes back to original ASR text const [restoreScope, setRestoreScope] = React.useState(''); // '' = all shows const [restoring, setRestoring] = React.useState(false); // EQ2e F-approve: per-candidate "also apply to existing episodes" checkbox const [applyToExisting, setApplyToExisting] = React.useState({}); const showToast = (msg, kind = 'success') => { setToast({ msg, kind }); setTimeout(() => setToast(null), 3500); }; const reload = React.useCallback(async () => { setError(null); try { const res = await apiFetch('/admin/asr-corrections'); if (!res.ok) throw new Error(`HTTP ${res.status}`); setRows(await res.json()); } catch (err) { setError(err.message || String(err)); } }, []); React.useEffect(() => { reload(); }, [reload]); React.useEffect(() => { (async () => { try { const res = await apiFetch('/shows'); if (res.ok) setShows(await res.json()); } catch { /* non-fatal */ } })(); }, []); const showTitle = (id) => { const s = shows.find((x) => x.id === id); return s ? s.title : id; }; // ── match-count preview (over-broad rule guard) ── React.useEffect(() => { if (!wrong.trim() || (scope === 'show' && !showId)) { setMatchCount(null); return; } let cancelled = false; const tid = setTimeout(async () => { try { const qs = new URLSearchParams({ wrong: wrong.trim(), scope }); if (scope === 'show' && showId) qs.set('show_id', showId); const res = await apiFetch(`/admin/asr-corrections/match-count?${qs}`); if (res.ok && !cancelled) setMatchCount((await res.json()).match_count); } catch { /* preview is best-effort */ } }, 400); return () => { cancelled = true; clearTimeout(tid); }; }, [wrong, scope, showId]); const handleAdd = async (e) => { e?.preventDefault?.(); if (!wrong.trim() || adding) return; if (scope === 'show' && !showId) { showToast(t ? '請選擇節目' : 'Please choose a show', 'warn'); return; } setAdding(true); try { const body = { wrong: wrong.trim(), correct: correct, scope, show_id: scope === 'show' ? showId : null, note: note.trim() || null, }; const res = await apiFetch('/admin/asr-corrections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.status === 409) { showToast(t ? '規則已存在' : 'Rule already exists', 'warn'); return; } if (res.status === 422) { showToast(t ? '欄位有誤(綁節目需選節目)' : 'Invalid fields', 'warn'); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); setWrong(''); setCorrect(''); setNote(''); setMatchCount(null); showToast( t ? '已新增(既有逐字稿需按下方「批次回填」才會更新)' : 'Added (existing transcripts need a manual backfill below)', ); await reload(); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setAdding(false); } }; const handleToggle = async (r) => { try { const res = await apiFetch(`/admin/asr-corrections/${r.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: !r.enabled }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); await reload(); } catch (err) { showToast(err.message || String(err), 'error'); } }; const handleDelete = async (r) => { if (!confirm(t ? `刪除「${r.wrong}→${r.correct}」?` : `Delete "${r.wrong}→${r.correct}"?`)) return; try { const res = await apiFetch(`/admin/asr-corrections/${r.id}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`); showToast(t ? '已刪除' : 'Deleted'); await reload(); } catch (err) { showToast(err.message || String(err), 'error'); } }; // ── EQ2b: LLM candidate review (approve / reject) ── // EQ2c F3: per-candidate edited `correct` value (keyed by candidate id), // pre-filled lazily from the candidate's detected value. const [reviewingId, setReviewingId] = React.useState(null); const [editedCorrect, setEditedCorrect] = React.useState({}); const handleReview = async (c, action) => { if (reviewingId) return; setReviewingId(c.id); try { const opts = { method: 'POST' }; const wantApply = action === 'approve' && !!applyToExisting[c.id]; if (action === 'approve') { // Send the (possibly edited) correct value so admin fixes apply. // EQ2e F-approve: apply_to_existing also re-runs the rule over old episodes. const edited = editedCorrect[c.id]; const finalCorrect = (edited === undefined ? c.correct : edited).trim(); opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify({ correct: finalCorrect, apply_to_existing: wantApply }); } const res = await apiFetch(`/admin/asr-corrections/${c.id}/${action}`, opts); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = action === 'approve' ? await res.json().catch(() => ({})) : {}; // EQ2e: if approval kicked off a rule-application job, track it. if (wantApply && data.task_id) { setActiveJob({ taskId: data.task_id, label: t ? `套用「${c.wrong}→${c.correct}」` : `Apply "${c.wrong}→${c.correct}"`, status: null }); } showToast( action === 'approve' ? (wantApply ? (t ? '已核准,套用既有集作業已啟動' : 'Approved — applying to existing episodes') : (t ? '已核准,已跨集生效' : 'Approved — now active across episodes')) : (t ? '已駁回' : 'Rejected'), ); await reload(); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setReviewingId(null); } }; const handlePreview = async () => { if (previewing) return; setPreviewing(true); setPreview(null); try { const body = { dry_run: true }; if (backfillScope) body.show_id = backfillScope; const res = await apiFetch('/admin/asr-corrections/backfill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setPreview(await res.json()); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setPreviewing(false); } }; const handleConfirmBackfill = async () => { if (backfilling) return; setBackfilling(true); try { const body = { dry_run: false }; if (backfillScope) body.show_id = backfillScope; const res = await apiFetch('/admin/asr-corrections/backfill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // EQ2e F8: track the apply job so the operator sees progress + can cancel. if (data.task_id) { setActiveJob({ taskId: data.task_id, label: t ? '套用原有集數' : 'Apply to existing episodes', status: null }); } showToast( t ? `套用已在背景啟動(任務 ${data.task_id?.slice(0, 8) || ''})` : `Apply started in background (task ${data.task_id?.slice(0, 8) || ''})`, ); setPreview(null); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setBackfilling(false); } }; // ── EQ2e F6: detect over a show's existing episodes ── const handleDetectEstimate = async () => { if (detectEstimating || !detectScope) return; setDetectEstimating(true); setDetectEstimate(null); try { const res = await apiFetch('/admin/asr-corrections/detect-existing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ show_id: detectScope, dry_run: true }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setDetectEstimate(await res.json()); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setDetectEstimating(false); } }; const handleConfirmDetect = async () => { if (detectStarting || !detectScope) return; setDetectStarting(true); try { const res = await apiFetch('/admin/asr-corrections/detect-existing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ show_id: detectScope, dry_run: false }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (data.task_id) { setActiveJob({ taskId: data.task_id, label: t ? '偵測原有集數' : 'Detect existing episodes', status: null }); } showToast(t ? '偵測作業已在背景啟動' : 'Detection started in background'); setDetectEstimate(null); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setDetectStarting(false); } }; // ── EQ2e F8: poll the active job's status (current/total + failures) ── React.useEffect(() => { const taskId = activeJob?.taskId; if (!taskId) return; let cancelled = false; const poll = async () => { try { const res = await apiFetch(`/admin/asr-corrections/backfill-status/${taskId}`); if (!res.ok || cancelled) return; const s = await res.json(); if (cancelled) return; setActiveJob((j) => (j && j.taskId === taskId ? { ...j, status: s } : j)); if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(s.state)) { clearInterval(iv); reload(); // a finished detect job may have produced new pending candidates } } catch { /* keep polling through transient errors */ } }; poll(); const iv = setInterval(poll, 2000); return () => { cancelled = true; clearInterval(iv); }; }, [activeJob?.taskId, reload]); const handleCancelJob = async () => { const taskId = activeJob?.taskId; if (!taskId || cancelling) return; setCancelling(true); try { const res = await apiFetch(`/admin/asr-corrections/backfill-cancel/${taskId}`, { method: 'POST' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); showToast(t ? '已送出取消(已完成部分會保留)' : 'Cancel requested (completed work is kept)'); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setCancelling(false); } }; // ── EQ2e F8: batch restore existing episodes to original ASR text ── const handleBatchRestore = async () => { if (restoring) return; const scopeLabel = restoreScope ? showTitle(restoreScope) : (t ? '全部節目' : 'all shows'); if (!confirm( t ? `將「${scopeLabel}」所有曾被校正的既有集還原回原始 ASR 文字,確定?` : `Restore every previously-corrected episode in "${scopeLabel}" back to original ASR text. Continue?`, )) return; setRestoring(true); try { const body = {}; if (restoreScope) body.show_id = restoreScope; const res = await apiFetch('/admin/asr-corrections/batch-restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); showToast( t ? `已還原 ${data.affected_transcripts} 集(${data.affected_chunks} 個片段索引)` : `Restored ${data.affected_transcripts} episode(s) (${data.affected_chunks} chunk index(es))`, ); await reload(); } catch (err) { showToast(err.message || String(err), 'error'); } finally { setRestoring(false); } }; // EQ2b: pending LLM candidates are derived from the full list and shown in a // dedicated review section; the main rules table excludes them. const candidates = (rows || []).filter( (r) => r.source === 'llm' && r.status === 'pending', ); const ruleRows = (rows || []).filter( (r) => !(r.source === 'llm' && r.status === 'pending'), ); const totalCount = ruleRows.length; return (
{t ? 'ASR 校正字典:把 Whisper 聽錯的詞(例「咪有企」)對應到正確的詞(「滅火器」)。整詞精確比對。新轉錄會自動套用;既有逐字稿需按下方「批次回填」才會更新(連搜尋一起修)。' : 'ASR correction dictionary: map a mis-transcribed term (e.g. "咪有企") to the correct one ("滅火器"). Whole-term literal match. New transcripts apply it automatically; existing transcripts need the backfill below (which also fixes search).'}
{/* ── new rule form ── */} {/* match-count preview — over-broad rule guard */}{t ? 'LLM 在轉錄時偵測到的疑似同音錯字。核准後會跨集生效並進入校正字典;駁回則不再提醒。(候選不影響既有資料,需核准才生效。)' : 'Suspected homophone typos the LLM detected during transcription. Approve to activate across episodes and add to the dictionary; reject to dismiss. (Candidates do not affect existing data until approved.)'}
| {t ? '錯字 → 正字' : 'Wrong → Correct'} | {t ? '節目' : 'Show'} | |
|---|---|---|
| {c.wrong} →{' '} {/* EQ2c F3: correct is editable; admin can fix a near-miss before approving */} setEditedCorrect((m) => ({ ...m, [c.id]: e.target.value }))} disabled={reviewingId === c.id} style={{ width: 130, fontFamily: 'ui-monospace, monospace', fontSize: 14, background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '4px 8px', }} /> |
|
{/* EQ2e F-approve: also apply the rule to existing episodes on approve */}
|
{t ? '對一個節目「偵測功能上線前的舊集」逐集跑 LLM 同音字偵測,產生待審核候選。只產候選、不改任何逐字稿;會先估成本,你確認後才執行。' : 'Run LLM homophone detection over a show\'s older episodes to produce pending candidates. It only creates candidates (never changes transcripts); it estimates cost first and runs only after you confirm.'}
| {t ? '錯字 → 正字' : 'Wrong → Correct'} | {t ? '範圍' : 'Scope'} | {t ? '狀態' : 'Status'} | |
|---|---|---|---|
| {r.wrong} → {r.correct} {r.note ? {r.note} : null} |
{r.scope === 'global'
? |
|
|
{t ? '新增規則後,既有逐字稿不會自動更新。回填會修正既有逐字稿並重算受影響片段的搜尋索引。會先估算範圍與成本,你確認後才執行。' : 'Newly added rules do not update existing transcripts automatically. Backfill corrects existing transcripts and recomputes the search index for affected chunks. It previews scope + cost first; it runs only after you confirm.'}
{t ? '把曾被校正的既有集還原回原始 ASR 文字(含搜尋索引)。注意:會還原該範圍內「所有曾被校正的集」,不限定某一次套用作業。' : 'Revert previously-corrected episodes back to original ASR text (and search index). Note: this reverts ALL corrected episodes in scope, not just one apply run.'}