Update llm assisted annotation

This commit is contained in:
Xin Wang
2026-09-14 23:04:21 +08:00
parent 068b7ab9e6
commit 06b5e6c385
12 changed files with 1348 additions and 238 deletions
+48 -15
View File
@@ -27,6 +27,8 @@ 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"
@@ -201,18 +203,22 @@ def _candidate_digest(path: Path, source_row: int, payload: dict[str, Any]) -> s
return hashlib.sha256(encoded).hexdigest()[:20]
def load_candidates(paths: Iterable[Path]) -> list[Candidate]:
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 rows")
raise ValueError("Candidate files contain no primary review rows")
return candidates
@@ -342,12 +348,20 @@ class CandidateRepository:
"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:
@@ -362,10 +376,14 @@ class CandidateRepository:
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": (
@@ -478,14 +496,13 @@ class ReviewStore:
original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")),
)
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")
# 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
@@ -633,6 +650,20 @@ def parse_byte_range(value: str | None, size: int) -> tuple[int, int] | None:
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
@@ -814,13 +845,7 @@ class ReviewRequestHandler(BaseHTTPRequestHandler):
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)
stream_file_range(handle, self.wfile.write, end - start + 1)
def parse_args() -> argparse.Namespace:
@@ -842,6 +867,12 @@ def parse_args() -> argparse.Namespace:
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()
@@ -859,7 +890,9 @@ def main() -> None:
output_path = args.output.expanduser().resolve()
candidate_paths = expand_candidate_paths(args.events, output_path=output_path)
candidates = load_candidates(candidate_paths)
candidates = load_candidates(
candidate_paths, include_offset_controls=args.include_offset_controls
)
repository = CandidateRepository(
candidates,
dataset_root,