Update llm assisted annotation

This commit is contained in:
Xin Wang
2026-09-14 23:04:21 +08:00
parent 068b7ab9e6
commit 06b5e6c385
12 changed files with 1348 additions and 238 deletions

View File

@@ -22,7 +22,7 @@
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { min-height: 100%; }
html, body { height: 100%; overflow: hidden; }
body {
margin: 0;
@@ -38,13 +38,13 @@ button, a, select, input, textarea { outline-offset: 3px; }
:focus-visible { outline: 2px solid var(--accent); }
.topbar {
min-height: 72px;
padding: 12px 20px;
height: 56px;
padding: 8px 14px;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(280px, 440px) minmax(220px, 1fr);
align-items: center;
gap: 24px;
gap: 18px;
background: rgba(7, 16, 29, 0.92);
backdrop-filter: blur(14px);
position: sticky;
@@ -54,8 +54,8 @@ button, a, select, input, textarea { outline-offset: 3px; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: 2px solid var(--accent);
border-radius: 9px;
position: relative;
@@ -66,16 +66,16 @@ button, a, select, input, textarea { outline-offset: 3px; }
position: absolute;
left: 5px;
right: 5px;
top: 14px;
top: 12px;
height: 2px;
background: var(--accent);
box-shadow: 5px -6px 0 -0.5px var(--accent), 11px 5px 0 -0.5px var(--accent);
transform: skewY(-35deg);
}
.brand h1 { font-size: 1rem; margin: 0; letter-spacing: 0.01em; }
.brand p { color: var(--muted); font-size: 0.8rem; margin: 2px 0 0; }
.brand p { color: var(--muted); font-size: 0.72rem; margin: 1px 0 0; }
.progress-block { display: grid; gap: 7px; }
.progress-block { display: grid; gap: 5px; }
.progress-copy { display: flex; justify-content: space-between; gap: 20px; font-size: 0.86rem; }
.progress-copy span { color: var(--muted); }
.progress-track { height: 5px; border-radius: 8px; background: #17243a; overflow: hidden; }
@@ -87,14 +87,15 @@ button, a, select, input, textarea { outline-offset: 3px; }
text-decoration: none;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 8px 11px;
padding: 6px 9px;
font-size: 0.84rem;
background: var(--panel);
}
.export-actions a:hover { border-color: var(--accent); color: var(--accent); }
.filters {
padding: 12px 20px;
height: 54px;
padding: 6px 14px;
display: grid;
grid-template-columns: repeat(4, minmax(130px, 175px)) minmax(190px, 1fr) auto;
gap: 10px;
@@ -103,7 +104,7 @@ button, a, select, input, textarea { outline-offset: 3px; }
background: #091321;
}
.filters label, .inline-fields label, .text-field { display: grid; gap: 6px; }
.filters label, .inline-fields label, .text-field, .confidence-field { display: grid; gap: 3px; }
.filters label > span, .inline-fields label > span, .text-field > span {
color: var(--muted);
font-size: 0.75rem;
@@ -118,17 +119,17 @@ select, input[type="search"], input[type="text"], .text-field input, textarea {
background: #0a1525;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 9px 10px;
min-height: 39px;
padding: 5px 8px;
min-height: 30px;
}
textarea { resize: vertical; line-height: 1.45; }
::placeholder { color: #65758d; }
.button {
min-height: 39px;
min-height: 30px;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 9px 12px;
padding: 5px 10px;
color: var(--text);
cursor: pointer;
background: var(--panel-2);
@@ -139,12 +140,13 @@ textarea { resize: vertical; line-height: 1.45; }
.button:disabled { cursor: wait; opacity: 0.55; }
.workspace {
min-height: calc(100vh - 142px);
padding: 14px;
height: calc(100vh - 110px);
min-height: 0;
padding: 10px;
display: grid;
grid-template-columns: minmax(230px, 0.66fr) minmax(460px, 1.65fr) minmax(365px, 1fr);
gap: 14px;
align-items: start;
gap: 10px;
align-items: stretch;
}
.panel {
@@ -153,10 +155,12 @@ textarea { resize: vertical; line-height: 1.45; }
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
min-height: 0;
}
.queue-panel, .review-panel { position: sticky; top: 152px; max-height: calc(100vh - 166px); }
.queue-panel, .review-panel { height: 100%; }
.queue-panel { display: flex; flex-direction: column; }
.review-panel { overflow-y: auto; }
.review-panel { display: flex; flex-direction: column; }
.evidence-panel { height: 100%; display: flex; flex-direction: column; }
.panel-heading, .event-header, .section-heading {
display: flex;
@@ -164,7 +168,7 @@ textarea { resize: vertical; line-height: 1.45; }
gap: 16px;
align-items: center;
}
.panel-heading { padding: 15px 16px 12px; border-bottom: 1px solid var(--line); }
.panel-heading { padding: 9px 12px 8px; border-bottom: 1px solid var(--line); }
.panel-heading h2, .section-heading h3 { margin: 2px 0 0; font-size: 1rem; }
.eyebrow { color: var(--accent); font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; }
.count-pill, .badge {
@@ -180,18 +184,18 @@ textarea { resize: vertical; line-height: 1.45; }
}
.badge.accent { color: var(--accent); border-color: rgba(94, 234, 212, 0.45); background: var(--accent-dim); }
.queue-list { overflow-y: auto; padding: 7px; }
.queue-list { min-height: 0; overflow-y: auto; padding: 5px; }
.queue-item {
width: 100%;
border: 1px solid transparent;
border-radius: 9px;
padding: 10px;
padding: 7px 8px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
display: grid;
gap: 6px;
gap: 3px;
}
.queue-item:hover { background: rgba(255,255,255,0.035); }
.queue-item.active { border-color: var(--accent); background: var(--accent-dim); }
@@ -201,129 +205,190 @@ textarea { resize: vertical; line-height: 1.45; }
.queue-status { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: #516078; }
.queue-meta { color: var(--muted); font-size: 0.74rem; display: flex; gap: 8px; }
.queue-label { font-size: 0.73rem; color: #cbd7e7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-limit { margin: 0; padding: 10px 14px; color: var(--muted); font-size: 0.75rem; border-top: 1px solid var(--line); }
.queue-limit { margin: 0; padding: 6px 10px; color: var(--muted); font-size: 0.7rem; border-top: 1px solid var(--line); }
.event-header { padding: 16px 18px; }
.event-header h2 { margin: 8px 0 3px; font-size: 1.2rem; }
.event-header { padding: 9px 14px 7px; }
.event-header h2 { margin: 5px 0 1px; font-size: 1.05rem; }
.event-header p { margin: 0; color: var(--muted); font-size: 0.82rem; }
.event-badges { display: flex; gap: 6px; flex-wrap: wrap; }
.llm-suggestion {
margin: 0 18px 14px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.3);
border-radius: 10px;
background: rgba(94, 234, 212, 0.07);
color: #c9f8ef;
font-size: 0.8rem;
line-height: 1.45;
}
.llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; }
.decision-guide { margin: 0 14px 8px; display: grid; grid-template-columns: minmax(170px, .52fr) minmax(0, 1.48fr); gap: 7px; }
.review-question { padding: 9px 11px; border: 1px solid rgba(251,191,36,.28); border-left: 3px solid var(--amber); border-radius: 8px; background: rgba(251,191,36,.055); display: grid; align-content: center; gap: 5px; }
.review-question span { color: var(--amber); font-size: .62rem; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; }
.review-question strong { font-size: .78rem; line-height: 1.35; }
.ai-decision-card { padding: 8px 10px; border: 1px solid rgba(94,234,212,.38); border-radius: 8px; background: linear-gradient(115deg, rgba(94,234,212,.1), rgba(17,29,48,.38)); box-shadow: inset 3px 0 0 var(--accent); display: grid; gap: 6px; }
.ai-decision-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.ai-decision-heading span { color: var(--accent); font-size: .66rem; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; }
.ai-decision-heading span i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px rgba(94,234,212,.1); }
.ai-decision-heading em { color: var(--muted); font-style: normal; font-size: .68rem; font-variant-numeric: tabular-nums; }
.ai-decision-main { display: grid; grid-template-columns: auto auto minmax(0,1fr); align-items: center; gap: 9px; }
.ai-verdict { min-width: 116px; padding: 7px 9px; border-radius: 6px; background: rgba(251,191,36,.12); color: #ffd66e; font-size: .82rem; letter-spacing: .02em; text-align: center; }
.decision-arrow { color: #5e718b; font-size: 1rem; }
.ai-decision-main b { display: block; color: var(--text); font-size: .82rem; }
.ai-decision-main p { margin: 2px 0 0; color: #c3d2e4; font-size: .72rem; line-height: 1.3; }
.ai-rationale { padding-top: 5px; border-top: 1px solid rgba(94,234,212,.16); display: grid; grid-template-columns: auto minmax(0,1fr); gap: 7px; }
.ai-rationale span { color: var(--muted); font-size: .66rem; white-space: nowrap; }
.ai-rationale p { margin: 0; color: #d7e6f5; font-size: .7rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ai-decision-card[data-verdict="POSITIVE"] { border-color: rgba(52,211,153,.48); box-shadow: inset 3px 0 0 var(--success); }
.ai-decision-card[data-verdict="POSITIVE"] .ai-verdict { color: #86efac; background: rgba(52,211,153,.13); }
.ai-decision-card[data-verdict="NOT_APPLICABLE"] { border-color: var(--line-strong); box-shadow: inset 3px 0 0 #71829a; }
.ai-decision-card[data-verdict="NOT_APPLICABLE"] .ai-verdict { color: #c4d0df; background: rgba(113,130,154,.14); }
.ai-decision-card[data-verdict="UNSURE"] { border-color: rgba(167,139,250,.48); box-shadow: inset 3px 0 0 #a78bfa; }
.ai-decision-card[data-verdict="UNSURE"] .ai-verdict { color: #c4b5fd; background: rgba(167,139,250,.13); }
.ai-decision-card[data-verdict="EXCLUDE"] { border-color: rgba(251,113,133,.48); box-shadow: inset 3px 0 0 var(--danger); }
.ai-decision-card[data-verdict="EXCLUDE"] .ai-verdict { color: #fda4af; background: rgba(251,113,133,.13); }
.event-position { color: var(--muted); font-size: 0.8rem; }
.timeline-section { padding: 0 18px 16px; display: grid; gap: 10px; }
.timeline-section { padding: 0 14px 7px; display: grid; gap: 6px; }
.timeline-section .section-heading { margin-top: 2px; }
.local-heading { margin-top: 8px !important; }
.timeline-heading { align-items: end; }
.timeline-controls { display: flex; align-items: center; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 8px; background: #07101d; }
.timeline-controls button { min-width: 30px; height: 28px; padding: 0 8px; color: #bed0e6; border: 0; border-right: 1px solid var(--line); background: transparent; cursor: pointer; font-size: .72rem; font-weight: 750; }
.timeline-controls button:last-child { border-right: 0; }
.timeline-controls button:hover { color: var(--accent); background: var(--accent-dim); }
.timeline-readout { display: flex; align-items: baseline; gap: 9px; color: var(--muted); font-variant-numeric: tabular-nums; }
.timeline-readout strong { color: var(--amber); font-size: .78rem; min-width: 28px; }
.timeline-readout span { font-size: .72rem; }
.timeline-readout small { margin-left: auto; font-size: .65rem; }
.local-heading { margin-top: 2px !important; }
.session-overview-details { border-bottom: 1px solid var(--line); }
.session-overview-details > summary { min-height: 30px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); cursor: pointer; list-style: none; font-size: .74rem; }
.session-overview-details > summary::-webkit-details-marker { display: none; }
.session-overview-details > summary span { display: flex; align-items: baseline; gap: 8px; }
.session-overview-details > summary b { color: #cbd7e7; }
.session-overview-details > summary small { font-size: .67rem; }
.session-overview-details > summary em { font-style: normal; font-size: .66rem; }
.session-overview-details[open] > summary em { color: var(--accent); }
.session-overview-body { display: grid; gap: 7px; padding: 6px 0 9px; }
.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 { display: grid; grid-template-columns: 52px 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-events { position: absolute; left: 64px; right: 7px; bottom: 4px; height: 20px; border-top: 1px solid var(--line-strong); }
.session-marker { position: absolute; top: 3px; width: 7px; min-width: 7px; max-width: 100%; height: 15px; margin-left: -3px; padding: 0; border: 0; border-radius: 4px; background: var(--amber); cursor: pointer; opacity: .68; transition: opacity 120ms ease, transform 120ms ease; }
.session-marker:hover { opacity: 1; transform: translateY(-1px); z-index: 4; }
.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-timeline { display: grid; gap: 4px; color: var(--muted); font-size: .76rem; }
.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: 22px; position: relative; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; background: #07101d; }
.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; }
.speech-segment { position: absolute; top: 5px; height: 10px; min-width: 2px; border-radius: 3px; background: #60a5fa; }
.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; }
.speaker-evidence { margin: 5px 0 0 66px; min-height: 42px; display: grid; grid-template-columns: auto 1fr; column-gap: 8px; align-items: baseline; padding: 6px 9px; border: 1px solid rgba(94,234,212,.25); border-left: 3px solid var(--accent); border-radius: 6px; background: rgba(94,234,212,.055); }
.speaker-evidence > strong { color: var(--accent); font-size: .76rem; white-space: nowrap; }
.speaker-evidence > span { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; }
.speaker-evidence > p { grid-column: 1 / -1; margin: 3px 0 0; color: #dce9f8; font-size: .78rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.video-shell { background: #02050a; aspect-ratio: 16 / 9; position: relative; border-block: 1px solid var(--line); }
.evidence-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .8fr); border-top: 1px solid var(--line); }
.media-column, .transcript-column { min-width: 0; min-height: 0; }
.media-column { display: flex; flex-direction: column; background: #02050a; }
.transcript-column { display: flex; flex-direction: column; border-left: 1px solid var(--line); }
.video-shell { flex: 1; min-height: 0; background: #02050a; position: relative; }
.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); }
.empty-state strong { color: var(--text); }
.time-marker { position: absolute; pointer-events: none; left: 50%; top: 10px; bottom: 44px; border-left: 1px dashed rgba(251,191,36,.7); opacity: 0; }
.video-shell:hover .time-marker { opacity: .55; }
.transport { padding: 10px 14px; display: flex; gap: 7px; align-items: center; border-bottom: 1px solid var(--line); }
.transport button { color: var(--muted); border: 1px solid var(--line); background: #0a1525; border-radius: 7px; padding: 6px 9px; cursor: pointer; }
.transport { flex: 0 0 auto; padding: 6px 8px; display: flex; gap: 5px; align-items: center; border-top: 1px solid var(--line); }
.transport button { color: var(--muted); border: 1px solid var(--line); background: #0a1525; border-radius: 6px; padding: 4px 6px; cursor: pointer; font-size: .72rem; }
.transport button:hover { color: var(--accent); border-color: var(--accent); }
.transport .jump-event { color: var(--amber); }
#timeReadout { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 0.8rem; }
.transcript-section { padding: 16px 18px 8px; }
.section-heading { margin-bottom: 10px; }
.transcript-section { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 9px 10px 5px; }
.section-heading { margin-bottom: 6px; }
.section-heading > span { color: var(--muted); font-size: .78rem; }
.transcript-list { display: grid; gap: 7px; max-height: 270px; overflow-y: auto; padding-right: 3px; }
.transcript-row { display: grid; grid-template-columns: 72px minmax(0,1fr); gap: 10px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: rgba(2, 8, 18, 0.36); }
.transcript-list { flex: 1; min-height: 0; display: grid; align-content: start; gap: 5px; overflow-y: auto; padding-right: 3px; }
.transcript-row { display: grid; grid-template-columns: 64px minmax(0,1fr); gap: 7px; padding: 6px 7px; border: 1px solid var(--line); border-radius: 7px; background: rgba(2, 8, 18, 0.36); }
.transcript-row.anchor { border-color: rgba(251,191,36,.65); background: rgba(251,191,36,.08); }
.transcript-row[data-uid] { cursor: pointer; }
.transcript-row.linked { border-color: rgba(94,234,212,.75); background: rgba(94,234,212,.09); box-shadow: inset 3px 0 0 var(--accent); }
.transcript-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.speaker { color: var(--accent); font-size: .75rem; font-weight: 800; }
.speaker small { color: var(--muted); display: block; margin-top: 3px; font-weight: 500; }
.utterance { line-height: 1.46; font-size: .9rem; }
.utterance { line-height: 1.35; font-size: .82rem; }
.utterance small { display: block; color: var(--muted); margin-top: 4px; font-size: .72rem; }
.empty-copy { color: var(--muted); padding: 22px; text-align: center; border: 1px dashed var(--line-strong); border-radius: 8px; }
.raw-details { margin: 10px 18px 18px; border-top: 1px solid var(--line); padding-top: 12px; }
.raw-details { flex: 0 0 auto; margin: 4px 10px 8px; border-top: 1px solid var(--line); padding-top: 6px; }
.raw-details summary { color: var(--muted); cursor: pointer; font-size: .82rem; }
.raw-details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 260px; overflow-y: auto; color: #bfd0e7; font-size: .74rem; line-height: 1.5; }
.review-heading { background: rgba(94,234,212,.035); }
.save-state { color: var(--muted); font-size: .75rem; }
.save-state.saved { color: var(--success); }
#reviewForm { padding: 13px 15px 16px; display: grid; gap: 14px; }
#reviewForm { min-height: 0; overflow-y: auto; padding: 9px 11px 10px; display: grid; gap: 9px; }
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 8px; }
legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; }
legend { font-size: .8rem; font-weight: 750; margin-bottom: 5px; }
.required { color: var(--muted); font-size: .66rem; font-weight: 500; }
.choice-grid { display: grid; gap: 7px; }
.choice-grid.three { grid-template-columns: repeat(3, 1fr); }
.choice-grid.two { grid-template-columns: repeat(2, 1fr); }
.choice-grid.action-grid { grid-template-columns: repeat(2, 1fr); }
.choice-grid label { cursor: pointer; min-width: 0; }
.choice-grid input { position: absolute; opacity: 0; pointer-events: none; }
.choice-grid label span { min-height: 52px; border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: grid; align-content: center; gap: 2px; background: rgba(2,8,18,.28); }
.choice-grid label span { min-height: 44px; border: 1px solid var(--line); border-radius: 8px; padding: 6px 7px; display: grid; align-content: center; gap: 1px; background: rgba(2,8,18,.28); }
.choice-grid.compact label span { min-height: 40px; }
.choice-grid label:hover span { border-color: var(--line-strong); }
.choice-grid input:checked + span { border-color: var(--accent); background: var(--accent-dim); box-shadow: inset 0 0 0 1px rgba(94,234,212,.18); }
.choice-grid input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.choice-grid b { font-size: .8rem; overflow: hidden; text-overflow: ellipsis; }
.choice-grid small { color: var(--muted); font-size: .67rem; line-height: 1.25; }
.choice-grid .accept-option { grid-column: 1 / -1; }
.choice-grid .accept-option span { min-height: 62px; padding: 8px 10px; border-color: rgba(94,234,212,.34); background: linear-gradient(100deg, rgba(94,234,212,.09), rgba(2,8,18,.24)); box-shadow: inset 3px 0 0 rgba(94,234,212,.72); }
.choice-grid .accept-option em { color: var(--accent); font-size: .6rem; font-style: normal; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; }
.choice-grid .accept-option b { color: #ecfffb; font-size: .9rem; }
.choice-grid .accept-option small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.choice-grid .accept-option input:checked + span { border-color: var(--accent); background: rgba(94,234,212,.17); box-shadow: inset 4px 0 0 var(--accent), 0 0 0 1px rgba(94,234,212,.12); }
.choice-grid input:disabled + span { cursor: not-allowed; opacity: .42; filter: grayscale(.45); }
.inline-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.confidence-field { color: var(--muted); font-size: .72rem; font-weight: 700; }
.confidence-field select { color: var(--text); font-size: .82rem; }
.inline-fields select { font-size: .82rem; }
.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; }
.review-actions { display: grid; grid-template-columns: 1fr; 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; }
.boundary-details, .optional-details { border-top: 1px solid var(--line); padding-top: 7px; }
.boundary-details summary, .optional-details summary { color: var(--muted); cursor: pointer; font-size: .75rem; margin-bottom: 6px; }
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 { flex: 0 0 auto; border-top: 1px solid var(--line); padding: 7px 11px 8px; display: flex; flex-wrap: wrap; gap: 5px 10px; color: var(--muted); font-size: .68rem; }
.shortcuts kbd { color: #cbd7e7; border-color: var(--line-strong); margin-right: 2px; }
.toast { position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 20px); background: #d9fff9; color: #05251f; border-radius: 9px; padding: 10px 15px; font-weight: 750; font-size: .84rem; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transition: 160ms ease; z-index: 40; }
.toast.visible { opacity: 1; transform: translate(-50%, 0); }
@media (max-width: 1180px) {
html, body { height: auto; overflow: auto; }
.topbar { grid-template-columns: 1fr minmax(250px, 380px); }
.export-actions { display: none; }
.workspace { grid-template-columns: 220px minmax(430px, 1fr); }
.review-panel { position: static; max-height: none; grid-column: 1 / -1; }
.queue-panel { top: 152px; }
.workspace { height: auto; min-height: calc(100vh - 110px); grid-template-columns: 220px minmax(430px, 1fr); }
.review-panel { height: auto; grid-column: 1 / -1; }
.queue-panel, .evidence-panel { min-height: 720px; }
.filters { grid-template-columns: repeat(3, 1fr); }
.search-field { grid-column: span 2; }
}
@media (max-width: 760px) {
.topbar { position: static; grid-template-columns: 1fr; gap: 12px; }
.filters { grid-template-columns: 1fr 1fr; padding: 10px; }
.topbar { position: static; height: auto; grid-template-columns: 1fr; gap: 12px; }
.filters { height: auto; grid-template-columns: 1fr 1fr; padding: 10px; }
.search-field { grid-column: 1 / -1; }
.workspace { grid-template-columns: 1fr; padding: 10px; }
.queue-panel, .review-panel { position: static; max-height: none; }
.queue-panel { max-height: 340px; }
.evidence-grid { grid-template-columns: 1fr; }
.decision-guide { grid-template-columns: 1fr; }
.transcript-column { min-height: 420px; border-left: 0; border-top: 1px solid var(--line); }
.transport { flex-wrap: wrap; }
#timeReadout { width: 100%; margin: 2px 0 0; }
.choice-grid.three { grid-template-columns: 1fr; }
.choice-grid.action-grid { grid-template-columns: 1fr; }
}

View File

@@ -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)))

View File

@@ -29,33 +29,55 @@
</aside>
<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 class="event-header"><div><div class="event-badges"><span id="familyBadge" class="badge accent">—</span><span id="splitBadge" 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>AI 预标注(不是最终标签)</strong><span id="llmSuggestionText">—</span></div>
<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 class="decision-guide" aria-label="当前审核目标">
<div class="review-question"><span>本条需要判断</span><strong id="reviewQuestion">—</strong></div>
<div id="llmSuggestion" class="ai-decision-card" hidden>
<div class="ai-decision-heading"><span><i></i>AI 建议</span><em id="aiConfidence">置信度 —</em></div>
<div class="ai-decision-main">
<strong id="aiVerdict" class="ai-verdict">—</strong>
<span class="decision-arrow">→</span>
<div><b id="aiLabel">—</b><p id="aiMeaning">—</p></div>
</div>
<div class="ai-rationale"><span>判断依据</span><p id="aiRationale">—</p></div>
</div>
</section>
<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="timeline-section">
<details class="session-overview-details">
<summary><span><b>Session overview</b><small>全局候选与说话活动</small></span><em>按需展开</em></summary>
<div class="session-overview-body">
<div class="section-heading timeline-heading"><div><span class="eyebrow">SESSION OVERVIEW</span><h3>本 Session 候选分布</h3></div><div class="timeline-controls" aria-label="时间轴缩放"><button id="timelinePanLeft" type="button" title="向前平移">←</button><button id="timelineZoomOut" type="button" title="缩小">−</button><button id="timelineFocus" type="button" title="聚焦当前事件">聚焦</button><button id="timelineZoomIn" type="button" title="放大">+</button><button id="timelinePanRight" type="button" title="向后平移">→</button><button id="timelineReset" type="button" title="显示完整 Session">全局</button></div></div>
<div class="timeline-readout"><strong id="timelineZoomLabel">8×</strong><span id="timelineRange">—</span><small>marker 跳转 · Shift + 滚轮缩放</small></div>
<div id="sessionTimeline" class="session-timeline"></div>
</div>
</details>
<div class="section-heading local-heading"><div><span class="eyebrow">LOCAL SPEAKER ACTIVITY</span><h3>事件附近 participant timeline</h3></div><span>W = wearer · P# = Participant_ID</span></div>
<div id="speakerTimeline" class="speaker-timeline"></div>
<div id="speakerEvidence" class="speaker-evidence" aria-live="polite"><span>悬停或点击任意 activity 区段,转写会固定显示在这里。</span></div>
</section>
<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>
<div class="evidence-grid">
<div class="media-column">
<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>
</div>
<div class="transcript-column">
<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>
</div>
</div>
</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>
<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>
<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 class="accept-option"><input id="acceptRadio" type="radio" name="human_action" value="accept" /><span><em>AI 建议</em><b id="acceptTitle">接受 AI 结论</b><small id="acceptSummary">载入候选后显示具体标签</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>
</fieldset>
@@ -69,10 +91,10 @@
<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>
<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>
<label class="confidence-field"><span>置信度</span><select name="confidence"><option value="high">高</option><option value="medium" selected>中</option><option value="low">低</option></select></label>
<details class="optional-details"><summary>备注(可选)</summary><label class="text-field"><textarea name="notes" maxlength="4000" rows="2" placeholder="只记录必要的歧义或证据"></textarea></label></details>
<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>
<div class="review-actions"><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>