#!/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 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 = 3 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"} HUMAN_ACTION_VALUES = {"accept", "relabel", "exclude", "second_review"} HUMAN_VERDICT_VALUES = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"} HUMAN_EVENT_TYPE_VALUES = {"EOT_RESPONSE", "INT_YIELD"} HUMAN_SUBTYPE_VALUES = { "EOT_MIDTURN_PAUSE", "EOT_OTHER_TO_OTHER", "EOT_WEARER_BACKCHANNEL", "INT_OTHER_BACKCHANNEL", "INT_NON_TAKEOVER_OVERLAP", "INT_WEARER_HOLDS_FLOOR", "OTHER_HARD_NEGATIVE", } EXCLUDE_REASON_VALUES = { "ANNOTATION_ERROR", "RECORDING_BOUNDARY", "MISSING_CONTEXT", "NON_SPEECH_ARTIFACT", "TIMESTAMP_CORRUPTION", "UNRESOLVABLE", } @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("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.payload.get("candidate_source"): return "eot" if self.task == "eot_response" else "int" 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], *, 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): 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: 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]]] = {} 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 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( "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"), "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"), "participant_role": row.get("participant_role") or row.get("holder_role"), "event_time_seconds": event_time, "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]] = [] 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( { "uid": f"{candidate.session}/{clip_stem}/{index:04d}", "index": index, "start_seconds": start, "end_seconds": end, "participant_id": row.get("Participant_ID"), "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") # 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 action = normalized["human_action"] candidate = self.candidates[candidate_id].payload 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") 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 elif action == "second_review": verdict, event_type, subtype = "UNSURE", None, None else: verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype") 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 EOT_RESPONSE or INT_YIELD") 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") exclude_reason = payload.get("exclude_reason") if action == "exclude" and exclude_reason not in EXCLUDE_REASON_VALUES: raise ValueError("Exclude requires an exclude_reason") if action != "exclude" and exclude_reason is not None: raise ValueError("exclude_reason is only valid for Exclude") 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="SECOND_REVIEW" if action == "second_review" else "REVIEWED", human_verdict=verdict, human_event_type=event_type, human_subtype=subtype, exclude_reason=exclude_reason, 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") # 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 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" ), "ai_verdict": candidate.payload.get("ai_verdict"), "ai_event_type": candidate.payload.get("ai_event_type"), "ai_subtype": candidate.payload.get("ai_subtype"), "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", "ai_verdict", "ai_event_type", "ai_subtype", "human_action", "human_status", "human_verdict", "human_event_type", "human_subtype", "exclude_reason", "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) 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 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) 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) 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) 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()