Add manuscript-aligned EasyCom EOT INT review workflow

This commit is contained in:
Xin Wang
2026-09-14 16:29:25 +08:00
parent f4a7c01552
commit 7d9db4af58
12 changed files with 3112 additions and 0 deletions

View File

@@ -304,6 +304,9 @@ 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 |
| `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 |
### Local features

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Add conservative DeepSeek suggestions to EOT/INT review 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.
"""
from __future__ import annotations
import argparse
from collections import Counter
import hashlib
import json
import os
from pathlib import Path
import random
import time
from typing import Any, Sequence
import urllib.error
import urllib.request
PROMPT_VERSION = "easycom-manuscript-eot-int-adjudicator-v1"
DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_BASE_URL = "https://api.deepseek.com"
DISPOSITIONS = {"positive", "hard_negative", "exclude", "uncertain"}
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",
}
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.
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.
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.
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.
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."""
def read_candidates(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
if not line.strip():
continue
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}")
rows.append(row)
if not rows:
raise ValueError(f"No candidates in {path}")
return rows
def candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
fields = (
"uid",
"task",
"automatic_protocol_label",
"automatic_hard_negative_type",
"anchor_type",
"anchor_dense_seconds",
"behavior_anchor_type",
"behavior_seconds",
"region_start_seconds",
"region_end_seconds",
"region_end_reason",
"previous_other_participant_ids",
"incoming_other_participant_ids",
"automatic_reason",
"context",
)
return {field: row.get(field) for field in fields}
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
instruction = (
"Primary pass: classify conservatively."
if pass_index == 0
else "Independent skeptical pass: seek alternative explanations and choose uncertain when semantics are unsupported."
)
return (
instruction
+ "\nCandidate JSON:\n"
+ json.dumps([candidate_for_prompt(row) for row in rows], ensure_ascii=False, separators=(",", ":"))
)
def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, str]]:
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]] = {}
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")
confidence = item.get("confidence")
rationale = item.get("rationale")
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}")
decisions[str(uid)] = {
"uid": str(uid),
"protocol_disposition": str(disposition),
"hard_negative_type": str(negative_type),
"confidence": str(confidence),
"rationale": rationale.strip(),
}
if set(decisions) != expected:
raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}")
return [decisions[uid] for uid in expected_uids]
def _post(
*,
rows: Sequence[dict[str, Any]],
pass_index: int,
api_key: str,
model: str,
base_url: str,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, str]], dict[str, Any]]:
body = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_prompt(rows, pass_index)},
],
"response_format": {"type": "json_object"},
"thinking": {"type": "disabled"},
"temperature": 0,
"max_tokens": 2048,
"stream": False,
}
request = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
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", {})
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
KeyError,
TypeError,
json.JSONDecodeError,
ValueError,
) as error:
last_error = error
if attempt >= max_retries:
break
time.sleep(min(30.0, 2.0**attempt))
raise RuntimeError(f"DeepSeek request failed after {max_retries + 1} attempts: {last_error}")
def add_suggestions(
rows: Sequence[dict[str, Any]],
*,
api_key: str,
model: str,
base_url: str,
passes: int,
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}
usage: Counter[str] = Counter()
for pass_index in range(passes):
decisions, pass_usage = _post(
rows=rows,
pass_index=pass_index,
api_key=api_key,
model=model,
base_url=base_url,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
)
for decision in decisions:
by_uid[decision["uid"]].append(decision)
for key, value in pass_usage.items():
if isinstance(value, int):
usage[key] += value
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
}
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)
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),
}
enriched = dict(source)
enriched["llm_suggestion"] = {
"protocol_disposition": disposition,
"hard_negative_type": negative_type,
"confidence": confidence,
"rationale": rationale,
"unanimous": unanimous,
}
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(),
"decisions": decisions,
"human_review_required": True,
}
output.append(enriched)
return output, usage
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
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("--max-events", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--allow-external-transcript-upload", action="store_true")
parser.add_argument("--overwrite", action="store_true")
return parser.parse_args()
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")
rows = read_candidates(args.input.expanduser().resolve())
if args.max_events is not None:
if args.max_events < 0:
raise ValueError("--max-events must be non-negative")
rng = random.Random(args.selection_seed)
rows = rng.sample(rows, min(args.max_events, len(rows)))
rows.sort(key=lambda row: row["uid"])
print(f"Candidates={len(rows)} model={args.model} passes={args.passes}")
if args.dry_run:
print("DRY RUN: no API request will be made.\n")
print(build_user_prompt(rows, 0))
return
if not args.allow_external_transcript_upload:
raise PermissionError(
"Refusing to upload transcript excerpts; inspect --dry-run, then pass "
"--allow-external-transcript-upload when authorized."
)
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("Set DEEPSEEK_API_KEY in the environment")
output = args.output.expanduser().resolve()
if output.exists() and not args.overwrite:
raise FileExistsError(f"Refusing to overwrite {output}; pass --overwrite")
enriched, usage = add_suggestions(
rows,
api_key=api_key,
model=args.model,
base_url=args.base_url,
passes=args.passes,
timeout_seconds=args.timeout_seconds,
max_retries=args.max_retries,
)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8") as handle:
for row in enriched:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"Saved {len(enriched)} suggested candidates to {output}")
print("Usage:", dict(usage))
if __name__ == "__main__":
main()

View File

@@ -33,6 +33,7 @@ tests.
| [`easycom_oracle_information.md`](easycom_oracle_information.md) | Causal oracle per-person speaking-history upper bound and Session-12 screening commands |
| [`easycom_competing_risk_floor_evaluation.md`](easycom_competing_risk_floor_evaluation.md) | Running and auditing the competing-risk entry/yield evaluator with training-side threshold calibration |
| [`easycom_floor_event_diagnostics.md`](easycom_floor_event_diagnostics.md) | Separating boundary observability, event-objective mismatch and addressee information with remote commands |
| [`easycom_event_review_tool.md`](easycom_event_review_tool.md) | Reviewing automatically mined EasyCom floor-event candidates against local video and transcripts |
## Design history and implementation options

View File

@@ -0,0 +1,97 @@
# EasyCom EOT/INT review workspace
`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.
## Prepare manuscript-aligned candidates
Develop the annotation guide on training-side Sessions 4--12:
```bash
python3 prepare_easycom_eot_int_review.py \
--dataset-root /path/to/easycom \
--sessions Session_4 Session_5 Session_6 Session_7 Session_8 Session_9 Session_10 Session_11 Session_12 \
--output-dir reports/easycom_floor_event_candidates
```
For a one-event pilot, add `--max-candidates 1`. The generator follows the
manuscript direction exactly:
- 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.
Automatic labels remain candidates until independent human semantic review.
## Optional DeepSeek suggestion
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:
```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 \
--dry-run
```
After setting `DEEPSEEK_API_KEY`, make the authorized request:
```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.
## Start the local workspace
```bash
python3 review_floor_events.py \
--dataset-root /path/to/easycom \
--events reports/easycom_floor_event_candidates/easycom_eot_int_candidates.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.
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.
## 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.

View File

