diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
index c44d9a0..199072b 100644
--- a/PROJECT_CONTEXT.md
+++ b/PROJECT_CONTEXT.md
@@ -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
diff --git a/adjudicate_easycom_eot_int_candidates.py b/adjudicate_easycom_eot_int_candidates.py
new file mode 100644
index 0000000..a2f0460
--- /dev/null
+++ b/adjudicate_easycom_eot_int_candidates.py
@@ -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()
diff --git a/docs/README.md b/docs/README.md
index d4934e3..eae0f5a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/easycom_event_review_tool.md b/docs/easycom_event_review_tool.md
new file mode 100644
index 0000000..d56cbe8
--- /dev/null
+++ b/docs/easycom_event_review_tool.md
@@ -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.
diff --git a/floor_event_review_web/app.css b/floor_event_review_web/app.css
new file mode 100644
index 0000000..90487cb
--- /dev/null
+++ b/floor_event_review_web/app.css
@@ -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; }
+}
diff --git a/floor_event_review_web/app.js b/floor_event_review_web/app.js
new file mode 100644
index 0000000..d199902
--- /dev/null
+++ b/floor_event_review_web/app.js
@@ -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();
diff --git a/floor_event_review_web/index.html b/floor_event_review_web/index.html
new file mode 100644
index 0000000..f4f0b5c
--- /dev/null
+++ b/floor_event_review_web/index.html
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+ Floor Event Review
+
+
+
+
+
+
+
+
+
Floor Event Review
+
EasyCom 事件审核
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DeepSeek 预标注(仅供复核)
+ —
+
+
+
+
+
+ 当前候选未找到视频
+ 检查 dataset root、session 和 clip stem。
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+ TRANSCRIPT
+
事件上下文
+
+
—
+
+
+
+
+
+ 查看自动规则与原始候选字段
+ {}
+
+
+
+
+
+
+
+
+
+
diff --git a/prepare_easycom_eot_int_review.py b/prepare_easycom_eot_int_review.py
new file mode 100644
index 0000000..4352b52
--- /dev/null
+++ b/prepare_easycom_eot_int_review.py
@@ -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()
diff --git a/review_floor_events.py b/review_floor_events.py
new file mode 100644
index 0000000..573d4c6
--- /dev/null
+++ b/review_floor_events.py
@@ -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()
diff --git a/test_adjudicate_easycom_eot_int_candidates.py b/test_adjudicate_easycom_eot_int_candidates.py
new file mode 100644
index 0000000..3109897
--- /dev/null
+++ b/test_adjudicate_easycom_eot_int_candidates.py
@@ -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()
diff --git a/test_prepare_easycom_eot_int_review.py b/test_prepare_easycom_eot_int_review.py
new file mode 100644
index 0000000..192be66
--- /dev/null
+++ b/test_prepare_easycom_eot_int_review.py
@@ -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()
diff --git a/test_review_floor_events.py b/test_review_floor_events.py
new file mode 100644
index 0000000..eff825a
--- /dev/null
+++ b/test_review_floor_events.py
@@ -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()