Files
icassp2027-experiment/floor_event_review_web/app.js
T

746 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const state = {
candidates: [],
filtered: [],
reviews: {},
currentId: null,
currentDetail: null,
saving: false,
sessionSpeech: {},
timelineSession: null,
timelineZoom: 8,
timelineCenter: null,
};
const elements = Object.fromEntries(
[
"progressText", "progressMeta", "progressBar", "sessionFilter", "splitFilter",
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
"eventTitle", "eventSubtitle", "eventPosition", "reviewQuestion",
"llmSuggestion", "aiConfidence", "aiVerdict", "aiLabel", "aiMeaning", "aiRationale",
"acceptRadio", "acceptTitle", "acceptSummary", "sessionTimeline", "speakerTimeline", "speakerEvidence",
"timelineZoomLabel", "timelineRange", "timelinePanLeft", "timelineZoomOut",
"timelineFocus", "timelineZoomIn", "timelinePanRight", "timelineReset",
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "formError",
"saveButton", "saveState", "toast", "relabelFields",
"excludeFields", "eventTypeField", "opportunityField", "opportunitySelect", "subtypeField",
].map((id) => [id, document.getElementById(id)])
);
const familyNames = {
entry: "WEARER-ENTRY",
int: "INT-YIELD",
onset: "WEARER ONSET",
offset: "LEGACY EOT PROXY",
interaction: "LEGACY INTERACTION",
};
const reviewQuestions = {
entry: "这次 floor-level wearer onset 是否是真正进入或重新进入 floor?",
int: "Other 是否在 wearer 发言中接管,使 wearer 让出 floor?",
onset: "这次 wearer onset 是否代表新的 floor entry?",
offset: "这次 wearer offset 是否代表让出 floor?",
interaction: "这次说话人变化是否属于目标 floor event?",
};
const decisionMeanings = {
WEARER_ENTRY: "Wearer 真正进入或重新进入 floor;不要求此前 other 已释放。",
INT_YIELD: "Other 在 wearer 发言中接管,wearer 随后让出 floor。",
ENTRY_OTHER_MIDTURN_PAUSE: "同一个 other 只是在自己的 turn 内暂停后继续;wearer 没有进入 floor。",
ENTRY_OTHER_TO_OTHER: "一个 other 结束后由另一个 other 接话;wearer 没有进入 floor。",
ENTRY_WEARER_BACKCHANNEL: "Wearer 只有附和或反馈,没有形成真实 floor entry;other 保持或恢复 floor。",
INT_OTHER_BACKCHANNEL: "Other 只是反馈、笑声或附和,且 wearer 明确继续或恢复原 turn。若 wearer 正常说完,应 Exclude。",
INT_NON_TAKEOVER_OVERLAP: "Other 不是单纯 backchannel,但重叠仍未形成持续接管。",
INT_WEARER_HOLDS_FLOOR: "Other 不是单纯 backchannel,且 wearer 在重叠后明确继续或恢复原 turn。",
OTHER_HARD_NEGATIVE: "这是可解释的困难负例,但不符合任何命名子类型;不能用于替代 Exclude。",
EXCLUDE: "该区域是无需参评的非目标事件,或存在证据/数据问题,不进入评分。",
OUT_OF_SCOPE_NORMAL_COMPLETION: "有效语境中的自然结束,不是 interruption yield。",
OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET: "该 wearer 发声只是 backchannel;其 onset 已作为 ENTRY 困难负例审查,尾部 offset 不再重复计为 INT。",
OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION: "这是同一 wearer turn 内的暂停或继续,不是新的 floor event。",
OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY: "Other 在 wearer 已结束后才开始,不能构成导致停止的 takeover。",
OUT_OF_SCOPE_NON_EVENT: "数据有效且可解释,但该边界不属于目标行为。",
INSUFFICIENT_EVIDENCE: "数据未显示明确损坏,但现有证据不足以可靠确定 conversational function。",
DATA_ANNOTATION_ERROR: "参与者、语音、转写或 target 标注明显错误或互相矛盾。",
DATA_RECORDING_BOUNDARY: "录制或 clip 边界使该事件无法可靠解释。",
DATA_MISSING_CONTEXT: "判断所需上下文未被提供。",
DATA_NON_SPEECH_ARTIFACT: "候选由非语音伪影造成。",
DATA_TIMESTAMP_CORRUPTION: "时间戳损坏或无法对齐。",
};
const currentAiVerdicts = new Set(["POSITIVE", "HARD_NEGATIVE", "EXCLUDE"]);
function suggestionView(suggestion) {
const verdict = suggestion.verdict || suggestion.protocol_disposition || "—";
const label = suggestion.event_type || suggestion.subtype || suggestion.exclude_reason || suggestion.hard_negative_type || verdict;
const rawConfidence = suggestion.confidence ?? suggestion.final_confidence;
const numericConfidence = Number(rawConfidence);
const confidence = Number.isFinite(numericConfidence)
? `${Math.round(numericConfidence * 100)}%`
: escapeText(rawConfidence || "—");
const rawRationale = suggestion.reason || suggestion.rationale || "未提供文字依据。";
const rationale = [...new Set(String(rawRationale).split(" | ").map((item) => item.trim()).filter(Boolean))].join(" · ");
return {
verdict,
label,
confidence,
rationale,
meaning: decisionMeanings[label] || decisionMeanings[verdict] || "请结合时间轴与转写核对该标签。",
};
}
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 participantCode(value) {
const raw = String(value ?? "?");
return /^(p|person|wearer)/i.test(raw) ? raw : `P${raw}`;
}
function participantIdentity(row, verbose = false) {
const raw = String(row.participant_id ?? "?");
const participant = participantCode(raw);
if (row.participant_role === "wearer") {
return verbose ? `W · ${participant} · wearer` : `W · ${participant}`;
}
return verbose ? `${participant} · other` : participant;
}
function matchingElements(selector, uid) {
return [...document.querySelectorAll(selector)].filter((element) => element.dataset.uid === uid);
}
function showSpeakerEvidence(row, activeSegment = null, revealTranscript = false) {
document.querySelectorAll(".speech-segment.active").forEach((element) => element.classList.remove("active"));
document.querySelectorAll(".transcript-row.linked").forEach((element) => element.classList.remove("linked"));
if (activeSegment) activeSegment.classList.add("active");
elements.speakerEvidence.replaceChildren();
const identity = document.createElement("strong");
identity.textContent = participantIdentity(row, true);
const time = document.createElement("span");
time.textContent = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)}`;
const transcription = document.createElement("p");
transcription.textContent = row.transcription || "(该 activity 没有转写文本)";
elements.speakerEvidence.append(identity, time, transcription);
if (!row.uid) return;
const transcriptRows = matchingElements(".transcript-row[data-uid]", row.uid);
for (const transcriptRow of transcriptRows) transcriptRow.classList.add("linked");
if (revealTranscript && transcriptRows[0]) {
transcriptRows[0].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
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.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${item.ai_exclude_reason || ""} ${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 = `${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`;
const label = document.createElement("div");
label.className = "queue-label";
label.textContent = item.ai_verdict
? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || item.ai_exclude_reason || "—"}`
: `${item.rule_candidate_type || item.automatic_label} · 待 AI 粗标`;
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.reviewQuestion.textContent = "—";
elements.acceptRadio.disabled = true;
elements.acceptTitle.textContent = "没有 AI 结论";
elements.acceptSummary.textContent = "请选择 Relabel 完成人工标注";
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.eventTitle.textContent = detail.candidate_source || detail.automatic_label;
elements.eventSubtitle.textContent = detail.uid;
elements.clipLabel.textContent = detail.clip_stem ? `${detail.session} / ${detail.clip_stem}` : detail.session;
elements.rawCandidate.textContent = JSON.stringify(detail.candidate, null, 2);
const opportunityOptions = detail.candidate?.temporal_evidence?.opportunity_options || [];
elements.opportunitySelect.replaceChildren();
const emptyOption = document.createElement("option");
emptyOption.value = "";
emptyOption.textContent = "无/不确定(可选)";
elements.opportunitySelect.append(emptyOption);
for (const option of opportunityOptions) {
const control = document.createElement("option");
control.value = option.uid;
control.textContent = `${formatTime(option.anchor_seconds)} · ${participantCode(option.participant_id)} · ${option.transcription || "无转写"}`;
elements.opportunitySelect.append(control);
}
elements.saveState.textContent = detail.review ? "已保存" : "未保存";
elements.saveState.classList.toggle("saved", Boolean(detail.review));
elements.reviewQuestion.textContent = reviewQuestions[detail.family] || "核对该候选是否属于目标 floor event。";
const suggestion = detail.llm_suggestion;
elements.llmSuggestion.hidden = !suggestion;
if (suggestion) {
const view = suggestionView(suggestion);
elements.llmSuggestion.dataset.verdict = view.verdict;
elements.aiConfidence.textContent = `置信度 ${view.confidence}`;
elements.aiVerdict.textContent = view.verdict.replaceAll("_", " ");
elements.aiLabel.textContent = view.label;
elements.aiMeaning.textContent = view.meaning;
elements.aiRationale.textContent = view.rationale;
const familyEvent = detail.family === "entry" ? "WEARER_ENTRY" : detail.family === "int" ? "INT_YIELD" : null;
const familyPrefix = detail.family === "entry" ? "ENTRY_" : detail.family === "int" ? "INT_" : null;
const canAccept = currentAiVerdicts.has(view.verdict)
&& (view.verdict !== "POSITIVE" || !familyEvent || suggestion.event_type === familyEvent)
&& (view.verdict !== "HARD_NEGATIVE" || !familyPrefix || suggestion.subtype === "OTHER_HARD_NEGATIVE" || String(suggestion.subtype || "").startsWith(familyPrefix))
&& (view.verdict !== "EXCLUDE" || Boolean(suggestion.exclude_reason));
elements.acceptRadio.disabled = !canAccept;
elements.acceptTitle.textContent = canAccept
? `接受 ${view.verdict.replaceAll("_", " ")}`
: `旧标签 ${view.verdict.replaceAll("_", " ")}`;
const selected = suggestion.selected_opportunity_uid;
const selectedOption = opportunityOptions.find((option) => option.uid === selected);
const anchorText = selectedOption ? ` · trigger ${formatTime(selectedOption.anchor_seconds)} P${selectedOption.participant_id}` : "";
elements.acceptSummary.textContent = canAccept
? `${view.label}${anchorText} · ${view.meaning}`
: (view.verdict === "EXCLUDE"
? "旧 AI EXCLUDE 没有 reason taxonomy,请选择 Exclude 并补充原因。"
: "该标签已移除,请改标为正例/硬负例,或选择 Exclude。");
} else {
elements.acceptRadio.disabled = true;
elements.acceptTitle.textContent = "没有 AI 结论";
elements.acceptSummary.textContent = "请选择 Relabel 完成人工标注";
}
if (state.timelineSession !== detail.session) {
state.timelineSession = detail.session;
state.timelineZoom = 8;
}
state.timelineCenter = Number(detail.event_time_seconds);
renderSessionTimeline(detail);
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" : ""}`;
if (row.uid) item.dataset.uid = row.uid;
const speaker = document.createElement("div");
speaker.className = "speaker";
speaker.textContent = participantIdentity(row, true);
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);
if (row.uid) {
item.tabIndex = 0;
item.title = "定位到对应的 local speaker activity";
const activate = () => {
const segment = matchingElements(".speech-segment[data-uid]", row.uid)[0] || null;
showSpeakerEvidence(row, segment, false);
};
item.addEventListener("mouseenter", activate);
item.addEventListener("focus", activate);
item.addEventListener("click", activate);
}
elements.transcriptList.append(item);
}
}
renderSpeakerTimeline(detail);
updateTimeReadout();
}
async function renderSessionTimeline(detail) {
elements.sessionTimeline.replaceChildren();
if (!state.sessionSpeech[detail.session]) {
try {
const payload = await request(`/api/session-timeline?session=${encodeURIComponent(detail.session)}`);
state.sessionSpeech[detail.session] = payload.speech || [];
} catch (_) {
state.sessionSpeech[detail.session] = [];
}
}
if (state.currentId !== detail.candidate_id) return;
const events = state.candidates.filter((item) => item.session === detail.session);
const speech = state.sessionSpeech[detail.session];
const sessionEnd = Math.max(1,
...events.map((item) => Number(item.event_time_seconds) || 0),
...speech.map((row) => Number(row.end_seconds) || 0));
const visibleDuration = sessionEnd / state.timelineZoom;
const center = Number.isFinite(state.timelineCenter) ? state.timelineCenter : sessionEnd / 2;
const viewStart = Math.max(0, Math.min(sessionEnd - visibleDuration, center - visibleDuration / 2));
const viewEnd = viewStart + visibleDuration;
elements.timelineZoomLabel.textContent = `${state.timelineZoom}×`;
elements.timelineRange.textContent = `${formatTime(viewStart)} – ${formatTime(viewEnd)} / ${formatTime(sessionEnd)}`;
const position = (seconds) => 100 * (seconds - viewStart) / visibleDuration;
const speakers = [...new Set(speech.map((row) => participantIdentity(row)))];
for (const speakerId of speakers) {
const lane = document.createElement("div"); lane.className = "session-lane";
const label = document.createElement("strong"); label.textContent = speakerId;
const track = document.createElement("div"); track.className = "session-track";
for (const row of speech.filter((row) =>
participantIdentity(row) === speakerId &&
Number(row.end_seconds) > viewStart && Number(row.start_seconds) < viewEnd)) {
const segment = document.createElement("span");
segment.className = `session-speech${row.participant_role === "wearer" ? " wearer" : ""}`;
const start = Math.max(viewStart, Number(row.start_seconds));
const end = Math.min(viewEnd, Number(row.end_seconds));
segment.style.left = `${position(start)}%`;
segment.style.width = `${Math.max(0.15, 100 * (end - start) / visibleDuration)}%`;
track.append(segment);
}
lane.append(label, track); elements.sessionTimeline.append(lane);
}
const eventTrack = document.createElement("div"); eventTrack.className = "session-events";
for (const item of events.filter((item) => {
const start = Number(item.region_start_seconds ?? item.event_time_seconds);
const end = Number(item.region_end_seconds ?? item.event_time_seconds);
return end >= viewStart && start <= viewEnd;
})) {
const marker = document.createElement("button");
marker.type = "button";
marker.className = `session-marker ${item.family}${item.candidate_id === detail.candidate_id ? " current" : ""}`;
const eventTime = Number(item.event_time_seconds) || 0;
const regionStart = Math.max(viewStart, Number(item.region_start_seconds ?? eventTime));
const regionEnd = Math.min(viewEnd, Number(item.region_end_seconds ?? eventTime));
marker.style.left = `${Math.max(0, Math.min(100, position(regionStart)))}%`;
marker.style.width = `${Math.max(0.35, 100 * Math.max(0, regionEnd - regionStart) / visibleDuration)}%`;
marker.title = `${formatTime(item.event_time_seconds)} · ${item.candidate_source || item.automatic_label} · ${item.ai_verdict || "未粗标"}`;
marker.addEventListener("click", () => selectCandidate(item.candidate_id));
eventTrack.append(marker);
}
elements.sessionTimeline.append(eventTrack);
}
function changeTimelineZoom(multiplier) {
state.timelineZoom = Math.max(1, Math.min(64, state.timelineZoom * multiplier));
if (state.currentDetail) renderSessionTimeline(state.currentDetail);
}
function panTimeline(direction) {
if (!state.currentDetail) return;
const speech = state.sessionSpeech[state.currentDetail.session] || [];
const events = state.candidates.filter((item) => item.session === state.currentDetail.session);
const sessionEnd = Math.max(1, ...speech.map((row) => Number(row.end_seconds) || 0),
...events.map((item) => Number(item.event_time_seconds) || 0));
state.timelineCenter = (state.timelineCenter ?? sessionEnd / 2) + direction * sessionEnd / state.timelineZoom * 0.7;
renderSessionTimeline(state.currentDetail);
}
function focusTimeline() {
if (!state.currentDetail) return;
state.timelineCenter = Number(state.currentDetail.event_time_seconds);
if (state.timelineZoom === 1) state.timelineZoom = 8;
renderSessionTimeline(state.currentDetail);
}
function renderSpeakerTimeline(detail) {
elements.speakerTimeline.replaceChildren();
const rows = detail.candidate.speaker_timeline || detail.candidate.context || [];
if (!rows.length) {
elements.speakerTimeline.textContent = "没有 participant activity evidence。";
return;
}
const eventTime = Number(detail.candidate.event_time ?? detail.candidate.anchor_dense_seconds);
const opportunityOptions = detail.candidate?.temporal_evidence?.opportunity_options || [];
const left = Math.min(...rows.map((row) => Number(row.start_seconds)), eventTime - 3);
const right = Math.max(...rows.map((row) => Number(row.end_seconds)), eventTime + 3);
const span = Math.max(0.001, right - left);
const speakers = [...new Set(rows.map((row) => participantIdentity(row)))];
for (const speakerId of speakers) {
const lane = document.createElement("div");
lane.className = "speaker-lane";
const label = document.createElement("strong"); label.textContent = speakerId;
const track = document.createElement("div"); track.className = "speaker-track";
for (const row of rows.filter((row) => participantIdentity(row) === speakerId)) {
const segment = document.createElement("button");
segment.type = "button";
segment.className = `speech-segment${row.participant_role === "wearer" ? " wearer" : ""}`;
segment.style.left = `${100 * (Number(row.start_seconds) - left) / span}%`;
segment.style.width = `${Math.max(0.8, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / span)}%`;
if (row.uid) segment.dataset.uid = row.uid;
segment.title = `${participantIdentity(row, true)} · ${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)} · ${row.transcription || "无转写"}`;
segment.setAttribute("aria-label", segment.title);
segment.addEventListener("mouseenter", () => showSpeakerEvidence(row, segment, false));
segment.addEventListener("focus", () => showSpeakerEvidence(row, segment, false));
segment.addEventListener("click", () => showSpeakerEvidence(row, segment, true));
track.append(segment);
}
for (const option of opportunityOptions) {
const opportunity = document.createElement("i");
opportunity.className = "opportunity-anchor";
opportunity.style.left = `${100 * (Number(option.anchor_seconds) - left) / span}%`;
opportunity.title = `可选 release · ${formatTime(option.anchor_seconds)} · ${participantCode(option.participant_id)}`;
track.append(opportunity);
}
const anchor = document.createElement("i");
anchor.className = "behavior-anchor";
anchor.style.left = `${100 * (eventTime - left) / span}%`;
anchor.title = `wearer behavior · ${formatTime(eventTime)}`;
track.append(anchor);
lane.append(label, track); elements.speakerTimeline.append(lane);
}
const defaultRow = rows.find((row) =>
Number(row.start_seconds) <= eventTime && Number(row.end_seconds) >= eventTime
) || rows[0];
const defaultSegment = defaultRow?.uid
? matchingElements(".speech-segment[data-uid]", defaultRow.uid)[0] || null
: null;
if (defaultRow) showSpeakerEvidence(defaultRow, defaultSegment, false);
}
function restoreForm(review) {
elements.reviewForm.reset();
elements.formError.hidden = true;
if (!review) {
elements.reviewForm.elements.confidence.value = "medium";
updateConditionalFields();
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);
}
updateConditionalFields();
}
function formPayload() {
const data = new FormData(elements.reviewForm);
const nullable = (name) => data.get(name) || null;
const correction = data.get("boundary_correction_seconds");
return {
human_action: data.get("human_action"),
human_verdict: nullable("human_verdict"),
human_event_type: nullable("human_event_type"),
human_subtype: nullable("human_subtype"),
selected_opportunity_uid: nullable("selected_opportunity_uid"),
exclude_reason: nullable("exclude_reason"),
boundary_correction_seconds: correction === "" ? null : Number(correction),
confidence: data.get("confidence"),
notes: String(data.get("notes") || "").trim(),
};
}
function validateReview(review) {
const missing = [];
if (!review.human_action) missing.push("主操作");
if (review.human_action === "relabel" && !review.human_verdict) missing.push("一级 verdict");
if (review.human_action === "relabel" && review.human_verdict === "POSITIVE" && !review.human_event_type) missing.push("positive event");
const options = state.currentDetail?.candidate?.temporal_evidence?.opportunity_options || [];
if (review.human_action === "relabel" && review.human_verdict === "HARD_NEGATIVE" && !review.human_subtype) missing.push("hard-negative subtype");
if (review.human_action === "exclude" && !review.exclude_reason) missing.push("排除原因");
if (review.human_action === "accept" && !state.currentDetail?.candidate?.ai_verdict && !state.currentDetail?.llm_suggestion?.verdict) missing.push("AI 预标注(尚未运行,需 Relabel)");
return missing;
}
function updateConditionalFields() {
const action = elements.reviewForm.elements.human_action.value;
const verdict = elements.reviewForm.elements.human_verdict.value;
const eventType = elements.reviewForm.elements.human_event_type.value;
const hasOpportunityOptions = Boolean(state.currentDetail?.candidate?.temporal_evidence?.opportunity_options?.length);
elements.relabelFields.hidden = action !== "relabel";
elements.excludeFields.hidden = action !== "exclude";
elements.eventTypeField.hidden = action !== "relabel" || verdict !== "POSITIVE";
elements.opportunityField.hidden = action !== "relabel" || verdict !== "POSITIVE" || eventType !== "WEARER_ENTRY" || !hasOpportunityOptions;
elements.subtypeField.hidden = action !== "relabel" || verdict !== "HARD_NEGATIVE";
}
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;
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.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields));
elements.reviewForm.elements.human_verdict.addEventListener("change", updateConditionalFields);
elements.reviewForm.elements.human_event_type.addEventListener("change", updateConditionalFields);
elements.timelineZoomIn.addEventListener("click", () => changeTimelineZoom(2));
elements.timelineZoomOut.addEventListener("click", () => changeTimelineZoom(0.5));
elements.timelinePanLeft.addEventListener("click", () => panTimeline(-1));
elements.timelinePanRight.addEventListener("click", () => panTimeline(1));
elements.timelineFocus.addEventListener("click", focusTimeline);
elements.timelineReset.addEventListener("click", () => {
state.timelineZoom = 1;
state.timelineCenter = Number(state.currentDetail?.event_time_seconds ?? 0);
renderSessionTimeline(state.currentDetail);
});
elements.sessionTimeline.addEventListener("wheel", (event) => {
if (!event.shiftKey) return;
event.preventDefault();
changeTimelineZoom(event.deltaY < 0 ? 2 : 0.5);
}, { passive: false });
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();