Revamp EasyCom floor event review pipeline
This commit is contained in:
@@ -220,6 +220,27 @@ textarea { resize: vertical; line-height: 1.45; }
|
||||
.llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; }
|
||||
.event-position { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
.timeline-section { padding: 0 18px 16px; display: grid; gap: 10px; }
|
||||
.timeline-section .section-heading { margin-top: 2px; }
|
||||
.local-heading { margin-top: 8px !important; }
|
||||
.session-timeline { min-height: 34px; border: 1px solid var(--line); border-radius: 8px; position: relative; background: #07101d; padding: 6px 7px 28px; display: grid; gap: 3px; overflow: hidden; }
|
||||
.session-lane { display: grid; grid-template-columns: 28px 1fr; gap: 5px; align-items: center; min-height: 10px; }
|
||||
.session-lane > strong { color: var(--muted); text-align: right; font-size: .58rem; }
|
||||
.session-track { height: 8px; position: relative; background: rgba(255,255,255,.025); }
|
||||
.session-speech { position: absolute; top: 1px; height: 6px; min-width: 1px; background: #60a5fa; opacity: .75; }
|
||||
.session-speech.wearer { background: var(--accent); }
|
||||
.session-events { position: absolute; left: 40px; right: 7px; bottom: 4px; height: 20px; border-top: 1px solid var(--line-strong); }
|
||||
.session-marker { position: absolute; top: 3px; width: 7px; height: 15px; margin-left: -3px; padding: 0; border: 0; border-radius: 4px; background: var(--amber); cursor: pointer; opacity: .68; }
|
||||
.session-marker.int { background: #a78bfa; }
|
||||
.session-marker.current { opacity: 1; box-shadow: 0 0 0 2px white; z-index: 2; }
|
||||
.speaker-timeline { display: grid; gap: 6px; color: var(--muted); font-size: .76rem; }
|
||||
.speaker-lane { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 8px; }
|
||||
.speaker-lane > strong { color: var(--muted); text-align: right; font-size: .72rem; }
|
||||
.speaker-track { height: 22px; 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; }
|
||||
.speech-segment { position: absolute; top: 5px; height: 10px; min-width: 2px; border-radius: 3px; background: #60a5fa; }
|
||||
.speech-segment.wearer { background: var(--accent); }
|
||||
|
||||
.video-shell { background: #02050a; aspect-ratio: 16 / 9; position: relative; border-block: 1px solid var(--line); }
|
||||
.video-shell video { width: 100%; height: 100%; display: block; object-fit: contain; }
|
||||
.empty-state { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 7px; color: var(--muted); }
|
||||
@@ -273,6 +294,11 @@ legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; }
|
||||
.text-field input, .text-field textarea { font-size: .84rem; }
|
||||
.form-error { color: #fecdd3; background: rgba(251,113,133,.1); border: 1px solid rgba(251,113,133,.4); border-radius: 8px; padding: 9px; font-size: .8rem; }
|
||||
.review-actions { display: grid; grid-template-columns: .9fr 1.4fr; gap: 8px; }
|
||||
.conditional-fields { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(94,234,212,.035); display: grid; gap: 9px; }
|
||||
.conditional-fields label, .boundary-details label { display: grid; gap: 6px; }
|
||||
.conditional-fields label > span, .boundary-details label > span { color: var(--muted); font-size: .72rem; font-weight: 700; }
|
||||
.boundary-details { border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.boundary-details summary { color: var(--muted); cursor: pointer; font-size: .78rem; margin-bottom: 9px; }
|
||||
kbd { border: 1px solid rgba(3,24,22,.35); border-bottom-width: 2px; border-radius: 4px; padding: 1px 4px; font-size: .68rem; font-family: inherit; }
|
||||
.shortcuts { border-top: 1px solid var(--line); padding: 11px 15px 14px; display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--muted); font-size: .71rem; }
|
||||
.shortcuts kbd { color: #cbd7e7; border-color: var(--line-strong); margin-right: 2px; }
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -1,285 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="EasyCom wearer floor-event local review workspace" />
|
||||
<title>Floor Event Review</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230b1324'/%3E%3Cpath d='M14 39h8l5-17 9 29 6-22 4 10h5' fill='none' stroke='%235eead4' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<h1>Floor Event Review</h1>
|
||||
<p>EasyCom 事件审核</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-block" aria-live="polite">
|
||||
<div class="progress-copy">
|
||||
<strong id="progressText">载入中</strong>
|
||||
<span id="progressMeta">—</span>
|
||||
</div>
|
||||
<div class="progress-track"><span id="progressBar"></span></div>
|
||||
</div>
|
||||
<nav class="export-actions" aria-label="导出审核结果">
|
||||
<a href="/api/export.jsonl" download>导出 JSONL</a>
|
||||
<a href="/api/export.csv" download>导出 CSV</a>
|
||||
</nav>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>EasyCom Floor Event Review</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-mark"></span><div><h1>Floor Event Review</h1><p>AI 粗标注 → 人工确认</p></div></div>
|
||||
<div class="progress-block"><div class="progress-copy"><strong id="progressText">载入中</strong><span id="progressMeta">—</span></div><div class="progress-track"><span id="progressBar"></span></div></div>
|
||||
<nav class="export-actions"><a href="/api/export.jsonl" download>导出 JSONL</a><a href="/api/export.csv" download>导出 CSV</a></nav>
|
||||
</header>
|
||||
|
||||
<section class="filters" aria-label="候选筛选">
|
||||
<label>
|
||||
<span>Session</span>
|
||||
<select id="sessionFilter"><option value="all">全部</option></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Split</span>
|
||||
<select id="splitFilter">
|
||||
<option value="all">全部</option>
|
||||
<option value="train">train</option>
|
||||
<option value="test">test</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>事件族</span>
|
||||
<select id="familyFilter">
|
||||
<option value="all">全部</option>
|
||||
<option value="eot">EOT-response</option>
|
||||
<option value="int">INT-yield</option>
|
||||
<option value="onset">wearer onset</option>
|
||||
<option value="offset">旧 EOT proxy</option>
|
||||
<option value="interaction">旧 interaction proxy</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>审核状态</span>
|
||||
<select id="statusFilter">
|
||||
<option value="unreviewed">未审核</option>
|
||||
<option value="all">全部</option>
|
||||
<option value="reviewed">已审核</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="search-field">
|
||||
<span>搜索</span>
|
||||
<input id="searchFilter" type="search" placeholder="UID、类别、session" />
|
||||
</label>
|
||||
<button id="clearFilters" class="button ghost" type="button">清除筛选</button>
|
||||
</section>
|
||||
<section class="filters" aria-label="候选筛选">
|
||||
<label><span>Session</span><select id="sessionFilter"><option value="all">全部</option></select></label>
|
||||
<label><span>Split</span><select id="splitFilter"><option value="all">全部</option><option value="train">train</option><option value="test">test</option></select></label>
|
||||
<label><span>任务</span><select id="familyFilter"><option value="all">全部</option><option value="eot">EOT / ENTRY</option><option value="int">INT / YIELD</option></select></label>
|
||||
<label><span>审核状态</span><select id="statusFilter"><option value="unreviewed">未审核</option><option value="all">全部</option><option value="reviewed">已审核</option></select></label>
|
||||
<label class="search-field"><span>搜索</span><input id="searchFilter" type="search" placeholder="事件来源、AI 标签、session" /></label>
|
||||
<button id="clearFilters" class="button ghost" type="button">清除筛选</button>
|
||||
</section>
|
||||
|
||||
<main class="workspace">
|
||||
<aside class="queue-panel panel" aria-label="事件候选列表">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="eyebrow">QUEUE</span>
|
||||
<h2 id="queueTitle">候选</h2>
|
||||
</div>
|
||||
<span id="queueCount" class="count-pill">0</span>
|
||||
</div>
|
||||
<div id="queueList" class="queue-list"></div>
|
||||
<p id="queueLimit" class="queue-limit" hidden></p>
|
||||
</aside>
|
||||
<main class="workspace">
|
||||
<aside class="queue-panel panel">
|
||||
<div class="panel-heading"><div><span class="eyebrow">QUEUE</span><h2 id="queueTitle">待审核</h2></div><span id="queueCount" class="count-pill">0</span></div>
|
||||
<div id="queueList" class="queue-list"></div><p id="queueLimit" class="queue-limit" hidden></p>
|
||||
</aside>
|
||||
|
||||
<section class="evidence-panel panel" aria-label="事件证据">
|
||||
<div class="event-header">
|
||||
<div>
|
||||
<div class="event-badges">
|
||||
<span id="familyBadge" class="badge accent">—</span>
|
||||
<span id="splitBadge" class="badge">—</span>
|
||||
<span id="autoConfidenceBadge" class="badge">—</span>
|
||||
</div>
|
||||
<h2 id="eventTitle">请选择一个候选事件</h2>
|
||||
<p id="eventSubtitle">—</p>
|
||||
</div>
|
||||
<div class="event-position"><strong id="eventPosition">0 / 0</strong></div>
|
||||
</div>
|
||||
<section class="evidence-panel panel">
|
||||
<div class="event-header"><div><div class="event-badges"><span id="familyBadge" class="badge accent">—</span><span id="splitBadge" class="badge">—</span><span id="autoConfidenceBadge" class="badge">—</span></div><h2 id="eventTitle">请选择候选</h2><p id="eventSubtitle">—</p></div><div class="event-position"><strong id="eventPosition">0 / 0</strong></div></div>
|
||||
|
||||
<div id="llmSuggestion" class="llm-suggestion" hidden>
|
||||
<strong>DeepSeek 预标注(仅供复核)</strong>
|
||||
<span id="llmSuggestionText">—</span>
|
||||
</div>
|
||||
<div id="llmSuggestion" class="llm-suggestion" hidden><strong>AI 预标注(不是最终标签)</strong><span id="llmSuggestionText">—</span></div>
|
||||
|
||||
<div class="video-shell">
|
||||
<video id="eventVideo" controls preload="metadata"></video>
|
||||
<div id="mediaEmpty" class="empty-state" hidden>
|
||||
<strong>当前候选未找到视频</strong>
|
||||
<span>检查 dataset root、session 和 clip stem。</span>
|
||||
</div>
|
||||
<div class="time-marker" aria-hidden="true"><span></span></div>
|
||||
</div>
|
||||
|
||||
<div class="transport" aria-label="视频定位">
|
||||
<button type="button" data-seek="-2">−2 s</button>
|
||||
<button type="button" data-seek="-0.2">−0.2 s</button>
|
||||
<button id="jumpEvent" class="jump-event" type="button">回到事件</button>
|
||||
<button type="button" data-seek="0.2">+0.2 s</button>
|
||||
<button type="button" data-seek="2">+2 s</button>
|
||||
<span id="timeReadout">—</span>
|
||||
</div>
|
||||
|
||||
<section class="transcript-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">TRANSCRIPT</span>
|
||||
<h3>事件上下文</h3>
|
||||
</div>
|
||||
<span id="clipLabel">—</span>
|
||||
</div>
|
||||
<div id="transcriptList" class="transcript-list">
|
||||
<div class="empty-copy">选择事件后显示 transcript。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="raw-details">
|
||||
<summary>查看自动规则与原始候选字段</summary>
|
||||
<pre id="rawCandidate">{}</pre>
|
||||
</details>
|
||||
<section class="timeline-section">
|
||||
<div class="section-heading"><div><span class="eyebrow">SESSION OVERVIEW</span><h3>本 Session 候选分布</h3></div><span>点击 marker 跳转</span></div>
|
||||
<div id="sessionTimeline" class="session-timeline"></div>
|
||||
<div class="section-heading local-heading"><div><span class="eyebrow">LOCAL SPEAKER ACTIVITY</span><h3>事件附近 participant timeline</h3></div><span>原始标注,只读</span></div>
|
||||
<div id="speakerTimeline" class="speaker-timeline"></div>
|
||||
</section>
|
||||
|
||||
<aside class="review-panel panel" aria-label="人工审核表单">
|
||||
<div class="panel-heading review-heading">
|
||||
<div>
|
||||
<span class="eyebrow">HUMAN REVIEW</span>
|
||||
<h2>审核结论</h2>
|
||||
<div class="video-shell"><video id="eventVideo" controls preload="metadata"></video><div id="mediaEmpty" class="empty-state" hidden><strong>未找到视频</strong><span>检查 dataset root 与 clip。</span></div></div>
|
||||
<div class="transport"><button type="button" data-seek="-2">−2 s</button><button type="button" data-seek="-0.2">−0.2 s</button><button id="jumpEvent" class="jump-event" type="button">回到事件</button><button type="button" data-seek="0.2">+0.2 s</button><button type="button" data-seek="2">+2 s</button><span id="timeReadout">—</span></div>
|
||||
|
||||
<section class="transcript-section"><div class="section-heading"><div><span class="eyebrow">TRANSCRIPT</span><h3>事件上下文</h3></div><span id="clipLabel">—</span></div><div id="transcriptList" class="transcript-list"><div class="empty-copy">选择事件后显示 transcript。</div></div></section>
|
||||
<details class="raw-details"><summary>查看 structured timing 与原始候选</summary><pre id="rawCandidate">{}</pre></details>
|
||||
</section>
|
||||
|
||||
<aside class="review-panel panel">
|
||||
<div class="panel-heading review-heading"><div><span class="eyebrow">HUMAN REVIEW</span><h2>只做一个主操作</h2></div><span id="saveState" class="save-state">未保存</span></div>
|
||||
<form id="reviewForm">
|
||||
<fieldset><legend>确认 AI 判断,或改标 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two action-grid">
|
||||
<label><input type="radio" name="human_action" value="accept" /><span><b>Accept</b><small>接受 AI verdict/type</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="relabel" /><span><b>Relabel</b><small>展开并选择正确标签</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="exclude" /><span><b>Exclude</b><small>数据不可可靠评分</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="second_review" /><span><b>Unsure / 二审</b><small>证据不足,交给二审</small></span></label>
|
||||
</div>
|
||||
<span id="saveState" class="save-state">未保存</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<form id="reviewForm">
|
||||
<fieldset>
|
||||
<legend>1. 稿件协议结论 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two compact">
|
||||
<label><input type="radio" name="protocol_disposition" value="positive" /><span><b>Positive</b><small>EOT-response / INT-yield 正例</small></span></label>
|
||||
<label><input type="radio" name="protocol_disposition" value="hard_negative" /><span><b>Hard negative</b><small>错误 commit 的计分区间</small></span></label>
|
||||
<label><input type="radio" name="protocol_disposition" value="exclude" /><span><b>Exclude</b><small>歧义或标注损坏</small></span></label>
|
||||
<label><input type="radio" name="protocol_disposition" value="uncertain" /><span><b>不确定</b><small>需要裁决</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<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="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>
|
||||
|
||||
<fieldset>
|
||||
<legend>2. Hard-negative 类型 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two compact">
|
||||
<label><input type="radio" name="hard_negative_type" value="eot_midturn_pause" /><span><b>EOT 句中停顿</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="eot_other_to_other" /><span><b>EOT other→other</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="eot_no_response" /><span><b>EOT 保持沉默</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="int_backchannel" /><span><b>INT backchannel</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="int_non_takeover_overlap" /><span><b>INT 非接管重叠</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="int_retain_floor" /><span><b>INT 保持话轮</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="other" /><span><b>其他</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||
<label><input type="radio" name="hard_negative_type" value="uncertain" /><span><b>不确定</b></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<section id="excludeFields" class="conditional-fields" hidden><label><span>排除原因</span><select name="exclude_reason"><option value="">请选择</option><option>ANNOTATION_ERROR</option><option>RECORDING_BOUNDARY</option><option>MISSING_CONTEXT</option><option>NON_SPEECH_ARTIFACT</option><option>TIMESTAMP_CORRUPTION</option><option>UNRESOLVABLE</option></select></label></section>
|
||||
|
||||
<fieldset>
|
||||
<legend>3. 活动边界是否真实? <span class="required">必填</span></legend>
|
||||
<div class="choice-grid three">
|
||||
<label><input type="radio" name="validity" value="valid" /><span><b>真实</b><small>有效活动边界</small></span></label>
|
||||
<label><input type="radio" name="validity" value="artifact" /><span><b>伪边界</b><small>噪声、断点或错标</small></span></label>
|
||||
<label><input type="radio" name="validity" value="uncertain" /><span><b>不确定</b><small>证据不足</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<details class="boundary-details"><summary>仅在原 annotation 明显错误时校正边界</summary><label><span>可选校正(原时间始终保留)</span><select name="boundary_correction_seconds"><option value="">不校正</option><option value="-0.2">−0.2 s</option><option value="0.2">+0.2 s</option></select></label></details>
|
||||
|
||||
<fieldset>
|
||||
<legend>4. Onset 的 floor 功能 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two compact">
|
||||
<label><input type="radio" name="onset_function" value="floor_taking" /><span><b>取得话轮</b></span></label>
|
||||
<label><input type="radio" name="onset_function" value="backchannel" /><span><b>Backchannel</b></span></label>
|
||||
<label><input type="radio" name="onset_function" value="within_turn_resume" /><span><b>句内恢复</b></span></label>
|
||||
<label><input type="radio" name="onset_function" value="nonverbal" /><span><b>非语言发声</b></span></label>
|
||||
<label><input type="radio" name="onset_function" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||
<label><input type="radio" name="onset_function" value="uncertain" /><span><b>不确定</b></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>5. 对话指向/场景 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two compact">
|
||||
<label><input type="radio" name="context" value="addressed_wearer" /><span><b>对 wearer</b></span></label>
|
||||
<label><input type="radio" name="context" value="third_party" /><span><b>第三方交谈</b></span></label>
|
||||
<label><input type="radio" name="context" value="group" /><span><b>群体</b></span></label>
|
||||
<label><input type="radio" name="context" value="unknown" /><span><b>未知</b></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>6. Offset 的结果 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two compact">
|
||||
<label><input type="radio" name="offset_outcome" value="yield_to_other" /><span><b>让给 other</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="completion_to_silence" /><span><b>结束后静音</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="temporary_pause" /><span><b>短暂停顿</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="backchannel_end" /><span><b>Backchannel 结束</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="cutoff" /><span><b>截断/边界</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||
<label><input type="radio" name="offset_outcome" value="uncertain" /><span><b>不确定</b></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="inline-fields">
|
||||
<label>
|
||||
<span>other 锚点校正</span>
|
||||
<select name="anchor_adjustment_bins">
|
||||
<option value="">无法判断</option>
|
||||
<option value="-1">−1 bin(−200 ms)</option>
|
||||
<option value="0" selected>原 bin</option>
|
||||
<option value="1">+1 bin(+200 ms)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>wearer 行为锚点校正</span>
|
||||
<select name="behavior_adjustment_bins">
|
||||
<option value="">不适用/无法判断</option>
|
||||
<option value="-1">−1 bin(−200 ms)</option>
|
||||
<option value="0" selected>原 bin</option>
|
||||
<option value="1">+1 bin(+200 ms)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>负区间终点校正</span>
|
||||
<select name="region_end_adjustment_bins">
|
||||
<option value="">不适用/无法判断</option>
|
||||
<option value="-1">−1 bin(−200 ms)</option>
|
||||
<option value="0" selected>原 bin</option>
|
||||
<option value="1">+1 bin(+200 ms)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>置信度</span>
|
||||
<select name="confidence" required>
|
||||
<option value="high">高</option>
|
||||
<option value="medium" selected>中</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="text-field">
|
||||
<span>审核者</span>
|
||||
<input id="reviewerInput" name="reviewer" maxlength="100" placeholder="姓名或代号" required />
|
||||
</label>
|
||||
<label class="text-field">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" maxlength="4000" rows="3" placeholder="记录歧义、说话对象或边界问题"></textarea>
|
||||
</label>
|
||||
|
||||
<div id="formError" class="form-error" role="alert" hidden></div>
|
||||
<div class="review-actions">
|
||||
<button id="uncertainButton" class="button ghost" type="button">全部标为不确定</button>
|
||||
<button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="shortcuts">
|
||||
<span><kbd>←</kbd><kbd>→</kbd> 前后事件</span>
|
||||
<span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span>
|
||||
<span><kbd>Space</kbd> 播放/暂停</span>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
<div class="inline-fields"><label><span>置信度</span><select name="confidence"><option value="high">高</option><option value="medium" selected>中</option><option value="low">低</option></select></label><label><span>审核者</span><input id="reviewerInput" name="reviewer" maxlength="100" placeholder="姓名或代号" required /></label></div>
|
||||
<label class="text-field"><span>备注(可选)</span><textarea name="notes" maxlength="4000" rows="3" placeholder="只记录必要的歧义或证据"></textarea></label>
|
||||
<div id="formError" class="form-error" role="alert" hidden></div>
|
||||
<div class="review-actions"><button id="uncertainButton" class="button ghost" type="button">送二审</button><button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button></div>
|
||||
</form>
|
||||
<div class="shortcuts"><span><kbd>←</kbd><kbd>→</kbd> 前后事件</span><span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span><span><kbd>Space</kbd> 播放/暂停</span></div>
|
||||
</aside>
|
||||
</main>
|
||||
<div id="toast" class="toast" role="status"></div><script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user