#!/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()