Make EasyCom event review wearer anchor first

This commit is contained in:
Xin Wang
2026-09-14 23:30:31 +08:00
parent 06b5e6c385
commit 65347dd75d
12 changed files with 336 additions and 107 deletions
+3 -1
View File
@@ -274,7 +274,9 @@ textarea { resize: vertical; line-height: 1.45; }
.speaker-lane { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 8px; }
.speaker-lane > strong { color: var(--muted); text-align: right; font-size: .72rem; }
.speaker-track { height: 19px; position: relative; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; background: #07101d; }
.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--amber); z-index: 3; }
.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; z-index: 3; }
.speaker-track > i.behavior-anchor { background: var(--amber); }
.speaker-track > i.opportunity-anchor { width: 2px; background: #60a5fa; opacity: .9; }
.speech-segment { position: absolute; top: 3px; height: 11px; min-width: 4px; padding: 0; border: 1px solid transparent; border-radius: 3px; background: #60a5fa; cursor: pointer; }
.speech-segment.wearer { background: var(--accent); }
.speech-segment:hover, .speech-segment:focus-visible, .speech-segment.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,.28); z-index: 4; outline: none; }
+37 -3
View File
@@ -26,7 +26,7 @@ const elements = Object.fromEntries(
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "formError",
"saveButton", "saveState", "toast", "relabelFields",
"excludeFields", "eventTypeField", "subtypeField",
"excludeFields", "eventTypeField", "opportunityField", "opportunitySelect", "subtypeField",
].map((id) => [id, document.getElementById(id)])
);
@@ -262,6 +262,18 @@ function renderDetail(detail) {
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 = "请选择 preceding release";
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。";
@@ -278,7 +290,10 @@ function renderDetail(detail) {
elements.aiRationale.textContent = view.rationale;
elements.acceptRadio.disabled = false;
elements.acceptTitle.textContent = `接受 ${view.verdict.replaceAll("_", " ")}`;
elements.acceptSummary.textContent = `${view.label} · ${view.meaning}`;
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 = `${view.label}${anchorText} · ${view.meaning}`;
} else {
elements.acceptRadio.disabled = true;
elements.acceptTitle.textContent = "没有 AI 结论";
@@ -435,6 +450,7 @@ function renderSpeakerTimeline(detail) {
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);
@@ -458,7 +474,18 @@ function renderSpeakerTimeline(detail) {
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);
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) =>
@@ -496,6 +523,7 @@ function formPayload() {
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"),
@@ -508,6 +536,8 @@ function validateReview(review) {
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 === "POSITIVE" && review.human_event_type === "EOT_RESPONSE" && options.length && !review.selected_opportunity_uid) missing.push("EOT opportunity anchor");
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)");
@@ -517,9 +547,12 @@ function validateReview(review) {
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 !== "EOT_RESPONSE" || !hasOpportunityOptions;
elements.subtypeField.hidden = action !== "relabel" || verdict !== "HARD_NEGATIVE";
}
@@ -619,6 +652,7 @@ function bindEvents() {
});
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));
+1
View File
@@ -84,6 +84,7 @@
<section id="relabelFields" class="conditional-fields" hidden>
<label><span>一级 verdict</span><select name="human_verdict"><option value="">请选择</option><option>POSITIVE</option><option>HARD_NEGATIVE</option><option>NOT_APPLICABLE</option><option>UNSURE</option></select></label>
<label id="eventTypeField" hidden><span>Positive event</span><select name="human_event_type"><option value="">请选择</option><option>EOT_RESPONSE</option><option>INT_YIELD</option></select></label>
<label id="opportunityField" hidden><span>EOT opportunity anchor</span><select id="opportunitySelect" name="selected_opportunity_uid"><option value="">请选择 preceding release</option></select></label>
<label id="subtypeField" hidden><span>Hard-negative subtype</span><select name="human_subtype"><option value="">请选择</option><option>EOT_MIDTURN_PAUSE</option><option>EOT_OTHER_TO_OTHER</option><option>EOT_WEARER_BACKCHANNEL</option><option>INT_OTHER_BACKCHANNEL</option><option>INT_NON_TAKEOVER_OVERLAP</option><option>INT_WEARER_HOLDS_FLOOR</option><option>OTHER_HARD_NEGATIVE</option></select></label>
</section>