Update llm assisted annotation
This commit is contained in:
+208
-37
@@ -8,6 +8,9 @@ const state = {
|
||||
currentDetail: null,
|
||||
saving: false,
|
||||
sessionSpeech: {},
|
||||
timelineSession: null,
|
||||
timelineZoom: 8,
|
||||
timelineCenter: null,
|
||||
};
|
||||
|
||||
const elements = Object.fromEntries(
|
||||
@@ -15,11 +18,14 @@ 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", "sessionTimeline", "speakerTimeline",
|
||||
"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", "reviewerInput", "formError",
|
||||
"saveButton", "uncertainButton", "saveState", "toast", "relabelFields",
|
||||
"transcriptList", "rawCandidate", "reviewForm", "formError",
|
||||
"saveButton", "saveState", "toast", "relabelFields",
|
||||
"excludeFields", "eventTypeField", "subtypeField",
|
||||
].map((id) => [id, document.getElementById(id)])
|
||||
);
|
||||
@@ -32,6 +38,48 @@ const familyNames = {
|
||||
interaction: "LEGACY INTERACTION",
|
||||
};
|
||||
|
||||
const reviewQuestions = {
|
||||
eot: "Other 释放 floor 后,wearer 是否真正持续接话?",
|
||||
int: "Other 是否在 wearer 发言中接管,使 wearer 让出 floor?",
|
||||
onset: "这次 wearer onset 是否代表新的 floor entry?",
|
||||
offset: "这次 wearer offset 是否代表让出 floor?",
|
||||
interaction: "这次说话人变化是否属于目标 floor event?",
|
||||
};
|
||||
|
||||
const decisionMeanings = {
|
||||
EOT_RESPONSE: "Other 释放 floor 后,wearer 持续接话。",
|
||||
INT_YIELD: "Other 在 wearer 发言中接管,wearer 随后让出 floor。",
|
||||
EOT_MIDTURN_PAUSE: "Other 只是短暂停顿后继续,没有释放 floor 给 wearer。",
|
||||
EOT_OTHER_TO_OTHER: "这是 non-wearer 之间的换人,不是 wearer 接话。",
|
||||
EOT_WEARER_BACKCHANNEL: "Wearer 只是反馈、笑声或短回应,没有接过 floor。",
|
||||
INT_OTHER_BACKCHANNEL: "Other 只是反馈或笑声,没有接管 wearer 的 floor。",
|
||||
INT_NON_TAKEOVER_OVERLAP: "虽然发生重叠,但 other 没有形成持续接管。",
|
||||
INT_WEARER_HOLDS_FLOOR: "Wearer 在重叠后仍持有或继续自己的 floor。",
|
||||
OTHER_HARD_NEGATIVE: "这是有效困难负例,但不属于更具体的负例类型。",
|
||||
NOT_APPLICABLE: "这是真实活动边界,但不属于当前 EOT/INT 评测问题。",
|
||||
UNSURE: "现有时间和转写证据不足,需要人工直接判断标签。",
|
||||
EXCLUDE: "该区域存在数据质量问题,不应进入可靠评分。",
|
||||
};
|
||||
|
||||
function suggestionView(suggestion) {
|
||||
const verdict = suggestion.verdict || suggestion.protocol_disposition || "UNSURE";
|
||||
const label = suggestion.event_type || suggestion.subtype || 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 ?? "");
|
||||
}
|
||||
@@ -43,6 +91,40 @@ function formatTime(value) {
|
||||
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");
|
||||
@@ -120,7 +202,7 @@ function renderQueue() {
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "queue-meta";
|
||||
meta.textContent = `${item.session} · ${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`;
|
||||
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
|
||||
@@ -150,6 +232,10 @@ async function selectCandidate(candidateId) {
|
||||
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;
|
||||
}
|
||||
@@ -172,26 +258,39 @@ async function selectCandidate(candidateId) {
|
||||
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.candidate_source || detail.automatic_label;
|
||||
elements.eventSubtitle.textContent = `${detail.uid} · ${detail.source_file}:${detail.source_row}`;
|
||||
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);
|
||||
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 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 || "—";
|
||||
const rationale = suggestion.reason || suggestion.rationale || "";
|
||||
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
|
||||
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;
|
||||
elements.acceptRadio.disabled = false;
|
||||
elements.acceptTitle.textContent = `接受 ${view.verdict.replaceAll("_", " ")}`;
|
||||
elements.acceptSummary.textContent = `${view.label} · ${view.meaning}`;
|
||||
} 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);
|
||||
renderSpeakerTimeline(detail);
|
||||
|
||||
elements.mediaEmpty.hidden = detail.media_available;
|
||||
if (detail.media_available) {
|
||||
@@ -212,9 +311,10 @@ function renderDetail(detail) {
|
||||
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 = `P${row.participant_id ?? "?"}`;
|
||||
speaker.textContent = participantIdentity(row, true);
|
||||
const time = document.createElement("small");
|
||||
time.textContent = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)}`;
|
||||
speaker.append(time);
|
||||
@@ -225,9 +325,21 @@ function renderDetail(detail) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -244,29 +356,48 @@ async function renderSessionTimeline(detail) {
|
||||
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,
|
||||
const sessionEnd = 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}`))];
|
||||
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) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
|
||||
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${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)}%`;
|
||||
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) {
|
||||
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" : ""}`;
|
||||
marker.style.left = `${Math.min(100, 100 * (Number(item.event_time_seconds) || 0) / end)}%`;
|
||||
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);
|
||||
@@ -274,6 +405,28 @@ async function renderSessionTimeline(detail) {
|
||||
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 || [];
|
||||
@@ -285,30 +438,41 @@ function renderSpeakerTimeline(detail) {
|
||||
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}`}`))];
|
||||
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) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
|
||||
const segment = document.createElement("span");
|
||||
segment.className = `speech-segment${speakerId === "W" ? " wearer" : ""}`;
|
||||
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)}%`;
|
||||
segment.title = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)} ${row.transcription || ""}`;
|
||||
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);
|
||||
}
|
||||
const anchor = document.createElement("i"); anchor.style.left = `${100 * (eventTime - left) / span}%`; 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;
|
||||
const reviewer = review?.reviewer || localStorage.getItem("floor-reviewer") || "";
|
||||
elements.reviewerInput.value = reviewer;
|
||||
if (!review) {
|
||||
elements.reviewForm.elements.confidence.value = "medium";
|
||||
updateConditionalFields();
|
||||
@@ -335,7 +499,6 @@ function formPayload() {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -348,7 +511,6 @@ function validateReview(review) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -381,7 +543,6 @@ async function saveCurrent({ advance = true } = {}) {
|
||||
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();
|
||||
@@ -456,13 +617,23 @@ function bindEvents() {
|
||||
event.preventDefault();
|
||||
saveCurrent();
|
||||
});
|
||||
elements.uncertainButton.addEventListener("click", () => {
|
||||
elements.reviewForm.elements.human_action.value = "second_review";
|
||||
elements.reviewForm.elements.confidence.value = "low";
|
||||
updateConditionalFields();
|
||||
});
|
||||
elements.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields));
|
||||
elements.reviewForm.elements.human_verdict.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)))
|
||||
|
||||
Reference in New Issue
Block a user