2026-09-14 16:29:25 +08:00
|
|
|
#!/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
|
|
|
|
|
|
2026-09-15 02:12:18 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-14 16:29:25 +08:00
|
|
|
|
|
|
|
|
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")
|
2026-09-15 02:12:18 +08:00
|
|
|
REVIEW_SCHEMA_VERSION = 6
|
2026-09-14 16:29:25 +08:00
|
|
|
MAX_REQUEST_BYTES = 128 * 1024
|
|
|
|
|
|
2026-09-15 01:25:39 +08:00
|
|
|
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude"}
|
2026-09-14 16:29:25 +08:00
|
|
|
HARD_NEGATIVE_TYPE_VALUES = {
|
|
|
|
|
"eot_midturn_pause",
|
|
|
|
|
"eot_other_to_other",
|
|
|
|
|
"eot_no_response",
|
|
|
|
|
"int_backchannel",
|
|
|
|
|
"int_non_takeover_overlap",
|
|
|
|
|
"int_retain_floor",
|
|
|
|
|
"other",
|
|
|
|
|
}
|
2026-09-15 01:25:39 +08:00
|
|
|
VALIDITY_VALUES = {"valid", "artifact"}
|
2026-09-14 16:29:25 +08:00
|
|
|
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"}
|
2026-09-15 01:25:39 +08:00
|
|
|
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 = {
|
2026-09-15 01:25:39 +08:00
|
|
|
"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",
|
|
|
|
|
}
|
2026-09-15 02:12:18 +08:00
|
|
|
EXCLUDE_REASON_VALUES = EXCLUDE_REASONS | LEGACY_EXCLUDE_REASONS
|
2026-09-14 16:29:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|
2026-09-14 16:29:25 +08:00
|
|
|
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:
|
2026-09-15 01:25:39 +08:00
|
|
|
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"}:
|
2026-09-14 16:29:25 +08:00
|
|
|
return "int"
|
2026-09-15 01:25:39 +08:00
|
|
|
if self.payload.get("candidate_source") == "WEARER_ONSET":
|
|
|
|
|
return "entry"
|
2026-09-14 16:29:25 +08:00
|
|
|
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]:
|
2026-09-14 16:29:25 +08:00
|
|
|
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
|
2026-09-14 16:29:25 +08:00
|
|
|
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")
|
2026-09-14 16:29:25 +08:00
|
|
|
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)]
|
2026-09-14 16:29:25 +08:00
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
)
|
2026-09-14 16:29:25 +08:00
|
|
|
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",
|
2026-09-14 16:29:25 +08:00
|
|
|
row.get("dense_start_seconds", row.get("boundary_seconds")),
|
2026-09-14 18:43:58 +08:00
|
|
|
),
|
2026-09-14 16:29:25 +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"),
|
2026-09-15 02:12:18 +08:00
|
|
|
"ai_exclude_reason": row.get("ai_exclude_reason"),
|
|
|
|
|
"ai_exclude_reason_group": row.get("ai_exclude_reason_group"),
|
2026-09-14 16:29:25 +08:00
|
|
|
"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),
|
2026-09-14 16:29:25 +08:00
|
|
|
"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"
|
|
|
|
|
}
|
2026-09-14 16:29:25 +08:00
|
|
|
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}",
|
2026-09-14 16:29:25 +08:00
|
|
|
"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"
|
|
|
|
|
),
|
2026-09-14 16:29:25 +08:00
|
|
|
"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}
|
2026-09-14 16:29:25 +08:00
|
|
|
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"]
|
2026-09-15 01:25:39 +08:00
|
|
|
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")
|
2026-09-14 23:30:31 +08:00
|
|
|
selected_opportunity_uid = candidate.get("ai_selected_opportunity_uid") or (
|
|
|
|
|
candidate.get("llm_suggestion") or {}
|
|
|
|
|
).get("selected_opportunity_uid")
|
2026-09-15 02:12:18 +08:00
|
|
|
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
|
2026-09-14 23:30:31 +08:00
|
|
|
selected_opportunity_uid = None
|
2026-09-15 02:12:18 +08:00
|
|
|
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")
|
2026-09-14 23:30:31 +08:00
|
|
|
selected_opportunity_uid = payload.get("selected_opportunity_uid")
|
2026-09-15 02:12:18 +08:00
|
|
|
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:
|
2026-09-15 01:25:39 +08:00
|
|
|
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")
|
2026-09-15 01:25:39 +08:00
|
|
|
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")
|
2026-09-14 23:30:31 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-15 01:25:39 +08:00
|
|
|
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")
|
2026-09-14 23:30:31 +08:00
|
|
|
if selected_opportunity_uid not in allowed_opportunity_uids:
|
2026-09-15 01:25:39 +08:00
|
|
|
raise ValueError("WEARER_ENTRY may select only a supplied opportunity anchor")
|
2026-09-14 23:30:31 +08:00
|
|
|
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")),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-09-15 02:12:18 +08:00
|
|
|
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(
|
2026-09-15 01:25:39 +08:00
|
|
|
human_status="REVIEWED",
|
2026-09-14 18:43:58 +08:00
|
|
|
human_verdict=verdict,
|
|
|
|
|
human_event_type=event_type,
|
|
|
|
|
human_subtype=subtype,
|
2026-09-14 23:30:31 +08:00
|
|
|
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,
|
2026-09-15 02:12:18 +08:00
|
|
|
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")),
|
|
|
|
|
)
|
2026-09-14 16:29:25 +08:00
|
|
|
|
|
|
|
|
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")
|
2026-09-14 16:29:25 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-14 16:29:25 +08:00
|
|
|
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"),
|
2026-09-15 02:12:18 +08:00
|
|
|
"ai_exclude_reason": candidate.payload.get("ai_exclude_reason"),
|
|
|
|
|
"ai_exclude_reason_group": candidate.payload.get("ai_exclude_reason_group"),
|
2026-09-14 16:29:25 +08:00
|
|
|
"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",
|
2026-09-15 02:12:18 +08:00
|
|
|
"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",
|
2026-09-14 23:30:31 +08:00
|
|
|
"selected_opportunity_uid", "selected_opportunity_seconds",
|
2026-09-15 02:12:18 +08:00
|
|
|
"valid_prediction_interval_seconds", "exclude_reason", "exclude_reason_group", "relabeled",
|
2026-09-14 23:30:31 +08:00
|
|
|
"boundary_correction_seconds", "original_event_time",
|
2026-09-14 16:29:25 +08:00
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 16:29:25 +08:00
|
|
|
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
|
2026-09-14 16:29:25 +08:00
|
|
|
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)
|
2026-09-14 16:29:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
|
)
|
2026-09-14 16:29:25 +08:00
|
|
|
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
|
|
|
|
|
)
|
2026-09-14 16:29:25 +08:00
|
|
|
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()
|