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

@@ -304,7 +304,7 @@ Do not reorganize them physically without compatibility wrappers.
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
| `prepare_easycom_eot_int_review.py` | Build wearer-transition and independent hard-negative candidates with structured timing evidence; semantic labels remain AI/human review outputs |
| `prepare_easycom_eot_int_review.py` | Group short non-competed wearer resumptions into floor episodes, pair EOT release/opportunity and wearer-entry behavior anchors, require `wearer_floor_start < other_onset < wearer_offset` plus boundary crossing for INT, preserve ineligible boundaries as controls, and scan independent hard negatives; semantic labels remain AI/human review outputs |
| `adjudicate_easycom_eot_int_candidates.py` | Add optional DeepSeek transcript/timing suggestions; never human ground truth |
| `review_floor_events.py` | Serve the local video/transcript workspace and persist human event reviews |
| `prepare_easycom_oracle_features.py` | Build explicit past-only oracle person-activity sidecars and analysis-only addressee controls |

View File

@@ -4,7 +4,7 @@
The model receives the candidate source, structured participant timing and the
transcript excerpt. It cannot change any source timestamp. Its output is
stored as an auditable suggestion beside the untouched rule candidate and must
still be accepted, relabeled, excluded, or sent to second review by a human.
still be accepted, relabeled, or excluded by a human.
"""
from __future__ import annotations
@@ -17,13 +17,16 @@ import os
from pathlib import Path
import random
import re
import sys
import time
from typing import Any, Sequence
from typing import Any, Sequence, TextIO
import urllib.error
import urllib.request
from prepare_easycom_eot_int_review import requires_semantic_adjudication
PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v2"
PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v6"
DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_BASE_URL = "https://api.deepseek.com"
VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"}
@@ -38,6 +41,51 @@ HARD_NEGATIVE_TYPES = {
"OTHER_HARD_NEGATIVE",
}
def _progress_line(
completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26
) -> str:
fraction = 1.0 if total == 0 else min(1.0, max(0.0, completed / total))
filled = round(width * fraction)
bar = "█" * filled + "░" * (width - filled)
minutes, seconds = divmod(max(0, round(elapsed_seconds)), 60)
return (
f"DeepSeek [{bar}] {completed}/{total} ({fraction * 100:5.1f}%) "
f"{label} · {minutes:02d}:{seconds:02d}"
)
class _ProgressBar:
"""Dependency-free terminal progress with readable redirected logs."""
def __init__(self, total: int, *, stream: TextIO | None = None) -> None:
self.total = total
self.stream = stream or sys.stderr
self.started = time.monotonic()
self.is_tty = bool(getattr(self.stream, "isatty", lambda: False)())
self.last_logged = -1
def update(self, completed: int, label: str, *, final: bool = False) -> None:
line = _progress_line(
completed,
self.total,
label=label,
elapsed_seconds=time.monotonic() - self.started,
)
if self.is_tty:
self.stream.write("\r" + line)
if final:
self.stream.write("\n")
self.stream.flush()
return
# Avoid producing one permanent log line per API batch in redirected jobs.
step = max(1, self.total // 10)
if final or self.last_logged < 0 or completed - self.last_logged >= step:
self.stream.write(line + "\n")
self.stream.flush()
self.last_logged = completed
SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientific study of wearer-relative conversational floor control.
Evidence rules:
@@ -45,20 +93,27 @@ Evidence rules:
2. Never alter or propose an event timestamp. Do not infer prosody, gaze, intention, or acoustic cues that were not supplied.
3. A wearer onset is not automatically a floor take; a wearer offset is not automatically a yield; an other onset is not automatically a takeover. A pause is not automatically EOT, overlap is not automatically interruption, and words such as "yeah" are not automatically backchannels. Check who speaks sustainably afterward and who resumes.
4. Use UNSURE when the evidence does not establish conversational function. Never force a label for coverage.
5. Every supplied WEARER_ONSET has two immutable anchors: a non-wearer release opportunity and a later floor-level wearer entry. Do not reject it solely because response_delay_seconds is long. Decide whether the release remained available, whether the wearer contribution is a genuine response, or whether it is a semantic hard negative such as feedback.
6. Every supplied WEARER_OFFSET has already passed both deterministic INT prerequisites: a new non-wearer onset occurs strictly inside the current wearer floor episode (t_w < t_o < t_y), and that same non-wearer interval crosses the wearer yield boundary within the recorded annotation tolerance. Decide only whether it is a genuine takeover or a semantic hard negative. Pre-existing other activity, post-completion other onset, within-turn wearer pauses, and completion-to-silence are controls outside this queue.
7. Transcript event markers are evidence, not lexical turns. In EasyCom, a transcription consisting of [L] denotes laughter-only vocalization. Do not treat [L] as a sustained floor take, an EOT response, or an interruption takeover merely because its activity interval creates an onset, offset, or overlap candidate. Specifically:
- wearer [L] during a non-wearer's retained turn is normally HARD_NEGATIVE / EOT_WEARER_BACKCHANNEL, not EOT_RESPONSE;
- non-wearer [L] overlapping a wearer's retained turn is normally HARD_NEGATIVE / INT_OTHER_BACKCHANNEL, not INT_YIELD;
- wearer [L] ending while a non-wearer continues does not establish that the wearer held and yielded the floor.
Use UNSURE if the surrounding timing does not establish whose floor was retained. Do not EXCLUDE a valid laughter-only annotation merely because it is non-lexical, and do not infer that a visible smile occurred: [L] supplies laughter evidence only. Do not assign semantics to other bracketed markers unless the supplied context establishes them.
Top-level verdicts:
- POSITIVE: the system should commit.
- HARD_NEGATIVE: a valid, interpretable region where the system should not commit.
- NOT_APPLICABLE: a real speech transition outside the committed EOT/INT task, such as a wearer starting after long silence or naturally ending into silence.
- EXCLUDE: the region cannot be scored reliably because of annotation corruption, a recording boundary, missing context, a non-speech artifact, timestamp corruption, or another unresolvable defect.
- UNSURE: evidence is insufficient and a second human review is needed.
- UNSURE: evidence is insufficient for a reliable automatic pre-label and needs human judgment.
POSITIVE event_type must be exactly EOT_RESPONSE or INT_YIELD. EOT_RESPONSE means a non-wearer genuinely releases the floor and the wearer then takes it with a sustained response. INT_YIELD means a non-wearer genuinely takes over during wearer speech and the wearer relinquishes the floor.
HARD_NEGATIVE subtype must be exactly one of EOT_MIDTURN_PAUSE, EOT_OTHER_TO_OTHER, EOT_WEARER_BACKCHANNEL, INT_OTHER_BACKCHANNEL, INT_NON_TAKEOVER_OVERLAP, INT_WEARER_HOLDS_FLOOR, or OTHER_HARD_NEGATIVE.
Return exactly one JSON object with this shape and no markdown:
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence","needs_human_review":true}]}
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence"}]}
Return every supplied uid exactly once. Copy uid byte-for-byte and never add a backslash before an underscore. confidence must be a number from 0 to 1."""
@@ -171,7 +226,6 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
subtype = item.get("subtype")
confidence = item.get("confidence")
reason = item.get("reason")
needs_human_review = item.get("needs_human_review")
if uid not in expected or uid in decisions:
raise ValueError(f"Unexpected or duplicate uid: {raw_uid!r}")
if verdict not in VERDICTS:
@@ -188,8 +242,6 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
raise ValueError(f"confidence must be in [0, 1] for {uid}")
if not isinstance(reason, str) or not reason.strip():
raise ValueError(f"Missing reason for {uid}")
if not isinstance(needs_human_review, bool):
raise ValueError(f"needs_human_review must be boolean for {uid}")
decisions[str(uid)] = {
"uid": str(uid),
"verdict": str(verdict),
@@ -197,7 +249,10 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
"subtype": subtype,
"confidence": float(confidence),
"reason": reason.strip(),
"needs_human_review": needs_human_review,
# Every AI decision enters the human queue. This is deliberately
# local rather than model-generated, so harmless JSON type drift
# cannot invalidate and retry an otherwise valid batch.
"needs_human_review": True,
}
if set(decisions) != expected:
raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}")
@@ -276,28 +331,47 @@ def add_suggestions(
usage: Counter[str] = Counter()
if batch_size <= 0:
raise ValueError("batch_size must be positive")
for pass_index in range(passes):
for start in range(0, len(rows), batch_size):
batch = rows[start:start + batch_size]
transported, _ = _transport_rows(batch)
prompt_hash = hashlib.sha256(
(SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8")
).hexdigest()
decisions, pass_usage = _post(
rows=batch,
pass_index=pass_index,
api_key=api_key,
model=model,
base_url=base_url,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
)
for decision in decisions:
by_uid[decision["uid"]].append(decision)
prompt_hashes[decision["uid"]].append(prompt_hash)
for key, value in pass_usage.items():
if isinstance(value, int):
usage[key] += value
total_work = len(rows) * passes
batches_per_pass = (len(rows) + batch_size - 1) // batch_size
completed_work = 0
progress = _ProgressBar(total_work)
progress.update(0, f"pass 1/{passes} · waiting")
try:
for pass_index in range(passes):
for batch_index, start in enumerate(range(0, len(rows), batch_size), 1):
batch = rows[start:start + batch_size]
progress.update(
completed_work,
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
)
transported, _ = _transport_rows(batch)
prompt_hash = hashlib.sha256(
(SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8")
).hexdigest()
decisions, pass_usage = _post(
rows=batch,
pass_index=pass_index,
api_key=api_key,
model=model,
base_url=base_url,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
)
for decision in decisions:
by_uid[decision["uid"]].append(decision)
prompt_hashes[decision["uid"]].append(prompt_hash)
for key, value in pass_usage.items():
if isinstance(value, int):
usage[key] += value
completed_work += len(batch)
progress.update(
completed_work,
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
)
except Exception:
progress.update(completed_work, "failed", final=True)
raise
progress.update(completed_work, "complete", final=True)
output: list[dict[str, Any]] = []
for source in rows:
@@ -370,14 +444,19 @@ def main() -> None:
args = parse_args()
if args.passes <= 0 or args.batch_size <= 0 or args.max_retries < 0:
raise ValueError("passes/batch-size must be positive and max-retries non-negative")
rows = read_candidates(args.input.expanduser().resolve())
input_rows = read_candidates(args.input.expanduser().resolve())
rows = [row for row in input_rows if requires_semantic_adjudication(row)]
skipped_controls = len(input_rows) - len(rows)
if args.max_events is not None:
if args.max_events < 0:
raise ValueError("--max-events must be non-negative")
rng = random.Random(args.selection_seed)
rows = rng.sample(rows, min(args.max_events, len(rows)))
rows.sort(key=lambda row: row["uid"])
print(f"Candidates={len(rows)} model={args.model} passes={args.passes}")
print(
f"Candidates={len(rows)} skipped_deterministic_controls={skipped_controls} "
f"model={args.model} passes={args.passes}"
)
if args.dry_run:
print("DRY RUN: no API request will be made.\n")
print(build_user_prompt(rows, 0))

View File

@@ -6,9 +6,9 @@ ground truth.
```text
EasyCom participant speech intervals
-> rule candidates + structured timing evidence
-> floor-episode grouping + rule candidates + structured timing evidence
-> DeepSeek semantic pre-label (optional but recommended)
-> human Accept / Relabel / Exclude / Second review
-> human Accept / Relabel / Exclude
```
## 1. Generate candidates
@@ -22,17 +22,46 @@ python3 prepare_easycom_eot_int_review.py \
--output-dir reports/easycom_floor_event_candidates
```
The generator emits two independent sources:
The generator emits two independent files:
- every wearer `0->1` (`WEARER_ONSET`, `ENTRY_CANDIDATE`) and `1->0`
(`WEARER_OFFSET`, `YIELD_CANDIDATE`) transition;
- hard-negative regions that need no wearer transition:
- `easycom_eot_int_candidates.jsonl` contains EOT opportunities that pair a
non-wearer release with a later floor-level wearer `0->1` behavior anchor
(`WEARER_ONSET`, `EOT_OPPORTUNITY_CANDIDATE`), only episode-ending wearer
`1->0` boundaries where a new non-wearer onset satisfies
`wearer_floor_start < other_onset < wearer_offset` and the same activity
crosses the offset (`WEARER_OFFSET`, `YIELD_CANDIDATE`), and hard-negative
regions that need no wearer transition:
`SAME_SPEAKER_PAUSE`, `OTHER_TO_OTHER`, `OTHER_BACKCHANNEL`, and
`NON_TAKEOVER_OVERLAP`.
`NON_TAKEOVER_OVERLAP`;
- `easycom_eot_int_controls.jsonl` contains short within-turn raw onsets
(`WEARER_RESUME`), floor-level wearer entries with no surviving preceding
release opportunity (`WEARER_ONSET_CONTROL`), and wearer offsets that fail
the strict INT ordering/crossing prerequisite (`WEARER_OFFSET_CONTROL`). They
are retained for audits, but are not sent to DeepSeek or placed in the
primary human-review queue.
Wearer transitions are initially `unsure`; timing alone is not a semantic
positive label. Hard-negative scans are rule hints and still require AI and
human review. The JSONL retains candidate source, original event time,
A raw wearer onset is merged into its preceding floor episode only when its
preceding silence is at most 0.8 seconds and no plausible sustained non-wearer
takeover occurs in that gap. Short feedback-like lexical or nonverbal activity
is recorded but does not by itself split the episode. A non-feedback other
contribution that occupies at least `continuation_min_seconds` of the gap or
continues across the resumed onset conservatively preserves a new wearer-entry
candidate. Every decision is stored in `temporal_evidence`, including the raw
gap, feedback IDs, competing IDs, threshold, and `floor_episode_merged` flag.
After each non-wearer release, EOT construction scans forward until a
floor-level wearer onset or a competing non-wearer onset. Silence length alone
does not invalidate the opportunity. Each primary EOT candidate stores both
`opportunity_anchor_seconds` and `behavior_anchor_seconds`, plus their response
delay.
Paired wearer onsets and strictly qualified offsets are initially `unsure`;
timing alone is not a semantic positive label. The INT prerequisite is
`wearer_floor_start < other.start < wearer_offset < other.end`, with a
configurable one-frame (50 ms) tolerance only at the offset boundary.
Pre-existing other activity and raw offsets inside a merged wearer episode stay
in the controls file. Hard-negative scans are rule hints and still require AI
and human review. The JSONL retains candidate source, original event time,
participant-level activity, raw durations/gaps/overlap evidence, transcript,
and `Target_of_Speech` when present. `Target_of_Speech` is annotation assistance
only and cannot be a deployable model input.
@@ -45,6 +74,8 @@ All heuristic durations are configurable rather than frozen paper rules:
--other-handover-max-seconds
--short-vocalization-max-seconds
--continuation-min-seconds
--int-offset-tolerance-seconds
--wearer-resume-merge-max-seconds
```
Use `--max-candidates 1` for a one-event smoke test. `summary.json` records the
@@ -54,6 +85,20 @@ timeline; it is not sent to DeepSeek.
## 2. Add DeepSeek pre-labels
Prompt v6 treats the EasyCom transcript marker `[L]` as laughter-only evidence,
not as a lexical floor take. Wearer laughter during another participant's
retained turn is directed toward `EOT_WEARER_BACKCHANNEL`; non-wearer laughter
overlapping a retained wearer turn is directed toward `INT_OTHER_BACKCHANNEL`.
Ambiguous laughter remains `UNSURE`, and `[L]` alone is neither an exclusion
reason nor evidence of a visible smile. The prompt also treats the two EOT
anchors and strict INT ordering as deterministic prerequisites rather than
asking the model to reconstruct them.
`needs_human_review` is not requested from DeepSeek: every pre-label enters the
human queue by protocol, so the adjudicator assigns that boolean locally. This
prevents a string-valued model field from invalidating and retrying an otherwise
valid batch.
First inspect exactly what would be uploaded:
```bash
@@ -83,6 +128,14 @@ transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`,
cannot modify timestamps. The output preserves both the original rule candidate
and the complete AI decision/provenance.
During live adjudication, the CLI displays a dependency-free progress bar over
all candidate/pass work, including the current pass, batch, percentage, and
elapsed time. Redirected logs emit periodic progress lines instead of carriage
return animation.
As defense in depth, the adjudicator also detects and skips non-crossing
`WEARER_OFFSET` rows from older v2 candidate files before making any API call.
## 3. Human review
Run the web workspace on the AI-enriched JSONL:
@@ -91,22 +144,45 @@ Run the web workspace on the AI-enriched JSONL:
python3 review_floor_events.py \
--dataset-root /path/to/easycom \
--events reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.jsonl \
--output reports/easycom_event_reviews/reviewer_1.jsonl
--output reports/easycom_event_reviews/reviews.jsonl
```
Open `http://127.0.0.1:8765`. The page shows a session candidate overview,
participant activity near the event, video/audio, transcript, structured timing,
and the AI verdict/confidence/reason. The common path is one operation:
and the AI verdict/confidence/reason. A decision guide first states the behavioral
question, then presents the exact AI verdict/subtype with a plain-language
meaning; the Accept control repeats that exact label so its effect is explicit.
The common path is one operation:
- **Accept** keeps the AI verdict/type;
- **Relabel** expands verdict and subtype controls;
- **Exclude** requires a concrete data-quality reason;
- **Unsure / Second review** preserves the event for adjudication.
- **Exclude** requires a concrete data-quality reason.
The current primary-review UI deliberately has no second-review action. Older
saved `second_review` records remain readable for backward compatibility.
Boundary correction is hidden under an optional disclosure and is limited to
one 200 ms bin. The original timestamp is always retained. Each save atomically
rewrites JSONL and CSV while preserving the rule candidate, AI decision, human
decision, relabel flag, exclusion state, reviewer, and provenance.
decision, relabel flag, exclusion state, confidence, and provenance. The current
UI does not ask for a reviewer name; the export retains an empty `reviewer` field
only for compatibility with older review files.
The primary review queue also filters legacy non-crossing `WEARER_OFFSET` rows
and explicit `WEARER_RESUME` controls. To audit the controls intentionally,
pass `--events .../easycom_eot_int_controls.jsonl --include-controls` and use a
separate review output file. The old `--include-offset-controls` spelling
remains an alias.
Protocol wording for the paper appendix:
> Raw wearer speech onsets are first grouped into floor-level speech episodes
> by merging short within-turn resumptions that are not separated by a
> competing floor takeover. Only the resulting floor-level 0→1 transitions are
> proposed as EOT-response candidates.
> The original speech intervals are preserved unchanged for dense forecasting
> and auditability; this grouping is used only for behavioral event annotation.
## Freeze discipline

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>

View File

@@ -514,6 +514,130 @@ signed latency, causal-control evidence, and main limitation. If no learned
variant passes these tests, reframe the submission as an information audit or
do not make the method claim.}
\appendix
\section{LLM-Assisted Behavioral Event Annotation}
\label{app:event_annotation}
EasyCom provides participant-resolved speech intervals, transcripts, and
target-of-speech metadata, but raw speech transitions do not uniquely specify
conversational floor function. A wearer onset can be a genuine floor entry, a
backchannel, or a within-turn resumption; a wearer offset can reflect yielding,
natural completion, or a temporary pause. We therefore construct a separate
behavioral-event layer while preserving the original speech intervals used for
dense activity targets. The pipeline has three stages: deterministic candidate
construction, language-model-assisted semantic pre-labeling, and mandatory
human adjudication. The language model never supplies final ground truth.
\subsection{Deterministic candidate construction}
\textbf{Floor-level wearer entries.}
Raw wearer onsets are conditionally grouped into floor-level episodes. Let
$g$ be the silence from the preceding wearer offset to a new raw onset. In the
current audit configuration, an onset is stored as a
\textsc{Wearer-Resume} control rather than proposed as an EOT entry when
$g\leq0.8$~s and the gap contains no plausible competing non-wearer floor
activity. A non-feedback contribution that occupies at least $0.4$~s of the
gap, or continues across the resumed wearer onset, conservatively preserves a
new entry candidate. A short ($\leq1.0$~s) feedback-like lexical or nonverbal
contribution is retained as evidence but does not by itself split the wearer
episode. These rules group events only; they do not modify the source
annotations.
Following each candidate non-wearer release, the generator opens an EOT
opportunity and scans forward until a floor-level wearer entry or a competing
non-wearer onset terminates it. A retained \textsc{Wearer-Onset} candidate
therefore contains two immutable anchors: the non-wearer release
(\emph{opportunity anchor}) and the subsequent floor-level wearer onset
(\emph{behavior anchor}). The response delay is stored explicitly. Silence
duration alone does not cancel the opportunity; an unpaired wearer onset is
kept only as an audit control and does not enter the primary EOT queue.
\textbf{EOT hard negatives.}
Two participant-resolved timing rules independently propose regions in which
the wearer should remain silent. A \textsc{Same-Speaker-Pause} region is
created when a non-wearer resumes within 2~s and both the wearer and all other
participants remain silent in the intervening gap. An
\textsc{Other-to-Other} region is created when a different non-wearer begins
within 1.5~s, the original speaker does not first resume, and the wearer stays
silent through the handover. The semantic pre-labeler can additionally assign
\textsc{EOT-Wearer-Backchannel} to a floor-level onset that is interpretable
as feedback rather than a response.
\textbf{INT candidates and hard negatives.}
Let $t_w$ be the start of the current floor-level wearer episode, $t_o$ a new
non-wearer onset, and $t_y$ the episode-ending wearer offset. A potential
\textsc{INT-Yield} is proposed only when
\begin{equation}
t_w < t_o < t_y < t_o^{\mathrm{off}},
\label{eq:int_candidate}
\end{equation}
with a one-frame (50-ms) tolerance only at the offset boundary in the current
audit. Thus the competing activity must begin after the wearer acquired the
floor and must continue across the wearer yield. Pre-existing non-wearer
activity, post-completion onsets, offsets internal to merged wearer episodes,
and offsets without crossing activity are retained separately as
\textsc{Wearer-Offset-Control} records and are not sent for semantic
pre-labeling. Thus ordinary completion-to-silence cases do not enter the
primary INT review queue.
INT hard-negative regions are also proposed without requiring a wearer
offset. If a non-wearer contribution ends while the wearer remains active
throughout it and for at least another 0.4~s, the region is labeled by rule as
\textsc{INT-Other-Backchannel} when it is short and feedback-like, and
\textsc{INT-Non-Takeover-Overlap} otherwise. These timing labels are candidate
priors, not final semantic annotations.
\subsection{Semantic pre-labeling}
For every primary candidate, the annotation assistant receives the candidate
source and rule type, immutable timestamps, participant-resolved activity,
neighboring onsets and offsets, gap and overlap measurements, transcript
context, and target-of-speech metadata when available. Participant identities
are used only inside this annotation package. The assistant is instructed to
treat timing as factual, not to invent prosody, gaze, gesture, intention, or
other unavailable cues, and not to equate onset with floor entry, offset with
yield, silence with EOT, or overlap with interruption. EasyCom's \texttt{[L]}
marker is treated as evidence of laughter only: it is not by itself a sustained
floor take or takeover.
The allowed decisions are \textsc{Positive}, \textsc{Hard-Negative},
\textsc{Not-Applicable}, \textsc{Exclude}, and \textsc{Unsure}. Positive
events are typed as \textsc{EOT-Response} or \textsc{INT-Yield}; hard
negatives use a fixed subtype vocabulary. The current pre-labeling run uses
\texttt{deepseek-v4-flash}, temperature zero, and two passes: a primary pass
and a skeptical pass that seeks alternative explanations. If they disagree on
the verdict, event type, or hard-negative subtype, the combined suggestion is
\textsc{Unsure}. Model identifier, prompt version, per-request prompt hashes,
both raw decisions, confidence values, and explanations are stored with every
candidate.
\subsection{Human adjudication and scoring eligibility}
Every AI-pre-labeled candidate enters an event-centered interface containing
the original video, local participant timeline, transcript, deterministic
timing evidence, and the AI suggestion. The adjudicator performs one primary
action: accept the complete AI label, relabel it, or exclude an unscorable
sample. Relabeling exposes the same constrained verdict, event-type, and
hard-negative subtype vocabulary. A confidence level is required; notes are
optional, and an optional $\pm0.2$~s boundary correction is available only for
an evident source-annotation error. The original event time is always retained.
Only the completed human decision is eligible to become semantic ground truth.
\textsc{Not-Applicable} denotes a valid observation outside the committed
EOT/INT task, whereas \textsc{Exclude} denotes a region that cannot be scored
reliably because of annotation corruption, a recording boundary, missing
context, timestamp corruption, or another unresolved defect. Neither category
enters the primary event denominator. Transcripts, participant identities,
target-of-speech metadata, annotation-model outputs, and future observations
are never inputs to the evaluated forecasting model.
\todo{Before freezing this annotation protocol, audit randomly sampled
continuous timeline segments without candidate hints to estimate candidate
recall for EOT, INT, and each hard-negative family. Independently double-label
a pre-registered subset, report its size and agreement statistic, freeze all
candidate parameters and prompt/model identifiers on Sessions 4--12, and
report final candidate, control, exclusion, and adjudicated-label counts.}
\bibliographystyle{IEEEbib}
\bibliography{references}

View File

@@ -2,9 +2,12 @@
"""Generate wearer-centric EasyCom EOT/INT candidates for AI and human review.
This stage never changes EasyCom speech boundaries and never claims semantic
ground truth. It emits every wearer transition plus independent participant-
resolved hard-negative regions. All timing thresholds are CLI parameters, and
raw evidence is retained for DeepSeek pre-labeling and human correction.
ground truth. It groups short wearer resumptions into floor-level episodes,
pairs EOT candidates with a preceding non-wearer release, requires a new
non-wearer onset inside the wearer floor episode for INT candidates, and keeps
unpaired or ineligible raw boundaries as deterministic controls. All timing
thresholds are CLI parameters, and raw evidence is retained for DeepSeek
pre-labeling and human correction.
"""
from __future__ import annotations
@@ -24,8 +27,8 @@ from derive_easycom_interaction_events import (
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
SCHEMA_VERSION = 2
PROTOCOL_VERSION = "easycom-wearer-centric-eot-int-candidates-v2"
SCHEMA_VERSION = 3
PROTOCOL_VERSION = "easycom-wearer-centric-eot-int-candidates-v5"
@dataclass(frozen=True)
@@ -36,6 +39,8 @@ class CandidateConfig:
other_handover_max_seconds: float = 1.5
short_vocalization_max_seconds: float = 1.0
continuation_min_seconds: float = 0.4
int_offset_tolerance_seconds: float = 0.05
wearer_resume_merge_max_seconds: float = 0.8
@dataclass(frozen=True)
@@ -79,6 +84,32 @@ class ProtocolCandidate:
exclude_reason: None = None
def requires_semantic_adjudication(row: dict[str, Any]) -> bool:
"""Return whether a stored row belongs in the primary AI/human queue.
Explicit v3--v5 control sources are authoritative. For old v2 candidate
files, reconstruct the same-speaker crossing requirement from the stored
activity IDs. Missing legacy evidence remains reviewable rather than being
silently discarded.
"""
source = row.get("candidate_source")
if source in {"WEARER_ONSET_CONTROL", "WEARER_OFFSET_CONTROL", "WEARER_RESUME"}:
return False
if source != "WEARER_OFFSET":
return True
evidence = row.get("temporal_evidence")
if not isinstance(evidence, dict):
return True
explicit = evidence.get("other_crosses_wearer_offset")
if isinstance(explicit, bool):
return explicit
before = evidence.get("active_other_ids_before_offset")
after = evidence.get("active_other_ids_after_offset")
if isinstance(before, list) and isinstance(after, list):
return bool(set(before) & set(after))
return True
def _fill_states(rows: Sequence[Utterance], *, segment_start: int, segment_end: int,
wearer_id: int) -> tuple[bytearray, bytearray]:
length = segment_end - segment_start
@@ -120,6 +151,68 @@ def _active_other_ids(rows: Sequence[Utterance], frame: int, *, wearer_id: int)
}))
def _other_ids_crossing_offset(
rows: Sequence[Utterance], *, frame: int, wearer_id: int, tolerance_frames: int
) -> tuple[int, ...]:
"""Return other speakers whose annotated interval crosses a wearer offset.
With zero tolerance this encodes the strict prerequisite
``other.start < wearer_offset < other.end``. A one-frame tolerance accepts
an annotation boundary at the same 20 Hz frame, but not an other onset one
or more frames after the wearer offset.
"""
return tuple(sorted({
row.participant_id for row in rows
if row.participant_id != wearer_id
and row.dense_start_frame < frame + tolerance_frames
and row.dense_end_frame_exclusive > frame - tolerance_frames
}))
def _wearer_gap_competition(
rows: Sequence[Utterance], *, gap_start_frame: int, onset_frame: int,
wearer_id: int, short_frames: int, continuation_frames: int,
) -> dict[str, Any]:
"""Summarize plausible competing floor activity inside a short wearer gap.
Feedback-like short lexical/nonverbal activity is retained as evidence but
does not by itself split the wearer floor episode. Any other contribution
that occupies the gap for ``continuation_frames`` or continues across the
resumed wearer onset conservatively preserves a new ENTRY candidate.
"""
gap_rows = [
row for row in rows
if row.participant_id != wearer_id
and row.dense_start_frame < onset_frame
and row.dense_end_frame_exclusive > gap_start_frame
]
feedback_ids: set[int] = set()
competing_ids: set[int] = set()
max_gap_coverage = 0
for row in gap_rows:
coverage = max(
0,
min(row.dense_end_frame_exclusive, onset_frame)
- max(row.dense_start_frame, gap_start_frame),
)
max_gap_coverage = max(max_gap_coverage, coverage)
feedback_like = (
row.dense_end_frame_exclusive - row.dense_start_frame <= short_frames
and (is_lexical_feedback(row.transcription) or is_pure_nonverbal(row.transcription))
)
if feedback_like:
feedback_ids.add(row.participant_id)
elif coverage >= continuation_frames or row.dense_end_frame_exclusive > onset_frame:
competing_ids.add(row.participant_id)
return {
"gap_other_participant_ids": sorted({row.participant_id for row in gap_rows}),
"feedback_like_other_ids_during_gap": sorted(feedback_ids),
"sustained_competing_other_ids_during_gap": sorted(competing_ids),
"max_other_gap_coverage_seconds": max_gap_coverage / SOURCE_FPS,
"sustained_other_takeover_during_gap": bool(competing_ids),
}
def _clip_for_frame(clips: Sequence[Clip], frame: int) -> Clip | None:
return next((clip for clip in clips
if clip.dense_start_frame <= frame < clip.dense_start_frame + clip.num_frames), None)
@@ -216,7 +309,7 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
config: CandidateConfig | None = None,
window_seconds: float | None = None,
context_seconds: float | None = None) -> list[ProtocolCandidate]:
"""Return every wearer transition and independent hard-negative regions."""
"""Return primary semantic candidates plus separate offset controls."""
cfg = config or CandidateConfig()
values = asdict(cfg)
if window_seconds is not None:
@@ -232,6 +325,8 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
handover_frames = round(cfg.other_handover_max_seconds * SOURCE_FPS)
short_frames = round(cfg.short_vocalization_max_seconds * SOURCE_FPS)
continuation_frames = round(cfg.continuation_min_seconds * SOURCE_FPS)
int_offset_tolerance_frames = round(cfg.int_offset_tolerance_seconds * SOURCE_FPS)
wearer_resume_merge_frames = round(cfg.wearer_resume_merge_max_seconds * SOURCE_FPS)
rows_by_segment: dict[int, list[Utterance]] = defaultdict(list)
clips_by_segment: dict[int, list[Clip]] = defaultdict(list)
for row in utterances:
@@ -254,52 +349,256 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
segment_end = segment_clips[-1].dense_start_frame + segment_clips[-1].num_frames
wearer, others = _fill_states(rows, segment_start=segment_start,
segment_end=segment_end, wearer_id=wearer_id)
other_rows = [row for row in rows if row.participant_id != wearer_id]
# A: all wearer 0->1 and 1->0 transitions, without semantic guessing.
# A: classify raw wearer onsets before proposing any behavioral event.
# Short uncompeted gaps are internal to a floor episode. All other raw
# onsets begin a new floor episode, but become EOT candidates only when
# they can be paired with a preceding non-wearer release opportunity.
onset_records: dict[int, dict[str, Any]] = {}
raw_offsets: list[int] = []
for index in range(1, len(wearer)):
frame = segment_start + index
if not wearer[index - 1] and wearer[index]:
gap_frames = _run_before(wearer, index, False)
previous_wearer_index = index - gap_frames - 1
has_previous_wearer = previous_wearer_index >= 0 and bool(wearer[previous_wearer_index])
previous_wearer_offset = segment_start + index - gap_frames
competition = _wearer_gap_competition(
rows,
gap_start_frame=previous_wearer_offset,
onset_frame=frame,
wearer_id=wearer_id,
short_frames=short_frames,
continuation_frames=continuation_frames,
) if has_previous_wearer else {
"gap_other_participant_ids": [],
"feedback_like_other_ids_during_gap": [],
"sustained_competing_other_ids_during_gap": [],
"max_other_gap_coverage_seconds": 0.0,
"sustained_other_takeover_during_gap": False,
}
within_turn_resume = (
has_previous_wearer
and gap_frames <= wearer_resume_merge_frames
and not competition["sustained_other_takeover_during_gap"]
)
evidence = _relation_evidence(rows, frame=frame, wearer_id=wearer_id,
window_frames=relation_frames)
evidence.update(
wearer_speech_duration_after_onset_seconds=_run_after(wearer, index, True) / SOURCE_FPS,
previous_wearer_silence_duration_seconds=_run_before(wearer, index, False) / SOURCE_FPS,
previous_wearer_silence_duration_seconds=gap_frames / SOURCE_FPS,
previous_wearer_offset_seconds=(
previous_wearer_offset / SOURCE_FPS if has_previous_wearer else None
),
wearer_resume_merge_max_seconds=cfg.wearer_resume_merge_max_seconds,
floor_episode_merged=within_turn_resume,
original_timestamp_seconds=frame / SOURCE_FPS,
**competition,
)
add(_candidate(
session=session, task="eot_response", source="WEARER_ONSET",
rule_type="ENTRY_CANDIDATE", anchor_type="wearer_onset", frame=frame,
region_start=frame, region_end=min(segment_end, frame + relation_frames),
label="unsure", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="observed wearer 0->1 transition; semantic EOT-response status is unresolved",
), ("WEARER_ONSET", frame, ()))
onset_records[frame] = {
"within_turn_resume": within_turn_resume,
"evidence": evidence,
}
elif wearer[index - 1] and not wearer[index]:
evidence = _relation_evidence(rows, frame=frame, wearer_id=wearer_id,
window_frames=relation_frames)
evidence.update(
wearer_speech_duration_before_offset_seconds=_run_before(wearer, index, True) / SOURCE_FPS,
wearer_silence_duration_after_offset_seconds=_run_after(wearer, index, False) / SOURCE_FPS,
active_other_ids_before_offset=list(_active_other_ids(rows, frame - 1, wearer_id=wearer_id)),
active_other_ids_after_offset=list(_active_other_ids(rows, frame, wearer_id=wearer_id)),
original_timestamp_seconds=frame / SOURCE_FPS,
)
add(_candidate(
session=session, task="int_yield", source="WEARER_OFFSET",
rule_type="YIELD_CANDIDATE", anchor_type="wearer_offset", frame=frame,
region_start=max(segment_start, frame - relation_frames),
region_end=min(segment_end, frame + relation_frames), label="unsure", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="observed wearer 1->0 transition; semantic INT-yield status is unresolved",
), ("WEARER_OFFSET", frame, ()))
raw_offsets.append(frame)
other_rows = [row for row in rows if row.participant_id != wearer_id]
resume_onsets = {
frame for frame, record in onset_records.items() if record["within_turn_resume"]
}
floor_onsets = sorted(set(onset_records) - resume_onsets)
for frame in sorted(resume_onsets):
record = onset_records[frame]
add(_candidate(
session=session, task="eot_response_control", source="WEARER_RESUME",
rule_type="WITHIN_TURN_RESUME_CONTROL", anchor_type="wearer_onset", frame=frame,
region_start=frame, region_end=min(segment_end, frame + relation_frames),
label="not_applicable", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id),
evidence=record["evidence"], clips=segment_clips, rows=rows,
wearer_id=wearer_id, context_frames=context_frames,
reason="short raw wearer pause without sustained competing other takeover; merged into the existing floor episode",
), ("WEARER_RESUME", frame, ()))
# A1: open an opportunity at a non-wearer release and pair it with the
# subsequent floor-level wearer entry only if no competing other onset
# or wearer activity intervenes. Long silence alone does not invalidate
# the opportunity. Unpaired floor onsets are retained as controls.
paired_floor_onsets: set[int] = set()
for release in other_rows:
opportunity_frame = release.dense_end_frame_exclusive
onset_frame = next(
(value for value in floor_onsets if value >= opportunity_frame), None
)
if onset_frame is None:
continue
wearer_silent = (
opportunity_frame == onset_frame
or _all_state(
wearer,
opportunity_frame - segment_start,
onset_frame - segment_start,
False,
)
)
competing_onsets = [
row for row in other_rows
if opportunity_frame <= row.dense_start_frame < onset_frame
]
other_already_holds = bool(
_active_other_ids(rows, opportunity_frame, wearer_id=wearer_id)
)
if not wearer_silent or competing_onsets or other_already_holds:
continue
evidence = dict(onset_records[onset_frame]["evidence"])
evidence.update(
opportunity_anchor_type="other_offset",
opportunity_anchor_frame=opportunity_frame,
opportunity_anchor_seconds=opportunity_frame / SOURCE_FPS,
opportunity_speaker_id=release.participant_id,
opportunity_source_uid=release.uid,
opportunity_source_transcription=release.transcription,
opportunity_source_target_of_speech=list(release.target_of_speech),
behavior_anchor_type="wearer_floor_onset",
behavior_anchor_frame=onset_frame,
behavior_anchor_seconds=onset_frame / SOURCE_FPS,
response_delay_seconds=(onset_frame - opportunity_frame) / SOURCE_FPS,
competing_other_onsets_before_behavior=[],
eot_opportunity_paired=True,
)
add(_candidate(
session=session, task="eot_response", source="WEARER_ONSET",
rule_type="EOT_OPPORTUNITY_CANDIDATE", anchor_type="wearer_onset",
frame=onset_frame, region_start=opportunity_frame,
region_end=min(segment_end, onset_frame + relation_frames),
label="unsure", subtype=None, previous_ids=(release.participant_id,),
incoming_ids=_active_other_ids(rows, onset_frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="non-wearer release opportunity paired with a subsequent floor-level wearer entry",
), ("WEARER_ONSET", onset_frame, (release.participant_id,)))
paired_floor_onsets.add(onset_frame)
for frame in floor_onsets:
if frame in paired_floor_onsets:
continue
evidence = dict(onset_records[frame]["evidence"])
evidence.update(
opportunity_anchor_type=None,
opportunity_anchor_frame=None,
opportunity_anchor_seconds=None,
behavior_anchor_type="wearer_floor_onset",
behavior_anchor_frame=frame,
behavior_anchor_seconds=frame / SOURCE_FPS,
eot_opportunity_paired=False,
)
add(_candidate(
session=session, task="eot_response_control", source="WEARER_ONSET_CONTROL",
rule_type="UNPAIRED_WEARER_ENTRY_CONTROL", anchor_type="wearer_onset", frame=frame,
region_start=frame, region_end=min(segment_end, frame + relation_frames),
label="not_applicable", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="floor-level wearer entry has no surviving preceding non-wearer release opportunity",
), ("WEARER_ONSET_CONTROL", frame, ()))
# A2: a raw wearer offset is an INT candidate only at the end of a
# floor-level wearer episode and only if a new non-wearer onset obeys
# t_w < t_o < t_y and that same interval crosses the yield boundary.
# All other offsets remain auditable controls.
ordered_raw_onsets = sorted(onset_records)
current_floor_start = segment_start if wearer and wearer[0] else None
floor_start_by_offset: dict[int, tuple[int | None, bool]] = {}
for index in range(1, len(wearer)):
frame = segment_start + index
if not wearer[index - 1] and wearer[index] and frame not in resume_onsets:
current_floor_start = frame
elif wearer[index - 1] and not wearer[index]:
next_onset = next((value for value in ordered_raw_onsets if value > frame), None)
internal_pause = next_onset in resume_onsets
floor_start_by_offset[frame] = (current_floor_start, internal_pause)
if not internal_pause:
current_floor_start = None
for frame in raw_offsets:
index = frame - segment_start
floor_start, internal_pause = floor_start_by_offset[frame]
strict_crossing_ids = _other_ids_crossing_offset(
other_rows, frame=frame, wearer_id=wearer_id, tolerance_frames=0
)
tolerance_crossing_ids = _other_ids_crossing_offset(
other_rows, frame=frame, wearer_id=wearer_id,
tolerance_frames=int_offset_tolerance_frames,
)
eligible_rows = [
row for row in other_rows
if floor_start is not None
and floor_start < row.dense_start_frame < frame
and row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames
]
eligible_ids = tuple(sorted({row.participant_id for row in eligible_rows}))
preexisting_ids = tuple(sorted({
row.participant_id for row in other_rows
if floor_start is not None
and row.dense_start_frame <= floor_start
and row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames
}))
is_int_candidate = bool(eligible_ids) and not internal_pause
evidence = _relation_evidence(
rows, frame=frame, wearer_id=wearer_id, window_frames=relation_frames
)
evidence.update(
wearer_floor_episode_start_frame=floor_start,
wearer_floor_episode_start_seconds=(
None if floor_start is None else floor_start / SOURCE_FPS
),
wearer_speech_duration_before_offset_seconds=_run_before(wearer, index, True) / SOURCE_FPS,
wearer_silence_duration_after_offset_seconds=_run_after(wearer, index, False) / SOURCE_FPS,
raw_offset_is_within_merged_floor_episode=internal_pause,
active_other_ids_before_offset=list(_active_other_ids(rows, frame - 1, wearer_id=wearer_id)),
active_other_ids_after_offset=list(_active_other_ids(rows, frame, wearer_id=wearer_id)),
strict_crossing_other_ids=list(strict_crossing_ids),
tolerance_crossing_other_ids=list(tolerance_crossing_ids),
eligible_new_other_onset_ids=list(eligible_ids),
eligible_new_other_onsets=[{
"participant_id": row.participant_id,
"onset_seconds": row.dense_start_seconds,
"offset_seconds": row.dense_end_seconds,
"uid": row.uid,
} for row in eligible_rows],
preexisting_crossing_other_ids=list(preexisting_ids),
other_crosses_wearer_offset=bool(tolerance_crossing_ids),
int_ordering_satisfied=bool(eligible_ids),
int_offset_tolerance_seconds=cfg.int_offset_tolerance_seconds,
original_timestamp_seconds=frame / SOURCE_FPS,
)
control_reason = (
"raw wearer offset is internal to a merged floor episode"
if internal_pause
else "no new non-wearer onset satisfies t_w < t_o < t_y and crosses the wearer offset"
)
add(_candidate(
session=session,
task="int_yield" if is_int_candidate else "int_yield_control",
source="WEARER_OFFSET" if is_int_candidate else "WEARER_OFFSET_CONTROL",
rule_type="YIELD_CANDIDATE" if is_int_candidate else "WEARER_OFFSET_CONTROL",
anchor_type="wearer_offset", frame=frame,
region_start=max(segment_start, frame - relation_frames),
region_end=min(segment_end, frame + relation_frames),
label="unsure" if is_int_candidate else "not_applicable", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=eligible_ids,
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason=(
"new non-wearer onset occurs inside the wearer floor episode and crosses the yield boundary; semantic takeover status is unresolved"
if is_int_candidate else control_reason
),
), ("WEARER_OFFSET" if is_int_candidate else "WEARER_OFFSET_CONTROL", frame, eligible_ids))
# B1: same speaker pauses and resumes while the wearer remains silent.
by_speaker: dict[int, list[Utterance]] = defaultdict(list)
@@ -437,6 +736,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--other-handover-max-seconds", type=float, default=1.5)
parser.add_argument("--short-vocalization-max-seconds", type=float, default=1.0)
parser.add_argument("--continuation-min-seconds", type=float, default=0.4)
parser.add_argument("--int-offset-tolerance-seconds", type=float, default=0.05)
parser.add_argument("--wearer-resume-merge-max-seconds", type=float, default=0.8)
parser.add_argument("--candidate-sources", nargs="+")
parser.add_argument("--max-candidates", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
@@ -455,6 +756,8 @@ def main() -> None:
other_handover_max_seconds=args.other_handover_max_seconds,
short_vocalization_max_seconds=args.short_vocalization_max_seconds,
continuation_min_seconds=args.continuation_min_seconds,
int_offset_tolerance_seconds=args.int_offset_tolerance_seconds,
wearer_resume_merge_max_seconds=args.wearer_resume_merge_max_seconds,
)
dataset_root, output_dir = args.dataset_root.expanduser().resolve(), args.output_dir.expanduser().resolve()
all_rows: list[ProtocolCandidate] = []
@@ -475,10 +778,14 @@ def main() -> None:
all_rows.extend(rows)
print(f"{session}: candidates={len(rows)} sources={dict(sorted(Counter(row.candidate_source for row in rows).items()))}")
sources = None if args.candidate_sources is None else set(args.candidate_sources)
eligible = [row for row in all_rows if sources is None or row.candidate_source in sources]
controls = [row for row in all_rows if not requires_semantic_adjudication(asdict(row))]
primary = [row for row in all_rows if requires_semantic_adjudication(asdict(row))]
eligible = [row for row in primary if sources is None or row.candidate_source in sources]
selected = stratified_limit(eligible, args.max_candidates, seed=args.selection_seed)
output_path = output_dir / "easycom_eot_int_candidates.jsonl"
_write_jsonl(output_path, selected)
controls_path = output_dir / "easycom_eot_int_controls.jsonl"
_write_jsonl(controls_path, controls)
counts = Counter((row.split, row.candidate_source, row.rule_candidate_type) for row in selected)
summary = {
"schema_version": SCHEMA_VERSION, "protocol_version": PROTOCOL_VERSION, "dataset": "EasyCom",
@@ -487,8 +794,10 @@ def main() -> None:
"candidate_config": asdict(config), "selection_seed": args.selection_seed,
"max_candidates": args.max_candidates,
"candidate_sources": None if sources is None else sorted(sources),
"num_candidates_before_filter": len(all_rows), "num_candidates_before_limit": len(eligible),
"num_candidates_before_filter": len(primary), "num_candidates_before_limit": len(eligible),
"num_candidates_written": len(selected),
"num_controls_written": len(controls),
"controls_file": controls_path.name,
"counts": [{"split": key[0], "candidate_source": key[1],
"rule_candidate_type": key[2], "count": value}
for key, value in sorted(counts.items())],
@@ -501,6 +810,7 @@ def main() -> None:
encoding="utf-8",
)
print(f"Saved {len(selected)} wearer-centric review candidates to {output_path}")
print(f"Saved {len(controls)} deterministic boundary controls to {controls_path}")
if __name__ == "__main__":

View File

@@ -27,6 +27,8 @@ from threading import Lock
from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse
from prepare_easycom_eot_int_review import requires_semantic_adjudication
SOURCE_FPS = 20.0
STATIC_ROOT = Path(__file__).resolve().parent / "floor_event_review_web"
@@ -201,18 +203,22 @@ def _candidate_digest(path: Path, source_row: int, payload: dict[str, Any]) -> s
return hashlib.sha256(encoded).hexdigest()[:20]
def load_candidates(paths: Iterable[Path]) -> list[Candidate]:
def load_candidates(
paths: Iterable[Path], *, include_offset_controls: bool = False
) -> list[Candidate]:
candidates: list[Candidate] = []
ids: set[str] = set()
for path in paths:
for source_row, payload in enumerate(_read_jsonl(path), 1):
if not include_offset_controls and not requires_semantic_adjudication(payload):
continue
candidate_id = _candidate_digest(path, source_row, payload)
if candidate_id in ids:
raise ValueError(f"Duplicate candidate identity in {path}:{source_row}")
ids.add(candidate_id)
candidates.append(Candidate(candidate_id, path, source_row, payload))
if not candidates:
raise ValueError("Candidate files contain no rows")
raise ValueError("Candidate files contain no primary review rows")
return candidates
@@ -342,12 +348,20 @@ class CandidateRepository:
"ai_subtype": row.get("ai_subtype"),
"participant_role": row.get("participant_role") or row.get("holder_role"),
"event_time_seconds": event_time,
"region_start_seconds": row.get("region_start_seconds", event_time),
"region_end_seconds": row.get("region_end_seconds", event_time),
"reviewed": reviewed,
}
def detail(self, candidate: Candidate) -> dict[str, Any]:
clip_stem, local_seconds, transcript_index = self._location(candidate)
transcript_rows: list[dict[str, Any]] = []
evidence_rows = candidate.payload.get("speaker_timeline") or candidate.payload.get("context") or []
wearer_ids = {
row.get("participant_id")
for row in evidence_rows
if isinstance(row, dict) and row.get("participant_role") == "wearer"
}
if clip_stem is not None:
for index, row in enumerate(self._transcript(candidate.session, clip_stem)):
try:
@@ -362,10 +376,14 @@ class CandidateRepository:
continue
transcript_rows.append(
{
"uid": f"{candidate.session}/{clip_stem}/{index:04d}",
"index": index,
"start_seconds": start,
"end_seconds": end,
"participant_id": row.get("Participant_ID"),
"participant_role": (
"wearer" if row.get("Participant_ID") in wearer_ids else "other"
),
"target_of_speech": row.get("Target_of_Speech"),
"transcription": str(row.get("Transcription", "")),
"is_anchor": (
@@ -478,14 +496,13 @@ class ReviewStore:
original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")),
)
reviewer = str(payload.get("reviewer", "")).strip()
notes = str(payload.get("notes", "")).strip()
if len(reviewer) > 100:
raise ValueError("reviewer is too long")
if not reviewer:
raise ValueError("reviewer is required")
if len(notes) > 4000:
raise ValueError("notes are too long")
# Keep an empty compatibility field in exports written by the reviewer-less UI.
reviewer = str(payload.get("reviewer", "")).strip()
if len(reviewer) > 100:
raise ValueError("reviewer is too long")
normalized["reviewer"] = reviewer
normalized["notes"] = notes
return normalized
@@ -633,6 +650,20 @@ def parse_byte_range(value: str | None, size: int) -> tuple[int, int] | None:
return start, min(end, size - 1)
def stream_file_range(handle: Any, write: Any, remaining: int) -> bool:
"""Stream a media range; return False when the browser cancels normally."""
try:
while remaining:
chunk = handle.read(min(64 * 1024, remaining))
if not chunk:
break
write(chunk)
remaining -= len(chunk)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
return False
return True
class ReviewHTTPServer(ThreadingHTTPServer):
daemon_threads = True
@@ -814,13 +845,7 @@ class ReviewRequestHandler(BaseHTTPRequestHandler):
return
with path.open("rb") as handle:
handle.seek(start)
remaining = end - start + 1
while remaining:
chunk = handle.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
stream_file_range(handle, self.wfile.write, end - start + 1)
def parse_args() -> argparse.Namespace:
@@ -842,6 +867,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--context-before-seconds", type=float, default=3.0)
parser.add_argument("--context-after-seconds", type=float, default=3.0)
parser.add_argument(
"--include-controls", "--include-offset-controls",
dest="include_offset_controls",
action="store_true",
help="Include deterministic wearer-resume and non-overlap offset controls for a separate audit.",
)
return parser.parse_args()
@@ -859,7 +890,9 @@ def main() -> None:
output_path = args.output.expanduser().resolve()
candidate_paths = expand_candidate_paths(args.events, output_path=output_path)
candidates = load_candidates(candidate_paths)
candidates = load_candidates(
candidate_paths, include_offset_controls=args.include_offset_controls
)
repository = CandidateRepository(
candidates,
dataset_root,

View File

@@ -8,11 +8,18 @@ import json
import tempfile
from adjudicate_easycom_eot_int_candidates import (
_transport_rows, build_user_prompt, read_candidates, validate_decisions,
PROMPT_VERSION, SYSTEM_PROMPT, _progress_line, _transport_rows, build_user_prompt, read_candidates,
requires_semantic_adjudication, validate_decisions,
)
class EOTINTAdjudicationTests(unittest.TestCase):
def test_progress_line_reports_items_percent_pass_and_elapsed_time(self) -> None:
line = _progress_line(25, 100, label="pass 1/2 · batch 4/13", elapsed_seconds=65)
self.assertIn("25/100 ( 25.0%)", line)
self.assertIn("pass 1/2 · batch 4/13", line)
self.assertTrue(line.endswith("01:05"))
def test_prompt_contains_task_and_context(self) -> None:
prompt = build_user_prompt(
[{"uid": "u1", "candidate_source": "WEARER_ONSET", "task": "eot_response",
@@ -24,6 +31,16 @@ class EOTINTAdjudicationTests(unittest.TestCase):
self.assertIn("wearer_duration", prompt)
self.assertIn("Yes.", prompt)
def test_system_prompt_treats_laughter_as_nonlexical_floor_feedback(self) -> None:
self.assertEqual(PROMPT_VERSION, "easycom-wearer-centric-eot-int-prelabel-v6")
self.assertIn("two immutable anchors", SYSTEM_PROMPT)
self.assertIn("t_w < t_o < t_y", SYSTEM_PROMPT)
self.assertIn("a transcription consisting of [L] denotes laughter-only vocalization", SYSTEM_PROMPT)
self.assertIn("EOT_WEARER_BACKCHANNEL", SYSTEM_PROMPT)
self.assertIn("INT_OTHER_BACKCHANNEL", SYSTEM_PROMPT)
self.assertIn("do not infer that a visible smile occurred", SYSTEM_PROMPT)
self.assertNotIn('"needs_human_review"', SYSTEM_PROMPT)
def test_decisions_require_exact_uid_and_vocabulary(self) -> None:
payload = {
"items": [
@@ -40,9 +57,21 @@ class EOTINTAdjudicationTests(unittest.TestCase):
}
decisions = validate_decisions(payload, ["u1"])
self.assertEqual(decisions[0]["subtype"], "INT_OTHER_BACKCHANNEL")
self.assertTrue(decisions[0]["needs_human_review"])
with self.assertRaisesRegex(ValueError, "mismatch"):
validate_decisions({"items": []}, ["u1"])
def test_model_needs_human_review_type_drift_is_ignored(self) -> None:
payload = {
"items": [{
"uid": "u1", "verdict": "UNSURE", "event_type": None,
"subtype": None, "confidence": 0.4, "reason": "Ambiguous context.",
"needs_human_review": "true",
}]
}
decision = validate_decisions(payload, ["u1"])[0]
self.assertTrue(decision["needs_human_review"])
def test_markdown_escaped_uid_is_normalized_but_still_unique(self) -> None:
item = {
"uid": "Session\\_4/wearer\\_onset/00000024",
@@ -79,6 +108,35 @@ class EOTINTAdjudicationTests(unittest.TestCase):
self.assertEqual([row["uid"] for row in transported], ["item-0000", "item-0001"])
self.assertEqual(mapping["item-0001"], rows[1]["uid"])
def test_old_nonoverlap_wearer_offset_is_not_sent_for_adjudication(self) -> None:
control = {
"candidate_source": "WEARER_OFFSET",
"temporal_evidence": {
"active_other_ids_before_offset": [],
"active_other_ids_after_offset": [],
},
}
crossing = {
"candidate_source": "WEARER_OFFSET",
"temporal_evidence": {
"active_other_ids_before_offset": [4],
"active_other_ids_after_offset": [4],
},
}
speaker_change = {
"candidate_source": "WEARER_OFFSET",
"temporal_evidence": {
"active_other_ids_before_offset": [3],
"active_other_ids_after_offset": [4],
},
}
self.assertFalse(requires_semantic_adjudication(control))
self.assertTrue(requires_semantic_adjudication(crossing))
self.assertFalse(requires_semantic_adjudication(speaker_change))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_OFFSET_CONTROL"}))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_ONSET_CONTROL"}))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_RESUME"}))
if __name__ == "__main__":
unittest.main()

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import unittest
from derive_easycom_interaction_events import Clip, Utterance
from prepare_easycom_eot_int_review import derive_protocol_candidates
from prepare_easycom_eot_int_review import CandidateConfig, derive_protocol_candidates
def utterance(uid: str, participant: int, start: float, end: float, text: str) -> Utterance:
@@ -50,18 +50,83 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
def derive(self, rows: list[Utterance]):
return derive_protocol_candidates(rows, [clip()], session="Session_4")
def test_every_wearer_onset_is_an_unresolved_entry_candidate(self) -> None:
def test_eot_candidate_pairs_release_and_behavior_anchors(self) -> None:
rows = [
utterance("other", 4, 0.0, 1.0, "Your turn."),
utterance("wearer", 2, 1.2, 2.0, "My response."),
]
event = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_ONSET")
self.assertEqual(event.automatic_protocol_label, "unsure")
self.assertEqual(event.rule_candidate_type, "ENTRY_CANDIDATE")
self.assertEqual(event.rule_candidate_type, "EOT_OPPORTUNITY_CANDIDATE")
self.assertEqual(event.anchor_type, "wearer_onset")
self.assertAlmostEqual(event.event_time, 1.2)
self.assertAlmostEqual(event.temporal_evidence["opportunity_anchor_seconds"], 1.0)
self.assertAlmostEqual(event.temporal_evidence["behavior_anchor_seconds"], 1.2)
self.assertAlmostEqual(event.temporal_evidence["response_delay_seconds"], 0.2)
self.assertAlmostEqual(event.temporal_evidence["wearer_speech_duration_after_onset_seconds"], 0.8)
def test_long_silence_does_not_cancel_eot_opportunity(self) -> None:
rows = [
utterance("other", 4, 0.0, 0.5, "Your turn."),
utterance("wearer", 2, 3.5, 4.5, "Delayed response."),
]
event = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_ONSET")
self.assertAlmostEqual(event.temporal_evidence["response_delay_seconds"], 3.0)
def test_competing_other_onset_terminates_eot_opportunity(self) -> None:
rows = [
utterance("release", 4, 0.0, 1.0, "First turn."),
utterance("competitor", 3, 1.5, 3.0, "I take the floor."),
utterance("wearer", 2, 2.5, 3.5, "Wearer later."),
]
onset = next(
row for row in self.derive(rows)
if row.anchor_dense_seconds == 2.5 and row.anchor_type == "wearer_onset"
)
self.assertEqual(onset.candidate_source, "WEARER_ONSET_CONTROL")
self.assertEqual(onset.automatic_protocol_label, "not_applicable")
def test_short_wearer_pause_without_other_takeover_becomes_resume_control(self) -> None:
rows = [
utterance("wearer-1", 2, 0.2, 1.0, "I was saying"),
utterance("wearer-2", 2, 1.5, 2.5, "and then this."),
]
derived = self.derive(rows)
resume = next(row for row in derived if row.candidate_source == "WEARER_RESUME")
self.assertEqual(resume.task, "eot_response_control")
self.assertEqual(resume.rule_candidate_type, "WITHIN_TURN_RESUME_CONTROL")
self.assertEqual(resume.automatic_protocol_label, "not_applicable")
self.assertTrue(resume.temporal_evidence["floor_episode_merged"])
self.assertAlmostEqual(resume.temporal_evidence["previous_wearer_silence_duration_seconds"], 0.5)
self.assertFalse(any(
row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.5
for row in derived
))
def test_short_feedback_does_not_split_wearer_floor_episode(self) -> None:
rows = [
utterance("wearer-1", 2, 0.2, 1.0, "First clause"),
utterance("feedback", 4, 1.1, 1.4, "Yeah."),
utterance("wearer-2", 2, 1.6, 2.5, "same turn continues."),
]
resume = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_RESUME")
self.assertEqual(resume.temporal_evidence["feedback_like_other_ids_during_gap"], [4])
self.assertFalse(resume.temporal_evidence["sustained_other_takeover_during_gap"])
def test_short_gap_with_sustained_other_takeover_preserves_floor_reentry_but_not_eot(self) -> None:
rows = [
utterance("wearer-1", 2, 0.2, 1.0, "First wearer turn."),
utterance("other", 4, 1.0, 2.2, "I have the floor now."),
utterance("wearer-2", 2, 1.6, 2.5, "Wearer re-enters."),
]
reentry = next(
row for row in self.derive(rows)
if row.candidate_source == "WEARER_ONSET_CONTROL" and row.anchor_dense_seconds == 1.6
)
self.assertFalse(reentry.temporal_evidence["floor_episode_merged"])
self.assertTrue(reentry.temporal_evidence["sustained_other_takeover_during_gap"])
self.assertEqual(reentry.temporal_evidence["sustained_competing_other_ids_during_gap"], [4])
def test_same_other_resume_is_eot_midturn_hard_negative(self) -> None:
rows = [
utterance("first", 4, 0.0, 1.0, "First clause."),
@@ -93,7 +158,7 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
event.temporal_evidence["incoming_speaker_id"],
)
def test_every_wearer_offset_is_an_unresolved_yield_candidate(self) -> None:
def test_other_crossing_wearer_offset_is_an_unresolved_yield_candidate(self) -> None:
rows = [
utterance("wearer", 2, 0.0, 2.0, "Wearer is speaking."),
utterance("other", 4, 1.0, 3.0, "I will take over now."),
@@ -103,6 +168,63 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
self.assertEqual(event.rule_candidate_type, "YIELD_CANDIDATE")
self.assertEqual(event.anchor_type, "wearer_offset")
self.assertAlmostEqual(event.event_time, 2.0)
self.assertEqual(event.temporal_evidence["tolerance_crossing_other_ids"], [4])
self.assertEqual(event.temporal_evidence["eligible_new_other_onset_ids"], [4])
self.assertAlmostEqual(
event.temporal_evidence["eligible_new_other_onsets"][0]["onset_seconds"], 1.0
)
self.assertTrue(event.temporal_evidence["int_ordering_satisfied"])
def test_preexisting_other_crossing_offset_is_not_an_int_candidate(self) -> None:
rows = [
utterance("other", 4, 0.0, 3.0, "Other already holds the floor."),
utterance("wearer", 2, 1.0, 2.0, "Wearer enters and stops."),
]
event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset")
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL")
self.assertEqual(event.temporal_evidence["preexisting_crossing_other_ids"], [4])
self.assertFalse(event.temporal_evidence["int_ordering_satisfied"])
def test_other_starting_after_wearer_offset_is_a_separate_control(self) -> None:
rows = [
utterance("wearer", 2, 0.0, 2.0, "Wearer finishes."),
utterance("other", 4, 2.2, 3.0, "Later turn."),
]
event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset")
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL")
self.assertEqual(event.rule_candidate_type, "WEARER_OFFSET_CONTROL")
self.assertEqual(event.automatic_protocol_label, "not_applicable")
self.assertFalse(event.temporal_evidence["other_crosses_wearer_offset"])
def test_one_frame_boundary_tolerance_is_configurable(self) -> None:
rows = [
utterance("wearer", 2, 0.0, 2.0, "Wearer finishes."),
utterance("other", 4, 1.0, 2.0, "Boundary-aligned takeover."),
]
tolerant = self.derive(rows)
strict = derive_protocol_candidates(
rows,
[clip()],
session="Session_4",
config=CandidateConfig(int_offset_tolerance_seconds=0.0),
)
tolerant_offset = next(row for row in tolerant if row.anchor_type == "wearer_offset")
strict_offset = next(row for row in strict if row.anchor_type == "wearer_offset")
self.assertEqual(tolerant_offset.candidate_source, "WEARER_OFFSET")
self.assertEqual(strict_offset.candidate_source, "WEARER_OFFSET_CONTROL")
def test_internal_raw_offset_is_not_an_int_candidate(self) -> None:
rows = [
utterance("wearer-1", 2, 0.0, 1.0, "First clause."),
utterance("other", 4, 0.8, 1.3, "Yeah."),
utterance("wearer-2", 2, 1.2, 2.5, "Same floor episode."),
]
event = next(
row for row in self.derive(rows)
if row.anchor_type == "wearer_offset" and row.anchor_dense_seconds == 1.0
)
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL")
self.assertTrue(event.temporal_evidence["raw_offset_is_within_merged_floor_episode"])
def test_brief_feedback_is_int_backchannel_hard_negative(self) -> None:
rows = [

View File

@@ -11,6 +11,7 @@ from review_floor_events import (
expand_candidate_paths,
load_candidates,
parse_byte_range,
stream_file_range,
)
@@ -61,6 +62,16 @@ class FloorEventReviewTests(unittest.TestCase):
"ai_verdict": "POSITIVE",
"ai_event_type": "EOT_RESPONSE",
"ai_subtype": None,
"speaker_timeline": [
{
"uid": "Session_4/00-00-000/0001",
"participant_id": 2,
"participant_role": "wearer",
"start_seconds": 1.2,
"end_seconds": 2.0,
"transcription": "Yes.",
}
],
}
)
+ "\n",
@@ -91,6 +102,8 @@ class FloorEventReviewTests(unittest.TestCase):
self.assertEqual(detail["event_local_seconds"], 1.2)
self.assertEqual(len(detail["transcript"]), 2)
self.assertTrue(detail["transcript"][1]["is_anchor"])
self.assertEqual(detail["transcript"][1]["participant_role"], "wearer")
self.assertEqual(detail["transcript"][1]["uid"], "Session_4/00-00-000/0001")
self.assertEqual(len(repository.session_timelines["Session_4"]), 2)
def test_review_store_writes_resumable_jsonl_and_csv(self) -> None:
@@ -108,7 +121,6 @@ class FloorEventReviewTests(unittest.TestCase):
"exclude_reason": None,
"boundary_correction_seconds": None,
"confidence": "high",
"reviewer": "r1",
"notes": "clear acknowledgement",
}
store.save(candidates[0].candidate_id, review)
@@ -120,6 +132,7 @@ class FloorEventReviewTests(unittest.TestCase):
"EOT_RESPONSE",
)
self.assertFalse(resumed.reviews[candidates[0].candidate_id]["review"]["relabeled"])
self.assertEqual(resumed.reviews[candidates[0].candidate_id]["review"]["reviewer"], "")
def test_directory_expansion_excludes_output(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -133,6 +146,33 @@ class FloorEventReviewTests(unittest.TestCase):
[candidate.resolve()],
)
def test_primary_queue_skips_legacy_nonoverlap_wearer_offset(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "legacy.jsonl"
rows = [
{
"uid": "Session_4/wearer_offset/00000285",
"candidate_source": "WEARER_OFFSET",
"task": "int_yield",
"temporal_evidence": {
"active_other_ids_before_offset": [],
"active_other_ids_after_offset": [],
},
},
{
"uid": "Session_4/wearer_onset/00000338",
"candidate_source": "WEARER_ONSET",
"task": "eot_response",
},
]
path.write_text(
"".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8"
)
primary = load_candidates([path])
all_rows = load_candidates([path], include_offset_controls=True)
self.assertEqual([row.uid for row in primary], [rows[1]["uid"]])
self.assertEqual(len(all_rows), 2)
def test_relabel_preserves_ai_and_records_human_decision(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -161,6 +201,16 @@ class FloorEventReviewTests(unittest.TestCase):
with self.assertRaises(ValueError):
parse_byte_range("bytes=20-30", 10)
def test_cancelled_media_stream_is_not_an_error(self) -> None:
class Reader:
def read(self, _: int) -> bytes:
return b"video"
def disconnected(_: bytes) -> None:
raise BrokenPipeError(32, "Broken pipe")
self.assertFalse(stream_file_range(Reader(), disconnected, 5))
if __name__ == "__main__":
unittest.main()