Revamp EasyCom floor event review pipeline

This commit is contained in:
Xin Wang
2026-09-14 18:43:58 +08:00
parent 7d9db4af58
commit 4f88806d56
11 changed files with 1081 additions and 965 deletions
+123 -31
View File
@@ -32,7 +32,7 @@ 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
REVIEW_SCHEMA_VERSION = 3
MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude", "uncertain"}
@@ -67,6 +67,18 @@ OFFSET_OUTCOME_VALUES = {
"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)
@@ -95,7 +107,8 @@ class Candidate:
@property
def automatic_label(self) -> str:
return str(
self.payload.get("event_type")
self.payload.get("candidate_source")
or self.payload.get("event_type")
or self.payload.get("category")
or self.payload.get("proxy_group")
or "unclassified"
@@ -108,6 +121,8 @@ class Candidate:
@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":
@@ -233,6 +248,16 @@ class CandidateRepository:
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)
@@ -255,7 +280,9 @@ class CandidateRepository:
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"))
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:
@@ -291,8 +318,10 @@ class CandidateRepository:
def summary(self, candidate: Candidate, reviewed: bool) -> dict[str, Any]:
row = candidate.payload
event_time = row.get(
"anchor_dense_seconds",
"event_time",
row.get("anchor_dense_seconds",
row.get("dense_start_seconds", row.get("boundary_seconds")),
),
)
return {
"candidate_id": candidate.candidate_id,
@@ -306,6 +335,11 @@ class CandidateRepository:
"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,
"reviewed": reviewed,
@@ -389,15 +423,11 @@ class ReviewStore:
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,
}
# 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)
@@ -405,15 +435,48 @@ class ReviewStore:
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
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")),
)
reviewer = str(payload.get("reviewer", "")).strip()
notes = str(payload.get("notes", "")).strip()
@@ -427,6 +490,31 @@ class ReviewStore:
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]
@@ -445,6 +533,9 @@ class ReviewStore:
"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,
@@ -497,15 +588,9 @@ class ReviewStore:
"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",
"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",
@@ -621,6 +706,13 @@ class ReviewRequestHandler(BaseHTTPRequestHandler):
)
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