"use strict"; const state = { candidates: [], filtered: [], reviews: {}, currentId: null, currentDetail: null, saving: false, }; const elements = Object.fromEntries( [ "progressText", "progressMeta", "progressBar", "sessionFilter", "splitFilter", "familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle", "queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge", "autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition", "llmSuggestion", "llmSuggestionText", "eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel", "transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError", "saveButton", "uncertainButton", "saveState", "toast", ].map((id) => [id, document.getElementById(id)]) ); const familyNames = { eot: "EOT-RESPONSE", int: "INT-YIELD", onset: "WEARER ONSET", offset: "LEGACY EOT PROXY", interaction: "LEGACY INTERACTION", }; function escapeText(value) { return String(value ?? ""); } function formatTime(value) { if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; const seconds = Number(value); const minutes = Math.floor(seconds / 60); return `${String(minutes).padStart(2, "0")}:${(seconds % 60).toFixed(1).padStart(4, "0")}`; } function toast(message) { elements.toast.textContent = message; elements.toast.classList.add("visible"); window.clearTimeout(toast.timeout); toast.timeout = window.setTimeout(() => elements.toast.classList.remove("visible"), 1800); } async function request(url, options) { const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `请求失败 (${response.status})`); return payload; } function updateProgress() { const total = state.candidates.length; const reviewed = Object.keys(state.reviews).length; const percent = total ? (reviewed / total) * 100 : 0; elements.progressText.textContent = `${reviewed} / ${total} 已审核`; elements.progressMeta.textContent = `${percent.toFixed(1)}%`; elements.progressBar.style.width = `${percent}%`; } function populateSessions() { const sessions = [...new Set(state.candidates.map((item) => item.session))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }) ); for (const session of sessions) { const option = document.createElement("option"); option.value = session; option.textContent = session; elements.sessionFilter.append(option); } } function applyFilters({ keepCurrent = true } = {}) { const search = elements.searchFilter.value.trim().toLowerCase(); state.filtered = state.candidates.filter((item) => { const reviewed = Boolean(state.reviews[item.candidate_id]); const status = elements.statusFilter.value; return ( (elements.sessionFilter.value === "all" || item.session === elements.sessionFilter.value) && (elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) && (elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) && (status === "all" || (status === "reviewed" ? reviewed : !reviewed)) && (!search || `${item.uid} ${item.automatic_label} ${item.automatic_protocol_label || ""} ${item.automatic_hard_negative_type || ""} ${item.session}`.toLowerCase().includes(search)) ); }); renderQueue(); if (!keepCurrent || !state.filtered.some((item) => item.candidate_id === state.currentId)) { selectCandidate(state.filtered[0]?.candidate_id || null); } else { updatePosition(); } } function renderQueue() { elements.queueList.replaceChildren(); elements.queueCount.textContent = String(state.filtered.length); elements.queueTitle.textContent = elements.statusFilter.value === "unreviewed" ? "待审核候选" : "候选事件"; const limit = 250; for (const item of state.filtered.slice(0, limit)) { const button = document.createElement("button"); button.type = "button"; button.className = `queue-item${item.candidate_id === state.currentId ? " active" : ""}${state.reviews[item.candidate_id] ? " reviewed" : ""}`; button.dataset.id = item.candidate_id; const line = document.createElement("div"); line.className = "queue-line"; const dot = document.createElement("span"); dot.className = "queue-status"; const title = document.createElement("strong"); title.textContent = item.uid; line.append(dot, title); const meta = document.createElement("div"); meta.className = "queue-meta"; meta.textContent = `${item.session} · ${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`; const label = document.createElement("div"); label.className = "queue-label"; label.textContent = item.automatic_protocol_label ? `${item.automatic_protocol_label} · ${item.automatic_hard_negative_type || item.automatic_label}` : item.automatic_label; button.append(line, meta, label); button.addEventListener("click", () => selectCandidate(item.candidate_id)); elements.queueList.append(button); } elements.queueLimit.hidden = state.filtered.length <= limit; elements.queueLimit.textContent = state.filtered.length > limit ? `列表显示前 ${limit} 条;可用筛选缩小范围。` : ""; } function updatePosition() { const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId); elements.eventPosition.textContent = index < 0 ? `— / ${state.filtered.length}` : `${index + 1} / ${state.filtered.length}`; } async function selectCandidate(candidateId) { state.currentId = candidateId; renderQueue(); updatePosition(); if (!candidateId) { elements.eventTitle.textContent = "当前筛选下没有候选"; elements.eventSubtitle.textContent = "调整筛选条件后继续。"; elements.eventVideo.removeAttribute("src"); elements.eventVideo.load(); elements.mediaEmpty.hidden = false; elements.llmSuggestion.hidden = true; elements.reviewForm.reset(); return; } elements.eventTitle.textContent = "载入事件…"; elements.saveButton.disabled = true; try { const detail = await request(`/api/event?id=${encodeURIComponent(candidateId)}`); if (state.currentId !== candidateId) return; state.currentDetail = detail; renderDetail(detail); restoreForm(detail.review); } catch (error) { elements.eventTitle.textContent = "事件载入失败"; elements.eventSubtitle.textContent = error.message; } finally { elements.saveButton.disabled = false; } } function renderDetail(detail) { elements.familyBadge.textContent = familyNames[detail.family] || detail.family; elements.splitBadge.textContent = `${detail.split} · ${detail.session}`; elements.autoConfidenceBadge.textContent = detail.automatic_confidence ? `auto ${detail.automatic_confidence}` : "auto confidence —"; elements.eventTitle.textContent = detail.automatic_label; elements.eventSubtitle.textContent = `${detail.uid} · ${detail.source_file}:${detail.source_row}`; elements.clipLabel.textContent = detail.clip_stem ? `${detail.session} / ${detail.clip_stem}` : detail.session; elements.rawCandidate.textContent = JSON.stringify(detail.candidate, null, 2); elements.saveState.textContent = detail.review ? "已保存" : "未保存"; elements.saveState.classList.toggle("saved", Boolean(detail.review)); const suggestion = detail.llm_suggestion; elements.llmSuggestion.hidden = !suggestion; if (suggestion) { const disposition = suggestion.protocol_disposition || suggestion.final_protocol_disposition || "uncertain"; const subtype = suggestion.hard_negative_type || suggestion.final_hard_negative_type || "uncertain"; const confidence = suggestion.confidence || suggestion.final_confidence || "—"; const rationale = suggestion.rationale || suggestion.final_rationale || ""; elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`; } elements.mediaEmpty.hidden = detail.media_available; if (detail.media_available) { elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`; elements.eventVideo.load(); } else { elements.eventVideo.removeAttribute("src"); elements.eventVideo.load(); } elements.transcriptList.replaceChildren(); if (!detail.transcript.length) { const empty = document.createElement("div"); empty.className = "empty-copy"; empty.textContent = "当前上下文未找到 transcript。"; elements.transcriptList.append(empty); } else { for (const row of detail.transcript) { const item = document.createElement("div"); item.className = `transcript-row${row.is_anchor ? " anchor" : ""}`; const speaker = document.createElement("div"); speaker.className = "speaker"; speaker.textContent = `P${row.participant_id ?? "?"}`; const time = document.createElement("small"); time.textContent = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)}`; speaker.append(time); const utterance = document.createElement("div"); utterance.className = "utterance"; utterance.textContent = escapeText(row.transcription); const target = document.createElement("small"); target.textContent = `Target: ${typeof row.target_of_speech === "string" ? row.target_of_speech : JSON.stringify(row.target_of_speech ?? "unknown")}`; utterance.append(target); item.append(speaker, utterance); elements.transcriptList.append(item); } } updateTimeReadout(); } function restoreForm(review) { elements.reviewForm.reset(); elements.formError.hidden = true; const reviewer = review?.reviewer || localStorage.getItem("floor-reviewer") || ""; elements.reviewerInput.value = reviewer; if (!review) { elements.reviewForm.elements.anchor_adjustment_bins.value = "0"; elements.reviewForm.elements.behavior_adjustment_bins.value = "0"; elements.reviewForm.elements.region_end_adjustment_bins.value = "0"; elements.reviewForm.elements.confidence.value = "medium"; return; } for (const [name, value] of Object.entries(review)) { const control = elements.reviewForm.elements[name]; if (!control) continue; if (control instanceof RadioNodeList) control.value = String(value); else control.value = value === null ? "" : String(value); } } function formPayload() { const data = new FormData(elements.reviewForm); const adjustment = (name) => { const value = data.get(name); return value === "" ? null : Number(value); }; return { protocol_disposition: data.get("protocol_disposition"), hard_negative_type: data.get("hard_negative_type"), validity: data.get("validity"), onset_function: data.get("onset_function"), context: data.get("context"), offset_outcome: data.get("offset_outcome"), anchor_adjustment_bins: adjustment("anchor_adjustment_bins"), behavior_adjustment_bins: adjustment("behavior_adjustment_bins"), region_end_adjustment_bins: adjustment("region_end_adjustment_bins"), confidence: data.get("confidence"), reviewer: String(data.get("reviewer") || "").trim(), notes: String(data.get("notes") || "").trim(), }; } function validateReview(review) { const missing = []; if (!review.protocol_disposition) missing.push("协议结论"); if (!review.hard_negative_type) missing.push("hard-negative 类型"); if (!review.validity) missing.push("活动边界"); if (!review.onset_function) missing.push("onset 功能"); if (!review.context) missing.push("对话场景"); if (!review.offset_outcome) missing.push("offset 结果"); if (!review.reviewer) missing.push("审核者"); return missing; } async function saveCurrent({ advance = true } = {}) { if (!state.currentId || state.saving) return; const review = formPayload(); const missing = validateReview(review); if (missing.length) { elements.formError.textContent = `请完成:${missing.join("、")}`; elements.formError.hidden = false; return; } elements.formError.hidden = true; state.saving = true; elements.saveButton.disabled = true; elements.saveState.textContent = "保存中…"; try { await request("/api/review", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ candidate_id: state.currentId, review }), }); state.reviews[state.currentId] = review; localStorage.setItem("floor-reviewer", review.reviewer); elements.saveState.textContent = "已保存"; elements.saveState.classList.add("saved"); updateProgress(); toast("审核结果已保存"); if (advance) { const previousCandidates = [...state.filtered]; const index = previousCandidates.findIndex((item) => item.candidate_id === state.currentId); applyFilters(); const next = state.filtered[index] || state.filtered[index - 1] || state.filtered[0]; if (elements.statusFilter.value === "all") { const sequential = previousCandidates[index + 1] || previousCandidates[index]; selectCandidate(sequential?.candidate_id || null); } else { selectCandidate(next?.candidate_id || null); } } else { renderQueue(); } } catch (error) { elements.formError.textContent = error.message; elements.formError.hidden = false; elements.saveState.textContent = "保存失败"; } finally { state.saving = false; elements.saveButton.disabled = false; } } function moveCandidate(delta) { if (!state.filtered.length) return; const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId); const nextIndex = Math.max(0, Math.min(state.filtered.length - 1, (index < 0 ? 0 : index) + delta)); selectCandidate(state.filtered[nextIndex].candidate_id); } function seek(delta) { const video = elements.eventVideo; if (!Number.isFinite(video.duration)) return; video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + delta)); } function jumpToEvent() { const value = state.currentDetail?.event_local_seconds; if (value !== null && value !== undefined) elements.eventVideo.currentTime = Number(value); } function updateTimeReadout() { const eventTime = state.currentDetail?.event_local_seconds; const current = Number.isFinite(elements.eventVideo.currentTime) ? elements.eventVideo.currentTime : 0; const delta = eventTime === null || eventTime === undefined ? null : current - Number(eventTime); elements.timeReadout.textContent = delta === null ? formatTime(current) : `${formatTime(current)} · event ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} s`; } function isTypingTarget(target) { return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement; } function bindEvents() { for (const control of [elements.sessionFilter, elements.splitFilter, elements.familyFilter, elements.statusFilter]) { control.addEventListener("change", () => applyFilters({ keepCurrent: false })); } elements.searchFilter.addEventListener("input", () => applyFilters({ keepCurrent: false })); elements.clearFilters.addEventListener("click", () => { elements.sessionFilter.value = "all"; elements.splitFilter.value = "all"; elements.familyFilter.value = "all"; elements.statusFilter.value = "unreviewed"; elements.searchFilter.value = ""; applyFilters({ keepCurrent: false }); }); elements.reviewForm.addEventListener("submit", (event) => { event.preventDefault(); saveCurrent(); }); elements.uncertainButton.addEventListener("click", () => { elements.reviewForm.elements.protocol_disposition.value = "uncertain"; elements.reviewForm.elements.hard_negative_type.value = "uncertain"; elements.reviewForm.elements.validity.value = "uncertain"; elements.reviewForm.elements.onset_function.value = "uncertain"; elements.reviewForm.elements.context.value = "unknown"; elements.reviewForm.elements.offset_outcome.value = "uncertain"; elements.reviewForm.elements.confidence.value = "low"; }); elements.jumpEvent.addEventListener("click", jumpToEvent); document.querySelectorAll("[data-seek]").forEach((button) => button.addEventListener("click", () => seek(Number(button.dataset.seek))) ); elements.eventVideo.addEventListener("loadedmetadata", () => { const start = state.currentDetail?.context_start_seconds; if (start !== null && start !== undefined) elements.eventVideo.currentTime = Number(start); }); elements.eventVideo.addEventListener("timeupdate", updateTimeReadout); elements.eventVideo.addEventListener("error", () => { elements.mediaEmpty.hidden = false; }); document.addEventListener("keydown", (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); saveCurrent(); return; } if (isTypingTarget(event.target)) return; if (event.key === "ArrowLeft") { event.preventDefault(); moveCandidate(-1); } else if (event.key === "ArrowRight") { event.preventDefault(); moveCandidate(1); } else if (event.key === "[") { event.preventDefault(); seek(-0.2); } else if (event.key === "]") { event.preventDefault(); seek(0.2); } else if (event.code === "Space") { event.preventDefault(); elements.eventVideo.paused ? elements.eventVideo.play() : elements.eventVideo.pause(); } }); } async function initialize() { bindEvents(); try { const payload = await request("/api/bootstrap"); state.candidates = payload.candidates; state.reviews = payload.reviews || {}; populateSessions(); updateProgress(); applyFilters({ keepCurrent: false }); } catch (error) { elements.progressText.textContent = "载入失败"; elements.progressMeta.textContent = error.message; elements.eventTitle.textContent = "无法载入候选事件"; elements.eventSubtitle.textContent = error.message; } } initialize();