Files
icassp2027-experiment/floor_event_review_web/app.js
T

513 lines
23 KiB
JavaScript
Raw Normal View History

"use strict";
const state = {
candidates: [],
filtered: [],
reviews: {},
currentId: null,
currentDetail: null,
saving: false,
2026-09-14 18:43:58 +08:00
sessionSpeech: {},
};
const elements = Object.fromEntries(
[
"progressText", "progressMeta", "progressBar", "sessionFilter", "splitFilter",
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
"autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition",
2026-09-14 18:43:58 +08:00
"llmSuggestion", "llmSuggestionText", "sessionTimeline", "speakerTimeline",
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError",
2026-09-14 18:43:58 +08:00
"saveButton", "uncertainButton", "saveState", "toast", "relabelFields",
"excludeFields", "eventTypeField", "subtypeField",
].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)) &&
2026-09-14 18:43:58 +08:00
(!search || `${item.uid} ${item.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${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";
2026-09-14 18:43:58 +08:00
label.textContent = item.ai_verdict
? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || "—"}`
: `${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.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 —";
2026-09-14 18:43:58 +08:00
elements.eventTitle.textContent = detail.candidate_source || 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) {
2026-09-14 18:43:58 +08:00
const disposition = suggestion.verdict || suggestion.protocol_disposition || "UNSURE";
const subtype = suggestion.event_type || suggestion.subtype || suggestion.hard_negative_type || "—";
const confidence = suggestion.confidence || suggestion.final_confidence || "—";
2026-09-14 18:43:58 +08:00
const rationale = suggestion.reason || suggestion.rationale || "";
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
}
2026-09-14 18:43:58 +08:00
renderSessionTimeline(detail);
renderSpeakerTimeline(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" : ""}`;
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();
}
2026-09-14 18:43:58 +08:00
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 end = Math.max(1,
...events.map((item) => Number(item.event_time_seconds) || 0),
...speech.map((row) => Number(row.end_seconds) || 0));
const speakers = [...new Set(speech.map((row) => row.participant_role === "wearer" ? "W" : `P${row.participant_id}`))];
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) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
const segment = document.createElement("span");
segment.className = `session-speech${speakerId === "W" ? " wearer" : ""}`;
segment.style.left = `${100 * Number(row.start_seconds) / end}%`;
segment.style.width = `${Math.max(0.08, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / end)}%`;
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) {
const marker = document.createElement("button");
marker.type = "button";
marker.className = `session-marker ${item.family}${item.candidate_id === detail.candidate_id ? " current" : ""}`;
marker.style.left = `${Math.min(100, 100 * (Number(item.event_time_seconds) || 0) / end)}%`;
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 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 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) => `${row.participant_role === "wearer" ? "W" : `P${row.participant_id}`}`))];
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) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
const segment = document.createElement("span");
segment.className = `speech-segment${speakerId === "W" ? " 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)}%`;
segment.title = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)} ${row.transcription || ""}`;
track.append(segment);
}
const anchor = document.createElement("i"); anchor.style.left = `${100 * (eventTime - left) / span}%`; track.append(anchor);
lane.append(label, track); elements.speakerTimeline.append(lane);
}
}
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.confidence.value = "medium";
2026-09-14 18:43:58 +08:00
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);
}
2026-09-14 18:43:58 +08:00
updateConditionalFields();
}
function formPayload() {
const data = new FormData(elements.reviewForm);
2026-09-14 18:43:58 +08:00
const nullable = (name) => data.get(name) || null;
const correction = data.get("boundary_correction_seconds");
return {
2026-09-14 18:43:58 +08:00
human_action: data.get("human_action"),
human_verdict: nullable("human_verdict"),
human_event_type: nullable("human_event_type"),
human_subtype: nullable("human_subtype"),
exclude_reason: nullable("exclude_reason"),
boundary_correction_seconds: correction === "" ? null : Number(correction),
confidence: data.get("confidence"),
reviewer: String(data.get("reviewer") || "").trim(),
notes: String(data.get("notes") || "").trim(),
};
}
function validateReview(review) {
const missing = [];
2026-09-14 18:43:58 +08:00
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");
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)");
if (!review.reviewer) missing.push("审核者");
return missing;
}
2026-09-14 18:43:58 +08:00
function updateConditionalFields() {
const action = elements.reviewForm.elements.human_action.value;
const verdict = elements.reviewForm.elements.human_verdict.value;
elements.relabelFields.hidden = action !== "relabel";
elements.excludeFields.hidden = action !== "exclude";
elements.eventTypeField.hidden = action !== "relabel" || verdict !== "POSITIVE";
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;
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", () => {
2026-09-14 18:43:58 +08:00
elements.reviewForm.elements.human_action.value = "second_review";
elements.reviewForm.elements.confidence.value = "low";
2026-09-14 18:43:58 +08:00
updateConditionalFields();
});
2026-09-14 18:43:58 +08:00
elements.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields));
elements.reviewForm.elements.human_verdict.addEventListener("change", updateConditionalFields);
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();