462 lines
17 KiB
Python
462 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Optionally adjudicate ambiguous EasyCom interaction proxies with DeepSeek.
|
|
|
|
This is a second-stage *weak-label adjudicator*, not a replacement for the
|
|
timestamp rules in ``derive_easycom_interaction_events.py``. By default it
|
|
only reviews ambiguous training candidates and promotes a semantic label when
|
|
two passes agree and both report high confidence.
|
|
|
|
Transcript excerpts are sent to an external API only when the caller passes
|
|
``--allow-external-transcript-upload``. The API key is read exclusively from
|
|
``DEEPSEEK_API_KEY`` and is never written to output.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import random
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
|
|
PROMPT_VERSION = "easycom-interaction-adjudicator-v1"
|
|
OUTPUT_SCHEMA_VERSION = 1
|
|
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
|
DEFAULT_MODEL = "deepseek-v4-flash"
|
|
DEFAULT_EVENT_TYPES = ("backchannel_candidate", "interruption_candidate")
|
|
ALLOWED_LABELS = {
|
|
"backchannel",
|
|
"short_answer_or_response",
|
|
"interruption",
|
|
"cooperative_overlap_takeover",
|
|
"other",
|
|
"uncertain",
|
|
}
|
|
ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
|
|
|
SYSTEM_PROMPT = """You are adjudicating weak labels for conversational turn-taking research.
|
|
Use only the supplied transcript and timing evidence. Do not infer prosody, gaze, or intention that is not observed.
|
|
|
|
Definitions:
|
|
- backchannel: a brief acknowledgement/feedback that does not take the conversational floor.
|
|
- short_answer_or_response: a brief but content-bearing answer/response, not merely feedback.
|
|
- interruption: the incoming speaker competitively cuts off the previous floor holder and takes the floor.
|
|
- cooperative_overlap_takeover: overlapping floor transfer without enough evidence of competitive interruption.
|
|
- other: none of the above.
|
|
- uncertain: the available text/timing cannot reliably distinguish the labels.
|
|
|
|
Be conservative. Timing overlap alone does not prove interruption. A short word alone does not prove backchannel.
|
|
Return one JSON object exactly in this form:
|
|
{"items":[{"uid":"...","label":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]}
|
|
Return every input uid exactly once. The response must be JSON, with no markdown or extra text."""
|
|
|
|
|
|
def _load_jsonl(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, start=1):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
row = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid JSON at {path}:{line_number}: {exc}") from exc
|
|
if not isinstance(row, dict) or not isinstance(row.get("uid"), str):
|
|
raise ValueError(f"Expected object with uid at {path}:{line_number}")
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def load_candidates(
|
|
input_dir: Path,
|
|
*,
|
|
splits: set[str],
|
|
event_types: set[str],
|
|
) -> list[dict[str, Any]]:
|
|
paths = sorted(input_dir.glob("Session_*.events.jsonl"))
|
|
if not paths:
|
|
raise FileNotFoundError(f"No Session_*.events.jsonl files in {input_dir}")
|
|
candidates: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
for path in paths:
|
|
for row in _load_jsonl(path):
|
|
uid = row["uid"]
|
|
if uid in seen:
|
|
raise ValueError(f"Duplicate event uid: {uid}")
|
|
seen.add(uid)
|
|
if row.get("split") in splits and row.get("event_type") in event_types:
|
|
candidates.append(row)
|
|
return sorted(candidates, key=lambda row: row["uid"])
|
|
|
|
|
|
def stratified_limit(
|
|
rows: Sequence[dict[str, Any]],
|
|
limit: int | None,
|
|
*,
|
|
seed: int,
|
|
) -> list[dict[str, Any]]:
|
|
"""Select a deterministic pilot across sessions and candidate types."""
|
|
|
|
if limit is None or limit >= len(rows):
|
|
return list(rows)
|
|
if limit < 0:
|
|
raise ValueError("--max-events must be non-negative")
|
|
rng = random.Random(seed)
|
|
buckets: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
for row in rows:
|
|
buckets[(str(row.get("event_type")), str(row.get("session")))].append(row)
|
|
keys = sorted(buckets)
|
|
for key in keys:
|
|
rng.shuffle(buckets[key])
|
|
rng.shuffle(keys)
|
|
selected: list[dict[str, Any]] = []
|
|
while len(selected) < limit:
|
|
made_progress = False
|
|
for key in keys:
|
|
if buckets[key]:
|
|
selected.append(buckets[key].pop())
|
|
made_progress = True
|
|
if len(selected) == limit:
|
|
break
|
|
if not made_progress:
|
|
break
|
|
return sorted(selected, key=lambda row: row["uid"])
|
|
|
|
|
|
def _candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
|
|
fields = (
|
|
"uid",
|
|
"event_type",
|
|
"participant_role",
|
|
"participant_id",
|
|
"previous_floor_participant_id",
|
|
"retained_floor_participant_id",
|
|
"target_matches_previous_floor",
|
|
"duration_seconds",
|
|
"onset_overlap_seconds",
|
|
"gap_from_previous_seconds",
|
|
"speaker_after_previous_end_seconds",
|
|
"previous_transcript_has_cutoff",
|
|
"previous_transcription",
|
|
"transcription",
|
|
"target_of_speech",
|
|
)
|
|
return {field: row.get(field) for field in fields}
|
|
|
|
|
|
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
|
|
if pass_index == 0:
|
|
review = "Primary pass: classify conservatively from the supplied evidence."
|
|
else:
|
|
review = (
|
|
"Skeptical review pass: actively look for alternative explanations. "
|
|
"Use uncertain unless the semantic label remains well supported."
|
|
)
|
|
payload = [_candidate_for_prompt(row) for row in rows]
|
|
return (
|
|
review
|
|
+ "\nReturn JSON for these candidates:\n"
|
|
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
)
|
|
|
|
|
|
def prompt_hash(rows: Sequence[dict[str, Any]], passes: int) -> str:
|
|
payload = {
|
|
"version": PROMPT_VERSION,
|
|
"passes": passes,
|
|
"system": SYSTEM_PROMPT,
|
|
"rows": [_candidate_for_prompt(row) for row in rows],
|
|
}
|
|
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _validate_decisions(
|
|
payload: Any,
|
|
expected_uids: Sequence[str],
|
|
) -> list[dict[str, str]]:
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
|
raise ValueError("Response must be a JSON object containing an items list")
|
|
expected = set(expected_uids)
|
|
actual: dict[str, dict[str, str]] = {}
|
|
for item in payload["items"]:
|
|
if not isinstance(item, dict):
|
|
raise ValueError("Every response item must be an object")
|
|
uid = item.get("uid")
|
|
label = item.get("label")
|
|
confidence = item.get("confidence")
|
|
rationale = item.get("rationale")
|
|
if uid not in expected or uid in actual:
|
|
raise ValueError(f"Unexpected or duplicate uid in response: {uid!r}")
|
|
if label not in ALLOWED_LABELS:
|
|
raise ValueError(f"Invalid label for {uid}: {label!r}")
|
|
if confidence not in ALLOWED_CONFIDENCE:
|
|
raise ValueError(f"Invalid confidence for {uid}: {confidence!r}")
|
|
if not isinstance(rationale, str) or not rationale.strip():
|
|
raise ValueError(f"Missing rationale for {uid}")
|
|
actual[uid] = {
|
|
"uid": uid,
|
|
"label": label,
|
|
"confidence": confidence,
|
|
"rationale": rationale.strip(),
|
|
}
|
|
if set(actual) != expected:
|
|
raise ValueError(f"Response uid mismatch; missing={sorted(expected-set(actual))}")
|
|
return [actual[uid] for uid in expected_uids]
|
|
|
|
|
|
def _post_chat_completion(
|
|
*,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
user_prompt: str,
|
|
timeout_seconds: float,
|
|
max_retries: int,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
endpoint = base_url.rstrip("/") + "/chat/completions"
|
|
body = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
"response_format": {"type": "json_object"},
|
|
"thinking": {"type": "disabled"},
|
|
"temperature": 0,
|
|
"max_tokens": 2048,
|
|
"stream": False,
|
|
}
|
|
request = urllib.request.Request(
|
|
endpoint,
|
|
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"]
|
|
if not isinstance(content, str) or not content.strip():
|
|
raise ValueError("DeepSeek returned empty JSON content")
|
|
return json.loads(content), envelope.get("usage", {})
|
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError, ValueError) as exc:
|
|
last_error = exc
|
|
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 adjudicate_batch(
|
|
rows: Sequence[dict[str, Any]],
|
|
*,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
passes: int,
|
|
timeout_seconds: float,
|
|
max_retries: int,
|
|
) -> tuple[list[dict[str, Any]], Counter[str]]:
|
|
expected_uids = [row["uid"] for row in rows]
|
|
by_uid: dict[str, list[dict[str, str]]] = {uid: [] for uid in expected_uids}
|
|
usage: Counter[str] = Counter()
|
|
for pass_index in range(passes):
|
|
payload, pass_usage = _post_chat_completion(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
user_prompt=build_user_prompt(rows, pass_index),
|
|
timeout_seconds=timeout_seconds,
|
|
max_retries=max_retries,
|
|
)
|
|
for key, value in pass_usage.items():
|
|
if isinstance(value, int):
|
|
usage[key] += value
|
|
for decision in _validate_decisions(payload, expected_uids):
|
|
by_uid[decision["uid"]].append(decision)
|
|
|
|
request_prompt_sha256 = prompt_hash(rows, passes)
|
|
results: list[dict[str, Any]] = []
|
|
for source in rows:
|
|
decisions = by_uid[source["uid"]]
|
|
labels = {decision["label"] for decision in decisions}
|
|
unanimous = len(labels) == 1
|
|
final_label = decisions[0]["label"] if unanimous else "uncertain"
|
|
all_high = all(decision["confidence"] == "high" for decision in decisions)
|
|
expected_label = {
|
|
"backchannel_candidate": "backchannel",
|
|
"interruption_candidate": "interruption",
|
|
}.get(source.get("event_type"))
|
|
accepted = unanimous and all_high and final_label == expected_label
|
|
promoted = (
|
|
f"{final_label}_proxy_llm_consensus" if accepted else None
|
|
)
|
|
results.append(
|
|
{
|
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
|
"uid": source["uid"],
|
|
"session": source.get("session"),
|
|
"split": source.get("split"),
|
|
"source_event_type": source.get("event_type"),
|
|
"model": model,
|
|
"prompt_version": PROMPT_VERSION,
|
|
"request_prompt_sha256": request_prompt_sha256,
|
|
"passes": passes,
|
|
"decisions": decisions,
|
|
"unanimous": unanimous,
|
|
"final_label": final_label,
|
|
"all_high_confidence": all_high,
|
|
"accepted_for_auxiliary": accepted,
|
|
"promoted_event_type": promoted,
|
|
}
|
|
)
|
|
return results, usage
|
|
|
|
|
|
def _validate_resume_rows(
|
|
rows: Sequence[dict[str, Any]],
|
|
*,
|
|
model: str,
|
|
passes: int,
|
|
) -> None:
|
|
"""Prevent an append-only output from silently mixing experiments."""
|
|
|
|
seen: set[str] = set()
|
|
for row in rows:
|
|
uid = row["uid"]
|
|
if uid in seen:
|
|
raise ValueError(f"Duplicate uid in existing output: {uid}")
|
|
seen.add(uid)
|
|
expected = {
|
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
|
"model": model,
|
|
"prompt_version": PROMPT_VERSION,
|
|
"passes": passes,
|
|
}
|
|
mismatches = {
|
|
key: (row.get(key), value)
|
|
for key, value in expected.items()
|
|
if row.get(key) != value
|
|
}
|
|
if mismatches:
|
|
raise ValueError(
|
|
f"Existing output {uid} was produced by a different adjudication "
|
|
f"configuration: {mismatches}. Use a new --output path."
|
|
)
|
|
|
|
|
|
def _chunks(rows: Sequence[dict[str, Any]], size: int) -> Iterable[Sequence[dict[str, Any]]]:
|
|
for start in range(0, len(rows), size):
|
|
yield rows[start : start + size]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--input-dir", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--splits", nargs="+", default=["train"])
|
|
parser.add_argument("--event-types", nargs="+", default=list(DEFAULT_EVENT_TYPES))
|
|
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("--batch-size", type=int, default=8)
|
|
parser.add_argument("--passes", type=int, default=2)
|
|
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")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
if args.batch_size <= 0 or args.passes <= 0 or args.max_retries < 0:
|
|
raise ValueError("batch-size/passes must be positive and max-retries non-negative")
|
|
candidates = load_candidates(
|
|
args.input_dir.resolve(),
|
|
splits=set(args.splits),
|
|
event_types=set(args.event_types),
|
|
)
|
|
candidates = stratified_limit(
|
|
candidates,
|
|
args.max_events,
|
|
seed=args.selection_seed,
|
|
)
|
|
|
|
completed: set[str] = set()
|
|
if args.output.exists():
|
|
resume_rows = _load_jsonl(args.output)
|
|
_validate_resume_rows(resume_rows, model=args.model, passes=args.passes)
|
|
completed = {row["uid"] for row in resume_rows}
|
|
pending = [row for row in candidates if row["uid"] not in completed]
|
|
print(
|
|
f"Candidates={len(candidates)} completed={len(completed & {row['uid'] for row in candidates})} "
|
|
f"pending={len(pending)} model={args.model} passes={args.passes}"
|
|
)
|
|
if not pending:
|
|
return
|
|
|
|
if args.dry_run:
|
|
sample = pending[: args.batch_size]
|
|
print("DRY RUN: no API request will be made. First request prompt:\n")
|
|
print(build_user_prompt(sample, 0))
|
|
print("\nprompt_sha256:", prompt_hash(sample, args.passes))
|
|
return
|
|
if not args.allow_external_transcript_upload:
|
|
raise PermissionError(
|
|
"Refusing to upload transcript excerpts. Review --dry-run, then pass "
|
|
"--allow-external-transcript-upload if authorized."
|
|
)
|
|
api_key = os.environ.get("DEEPSEEK_API_KEY")
|
|
if not api_key:
|
|
raise RuntimeError("Set DEEPSEEK_API_KEY in the environment")
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
usage: Counter[str] = Counter()
|
|
accepted: Counter[str] = Counter()
|
|
processed = 0
|
|
with args.output.open("a", encoding="utf-8") as handle:
|
|
for batch_number, batch in enumerate(_chunks(pending, args.batch_size), start=1):
|
|
results, batch_usage = adjudicate_batch(
|
|
batch,
|
|
api_key=api_key,
|
|
base_url=args.base_url,
|
|
model=args.model,
|
|
passes=args.passes,
|
|
timeout_seconds=args.timeout_seconds,
|
|
max_retries=args.max_retries,
|
|
)
|
|
usage.update(batch_usage)
|
|
for result in results:
|
|
handle.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
if result["accepted_for_auxiliary"]:
|
|
accepted[result["final_label"]] += 1
|
|
handle.flush()
|
|
processed += len(results)
|
|
print(
|
|
f"batch={batch_number} processed={processed}/{len(pending)} "
|
|
f"accepted={dict(accepted)} total_tokens={usage.get('total_tokens', 0)}"
|
|
)
|
|
print(f"Saved adjudications to {args.output.resolve()}")
|
|
print("Usage:", dict(usage))
|
|
print("Accepted high-confidence consensus:", dict(accepted))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|