Make EasyCom event review wearer anchor first

This commit is contained in:
Xin Wang
2026-09-14 23:30:31 +08:00
parent 06b5e6c385
commit 65347dd75d
12 changed files with 336 additions and 107 deletions

View File

@@ -304,7 +304,7 @@ Do not reorganize them physically without compatibility wrappers.
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
| `prepare_easycom_eot_int_review.py` | Group short non-competed wearer resumptions into floor episodes, pair EOT release/opportunity and wearer-entry behavior anchors, require `wearer_floor_start < other_onset < wearer_offset` plus boundary crossing for INT, preserve ineligible boundaries as controls, and scan independent hard negatives; semantic labels remain AI/human review outputs |
| `prepare_easycom_eot_int_review.py` | Group short non-competed wearer resumptions into floor episodes; start from wearer onsets/offsets, retrieve constrained preceding release options for EOT, require `wearer_floor_start < other_onset < wearer_offset` plus boundary crossing for INT, preserve ineligible boundaries as controls, and scan independent hard negatives; semantic labels and the selected EOT trigger remain AI/human review outputs |
| `adjudicate_easycom_eot_int_candidates.py` | Add optional DeepSeek transcript/timing suggestions; never human ground truth |
| `review_floor_events.py` | Serve the local video/transcript workspace and persist human event reviews |
| `prepare_easycom_oracle_features.py` | Build explicit past-only oracle person-activity sidecars and analysis-only addressee controls |

View File

