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 事件审核

+
+
+
+
+ 载入中 + — +
+
+
+ +
+ +
+ + + + + + +
+ +
+ + +
+
+
+
+ — + — + — +
+

请选择一个候选事件

+

—

+
+
0 / 0
+
+ + + +
+ + + +
+ +
+ + + + + + — +
+ +
+
+
+ TRANSCRIPT +

事件上下文

+
+ — +
+
+
选择事件后显示 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()