// 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 ── */}
setWrong(e.target.value)} placeholder={t ? '錯字,例:咪有企' : 'Wrong, e.g. 咪有企'} style={{ width: 180 }} /> setCorrect(e.target.value)} placeholder={t ? '正字,例:滅火器' : 'Correct, e.g. 滅火器'} style={{ width: 180 }} /> {scope === 'show' && ( )} setNote(e.target.value)} placeholder={t ? '備註(選填)' : 'Note (optional)'} style={{ width: 160 }} /> {adding ? (t ? '新增中…' : 'Adding…') : (t ? '新增' : 'Add')}
{/* match-count preview — over-broad rule guard */}
50 ? '#fbbf24' : TOKEN.textMuted }}> {matchCount !== null && ( t ? `目前命中 ${matchCount} 段逐字稿${matchCount > 50 ? '(範圍偏大,請確認不會誤傷)' : ''}` : `Currently matches ${matchCount} segment(s)${matchCount > 50 ? ' (broad — double-check for false hits)' : ''}` )}
{error &&
{error}
} {/* ── EQ2b: pending LLM candidate review ── */} {candidates.length > 0 && (
{t ? `待審核候選(${candidates.length})` : `Pending candidates (${candidates.length})`}

{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.)'}

{candidates.map((c) => ( ))}
{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', }} /> {showTitle(c.show_id)} {/* EQ2e F-approve: also apply the rule to existing episodes on approve */} handleReview(c, 'approve')}> {t ? '核准' : 'Approve'} handleReview(c, 'reject')}> {t ? '駁回' : 'Reject'}
)} {/* ── EQ2e F6: detect over a show's existing episodes ── */}
{t ? '偵測原有集數' : 'Detect over existing episodes'}

{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.'}

{detectEstimating ? (t ? '估算中…' : 'Estimating…') : (t ? '估算成本' : 'Estimate cost')}
{detectEstimate && (
{t ? `將偵測 ${detectEstimate.episode_count} 集、估 ${detectEstimate.estimated_input_tokens.toLocaleString()} input tokens,預估成本約 $${detectEstimate.estimated_cost_usd}。` : `Will detect ${detectEstimate.episode_count} episode(s), est. ${detectEstimate.estimated_input_tokens.toLocaleString()} input tokens, ~$${detectEstimate.estimated_cost_usd}.`}
{detectStarting ? (t ? '啟動中…' : 'Starting…') : (t ? '確認偵測' : 'Confirm & detect')} setDetectEstimate(null)}>{t ? '取消' : 'Cancel'}
{detectEstimate.episode_count === 0 && (
{t ? '這個節目沒有可偵測的逐字稿。' : 'This show has no transcripts to detect.'}
)}
)}
{/* ── EQ2e F8: active background job (progress / failures / cancel) ── */} {activeJob && (
{activeJob.label} {activeJob.status?.state && ( {activeJob.status.state} )}
{!['SUCCESS', 'FAILURE', 'REVOKED', 'UNKNOWN'].includes(activeJob.status?.state) && ( {cancelling ? (t ? '取消中…' : 'Cancelling…') : (t ? '取消作業' : 'Cancel')} )} setActiveJob(null)}> {t ? '關閉' : 'Dismiss'}
{(() => { const st = activeJob.status; const total = st?.total || 0; const current = st?.current || 0; const pct = total > 0 ? Math.round((current / total) * 100) : 0; return (
{st?.message || (t ? '查詢中…' : 'Polling…')} {total > 0 ? `(${current}/${total})` : ''}
{st?.failed_chunk_ids?.length > 0 && (
{t ? `失敗 ${st.failed_chunk_ids.length} 筆:` : `${st.failed_chunk_ids.length} failed: `} {st.failed_chunk_ids.slice(0, 10).join(', ')} {st.failed_chunk_ids.length > 10 ? '…' : ''}
)}
); })()}
)} {/* ── rules table ── */} {rows === null ? (
{t ? '載入中…' : 'Loading…'}
) : ruleRows.length === 0 ? (
{t ? '尚無規則' : 'No rules yet'}
) : (
{ruleRows.map((r) => ( ))}
{t ? '錯字 → 正字' : 'Wrong → Correct'} {t ? '範圍' : 'Scope'} {t ? '狀態' : 'Status'}
{r.wrong} {r.correct} {r.note ?  {r.note} : null} {r.scope === 'global' ? {t ? '全站' : 'Global'} : {showTitle(r.show_id)}} {r.enabled ? (t ? '啟用' : 'Enabled') : (t ? '停用' : 'Disabled')} handleToggle(r)}> {r.enabled ? (t ? '停用' : 'Disable') : (t ? '啟用' : 'Enable')} handleDelete(r)}> {t ? '刪除' : 'Delete'}
)} {/* ── backfill ── */}
{t ? '批次回填既有逐字稿' : 'Backfill existing transcripts'}

{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.'}

{previewing ? (t ? '估算中…' : 'Estimating…') : (t ? '估算範圍' : 'Preview impact')}
{preview && (
{t ? `將更新 ${preview.affected_segments} 段、重算 ${preview.affected_chunks} 個片段索引,預估成本約 $${preview.estimated_cost_usd}(embedding)。` : `Will update ${preview.affected_segments} segment(s), recompute ${preview.affected_chunks} chunk index(es), est. ~$${preview.estimated_cost_usd} (embedding).`}
{backfilling ? (t ? '執行中…' : 'Running…') : (t ? '確認執行' : 'Confirm & run')} setPreview(null)}>{t ? '取消' : 'Cancel'}
{preview.affected_chunks === 0 && (
{t ? '沒有需要更新的內容。' : 'Nothing to update.'}
)}
)}
{/* ── EQ2e F8: batch restore existing episodes ── */}
{t ? '批次還原既有集' : 'Batch restore existing episodes'}

{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.'}

{restoring ? (t ? '還原中…' : 'Restoring…') : (t ? '批次還原' : 'Batch restore')}
{t ? `共 ${totalCount} 條規則` : `${totalCount} rule(s) total`}
{toast && (
{toast.msg}
)}
); };