@@ -26,7 +26,7 @@ import urllib.request
from prepare_easycom_eot_int_review import requires_semantic_adjudication
PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v6"
PROMPT_VERSION = "easycom-wearer-anchor-first-eot-int-prelabel-v7"
DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_BASE_URL = "https://api.deepseek.com"
VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"}
@@ -93,7 +93,7 @@ Evidence rules:
2. Never alter or propose an event timestamp. Do not infer prosody, gaze, intention, or acoustic cues that were not supplied.
3. A wearer onset is not automatically a floor take; a wearer offset is not automatically a yield; an other onset is not automatically a takeover. A pause is not automatically EOT, overlap is not automatically interruption, and words such as "yeah" are not automatically backchannels. Check who speaks sustainably afterward and who resumes.
4. Use UNSURE when the evidence does not establish conversational function. Never force a label for coverage.
5. Every supplied WEARER_ONSET has two immutable anchors: a non-wearer release opportunity and a later floor-level wearer entry. Do not reject it solely because response_delay_seconds is long. Decide whether the release remained available, whether the wearer contribution is a genuine response, or whether it is a semantic hard negative such as feedback.
5. Every supplied WEARER_ONSET was found by starting from an observed floor-level wearer entry and looking backward. Its immutable behavior anchor is fixed. Select the true preceding release only from temporal_evidence.opportunity_options; never invent a trigger or timestamp. A POSITIVE EOT_RESPONSE must return that option's uid as selected_opportunity_uid. Do not reject an option solely because its response delay is long. If none is a defensible trigger, return another verdict and null.
6. Every supplied WEARER_OFFSET has already passed both deterministic INT prerequisites: a new non-wearer onset occurs strictly inside the current wearer floor episode (t_w < t_o < t_y), and that same non-wearer interval crosses the wearer yield boundary within the recorded annotation tolerance. Decide only whether it is a genuine takeover or a semantic hard negative. Pre-existing other activity, post-completion other onset, within-turn wearer pauses, and completion-to-silence are controls outside this queue.
7. Transcript event markers are evidence, not lexical turns. In EasyCom, a transcription consisting of [L] denotes laughter-only vocalization. Do not treat [L] as a sustained floor take, an EOT response, or an interruption takeover merely because its activity interval creates an onset, offset, or overlap candidate. Specifically:
- wearer [L] during a non-wearer's retained turn is normally HARD_NEGATIVE / EOT_WEARER_BACKCHANNEL, not EOT_RESPONSE;
@@ -113,7 +113,7 @@ POSITIVE event_type must be exactly EOT_RESPONSE or INT_YIELD. EOT_RESPONSE mean
HARD_NEGATIVE subtype must be exactly one of EOT_MIDTURN_PAUSE, EOT_OTHER_TO_OTHER, EOT_WEARER_BACKCHANNEL, INT_OTHER_BACKCHANNEL, INT_NON_TAKEOVER_OVERLAP, INT_WEARER_HOLDS_FLOOR, or OTHER_HARD_NEGATIVE.
Return exactly one JSON object with this shape and no markdown:
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence"}]}
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"selected_opportunity_uid":null,"confidence":0.25,"reason":"brief evidence-based sentence"}]}
Return every supplied uid exactly once. Copy uid byte-for-byte and never add a backslash before an underscore. confidence must be a number from 0 to 1."""
@@ -211,7 +211,11 @@ def _transport_rows(rows: Sequence[dict[str, Any]]) -> tuple[list[dict[str, Any]
return transported, original_by_transport
def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, Any]]:
def validate_decisions(
payload: Any,
expected_uids: Sequence[str],
allowed_opportunities: dict[str, set[str]] | None = None,
) -> list[dict[str, Any]]:
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
raise ValueError("Response must be an object with an items list")
expected = set(expected_uids)
@@ -224,6 +228,7 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
verdict = item.get("verdict")
event_type = item.get("event_type")
subtype = item.get("subtype")
selected_opportunity_uid = item.get("selected_opportunity_uid")
confidence = item.get("confidence")
reason = item.get("reason")
if uid not in expected or uid in decisions:
@@ -238,6 +243,14 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}")
if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}")
allowed = set() if allowed_opportunities is None else allowed_opportunities.get(str(uid), set())
if verdict == "POSITIVE" and event_type == "EOT_RESPONSE" and allowed:
if selected_opportunity_uid not in allowed:
raise ValueError(
f"EOT_RESPONSE must select a supplied opportunity uid for {uid}"
)
elif selected_opportunity_uid is not None:
raise ValueError(f"selected_opportunity_uid is only valid for EOT_RESPONSE: {uid}")
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
raise ValueError(f"confidence must be in [0, 1] for {uid}")
if not isinstance(reason, str) or not reason.strip():
@@ -247,6 +260,7 @@ def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[
"verdict": str(verdict),
"event_type": event_type,
"subtype": subtype,
"selected_opportunity_uid": selected_opportunity_uid,
"confidence": float(confidence),
"reason": reason.strip(),
# Every AI decision enters the human queue. This is deliberately
@@ -295,7 +309,20 @@ def _post(
envelope = json.loads(response.read().decode("utf-8"))
content = envelope["choices"][0]["message"]["content"]
parsed = json.loads(content)
decisions = validate_decisions(parsed, list(original_by_transport))
allowed_opportunities = {}
for row in transported:
evidence = row.get("temporal_evidence") or {}
options = evidence.get("opportunity_options") or []
allowed_opportunities[row["uid"]] = {
str(option["uid"])
for option in options
if isinstance(option, dict) and option.get("uid") is not None
}
if not allowed_opportunities[row["uid"]] and evidence.get("opportunity_source_uid"):
allowed_opportunities[row["uid"]].add(str(evidence["opportunity_source_uid"]))
decisions = validate_decisions(
parsed, list(original_by_transport), allowed_opportunities
)
for decision in decisions:
decision["uid"] = original_by_transport[decision["uid"]]
return decisions, envelope.get("usage", {})
@@ -376,23 +403,47 @@ def add_suggestions(
output: list[dict[str, Any]] = []
for source in rows:
decisions = by_uid[source["uid"]]
labels = {(d["verdict"], d["event_type"], d["subtype"]) for d in decisions}
labels = {
(d["verdict"], d["event_type"], d["subtype"], d["selected_opportunity_uid"])
for d in decisions
}
unanimous = len(labels) == 1
if unanimous:
verdict, event_type, subtype = next(iter(labels))
verdict, event_type, subtype, selected_opportunity_uid = next(iter(labels))
confidence = min(float(item["confidence"]) for item in decisions)
reason = " | ".join(item["reason"] for item in decisions)
needs_human_review = any(item["needs_human_review"] for item in decisions)
else:
verdict, event_type, subtype = "UNSURE", None, None
verdict, event_type, subtype, selected_opportunity_uid = "UNSURE", None, None, None
confidence = min(float(item["confidence"]) for item in decisions)
reason = "The independent DeepSeek passes disagreed."
needs_human_review = True
opportunity_options = (source.get("temporal_evidence") or {}).get("opportunity_options") or []
selected_opportunity = next(
(
option for option in opportunity_options
if isinstance(option, dict) and option.get("uid") == selected_opportunity_uid
),
None,
)
selected_opportunity_seconds = (
None if selected_opportunity is None else selected_opportunity.get("anchor_seconds")
)
behavior_seconds = (source.get("temporal_evidence") or {}).get(
"behavior_anchor_seconds", source.get("anchor_dense_seconds")
)
enriched = dict(source)
enriched["llm_suggestion"] = {
"verdict": verdict,
"event_type": event_type,
"subtype": subtype,
"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_seconds]
),
"confidence": confidence,
"reason": reason,
"needs_human_review": needs_human_review,
@@ -402,6 +453,8 @@ def add_suggestions(
ai_verdict=verdict,
ai_event_type=event_type,
ai_subtype=subtype,
ai_selected_opportunity_uid=selected_opportunity_uid,
ai_selected_opportunity_seconds=selected_opportunity_seconds,
ai_confidence=confidence,
ai_reason=reason,
human_status="UNREVIEWED",

View File

@@ -24,8 +24,8 @@ python3 prepare_easycom_eot_int_review.py \
The generator emits two independent files:
- `easycom_eot_int_candidates.jsonl` contains EOT opportunities that pair a
non-wearer release with a later floor-level wearer `0->1` behavior anchor
- `easycom_eot_int_candidates.jsonl` contains floor-level wearer `0->1`
behavior anchors with one or more admissible preceding non-wearer releases
(`WEARER_ONSET`, `EOT_OPPORTUNITY_CANDIDATE`), only episode-ending wearer
`1->0` boundaries where a new non-wearer onset satisfies
`wearer_floor_start < other_onset < wearer_offset` and the same activity
@@ -49,11 +49,15 @@ continues across the resumed onset conservatively preserves a new wearer-entry
candidate. Every decision is stored in `temporal_evidence`, including the raw
gap, feedback IDs, competing IDs, threshold, and `floor_episode_merged` flag.
After each non-wearer release, EOT construction scans forward until a
floor-level wearer onset or a competing non-wearer onset. Silence length alone
does not invalidate the opportunity. Each primary EOT candidate stores both
`opportunity_anchor_seconds` and `behavior_anchor_seconds`, plus their response
delay.
EOT construction iterates floor-level wearer onsets, then searches backward for
preceding non-wearer releases. A release is an admissible option only when the
wearer stays silent and no new non-wearer onset or already-active other floor
invalidates the interval before the wearer onset. Silence length alone does not
invalidate an option. Each wearer onset produces at most one primary row. Its
`opportunity_options` list contains the only release anchors that DeepSeek or a
human may select; neither may invent a timestamp. The selected release is the
opportunity anchor, the wearer onset is the behavior anchor, and their interval
deterministically defines the valid EOT prediction interval.
Paired wearer onsets and strictly qualified offsets are initially `unsure`;
timing alone is not a semantic positive label. The INT prerequisite is
@@ -85,14 +89,15 @@ timeline; it is not sent to DeepSeek.
## 2. Add DeepSeek pre-labels
Prompt v6 treats the EasyCom transcript marker `[L]` as laughter-only evidence,
Prompt v7 treats the EasyCom transcript marker `[L]` as laughter-only evidence,
not as a lexical floor take. Wearer laughter during another participant's
retained turn is directed toward `EOT_WEARER_BACKCHANNEL`; non-wearer laughter
overlapping a retained wearer turn is directed toward `INT_OTHER_BACKCHANNEL`.
Ambiguous laughter remains `UNSURE`, and `[L]` alone is neither an exclusion
reason nor evidence of a visible smile. The prompt also treats the two EOT
anchors and strict INT ordering as deterministic prerequisites rather than
asking the model to reconstruct them.
anchor options and strict INT ordering as deterministic prerequisites. For a
positive EOT response it must return `selected_opportunity_uid` from the supplied
options; all other decisions use `null`.
`needs_human_review` is not requested from DeepSeek: every pre-label enters the
human queue by protocol, so the adjudicator assigns that boolean locally. This
@@ -125,8 +130,9 @@ DeepSeek sees candidate source, structured timing, per-speaker activity, and
transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`,
`NOT_APPLICABLE`, `EXCLUDE`, or `UNSURE`; positives additionally receive
`EOT_RESPONSE` or `INT_YIELD`, and hard negatives receive a named subtype. It
cannot modify timestamps. The output preserves both the original rule candidate
and the complete AI decision/provenance.
cannot modify timestamps. The output preserves the original rule candidate,
the selected EOT opportunity UID when applicable, and the complete AI
decision/provenance.
During live adjudication, the CLI displays a dependency-free progress bar over
all candidate/pass work, including the current pass, batch, percentage, and
@@ -154,8 +160,9 @@ question, then presents the exact AI verdict/subtype with a plain-language
meaning; the Accept control repeats that exact label so its effect is explicit.
The common path is one operation:
- **Accept** keeps the AI verdict/type;
- **Relabel** expands verdict and subtype controls;
- **Accept** keeps the AI verdict/type and its selected EOT opportunity anchor;
- **Relabel** expands verdict and subtype controls and, for a positive EOT,
requires one of the supplied release anchors;
- **Exclude** requires a concrete data-quality reason.
The current primary-review UI deliberately has no second-review action. Older

View File

@@ -274,7 +274,9 @@ textarea { resize: vertical; line-height: 1.45; }
.speaker-lane { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 8px; }
.speaker-lane > strong { color: var(--muted); text-align: right; font-size: .72rem; }
.speaker-track { height: 19px; position: relative; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; background: #07101d; }
.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--amber); z-index: 3; }
.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; z-index: 3; }
.speaker-track > i.behavior-anchor { background: var(--amber); }
.speaker-track > i.opportunity-anchor { width: 2px; background: #60a5fa; opacity: .9; }
.speech-segment { position: absolute; top: 3px; height: 11px; min-width: 4px; padding: 0; border: 1px solid transparent; border-radius: 3px; background: #60a5fa; cursor: pointer; }
.speech-segment.wearer { background: var(--accent); }
.speech-segment:hover, .speech-segment:focus-visible, .speech-segment.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,.28); z-index: 4; outline: none; }

View File

@@ -26,7 +26,7 @@ const elements = Object.fromEntries(
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
"transcriptList", "rawCandidate", "reviewForm", "formError",
"saveButton", "saveState", "toast", "relabelFields",
"excludeFields", "eventTypeField", "subtypeField",
"excludeFields", "eventTypeField", "opportunityField", "opportunitySelect", "subtypeField",
].map((id) => [id, document.getElementById(id)])
);
@@ -262,6 +262,18 @@ function renderDetail(detail) {
elements.eventSubtitle.textContent = detail.uid;
elements.clipLabel.textContent = detail.clip_stem ? `${detail.session} / ${detail.clip_stem}` : detail.session;
elements.rawCandidate.textContent = JSON.stringify(detail.candidate, null, 2);
const opportunityOptions = detail.candidate?.temporal_evidence?.opportunity_options || [];
elements.opportunitySelect.replaceChildren();
const emptyOption = document.createElement("option");
emptyOption.value = "";
emptyOption.textContent = "请选择 preceding release";
elements.opportunitySelect.append(emptyOption);
for (const option of opportunityOptions) {
const control = document.createElement("option");
control.value = option.uid;
control.textContent = `${formatTime(option.anchor_seconds)} · P${option.participant_id ?? "?"} · ${option.transcription || "无转写"}`;
elements.opportunitySelect.append(control);
}
elements.saveState.textContent = detail.review ? "已保存" : "未保存";
elements.saveState.classList.toggle("saved", Boolean(detail.review));
elements.reviewQuestion.textContent = reviewQuestions[detail.family] || "核对该候选是否属于目标 floor event。";
@@ -278,7 +290,10 @@ function renderDetail(detail) {
elements.aiRationale.textContent = view.rationale;
elements.acceptRadio.disabled = false;
elements.acceptTitle.textContent = `接受 ${view.verdict.replaceAll("_", " ")}`;
elements.acceptSummary.textContent = `${view.label} · ${view.meaning}`;
const selected = suggestion.selected_opportunity_uid;
const selectedOption = opportunityOptions.find((option) => option.uid === selected);
const anchorText = selectedOption ? ` · trigger ${formatTime(selectedOption.anchor_seconds)} P${selectedOption.participant_id}` : "";
elements.acceptSummary.textContent = `${view.label}${anchorText} · ${view.meaning}`;
} else {
elements.acceptRadio.disabled = true;
elements.acceptTitle.textContent = "没有 AI 结论";
@@ -435,6 +450,7 @@ function renderSpeakerTimeline(detail) {
return;
}
const eventTime = Number(detail.candidate.event_time ?? detail.candidate.anchor_dense_seconds);
const opportunityOptions = detail.candidate?.temporal_evidence?.opportunity_options || [];
const left = Math.min(...rows.map((row) => Number(row.start_seconds)), eventTime - 3);
const right = Math.max(...rows.map((row) => Number(row.end_seconds)), eventTime + 3);
const span = Math.max(0.001, right - left);
@@ -458,7 +474,18 @@ function renderSpeakerTimeline(detail) {
segment.addEventListener("click", () => showSpeakerEvidence(row, segment, true));
track.append(segment);
}
const anchor = document.createElement("i"); anchor.style.left = `${100 * (eventTime - left) / span}%`; track.append(anchor);
for (const option of opportunityOptions) {
const opportunity = document.createElement("i");
opportunity.className = "opportunity-anchor";
opportunity.style.left = `${100 * (Number(option.anchor_seconds) - left) / span}%`;
opportunity.title = `可选 release · ${formatTime(option.anchor_seconds)} · P${option.participant_id ?? "?"}`;
track.append(opportunity);
}
const anchor = document.createElement("i");
anchor.className = "behavior-anchor";
anchor.style.left = `${100 * (eventTime - left) / span}%`;
anchor.title = `wearer behavior · ${formatTime(eventTime)}`;
track.append(anchor);
lane.append(label, track); elements.speakerTimeline.append(lane);
}
const defaultRow = rows.find((row) =>
@@ -496,6 +523,7 @@ function formPayload() {
human_verdict: nullable("human_verdict"),
human_event_type: nullable("human_event_type"),
human_subtype: nullable("human_subtype"),
selected_opportunity_uid: nullable("selected_opportunity_uid"),
exclude_reason: nullable("exclude_reason"),
boundary_correction_seconds: correction === "" ? null : Number(correction),
confidence: data.get("confidence"),
@@ -508,6 +536,8 @@ function validateReview(review) {
if (!review.human_action) missing.push("主操作");
if (review.human_action === "relabel" && !review.human_verdict) missing.push("一级 verdict");
if (review.human_action === "relabel" && review.human_verdict === "POSITIVE" && !review.human_event_type) missing.push("positive event");
const options = state.currentDetail?.candidate?.temporal_evidence?.opportunity_options || [];
if (review.human_action === "relabel" && review.human_verdict === "POSITIVE" && review.human_event_type === "EOT_RESPONSE" && options.length && !review.selected_opportunity_uid) missing.push("EOT opportunity anchor");
if (review.human_action === "relabel" && review.human_verdict === "HARD_NEGATIVE" && !review.human_subtype) missing.push("hard-negative subtype");
if (review.human_action === "exclude" && !review.exclude_reason) missing.push("排除原因");
if (review.human_action === "accept" && !state.currentDetail?.candidate?.ai_verdict && !state.currentDetail?.llm_suggestion?.verdict) missing.push("AI 预标注(尚未运行,需 Relabel)");
@@ -517,9 +547,12 @@ function validateReview(review) {
function updateConditionalFields() {
const action = elements.reviewForm.elements.human_action.value;
const verdict = elements.reviewForm.elements.human_verdict.value;
const eventType = elements.reviewForm.elements.human_event_type.value;
const hasOpportunityOptions = Boolean(state.currentDetail?.candidate?.temporal_evidence?.opportunity_options?.length);
elements.relabelFields.hidden = action !== "relabel";
elements.excludeFields.hidden = action !== "exclude";
elements.eventTypeField.hidden = action !== "relabel" || verdict !== "POSITIVE";
elements.opportunityField.hidden = action !== "relabel" || verdict !== "POSITIVE" || eventType !== "EOT_RESPONSE" || !hasOpportunityOptions;
elements.subtypeField.hidden = action !== "relabel" || verdict !== "HARD_NEGATIVE";
}
@@ -619,6 +652,7 @@ function bindEvents() {
});
elements.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields));
elements.reviewForm.elements.human_verdict.addEventListener("change", updateConditionalFields);
elements.reviewForm.elements.human_event_type.addEventListener("change", updateConditionalFields);
elements.timelineZoomIn.addEventListener("click", () => changeTimelineZoom(2));
elements.timelineZoomOut.addEventListener("click", () => changeTimelineZoom(0.5));
elements.timelinePanLeft.addEventListener("click", () => panTimeline(-1));

View File

@@ -84,6 +84,7 @@
<section id="relabelFields" class="conditional-fields" hidden>
<label><span>一级 verdict</span><select name="human_verdict"><option value="">请选择</option><option>POSITIVE</option><option>HARD_NEGATIVE</option><option>NOT_APPLICABLE</option><option>UNSURE</option></select></label>
<label id="eventTypeField" hidden><span>Positive event</span><select name="human_event_type"><option value="">请选择</option><option>EOT_RESPONSE</option><option>INT_YIELD</option></select></label>
<label id="opportunityField" hidden><span>EOT opportunity anchor</span><select id="opportunitySelect" name="selected_opportunity_uid"><option value="">请选择 preceding release</option></select></label>
<label id="subtypeField" hidden><span>Hard-negative subtype</span><select name="human_subtype"><option value="">请选择</option><option>EOT_MIDTURN_PAUSE</option><option>EOT_OTHER_TO_OTHER</option><option>EOT_WEARER_BACKCHANNEL</option><option>INT_OTHER_BACKCHANNEL</option><option>INT_NON_TAKEOVER_OVERLAP</option><option>INT_WEARER_HOLDS_FLOOR</option><option>OTHER_HARD_NEGATIVE</option></select></label>
</section>

View File

@@ -543,14 +543,18 @@ contribution is retained as evidence but does not by itself split the wearer
episode. These rules group events only; they do not modify the source
annotations.
Following each candidate non-wearer release, the generator opens an EOT
opportunity and scans forward until a floor-level wearer entry or a competing
non-wearer onset terminates it. A retained \textsc{Wearer-Onset} candidate
therefore contains two immutable anchors: the non-wearer release
(\emph{opportunity anchor}) and the subsequent floor-level wearer onset
(\emph{behavior anchor}). The response delay is stored explicitly. Silence
duration alone does not cancel the opportunity; an unpaired wearer onset is
kept only as an audit control and does not enter the primary EOT queue.
EOT candidate construction starts from each observed floor-level wearer onset,
which is retained as the immutable \emph{behavior anchor}. The generator then
searches backward for preceding non-wearer releases. A release remains an
admissible \emph{opportunity-anchor} option only when the wearer stays silent
and no new non-wearer onset or already-active other floor invalidates the
intervening interval. Silence duration alone does not reject an option. Thus
each wearer onset contributes at most one primary review item rather than one
item per preceding release. The semantic annotator must either select one of
the supplied release options as the trigger or reject the EOT interpretation;
it cannot introduce a new timestamp. The selected opportunity and observed
behavior anchors deterministically define the valid prediction interval. A
wearer onset without an admissible release is retained only as an audit control.
\textbf{EOT hard negatives.}
Two participant-resolved timing rules independently propose regions in which
@@ -598,7 +602,8 @@ treat timing as factual, not to invent prosody, gaze, gesture, intention, or
other unavailable cues, and not to equate onset with floor entry, offset with
yield, silence with EOT, or overlap with interruption. EasyCom's \texttt{[L]}
marker is treated as evidence of laughter only: it is not by itself a sustained
floor take or takeover.
floor take or takeover. For EOT, a positive decision must name one supplied
opportunity-anchor option; for all other EOT decisions no trigger is selected.
The allowed decisions are \textsc{Positive}, \textsc{Hard-Negative},
\textsc{Not-Applicable}, \textsc{Exclude}, and \textsc{Unsure}. Positive
@@ -616,9 +621,11 @@ candidate.
Every AI-pre-labeled candidate enters an event-centered interface containing
the original video, local participant timeline, transcript, deterministic
timing evidence, and the AI suggestion. The adjudicator performs one primary
action: accept the complete AI label, relabel it, or exclude an unscorable
action: accept the complete AI label and selected EOT trigger, relabel it, or
exclude an unscorable
sample. Relabeling exposes the same constrained verdict, event-type, and
hard-negative subtype vocabulary. A confidence level is required; notes are
hard-negative subtype vocabulary. Relabeling a positive EOT also requires one
of the displayed release options. A confidence level is required; notes are
optional, and an optional $\pm0.2$~s boundary correction is available only for
an evident source-annotation error. The original event time is always retained.

View File

@@ -3,7 +3,8 @@
This stage never changes EasyCom speech boundaries and never claims semantic
ground truth. It groups short wearer resumptions into floor-level episodes,
pairs EOT candidates with a preceding non-wearer release, requires a new
starts EOT candidates from observed wearer entries and retrieves preceding
non-wearer release options, requires a new
non-wearer onset inside the wearer floor episode for INT candidates, and keeps
unpaired or ineligible raw boundaries as deterministic controls. All timing
thresholds are CLI parameters, and raw evidence is retained for DeepSeek
@@ -27,8 +28,8 @@ from derive_easycom_interaction_events import (
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
SCHEMA_VERSION = 3
PROTOCOL_VERSION = "easycom-wearer-centric-eot-int-candidates-v5"
SCHEMA_VERSION = 4
PROTOCOL_VERSION = "easycom-wearer-anchor-first-eot-int-candidates-v6"
@dataclass(frozen=True)
@@ -422,70 +423,84 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
reason="short raw wearer pause without sustained competing other takeover; merged into the existing floor episode",
), ("WEARER_RESUME", frame, ()))
# A1: open an opportunity at a non-wearer release and pair it with the
# subsequent floor-level wearer entry only if no competing other onset
# or wearer activity intervenes. Long silence alone does not invalidate
# the opportunity. Unpaired floor onsets are retained as controls.
paired_floor_onsets: set[int] = set()
for release in other_rows:
opportunity_frame = release.dense_end_frame_exclusive
onset_frame = next(
(value for value in floor_onsets if value >= opportunity_frame), None
)
if onset_frame is None:
continue
wearer_silent = (
opportunity_frame == onset_frame
or _all_state(
wearer,
opportunity_frame - segment_start,
onset_frame - segment_start,
False,
# A1: start from each observed floor-level wearer entry, then search
# backward for release anchors that survive deterministic timing checks.
# This yields at most one review item per wearer behavior. The semantic
# annotator may select only one of the stored options; it cannot invent
# a trigger or move either timestamp. Long silence alone is not a veto.
for onset_frame in floor_onsets:
release_options: list[tuple[Utterance, int]] = []
for release in other_rows:
opportunity_frame = release.dense_end_frame_exclusive
if opportunity_frame > onset_frame:
continue
wearer_silent = (
opportunity_frame == onset_frame
or _all_state(
wearer,
opportunity_frame - segment_start,
onset_frame - segment_start,
False,
)
)
)
competing_onsets = [
row for row in other_rows
if opportunity_frame <= row.dense_start_frame < onset_frame
]
other_already_holds = bool(
_active_other_ids(rows, opportunity_frame, wearer_id=wearer_id)
)
if not wearer_silent or competing_onsets or other_already_holds:
continue
evidence = dict(onset_records[onset_frame]["evidence"])
evidence.update(
opportunity_anchor_type="other_offset",
opportunity_anchor_frame=opportunity_frame,
opportunity_anchor_seconds=opportunity_frame / SOURCE_FPS,
opportunity_speaker_id=release.participant_id,
opportunity_source_uid=release.uid,
opportunity_source_transcription=release.transcription,
opportunity_source_target_of_speech=list(release.target_of_speech),
behavior_anchor_type="wearer_floor_onset",
behavior_anchor_frame=onset_frame,
behavior_anchor_seconds=onset_frame / SOURCE_FPS,
response_delay_seconds=(onset_frame - opportunity_frame) / SOURCE_FPS,
competing_other_onsets_before_behavior=[],
eot_opportunity_paired=True,
)
add(_candidate(
session=session, task="eot_response", source="WEARER_ONSET",
rule_type="EOT_OPPORTUNITY_CANDIDATE", anchor_type="wearer_onset",
frame=onset_frame, region_start=opportunity_frame,
region_end=min(segment_end, onset_frame + relation_frames),
label="unsure", subtype=None, previous_ids=(release.participant_id,),
incoming_ids=_active_other_ids(rows, onset_frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="non-wearer release opportunity paired with a subsequent floor-level wearer entry",
), ("WEARER_ONSET", onset_frame, (release.participant_id,)))
paired_floor_onsets.add(onset_frame)
competing_onset = any(
opportunity_frame <= row.dense_start_frame < onset_frame
for row in other_rows
)
other_already_holds = bool(
_active_other_ids(rows, opportunity_frame, wearer_id=wearer_id)
)
if wearer_silent and not competing_onset and not other_already_holds:
release_options.append((release, opportunity_frame))
for frame in floor_onsets:
if frame in paired_floor_onsets:
release_options.sort(
key=lambda item: (item[1], item[0].participant_id, item[0].uid)
)
if release_options:
_, latest_opportunity_frame = release_options[-1]
earliest_opportunity_frame = release_options[0][1]
option_rows = [{
"uid": release.uid,
"anchor_type": "other_offset",
"anchor_frame": frame,
"anchor_seconds": frame / SOURCE_FPS,
"participant_id": release.participant_id,
"transcription": release.transcription,
"target_of_speech": list(release.target_of_speech),
"response_delay_seconds": (onset_frame - frame) / SOURCE_FPS,
} for release, frame in release_options]
evidence = dict(onset_records[onset_frame]["evidence"])
evidence.update(
opportunity_options=option_rows,
opportunity_anchor_type=None,
opportunity_anchor_frame=None,
opportunity_anchor_seconds=None,
behavior_anchor_type="wearer_floor_onset",
behavior_anchor_frame=onset_frame,
behavior_anchor_seconds=onset_frame / SOURCE_FPS,
minimum_response_delay_seconds=(onset_frame - latest_opportunity_frame) / SOURCE_FPS,
maximum_response_delay_seconds=(onset_frame - earliest_opportunity_frame) / SOURCE_FPS,
competing_other_onsets_before_behavior=[],
eot_opportunity_paired=True,
)
previous_ids = tuple(sorted({row.participant_id for row, _ in release_options}))
add(_candidate(
session=session, task="eot_response", source="WEARER_ONSET",
rule_type="EOT_OPPORTUNITY_CANDIDATE", anchor_type="wearer_onset",
frame=onset_frame, region_start=earliest_opportunity_frame,
region_end=min(segment_end, onset_frame + relation_frames),
label="unsure", subtype=None, previous_ids=previous_ids,
incoming_ids=_active_other_ids(rows, onset_frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="floor-level wearer entry with one or more surviving preceding release options",
), ("WEARER_ONSET", onset_frame, previous_ids))
continue
frame = onset_frame
evidence = dict(onset_records[frame]["evidence"])
evidence.update(
opportunity_options=[],
opportunity_anchor_type=None,
opportunity_anchor_frame=None,
opportunity_anchor_seconds=None,

View File

@@ -34,7 +34,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 = 3
REVIEW_SCHEMA_VERSION = 4
MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude", "uncertain"}
@@ -459,14 +459,20 @@ class ReviewStore:
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")
selected_opportunity_uid = candidate.get("ai_selected_opportunity_uid") or (
candidate.get("llm_suggestion") or {}
).get("selected_opportunity_uid")
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
selected_opportunity_uid = None
elif action == "second_review":
verdict, event_type, subtype = "UNSURE", None, None
selected_opportunity_uid = None
else:
verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype")
selected_opportunity_uid = payload.get("selected_opportunity_uid")
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:
@@ -477,6 +483,35 @@ class ReviewStore:
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")
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
}
if verdict == "POSITIVE" and event_type == "EOT_RESPONSE" and allowed_opportunity_uids:
if selected_opportunity_uid not in allowed_opportunity_uids:
raise ValueError("EOT_RESPONSE requires one supplied opportunity anchor")
elif selected_opportunity_uid is not None:
raise ValueError("selected_opportunity_uid is only valid for EOT_RESPONSE")
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")),
),
)
exclude_reason = payload.get("exclude_reason")
if action == "exclude" and exclude_reason not in EXCLUDE_REASON_VALUES:
raise ValueError("Exclude requires an exclude_reason")
@@ -490,6 +525,13 @@ class ReviewStore:
human_verdict=verdict,
human_event_type=event_type,
human_subtype=subtype,
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]
),
exclude_reason=exclude_reason,
relabeled=action == "relabel",
boundary_correction_seconds=correction,
@@ -607,7 +649,9 @@ class ReviewStore:
"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",
"selected_opportunity_uid", "selected_opportunity_seconds",
"valid_prediction_interval_seconds", "exclude_reason", "relabeled",
"boundary_correction_seconds", "original_event_time",
"confidence",
"reviewer",
"notes",

View File

@@ -32,8 +32,8 @@ class EOTINTAdjudicationTests(unittest.TestCase):
self.assertIn("Yes.", prompt)
def test_system_prompt_treats_laughter_as_nonlexical_floor_feedback(self) -> None:
self.assertEqual(PROMPT_VERSION, "easycom-wearer-centric-eot-int-prelabel-v6")
self.assertIn("two immutable anchors", SYSTEM_PROMPT)
self.assertEqual(PROMPT_VERSION, "easycom-wearer-anchor-first-eot-int-prelabel-v7")
self.assertIn("opportunity_options", SYSTEM_PROMPT)
self.assertIn("t_w < t_o < t_y", SYSTEM_PROMPT)
self.assertIn("a transcription consisting of [L] denotes laughter-only vocalization", SYSTEM_PROMPT)
self.assertIn("EOT_WEARER_BACKCHANNEL", SYSTEM_PROMPT)
@@ -72,6 +72,23 @@ class EOTINTAdjudicationTests(unittest.TestCase):
decision = validate_decisions(payload, ["u1"])[0]
self.assertTrue(decision["needs_human_review"])
def test_eot_positive_must_select_one_supplied_release(self) -> None:
item = {
"uid": "u1", "verdict": "POSITIVE", "event_type": "EOT_RESPONSE",
"subtype": None, "selected_opportunity_uid": "release-2",
"confidence": 0.9, "reason": "The wearer answers the second release.",
}
decision = validate_decisions(
{"items": [item]}, ["u1"], {"u1": {"release-1", "release-2"}}
)[0]
self.assertEqual(decision["selected_opportunity_uid"], "release-2")
with self.assertRaisesRegex(ValueError, "select a supplied opportunity"):
validate_decisions(
{"items": [{**item, "selected_opportunity_uid": "invented"}]},
["u1"],
{"u1": {"release-1", "release-2"}},
)
def test_markdown_escaped_uid_is_normalized_but_still_unique(self) -> None:
item = {
"uid": "Session\\_4/wearer\\_onset/00000024",

View File

@@ -60,9 +60,12 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
self.assertEqual(event.rule_candidate_type, "EOT_OPPORTUNITY_CANDIDATE")
self.assertEqual(event.anchor_type, "wearer_onset")
self.assertAlmostEqual(event.event_time, 1.2)
self.assertAlmostEqual(event.temporal_evidence["opportunity_anchor_seconds"], 1.0)
self.assertIsNone(event.temporal_evidence["opportunity_anchor_seconds"])
self.assertAlmostEqual(
event.temporal_evidence["opportunity_options"][0]["anchor_seconds"], 1.0
)
self.assertAlmostEqual(event.temporal_evidence["behavior_anchor_seconds"], 1.2)
self.assertAlmostEqual(event.temporal_evidence["response_delay_seconds"], 0.2)
self.assertAlmostEqual(event.temporal_evidence["minimum_response_delay_seconds"], 0.2)
self.assertAlmostEqual(event.temporal_evidence["wearer_speech_duration_after_onset_seconds"], 0.8)
def test_long_silence_does_not_cancel_eot_opportunity(self) -> None:
@@ -71,7 +74,22 @@ class EOTINTReviewCandidateTests(unittest.TestCase):
utterance("wearer", 2, 3.5, 4.5, "Delayed response."),
]
event = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_ONSET")
self.assertAlmostEqual(event.temporal_evidence["response_delay_seconds"], 3.0)
self.assertAlmostEqual(event.temporal_evidence["maximum_response_delay_seconds"], 3.0)
def test_one_wearer_onset_has_one_row_with_constrained_release_options(self) -> None:
rows = [
utterance("other-4", 4, 0.0, 1.0, "Question one."),
utterance("other-3", 3, 0.2, 1.0, "Question two."),
utterance("wearer", 2, 1.4, 2.4, "Wearer answers."),
]
events = [
row for row in self.derive(rows)
if row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.4
]
self.assertEqual(len(events), 1)
options = events[0].temporal_evidence["opportunity_options"]
self.assertEqual({option["uid"] for option in options}, {"other-4", "other-3"})
self.assertEqual(events[0].temporal_evidence["behavior_anchor_seconds"], 1.4)
def test_competing_other_onset_terminates_eot_opportunity(self) -> None:
rows = [

View File

@@ -194,6 +194,37 @@ class FloorEventReviewTests(unittest.TestCase):
self.assertEqual(record["review"]["human_subtype"], "EOT_WEARER_BACKCHANNEL")
self.assertTrue(record["review"]["relabeled"])
def test_eot_review_selects_only_a_supplied_opportunity_anchor(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
_, events = self._fixture(root)
candidates = load_candidates([events])
candidate = candidates[0]
candidate.payload["temporal_evidence"] = {
"opportunity_options": [
{"uid": "release-1", "anchor_seconds": 1.0, "participant_id": 4}
]
}
store = ReviewStore(root / "reviews.jsonl", candidates)
base = {
"human_action": "relabel", "human_verdict": "POSITIVE",
"human_event_type": "EOT_RESPONSE", "human_subtype": None,
"exclude_reason": None, "boundary_correction_seconds": None,
"confidence": "high", "notes": "clear response",
}
record = store.save(
candidate.candidate_id, {**base, "selected_opportunity_uid": "release-1"}
)
self.assertEqual(record["review"]["selected_opportunity_uid"], "release-1")
self.assertEqual(
record["review"]["valid_prediction_interval_seconds"], [1.0, 1.2]
)
with self.assertRaisesRegex(ValueError, "requires one supplied"):
store.save(
candidate.candidate_id,
{**base, "selected_opportunity_uid": "invented"},
)
def test_byte_ranges(self) -> None:
self.assertEqual(parse_byte_range("bytes=2-5", 10), (2, 5))
self.assertEqual(parse_byte_range("bytes=7-", 10), (7, 9))