Update llm assisted annotation
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -29,33 +29,55 @@
|
||||
</aside>
|
||||
|
||||
<section class="evidence-panel panel">
|
||||
<div class="event-header"><div><div class="event-badges"><span id="familyBadge" class="badge accent">—</span><span id="splitBadge" class="badge">—</span><span id="autoConfidenceBadge" class="badge">—</span></div><h2 id="eventTitle">请选择候选</h2><p id="eventSubtitle">—</p></div><div class="event-position"><strong id="eventPosition">0 / 0</strong></div></div>
|
||||
<div class="event-header"><div><div class="event-badges"><span id="familyBadge" class="badge accent">—</span><span id="splitBadge" class="badge">—</span></div><h2 id="eventTitle">请选择候选</h2><p id="eventSubtitle">—</p></div><div class="event-position"><strong id="eventPosition">0 / 0</strong></div></div>
|
||||
|
||||
<div id="llmSuggestion" class="llm-suggestion" hidden><strong>AI 预标注(不是最终标签)</strong><span id="llmSuggestionText">—</span></div>
|
||||
|
||||
<section class="timeline-section">
|
||||
<div class="section-heading"><div><span class="eyebrow">SESSION OVERVIEW</span><h3>本 Session 候选分布</h3></div><span>点击 marker 跳转</span></div>
|
||||
<div id="sessionTimeline" class="session-timeline"></div>
|
||||
<div class="section-heading local-heading"><div><span class="eyebrow">LOCAL SPEAKER ACTIVITY</span><h3>事件附近 participant timeline</h3></div><span>原始标注,只读</span></div>
|
||||
<div id="speakerTimeline" class="speaker-timeline"></div>
|
||||
<section class="decision-guide" aria-label="当前审核目标">
|
||||
<div class="review-question"><span>本条需要判断</span><strong id="reviewQuestion">—</strong></div>
|
||||
<div id="llmSuggestion" class="ai-decision-card" hidden>
|
||||
<div class="ai-decision-heading"><span><i></i>AI 建议</span><em id="aiConfidence">置信度 —</em></div>
|
||||
<div class="ai-decision-main">
|
||||
<strong id="aiVerdict" class="ai-verdict">—</strong>
|
||||
<span class="decision-arrow">→</span>
|
||||
<div><b id="aiLabel">—</b><p id="aiMeaning">—</p></div>
|
||||
</div>
|
||||
<div class="ai-rationale"><span>判断依据</span><p id="aiRationale">—</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="video-shell"><video id="eventVideo" controls preload="metadata"></video><div id="mediaEmpty" class="empty-state" hidden><strong>未找到视频</strong><span>检查 dataset root 与 clip。</span></div></div>
|
||||
<div class="transport"><button type="button" data-seek="-2">−2 s</button><button type="button" data-seek="-0.2">−0.2 s</button><button id="jumpEvent" class="jump-event" type="button">回到事件</button><button type="button" data-seek="0.2">+0.2 s</button><button type="button" data-seek="2">+2 s</button><span id="timeReadout">—</span></div>
|
||||
<section class="timeline-section">
|
||||
<details class="session-overview-details">
|
||||
<summary><span><b>Session overview</b><small>全局候选与说话活动</small></span><em>按需展开</em></summary>
|
||||
<div class="session-overview-body">
|
||||
<div class="section-heading timeline-heading"><div><span class="eyebrow">SESSION OVERVIEW</span><h3>本 Session 候选分布</h3></div><div class="timeline-controls" aria-label="时间轴缩放"><button id="timelinePanLeft" type="button" title="向前平移">←</button><button id="timelineZoomOut" type="button" title="缩小">−</button><button id="timelineFocus" type="button" title="聚焦当前事件">聚焦</button><button id="timelineZoomIn" type="button" title="放大">+</button><button id="timelinePanRight" type="button" title="向后平移">→</button><button id="timelineReset" type="button" title="显示完整 Session">全局</button></div></div>
|
||||
<div class="timeline-readout"><strong id="timelineZoomLabel">8×</strong><span id="timelineRange">—</span><small>marker 跳转 · Shift + 滚轮缩放</small></div>
|
||||
<div id="sessionTimeline" class="session-timeline"></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="section-heading local-heading"><div><span class="eyebrow">LOCAL SPEAKER ACTIVITY</span><h3>事件附近 participant timeline</h3></div><span>W = wearer · P# = Participant_ID</span></div>
|
||||
<div id="speakerTimeline" class="speaker-timeline"></div>
|
||||
<div id="speakerEvidence" class="speaker-evidence" aria-live="polite"><span>悬停或点击任意 activity 区段,转写会固定显示在这里。</span></div>
|
||||
</section>
|
||||
|
||||
<section class="transcript-section"><div class="section-heading"><div><span class="eyebrow">TRANSCRIPT</span><h3>事件上下文</h3></div><span id="clipLabel">—</span></div><div id="transcriptList" class="transcript-list"><div class="empty-copy">选择事件后显示 transcript。</div></div></section>
|
||||
<details class="raw-details"><summary>查看 structured timing 与原始候选</summary><pre id="rawCandidate">{}</pre></details>
|
||||
<div class="evidence-grid">
|
||||
<div class="media-column">
|
||||
<div class="video-shell"><video id="eventVideo" controls preload="metadata"></video><div id="mediaEmpty" class="empty-state" hidden><strong>未找到视频</strong><span>检查 dataset root 与 clip。</span></div></div>
|
||||
<div class="transport"><button type="button" data-seek="-2">−2 s</button><button type="button" data-seek="-0.2">−0.2 s</button><button id="jumpEvent" class="jump-event" type="button">事件点</button><button type="button" data-seek="0.2">+0.2 s</button><button type="button" data-seek="2">+2 s</button><span id="timeReadout">—</span></div>
|
||||
</div>
|
||||
<div class="transcript-column">
|
||||
<section class="transcript-section"><div class="section-heading"><div><span class="eyebrow">TRANSCRIPT</span><h3>事件上下文</h3></div><span id="clipLabel">—</span></div><div id="transcriptList" class="transcript-list"><div class="empty-copy">选择事件后显示 transcript。</div></div></section>
|
||||
<details class="raw-details"><summary>Structured timing / 原始候选</summary><pre id="rawCandidate">{}</pre></details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="review-panel panel">
|
||||
<div class="panel-heading review-heading"><div><span class="eyebrow">HUMAN REVIEW</span><h2>只做一个主操作</h2></div><span id="saveState" class="save-state">未保存</span></div>
|
||||
<div class="panel-heading review-heading"><div><span class="eyebrow">HUMAN REVIEW</span><h2>选择你的操作</h2></div><span id="saveState" class="save-state">未保存</span></div>
|
||||
<form id="reviewForm">
|
||||
<fieldset><legend>确认 AI 判断,或改标 <span class="required">必填</span></legend>
|
||||
<fieldset><legend>AI 建议正确就接受,否则改标 <span class="required">必填</span></legend>
|
||||
<div class="choice-grid two action-grid">
|
||||
<label><input type="radio" name="human_action" value="accept" /><span><b>Accept</b><small>接受 AI verdict/type</small></span></label>
|
||||
<label class="accept-option"><input id="acceptRadio" type="radio" name="human_action" value="accept" /><span><em>AI 建议</em><b id="acceptTitle">接受 AI 结论</b><small id="acceptSummary">载入候选后显示具体标签</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="relabel" /><span><b>Relabel</b><small>展开并选择正确标签</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="exclude" /><span><b>Exclude</b><small>数据不可可靠评分</small></span></label>
|
||||
<label><input type="radio" name="human_action" value="second_review" /><span><b>Unsure / 二审</b><small>证据不足,交给二审</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -69,10 +91,10 @@
|
||||
|
||||
<details class="boundary-details"><summary>仅在原 annotation 明显错误时校正边界</summary><label><span>可选校正(原时间始终保留)</span><select name="boundary_correction_seconds"><option value="">不校正</option><option value="-0.2">−0.2 s</option><option value="0.2">+0.2 s</option></select></label></details>
|
||||
|
||||
<div class="inline-fields"><label><span>置信度</span><select name="confidence"><option value="high">高</option><option value="medium" selected>中</option><option value="low">低</option></select></label><label><span>审核者</span><input id="reviewerInput" name="reviewer" maxlength="100" placeholder="姓名或代号" required /></label></div>
|
||||
<label class="text-field"><span>备注(可选)</span><textarea name="notes" maxlength="4000" rows="3" placeholder="只记录必要的歧义或证据"></textarea></label>
|
||||
<label class="confidence-field"><span>置信度</span><select name="confidence"><option value="high">高</option><option value="medium" selected>中</option><option value="low">低</option></select></label>
|
||||
<details class="optional-details"><summary>备注(可选)</summary><label class="text-field"><textarea name="notes" maxlength="4000" rows="2" placeholder="只记录必要的歧义或证据"></textarea></label></details>
|
||||
<div id="formError" class="form-error" role="alert" hidden></div>
|
||||
<div class="review-actions"><button id="uncertainButton" class="button ghost" type="button">送二审</button><button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button></div>
|
||||
<div class="review-actions"><button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button></div>
|
||||
</form>
|
||||
<div class="shortcuts"><span><kbd>←</kbd><kbd>→</kbd> 前后事件</span><span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span><span><kbd>Space</kbd> 播放/暂停</span></div>
|
||||
</aside>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user