Revamp EasyCom floor event review pipeline
This commit is contained in:
+123
-31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user