Revamp EasyCom floor event review pipeline
This commit is contained in:
+117
-38
@@ -7,6 +7,7 @@ const state = {
|
||||
currentId: null,
|
||||
currentDetail: null,
|
||||
saving: false,
|
||||
sessionSpeech: {},
|
||||
};
|
||||
|
||||
const elements = Object.fromEntries(
|
||||
@@ -15,10 +16,11 @@ const elements = Object.fromEntries(
|
||||
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
|
||||
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
|
||||
"autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition",
|
||||
"llmSuggestion", "llmSuggestionText",
|
||||
"llmSuggestion", "llmSuggestionText", "sessionTimeline", "speakerTimeline",
|
||||
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
|
||||
"transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError",
|
||||
"saveButton", "uncertainButton", "saveState", "toast",
|
||||
"saveButton", "uncertainButton", "saveState", "toast", "relabelFields",
|
||||
"excludeFields", "eventTypeField", "subtypeField",
|
||||
].map((id) => [id, document.getElementById(id)])
|
||||
);
|
||||
|
||||
@@ -86,7 +88,7 @@ function applyFilters({ keepCurrent = true } = {}) {
|
||||
(elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) &&
|
||||
(elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) &&
|
||||
(status === "all" || (status === "reviewed" ? reviewed : !reviewed)) &&
|
||||
(!search || `${item.uid} ${item.automatic_label} ${item.automatic_protocol_label || ""} ${item.automatic_hard_negative_type || ""} ${item.session}`.toLowerCase().includes(search))
|
||||
(!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();
|
||||
@@ -121,9 +123,9 @@ function renderQueue() {
|
||||
meta.textContent = `${item.session} · ${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`;
|
||||
const label = document.createElement("div");
|
||||
label.className = "queue-label";
|
||||
label.textContent = item.automatic_protocol_label
|
||||
? `${item.automatic_protocol_label} · ${item.automatic_hard_negative_type || item.automatic_label}`
|
||||
: item.automatic_label;
|
||||
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);
|
||||
@@ -171,7 +173,7 @@ function renderDetail(detail) {
|
||||
elements.familyBadge.textContent = familyNames[detail.family] || detail.family;
|
||||
elements.splitBadge.textContent = `${detail.split} · ${detail.session}`;
|
||||
elements.autoConfidenceBadge.textContent = detail.automatic_confidence ? `auto ${detail.automatic_confidence}` : "auto confidence —";
|
||||
elements.eventTitle.textContent = detail.automatic_label;
|
||||
elements.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);
|
||||
@@ -181,13 +183,16 @@ function renderDetail(detail) {
|
||||
const suggestion = detail.llm_suggestion;
|
||||
elements.llmSuggestion.hidden = !suggestion;
|
||||
if (suggestion) {
|
||||
const disposition = suggestion.protocol_disposition || suggestion.final_protocol_disposition || "uncertain";
|
||||
const subtype = suggestion.hard_negative_type || suggestion.final_hard_negative_type || "uncertain";
|
||||
const 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.rationale || suggestion.final_rationale || "";
|
||||
const rationale = suggestion.reason || suggestion.rationale || "";
|
||||
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
|
||||
}
|
||||
|
||||
renderSessionTimeline(detail);
|
||||
renderSpeakerTimeline(detail);
|
||||
|
||||
elements.mediaEmpty.hidden = detail.media_available;
|
||||
if (detail.media_available) {
|
||||
elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`;
|
||||
@@ -226,16 +231,87 @@ function renderDetail(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 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.anchor_adjustment_bins.value = "0";
|
||||
elements.reviewForm.elements.behavior_adjustment_bins.value = "0";
|
||||
elements.reviewForm.elements.region_end_adjustment_bins.value = "0";
|
||||
elements.reviewForm.elements.confidence.value = "medium";
|
||||
updateConditionalFields();
|
||||
return;
|
||||
}
|
||||
for (const [name, value] of Object.entries(review)) {
|
||||
@@ -244,24 +320,20 @@ function restoreForm(review) {
|
||||
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 adjustment = (name) => {
|
||||
const value = data.get(name);
|
||||
return value === "" ? null : Number(value);
|
||||
};
|
||||
const nullable = (name) => data.get(name) || null;
|
||||
const correction = data.get("boundary_correction_seconds");
|
||||
return {
|
||||
protocol_disposition: data.get("protocol_disposition"),
|
||||
hard_negative_type: data.get("hard_negative_type"),
|
||||
validity: data.get("validity"),
|
||||
onset_function: data.get("onset_function"),
|
||||
context: data.get("context"),
|
||||
offset_outcome: data.get("offset_outcome"),
|
||||
anchor_adjustment_bins: adjustment("anchor_adjustment_bins"),
|
||||
behavior_adjustment_bins: adjustment("behavior_adjustment_bins"),
|
||||
region_end_adjustment_bins: adjustment("region_end_adjustment_bins"),
|
||||
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(),
|
||||
@@ -270,16 +342,25 @@ function formPayload() {
|
||||
|
||||
function validateReview(review) {
|
||||
const missing = [];
|
||||
if (!review.protocol_disposition) missing.push("协议结论");
|
||||
if (!review.hard_negative_type) missing.push("hard-negative 类型");
|
||||
if (!review.validity) missing.push("活动边界");
|
||||
if (!review.onset_function) missing.push("onset 功能");
|
||||
if (!review.context) missing.push("对话场景");
|
||||
if (!review.offset_outcome) missing.push("offset 结果");
|
||||
if (!review.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;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -376,14 +457,12 @@ function bindEvents() {
|
||||
saveCurrent();
|
||||
});
|
||||
elements.uncertainButton.addEventListener("click", () => {
|
||||
elements.reviewForm.elements.protocol_disposition.value = "uncertain";
|
||||
elements.reviewForm.elements.hard_negative_type.value = "uncertain";
|
||||
elements.reviewForm.elements.validity.value = "uncertain";
|
||||
elements.reviewForm.elements.onset_function.value = "uncertain";
|
||||
elements.reviewForm.elements.context.value = "unknown";
|
||||
elements.reviewForm.elements.offset_outcome.value = "uncertain";
|
||||
elements.reviewForm.elements.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.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