diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 1107d0d..0e5e576 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -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 | diff --git a/adjudicate_easycom_eot_int_candidates.py b/adjudicate_easycom_eot_int_candidates.py index 5477d0f..55c507d 100644 --- a/adjudicate_easycom_eot_int_candidates.py +++ b/adjudicate_easycom_eot_int_candidates.py @@ -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)) diff --git a/docs/easycom_event_review_tool.md b/docs/easycom_event_review_tool.md index 49a1bd5..21d1dd3 100644 --- a/docs/easycom_event_review_tool.md +++ b/docs/easycom_event_review_tool.md @@ -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 diff --git a/floor_event_review_web/app.css b/floor_event_review_web/app.css index bd6046d..51012ed 100644 --- a/floor_event_review_web/app.css +++ b/floor_event_review_web/app.css @@ -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; } } diff --git a/floor_event_review_web/app.js b/floor_event_review_web/app.js index d747528..fdd8162 100644 --- a/floor_event_review_web/app.js +++ b/floor_event_review_web/app.js @@ -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))) diff --git a/floor_event_review_web/index.html b/floor_event_review_web/index.html index 81b0962..c719b42 100644 --- a/floor_event_review_web/index.html +++ b/floor_event_review_web/index.html @@ -29,33 +29,55 @@
-
———

请选择候选

—

0 / 0
+
——

请选择候选

—

0 / 0
- - -
-
SESSION OVERVIEW

本 Session 候选分布

点击 marker 跳转
-
-
LOCAL SPEAKER ACTIVITY

事件附近 participant timeline

原始标注,只读
-
+
+
本条需要判断—
+
-
-
—
+
+
+ Session overview全局候选与说话活动按需展开 +
+
SESSION OVERVIEW

本 Session 候选分布

+
8×—marker 跳转 · Shift + 滚轮缩放
+
+
+
+
LOCAL SPEAKER ACTIVITY

事件附近 participant timeline

W = wearer · P# = Participant_ID
+
+
悬停或点击任意 activity 区段,转写会固定显示在这里。
+
-
TRANSCRIPT

事件上下文

—
选择事件后显示 transcript。
-
查看 structured timing 与原始候选
{}
+
+
+
+
—
+
+
+
TRANSCRIPT

事件上下文

—
选择事件后显示 transcript。
+
Structured timing / 原始候选
{}
+
+
diff --git a/manuscript/main/main.tex b/manuscript/main/main.tex index cc6cff5..1e85ff7 100644 --- a/manuscript/main/main.tex +++ b/manuscript/main/main.tex @@ -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} diff --git a/prepare_easycom_eot_int_review.py b/prepare_easycom_eot_int_review.py index 3729d9b..9ed314b 100644 --- a/prepare_easycom_eot_int_review.py +++ b/prepare_easycom_eot_int_review.py @@ -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__": diff --git a/review_floor_events.py b/review_floor_events.py index 2e67958..797d5f6 100644 --- a/review_floor_events.py +++ b/review_floor_events.py @@ -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, diff --git a/test_adjudicate_easycom_eot_int_candidates.py b/test_adjudicate_easycom_eot_int_candidates.py index 890f744..8b9a43f 100644 --- a/test_adjudicate_easycom_eot_int_candidates.py +++ b/test_adjudicate_easycom_eot_int_candidates.py @@ -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() diff --git a/test_prepare_easycom_eot_int_review.py b/test_prepare_easycom_eot_int_review.py index 41a5373..8627e61 100644 --- a/test_prepare_easycom_eot_int_review.py +++ b/test_prepare_easycom_eot_int_review.py @@ -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 = [ diff --git a/test_review_floor_events.py b/test_review_floor_events.py index 2e03ff1..8ce7c3a 100644 --- a/test_review_floor_events.py +++ b/test_review_floor_events.py @@ -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()