Revamp EasyCom floor event review pipeline

This commit is contained in:
Xin Wang
2026-09-14 18:43:58 +08:00
parent 7d9db4af58
commit 4f88806d56
11 changed files with 1081 additions and 965 deletions
+1 -1
View File
@@ -304,7 +304,7 @@ Do not reorganize them physically without compatibility wrappers.
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
| `prepare_easycom_eot_int_review.py` | Build manuscript-aligned EOT-response/INT-yield positive and hard-negative review regions |
| `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 |
| `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 |
+170 -94
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""Add conservative DeepSeek suggestions to EOT/INT review candidates.
"""Add conservative DeepSeek pre-labels to wearer-centric EOT/INT candidates.
Only transcript excerpts and timing metadata are sent. Suggestions are never
treated as gold labels: the output remains input to ``review_floor_events.py``
and requires an identified human reviewer. The API key is read only from the
``DEEPSEEK_API_KEY`` environment variable and is never persisted.
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.
"""
from __future__ import annotations
@@ -16,49 +16,65 @@ import json
import os
from pathlib import Path
import random
import re
import time
from typing import Any, Sequence
import urllib.error
import urllib.request
PROMPT_VERSION = "easycom-manuscript-eot-int-adjudicator-v1"
PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v2"
DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_BASE_URL = "https://api.deepseek.com"
DISPOSITIONS = {"positive", "hard_negative", "exclude", "uncertain"}
VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"}
EVENT_TYPES = {"EOT_RESPONSE", "INT_YIELD"}
HARD_NEGATIVE_TYPES = {
"eot_midturn_pause",
"eot_other_to_other",
"eot_no_response",
"int_backchannel",
"int_non_takeover_overlap",
"int_retain_floor",
"other",
"not_applicable",
"uncertain",
"EOT_MIDTURN_PAUSE",
"EOT_OTHER_TO_OTHER",
"EOT_WEARER_BACKCHANNEL",
"INT_OTHER_BACKCHANNEL",
"INT_NON_TAKEOVER_OVERLAP",
"INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE",
}
CONFIDENCES = {"high", "medium", "low"}
SYSTEM_PROMPT = """You audit candidate regions for a scientific study of wearer-relative floor control.
Use only the supplied participant-resolved transcript and timing evidence. Do not infer prosody, gaze, intention, or acoustic interruption cues that are not supplied.
SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientific study of wearer-relative conversational floor control.
Tasks:
- eot_response positive: a non-wearer releases the floor and the wearer makes a genuine floor-taking response.
- int_yield positive: a non-wearer takes over during wearer speech and the wearer genuinely yields the floor.
Evidence rules:
1. Use candidate source, structured participant timing, per-speaker activity, and transcript together. Timing evidence has priority over a speculative transcript interpretation.
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.
Hard negatives:
- eot_midturn_pause: the same non-wearer resumes after a within-turn pause.
- eot_other_to_other: a different non-wearer takes the floor while the wearer remains silent.
- eot_no_response: the wearer correctly remains silent for the labeled region.
- int_backchannel: brief feedback during wearer speech that does not take the floor.
- int_non_takeover_overlap: overlapping other speech without takeover.
- int_retain_floor: the wearer correctly retains the floor for the labeled region.
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.
Use exclude for annotation corruption or an unusable boundary. Use uncertain whenever transcript/timing evidence cannot establish the semantic function. Timing alone does not prove EOT, interruption, backchannel, or takeover.
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":"...","protocol_disposition":"uncertain","hard_negative_type":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]}
Return every supplied uid exactly once."""
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence","needs_human_review":true}]}
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."""
def _normalize_returned_uid(value: Any) -> Any:
"""Undo DeepSeek's occasional Markdown escaping without weakening identity checks."""
if not isinstance(value, str):
return value
return re.sub(r"\\+_", "_", value)
def _participant_uid_suffix(row: dict[str, Any], ordinal: int) -> str:
previous = row.get("previous_other_participant_ids") or []
incoming = row.get("incoming_other_participant_ids") or []
previous_key = "-".join(f"p{x}" for x in previous) or "none"
incoming_key = "-".join(f"p{x}" for x in incoming) or "none"
return f"prev-{previous_key}__in-{incoming_key}__row-{ordinal}"
def read_candidates(path: Path) -> list[dict[str, Any]]:
@@ -70,33 +86,50 @@ def read_candidates(path: Path) -> list[dict[str, Any]]:
row = json.loads(line)
if not isinstance(row, dict) or not isinstance(row.get("uid"), str):
raise ValueError(f"Expected candidate object with uid at {path}:{line_number}")
if row.get("task") not in {"eot_response", "int_yield"}:
raise ValueError(f"Unsupported task at {path}:{line_number}: {row.get('task')!r}")
if not (row.get("candidate_source") or row.get("task")):
raise ValueError(f"Missing candidate source/task at {path}:{line_number}")
rows.append(row)
if not rows:
raise ValueError(f"No candidates in {path}")
by_uid: dict[str, list[int]] = {}
for index, row in enumerate(rows):
by_uid.setdefault(row["uid"], []).append(index)
for original_uid, indices in by_uid.items():
if len(indices) == 1:
continue
for ordinal, index in enumerate(indices, 1):
row = dict(rows[index])
row["uid_disambiguated_from"] = original_uid
row["uid"] = f"{original_uid}/{_participant_uid_suffix(row, ordinal)}"
row["event_id"] = row["uid"]
rows[index] = row
if len({row["uid"] for row in rows}) != len(rows):
raise ValueError(f"Could not deterministically disambiguate duplicate candidate UIDs in {path}")
return rows
def candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
fields = (
"uid",
"candidate_source",
"rule_candidate_type",
"task",
"automatic_protocol_label",
"automatic_hard_negative_type",
"anchor_type",
"anchor_dense_seconds",
"behavior_anchor_type",
"behavior_seconds",
"event_time",
"region_start_seconds",
"region_end_seconds",
"region_end_reason",
"previous_other_participant_ids",
"incoming_other_participant_ids",
"automatic_reason",
"temporal_evidence",
"speaker_timeline",
"context",
)
return {field: row.get(field) for field in fields}
payload = {field: row.get(field) for field in fields if row.get(field) is not None}
# Older v1 files remain reviewable and can still be pre-labeled.
if "candidate_source" not in payload:
payload["candidate_source"] = row.get("anchor_type", row.get("event_type", "UNKNOWN"))
if "event_time" not in payload:
payload["event_time"] = row.get("anchor_dense_seconds")
return payload
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
@@ -112,35 +145,59 @@ def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
)
def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, str]]:
def _transport_rows(rows: Sequence[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, str]]:
"""Use simple batch-local IDs so the model never has to copy path-like UIDs."""
transported: list[dict[str, Any]] = []
original_by_transport: dict[str, str] = {}
for index, row in enumerate(rows):
transport_uid = f"item-{index:04d}"
original_by_transport[transport_uid] = row["uid"]
transported.append({**row, "uid": transport_uid})
return transported, original_by_transport
def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, Any]]:
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
raise ValueError("Response must be an object with an items list")
expected = set(expected_uids)
decisions: dict[str, dict[str, str]] = {}
decisions: dict[str, dict[str, Any]] = {}
for item in payload["items"]:
if not isinstance(item, dict):
raise ValueError("Every decision must be an object")
uid = item.get("uid")
disposition = item.get("protocol_disposition")
negative_type = item.get("hard_negative_type")
raw_uid = item.get("uid")
uid = _normalize_returned_uid(raw_uid)
verdict = item.get("verdict")
event_type = item.get("event_type")
subtype = item.get("subtype")
confidence = item.get("confidence")
rationale = item.get("rationale")
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: {uid!r}")
if disposition not in DISPOSITIONS:
raise ValueError(f"Invalid protocol_disposition for {uid}: {disposition!r}")
if negative_type not in HARD_NEGATIVE_TYPES:
raise ValueError(f"Invalid hard_negative_type for {uid}: {negative_type!r}")
if confidence not in CONFIDENCES:
raise ValueError(f"Invalid confidence for {uid}: {confidence!r}")
if not isinstance(rationale, str) or not rationale.strip():
raise ValueError(f"Missing rationale for {uid}")
raise ValueError(f"Unexpected or duplicate uid: {raw_uid!r}")
if verdict not in VERDICTS:
raise ValueError(f"Invalid verdict for {uid}: {verdict!r}")
if verdict == "POSITIVE" and event_type not in EVENT_TYPES:
raise ValueError(f"POSITIVE requires EOT_RESPONSE or INT_YIELD for {uid}")
if verdict != "POSITIVE" and event_type is not None:
raise ValueError(f"Only POSITIVE may have event_type for {uid}")
if verdict == "HARD_NEGATIVE" and subtype not in HARD_NEGATIVE_TYPES:
raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}")
if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}")
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
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),
"protocol_disposition": str(disposition),
"hard_negative_type": str(negative_type),
"confidence": str(confidence),
"rationale": rationale.strip(),
"verdict": str(verdict),
"event_type": event_type,
"subtype": subtype,
"confidence": float(confidence),
"reason": reason.strip(),
"needs_human_review": needs_human_review,
}
if set(decisions) != expected:
raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}")
@@ -156,12 +213,13 @@ def _post(
base_url: str,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, str]], dict[str, Any]]:
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
transported, original_by_transport = _transport_rows(rows)
body = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_prompt(rows, pass_index)},
{"role": "user", "content": build_user_prompt(transported, pass_index)},
],
"response_format": {"type": "json_object"},
"thinking": {"type": "disabled"},
@@ -182,7 +240,10 @@ def _post(
envelope = json.loads(response.read().decode("utf-8"))
content = envelope["choices"][0]["message"]["content"]
parsed = json.loads(content)
return validate_decisions(parsed, [row["uid"] for row in rows]), envelope.get("usage", {})
decisions = validate_decisions(parsed, list(original_by_transport))
for decision in decisions:
decision["uid"] = original_by_transport[decision["uid"]]
return decisions, envelope.get("usage", {})
except (
urllib.error.HTTPError,
urllib.error.URLError,
@@ -206,14 +267,24 @@ def add_suggestions(
model: str,
base_url: str,
passes: int,
batch_size: int = 8,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, Any]], Counter[str]]:
by_uid: dict[str, list[dict[str, str]]] = {row["uid"]: [] for row in rows}
by_uid: dict[str, list[dict[str, Any]]] = {row["uid"]: [] for row in rows}
prompt_hashes: dict[str, list[str]] = {row["uid"]: [] for row in rows}
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=rows,
rows=batch,
pass_index=pass_index,
api_key=api_key,
model=model,
@@ -223,6 +294,7 @@ def add_suggestions(
)
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
@@ -230,43 +302,45 @@ def add_suggestions(
output: list[dict[str, Any]] = []
for source in rows:
decisions = by_uid[source["uid"]]
labels = {
(decision["protocol_disposition"], decision["hard_negative_type"])
for decision in decisions
}
labels = {(d["verdict"], d["event_type"], d["subtype"]) for d in decisions}
unanimous = len(labels) == 1
if unanimous:
disposition, negative_type = next(iter(labels))
confidence = (
"high" if all(item["confidence"] == "high" for item in decisions) else "medium"
)
rationale = " | ".join(item["rationale"] for item in decisions)
verdict, event_type, subtype = next(iter(labels))
confidence = min(float(item["confidence"]) for item in decisions)
reason = " | ".join(item["reason"] for item in decisions)
needs_human_review = any(item["needs_human_review"] for item in decisions)
else:
disposition = "uncertain"
negative_type = "uncertain"
confidence = "low"
rationale = "The independent DeepSeek passes disagreed."
prompt_payload = {
"prompt_version": PROMPT_VERSION,
"model": model,
"passes": passes,
"candidate": candidate_for_prompt(source),
}
verdict, event_type, subtype = "UNSURE", None, None
confidence = min(float(item["confidence"]) for item in decisions)
reason = "The independent DeepSeek passes disagreed."
needs_human_review = True
enriched = dict(source)
enriched["llm_suggestion"] = {
"protocol_disposition": disposition,
"hard_negative_type": negative_type,
"verdict": verdict,
"event_type": event_type,
"subtype": subtype,
"confidence": confidence,
"rationale": rationale,
"reason": reason,
"needs_human_review": needs_human_review,
"unanimous": unanimous,
}
enriched.update(
ai_verdict=verdict,
ai_event_type=event_type,
ai_subtype=subtype,
ai_confidence=confidence,
ai_reason=reason,
human_status="UNREVIEWED",
human_verdict=None,
human_event_type=None,
human_subtype=None,
exclude_reason=None,
)
enriched["llm_adjudication"] = {
"model": model,
"prompt_version": PROMPT_VERSION,
"passes": passes,
"prompt_sha256": hashlib.sha256(
json.dumps(prompt_payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest(),
"request_prompt_sha256": prompt_hashes[source["uid"]],
"decisions": decisions,
"human_review_required": True,
}
@@ -281,6 +355,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--model", default=os.environ.get("DEEPSEEK_MODEL", DEFAULT_MODEL))
parser.add_argument("--base-url", default=os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_BASE_URL))
parser.add_argument("--passes", type=int, default=2)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--max-events", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
parser.add_argument("--timeout-seconds", type=float, default=120.0)
@@ -293,8 +368,8 @@ def parse_args() -> argparse.Namespace:
def main() -> None:
args = parse_args()
if args.passes <= 0 or args.max_retries < 0:
raise ValueError("passes must be positive and max-retries non-negative")
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())
if args.max_events is not None:
if args.max_events < 0:
@@ -324,6 +399,7 @@ def main() -> None:
model=args.model,
base_url=args.base_url,
passes=args.passes,
batch_size=args.batch_size,
timeout_seconds=args.timeout_seconds,
max_retries=args.max_retries,
)
+70 -51
View File
@@ -1,16 +1,19 @@
# EasyCom EOT/INT review workspace
# EasyCom wearer-centric EOT/INT pre-label and review pipeline
`review_floor_events.py` is a local annotation workspace for reviewing the
manuscript-aligned regions produced by `prepare_easycom_eot_int_review.py`.
It can also open the older timing/transcript proxy reports from
`derive_easycom_interaction_events.py` and `audit_easycom_floor_events.py`, but
those older reports do not have the directionality or hard-negative region
contract required by the current manuscript. Raw EasyCom media stays read-only;
human decisions are stored separately as JSONL and CSV.
The workflow has three explicit stages. Speech activity timestamps are
read-only throughout; reviewed events never modify the dense 5 Hz activity
ground truth.
## Prepare manuscript-aligned candidates
```text
EasyCom participant speech intervals
-> rule candidates + structured timing evidence
-> DeepSeek semantic pre-label (optional but recommended)
-> human Accept / Relabel / Exclude / Second review
```
Develop the annotation guide on training-side Sessions 4--12:
## 1. Generate candidates
Develop the rules on training-side Sessions 4--12:
```bash
python3 prepare_easycom_eot_int_review.py \
@@ -19,24 +22,39 @@ python3 prepare_easycom_eot_int_review.py \
--output-dir reports/easycom_floor_event_candidates
```
For a one-event pilot, add `--max-candidates 1`. The generator follows the
manuscript direction exactly:
The generator emits two independent sources:
- EOT-response begins at an aggregate-other offset while the wearer is silent.
- INT-yield begins at an aggregate-other onset while the wearer is active.
- An observed wearer transition before the competing other transition is a
timing-positive candidate.
- A competing other transition, or a fully observed no-transition window, is a
timing hard-negative candidate.
- A source boundary without a complete outcome/window is marked for exclusion.
- every wearer `0->1` (`WEARER_ONSET`, `ENTRY_CANDIDATE`) and `1->0`
(`WEARER_OFFSET`, `YIELD_CANDIDATE`) transition;
- hard-negative regions that need no wearer transition:
`SAME_SPEAKER_PAUSE`, `OTHER_TO_OTHER`, `OTHER_BACKCHANNEL`, and
`NON_TAKEOVER_OVERLAP`.
Automatic labels remain candidates until independent human semantic review.
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,
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.
## Optional DeepSeek suggestion
All heuristic durations are configurable rather than frozen paper rules:
DeepSeek suggestions are triage metadata and never count as a human label. The
script sends only the candidate transcript excerpt and timing fields. Inspect
the exact request without network access first:
```text
--relation-window-seconds
--same-speaker-pause-max-seconds
--other-handover-max-seconds
--short-vocalization-max-seconds
--continuation-min-seconds
```
Use `--max-candidates 1` for a one-event smoke test. `summary.json` records the
exact configuration and candidate counts. `easycom_session_timelines.json`
stores the read-only participant speech lanes used by the full-session web
timeline; it is not sent to DeepSeek.
## 2. Add DeepSeek pre-labels
First inspect exactly what would be uploaded:
```bash
python3 adjudicate_easycom_eot_int_candidates.py \
@@ -47,51 +65,52 @@ python3 adjudicate_easycom_eot_int_candidates.py \
--dry-run
```
After setting `DEEPSEEK_API_KEY`, make the authorized request:
After setting `DEEPSEEK_API_KEY` and obtaining authorization to upload the
candidate excerpts:
```bash
python3 adjudicate_easycom_eot_int_candidates.py \
--input reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \
--output reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.jsonl \
--model deepseek-v4-flash \
--max-events 1 \
--allow-external-transcript-upload
```
Point the review server at the DeepSeek-enriched JSONL to show its suggestion
beside the video. Keep suggestions hidden from an initial independent human
pass if anchoring bias is a concern.
DeepSeek sees candidate source, structured timing, per-speaker activity, and
transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`,
`NOT_APPLICABLE`, `EXCLUDE`, or `UNSURE`; positives additionally receive
`EOT_RESPONSE` or `INT_YIELD`, and hard negatives receive a named subtype. It
cannot modify timestamps. The output preserves both the original rule candidate
and the complete AI decision/provenance.
## Start the local workspace
## 3. Human review
Run the web workspace on the AI-enriched JSONL:
```bash
python3 review_floor_events.py \
--dataset-root /path/to/easycom \
--events reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \
--events reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.jsonl \
--output reports/easycom_event_reviews/reviewer_1.jsonl
```
Open `http://127.0.0.1:8765`. The page resolves the source MP4 and transcript,
seeks to a six-second event context, and supports session/split/task/status
filtering.
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:
Each save atomically rewrites both JSONL and CSV. The review explicitly records
`positive`, `hard_negative`, `exclude`, or `uncertain`; the hard-negative
subtype; boundary validity; onset function; conversational routing; offset
outcome; three 200 ms anchor/region corrections; confidence; and reviewer ID.
- **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.
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.
## Freeze discipline
Develop the annotation guide and resolve category disagreements on training
Sessions 4--12. Freeze the guide before reviewing official test Sessions 1--3.
Do not use test reviews to change candidate windows, label categories, or the
R=.8 threshold calibration rule.
For paper-quality labels, use two independent reviewers with separate
`--output` files and adjudicate disagreements in a third output. A non-empty
reviewer ID is required on every saved row. DeepSeek is not one of the two
reviewers.
This workspace produces audited labels. It does not yet convert them into the
final committed-event matching metrics; that integration follows only after
the schema and real-data pilot are frozen.
Develop the guide and resolve disagreements only on Sessions 4--12. Freeze
categories and thresholds before reviewing official test Sessions 1--3. Use two
independent human review files plus a separate adjudication output for paper-
quality labels. DeepSeek is pre-label metadata, not an independent reviewer.
+26
View File
@@ -220,6 +220,27 @@ textarea { resize: vertical; line-height: 1.45; }
.llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; }
.event-position { color: var(--muted); font-size: 0.8rem; }
.timeline-section { padding: 0 18px 16px; display: grid; gap: 10px; }
.timeline-section .section-heading { margin-top: 2px; }
.local-heading { margin-top: 8px !important; }
.session-timeline { min-height: 34px; border: 1px solid var(--line); border-radius: 8px; position: relative; background: #07101d; padding: 6px 7px 28px; display: grid; gap: 3px; overflow: hidden; }
.session-lane { display: grid; grid-template-columns: 28px 1fr; gap: 5px; align-items: center; min-height: 10px; }
.session-lane > strong { color: var(--muted); text-align: right; font-size: .58rem; }
.session-track { height: 8px; position: relative; background: rgba(255,255,255,.025); }
.session-speech { position: absolute; top: 1px; height: 6px; min-width: 1px; background: #60a5fa; opacity: .75; }
.session-speech.wearer { background: var(--accent); }
.session-events { position: absolute; left: 40px; right: 7px; bottom: 4px; height: 20px; border-top: 1px solid var(--line-strong); }
.session-marker { position: absolute; top: 3px; width: 7px; height: 15px; margin-left: -3px; padding: 0; border: 0; border-radius: 4px; background: var(--amber); cursor: pointer; opacity: .68; }
.session-marker.int { background: #a78bfa; }
.session-marker.current { opacity: 1; box-shadow: 0 0 0 2px white; z-index: 2; }
.speaker-timeline { display: grid; gap: 6px; color: var(--muted); font-size: .76rem; }
.speaker-lane { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 8px; }
.speaker-lane > strong { color: var(--muted); text-align: right; font-size: .72rem; }
.speaker-track { height: 22px; position: relative; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; background: #07101d; }
.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--amber); z-index: 3; }
.speech-segment { position: absolute; top: 5px; height: 10px; min-width: 2px; border-radius: 3px; background: #60a5fa; }
.speech-segment.wearer { background: var(--accent); }
.video-shell { background: #02050a; aspect-ratio: 16 / 9; position: relative; border-block: 1px solid var(--line); }
.video-shell video { width: 100%; height: 100%; display: block; object-fit: contain; }
.empty-state { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 7px; color: var(--muted); }
@@ -273,6 +294,11 @@ legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; }
.text-field input, .text-field textarea { font-size: .84rem; }
.form-error { color: #fecdd3; background: rgba(251,113,133,.1); border: 1px solid rgba(251,113,133,.4); border-radius: 8px; padding: 9px; font-size: .8rem; }
.review-actions { display: grid; grid-template-columns: .9fr 1.4fr; gap: 8px; }
.conditional-fields { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(94,234,212,.035); display: grid; gap: 9px; }
.conditional-fields label, .boundary-details label { display: grid; gap: 6px; }
.conditional-fields label > span, .boundary-details label > span { color: var(--muted); font-size: .72rem; font-weight: 700; }
.boundary-details { border-top: 1px solid var(--line); padding-top: 10px; }
.boundary-details summary { color: var(--muted); cursor: pointer; font-size: .78rem; margin-bottom: 9px; }
kbd { border: 1px solid rgba(3,24,22,.35); border-bottom-width: 2px; border-radius: 4px; padding: 1px 4px; font-size: .68rem; font-family: inherit; }
.shortcuts { border-top: 1px solid var(--line); padding: 11px 15px 14px; display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--muted); font-size: .71rem; }
.shortcuts kbd { color: #cbd7e7; border-color: var(--line-strong); margin-right: 2px; }
+117 -38
View File
@@ -7,6 +7,7 @@ const state = {
currentId: null,
currentDetail: null,
saving: false,
sessionSpeech: {},
};
const elements = Object.fromEntries(
@@ -15,10 +16,11 @@ const elements = Object.fromEntries(
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
"autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition",
"llmSuggestion", "llmSuggestionText",
"llmSuggestion", "llmSuggestionText", "sessionTimeline", "speakerTimeline",
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError",
"saveButton", "uncertainButton", "saveState", "toast",
"saveButton", "uncertainButton", "saveState", "toast", "relabelFields",
"excludeFields", "eventTypeField", "subtypeField",
].map((id) => [id, document.getElementById(id)])
);
@@ -86,7 +88,7 @@ function applyFilters({ keepCurrent = true } = {}) {
(elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) &&
(elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) &&
(status === "all" || (status === "reviewed" ? reviewed : !reviewed)) &&
(!search || `${item.uid} ${item.automatic_label} ${item.automatic_protocol_label || ""} ${item.automatic_hard_negative_type || ""} ${item.session}`.toLowerCase().includes(search))
(!search || `${item.uid} ${item.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${item.session}`.toLowerCase().includes(search))
);
});
renderQueue();
@@ -121,9 +123,9 @@ function renderQueue() {
meta.textContent = `${item.session} · ${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`;
const label = document.createElement("div");
label.className = "queue-label";
label.textContent = item.automatic_protocol_label
? `${item.automatic_protocol_label} · ${item.automatic_hard_negative_type || item.automatic_label}`
: item.automatic_label;
label.textContent = item.ai_verdict
? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || "—"}`
: `${item.rule_candidate_type || item.automatic_label} · 待 AI 粗标`;
button.append(line, meta, label);
button.addEventListener("click", () => selectCandidate(item.candidate_id));
elements.queueList.append(button);
@@ -171,7 +173,7 @@ function renderDetail(detail) {
elements.familyBadge.textContent = familyNames[detail.family] || detail.family;
elements.splitBadge.textContent = `${detail.split} · ${detail.session}`;
elements.autoConfidenceBadge.textContent = detail.automatic_confidence ? `auto ${detail.automatic_confidence}` : "auto confidence —";
elements.eventTitle.textContent = detail.automatic_label;
elements.eventTitle.textContent = detail.candidate_source || detail.automatic_label;
elements.eventSubtitle.textContent = `${detail.uid} · ${detail.source_file}:${detail.source_row}`;
elements.clipLabel.textContent = detail.clip_stem ? `${detail.session} / ${detail.clip_stem}` : detail.session;
elements.rawCandidate.textContent = JSON.stringify(detail.candidate, null, 2);
@@ -181,13 +183,16 @@ function renderDetail(detail) {
const suggestion = detail.llm_suggestion;
elements.llmSuggestion.hidden = !suggestion;
if (suggestion) {
const disposition = suggestion.protocol_disposition || suggestion.final_protocol_disposition || "uncertain";
const subtype = suggestion.hard_negative_type || suggestion.final_hard_negative_type || "uncertain";
const disposition = suggestion.verdict || suggestion.protocol_disposition || "UNSURE";
const subtype = suggestion.event_type || suggestion.subtype || suggestion.hard_negative_type || "—";
const confidence = suggestion.confidence || suggestion.final_confidence || "—";
const rationale = suggestion.rationale || suggestion.final_rationale || "";
const rationale = suggestion.reason || suggestion.rationale || "";
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
}
renderSessionTimeline(detail);
renderSpeakerTimeline(detail);
elements.mediaEmpty.hidden = detail.media_available;
if (detail.media_available) {
elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`;
@@ -226,16 +231,87 @@ function renderDetail(detail) {
updateTimeReadout();
}
async function renderSessionTimeline(detail) {
elements.sessionTimeline.replaceChildren();
if (!state.sessionSpeech[detail.session]) {
try {
const payload = await request(`/api/session-timeline?session=${encodeURIComponent(detail.session)}`);
state.sessionSpeech[detail.session] = payload.speech || [];
} catch (_) {
state.sessionSpeech[detail.session] = [];
}
}
if (state.currentId !== detail.candidate_id) return;
const events = state.candidates.filter((item) => item.session === detail.session);
const speech = state.sessionSpeech[detail.session];
const end = Math.max(1,
...events.map((item) => Number(item.event_time_seconds) || 0),
...speech.map((row) => Number(row.end_seconds) || 0));
const speakers = [...new Set(speech.map((row) => row.participant_role === "wearer" ? "W" : `P${row.participant_id}`))];
for (const speakerId of speakers) {
const lane = document.createElement("div"); lane.className = "session-lane";
const label = document.createElement("strong"); label.textContent = speakerId;
const track = document.createElement("div"); track.className = "session-track";
for (const row of speech.filter((row) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
const segment = document.createElement("span");
segment.className = `session-speech${speakerId === "W" ? " wearer" : ""}`;
segment.style.left = `${100 * Number(row.start_seconds) / end}%`;
segment.style.width = `${Math.max(0.08, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / end)}%`;
track.append(segment);
}
lane.append(label, track); elements.sessionTimeline.append(lane);
}
const eventTrack = document.createElement("div"); eventTrack.className = "session-events";
for (const item of events) {
const marker = document.createElement("button");
marker.type = "button";
marker.className = `session-marker ${item.family}${item.candidate_id === detail.candidate_id ? " current" : ""}`;
marker.style.left = `${Math.min(100, 100 * (Number(item.event_time_seconds) || 0) / end)}%`;
marker.title = `${formatTime(item.event_time_seconds)} · ${item.candidate_source || item.automatic_label} · ${item.ai_verdict || "未粗标"}`;
marker.addEventListener("click", () => selectCandidate(item.candidate_id));
eventTrack.append(marker);
}
elements.sessionTimeline.append(eventTrack);
}
function renderSpeakerTimeline(detail) {
elements.speakerTimeline.replaceChildren();
const rows = detail.candidate.speaker_timeline || detail.candidate.context || [];
if (!rows.length) {
elements.speakerTimeline.textContent = "没有 participant activity evidence。";
return;
}
const eventTime = Number(detail.candidate.event_time ?? detail.candidate.anchor_dense_seconds);
const left = Math.min(...rows.map((row) => Number(row.start_seconds)), eventTime - 3);
const right = Math.max(...rows.map((row) => Number(row.end_seconds)), eventTime + 3);
const span = Math.max(0.001, right - left);
const speakers = [...new Set(rows.map((row) => `${row.participant_role === "wearer" ? "W" : `P${row.participant_id}`}`))];
for (const speakerId of speakers) {
const lane = document.createElement("div");
lane.className = "speaker-lane";
const label = document.createElement("strong"); label.textContent = speakerId;
const track = document.createElement("div"); track.className = "speaker-track";
for (const row of rows.filter((row) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) {
const segment = document.createElement("span");
segment.className = `speech-segment${speakerId === "W" ? " wearer" : ""}`;
segment.style.left = `${100 * (Number(row.start_seconds) - left) / span}%`;
segment.style.width = `${Math.max(0.8, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / span)}%`;
segment.title = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)} ${row.transcription || ""}`;
track.append(segment);
}
const anchor = document.createElement("i"); anchor.style.left = `${100 * (eventTime - left) / span}%`; track.append(anchor);
lane.append(label, track); elements.speakerTimeline.append(lane);
}
}
function restoreForm(review) {
elements.reviewForm.reset();
elements.formError.hidden = true;
const reviewer = review?.reviewer || localStorage.getItem("floor-reviewer") || "";
elements.reviewerInput.value = reviewer;
if (!review) {
elements.reviewForm.elements.anchor_adjustment_bins.value = "0";
elements.reviewForm.elements.behavior_adjustment_bins.value = "0";
elements.reviewForm.elements.region_end_adjustment_bins.value = "0";
elements.reviewForm.elements.confidence.value = "medium";
updateConditionalFields();
return;
}
for (const [name, value] of Object.entries(review)) {
@@ -244,24 +320,20 @@ function restoreForm(review) {
if (control instanceof RadioNodeList) control.value = String(value);
else control.value = value === null ? "" : String(value);
}
updateConditionalFields();
}
function formPayload() {
const data = new FormData(elements.reviewForm);
const adjustment = (name) => {
const value = data.get(name);
return value === "" ? null : Number(value);
};
const nullable = (name) => data.get(name) || null;
const correction = data.get("boundary_correction_seconds");
return {
protocol_disposition: data.get("protocol_disposition"),
hard_negative_type: data.get("hard_negative_type"),
validity: data.get("validity"),
onset_function: data.get("onset_function"),
context: data.get("context"),
offset_outcome: data.get("offset_outcome"),
anchor_adjustment_bins: adjustment("anchor_adjustment_bins"),
behavior_adjustment_bins: adjustment("behavior_adjustment_bins"),
region_end_adjustment_bins: adjustment("region_end_adjustment_bins"),
human_action: data.get("human_action"),
human_verdict: nullable("human_verdict"),
human_event_type: nullable("human_event_type"),
human_subtype: nullable("human_subtype"),
exclude_reason: nullable("exclude_reason"),
boundary_correction_seconds: correction === "" ? null : Number(correction),
confidence: data.get("confidence"),
reviewer: String(data.get("reviewer") || "").trim(),
notes: String(data.get("notes") || "").trim(),
@@ -270,16 +342,25 @@ function formPayload() {
function validateReview(review) {
const missing = [];
if (!review.protocol_disposition) missing.push("协议结论");
if (!review.hard_negative_type) missing.push("hard-negative 类型");
if (!review.validity) missing.push("活动边界");
if (!review.onset_function) missing.push("onset 功能");
if (!review.context) missing.push("对话场景");
if (!review.offset_outcome) missing.push("offset 结果");
if (!review.human_action) missing.push("主操作");
if (review.human_action === "relabel" && !review.human_verdict) missing.push("一级 verdict");
if (review.human_action === "relabel" && review.human_verdict === "POSITIVE" && !review.human_event_type) missing.push("positive event");
if (review.human_action === "relabel" && review.human_verdict === "HARD_NEGATIVE" && !review.human_subtype) missing.push("hard-negative subtype");
if (review.human_action === "exclude" && !review.exclude_reason) missing.push("排除原因");
if (review.human_action === "accept" && !state.currentDetail?.candidate?.ai_verdict && !state.currentDetail?.llm_suggestion?.verdict) missing.push("AI 预标注(尚未运行,需 Relabel)");
if (!review.reviewer) missing.push("审核者");
return missing;
}
function updateConditionalFields() {
const action = elements.reviewForm.elements.human_action.value;
const verdict = elements.reviewForm.elements.human_verdict.value;
elements.relabelFields.hidden = action !== "relabel";
elements.excludeFields.hidden = action !== "exclude";
elements.eventTypeField.hidden = action !== "relabel" || verdict !== "POSITIVE";
elements.subtypeField.hidden = action !== "relabel" || verdict !== "HARD_NEGATIVE";
}
async function saveCurrent({ advance = true } = {}) {
if (!state.currentId || state.saving) return;
const review = formPayload();
@@ -376,14 +457,12 @@ function bindEvents() {
saveCurrent();
});
elements.uncertainButton.addEventListener("click", () => {
elements.reviewForm.elements.protocol_disposition.value = "uncertain";
elements.reviewForm.elements.hard_negative_type.value = "uncertain";
elements.reviewForm.elements.validity.value = "uncertain";
elements.reviewForm.elements.onset_function.value = "uncertain";
elements.reviewForm.elements.context.value = "unknown";
elements.reviewForm.elements.offset_outcome.value = "uncertain";
elements.reviewForm.elements.human_action.value = "second_review";
elements.reviewForm.elements.confidence.value = "low";
updateConditionalFields();
});
elements.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields));
elements.reviewForm.elements.human_verdict.addEventListener("change", updateConditionalFields);
elements.jumpEvent.addEventListener("click", jumpToEvent);
document.querySelectorAll("[data-seek]").forEach((button) =>
button.addEventListener("click", () => seek(Number(button.dataset.seek)))
+45 -248
View File
@@ -3,283 +3,80 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="EasyCom wearer floor-event local review workspace" />
<title>Floor Event Review</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230b1324'/%3E%3Cpath d='M14 39h8l5-17 9 29 6-22 4 10h5' fill='none' stroke='%235eead4' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
<title>EasyCom Floor Event Review</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<h1>Floor Event Review</h1>
<p>EasyCom 事件审核</p>
</div>
</div>
<div class="progress-block" aria-live="polite">
<div class="progress-copy">
<strong id="progressText">载入中</strong>
<span id="progressMeta">—</span>
</div>
<div class="progress-track"><span id="progressBar"></span></div>
</div>
<nav class="export-actions" aria-label="导出审核结果">
<a href="/api/export.jsonl" download>导出 JSONL</a>
<a href="/api/export.csv" download>导出 CSV</a>
</nav>
<div class="brand"><span class="brand-mark"></span><div><h1>Floor Event Review</h1><p>AI 粗标注 → 人工确认</p></div></div>
<div class="progress-block"><div class="progress-copy"><strong id="progressText">载入中</strong><span id="progressMeta">—</span></div><div class="progress-track"><span id="progressBar"></span></div></div>
<nav class="export-actions"><a href="/api/export.jsonl" download>导出 JSONL</a><a href="/api/export.csv" download>导出 CSV</a></nav>
</header>
<section class="filters" aria-label="候选筛选">
<label>
<span>Session</span>
<select id="sessionFilter"><option value="all">全部</option></select>
</label>
<label>
<span>Split</span>
<select id="splitFilter">
<option value="all">全部</option>
<option value="train">train</option>
<option value="test">test</option>
</select>
</label>
<label>
<span>事件族</span>
<select id="familyFilter">
<option value="all">全部</option>
<option value="eot">EOT-response</option>
<option value="int">INT-yield</option>
<option value="onset">wearer onset</option>
<option value="offset">旧 EOT proxy</option>
<option value="interaction">旧 interaction proxy</option>
</select>
</label>
<label>
<span>审核状态</span>
<select id="statusFilter">
<option value="unreviewed">未审核</option>
<option value="all">全部</option>
<option value="reviewed">已审核</option>
</select>
</label>
<label class="search-field">
<span>搜索</span>
<input id="searchFilter" type="search" placeholder="UID、类别、session" />
</label>
<label><span>Session</span><select id="sessionFilter"><option value="all">全部</option></select></label>
<label><span>Split</span><select id="splitFilter"><option value="all">全部</option><option value="train">train</option><option value="test">test</option></select></label>
<label><span>任务</span><select id="familyFilter"><option value="all">全部</option><option value="eot">EOT / ENTRY</option><option value="int">INT / YIELD</option></select></label>
<label><span>审核状态</span><select id="statusFilter"><option value="unreviewed">未审核</option><option value="all">全部</option><option value="reviewed">已审核</option></select></label>
<label class="search-field"><span>搜索</span><input id="searchFilter" type="search" placeholder="事件来源、AI 标签、session" /></label>
<button id="clearFilters" class="button ghost" type="button">清除筛选</button>
</section>
<main class="workspace">
<aside class="queue-panel panel" aria-label="事件候选列表">
<div class="panel-heading">
<div>
<span class="eyebrow">QUEUE</span>
<h2 id="queueTitle">候选</h2>
</div>
<span id="queueCount" class="count-pill">0</span>
</div>
<div id="queueList" class="queue-list"></div>
<p id="queueLimit" class="queue-limit" hidden></p>
<aside class="queue-panel panel">
<div class="panel-heading"><div><span class="eyebrow">QUEUE</span><h2 id="queueTitle">待审核</h2></div><span id="queueCount" class="count-pill">0</span></div>
<div id="queueList" class="queue-list"></div><p id="queueLimit" class="queue-limit" hidden></p>
</aside>
<section class="evidence-panel panel" aria-label="事件证据">
<div class="event-header">
<div>
<div class="event-badges">
<span id="familyBadge" class="badge accent">—</span>
<span id="splitBadge" class="badge">—</span>
<span id="autoConfidenceBadge" class="badge">—</span>
</div>
<h2 id="eventTitle">请选择一个候选事件</h2>
<p id="eventSubtitle">—</p>
</div>
<div class="event-position"><strong id="eventPosition">0 / 0</strong></div>
</div>
<section class="evidence-panel panel">
<div class="event-header"><div><div class="event-badges"><span id="familyBadge" class="badge accent">—</span><span id="splitBadge" class="badge">—</span><span id="autoConfidenceBadge" class="badge">—</span></div><h2 id="eventTitle">请选择候选</h2><p id="eventSubtitle">—</p></div><div class="event-position"><strong id="eventPosition">0 / 0</strong></div></div>
<div id="llmSuggestion" class="llm-suggestion" hidden>
<strong>DeepSeek 预标注(仅供复核)</strong>
<span id="llmSuggestionText">—</span>
</div>
<div id="llmSuggestion" class="llm-suggestion" hidden><strong>AI 预标注(不是最终标签)</strong><span id="llmSuggestionText">—</span></div>
<div class="video-shell">
<video id="eventVideo" controls preload="metadata"></video>
<div id="mediaEmpty" class="empty-state" hidden>
<strong>当前候选未找到视频</strong>
<span>检查 dataset root、session 和 clip stem。</span>
</div>
<div class="time-marker" aria-hidden="true"><span></span></div>
</div>
<div class="transport" aria-label="视频定位">
<button type="button" data-seek="-2">−2 s</button>
<button type="button" data-seek="-0.2">−0.2 s</button>
<button id="jumpEvent" class="jump-event" type="button">回到事件</button>
<button type="button" data-seek="0.2">+0.2 s</button>
<button type="button" data-seek="2">+2 s</button>
<span id="timeReadout">—</span>
</div>
<section class="transcript-section">
<div class="section-heading">
<div>
<span class="eyebrow">TRANSCRIPT</span>
<h3>事件上下文</h3>
</div>
<span id="clipLabel">—</span>
</div>
<div id="transcriptList" class="transcript-list">
<div class="empty-copy">选择事件后显示 transcript。</div>
</div>
<section 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>
<details class="raw-details">
<summary>查看自动规则与原始候选字段</summary>
<pre id="rawCandidate">{}</pre>
</details>
<div class="video-shell"><video id="eventVideo" controls preload="metadata"></video><div id="mediaEmpty" class="empty-state" hidden><strong>未找到视频</strong><span>检查 dataset root 与 clip。</span></div></div>
<div class="transport"><button type="button" data-seek="-2">−2 s</button><button type="button" data-seek="-0.2">−0.2 s</button><button id="jumpEvent" class="jump-event" type="button">回到事件</button><button type="button" data-seek="0.2">+0.2 s</button><button type="button" data-seek="2">+2 s</button><span id="timeReadout">—</span></div>
<section class="transcript-section"><div class="section-heading"><div><span class="eyebrow">TRANSCRIPT</span><h3>事件上下文</h3></div><span id="clipLabel">—</span></div><div id="transcriptList" class="transcript-list"><div class="empty-copy">选择事件后显示 transcript。</div></div></section>
<details class="raw-details"><summary>查看 structured timing 与原始候选</summary><pre id="rawCandidate">{}</pre></details>
</section>
<aside class="review-panel panel" aria-label="人工审核表单">
<div class="panel-heading review-heading">
<div>
<span class="eyebrow">HUMAN REVIEW</span>
<h2>审核结论</h2>
</div>
<span id="saveState" class="save-state">未保存</span>
</div>
<aside class="review-panel panel">
<div class="panel-heading review-heading"><div><span class="eyebrow">HUMAN REVIEW</span><h2>只做一个主操作</h2></div><span id="saveState" class="save-state">未保存</span></div>
<form id="reviewForm">
<fieldset>
<legend>1. 稿件协议结论 <span class="required">必填</span></legend>
<div class="choice-grid two compact">
<label><input type="radio" name="protocol_disposition" value="positive" /><span><b>Positive</b><small>EOT-response / INT-yield 正例</small></span></label>
<label><input type="radio" name="protocol_disposition" value="hard_negative" /><span><b>Hard negative</b><small>错误 commit 的计分区间</small></span></label>
<label><input type="radio" name="protocol_disposition" value="exclude" /><span><b>Exclude</b><small>歧义或标注损坏</small></span></label>
<label><input type="radio" name="protocol_disposition" value="uncertain" /><span><b>不确定</b><small>需要裁决</small></span></label>
<fieldset><legend>确认 AI 判断,或改标 <span class="required">必填</span></legend>
<div class="choice-grid two action-grid">
<label><input type="radio" name="human_action" value="accept" /><span><b>Accept</b><small>接受 AI verdict/type</small></span></label>
<label><input type="radio" name="human_action" value="relabel" /><span><b>Relabel</b><small>展开并选择正确标签</small></span></label>
<label><input type="radio" name="human_action" value="exclude" /><span><b>Exclude</b><small>数据不可可靠评分</small></span></label>
<label><input type="radio" name="human_action" value="second_review" /><span><b>Unsure / 二审</b><small>证据不足,交给二审</small></span></label>
</div>
</fieldset>
<fieldset>
<legend>2. Hard-negative 类型 <span class="required">必填</span></legend>
<div class="choice-grid two compact">
<label><input type="radio" name="hard_negative_type" value="eot_midturn_pause" /><span><b>EOT 句中停顿</b></span></label>
<label><input type="radio" name="hard_negative_type" value="eot_other_to_other" /><span><b>EOT other→other</b></span></label>
<label><input type="radio" name="hard_negative_type" value="eot_no_response" /><span><b>EOT 保持沉默</b></span></label>
<label><input type="radio" name="hard_negative_type" value="int_backchannel" /><span><b>INT backchannel</b></span></label>
<label><input type="radio" name="hard_negative_type" value="int_non_takeover_overlap" /><span><b>INT 非接管重叠</b></span></label>
<label><input type="radio" name="hard_negative_type" value="int_retain_floor" /><span><b>INT 保持话轮</b></span></label>
<label><input type="radio" name="hard_negative_type" value="other" /><span><b>其他</b></span></label>
<label><input type="radio" name="hard_negative_type" value="not_applicable" /><span><b>不适用</b></span></label>
<label><input type="radio" name="hard_negative_type" value="uncertain" /><span><b>不确定</b></span></label>
</div>
</fieldset>
<section id="relabelFields" class="conditional-fields" hidden>
<label><span>一级 verdict</span><select name="human_verdict"><option value="">请选择</option><option>POSITIVE</option><option>HARD_NEGATIVE</option><option>NOT_APPLICABLE</option><option>UNSURE</option></select></label>
<label id="eventTypeField" hidden><span>Positive event</span><select name="human_event_type"><option value="">请选择</option><option>EOT_RESPONSE</option><option>INT_YIELD</option></select></label>
<label id="subtypeField" hidden><span>Hard-negative subtype</span><select name="human_subtype"><option value="">请选择</option><option>EOT_MIDTURN_PAUSE</option><option>EOT_OTHER_TO_OTHER</option><option>EOT_WEARER_BACKCHANNEL</option><option>INT_OTHER_BACKCHANNEL</option><option>INT_NON_TAKEOVER_OVERLAP</option><option>INT_WEARER_HOLDS_FLOOR</option><option>OTHER_HARD_NEGATIVE</option></select></label>
</section>
<fieldset>
<legend>3. 活动边界是否真实? <span class="required">必填</span></legend>
<div class="choice-grid three">
<label><input type="radio" name="validity" value="valid" /><span><b>真实</b><small>有效活动边界</small></span></label>
<label><input type="radio" name="validity" value="artifact" /><span><b>伪边界</b><small>噪声、断点或错标</small></span></label>
<label><input type="radio" name="validity" value="uncertain" /><span><b>不确定</b><small>证据不足</small></span></label>
</div>
</fieldset>
<section id="excludeFields" class="conditional-fields" hidden><label><span>排除原因</span><select name="exclude_reason"><option value="">请选择</option><option>ANNOTATION_ERROR</option><option>RECORDING_BOUNDARY</option><option>MISSING_CONTEXT</option><option>NON_SPEECH_ARTIFACT</option><option>TIMESTAMP_CORRUPTION</option><option>UNRESOLVABLE</option></select></label></section>
<fieldset>
<legend>4. Onset 的 floor 功能 <span class="required">必填</span></legend>
<div class="choice-grid two compact">
<label><input type="radio" name="onset_function" value="floor_taking" /><span><b>取得话轮</b></span></label>
<label><input type="radio" name="onset_function" value="backchannel" /><span><b>Backchannel</b></span></label>
<label><input type="radio" name="onset_function" value="within_turn_resume" /><span><b>句内恢复</b></span></label>
<label><input type="radio" name="onset_function" value="nonverbal" /><span><b>非语言发声</b></span></label>
<label><input type="radio" name="onset_function" value="not_applicable" /><span><b>不适用</b></span></label>
<label><input type="radio" name="onset_function" value="uncertain" /><span><b>不确定</b></span></label>
</div>
</fieldset>
<fieldset>
<legend>5. 对话指向/场景 <span class="required">必填</span></legend>
<div class="choice-grid two compact">
<label><input type="radio" name="context" value="addressed_wearer" /><span><b>对 wearer</b></span></label>
<label><input type="radio" name="context" value="third_party" /><span><b>第三方交谈</b></span></label>
<label><input type="radio" name="context" value="group" /><span><b>群体</b></span></label>
<label><input type="radio" name="context" value="unknown" /><span><b>未知</b></span></label>
</div>
</fieldset>
<fieldset>
<legend>6. Offset 的结果 <span class="required">必填</span></legend>
<div class="choice-grid two compact">
<label><input type="radio" name="offset_outcome" value="yield_to_other" /><span><b>让给 other</b></span></label>
<label><input type="radio" name="offset_outcome" value="completion_to_silence" /><span><b>结束后静音</b></span></label>
<label><input type="radio" name="offset_outcome" value="temporary_pause" /><span><b>短暂停顿</b></span></label>
<label><input type="radio" name="offset_outcome" value="backchannel_end" /><span><b>Backchannel 结束</b></span></label>
<label><input type="radio" name="offset_outcome" value="cutoff" /><span><b>截断/边界</b></span></label>
<label><input type="radio" name="offset_outcome" value="not_applicable" /><span><b>不适用</b></span></label>
<label><input type="radio" name="offset_outcome" value="uncertain" /><span><b>不确定</b></span></label>
</div>
</fieldset>
<div class="inline-fields">
<label>
<span>other 锚点校正</span>
<select name="anchor_adjustment_bins">
<option value="">无法判断</option>
<option value="-1">−1 bin(−200 ms)</option>
<option value="0" selected>原 bin</option>
<option value="1">+1 bin(+200 ms)</option>
</select>
</label>
<label>
<span>wearer 行为锚点校正</span>
<select name="behavior_adjustment_bins">
<option value="">不适用/无法判断</option>
<option value="-1">−1 bin(−200 ms)</option>
<option value="0" selected>原 bin</option>
<option value="1">+1 bin(+200 ms)</option>
</select>
</label>
<label>
<span>负区间终点校正</span>
<select name="region_end_adjustment_bins">
<option value="">不适用/无法判断</option>
<option value="-1">−1 bin(−200 ms)</option>
<option value="0" selected>原 bin</option>
<option value="1">+1 bin(+200 ms)</option>
</select>
</label>
<label>
<span>置信度</span>
<select name="confidence" required>
<option value="high">高</option>
<option value="medium" selected>中</option>
<option value="low">低</option>
</select>
</label>
</div>
<label class="text-field">
<span>审核者</span>
<input id="reviewerInput" name="reviewer" maxlength="100" placeholder="姓名或代号" required />
</label>
<label class="text-field">
<span>备注</span>
<textarea name="notes" maxlength="4000" rows="3" placeholder="记录歧义、说话对象或边界问题"></textarea>
</label>
<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>
<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="uncertainButton" class="button ghost" type="button">送二审</button><button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button></div>
</form>
<div class="shortcuts">
<span><kbd>←</kbd><kbd>→</kbd> 前后事件</span>
<span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span>
<span><kbd>Space</kbd> 播放/暂停</span>
</div>
<div class="shortcuts"><span><kbd>←</kbd><kbd>→</kbd> 前后事件</span><span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span><span><kbd>Space</kbd> 播放/暂停</span></div>
</aside>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/app.js" defer></script>
<div id="toast" class="toast" role="status"></div><script src="/app.js" defer></script>
</body>
</html>
+357 -421
View File
@@ -1,18 +1,10 @@
#!/usr/bin/env python3
"""Prepare manuscript-aligned EasyCom EOT/INT regions for human review.
"""Generate wearer-centric EasyCom EOT/INT candidates for AI and human review.
The released EasyCom annotations provide participant-resolved speech intervals,
not semantic EOT/INT ground truth. This script therefore creates auditable
candidate regions from the original 20 Hz timeline. It keeps the two wearer-
relative directions distinct:
* EOT-response: an aggregate non-wearer offset while the wearer is silent;
* INT-yield: an aggregate non-wearer onset while the wearer is speaking.
Observed wearer transitions form timing-positive candidates. Competing other
transitions and fully observed no-transition windows form hard-negative
candidates. Source-boundary cases are retained as exclusions. Every output
row remains an automatic candidate until it is independently human reviewed.
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.
"""
from __future__ import annotations
@@ -26,22 +18,24 @@ import random
from typing import Any, Iterable, Sequence
from derive_easycom_interaction_events import (
CAMERA_WEARER_ID,
SOURCE_FPS,
Clip,
Rules,
Utterance,
build_clips,
is_lexical_feedback,
is_pure_nonverbal,
load_utterances,
CAMERA_WEARER_ID, SOURCE_FPS, Clip, Rules, Utterance, build_clips,
is_lexical_feedback, is_pure_nonverbal, load_utterances,
)
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
SCHEMA_VERSION = 1
PROTOCOL_VERSION = "easycom-manuscript-eot-int-v1"
DEFAULT_WINDOW_SECONDS = 2.0
SCHEMA_VERSION = 2
PROTOCOL_VERSION = "easycom-wearer-centric-eot-int-candidates-v2"
@dataclass(frozen=True)
class CandidateConfig:
context_seconds: float = 3.0
relation_window_seconds: float = 3.0
same_speaker_pause_max_seconds: float = 2.0
other_handover_max_seconds: float = 1.5
short_vocalization_max_seconds: float = 1.0
continuation_min_seconds: float = 0.4
@dataclass(frozen=True)
@@ -49,40 +43,46 @@ class ProtocolCandidate:
schema_version: int
protocol_version: str
uid: str
event_id: str
session: str
split: str
task: str
event_type: str
candidate_source: str
rule_candidate_type: str
automatic_protocol_label: str
automatic_hard_negative_type: str
automatic_hard_negative_type: str | None
automatic_confidence: str
anchor_type: str
anchor_dense_frame: int
anchor_dense_seconds: float
behavior_anchor_type: str | None
behavior_dense_frame: int | None
behavior_seconds: float | None
event_time: float
region_start_seconds: float
region_end_seconds: float
region_end_reason: str
previous_other_participant_ids: tuple[int, ...]
incoming_other_participant_ids: tuple[int, ...]
clip_stem: str
source_start_frame: int
previous_other_participant_ids: tuple[int, ...]
incoming_other_participant_ids: tuple[int, ...]
temporal_evidence: dict[str, Any]
speaker_timeline: tuple[dict[str, Any], ...]
context: tuple[dict[str, Any], ...]
automatic_reason: str
ai_verdict: None = None
ai_event_type: None = None
ai_subtype: None = None
ai_confidence: None = None
ai_reason: None = None
human_status: str = "UNREVIEWED"
human_verdict: None = None
human_event_type: None = None
human_subtype: None = None
exclude_reason: None = None
def _fill_state(
rows: Sequence[Utterance],
*,
segment_start: int,
segment_end: int,
wearer_id: int,
) -> tuple[bytearray, bytearray]:
def _fill_states(rows: Sequence[Utterance], *, segment_start: int, segment_end: int,
wearer_id: int) -> tuple[bytearray, bytearray]:
length = segment_end - segment_start
wearer = bytearray(length)
others = bytearray(length)
wearer, others = bytearray(length), bytearray(length)
for row in rows:
left = max(segment_start, row.dense_start_frame) - segment_start
right = min(segment_end, row.dense_end_frame_exclusive) - segment_start
@@ -93,371 +93,329 @@ def _fill_state(
return wearer, others
def _first_transition(
state: bytearray,
*,
start: int,
stop: int,
rising: bool,
) -> int | None:
for index in range(max(1, start), min(stop, len(state))):
before = bool(state[index - 1])
after = bool(state[index])
if (not before and after) if rising else (before and not after):
return index
return None
def _run_after(state: bytearray, index: int, value: bool) -> int:
end = index
while end < len(state) and bool(state[end]) == value:
end += 1
return end - index
def _active_other_ids(
rows: Sequence[Utterance],
dense_frame: int,
*,
wearer_id: int,
) -> tuple[int, ...]:
def _run_before(state: bytearray, index: int, value: bool) -> int:
start = index - 1
while start >= 0 and bool(state[start]) == value:
start -= 1
return index - start - 1
def _all_state(state: bytearray, start: int, stop: int, value: bool) -> bool:
left, right = max(0, start), min(len(state), stop)
return right > left and all(bool(state[i]) == value for i in range(left, right))
def _active_other_ids(rows: Sequence[Utterance], frame: int, *, wearer_id: int) -> tuple[int, ...]:
return tuple(sorted({
row.participant_id
for row in rows
row.participant_id for row in rows
if row.participant_id != wearer_id
and row.dense_start_frame <= dense_frame < row.dense_end_frame_exclusive
and row.dense_start_frame <= frame < row.dense_end_frame_exclusive
}))
def _clip_for_frame(clips: Sequence[Clip], dense_frame: int) -> Clip | None:
return next(
(
clip
for clip in clips
if clip.dense_start_frame <= dense_frame < clip.dense_start_frame + clip.num_frames
),
None,
)
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)
def _context_rows(
rows: Sequence[Utterance],
*,
anchor_frame: int,
context_frames: int,
wearer_id: int,
) -> tuple[dict[str, Any], ...]:
left = anchor_frame - context_frames
right = anchor_frame + context_frames
selected = []
for row in rows:
if row.dense_end_frame_exclusive <= left or row.dense_start_frame >= right:
continue
selected.append(
{
def _context_rows(rows: Sequence[Utterance], *, frame: int, context_frames: int,
wearer_id: int) -> tuple[dict[str, Any], ...]:
left, right = frame - context_frames, frame + context_frames
return tuple({
"uid": row.uid,
"participant_id": row.participant_id,
"participant_role": "wearer" if row.participant_id == wearer_id else "other",
"start_seconds": row.dense_start_seconds,
"end_seconds": row.dense_end_seconds,
"duration_seconds": row.duration_seconds,
"transcription": row.transcription,
"target_of_speech": list(row.target_of_speech),
}
)
return tuple(selected)
} for row in rows
if row.dense_end_frame_exclusive > left and row.dense_start_frame < right)
def _incoming_is_backchannel(
rows: Sequence[Utterance],
*,
dense_frame: int,
wearer_id: int,
max_seconds: float,
) -> bool:
incoming = [
row
for row in rows
if row.participant_id != wearer_id
and row.dense_start_frame <= dense_frame < row.dense_end_frame_exclusive
and row.dense_start_frame >= dense_frame - 1
]
return bool(incoming) and all(row.duration_seconds <= max_seconds for row in incoming) and any(
is_lexical_feedback(row.transcription) or is_pure_nonverbal(row.transcription)
for row in incoming
)
def _candidate(
*,
session: str,
task: str,
automatic_protocol_label: str,
automatic_hard_negative_type: str,
anchor_frame: int,
behavior_frame: int | None,
region_end_frame: int,
region_end_reason: str,
previous_ids: tuple[int, ...],
incoming_ids: tuple[int, ...],
clips: Sequence[Clip],
rows: Sequence[Utterance],
wearer_id: int,
context_frames: int,
reason: str,
) -> ProtocolCandidate:
clip = _clip_for_frame(clips, anchor_frame)
if clip is None:
raise ValueError(f"No source clip for {session} dense frame {anchor_frame}")
event_type = "eot_response_candidate" if task == "eot_response" else "int_yield_candidate"
anchor_type = "other_offset" if task == "eot_response" else "other_onset"
behavior_type = None
if behavior_frame is not None:
behavior_type = "wearer_onset" if task == "eot_response" else "wearer_offset"
return ProtocolCandidate(
schema_version=SCHEMA_VERSION,
protocol_version=PROTOCOL_VERSION,
uid=f"{session}/{task}/{anchor_frame:08d}",
session=session,
split="test" if session in TEST_SESSIONS else "train",
task=task,
event_type=event_type,
automatic_protocol_label=automatic_protocol_label,
automatic_hard_negative_type=automatic_hard_negative_type,
automatic_confidence="timing_candidate",
anchor_type=anchor_type,
anchor_dense_frame=anchor_frame,
anchor_dense_seconds=anchor_frame / SOURCE_FPS,
behavior_anchor_type=behavior_type,
behavior_dense_frame=behavior_frame,
behavior_seconds=None if behavior_frame is None else behavior_frame / SOURCE_FPS,
region_start_seconds=anchor_frame / SOURCE_FPS,
region_end_seconds=region_end_frame / SOURCE_FPS,
region_end_reason=region_end_reason,
previous_other_participant_ids=previous_ids,
incoming_other_participant_ids=incoming_ids,
clip_stem=clip.stem,
source_start_frame=anchor_frame - clip.dense_start_frame + 1,
context=_context_rows(
rows,
anchor_frame=anchor_frame,
context_frames=context_frames,
wearer_id=wearer_id,
def _relation_evidence(rows: Sequence[Utterance], *, frame: int, wearer_id: int,
window_frames: int) -> dict[str, Any]:
previous = [row for row in rows if row.participant_id != wearer_id
and row.dense_end_frame_exclusive <= frame
and frame - row.dense_end_frame_exclusive <= window_frames]
following = [row for row in rows if row.participant_id != wearer_id
and row.dense_start_frame >= frame
and row.dense_start_frame - frame <= window_frames]
prev = max(previous, key=lambda row: row.dense_end_frame_exclusive, default=None)
nxt = min(following, key=lambda row: row.dense_start_frame, default=None)
active_rows = [row for row in rows if row.participant_id != wearer_id
and row.dense_start_frame < frame < row.dense_end_frame_exclusive]
overlapping = sorted({row.participant_id for row in active_rows})
resumes = [] if prev is None else [row for row in following if row.participant_id == prev.participant_id]
resumed = min(resumes, key=lambda row: row.dense_start_frame, default=None)
nearby_onsets = [row for row in rows if row.participant_id != wearer_id
and abs(row.dense_start_frame - frame) <= window_frames]
closest_onset = min(nearby_onsets, key=lambda row: abs(row.dense_start_frame - frame), default=None)
return {
"previous_speaker_id": None if prev is None else prev.participant_id,
"previous_speaker_offset_seconds": None if prev is None else prev.dense_end_seconds,
"gap_from_previous_speaker_offset_seconds": (
None if prev is None else (frame - prev.dense_end_frame_exclusive) / SOURCE_FPS),
"overlapping_other_ids": overlapping,
"other_overlap_at_event_seconds": max(
[0.0] + [(row.dense_end_frame_exclusive - frame) / SOURCE_FPS for row in active_rows]
),
automatic_reason=reason,
"next_speaker_id": None if nxt is None else nxt.participant_id,
"next_other_onset_seconds": None if nxt is None else nxt.dense_start_seconds,
"previous_speaker_resumes": resumed is not None,
"previous_speaker_resume_delay_seconds": (
None if resumed is None or prev is None
else (resumed.dense_start_frame - prev.dense_end_frame_exclusive) / SOURCE_FPS
),
"closest_other_onset_relative_seconds": (
None if closest_onset is None else (closest_onset.dense_start_frame - frame) / SOURCE_FPS
),
}
def _candidate(*, session: str, task: str, source: str, rule_type: str,
anchor_type: str, frame: int, region_start: int, region_end: int,
label: str, subtype: str | None, previous_ids: tuple[int, ...],
incoming_ids: tuple[int, ...], evidence: dict[str, Any], clips: Sequence[Clip],
rows: Sequence[Utterance], wearer_id: int, context_frames: int,
reason: str) -> ProtocolCandidate | None:
clip = _clip_for_frame(clips, frame)
if clip is None:
return None
participant_key = "prev-" + ("-".join(f"p{x}" for x in previous_ids) or "none")
participant_key += "__in-" + ("-".join(f"p{x}" for x in incoming_ids) or "none")
uid = f"{session}/{source.lower()}/{frame:08d}/{participant_key}"
context = _context_rows(rows, frame=frame, context_frames=context_frames, wearer_id=wearer_id)
return ProtocolCandidate(
schema_version=SCHEMA_VERSION, protocol_version=PROTOCOL_VERSION,
uid=uid, event_id=uid, session=session,
split="test" if session in TEST_SESSIONS else "train", task=task,
event_type=source.lower(), candidate_source=source, rule_candidate_type=rule_type,
automatic_protocol_label=label, automatic_hard_negative_type=subtype,
automatic_confidence="rule_candidate", anchor_type=anchor_type,
anchor_dense_frame=frame, anchor_dense_seconds=frame / SOURCE_FPS,
event_time=frame / SOURCE_FPS, region_start_seconds=region_start / SOURCE_FPS,
region_end_seconds=region_end / SOURCE_FPS, clip_stem=clip.stem,
source_start_frame=frame - clip.dense_start_frame + 1,
previous_other_participant_ids=previous_ids,
incoming_other_participant_ids=incoming_ids, temporal_evidence=evidence,
speaker_timeline=context, context=context, automatic_reason=reason,
)
def derive_protocol_candidates(
utterances: Sequence[Utterance],
clips: Sequence[Clip],
*,
session: str,
wearer_id: int = CAMERA_WEARER_ID,
window_seconds: float = DEFAULT_WINDOW_SECONDS,
context_seconds: float = 3.0,
) -> list[ProtocolCandidate]:
"""Construct timing candidates with manuscript-aligned directionality."""
if window_seconds <= 0 or context_seconds < 0:
raise ValueError("window_seconds must be positive and context_seconds non-negative")
window_frames = max(1, round(window_seconds * SOURCE_FPS))
context_frames = max(0, round(context_seconds * SOURCE_FPS))
def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[Clip], *,
session: str, wearer_id: int = CAMERA_WEARER_ID,
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."""
cfg = config or CandidateConfig()
values = asdict(cfg)
if window_seconds is not None:
values["relation_window_seconds"] = window_seconds
if context_seconds is not None:
values["context_seconds"] = context_seconds
cfg = CandidateConfig(**values)
if any(value < 0 for value in asdict(cfg).values()) or cfg.relation_window_seconds == 0:
raise ValueError("candidate timing parameters must be non-negative and relation window positive")
context_frames = round(cfg.context_seconds * SOURCE_FPS)
relation_frames = round(cfg.relation_window_seconds * SOURCE_FPS)
pause_frames = round(cfg.same_speaker_pause_max_seconds * SOURCE_FPS)
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)
rows_by_segment: dict[int, list[Utterance]] = defaultdict(list)
clips_by_segment: dict[int, list[Clip]] = defaultdict(list)
for row in utterances:
rows_by_segment[row.segment_id].append(row)
for clip in clips:
clips_by_segment[clip.segment_id].append(clip)
candidates: list[ProtocolCandidate] = []
seen: set[tuple[str, int, tuple[int, ...]]] = set()
def add(item: ProtocolCandidate | None, key: tuple[str, int, tuple[int, ...]]) -> None:
if item is not None and key not in seen:
seen.add(key)
candidates.append(item)
for segment_id, segment_clips in sorted(clips_by_segment.items()):
segment_clips.sort(key=lambda clip: clip.dense_start_frame)
segment_rows = sorted(
rows_by_segment.get(segment_id, []),
key=lambda row: (row.dense_start_frame, row.dense_end_frame_exclusive, row.uid),
)
rows = sorted(rows_by_segment.get(segment_id, []),
key=lambda row: (row.dense_start_frame, row.dense_end_frame_exclusive, row.uid))
segment_start = segment_clips[0].dense_start_frame
segment_end = segment_clips[-1].dense_start_frame + segment_clips[-1].num_frames
wearer, others = _fill_state(
segment_rows,
segment_start=segment_start,
segment_end=segment_end,
wearer_id=wearer_id,
)
wearer, others = _fill_states(rows, segment_start=segment_start,
segment_end=segment_end, wearer_id=wearer_id)
for index in range(1, len(others)):
dense_frame = segment_start + index
if others[index - 1] and not others[index] and not wearer[index - 1]:
competing = _first_transition(
others,
start=index + 1,
stop=min(len(others), index + window_frames + 1),
rising=True,
# A: all wearer 0->1 and 1->0 transitions, without semantic guessing.
for index in range(1, len(wearer)):
frame = segment_start + index
if not wearer[index - 1] and wearer[index]:
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,
original_timestamp_seconds=frame / SOURCE_FPS,
)
nominal_end = index + window_frames
observable_end = min(nominal_end, len(others))
effective_end = min(observable_end, competing if competing is not None else observable_end)
outcome = _first_transition(
wearer,
start=index,
stop=effective_end,
rising=True,
)
previous_ids = _active_other_ids(
segment_rows, dense_frame - 1, wearer_id=wearer_id
)
incoming_ids = (
()
if competing is None
else _active_other_ids(
segment_rows, segment_start + competing, wearer_id=wearer_id
)
)
if outcome is not None:
label = "positive"
subtype = "not_applicable"
end_index = outcome
end_reason = "wearer_onset"
reason = "wearer onset observed after other offset and before competing other onset"
elif competing is not None:
label = "hard_negative"
subtype = (
"eot_midturn_pause"
if set(previous_ids) & set(incoming_ids)
else "eot_other_to_other"
)
end_index = competing
end_reason = "competing_other_onset"
reason = "wearer remains silent until another non-wearer onset"
elif nominal_end <= len(others):
label = "hard_negative"
subtype = "eot_no_response"
end_index = nominal_end
end_reason = "full_window"
reason = "wearer remains silent for the full observable response window"
else:
label = "exclude"
subtype = "other"
end_index = len(others)
end_reason = "right_censored"
reason = "recording segment ends before a wearer outcome or full window"
candidates.append(_candidate(
session=session,
task="eot_response",
automatic_protocol_label=label,
automatic_hard_negative_type=subtype,
anchor_frame=dense_frame,
behavior_frame=None if outcome is None else segment_start + outcome,
region_end_frame=segment_start + end_index,
region_end_reason=end_reason,
previous_ids=previous_ids,
incoming_ids=incoming_ids,
clips=segment_clips,
rows=segment_rows,
wearer_id=wearer_id,
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=reason,
))
if not others[index - 1] and others[index] and wearer[index - 1]:
competing = _first_transition(
others,
start=index + 1,
stop=min(len(others), index + window_frames + 1),
rising=False,
reason="observed wearer 0->1 transition; semantic EOT-response status is unresolved",
), ("WEARER_ONSET", frame, ()))
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,
)
nominal_end = index + window_frames
observable_end = min(nominal_end, len(others))
effective_end = min(observable_end, competing if competing is not None else observable_end)
outcome = _first_transition(
wearer,
start=index,
stop=effective_end,
rising=False,
)
previous_ids = _active_other_ids(
segment_rows, dense_frame - 1, wearer_id=wearer_id
)
incoming_ids = _active_other_ids(
segment_rows, dense_frame, wearer_id=wearer_id
)
if outcome is not None:
label = "positive"
subtype = "not_applicable"
end_index = outcome
end_reason = "wearer_offset"
reason = "wearer offset observed after other onset and before competing other offset"
elif competing is not None:
label = "hard_negative"
subtype = (
"int_backchannel"
if _incoming_is_backchannel(
segment_rows,
dense_frame=dense_frame,
wearer_id=wearer_id,
max_seconds=1.0,
)
else "int_non_takeover_overlap"
)
end_index = competing
end_reason = "competing_other_offset"
reason = "wearer retains the floor until the incoming other activity ends"
elif nominal_end <= len(others):
label = "hard_negative"
subtype = "int_retain_floor"
end_index = nominal_end
end_reason = "full_window"
reason = "wearer remains active through the full observable yield window"
else:
label = "exclude"
subtype = "other"
end_index = len(others)
end_reason = "right_censored"
reason = "recording segment ends before a wearer outcome or full window"
candidates.append(_candidate(
session=session,
task="int_yield",
automatic_protocol_label=label,
automatic_hard_negative_type=subtype,
anchor_frame=dense_frame,
behavior_frame=None if outcome is None else segment_start + outcome,
region_end_frame=segment_start + end_index,
region_end_reason=end_reason,
previous_ids=previous_ids,
incoming_ids=incoming_ids,
clips=segment_clips,
rows=segment_rows,
wearer_id=wearer_id,
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=reason,
))
reason="observed wearer 1->0 transition; semantic INT-yield status is unresolved",
), ("WEARER_OFFSET", frame, ()))
return sorted(candidates, key=lambda row: (row.anchor_dense_frame, row.task, row.uid))
other_rows = [row for row in rows if row.participant_id != wearer_id]
# B1: same speaker pauses and resumes while the wearer remains silent.
by_speaker: dict[int, list[Utterance]] = defaultdict(list)
for row in other_rows:
by_speaker[row.participant_id].append(row)
for participant_id, speaker_rows in by_speaker.items():
for first, resumed in zip(speaker_rows, speaker_rows[1:]):
gap = resumed.dense_start_frame - first.dense_end_frame_exclusive
left = first.dense_end_frame_exclusive - segment_start
right = resumed.dense_start_frame - segment_start
if (gap <= 0 or gap > pause_frames
or not _all_state(wearer, left, right, False)
or not _all_state(others, left, right, False)):
continue
frame = first.dense_end_frame_exclusive
evidence = {
"speaker_id": participant_id, "speaker_offset_seconds": first.dense_end_seconds,
"speaker_resume_seconds": resumed.dense_start_seconds,
"resume_delay_seconds": gap / SOURCE_FPS, "wearer_active_during_pause": False,
"original_timestamp_seconds": frame / SOURCE_FPS,
}
add(_candidate(
session=session, task="eot_response", source="SAME_SPEAKER_PAUSE",
rule_type="EOT_HARD_NEGATIVE_REGION", anchor_type="other_offset", frame=frame,
region_start=frame, region_end=resumed.dense_start_frame,
label="hard_negative", subtype="EOT_MIDTURN_PAUSE",
previous_ids=(participant_id,), incoming_ids=(participant_id,), evidence=evidence,
clips=segment_clips, rows=rows, wearer_id=wearer_id, context_frames=context_frames,
reason="same participant resumes after a configurable pause while wearer stays silent",
), ("SAME_SPEAKER_PAUSE", frame, (participant_id,)))
# B2: participant-resolved non-wearer to non-wearer handover.
for first in other_rows:
options = [row for row in other_rows if row.participant_id != first.participant_id
and row.dense_start_frame >= first.dense_end_frame_exclusive
and row.dense_start_frame - first.dense_end_frame_exclusive <= handover_frames]
if not options:
continue
incoming = min(options, key=lambda row: row.dense_start_frame)
same_resumes = any(row.participant_id == first.participant_id
and first.dense_end_frame_exclusive < row.dense_start_frame <= incoming.dense_start_frame
for row in other_rows)
left = first.dense_end_frame_exclusive - segment_start
right = max(left + 1, incoming.dense_start_frame - segment_start + 1)
if same_resumes or not _all_state(wearer, left, right, False):
continue
frame = first.dense_end_frame_exclusive
evidence = {
"previous_speaker_id": first.participant_id, "incoming_speaker_id": incoming.participant_id,
"previous_offset_seconds": first.dense_end_seconds,
"incoming_onset_seconds": incoming.dense_start_seconds,
"handover_gap_seconds": (incoming.dense_start_frame - frame) / SOURCE_FPS,
"wearer_active_during_handover": False, "original_timestamp_seconds": frame / SOURCE_FPS,
}
add(_candidate(
session=session, task="eot_response", source="OTHER_TO_OTHER",
rule_type="EOT_HARD_NEGATIVE_REGION", anchor_type="other_offset", frame=frame,
region_start=frame, region_end=incoming.dense_start_frame,
label="hard_negative", subtype="EOT_OTHER_TO_OTHER",
previous_ids=(first.participant_id,), incoming_ids=(incoming.participant_id,),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="participant-level IDs show a non-wearer to non-wearer handover",
), ("OTHER_TO_OTHER", frame, (first.participant_id, incoming.participant_id)))
# B3/B4: other activity ends while the wearer continuously holds and continues the floor.
for other in other_rows:
start = other.dense_start_frame - segment_start
end = other.dense_end_frame_exclusive - segment_start
if (start < 0 or end + continuation_frames > len(wearer)
or not _all_state(wearer, start, end + continuation_frames, True)):
continue
feedback = (other.dense_end_frame_exclusive - other.dense_start_frame <= short_frames
and (is_lexical_feedback(other.transcription) or is_pure_nonverbal(other.transcription)))
source = "OTHER_BACKCHANNEL" if feedback else "NON_TAKEOVER_OVERLAP"
subtype = "INT_OTHER_BACKCHANNEL" if feedback else "INT_NON_TAKEOVER_OVERLAP"
evidence = {
"other_speaker_id": other.participant_id, "other_onset_seconds": other.dense_start_seconds,
"other_offset_seconds": other.dense_end_seconds, "other_duration_seconds": other.duration_seconds,
"wearer_active_through_region": True,
"wearer_continuation_after_other_seconds": continuation_frames / SOURCE_FPS,
"lexical_or_nonverbal_feedback_hint": feedback,
"original_timestamp_seconds": other.dense_start_seconds,
}
add(_candidate(
session=session, task="int_yield", source=source,
rule_type="INT_HARD_NEGATIVE_REGION", anchor_type="other_onset",
frame=other.dense_start_frame, region_start=other.dense_start_frame,
region_end=other.dense_end_frame_exclusive, label="hard_negative", subtype=subtype,
previous_ids=_active_other_ids(rows, other.dense_start_frame - 1, wearer_id=wearer_id),
incoming_ids=(other.participant_id,), evidence=evidence, clips=segment_clips,
rows=rows, wearer_id=wearer_id, context_frames=context_frames,
reason=("short feedback-like other activity ends while wearer holds the floor"
if feedback else "other overlaps but wearer retains and continues the floor"),
), (source, other.dense_start_frame, (other.participant_id,)))
return sorted(candidates, key=lambda row: (row.anchor_dense_frame, row.candidate_source, row.uid))
def stratified_limit(
rows: Sequence[ProtocolCandidate],
limit: int | None,
*,
seed: int,
) -> list[ProtocolCandidate]:
def stratified_limit(rows: Sequence[ProtocolCandidate], limit: int | None, *, seed: int) -> list[ProtocolCandidate]:
if limit is None or limit >= len(rows):
return list(rows)
if limit < 0:
raise ValueError("--max-candidates must be non-negative")
rng = random.Random(seed)
buckets: dict[tuple[str, str, str], list[ProtocolCandidate]] = defaultdict(list)
buckets: dict[tuple[str, str], list[ProtocolCandidate]] = defaultdict(list)
for row in rows:
buckets[(row.task, row.automatic_protocol_label, row.automatic_hard_negative_type)].append(row)
keys = sorted(buckets)
for key in keys:
rng.shuffle(buckets[key])
rng.shuffle(keys)
buckets[(row.session, row.candidate_source)].append(row)
for bucket in buckets.values():
rng.shuffle(bucket)
selected: list[ProtocolCandidate] = []
while len(selected) < limit:
progressed = False
for key in keys:
if buckets[key]:
while len(selected) < limit and any(buckets.values()):
for key in sorted(buckets):
if buckets[key] and len(selected) < limit:
selected.append(buckets[key].pop())
progressed = True
if len(selected) == limit:
break
if not progressed:
break
return sorted(selected, key=lambda row: (row.session, row.anchor_dense_frame, row.task))
return sorted(selected, key=lambda row: (row.session, row.anchor_dense_frame, row.candidate_source))
def _write_jsonl(path: Path, rows: Iterable[ProtocolCandidate]) -> None:
@@ -473,20 +431,13 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
parser.add_argument("--camera-wearer-id", type=int, default=CAMERA_WEARER_ID)
parser.add_argument("--window-seconds", type=float, default=DEFAULT_WINDOW_SECONDS)
parser.add_argument("--context-seconds", type=float, default=3.0)
parser.add_argument(
"--tasks",
nargs="+",
choices=("eot_response", "int_yield"),
default=("eot_response", "int_yield"),
)
parser.add_argument(
"--automatic-labels",
nargs="+",
choices=("positive", "hard_negative", "exclude"),
default=("positive", "hard_negative", "exclude"),
)
parser.add_argument("--relation-window-seconds", type=float, default=3.0)
parser.add_argument("--same-speaker-pause-max-seconds", type=float, default=2.0)
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("--candidate-sources", nargs="+")
parser.add_argument("--max-candidates", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
return parser.parse_args()
@@ -497,74 +448,59 @@ def main() -> None:
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
if unknown:
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
dataset_root = args.dataset_root.expanduser().resolve()
output_dir = args.output_dir.expanduser().resolve()
config = CandidateConfig(
context_seconds=args.context_seconds,
relation_window_seconds=args.relation_window_seconds,
same_speaker_pause_max_seconds=args.same_speaker_pause_max_seconds,
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,
)
dataset_root, output_dir = args.dataset_root.expanduser().resolve(), args.output_dir.expanduser().resolve()
all_rows: list[ProtocolCandidate] = []
session_timelines: dict[str, list[dict[str, Any]]] = {}
rules = Rules()
for session in args.sessions:
clips = build_clips(dataset_root, session, rules)
utterances = load_utterances(session, clips)
rows = derive_protocol_candidates(
utterances,
clips,
session=session,
wearer_id=args.camera_wearer_id,
window_seconds=args.window_seconds,
context_seconds=args.context_seconds,
)
session_timelines[session] = [{
"uid": row.uid,
"participant_id": row.participant_id,
"participant_role": "wearer" if row.participant_id == args.camera_wearer_id else "other",
"start_seconds": row.dense_start_seconds,
"end_seconds": row.dense_end_seconds,
} for row in utterances]
rows = derive_protocol_candidates(utterances, clips, session=session,
wearer_id=args.camera_wearer_id, config=config)
all_rows.extend(rows)
counts = Counter((row.task, row.automatic_protocol_label) for row in rows)
print(f"{session}: candidates={len(rows)} counts={dict(sorted(counts.items()))}")
eligible = [
row
for row in all_rows
if row.task in set(args.tasks)
and row.automatic_protocol_label in set(args.automatic_labels)
]
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]
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)
counts = Counter(
(row.split, row.task, row.automatic_protocol_label, row.automatic_hard_negative_type)
for row in selected
)
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",
"semantic_warning": (
"Automatic labels are timing candidates for independent human audit; "
"they are not semantic EOT/INT ground truth."
),
"sessions": list(args.sessions),
"camera_wearer_id": args.camera_wearer_id,
"window_seconds": args.window_seconds,
"context_seconds": args.context_seconds,
"selection_seed": args.selection_seed,
"schema_version": SCHEMA_VERSION, "protocol_version": PROTOCOL_VERSION, "dataset": "EasyCom",
"semantic_warning": "Rules create candidates only; AI labels need human review and never modify dense activity ground truth.",
"sessions": list(args.sessions), "camera_wearer_id": args.camera_wearer_id,
"candidate_config": asdict(config), "selection_seed": args.selection_seed,
"max_candidates": args.max_candidates,
"tasks": list(args.tasks),
"automatic_labels": list(args.automatic_labels),
"num_candidates_before_filter": len(all_rows),
"num_candidates_before_limit": len(eligible),
"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_written": len(selected),
"counts": [
{
"split": key[0],
"task": key[1],
"automatic_protocol_label": key[2],
"automatic_hard_negative_type": key[3],
"count": value,
}
for key, value in sorted(counts.items())
],
"counts": [{"split": key[0], "candidate_source": key[1],
"rule_candidate_type": key[2], "count": value}
for key, value in sorted(counts.items())],
}
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "summary.json").write_text(
json.dumps(summary, indent=2, ensure_ascii=False) + "\n",
json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
(output_dir / "easycom_session_timelines.json").write_text(
json.dumps({"schema_version": 1, "sessions": session_timelines}, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Saved {len(selected)} manuscript-aligned review candidates to {output_path}")
print(f"Saved {len(selected)} wearer-centric review candidates to {output_path}")
if __name__ == "__main__":
+123 -31
View File
@@ -32,7 +32,7 @@ SOURCE_FPS = 20.0
STATIC_ROOT = Path(__file__).resolve().parent / "floor_event_review_web"
VIDEO_ROOT_PARTS = ("Main", "Video_Compressed")
TRANSCRIPT_ROOT_PARTS = ("Main", "Speech_Transcriptions")
REVIEW_SCHEMA_VERSION = 2
REVIEW_SCHEMA_VERSION = 3
MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude", "uncertain"}
@@ -67,6 +67,18 @@ OFFSET_OUTCOME_VALUES = {
"uncertain",
}
CONFIDENCE_VALUES = {"high", "medium", "low"}
HUMAN_ACTION_VALUES = {"accept", "relabel", "exclude", "second_review"}
HUMAN_VERDICT_VALUES = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"}
HUMAN_EVENT_TYPE_VALUES = {"EOT_RESPONSE", "INT_YIELD"}
HUMAN_SUBTYPE_VALUES = {
"EOT_MIDTURN_PAUSE", "EOT_OTHER_TO_OTHER", "EOT_WEARER_BACKCHANNEL",
"INT_OTHER_BACKCHANNEL", "INT_NON_TAKEOVER_OVERLAP", "INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE",
}
EXCLUDE_REASON_VALUES = {
"ANNOTATION_ERROR", "RECORDING_BOUNDARY", "MISSING_CONTEXT", "NON_SPEECH_ARTIFACT",
"TIMESTAMP_CORRUPTION", "UNRESOLVABLE",
}
@dataclass(frozen=True)
@@ -95,7 +107,8 @@ class Candidate:
@property
def automatic_label(self) -> str:
return str(
self.payload.get("event_type")
self.payload.get("candidate_source")
or self.payload.get("event_type")
or self.payload.get("category")
or self.payload.get("proxy_group")
or "unclassified"
@@ -108,6 +121,8 @@ class Candidate:
@property
def family(self) -> str:
if self.payload.get("candidate_source"):
return "eot" if self.task == "eot_response" else "int"
if self.task == "eot_response":
return "eot"
if self.task == "int_yield":
@@ -233,6 +248,16 @@ class CandidateRepository:
self.context_before_seconds = context_before_seconds
self.context_after_seconds = context_after_seconds
self._transcript_cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
self.session_timelines: dict[str, list[dict[str, Any]]] = {}
for path in sorted({candidate.source_path.parent / "easycom_session_timelines.json" for candidate in candidates}):
if not path.is_file():
continue
payload = json.loads(path.read_text(encoding="utf-8"))
sessions = payload.get("sessions") if isinstance(payload, dict) else None
if isinstance(sessions, dict):
for session, rows in sessions.items():
if isinstance(session, str) and isinstance(rows, list):
self.session_timelines[session] = [row for row in rows if isinstance(row, dict)]
def _transcript(self, session: str, clip_stem: str) -> list[dict[str, Any]]:
key = (session, clip_stem)
@@ -255,7 +280,9 @@ class CandidateRepository:
row = candidate.payload
clip_stem = row.get("clip_stem")
transcript_index: int | None = None
uid = _uid_parts(row.get("uid")) or _uid_parts(row.get("holder_uid"))
uid = None if row.get("candidate_source") else (
_uid_parts(row.get("uid")) or _uid_parts(row.get("holder_uid"))
)
if uid is not None:
uid_session, uid_stem, transcript_index = uid
if uid_session == candidate.session:
@@ -291,8 +318,10 @@ class CandidateRepository:
def summary(self, candidate: Candidate, reviewed: bool) -> dict[str, Any]:
row = candidate.payload
event_time = row.get(
"anchor_dense_seconds",
"event_time",
row.get("anchor_dense_seconds",
row.get("dense_start_seconds", row.get("boundary_seconds")),
),
)
return {
"candidate_id": candidate.candidate_id,
@@ -306,6 +335,11 @@ class CandidateRepository:
"automatic_hard_negative_type": row.get("automatic_hard_negative_type"),
"automatic_confidence": row.get("automatic_confidence", row.get("confidence")),
"llm_suggestion": row.get("llm_suggestion"),
"candidate_source": row.get("candidate_source"),
"rule_candidate_type": row.get("rule_candidate_type"),
"ai_verdict": row.get("ai_verdict"),
"ai_event_type": row.get("ai_event_type"),
"ai_subtype": row.get("ai_subtype"),
"participant_role": row.get("participant_role") or row.get("holder_role"),
"event_time_seconds": event_time,
"reviewed": reviewed,
@@ -389,15 +423,11 @@ class ReviewStore:
if not isinstance(payload, dict):
raise ValueError("review must be an object")
required_choices = {
"protocol_disposition": PROTOCOL_DISPOSITION_VALUES,
"hard_negative_type": HARD_NEGATIVE_TYPE_VALUES,
"validity": VALIDITY_VALUES,
"onset_function": ONSET_FUNCTION_VALUES,
"context": CONTEXT_VALUES,
"offset_outcome": OFFSET_OUTCOME_VALUES,
"confidence": CONFIDENCE_VALUES,
}
# Schema v3 deliberately keeps the common path small. Legacy v2 review
# payloads remain accepted so existing review files are not stranded.
if "human_action" not in payload:
return self._validated_legacy_review(payload)
required_choices = {"human_action": HUMAN_ACTION_VALUES, "confidence": CONFIDENCE_VALUES}
normalized: dict[str, Any] = {}
for key, allowed in required_choices.items():
value = payload.get(key)
@@ -405,15 +435,48 @@ class ReviewStore:
raise ValueError(f"Invalid {key}: {value!r}")
normalized[key] = value
for key in (
"anchor_adjustment_bins",
"behavior_adjustment_bins",
"region_end_adjustment_bins",
):
adjustment = payload.get(key)
if adjustment not in {-1, 0, 1, None}:
raise ValueError(f"{key} must be -1, 0, 1, or null")
normalized[key] = adjustment
action = normalized["human_action"]
candidate = self.candidates[candidate_id].payload
if action == "accept":
verdict = candidate.get("ai_verdict") or (candidate.get("llm_suggestion") or {}).get("verdict")
event_type = candidate.get("ai_event_type") or (candidate.get("llm_suggestion") or {}).get("event_type")
subtype = candidate.get("ai_subtype") or (candidate.get("llm_suggestion") or {}).get("subtype")
if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError("Accept requires an AI pre-label; use Relabel when no AI label is available")
elif action == "exclude":
verdict, event_type, subtype = "EXCLUDE", None, None
elif action == "second_review":
verdict, event_type, subtype = "UNSURE", None, None
else:
verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype")
if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError(f"Invalid human_verdict: {verdict!r}")
if verdict == "POSITIVE" and event_type not in HUMAN_EVENT_TYPE_VALUES:
raise ValueError("POSITIVE requires EOT_RESPONSE or INT_YIELD")
if verdict != "POSITIVE" and event_type is not None:
raise ValueError("Only POSITIVE may have a human_event_type")
if verdict == "HARD_NEGATIVE" and subtype not in HUMAN_SUBTYPE_VALUES:
raise ValueError("HARD_NEGATIVE requires a valid human_subtype")
if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError("Only HARD_NEGATIVE may have a human_subtype")
exclude_reason = payload.get("exclude_reason")
if action == "exclude" and exclude_reason not in EXCLUDE_REASON_VALUES:
raise ValueError("Exclude requires an exclude_reason")
if action != "exclude" and exclude_reason is not None:
raise ValueError("exclude_reason is only valid for Exclude")
correction = payload.get("boundary_correction_seconds")
if correction not in {-0.2, 0, 0.2, None}:
raise ValueError("boundary_correction_seconds must be -0.2, 0, 0.2, or null")
normalized.update(
human_status="SECOND_REVIEW" if action == "second_review" else "REVIEWED",
human_verdict=verdict,
human_event_type=event_type,
human_subtype=subtype,
exclude_reason=exclude_reason,
relabeled=action == "relabel",
boundary_correction_seconds=correction,
original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")),
)
reviewer = str(payload.get("reviewer", "")).strip()
notes = str(payload.get("notes", "")).strip()
@@ -427,6 +490,31 @@ class ReviewStore:
normalized["notes"] = notes
return normalized
def _validated_legacy_review(self, payload: dict[str, Any]) -> dict[str, Any]:
required_choices = {
"protocol_disposition": PROTOCOL_DISPOSITION_VALUES,
"hard_negative_type": HARD_NEGATIVE_TYPE_VALUES,
"validity": VALIDITY_VALUES, "onset_function": ONSET_FUNCTION_VALUES,
"context": CONTEXT_VALUES, "offset_outcome": OFFSET_OUTCOME_VALUES,
"confidence": CONFIDENCE_VALUES,
}
normalized: dict[str, Any] = {}
for key, allowed in required_choices.items():
value = payload.get(key)
if value not in allowed:
raise ValueError(f"Invalid {key}: {value!r}")
normalized[key] = value
for key in ("anchor_adjustment_bins", "behavior_adjustment_bins", "region_end_adjustment_bins"):
value = payload.get(key)
if value not in {-1, 0, 1, None}:
raise ValueError(f"{key} must be -1, 0, 1, or null")
normalized[key] = value
reviewer, notes = str(payload.get("reviewer", "")).strip(), str(payload.get("notes", "")).strip()
if not reviewer or len(reviewer) > 100 or len(notes) > 4000:
raise ValueError("invalid reviewer or notes")
normalized.update(reviewer=reviewer, notes=notes)
return normalized
def save(self, candidate_id: str, review: Any) -> dict[str, Any]:
normalized = self._validated_review(candidate_id, review)
candidate = self.candidates[candidate_id]
@@ -445,6 +533,9 @@ class ReviewStore:
"automatic_hard_negative_type": candidate.payload.get(
"automatic_hard_negative_type"
),
"ai_verdict": candidate.payload.get("ai_verdict"),
"ai_event_type": candidate.payload.get("ai_event_type"),
"ai_subtype": candidate.payload.get("ai_subtype"),
"review": normalized,
"updated_at": datetime.now(timezone.utc).isoformat(),
"candidate": candidate.payload,
@@ -497,15 +588,9 @@ class ReviewStore:
"automatic_label",
"automatic_protocol_label",
"automatic_hard_negative_type",
"protocol_disposition",
"hard_negative_type",
"validity",
"onset_function",
"context",
"offset_outcome",
"anchor_adjustment_bins",
"behavior_adjustment_bins",
"region_end_adjustment_bins",
"ai_verdict", "ai_event_type", "ai_subtype",
"human_action", "human_status", "human_verdict", "human_event_type", "human_subtype",
"exclude_reason", "relabeled", "boundary_correction_seconds", "original_event_time",
"confidence",
"reviewer",
"notes",
@@ -621,6 +706,13 @@ class ReviewRequestHandler(BaseHTTPRequestHandler):
)
self._json(detail)
return
if parsed.path == "/api/session-timeline":
session = query.get("session", [""])[0]
if session not in self.server.repository.session_timelines:
self._error("Session timeline unavailable", HTTPStatus.NOT_FOUND)
return
self._json({"session": session, "speech": self.server.repository.session_timelines[session]})
return
if parsed.path == "/api/export.jsonl":
self._send_download(self.server.store.output_path, "application/x-ndjson")
return
+54 -8
View File
@@ -3,17 +3,25 @@
from __future__ import annotations
import unittest
from pathlib import Path
import json
import tempfile
from adjudicate_easycom_eot_int_candidates import build_user_prompt, validate_decisions
from adjudicate_easycom_eot_int_candidates import (
_transport_rows, build_user_prompt, read_candidates, validate_decisions,
)
class EOTINTAdjudicationTests(unittest.TestCase):
def test_prompt_contains_task_and_context(self) -> None:
prompt = build_user_prompt(
[{"uid": "u1", "task": "eot_response", "context": [{"transcription": "Yes."}]}],
[{"uid": "u1", "candidate_source": "WEARER_ONSET", "task": "eot_response",
"temporal_evidence": {"wearer_duration": 2.0},
"context": [{"transcription": "Yes."}]}],
0,
)
self.assertIn("eot_response", prompt)
self.assertIn("WEARER_ONSET", prompt)
self.assertIn("wearer_duration", prompt)
self.assertIn("Yes.", prompt)
def test_decisions_require_exact_uid_and_vocabulary(self) -> None:
@@ -21,18 +29,56 @@ class EOTINTAdjudicationTests(unittest.TestCase):
"items": [
{
"uid": "u1",
"protocol_disposition": "hard_negative",
"hard_negative_type": "int_backchannel",
"confidence": "high",
"rationale": "Brief feedback while the wearer continues.",
"verdict": "HARD_NEGATIVE",
"event_type": None,
"subtype": "INT_OTHER_BACKCHANNEL",
"confidence": 0.91,
"reason": "Brief feedback while the wearer continues.",
"needs_human_review": False,
}
]
}
decisions = validate_decisions(payload, ["u1"])
self.assertEqual(decisions[0]["hard_negative_type"], "int_backchannel")
self.assertEqual(decisions[0]["subtype"], "INT_OTHER_BACKCHANNEL")
with self.assertRaisesRegex(ValueError, "mismatch"):
validate_decisions({"items": []}, ["u1"])
def test_markdown_escaped_uid_is_normalized_but_still_unique(self) -> None:
item = {
"uid": "Session\\_4/wearer\\_onset/00000024",
"verdict": "POSITIVE",
"event_type": "EOT_RESPONSE",
"subtype": None,
"confidence": 0.9,
"reason": "Sustained wearer response after the other speaker stops.",
"needs_human_review": False,
}
expected = "Session_4/wearer_onset/00000024"
decision = validate_decisions({"items": [item]}, [expected])[0]
self.assertEqual(decision["uid"], expected)
with self.assertRaisesRegex(ValueError, "duplicate"):
validate_decisions({"items": [item, {**item, "uid": expected}]}, [expected])
def test_duplicate_rule_uids_are_disambiguated_with_provenance(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "candidates.jsonl"
base = {"uid": "Session_4/other_to_other/00003600", "task": "eot_response"}
rows = [
{**base, "previous_other_participant_ids": [6], "incoming_other_participant_ids": [4]},
{**base, "previous_other_participant_ids": [3], "incoming_other_participant_ids": [6]},
]
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
loaded = read_candidates(path)
self.assertEqual(len({row["uid"] for row in loaded}), 2)
self.assertTrue(all(row["uid_disambiguated_from"] == base["uid"] for row in loaded))
def test_transport_ids_are_short_unique_and_reversible(self) -> None:
rows = [{"uid": "Session_4/other_to_other/00003600/a"},
{"uid": "Session_4/other_to_other/00003600/b"}]
transported, mapping = _transport_rows(rows)
self.assertEqual([row["uid"] for row in transported], ["item-0000", "item-0001"])
self.assertEqual(mapping["item-0001"], rows[1]["uid"])
if __name__ == "__main__":
unittest.main()
+34 -21
View File
@@ -50,17 +50,17 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
def derive(self, rows: list[Utterance]):
return derive_protocol_candidates(rows, [clip()], session="Session_4")
def test_eot_response_is_anchored_by_wearer_onset(self) -> None:
def test_every_wearer_onset_is_an_unresolved_entry_candidate(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.task == "eot_response")
self.assertEqual(event.automatic_protocol_label, "positive")
self.assertEqual(event.anchor_type, "other_offset")
self.assertEqual(event.behavior_anchor_type, "wearer_onset")
self.assertAlmostEqual(event.anchor_dense_seconds, 1.0)
self.assertAlmostEqual(event.behavior_seconds or -1, 1.2)
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.anchor_type, "wearer_onset")
self.assertAlmostEqual(event.event_time, 1.2)
self.assertAlmostEqual(event.temporal_evidence["wearer_speech_duration_after_onset_seconds"], 0.8)
def test_same_other_resume_is_eot_midturn_hard_negative(self) -> None:
rows = [
@@ -70,11 +70,11 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
event = next(
row
for row in self.derive(rows)
if row.task == "eot_response" and row.anchor_dense_seconds == 1.0
if row.candidate_source == "SAME_SPEAKER_PAUSE" and row.anchor_dense_seconds == 1.0
)
self.assertEqual(event.automatic_protocol_label, "hard_negative")
self.assertEqual(event.automatic_hard_negative_type, "eot_midturn_pause")
self.assertEqual(event.region_end_reason, "competing_other_onset")
self.assertEqual(event.automatic_hard_negative_type, "EOT_MIDTURN_PAUSE")
self.assertAlmostEqual(event.temporal_evidence["resume_delay_seconds"], 0.3)
def test_other_to_other_transfer_is_eot_hard_negative(self) -> None:
rows = [
@@ -84,31 +84,44 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
event = next(
row
for row in self.derive(rows)
if row.task == "eot_response" and row.anchor_dense_seconds == 1.0
if row.candidate_source == "OTHER_TO_OTHER" and row.anchor_dense_seconds == 1.0
)
self.assertEqual(event.automatic_protocol_label, "hard_negative")
self.assertEqual(event.automatic_hard_negative_type, "eot_other_to_other")
self.assertEqual(event.automatic_hard_negative_type, "EOT_OTHER_TO_OTHER")
self.assertNotEqual(
event.temporal_evidence["previous_speaker_id"],
event.temporal_evidence["incoming_speaker_id"],
)
def test_int_yield_is_anchored_by_wearer_offset(self) -> None:
def test_every_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."),
]
event = next(row for row in self.derive(rows) if row.task == "int_yield")
self.assertEqual(event.automatic_protocol_label, "positive")
self.assertEqual(event.anchor_type, "other_onset")
self.assertEqual(event.behavior_anchor_type, "wearer_offset")
self.assertAlmostEqual(event.behavior_seconds or -1, 2.0)
event = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_OFFSET")
self.assertEqual(event.automatic_protocol_label, "unsure")
self.assertEqual(event.rule_candidate_type, "YIELD_CANDIDATE")
self.assertEqual(event.anchor_type, "wearer_offset")
self.assertAlmostEqual(event.event_time, 2.0)
def test_brief_feedback_is_int_backchannel_hard_negative(self) -> None:
rows = [
utterance("wearer", 2, 0.0, 3.0, "Wearer keeps the floor."),
utterance("other", 4, 1.0, 1.4, "Yeah."),
]
event = next(row for row in self.derive(rows) if row.task == "int_yield")
event = next(row for row in self.derive(rows) if row.candidate_source == "OTHER_BACKCHANNEL")
self.assertEqual(event.automatic_protocol_label, "hard_negative")
self.assertEqual(event.automatic_hard_negative_type, "int_backchannel")
self.assertEqual(event.region_end_reason, "competing_other_offset")
self.assertEqual(event.automatic_hard_negative_type, "INT_OTHER_BACKCHANNEL")
self.assertTrue(event.temporal_evidence["wearer_active_through_region"])
def test_non_takeover_overlap_is_scanned_without_wearer_transition(self) -> None:
rows = [
utterance("wearer", 2, 0.0, 4.0, "Long wearer turn."),
utterance("other", 4, 1.0, 2.0, "Long overlapping contribution."),
]
event = next(row for row in self.derive(rows) if row.candidate_source == "NON_TAKEOVER_OVERLAP")
self.assertEqual(event.automatic_hard_negative_type, "INT_NON_TAKEOVER_OVERLAP")
self.assertAlmostEqual(event.region_start_seconds, 1.0)
if __name__ == "__main__":
+43 -11
View File
@@ -56,11 +56,23 @@ class FloorEventReviewTests(unittest.TestCase):
"clip_stem": "00-00-000",
"source_start_frame": 25,
"dense_start_seconds": 1.2,
"event_time": 1.2,
"candidate_source": "WEARER_ONSET",
"ai_verdict": "POSITIVE",
"ai_event_type": "EOT_RESPONSE",
"ai_subtype": None,
}
)
+ "\n",
encoding="utf-8",
)
(root / "easycom_session_timelines.json").write_text(
json.dumps({"schema_version": 1, "sessions": {"Session_4": [
{"participant_id": 4, "participant_role": "other", "start_seconds": 0.0, "end_seconds": 1.0},
{"participant_id": 2, "participant_role": "wearer", "start_seconds": 1.2, "end_seconds": 2.0},
]}}),
encoding="utf-8",
)
return dataset, events
def test_candidate_detail_resolves_media_and_transcript(self) -> None:
@@ -79,6 +91,7 @@ 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(len(repository.session_timelines["Session_4"]), 2)
def test_review_store_writes_resumable_jsonl_and_csv(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -88,15 +101,12 @@ class FloorEventReviewTests(unittest.TestCase):
output = root / "reviews" / "reviews.jsonl"
store = ReviewStore(output, candidates)
review = {
"protocol_disposition": "positive",
"hard_negative_type": "not_applicable",
"validity": "valid",
"onset_function": "backchannel",
"context": "addressed_wearer",
"offset_outcome": "not_applicable",
"anchor_adjustment_bins": 0,
"behavior_adjustment_bins": 0,
"region_end_adjustment_bins": None,
"human_action": "accept",
"human_verdict": None,
"human_event_type": None,
"human_subtype": None,
"exclude_reason": None,
"boundary_correction_seconds": None,
"confidence": "high",
"reviewer": "r1",
"notes": "clear acknowledgement",
@@ -106,9 +116,10 @@ class FloorEventReviewTests(unittest.TestCase):
self.assertTrue(output.with_suffix(".csv").is_file())
resumed = ReviewStore(output, candidates)
self.assertEqual(
resumed.reviews[candidates[0].candidate_id]["review"]["onset_function"],
"backchannel",
resumed.reviews[candidates[0].candidate_id]["review"]["human_event_type"],
"EOT_RESPONSE",
)
self.assertFalse(resumed.reviews[candidates[0].candidate_id]["review"]["relabeled"])
def test_directory_expansion_excludes_output(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -122,6 +133,27 @@ class FloorEventReviewTests(unittest.TestCase):
[candidate.resolve()],
)
def test_relabel_preserves_ai_and_records_human_decision(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
_, events = self._fixture(root)
candidates = load_candidates([events])
store = ReviewStore(root / "reviews.jsonl", candidates)
record = store.save(candidates[0].candidate_id, {
"human_action": "relabel",
"human_verdict": "HARD_NEGATIVE",
"human_event_type": None,
"human_subtype": "EOT_WEARER_BACKCHANNEL",
"exclude_reason": None,
"boundary_correction_seconds": None,
"confidence": "medium",
"reviewer": "r2",
"notes": "brief acknowledgement",
})
self.assertEqual(record["ai_event_type"], "EOT_RESPONSE")
self.assertEqual(record["review"]["human_subtype"], "EOT_WEARER_BACKCHANNEL")
self.assertTrue(record["review"]["relabeled"])
def test_byte_ranges(self) -> None:
self.assertEqual(parse_byte_range("bytes=2-5", 10), (2, 5))
self.assertEqual(parse_byte_range("bytes=7-", 10), (7, 9))