Files
icassp2027-experiment/adjudicate_easycom_eot_int_candidates.py
T

548 lines
25 KiB
Python

#!/usr/bin/env python3
"""Add conservative DeepSeek pre-labels to wearer-centric EOT/INT candidates.
The model receives the candidate source, structured participant timing and the
transcript excerpt. It cannot change any source timestamp. Its output is
stored as an auditable suggestion beside the untouched rule candidate and must
still be accepted, relabeled, or excluded by a human.
"""
from __future__ import annotations
import argparse
from collections import Counter
import hashlib
import json
import os
from pathlib import Path
import random
import re
import sys
import time
from typing import Any, Sequence, TextIO
import urllib.error
import urllib.request
from prepare_easycom_eot_int_review import requires_semantic_adjudication
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"}
EVENT_TYPES = {"EOT_RESPONSE", "INT_YIELD"}
HARD_NEGATIVE_TYPES = {
"EOT_MIDTURN_PAUSE",
"EOT_OTHER_TO_OTHER",
"EOT_WEARER_BACKCHANNEL",
"INT_OTHER_BACKCHANNEL",
"INT_NON_TAKEOVER_OVERLAP",
"INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE",
}
def _progress_line(
completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26
) -> str:
fraction = 1.0 if total == 0 else min(1.0, max(0.0, completed / total))
filled = round(width * fraction)
bar = "█" * filled + "░" * (width - filled)
minutes, seconds = divmod(max(0, round(elapsed_seconds)), 60)
return (
f"DeepSeek [{bar}] {completed}/{total} ({fraction * 100:5.1f}%) "
f"{label} · {minutes:02d}:{seconds:02d}"
)
class _ProgressBar:
"""Dependency-free terminal progress with readable redirected logs."""
def __init__(self, total: int, *, stream: TextIO | None = None) -> None:
self.total = total
self.stream = stream or sys.stderr
self.started = time.monotonic()
self.is_tty = bool(getattr(self.stream, "isatty", lambda: False)())
self.last_logged = -1
def update(self, completed: int, label: str, *, final: bool = False) -> None:
line = _progress_line(
completed,
self.total,
label=label,
elapsed_seconds=time.monotonic() - self.started,
)
if self.is_tty:
self.stream.write("\r" + line)
if final:
self.stream.write("\n")
self.stream.flush()
return
# Avoid producing one permanent log line per API batch in redirected jobs.
step = max(1, self.total // 10)
if final or self.last_logged < 0 or completed - self.last_logged >= step:
self.stream.write(line + "\n")
self.stream.flush()
self.last_logged = completed
SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientific study of wearer-relative conversational floor control.
Evidence rules:
1. Use candidate source, structured participant timing, per-speaker activity, and transcript together. Timing evidence has priority over a speculative transcript interpretation.
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 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;
- non-wearer [L] overlapping a wearer's retained turn is normally HARD_NEGATIVE / INT_OTHER_BACKCHANNEL, not INT_YIELD;
- wearer [L] ending while a non-wearer continues does not establish that the wearer held and yielded the floor.
Use UNSURE if the surrounding timing does not establish whose floor was retained. Do not EXCLUDE a valid laughter-only annotation merely because it is non-lexical, and do not infer that a visible smile occurred: [L] supplies laughter evidence only. Do not assign semantics to other bracketed markers unless the supplied context establishes them.
Top-level verdicts:
- POSITIVE: the system should commit.
- HARD_NEGATIVE: a valid, interpretable region where the system should not commit.
- NOT_APPLICABLE: a real speech transition outside the committed EOT/INT task, such as a wearer starting after long silence or naturally ending into silence.
- EXCLUDE: the region cannot be scored reliably because of annotation corruption, a recording boundary, missing context, a non-speech artifact, timestamp corruption, or another unresolvable defect.
- UNSURE: evidence is insufficient for a reliable automatic pre-label and needs human judgment.
POSITIVE event_type must be exactly EOT_RESPONSE or INT_YIELD. EOT_RESPONSE means a non-wearer genuinely releases the floor and the wearer then takes it with a sustained response. INT_YIELD means a non-wearer genuinely takes over during wearer speech and the wearer relinquishes the floor.
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,"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."""
def _normalize_returned_uid(value: Any) -> Any:
"""Undo DeepSeek's occasional Markdown escaping without weakening identity checks."""
if not isinstance(value, str):
return value
return re.sub(r"\\+_", "_", value)
def _participant_uid_suffix(row: dict[str, Any], ordinal: int) -> str:
previous = row.get("previous_other_participant_ids") or []
incoming = row.get("incoming_other_participant_ids") or []
previous_key = "-".join(f"p{x}" for x in previous) or "none"
incoming_key = "-".join(f"p{x}" for x in incoming) or "none"
return f"prev-{previous_key}__in-{incoming_key}__row-{ordinal}"
def read_candidates(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
if not line.strip():
continue
row = json.loads(line)
if not isinstance(row, dict) or not isinstance(row.get("uid"), str):
raise ValueError(f"Expected candidate object with uid at {path}:{line_number}")
if not (row.get("candidate_source") or row.get("task")):
raise ValueError(f"Missing candidate source/task at {path}:{line_number}")
rows.append(row)
if not rows:
raise ValueError(f"No candidates in {path}")
by_uid: dict[str, list[int]] = {}
for index, row in enumerate(rows):
by_uid.setdefault(row["uid"], []).append(index)
for original_uid, indices in by_uid.items():
if len(indices) == 1:
continue
for ordinal, index in enumerate(indices, 1):
row = dict(rows[index])
row["uid_disambiguated_from"] = original_uid
row["uid"] = f"{original_uid}/{_participant_uid_suffix(row, ordinal)}"
row["event_id"] = row["uid"]
rows[index] = row
if len({row["uid"] for row in rows}) != len(rows):
raise ValueError(f"Could not deterministically disambiguate duplicate candidate UIDs in {path}")
return rows
def candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
fields = (
"uid",
"candidate_source",
"rule_candidate_type",
"task",
"anchor_type",
"anchor_dense_seconds",
"event_time",
"region_start_seconds",
"region_end_seconds",
"temporal_evidence",
"speaker_timeline",
"context",
)
payload = {field: row.get(field) for field in fields if row.get(field) is not None}
# Older v1 files remain reviewable and can still be pre-labeled.
if "candidate_source" not in payload:
payload["candidate_source"] = row.get("anchor_type", row.get("event_type", "UNKNOWN"))
if "event_time" not in payload:
payload["event_time"] = row.get("anchor_dense_seconds")
return payload
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
instruction = (
"Primary pass: classify conservatively."
if pass_index == 0
else "Independent skeptical pass: seek alternative explanations and choose uncertain when semantics are unsupported."
)
return (
instruction
+ "\nCandidate JSON:\n"
+ json.dumps([candidate_for_prompt(row) for row in rows], ensure_ascii=False, separators=(",", ":"))
)
def _transport_rows(rows: Sequence[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, str]]:
"""Use simple batch-local IDs so the model never has to copy path-like UIDs."""
transported: list[dict[str, Any]] = []
original_by_transport: dict[str, str] = {}
for index, row in enumerate(rows):
transport_uid = f"item-{index:04d}"
original_by_transport[transport_uid] = row["uid"]
transported.append({**row, "uid": transport_uid})
return transported, original_by_transport
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)
decisions: dict[str, dict[str, Any]] = {}
for item in payload["items"]:
if not isinstance(item, dict):
raise ValueError("Every decision must be an object")
raw_uid = item.get("uid")
uid = _normalize_returned_uid(raw_uid)
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:
raise ValueError(f"Unexpected or duplicate uid: {raw_uid!r}")
if verdict not in VERDICTS:
raise ValueError(f"Invalid verdict for {uid}: {verdict!r}")
if verdict == "POSITIVE" and event_type not in EVENT_TYPES:
raise ValueError(f"POSITIVE requires EOT_RESPONSE or INT_YIELD for {uid}")
if verdict != "POSITIVE" and event_type is not None:
raise ValueError(f"Only POSITIVE may have event_type for {uid}")
if verdict == "HARD_NEGATIVE" and subtype not in HARD_NEGATIVE_TYPES:
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():
raise ValueError(f"Missing reason for {uid}")
decisions[str(uid)] = {
"uid": str(uid),
"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
# local rather than model-generated, so harmless JSON type drift
# cannot invalidate and retry an otherwise valid batch.
"needs_human_review": True,
}
if set(decisions) != expected:
raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}")
return [decisions[uid] for uid in expected_uids]
def _post(
*,
rows: Sequence[dict[str, Any]],
pass_index: int,
api_key: str,
model: str,
base_url: str,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
transported, original_by_transport = _transport_rows(rows)
body = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_prompt(transported, pass_index)},
],
"response_format": {"type": "json_object"},
"thinking": {"type": "disabled"},
"temperature": 0,
"max_tokens": 2048,
"stream": False,
}
request = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
envelope = json.loads(response.read().decode("utf-8"))
content = envelope["choices"][0]["message"]["content"]
parsed = json.loads(content)
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", {})
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
KeyError,
TypeError,
json.JSONDecodeError,
ValueError,
) as error:
last_error = error
if attempt >= max_retries:
break
time.sleep(min(30.0, 2.0**attempt))
raise RuntimeError(f"DeepSeek request failed after {max_retries + 1} attempts: {last_error}")
def add_suggestions(
rows: Sequence[dict[str, Any]],
*,
api_key: str,
model: str,
base_url: str,
passes: int,
batch_size: int = 8,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, Any]], Counter[str]]:
by_uid: dict[str, list[dict[str, Any]]] = {row["uid"]: [] for row in rows}
prompt_hashes: dict[str, list[str]] = {row["uid"]: [] for row in rows}
usage: Counter[str] = Counter()
if batch_size <= 0:
raise ValueError("batch_size must be positive")
total_work = len(rows) * passes
batches_per_pass = (len(rows) + batch_size - 1) // batch_size
completed_work = 0
progress = _ProgressBar(total_work)
progress.update(0, f"pass 1/{passes} · waiting")
try:
for pass_index in range(passes):
for batch_index, start in enumerate(range(0, len(rows), batch_size), 1):
batch = rows[start:start + batch_size]
progress.update(
completed_work,
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
)
transported, _ = _transport_rows(batch)
prompt_hash = hashlib.sha256(
(SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8")
).hexdigest()
decisions, pass_usage = _post(
rows=batch,
pass_index=pass_index,
api_key=api_key,
model=model,
base_url=base_url,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
)
for decision in decisions:
by_uid[decision["uid"]].append(decision)
prompt_hashes[decision["uid"]].append(prompt_hash)
for key, value in pass_usage.items():
if isinstance(value, int):
usage[key] += value
completed_work += len(batch)
progress.update(
completed_work,
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
)
except Exception:
progress.update(completed_work, "failed", final=True)
raise
progress.update(completed_work, "complete", final=True)
output: list[dict[str, Any]] = []
for source in rows:
decisions = by_uid[source["uid"]]
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, 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, 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,
"unanimous": unanimous,
}
enriched.update(
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",
human_verdict=None,
human_event_type=None,
human_subtype=None,
exclude_reason=None,
)
enriched["llm_adjudication"] = {
"model": model,
"prompt_version": PROMPT_VERSION,
"passes": passes,
"request_prompt_sha256": prompt_hashes[source["uid"]],
"decisions": decisions,
"human_review_required": True,
}
output.append(enriched)
return output, usage
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--model", default=os.environ.get("DEEPSEEK_MODEL", DEFAULT_MODEL))
parser.add_argument("--base-url", default=os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_BASE_URL))
parser.add_argument("--passes", type=int, default=2)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--max-events", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--allow-external-transcript-upload", action="store_true")
parser.add_argument("--overwrite", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.passes <= 0 or args.batch_size <= 0 or args.max_retries < 0:
raise ValueError("passes/batch-size must be positive and max-retries non-negative")
input_rows = read_candidates(args.input.expanduser().resolve())
rows = [row for row in input_rows if requires_semantic_adjudication(row)]
skipped_controls = len(input_rows) - len(rows)
if args.max_events is not None:
if args.max_events < 0:
raise ValueError("--max-events must be non-negative")
rng = random.Random(args.selection_seed)
rows = rng.sample(rows, min(args.max_events, len(rows)))
rows.sort(key=lambda row: row["uid"])
print(
f"Candidates={len(rows)} skipped_deterministic_controls={skipped_controls} "
f"model={args.model} passes={args.passes}"
)
if args.dry_run:
print("DRY RUN: no API request will be made.\n")
print(build_user_prompt(rows, 0))
return
if not args.allow_external_transcript_upload:
raise PermissionError(
"Refusing to upload transcript excerpts; inspect --dry-run, then pass "
"--allow-external-transcript-upload when authorized."
)
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("Set DEEPSEEK_API_KEY in the environment")
output = args.output.expanduser().resolve()
if output.exists() and not args.overwrite:
raise FileExistsError(f"Refusing to overwrite {output}; pass --overwrite")
enriched, usage = add_suggestions(
rows,
api_key=api_key,
model=args.model,
base_url=args.base_url,
passes=args.passes,
batch_size=args.batch_size,
timeout_seconds=args.timeout_seconds,
max_retries=args.max_retries,
)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8") as handle:
for row in enriched:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"Saved {len(enriched)} suggested candidates to {output}")
print("Usage:", dict(usage))
if __name__ == "__main__":
main()