740 lines
36 KiB
JavaScript
740 lines
36 KiB
JavaScript
"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 participantIdentity(row, verbose = false) {
|
||
const participant = `P${row.participant_id ?? "?"}`;
|
||
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)} · P${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)} · P${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();
|