diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 0e5e576..31547b8 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -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 | diff --git a/adjudicate_easycom_eot_int_candidates.py b/adjudicate_easycom_eot_int_candidates.py index 55c507d..67d8a34 100644 --- a/adjudicate_easycom_eot_int_candidates.py +++ b/adjudicate_easycom_eot_int_candidates.py @@ -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", diff --git a/docs/easycom_event_review_tool.md b/docs/easycom_event_review_tool.md index a5faa8c..5f64c4d 100644 --- a/docs/easycom_event_review_tool.md +++ b/docs/easycom_event_review_tool.md @@ -25,7 +25,7 @@ python3 prepare_easycom_eot_int_review.py \ The generator emits two independent files: - `easycom_eot_int_candidates.jsonl` contains floor-level wearer `0->1` - behavior anchors retrospectively paired with a preceding non-wearer release + 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,12 +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. -For each floor-level wearer onset, EOT construction searches backward for the -most recent non-wearer release and rejects the pair if wearer speech or a -competing non-wearer onset intervenes. 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 @@ -86,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 @@ -126,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 @@ -155,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 diff --git a/floor_event_review_web/app.css b/floor_event_review_web/app.css index 51012ed..340a813 100644 --- a/floor_event_review_web/app.css +++ b/floor_event_review_web/app.css @@ -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; } diff --git a/floor_event_review_web/app.js b/floor_event_review_web/app.js index fdd8162..df7ec64 100644 --- a/floor_event_review_web/app.js +++ b/floor_event_review_web/app.js @@ -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)); diff --git a/floor_event_review_web/index.html b/floor_event_review_web/index.html index c719b42..b651d73 100644 --- a/floor_event_review_web/index.html +++ b/floor_event_review_web/index.html @@ -84,6 +84,7 @@ diff --git a/manuscript/main/main.tex b/manuscript/main/main.tex index aa310b0..ebfcf7a 100644 --- a/manuscript/main/main.tex +++ b/manuscript/main/main.tex @@ -567,15 +567,15 @@ wearer episode. This grouping changes only the behavioral-event layer, not the source intervals or dense targets. \textbf{EOT-response.} -Starting from a floor-level wearer onset at $t_b$, we search backward for a -plausible non-wearer floor release at $t_r$. A candidate is retained only when -the interval from $t_r$ to $t_b$ contains neither intervening wearer speech nor -a competing non-wearer onset. The record stores both $t_r$ as the -\emph{opportunity anchor} and $t_b$ as the \emph{behavior anchor}, together with -the response delay $t_b-t_r$. Silence duration alone does not reject a -candidate. The LLM and human annotator then decide whether the release is -conversationally valid and whether the wearer speech is a genuine floor take. -Unpaired wearer onsets remain audit controls. +Starting from a floor-level wearer onset at $t_b$, we search backward for +plausible non-wearer floor releases. A release at $t_r$ is retained as an option +only when the interval from $t_r$ to $t_b$ contains neither intervening wearer +speech nor a competing non-wearer onset. Each wearer onset contributes at most +one review item: $t_b$ is its immutable \emph{behavior anchor}, and the item +stores every admissible \emph{opportunity-anchor} option and response delay. +Silence duration alone does not reject an option. A positive EOT annotation +must select one supplied release; neither the LLM nor the human can invent or +move a trigger timestamp. Unpaired wearer onsets remain audit controls. \textbf{INT-yield.} Starting from an episode-ending wearer offset $t_y$, we search backward inside @@ -609,12 +609,14 @@ measurements, transcripts, and target-of-speech metadata when available. It is instructed not to equate onset with entry, offset with yield, silence with EOT, or overlap with interruption, and not to invent unavailable prosody, gaze, or intent. EasyCom's \texttt{[L]} marker supplies laughter evidence only and is not -by itself a sustained floor take or takeover. +by itself a sustained floor take or takeover. A positive EOT pre-label must +also select one of the supplied opportunity-anchor options. We use \texttt{deepseek-v4-flash} at temperature zero with a primary and a skeptical pass. Disagreement on verdict, event type, or hard-negative subtype produces an \textsc{Unsure} suggestion. Every candidate then receives mandatory -human adjudication: accept the complete pre-label, relabel it, or exclude an +human adjudication: accept the complete pre-label and selected EOT trigger, +relabel it (selecting a displayed trigger for a positive EOT), or exclude an unscorable region. Only human-confirmed \textsc{EOT-Response}, \textsc{INT-Yield}, and hard-negative opportunities enter evaluation; \textsc{Not-Applicable}, \textsc{Exclude}, and unresolved candidates do not. diff --git a/prepare_easycom_eot_int_review.py b/prepare_easycom_eot_int_review.py index ceaedc1..19a512b 100644 --- a/prepare_easycom_eot_int_review.py +++ b/prepare_easycom_eot_int_review.py @@ -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,73 +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: anchor the observed floor-level wearer entry, then retrieve the - # most recent preceding non-wearer release. Keep the pair 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() + # 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: - releases = [ - row for row in other_rows - if row.dense_end_frame_exclusive <= onset_frame - ] - release = max(releases, key=lambda row: row.dense_end_frame_exclusive, default=None) - if release is None: - continue - opportunity_frame = release.dense_end_frame_exclusive - wearer_silent = ( - opportunity_frame == onset_frame - or _all_state( - wearer, - opportunity_frame - segment_start, - onset_frame - segment_start, - False, + 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, diff --git a/review_floor_events.py b/review_floor_events.py index 797d5f6..bbe2365 100644 --- a/review_floor_events.py +++ b/review_floor_events.py @@ -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", diff --git a/test_adjudicate_easycom_eot_int_candidates.py b/test_adjudicate_easycom_eot_int_candidates.py index 8b9a43f..7efa07d 100644 --- a/test_adjudicate_easycom_eot_int_candidates.py +++ b/test_adjudicate_easycom_eot_int_candidates.py @@ -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", diff --git a/test_prepare_easycom_eot_int_review.py b/test_prepare_easycom_eot_int_review.py index 8627e61..7954fc6 100644 --- a/test_prepare_easycom_eot_int_review.py +++ b/test_prepare_easycom_eot_int_review.py @@ -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 = [ diff --git a/test_review_floor_events.py b/test_review_floor_events.py index 8ce7c3a..936cd97 100644 --- a/test_review_floor_events.py +++ b/test_review_floor_events.py @@ -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))