@@ -0,0 +1,303 @@
:root {
color-scheme: dark;
--bg: #07101d;
--panel: #0d1728;
--panel-2: #111d30;
--line: #24334b;
--line-strong: #344865;
--text: #edf5ff;
--muted: #98a8bd;
--accent: #5eead4;
--accent-strong: #2dd4bf;
--accent-dim: rgba(94, 234, 212, 0.12);
--amber: #fbbf24;
--danger: #fb7185;
--success: #34d399;
--radius: 12px;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.24);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { min-height: 100%; }
body {
margin: 0;
background:
radial-gradient(circle at 45% -20%, rgba(30, 64, 175, 0.18), transparent 42%),
var(--bg);
color: var(--text);
font-size: 16px;
}
button, input, select, textarea { font: inherit; }
button, a, select, input, textarea { outline-offset: 3px; }
:focus-visible { outline: 2px solid var(--accent); }
.topbar {
min-height: 72px;
padding: 12px 20px;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(280px, 440px) minmax(220px, 1fr);
align-items: center;
gap: 24px;
background: rgba(7, 16, 29, 0.92);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 32px;
height: 32px;
border: 2px solid var(--accent);
border-radius: 9px;
position: relative;
box-shadow: inset 0 0 0 5px var(--accent-dim);
}
.brand-mark::after {
content: "";
position: absolute;
left: 5px;
right: 5px;
top: 14px;
height: 2px;
background: var(--accent);
box-shadow: 5px -6px 0 -0.5px var(--accent), 11px 5px 0 -0.5px var(--accent);
transform: skewY(-35deg);
}
.brand h1 { font-size: 1rem; margin: 0; letter-spacing: 0.01em; }
.brand p { color: var(--muted); font-size: 0.8rem; margin: 2px 0 0; }
.progress-block { display: grid; gap: 7px; }
.progress-copy { display: flex; justify-content: space-between; gap: 20px; font-size: 0.86rem; }
.progress-copy span { color: var(--muted); }
.progress-track { height: 5px; border-radius: 8px; background: #17243a; overflow: hidden; }
.progress-track span { display: block; height: 100%; width: 0; background: var(--accent); transition: width 180ms ease; }
.export-actions { display: flex; justify-content: flex-end; gap: 8px; }
.export-actions a {
color: var(--text);
text-decoration: none;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 8px 11px;
font-size: 0.84rem;
background: var(--panel);
}
.export-actions a:hover { border-color: var(--accent); color: var(--accent); }
.filters {
padding: 12px 20px;
display: grid;
grid-template-columns: repeat(4, minmax(130px, 175px)) minmax(190px, 1fr) auto;
gap: 10px;
align-items: end;
border-bottom: 1px solid var(--line);
background: #091321;
}
.filters label, .inline-fields label, .text-field { display: grid; gap: 6px; }
.filters label > span, .inline-fields label > span, .text-field > span {
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
select, input[type="search"], input[type="text"], .text-field input, textarea {
width: 100%;
color: var(--text);
background: #0a1525;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 9px 10px;
min-height: 39px;
}
textarea { resize: vertical; line-height: 1.45; }
::placeholder { color: #65758d; }
.button {
min-height: 39px;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 9px 12px;
color: var(--text);
cursor: pointer;
background: var(--panel-2);
}
.button:hover { border-color: var(--accent); }
.button.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: #04221f; font-weight: 800; }
.button.primary:hover { background: var(--accent); }
.button:disabled { cursor: wait; opacity: 0.55; }
.workspace {
min-height: calc(100vh - 142px);
padding: 14px;
display: grid;
grid-template-columns: minmax(230px, 0.66fr) minmax(460px, 1.65fr) minmax(365px, 1fr);
gap: 14px;
align-items: start;
}
.panel {
background: linear-gradient(180deg, rgba(17, 29, 48, 0.96), rgba(10, 20, 35, 0.96));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.queue-panel, .review-panel { position: sticky; top: 152px; max-height: calc(100vh - 166px); }
.queue-panel { display: flex; flex-direction: column; }
.review-panel { overflow-y: auto; }
.panel-heading, .event-header, .section-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.panel-heading { padding: 15px 16px 12px; border-bottom: 1px solid var(--line); }
.panel-heading h2, .section-heading h3 { margin: 2px 0 0; font-size: 1rem; }
.eyebrow { color: var(--accent); font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; }
.count-pill, .badge {
display: inline-flex;
align-items: center;
min-height: 25px;
border-radius: 999px;
padding: 3px 9px;
border: 1px solid var(--line-strong);
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
}
.badge.accent { color: var(--accent); border-color: rgba(94, 234, 212, 0.45); background: var(--accent-dim); }
.queue-list { overflow-y: auto; padding: 7px; }
.queue-item {
width: 100%;
border: 1px solid transparent;
border-radius: 9px;
padding: 10px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
display: grid;
gap: 6px;
}
.queue-item:hover { background: rgba(255,255,255,0.035); }
.queue-item.active { border-color: var(--accent); background: var(--accent-dim); }
.queue-item.reviewed .queue-status { background: var(--success); }
.queue-line { display: flex; align-items: center; gap: 7px; }
.queue-line strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.83rem; }
.queue-status { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: #516078; }
.queue-meta { color: var(--muted); font-size: 0.74rem; display: flex; gap: 8px; }
.queue-label { font-size: 0.73rem; color: #cbd7e7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-limit { margin: 0; padding: 10px 14px; color: var(--muted); font-size: 0.75rem; border-top: 1px solid var(--line); }
.event-header { padding: 16px 18px; }
.event-header h2 { margin: 8px 0 3px; font-size: 1.2rem; }
.event-header p { margin: 0; color: var(--muted); font-size: 0.82rem; }
.event-badges { display: flex; gap: 6px; flex-wrap: wrap; }
.llm-suggestion {
margin: 0 18px 14px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.3);
border-radius: 10px;
background: rgba(94, 234, 212, 0.07);
color: #c9f8ef;
font-size: 0.8rem;
line-height: 1.45;
}
.llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; }
.event-position { color: var(--muted); font-size: 0.8rem; }
.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); }
.empty-state strong { color: var(--text); }
.time-marker { position: absolute; pointer-events: none; left: 50%; top: 10px; bottom: 44px; border-left: 1px dashed rgba(251,191,36,.7); opacity: 0; }
.video-shell:hover .time-marker { opacity: .55; }
.transport { padding: 10px 14px; display: flex; gap: 7px; align-items: center; border-bottom: 1px solid var(--line); }
.transport button { color: var(--muted); border: 1px solid var(--line); background: #0a1525; border-radius: 7px; padding: 6px 9px; cursor: pointer; }
.transport button:hover { color: var(--accent); border-color: var(--accent); }
.transport .jump-event { color: var(--amber); }
#timeReadout { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 0.8rem; }
.transcript-section { padding: 16px 18px 8px; }
.section-heading { margin-bottom: 10px; }
.section-heading > span { color: var(--muted); font-size: .78rem; }
.transcript-list { display: grid; gap: 7px; max-height: 270px; overflow-y: auto; padding-right: 3px; }
.transcript-row { display: grid; grid-template-columns: 72px minmax(0,1fr); gap: 10px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: rgba(2, 8, 18, 0.36); }
.transcript-row.anchor { border-color: rgba(251,191,36,.65); background: rgba(251,191,36,.08); }
.speaker { color: var(--accent); font-size: .75rem; font-weight: 800; }
.speaker small { color: var(--muted); display: block; margin-top: 3px; font-weight: 500; }
.utterance { line-height: 1.46; font-size: .9rem; }
.utterance small { display: block; color: var(--muted); margin-top: 4px; font-size: .72rem; }
.empty-copy { color: var(--muted); padding: 22px; text-align: center; border: 1px dashed var(--line-strong); border-radius: 8px; }
.raw-details { margin: 10px 18px 18px; border-top: 1px solid var(--line); padding-top: 12px; }
.raw-details summary { color: var(--muted); cursor: pointer; font-size: .82rem; }
.raw-details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 260px; overflow-y: auto; color: #bfd0e7; font-size: .74rem; line-height: 1.5; }
.review-heading { background: rgba(94,234,212,.035); }
.save-state { color: var(--muted); font-size: .75rem; }
.save-state.saved { color: var(--success); }
#reviewForm { padding: 13px 15px 16px; display: grid; gap: 14px; }
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 8px; }
legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; }
.required { color: var(--muted); font-size: .66rem; font-weight: 500; }
.choice-grid { display: grid; gap: 7px; }
.choice-grid.three { grid-template-columns: repeat(3, 1fr); }
.choice-grid.two { grid-template-columns: repeat(2, 1fr); }
.choice-grid label { cursor: pointer; min-width: 0; }
.choice-grid input { position: absolute; opacity: 0; pointer-events: none; }
.choice-grid label span { min-height: 52px; border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: grid; align-content: center; gap: 2px; background: rgba(2,8,18,.28); }
.choice-grid.compact label span { min-height: 40px; }
.choice-grid label:hover span { border-color: var(--line-strong); }
.choice-grid input:checked + span { border-color: var(--accent); background: var(--accent-dim); box-shadow: inset 0 0 0 1px rgba(94,234,212,.18); }
.choice-grid input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.choice-grid b { font-size: .8rem; overflow: hidden; text-overflow: ellipsis; }
.choice-grid small { color: var(--muted); font-size: .67rem; line-height: 1.25; }
.inline-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.inline-fields select { font-size: .82rem; }
.text-field input, .text-field textarea { font-size: .84rem; }
.form-error { color: #fecdd3; background: rgba(251,113,133,.1); border: 1px solid rgba(251,113,133,.4); border-radius: 8px; padding: 9px; font-size: .8rem; }
.review-actions { display: grid; grid-template-columns: .9fr 1.4fr; gap: 8px; }
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; }
.toast { position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 20px); background: #d9fff9; color: #05251f; border-radius: 9px; padding: 10px 15px; font-weight: 750; font-size: .84rem; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transition: 160ms ease; z-index: 40; }
.toast.visible { opacity: 1; transform: translate(-50%, 0); }
@media (max-width: 1180px) {
.topbar { grid-template-columns: 1fr minmax(250px, 380px); }
.export-actions { display: none; }
.workspace { grid-template-columns: 220px minmax(430px, 1fr); }
.review-panel { position: static; max-height: none; grid-column: 1 / -1; }
.queue-panel { top: 152px; }
.filters { grid-template-columns: repeat(3, 1fr); }
.search-field { grid-column: span 2; }
}
@media (max-width: 760px) {
.topbar { position: static; grid-template-columns: 1fr; gap: 12px; }
.filters { grid-template-columns: 1fr 1fr; padding: 10px; }
.search-field { grid-column: 1 / -1; }
.workspace { grid-template-columns: 1fr; padding: 10px; }
.queue-panel, .review-panel { position: static; max-height: none; }
.queue-panel { max-height: 340px; }
.transport { flex-wrap: wrap; }
#timeReadout { width: 100%; margin: 2px 0 0; }
.choice-grid.three { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,433 @@
"use strict";
const state = {
candidates: [],
filtered: [],
reviews: {},
currentId: null,
currentDetail: null,
saving: false,
};
const elements = Object.fromEntries(
[
"progressText", "progressMeta", "progressBar", "sessionFilter", "splitFilter",
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
"autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition",
"llmSuggestion", "llmSuggestionText",
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError",
"saveButton", "uncertainButton", "saveState", "toast",
].map((id) => [id, document.getElementById(id)])
);
const familyNames = {
eot: "EOT-RESPONSE",
int: "INT-YIELD",
onset: "WEARER ONSET",
offset: "LEGACY EOT PROXY",
interaction: "LEGACY INTERACTION",
};
function escapeText(value) {
return String(value ?? "");
}
function formatTime(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
const seconds = Number(value);
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, "0")}:${(seconds % 60).toFixed(1).padStart(4, "0")}`;
}
function toast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("visible");
window.clearTimeout(toast.timeout);
toast.timeout = window.setTimeout(() => elements.toast.classList.remove("visible"), 1800);
}
async function request(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `请求失败 (${response.status})`);
return payload;
}
function updateProgress() {
const total = state.candidates.length;
const reviewed = Object.keys(state.reviews).length;
const percent = total ? (reviewed / total) * 100 : 0;
elements.progressText.textContent = `${reviewed} / ${total} 已审核`;
elements.progressMeta.textContent = `${percent.toFixed(1)}%`;
elements.progressBar.style.width = `${percent}%`;
}
function populateSessions() {
const sessions = [...new Set(state.candidates.map((item) => item.session))].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true })
);
for (const session of sessions) {
const option = document.createElement("option");
option.value = session;
option.textContent = session;
elements.sessionFilter.append(option);
}
}
function applyFilters({ keepCurrent = true } = {}) {
const search = elements.searchFilter.value.trim().toLowerCase();
state.filtered = state.candidates.filter((item) => {
const reviewed = Boolean(state.reviews[item.candidate_id]);
const status = elements.statusFilter.value;
return (
(elements.sessionFilter.value === "all" || item.session === elements.sessionFilter.value) &&
(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))
);
});
renderQueue();
if (!keepCurrent || !state.filtered.some((item) => item.candidate_id === state.currentId)) {
selectCandidate(state.filtered[0]?.candidate_id || null);
} else {
updatePosition();
}
}
function renderQueue() {
elements.queueList.replaceChildren();
elements.queueCount.textContent = String(state.filtered.length);
elements.queueTitle.textContent = elements.statusFilter.value === "unreviewed" ? "待审核候选" : "候选事件";
const limit = 250;
for (const item of state.filtered.slice(0, limit)) {
const button = document.createElement("button");
button.type = "button";
button.className = `queue-item${item.candidate_id === state.currentId ? " active" : ""}${state.reviews[item.candidate_id] ? " reviewed" : ""}`;
button.dataset.id = item.candidate_id;
const line = document.createElement("div");
line.className = "queue-line";
const dot = document.createElement("span");
dot.className = "queue-status";
const title = document.createElement("strong");
title.textContent = item.uid;
line.append(dot, title);
const meta = document.createElement("div");
meta.className = "queue-meta";
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;
button.append(line, meta, label);
button.addEventListener("click", () => selectCandidate(item.candidate_id));
elements.queueList.append(button);
}
elements.queueLimit.hidden = state.filtered.length <= limit;
elements.queueLimit.textContent = state.filtered.length > limit ? `列表显示前 ${limit} 条;可用筛选缩小范围。` : "";
}
function updatePosition() {
const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId);
elements.eventPosition.textContent = index < 0 ? `— / ${state.filtered.length}` : `${index + 1} / ${state.filtered.length}`;
}
async function selectCandidate(candidateId) {
state.currentId = candidateId;
renderQueue();
updatePosition();
if (!candidateId) {
elements.eventTitle.textContent = "当前筛选下没有候选";
elements.eventSubtitle.textContent = "调整筛选条件后继续。";
elements.eventVideo.removeAttribute("src");
elements.eventVideo.load();
elements.mediaEmpty.hidden = false;
elements.llmSuggestion.hidden = true;
elements.reviewForm.reset();
return;
}
elements.eventTitle.textContent = "载入事件…";
elements.saveButton.disabled = true;
try {
const detail = await request(`/api/event?id=${encodeURIComponent(candidateId)}`);
if (state.currentId !== candidateId) return;
state.currentDetail = detail;
renderDetail(detail);
restoreForm(detail.review);
} catch (error) {
elements.eventTitle.textContent = "事件载入失败";
elements.eventSubtitle.textContent = error.message;
} finally {
elements.saveButton.disabled = false;
}
}
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.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);
elements.saveState.textContent = detail.review ? "已保存" : "未保存";
elements.saveState.classList.toggle("saved", Boolean(detail.review));
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 confidence = suggestion.confidence || suggestion.final_confidence || "—";
const rationale = suggestion.rationale || suggestion.final_rationale || "";
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
}
elements.mediaEmpty.hidden = detail.media_available;
if (detail.media_available) {
elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`;
elements.eventVideo.load();
} else {
elements.eventVideo.removeAttribute("src");
elements.eventVideo.load();
}
elements.transcriptList.replaceChildren();
if (!detail.transcript.length) {
const empty = document.createElement("div");
empty.className = "empty-copy";
empty.textContent = "当前上下文未找到 transcript。";
elements.transcriptList.append(empty);
} else {
for (const row of detail.transcript) {
const item = document.createElement("div");
item.className = `transcript-row${row.is_anchor ? " anchor" : ""}`;
const speaker = document.createElement("div");
speaker.className = "speaker";
speaker.textContent = `P${row.participant_id ?? "?"}`;
const time = document.createElement("small");
time.textContent = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)}`;
speaker.append(time);
const utterance = document.createElement("div");
utterance.className = "utterance";
utterance.textContent = escapeText(row.transcription);
const target = document.createElement("small");
target.textContent = `Target: ${typeof row.target_of_speech === "string" ? row.target_of_speech : JSON.stringify(row.target_of_speech ?? "unknown")}`;
utterance.append(target);
item.append(speaker, utterance);
elements.transcriptList.append(item);
}
}
updateTimeReadout();
}
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";
return;
}
for (const [name, value] of Object.entries(review)) {
const control = elements.reviewForm.elements[name];
if (!control) continue;
if (control instanceof RadioNodeList) control.value = String(value);
else control.value = value === null ? "" : String(value);
}
}
function formPayload() {
const data = new FormData(elements.reviewForm);
const adjustment = (name) => {
const value = data.get(name);
return value === "" ? null : Number(value);
};
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"),
confidence: data.get("confidence"),
reviewer: String(data.get("reviewer") || "").trim(),
notes: String(data.get("notes") || "").trim(),
};
}
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.reviewer) missing.push("审核者");
return missing;
}
async function saveCurrent({ advance = true } = {}) {
if (!state.currentId || state.saving) return;
const review = formPayload();
const missing = validateReview(review);
if (missing.length) {
elements.formError.textContent = `请完成:${missing.join("、")}`;
elements.formError.hidden = false;
return;
}
elements.formError.hidden = true;
state.saving = true;
elements.saveButton.disabled = true;
elements.saveState.textContent = "保存中…";
try {
await request("/api/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidate_id: state.currentId, review }),
});
state.reviews[state.currentId] = review;
localStorage.setItem("floor-reviewer", review.reviewer);
elements.saveState.textContent = "已保存";
elements.saveState.classList.add("saved");
updateProgress();
toast("审核结果已保存");
if (advance) {
const previousCandidates = [...state.filtered];
const index = previousCandidates.findIndex((item) => item.candidate_id === state.currentId);
applyFilters();
const next = state.filtered[index] || state.filtered[index - 1] || state.filtered[0];
if (elements.statusFilter.value === "all") {
const sequential = previousCandidates[index + 1] || previousCandidates[index];
selectCandidate(sequential?.candidate_id || null);
} else {
selectCandidate(next?.candidate_id || null);
}
} else {
renderQueue();
}
} catch (error) {
elements.formError.textContent = error.message;
elements.formError.hidden = false;
elements.saveState.textContent = "保存失败";
} finally {
state.saving = false;
elements.saveButton.disabled = false;
}
}
function moveCandidate(delta) {
if (!state.filtered.length) return;
const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId);
const nextIndex = Math.max(0, Math.min(state.filtered.length - 1, (index < 0 ? 0 : index) + delta));
selectCandidate(state.filtered[nextIndex].candidate_id);
}
function seek(delta) {
const video = elements.eventVideo;
if (!Number.isFinite(video.duration)) return;
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + delta));
}
function jumpToEvent() {
const value = state.currentDetail?.event_local_seconds;
if (value !== null && value !== undefined) elements.eventVideo.currentTime = Number(value);
}
function updateTimeReadout() {
const eventTime = state.currentDetail?.event_local_seconds;
const current = Number.isFinite(elements.eventVideo.currentTime) ? elements.eventVideo.currentTime : 0;
const delta = eventTime === null || eventTime === undefined ? null : current - Number(eventTime);
elements.timeReadout.textContent = delta === null ? formatTime(current) : `${formatTime(current)} · event ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} s`;
}
function isTypingTarget(target) {
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement;
}
function bindEvents() {
for (const control of [elements.sessionFilter, elements.splitFilter, elements.familyFilter, elements.statusFilter]) {
control.addEventListener("change", () => applyFilters({ keepCurrent: false }));
}
elements.searchFilter.addEventListener("input", () => applyFilters({ keepCurrent: false }));
elements.clearFilters.addEventListener("click", () => {
elements.sessionFilter.value = "all";
elements.splitFilter.value = "all";
elements.familyFilter.value = "all";
elements.statusFilter.value = "unreviewed";
elements.searchFilter.value = "";
applyFilters({ keepCurrent: false });
});
elements.reviewForm.addEventListener("submit", (event) => {
event.preventDefault();
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.confidence.value = "low";
});
elements.jumpEvent.addEventListener("click", jumpToEvent);
document.querySelectorAll("[data-seek]").forEach((button) =>
button.addEventListener("click", () => seek(Number(button.dataset.seek)))
);
elements.eventVideo.addEventListener("loadedmetadata", () => {
const start = state.currentDetail?.context_start_seconds;
if (start !== null && start !== undefined) elements.eventVideo.currentTime = Number(start);
});
elements.eventVideo.addEventListener("timeupdate", updateTimeReadout);
elements.eventVideo.addEventListener("error", () => { elements.mediaEmpty.hidden = false; });
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
saveCurrent();
return;
}
if (isTypingTarget(event.target)) return;
if (event.key === "ArrowLeft") { event.preventDefault(); moveCandidate(-1); }
else if (event.key === "ArrowRight") { event.preventDefault(); moveCandidate(1); }
else if (event.key === "[") { event.preventDefault(); seek(-0.2); }
else if (event.key === "]") { event.preventDefault(); seek(0.2); }
else if (event.code === "Space") {
event.preventDefault();
elements.eventVideo.paused ? elements.eventVideo.play() : elements.eventVideo.pause();
}
});
}
async function initialize() {
bindEvents();
try {
const payload = await request("/api/bootstrap");
state.candidates = payload.candidates;
state.reviews = payload.reviews || {};
populateSessions();
updateProgress();
applyFilters({ keepCurrent: false });
} catch (error) {
elements.progressText.textContent = "载入失败";
elements.progressMeta.textContent = error.message;
elements.eventTitle.textContent = "无法载入候选事件";
elements.eventSubtitle.textContent = error.message;
}
}
initialize();

View File

@@ -0,0 +1,285 @@
<!doctype html>
<html lang="zh-CN">
<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" />
<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>
</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>
<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>
<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>
<div id="llmSuggestion" class="llm-suggestion" hidden>
<strong>DeepSeek 预标注(仅供复核)</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>
<details class="raw-details">
<summary>查看自动规则与原始候选字段</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>
<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>
</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>
<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>
<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>
<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>
</form>
<div class="shortcuts">
<span><kbd>←</kbd><kbd>→</kbd> 前后事件</span>
<span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span>
<span><kbd>Space</kbd> 播放/暂停</span>
</div>
</aside>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""Prepare manuscript-aligned EasyCom EOT/INT regions for 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.
"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
import json
from pathlib import Path
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,
)
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
SCHEMA_VERSION = 1
PROTOCOL_VERSION = "easycom-manuscript-eot-int-v1"
DEFAULT_WINDOW_SECONDS = 2.0
@dataclass(frozen=True)
class ProtocolCandidate:
schema_version: int
protocol_version: str
uid: str
session: str
split: str
task: str
event_type: str
automatic_protocol_label: str
automatic_hard_negative_type: str
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
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
context: tuple[dict[str, Any], ...]
automatic_reason: str
def _fill_state(
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)
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
if right <= left:
continue
target = wearer if row.participant_id == wearer_id else others
target[left:right] = b"\x01" * (right - left)
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 _active_other_ids(
rows: Sequence[Utterance],
dense_frame: int,
*,
wearer_id: int,
) -> tuple[int, ...]:
return tuple(sorted({
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
}))
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 _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(
{
"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,
"transcription": row.transcription,
"target_of_speech": list(row.target_of_speech),
}
)
return tuple(selected)
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,
),
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))
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] = []
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),
)
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,
)
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,
)
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,
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,
)
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,
context_frames=context_frames,
reason=reason,
))
return sorted(candidates, key=lambda row: (row.anchor_dense_frame, row.task, row.uid))
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)
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)
selected: list[ProtocolCandidate] = []
while len(selected) < limit:
progressed = False
for key in keys:
if buckets[key]:
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))
def _write_jsonl(path: Path, rows: Iterable[ProtocolCandidate]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(asdict(row), ensure_ascii=False) + "\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset-root", type=Path, required=True)
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("--max-candidates", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
return parser.parse_args()
def main() -> None:
args = parse_args()
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()
all_rows: list[ProtocolCandidate] = []
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,
)
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)
]
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
)
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,
"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),
"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())
],
}
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "summary.json").write_text(
json.dumps(summary, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Saved {len(selected)} manuscript-aligned review candidates to {output_path}")
if __name__ == "__main__":
main()

793
review_floor_events.py Normal file
View File

@@ -0,0 +1,793 @@
#!/usr/bin/env python3
"""Serve a local, event-centered EasyCom annotation review workspace.
The server reads JSONL candidates produced by
``derive_easycom_interaction_events.py`` or ``audit_easycom_floor_events.py``.
It serves only media inside the supplied EasyCom dataset root and stores human
reviews outside the source dataset as an atomically rewritten JSONL file plus
a CSV companion.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import mimetypes
import os
from pathlib import Path
import re
import tempfile
from threading import Lock
from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse
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
MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude", "uncertain"}
HARD_NEGATIVE_TYPE_VALUES = {
"eot_midturn_pause",
"eot_other_to_other",
"eot_no_response",
"int_backchannel",
"int_non_takeover_overlap",
"int_retain_floor",
"other",
"not_applicable",
"uncertain",
}
VALIDITY_VALUES = {"valid", "artifact", "uncertain"}
ONSET_FUNCTION_VALUES = {
"floor_taking",
"backchannel",
"within_turn_resume",
"nonverbal",
"not_applicable",
"uncertain",
}
CONTEXT_VALUES = {"addressed_wearer", "third_party", "group", "unknown"}
OFFSET_OUTCOME_VALUES = {
"yield_to_other",
"completion_to_silence",
"temporary_pause",
"backchannel_end",
"cutoff",
"not_applicable",
"uncertain",
}
CONFIDENCE_VALUES = {"high", "medium", "low"}
@dataclass(frozen=True)
class Candidate:
candidate_id: str
source_path: Path
source_row: int
payload: dict[str, Any]
@property
def uid(self) -> str:
return str(
self.payload.get("uid")
or self.payload.get("holder_uid")
or f"{self.source_path.name}:{self.source_row}"
)
@property
def session(self) -> str:
return str(self.payload.get("session", "unknown"))
@property
def split(self) -> str:
return str(self.payload.get("split", "unknown"))
@property
def automatic_label(self) -> str:
return str(
self.payload.get("event_type")
or self.payload.get("category")
or self.payload.get("proxy_group")
or "unclassified"
)
@property
def task(self) -> str | None:
value = self.payload.get("task")
return None if value is None else str(value)
@property
def family(self) -> str:
if self.task == "eot_response":
return "eot"
if self.task == "int_yield":
return "int"
if "holder_uid" in self.payload or self.automatic_label.startswith("eot_"):
return "offset"
if self.payload.get("participant_role") == "wearer":
return "onset"
return "interaction"
def _json_default(value: Any) -> Any:
if isinstance(value, Path):
return str(value)
raise TypeError(f"Cannot serialize {type(value).__name__}")
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, 1):
line = raw_line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid JSON at {path}:{line_number}: {error}") from error
if not isinstance(row, dict):
raise ValueError(f"Expected a JSON object at {path}:{line_number}")
rows.append(row)
return rows
def expand_candidate_paths(values: Iterable[Path], *, output_path: Path) -> list[Path]:
paths: list[Path] = []
output_resolved = output_path.expanduser().resolve()
for raw_path in values:
path = raw_path.expanduser().resolve()
if path.is_dir():
paths.extend(sorted(path.glob("Session_*.events.jsonl")))
paths.extend(sorted(path.glob("easycom_*_candidates.jsonl")))
elif path.is_file():
paths.append(path)
else:
raise FileNotFoundError(path)
unique: list[Path] = []
seen: set[Path] = set()
for path in paths:
resolved = path.resolve()
if resolved == output_resolved or resolved in seen:
continue
if resolved.suffix.lower() != ".jsonl":
raise ValueError(f"Candidate input must be JSONL: {resolved}")
seen.add(resolved)
unique.append(resolved)
if not unique:
raise FileNotFoundError("No candidate JSONL files found")
return unique
def _candidate_digest(path: Path, source_row: int, payload: dict[str, Any]) -> str:
identity = {
"source_name": path.name,
"source_row": source_row,
"uid": payload.get("uid") or payload.get("holder_uid"),
"session": payload.get("session"),
"event_type": payload.get("event_type"),
"task": payload.get("task"),
"category": payload.get("category"),
"dense_start_seconds": payload.get("dense_start_seconds"),
"anchor_dense_seconds": payload.get("anchor_dense_seconds"),
"boundary_seconds": payload.get("boundary_seconds"),
}
encoded = json.dumps(identity, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:20]
def load_candidates(paths: Iterable[Path]) -> list[Candidate]:
candidates: list[Candidate] = []
ids: set[str] = set()
for path in paths:
for source_row, payload in enumerate(_read_jsonl(path), 1):
candidate_id = _candidate_digest(path, source_row, payload)
if candidate_id in ids:
raise ValueError(f"Duplicate candidate identity in {path}:{source_row}")
ids.add(candidate_id)
candidates.append(Candidate(candidate_id, path, source_row, payload))
if not candidates:
raise ValueError("Candidate files contain no rows")
return candidates
def _uid_parts(value: Any) -> tuple[str, str, int] | None:
if not isinstance(value, str):
return None
parts = value.split("/")
if len(parts) < 3 or not parts[-1].isdigit():
return None
return parts[-3], parts[-2], int(parts[-1])
def _within_root(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
class CandidateRepository:
def __init__(
self,
candidates: list[Candidate],
dataset_root: Path,
*,
context_before_seconds: float,
context_after_seconds: float,
) -> None:
self.candidates = candidates
self.by_id = {candidate.candidate_id: candidate for candidate in candidates}
self.dataset_root = dataset_root.expanduser().resolve()
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]]] = {}
def _transcript(self, session: str, clip_stem: str) -> list[dict[str, Any]]:
key = (session, clip_stem)
if key not in self._transcript_cache:
path = self.dataset_root.joinpath(
*TRANSCRIPT_ROOT_PARTS, session, f"{clip_stem}.json"
)
if not path.is_file() or not _within_root(path, self.dataset_root):
self._transcript_cache[key] = []
else:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, list):
raise ValueError(f"Expected transcript list: {path}")
self._transcript_cache[key] = [
row for row in payload if isinstance(row, dict)
]
return self._transcript_cache[key]
def _location(self, candidate: Candidate) -> tuple[str | None, float | None, int | None]:
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"))
if uid is not None:
uid_session, uid_stem, transcript_index = uid
if uid_session == candidate.session:
clip_stem = clip_stem or uid_stem
local_seconds: float | None = None
if row.get("source_start_frame") is not None:
local_seconds = max(0.0, (float(row["source_start_frame"]) - 1.0) / SOURCE_FPS)
elif clip_stem and transcript_index is not None:
transcript = self._transcript(candidate.session, str(clip_stem))
if 0 <= transcript_index < len(transcript):
source_row = transcript[transcript_index]
frame_key = "End_Frame" if candidate.family == "offset" else "Start_Frame"
if source_row.get(frame_key) is not None:
frame = float(source_row[frame_key])
local_seconds = max(
0.0,
frame / SOURCE_FPS if frame_key == "End_Frame" else (frame - 1.0) / SOURCE_FPS,
)
return (None if clip_stem is None else str(clip_stem), local_seconds, transcript_index)
def media_path(self, candidate: Candidate) -> Path | None:
clip_stem, _, _ = self._location(candidate)
if clip_stem is None:
return None
path = self.dataset_root.joinpath(
*VIDEO_ROOT_PARTS, candidate.session, f"{clip_stem}.mp4"
).resolve()
if not _within_root(path, self.dataset_root) or not path.is_file():
return None
return path
def summary(self, candidate: Candidate, reviewed: bool) -> dict[str, Any]:
row = candidate.payload
event_time = row.get(
"anchor_dense_seconds",
row.get("dense_start_seconds", row.get("boundary_seconds")),
)
return {
"candidate_id": candidate.candidate_id,
"uid": candidate.uid,
"session": candidate.session,
"split": candidate.split,
"family": candidate.family,
"task": candidate.task,
"automatic_label": candidate.automatic_label,
"automatic_protocol_label": row.get("automatic_protocol_label"),
"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"),
"participant_role": row.get("participant_role") or row.get("holder_role"),
"event_time_seconds": event_time,
"reviewed": reviewed,
}
def detail(self, candidate: Candidate) -> dict[str, Any]:
clip_stem, local_seconds, transcript_index = self._location(candidate)
transcript_rows: list[dict[str, Any]] = []
if clip_stem is not None:
for index, row in enumerate(self._transcript(candidate.session, clip_stem)):
try:
start = max(0.0, (float(row.get("Start_Frame", 1)) - 1.0) / SOURCE_FPS)
end = max(start, float(row.get("End_Frame", 1)) / SOURCE_FPS)
except (TypeError, ValueError):
continue
if local_seconds is not None and (
end < local_seconds - self.context_before_seconds
or start > local_seconds + self.context_after_seconds
):
continue
transcript_rows.append(
{
"index": index,
"start_seconds": start,
"end_seconds": end,
"participant_id": row.get("Participant_ID"),
"target_of_speech": row.get("Target_of_Speech"),
"transcription": str(row.get("Transcription", "")),
"is_anchor": (
index == transcript_index
or (
transcript_index is None
and local_seconds is not None
and start <= local_seconds <= end
)
),
}
)
media_path = self.media_path(candidate)
return {
**self.summary(candidate, reviewed=False),
"source_file": candidate.source_path.name,
"source_row": candidate.source_row,
"clip_stem": clip_stem,
"event_local_seconds": local_seconds,
"context_start_seconds": (
None
if local_seconds is None
else max(0.0, local_seconds - self.context_before_seconds)
),
"context_end_seconds": (
None
if local_seconds is None
else local_seconds + self.context_after_seconds
),
"media_available": media_path is not None,
"transcript_available": bool(transcript_rows),
"transcript": transcript_rows,
"candidate": candidate.payload,
}
class ReviewStore:
def __init__(self, output_path: Path, candidates: list[Candidate]) -> None:
self.output_path = output_path.expanduser().resolve()
self.csv_path = self.output_path.with_suffix(".csv")
self.candidates = {candidate.candidate_id: candidate for candidate in candidates}
self.order = {candidate.candidate_id: index for index, candidate in enumerate(candidates)}
self.lock = Lock()
self.reviews: dict[str, dict[str, Any]] = {}
if self.output_path.is_file():
for row in _read_jsonl(self.output_path):
candidate_id = row.get("candidate_id")
if isinstance(candidate_id, str) and candidate_id in self.candidates:
self.reviews[candidate_id] = row
def _validated_review(self, candidate_id: str, payload: Any) -> dict[str, Any]:
if candidate_id not in self.candidates:
raise ValueError("Unknown candidate_id")
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,
}
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",
):
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
reviewer = str(payload.get("reviewer", "")).strip()
notes = str(payload.get("notes", "")).strip()
if len(reviewer) > 100:
raise ValueError("reviewer is too long")
if not reviewer:
raise ValueError("reviewer is required")
if len(notes) > 4000:
raise ValueError("notes are too long")
normalized["reviewer"] = reviewer
normalized["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]
record = {
"schema_version": REVIEW_SCHEMA_VERSION,
"candidate_id": candidate_id,
"candidate_uid": candidate.uid,
"source_file": candidate.source_path.name,
"source_row": candidate.source_row,
"session": candidate.session,
"split": candidate.split,
"candidate_family": candidate.family,
"task": candidate.task,
"automatic_label": candidate.automatic_label,
"automatic_protocol_label": candidate.payload.get("automatic_protocol_label"),
"automatic_hard_negative_type": candidate.payload.get(
"automatic_hard_negative_type"
),
"review": normalized,
"updated_at": datetime.now(timezone.utc).isoformat(),
"candidate": candidate.payload,
}
with self.lock:
self.reviews[candidate_id] = record
self._write_locked()
return record
def _ordered(self) -> list[dict[str, Any]]:
return sorted(
self.reviews.values(),
key=lambda row: self.order.get(str(row.get("candidate_id")), 10**12),
)
def _atomic_text(self, path: Path, write: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary_name: str | None = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", newline="", dir=path.parent, delete=False
) as handle:
temporary_name = handle.name
write(handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_name, path)
finally:
if temporary_name is not None and Path(temporary_name).exists():
Path(temporary_name).unlink()
def _write_locked(self) -> None:
rows = self._ordered()
def write_jsonl(handle: Any) -> None:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False, default=_json_default) + "\n")
self._atomic_text(self.output_path, write_jsonl)
fieldnames = [
"candidate_id",
"candidate_uid",
"source_file",
"source_row",
"session",
"split",
"candidate_family",
"task",
"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",
"confidence",
"reviewer",
"notes",
"updated_at",
]
def write_csv(handle: Any) -> None:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
review = row["review"]
writer.writerow(
{
key: review.get(key) if key in review else row.get(key)
for key in fieldnames
}
)
self._atomic_text(self.csv_path, write_csv)
def parse_byte_range(value: str | None, size: int) -> tuple[int, int] | None:
if not value:
return None
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value.strip())
if match is None:
raise ValueError("Invalid Range header")
start_text, end_text = match.groups()
if not start_text and not end_text:
raise ValueError("Invalid empty Range header")
if not start_text:
suffix = int(end_text)
if suffix <= 0:
raise ValueError("Invalid suffix Range")
return max(0, size - suffix), size - 1
start = int(start_text)
end = size - 1 if not end_text else int(end_text)
if start >= size or start < 0 or end < start:
raise ValueError("Unsatisfiable Range")
return start, min(end, size - 1)
class ReviewHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(
self,
address: tuple[str, int],
repository: CandidateRepository,
store: ReviewStore,
) -> None:
super().__init__(address, ReviewRequestHandler)
self.repository = repository
self.store = store
class ReviewRequestHandler(BaseHTTPRequestHandler):
server: ReviewHTTPServer
def log_message(self, format: str, *args: Any) -> None:
print(f"[{self.log_date_time_string()}] {format % args}")
def _json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False, default=_json_default).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _error(self, message: str, status: int = HTTPStatus.BAD_REQUEST) -> None:
self._json({"error": message}, status)
def _candidate_from_query(self, query: dict[str, list[str]]) -> Candidate | None:
candidate_id = query.get("id", [""])[0]
return self.server.repository.by_id.get(candidate_id)
def do_GET(self) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if parsed.path == "/api/bootstrap":
store = self.server.store
repository = self.server.repository
self._json(
{
"schema_version": REVIEW_SCHEMA_VERSION,
"candidate_count": len(repository.candidates),
"reviewed_count": len(store.reviews),
"output_name": store.output_path.name,
"candidates": [
repository.summary(
candidate, candidate.candidate_id in store.reviews
)
for candidate in repository.candidates
],
"reviews": {
candidate_id: row["review"]
for candidate_id, row in store.reviews.items()
},
}
)
return
if parsed.path == "/api/event":
candidate = self._candidate_from_query(query)
if candidate is None:
self._error("Unknown candidate", HTTPStatus.NOT_FOUND)
return
detail = self.server.repository.detail(candidate)
detail["review"] = self.server.store.reviews.get(candidate.candidate_id, {}).get(
"review"
)
self._json(detail)
return
if parsed.path == "/api/export.jsonl":
self._send_download(self.server.store.output_path, "application/x-ndjson")
return
if parsed.path == "/api/export.csv":
self._send_download(self.server.store.csv_path, "text/csv; charset=utf-8")
return
if parsed.path == "/media":
candidate = self._candidate_from_query(query)
if candidate is None:
self._error("Unknown candidate", HTTPStatus.NOT_FOUND)
return
media_path = self.server.repository.media_path(candidate)
if media_path is None:
self._error("Media unavailable", HTTPStatus.NOT_FOUND)
return
self._send_media(media_path)
return
self._send_static(parsed.path)
def do_HEAD(self) -> None:
self.do_GET()
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path != "/api/review":
self._error("Unknown endpoint", HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._error("Invalid Content-Length")
return
if length <= 0 or length > MAX_REQUEST_BYTES:
self._error("Invalid request size")
return
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Payload must be an object")
candidate_id = str(payload.get("candidate_id", ""))
record = self.server.store.save(candidate_id, payload.get("review"))
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error:
self._error(str(error))
return
self._json({"saved": True, "record": record})
def _send_static(self, request_path: str) -> None:
name = "index.html" if request_path in {"", "/"} else request_path.lstrip("/")
if name not in {"index.html", "app.css", "app.js"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
path = (STATIC_ROOT / name).resolve()
if not _within_root(path, STATIC_ROOT) or not path.is_file():
self.send_error(HTTPStatus.NOT_FOUND)
return
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
body = path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _send_download(self, path: Path, content_type: str) -> None:
if not path.is_file():
self._error("No reviews have been saved yet", HTTPStatus.NOT_FOUND)
return
body = path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _send_media(self, path: Path) -> None:
size = path.stat().st_size
try:
byte_range = parse_byte_range(self.headers.get("Range"), size)
except ValueError:
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
self.send_header("Content-Range", f"bytes */{size}")
self.end_headers()
return
start, end = byte_range or (0, size - 1)
status = HTTPStatus.PARTIAL_CONTENT if byte_range is not None else HTTPStatus.OK
self.send_response(status)
self.send_header("Content-Type", mimetypes.guess_type(path.name)[0] or "video/mp4")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(end - start + 1))
if byte_range is not None:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
if self.command == "HEAD":
return
with path.open("rb") as handle:
handle.seek(start)
remaining = end - start + 1
while remaining:
chunk = handle.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--events",
type=Path,
nargs="+",
required=True,
help="Candidate JSONL files or directories containing generated candidates.",
)
parser.add_argument("--dataset-root", type=Path, required=True)
parser.add_argument(
"--output",
type=Path,
default=Path("reports/easycom_event_reviews/reviews.jsonl"),
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--context-before-seconds", type=float, default=3.0)
parser.add_argument("--context-after-seconds", type=float, default=3.0)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not STATIC_ROOT.is_dir():
raise FileNotFoundError(f"Review UI assets are missing: {STATIC_ROOT}")
dataset_root = args.dataset_root.expanduser().resolve()
if not dataset_root.is_dir():
raise FileNotFoundError(dataset_root)
if not 1 <= args.port <= 65535:
raise ValueError("--port must be in [1, 65535]")
if args.context_before_seconds < 0 or args.context_after_seconds < 0:
raise ValueError("Context durations must be non-negative")
output_path = args.output.expanduser().resolve()
candidate_paths = expand_candidate_paths(args.events, output_path=output_path)
candidates = load_candidates(candidate_paths)
repository = CandidateRepository(
candidates,
dataset_root,
context_before_seconds=args.context_before_seconds,
context_after_seconds=args.context_after_seconds,
)
store = ReviewStore(output_path, candidates)
server = ReviewHTTPServer((args.host, args.port), repository, store)
actual_host, actual_port = server.server_address[:2]
print(f"Loaded {len(candidates)} candidates from {len(candidate_paths)} JSONL file(s)")
print(f"Resuming {len(store.reviews)} saved reviews")
print(f"Review workspace: http://{actual_host}:{actual_port}")
print(f"Review output: {store.output_path}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping review workspace")
finally:
server.server_close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,38 @@
"""Tests for EOT/INT DeepSeek suggestion validation without network calls."""
from __future__ import annotations
import unittest
from adjudicate_easycom_eot_int_candidates import build_user_prompt, 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."}]}],
0,
)
self.assertIn("eot_response", prompt)
self.assertIn("Yes.", prompt)
def test_decisions_require_exact_uid_and_vocabulary(self) -> None:
payload = {
"items": [
{
"uid": "u1",
"protocol_disposition": "hard_negative",
"hard_negative_type": "int_backchannel",
"confidence": "high",
"rationale": "Brief feedback while the wearer continues.",
}
]
}
decisions = validate_decisions(payload, ["u1"])
self.assertEqual(decisions[0]["hard_negative_type"], "int_backchannel")
with self.assertRaisesRegex(ValueError, "mismatch"):
validate_decisions({"items": []}, ["u1"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,115 @@
"""Tests for manuscript-aligned EasyCom EOT/INT review regions."""
from __future__ import annotations
import unittest
from derive_easycom_interaction_events import Clip, Utterance
from prepare_easycom_eot_int_review import derive_protocol_candidates
def utterance(uid: str, participant: int, start: float, end: float, text: str) -> Utterance:
return Utterance(
uid=uid,
session="Session_4",
clip_stem="00-00-000",
clip_index=0,
segment_id=0,
participant_id=participant,
target_of_speech=("Group",),
transcription=text,
source_start_frame=round(start * 20) + 1,
source_end_frame=round(end * 20),
dense_start_frame=round(start * 20),
dense_end_frame_exclusive=round(end * 20),
dense_start_seconds=start,
dense_end_seconds=end,
duration_seconds=end - start,
starts_at_clip_boundary=start == 0,
ends_at_clip_boundary=False,
annotation_end_frame_clamped_by=0,
is_cross_clip_continuation=False,
)
def clip(seconds: float = 5.0) -> Clip:
return Clip(
path="/dataset/Main/Video_Compressed/Session_4/00-00-000.mp4",
transcript_path="/dataset/Main/Speech_Transcriptions/Session_4/00-00-000.json",
stem="00-00-000",
source_start_seconds=0.0,
source_end_seconds=seconds,
dense_start_frame=0,
num_frames=round(seconds * 20),
segment_id=0,
discontinuity_before_seconds=None,
)
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:
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)
def test_same_other_resume_is_eot_midturn_hard_negative(self) -> None:
rows = [
utterance("first", 4, 0.0, 1.0, "First clause."),
utterance("resume", 4, 1.3, 2.5, "Same turn continues."),
]
event = next(
row
for row in self.derive(rows)
if row.task == "eot_response" 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")
def test_other_to_other_transfer_is_eot_hard_negative(self) -> None:
rows = [
utterance("first", 4, 0.0, 1.0, "First speaker."),
utterance("next", 3, 1.2, 2.5, "Second speaker."),
]
event = next(
row
for row in self.derive(rows)
if row.task == "eot_response" 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")
def test_int_yield_is_anchored_by_wearer_offset(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)
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")
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")
if __name__ == "__main__":
unittest.main()

134
test_review_floor_events.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import json
from pathlib import Path
import tempfile
import unittest
from review_floor_events import (
CandidateRepository,
ReviewStore,
expand_candidate_paths,
load_candidates,
parse_byte_range,
)
class FloorEventReviewTests(unittest.TestCase):
def _fixture(self, root: Path) -> tuple[Path, Path]:
dataset = root / "dataset"
video_dir = dataset / "Main" / "Video_Compressed" / "Session_4"
transcript_dir = dataset / "Main" / "Speech_Transcriptions" / "Session_4"
video_dir.mkdir(parents=True)
transcript_dir.mkdir(parents=True)
(video_dir / "00-00-000.mp4").write_bytes(b"mock-video")
(transcript_dir / "00-00-000.json").write_text(
json.dumps(
[
{
"Start_Frame": 1,
"End_Frame": 20,
"Participant_ID": 4,
"Transcription": "Are you coming?",
"Target_of_Speech": [2],
},
{
"Start_Frame": 25,
"End_Frame": 40,
"Participant_ID": 2,
"Transcription": "Yes.",
"Target_of_Speech": [4],
},
]
),
encoding="utf-8",
)
events = root / "candidates.jsonl"
events.write_text(
json.dumps(
{
"uid": "Session_4/00-00-000/0001",
"session": "Session_4",
"split": "train",
"event_type": "ordinary_switch",
"confidence": "timing",
"participant_role": "wearer",
"clip_stem": "00-00-000",
"source_start_frame": 25,
"dense_start_seconds": 1.2,
}
)
+ "\n",
encoding="utf-8",
)
return dataset, events
def test_candidate_detail_resolves_media_and_transcript(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
dataset, events = self._fixture(root)
candidates = load_candidates([events])
repository = CandidateRepository(
candidates,
dataset,
context_before_seconds=3.0,
context_after_seconds=3.0,
)
detail = repository.detail(candidates[0])
self.assertTrue(detail["media_available"])
self.assertEqual(detail["event_local_seconds"], 1.2)
self.assertEqual(len(detail["transcript"]), 2)
self.assertTrue(detail["transcript"][1]["is_anchor"])
def test_review_store_writes_resumable_jsonl_and_csv(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
_, events = self._fixture(root)
candidates = load_candidates([events])
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,
"confidence": "high",
"reviewer": "r1",
"notes": "clear acknowledgement",
}
store.save(candidates[0].candidate_id, review)
self.assertTrue(output.is_file())
self.assertTrue(output.with_suffix(".csv").is_file())
resumed = ReviewStore(output, candidates)
self.assertEqual(
resumed.reviews[candidates[0].candidate_id]["review"]["onset_function"],
"backchannel",
)
def test_directory_expansion_excludes_output(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
candidate = root / "Session_4.events.jsonl"
output = root / "reviews.jsonl"
candidate.write_text("{}\n", encoding="utf-8")
output.write_text("{}\n", encoding="utf-8")
self.assertEqual(
expand_candidate_paths([root], output_path=output),
[candidate.resolve()],
)
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))
self.assertEqual(parse_byte_range("bytes=-3", 10), (7, 9))
with self.assertRaises(ValueError):
parse_byte_range("bytes=20-30", 10)
if __name__ == "__main__":
unittest.main()