Files
icassp2027-experiment/review_floor_events.py
T

976 lines
41 KiB
Python
Raw Normal View History

#!/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
from easycom_event_taxonomy import (
EXCLUDE_REASONS, LEGACY_EXCLUDE_REASONS, exclude_reason_group,
)
2026-09-14 23:04:21 +08:00
from prepare_easycom_eot_int_review import requires_semantic_adjudication
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 = 6
MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude"}
HARD_NEGATIVE_TYPE_VALUES = {
"eot_midturn_pause",
"eot_other_to_other",
"eot_no_response",
"int_backchannel",
"int_non_takeover_overlap",
"int_retain_floor",
"other",
}
VALIDITY_VALUES = {"valid", "artifact"}
ONSET_FUNCTION_VALUES = {
"floor_taking",
"backchannel",
"within_turn_resume",
"nonverbal",
}
CONTEXT_VALUES = {"addressed_wearer", "third_party", "group", "unknown"}
OFFSET_OUTCOME_VALUES = {
"yield_to_other",
"completion_to_silence",
"temporary_pause",
"backchannel_end",
"cutoff",
}
CONFIDENCE_VALUES = {"high", "medium", "low"}
HUMAN_ACTION_VALUES = {"accept", "relabel", "exclude"}
HUMAN_VERDICT_VALUES = {"POSITIVE", "HARD_NEGATIVE", "EXCLUDE"}
HUMAN_EVENT_TYPE_VALUES = {"WEARER_ENTRY", "INT_YIELD"}
2026-09-14 18:43:58 +08:00
HUMAN_SUBTYPE_VALUES = {
"ENTRY_OTHER_MIDTURN_PAUSE", "ENTRY_OTHER_TO_OTHER", "ENTRY_WEARER_BACKCHANNEL",
2026-09-14 18:43:58 +08:00
"INT_OTHER_BACKCHANNEL", "INT_NON_TAKEOVER_OVERLAP", "INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE",
}
EXCLUDE_REASON_VALUES = EXCLUDE_REASONS | LEGACY_EXCLUDE_REASONS
@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(
2026-09-14 18:43:58 +08:00
self.payload.get("candidate_source")
or 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 in {"wearer_entry", "wearer_entry_control", "eot_response", "eot_response_control"}:
return "entry"
if self.task in {"int_yield", "int_yield_control"}:
return "int"
if self.payload.get("candidate_source") == "WEARER_ONSET":
return "entry"
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]
2026-09-14 23:04:21 +08:00
def load_candidates(
paths: Iterable[Path], *, include_offset_controls: bool = False
) -> list[Candidate]:
candidates: list[Candidate] = []
ids: set[str] = set()
for path in paths:
for source_row, payload in enumerate(_read_jsonl(path), 1):
2026-09-14 23:04:21 +08:00
if not include_offset_controls and not requires_semantic_adjudication(payload):
continue
candidate_id = _candidate_digest(path, source_row, payload)
if candidate_id in ids:
raise ValueError(f"Duplicate candidate identity in {path}:{source_row}")
ids.add(candidate_id)
candidates.append(Candidate(candidate_id, path, source_row, payload))
if not candidates:
2026-09-14 23:04:21 +08:00
raise ValueError("Candidate files contain no primary review 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]]] = {}
2026-09-14 18:43:58 +08:00
self.session_timelines: dict[str, list[dict[str, Any]]] = {}
for path in sorted({candidate.source_path.parent / "easycom_session_timelines.json" for candidate in candidates}):
if not path.is_file():
continue
payload = json.loads(path.read_text(encoding="utf-8"))
sessions = payload.get("sessions") if isinstance(payload, dict) else None
if isinstance(sessions, dict):
for session, rows in sessions.items():
if isinstance(session, str) and isinstance(rows, list):
self.session_timelines[session] = [row for row in rows if isinstance(row, dict)]
def _transcript(self, session: str, clip_stem: str) -> list[dict[str, Any]]:
key = (session, clip_stem)
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
2026-09-14 18:43:58 +08:00
uid = None if row.get("candidate_source") else (
_uid_parts(row.get("uid")) or _uid_parts(row.get("holder_uid"))
)
if uid is not None:
uid_session, uid_stem, transcript_index = uid
if uid_session == candidate.session:
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(
2026-09-14 18:43:58 +08:00
"event_time",
row.get("anchor_dense_seconds",
row.get("dense_start_seconds", row.get("boundary_seconds")),
2026-09-14 18:43:58 +08:00
),
)
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"),
2026-09-14 18:43:58 +08:00
"candidate_source": row.get("candidate_source"),
"rule_candidate_type": row.get("rule_candidate_type"),
"ai_verdict": row.get("ai_verdict"),
"ai_event_type": row.get("ai_event_type"),
"ai_subtype": row.get("ai_subtype"),
"ai_exclude_reason": row.get("ai_exclude_reason"),
"ai_exclude_reason_group": row.get("ai_exclude_reason_group"),
"participant_role": row.get("participant_role") or row.get("holder_role"),
"event_time_seconds": event_time,
2026-09-14 23:04:21 +08:00
"region_start_seconds": row.get("region_start_seconds", event_time),
"region_end_seconds": row.get("region_end_seconds", event_time),
"reviewed": reviewed,
}
def detail(self, candidate: Candidate) -> dict[str, Any]:
clip_stem, local_seconds, transcript_index = self._location(candidate)
transcript_rows: list[dict[str, Any]] = []
2026-09-14 23:04:21 +08:00
evidence_rows = candidate.payload.get("speaker_timeline") or candidate.payload.get("context") or []
wearer_ids = {
row.get("participant_id")
for row in evidence_rows
if isinstance(row, dict) and row.get("participant_role") == "wearer"
}
if clip_stem is not None:
for index, row in enumerate(self._transcript(candidate.session, clip_stem)):
try:
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(
{
2026-09-14 23:04:21 +08:00
"uid": f"{candidate.session}/{clip_stem}/{index:04d}",
"index": index,
"start_seconds": start,
"end_seconds": end,
"participant_id": row.get("Participant_ID"),
2026-09-14 23:04:21 +08:00
"participant_role": (
"wearer" if row.get("Participant_ID") in wearer_ids else "other"
),
"target_of_speech": row.get("Target_of_Speech"),
"transcription": str(row.get("Transcription", "")),
"is_anchor": (
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")
2026-09-14 18:43:58 +08:00
# Schema v3 deliberately keeps the common path small. Legacy v2 review
# payloads remain accepted so existing review files are not stranded.
if "human_action" not in payload:
return self._validated_legacy_review(payload)
required_choices = {"human_action": HUMAN_ACTION_VALUES, "confidence": CONFIDENCE_VALUES}
normalized: dict[str, Any] = {}
for key, allowed in required_choices.items():
value = payload.get(key)
if value not in allowed:
raise ValueError(f"Invalid {key}: {value!r}")
normalized[key] = value
2026-09-14 18:43:58 +08:00
action = normalized["human_action"]
candidate_record = self.candidates[candidate_id]
candidate = candidate_record.payload
2026-09-14 18:43:58 +08:00
if action == "accept":
verdict = candidate.get("ai_verdict") or (candidate.get("llm_suggestion") or {}).get("verdict")
event_type = candidate.get("ai_event_type") or (candidate.get("llm_suggestion") or {}).get("event_type")
subtype = candidate.get("ai_subtype") or (candidate.get("llm_suggestion") or {}).get("subtype")
selected_opportunity_uid = candidate.get("ai_selected_opportunity_uid") or (
candidate.get("llm_suggestion") or {}
).get("selected_opportunity_uid")
exclude_reason = candidate.get("ai_exclude_reason") or (
candidate.get("llm_suggestion") or {}
).get("exclude_reason")
2026-09-14 18:43:58 +08:00
if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError("Accept requires an AI pre-label; use Relabel when no AI label is available")
elif action == "exclude":
verdict, event_type, subtype = "EXCLUDE", None, None
selected_opportunity_uid = None
exclude_reason = payload.get("exclude_reason")
2026-09-14 18:43:58 +08:00
else:
verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype")
selected_opportunity_uid = payload.get("selected_opportunity_uid")
exclude_reason = payload.get("exclude_reason")
2026-09-14 18:43:58 +08:00
if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError(f"Invalid human_verdict: {verdict!r}")
if verdict == "POSITIVE" and event_type not in HUMAN_EVENT_TYPE_VALUES:
raise ValueError("POSITIVE requires WEARER_ENTRY or INT_YIELD")
2026-09-14 18:43:58 +08:00
if verdict != "POSITIVE" and event_type is not None:
raise ValueError("Only POSITIVE may have a human_event_type")
if verdict == "HARD_NEGATIVE" and subtype not in HUMAN_SUBTYPE_VALUES:
raise ValueError("HARD_NEGATIVE requires a valid human_subtype")
if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError("Only HARD_NEGATIVE may have a human_subtype")
family = candidate_record.family
if verdict == "POSITIVE" and family in {"entry", "int"}:
required_event_type = "WEARER_ENTRY" if family == "entry" else "INT_YIELD"
if event_type != required_event_type:
raise ValueError(f"{family.upper()} candidate requires {required_event_type}")
if verdict == "HARD_NEGATIVE" and family in {"entry", "int"}:
prefix = family.upper() + "_"
if subtype != "OTHER_HARD_NEGATIVE" and not str(subtype).startswith(prefix):
raise ValueError(f"{family.upper()} candidate requires {prefix}* hard-negative subtype")
evidence = candidate.get("temporal_evidence") or {}
opportunity_options = evidence.get("opportunity_options") or []
allowed_opportunity_uids = {
str(option["uid"])
for option in opportunity_options
if isinstance(option, dict) and option.get("uid") is not None
}
if selected_opportunity_uid is not None:
if verdict != "POSITIVE" or event_type != "WEARER_ENTRY":
raise ValueError("selected_opportunity_uid is only valid for WEARER_ENTRY")
if selected_opportunity_uid not in allowed_opportunity_uids:
raise ValueError("WEARER_ENTRY may select only a supplied opportunity anchor")
selected_opportunity = next(
(
option for option in opportunity_options
if isinstance(option, dict) and str(option.get("uid")) == selected_opportunity_uid
),
None,
)
selected_opportunity_seconds = (
None if selected_opportunity is None else selected_opportunity.get("anchor_seconds")
)
behavior_anchor_seconds = evidence.get(
"behavior_anchor_seconds",
candidate.get(
"anchor_dense_seconds",
candidate.get("event_time", candidate.get("dense_start_seconds")),
),
)
if verdict == "EXCLUDE" and exclude_reason not in EXCLUDE_REASON_VALUES:
raise ValueError(
"EXCLUDE requires an exclude_reason; legacy AI exclusions must be manually categorized"
)
if verdict != "EXCLUDE" and exclude_reason is not None:
raise ValueError("exclude_reason is only valid for EXCLUDE")
2026-09-14 18:43:58 +08:00
correction = payload.get("boundary_correction_seconds")
if correction not in {-0.2, 0, 0.2, None}:
raise ValueError("boundary_correction_seconds must be -0.2, 0, 0.2, or null")
normalized.update(
human_status="REVIEWED",
2026-09-14 18:43:58 +08:00
human_verdict=verdict,
human_event_type=event_type,
human_subtype=subtype,
selected_opportunity_uid=selected_opportunity_uid,
selected_opportunity_seconds=selected_opportunity_seconds,
valid_prediction_interval_seconds=(
None
if selected_opportunity_seconds is None
else [selected_opportunity_seconds, behavior_anchor_seconds]
),
2026-09-14 18:43:58 +08:00
exclude_reason=exclude_reason,
exclude_reason_group=exclude_reason_group(exclude_reason),
2026-09-14 18:43:58 +08:00
relabeled=action == "relabel",
boundary_correction_seconds=correction,
original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")),
)
notes = str(payload.get("notes", "")).strip()
if len(notes) > 4000:
raise ValueError("notes are too long")
2026-09-14 23:04:21 +08:00
# Keep an empty compatibility field in exports written by the reviewer-less UI.
reviewer = str(payload.get("reviewer", "")).strip()
if len(reviewer) > 100:
raise ValueError("reviewer is too long")
normalized["reviewer"] = reviewer
normalized["notes"] = notes
return normalized
2026-09-14 18:43:58 +08:00
def _validated_legacy_review(self, payload: dict[str, Any]) -> dict[str, Any]:
required_choices = {
"protocol_disposition": PROTOCOL_DISPOSITION_VALUES,
"hard_negative_type": HARD_NEGATIVE_TYPE_VALUES,
"validity": VALIDITY_VALUES, "onset_function": ONSET_FUNCTION_VALUES,
"context": CONTEXT_VALUES, "offset_outcome": OFFSET_OUTCOME_VALUES,
"confidence": CONFIDENCE_VALUES,
}
normalized: dict[str, Any] = {}
for key, allowed in required_choices.items():
value = payload.get(key)
if value not in allowed:
raise ValueError(f"Invalid {key}: {value!r}")
normalized[key] = value
for key in ("anchor_adjustment_bins", "behavior_adjustment_bins", "region_end_adjustment_bins"):
value = payload.get(key)
if value not in {-1, 0, 1, None}:
raise ValueError(f"{key} must be -1, 0, 1, or null")
normalized[key] = value
reviewer, notes = str(payload.get("reviewer", "")).strip(), str(payload.get("notes", "")).strip()
if not reviewer or len(reviewer) > 100 or len(notes) > 4000:
raise ValueError("invalid reviewer or notes")
normalized.update(reviewer=reviewer, notes=notes)
return normalized
def save(self, candidate_id: str, review: Any) -> dict[str, Any]:
normalized = self._validated_review(candidate_id, review)
candidate = self.candidates[candidate_id]
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"
),
2026-09-14 18:43:58 +08:00
"ai_verdict": candidate.payload.get("ai_verdict"),
"ai_event_type": candidate.payload.get("ai_event_type"),
"ai_subtype": candidate.payload.get("ai_subtype"),
"ai_exclude_reason": candidate.payload.get("ai_exclude_reason"),
"ai_exclude_reason_group": candidate.payload.get("ai_exclude_reason_group"),
"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",
2026-09-14 18:43:58 +08:00
"ai_verdict", "ai_event_type", "ai_subtype",
"ai_exclude_reason", "ai_exclude_reason_group",
2026-09-14 18:43:58 +08:00
"human_action", "human_status", "human_verdict", "human_event_type", "human_subtype",
"selected_opportunity_uid", "selected_opportunity_seconds",
"valid_prediction_interval_seconds", "exclude_reason", "exclude_reason_group", "relabeled",
"boundary_correction_seconds", "original_event_time",
"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)
2026-09-14 23:04:21 +08:00
def stream_file_range(handle: Any, write: Any, remaining: int) -> bool:
"""Stream a media range; return False when the browser cancels normally."""
try:
while remaining:
chunk = handle.read(min(64 * 1024, remaining))
if not chunk:
break
write(chunk)
remaining -= len(chunk)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
return False
return True
class ReviewHTTPServer(ThreadingHTTPServer):
daemon_threads = True
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
2026-09-14 18:43:58 +08:00
if parsed.path == "/api/session-timeline":
session = query.get("session", [""])[0]
if session not in self.server.repository.session_timelines:
self._error("Session timeline unavailable", HTTPStatus.NOT_FOUND)
return
self._json({"session": session, "speech": self.server.repository.session_timelines[session]})
return
if parsed.path == "/api/export.jsonl":
self._send_download(self.server.store.output_path, "application/x-ndjson")
return
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)
2026-09-14 23:04:21 +08:00
stream_file_range(handle, self.wfile.write, end - start + 1)
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)
2026-09-14 23:04:21 +08:00
parser.add_argument(
"--include-controls", "--include-offset-controls",
dest="include_offset_controls",
action="store_true",
help="Include deterministic wearer-resume and non-overlap offset controls for a separate audit.",
)
return parser.parse_args()
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)
2026-09-14 23:04:21 +08:00
candidates = load_candidates(
candidate_paths, include_offset_controls=args.include_offset_controls
)
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()