Revamp EasyCom floor event review pipeline
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add conservative DeepSeek suggestions to EOT/INT review candidates.
|
||||
"""Add conservative DeepSeek pre-labels to wearer-centric EOT/INT candidates.
|
||||
|
||||
Only transcript excerpts and timing metadata are sent. Suggestions are never
|
||||
treated as gold labels: the output remains input to ``review_floor_events.py``
|
||||
and requires an identified human reviewer. The API key is read only from the
|
||||
``DEEPSEEK_API_KEY`` environment variable and is never persisted.
|
||||
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, excluded, or sent to second review by a human.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,49 +16,65 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
PROMPT_VERSION = "easycom-manuscript-eot-int-adjudicator-v1"
|
||||
PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v2"
|
||||
DEFAULT_MODEL = "deepseek-v4-flash"
|
||||
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
||||
DISPOSITIONS = {"positive", "hard_negative", "exclude", "uncertain"}
|
||||
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_no_response",
|
||||
"int_backchannel",
|
||||
"int_non_takeover_overlap",
|
||||
"int_retain_floor",
|
||||
"other",
|
||||
"not_applicable",
|
||||
"uncertain",
|
||||
"EOT_MIDTURN_PAUSE",
|
||||
"EOT_OTHER_TO_OTHER",
|
||||
"EOT_WEARER_BACKCHANNEL",
|
||||
"INT_OTHER_BACKCHANNEL",
|
||||
"INT_NON_TAKEOVER_OVERLAP",
|
||||
"INT_WEARER_HOLDS_FLOOR",
|
||||
"OTHER_HARD_NEGATIVE",
|
||||
}
|
||||
CONFIDENCES = {"high", "medium", "low"}
|
||||
|
||||
SYSTEM_PROMPT = """You audit candidate regions for a scientific study of wearer-relative floor control.
|
||||
Use only the supplied participant-resolved transcript and timing evidence. Do not infer prosody, gaze, intention, or acoustic interruption cues that are not supplied.
|
||||
SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientific study of wearer-relative conversational floor control.
|
||||
|
||||
Tasks:
|
||||
- eot_response positive: a non-wearer releases the floor and the wearer makes a genuine floor-taking response.
|
||||
- int_yield positive: a non-wearer takes over during wearer speech and the wearer genuinely yields the floor.
|
||||
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.
|
||||
|
||||
Hard negatives:
|
||||
- eot_midturn_pause: the same non-wearer resumes after a within-turn pause.
|
||||
- eot_other_to_other: a different non-wearer takes the floor while the wearer remains silent.
|
||||
- eot_no_response: the wearer correctly remains silent for the labeled region.
|
||||
- int_backchannel: brief feedback during wearer speech that does not take the floor.
|
||||
- int_non_takeover_overlap: overlapping other speech without takeover.
|
||||
- int_retain_floor: the wearer correctly retains the floor for the labeled region.
|
||||
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 and a second human review is needed.
|
||||
|
||||
Use exclude for annotation corruption or an unusable boundary. Use uncertain whenever transcript/timing evidence cannot establish the semantic function. Timing alone does not prove EOT, interruption, backchannel, or takeover.
|
||||
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":"...","protocol_disposition":"uncertain","hard_negative_type":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]}
|
||||
Return every supplied uid exactly once."""
|
||||
{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence","needs_human_review":true}]}
|
||||
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]]:
|
||||
@@ -70,33 +86,50 @@ def read_candidates(path: Path) -> list[dict[str, Any]]:
|
||||
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 row.get("task") not in {"eot_response", "int_yield"}:
|
||||
raise ValueError(f"Unsupported task at {path}:{line_number}: {row.get('task')!r}")
|
||||
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",
|
||||
"automatic_protocol_label",
|
||||
"automatic_hard_negative_type",
|
||||
"anchor_type",
|
||||
"anchor_dense_seconds",
|
||||
"behavior_anchor_type",
|
||||
"behavior_seconds",
|
||||
"event_time",
|
||||
"region_start_seconds",
|
||||
"region_end_seconds",
|
||||
"region_end_reason",
|
||||
"previous_other_participant_ids",
|
||||
"incoming_other_participant_ids",
|
||||
"automatic_reason",
|
||||
"temporal_evidence",
|
||||
"speaker_timeline",
|
||||
"context",
|
||||
)
|
||||
return {field: row.get(field) for field in fields}
|
||||
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:
|
||||
@@ -112,35 +145,59 @@ def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, str]]:
|
||||
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]) -> 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, str]] = {}
|
||||
decisions: dict[str, dict[str, Any]] = {}
|
||||
for item in payload["items"]:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Every decision must be an object")
|
||||
uid = item.get("uid")
|
||||
disposition = item.get("protocol_disposition")
|
||||
negative_type = item.get("hard_negative_type")
|
||||
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")
|
||||
confidence = item.get("confidence")
|
||||
rationale = item.get("rationale")
|
||||
reason = item.get("reason")
|
||||
needs_human_review = item.get("needs_human_review")
|
||||
if uid not in expected or uid in decisions:
|
||||
raise ValueError(f"Unexpected or duplicate uid: {uid!r}")
|
||||
if disposition not in DISPOSITIONS:
|
||||
raise ValueError(f"Invalid protocol_disposition for {uid}: {disposition!r}")
|
||||
if negative_type not in HARD_NEGATIVE_TYPES:
|
||||
raise ValueError(f"Invalid hard_negative_type for {uid}: {negative_type!r}")
|
||||
if confidence not in CONFIDENCES:
|
||||
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}")
|
||||
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}")
|
||||
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}")
|
||||
if not isinstance(needs_human_review, bool):
|
||||
raise ValueError(f"needs_human_review must be boolean for {uid}")
|
||||
decisions[str(uid)] = {
|
||||
"uid": str(uid),
|
||||
"protocol_disposition": str(disposition),
|
||||
"hard_negative_type": str(negative_type),
|
||||
"confidence": str(confidence),
|
||||
"rationale": rationale.strip(),
|
||||
"verdict": str(verdict),
|
||||
"event_type": event_type,
|
||||
"subtype": subtype,
|
||||
"confidence": float(confidence),
|
||||
"reason": reason.strip(),
|
||||
"needs_human_review": needs_human_review,
|
||||
}
|
||||
if set(decisions) != expected:
|
||||
raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}")
|
||||
@@ -156,12 +213,13 @@ def _post(
|
||||
base_url: str,
|
||||
timeout_seconds: float,
|
||||
max_retries: int,
|
||||
) -> tuple[list[dict[str, str]], dict[str, Any]]:
|
||||
) -> 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(rows, pass_index)},
|
||||
{"role": "user", "content": build_user_prompt(transported, pass_index)},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"thinking": {"type": "disabled"},
|
||||
@@ -182,7 +240,10 @@ def _post(
|
||||
envelope = json.loads(response.read().decode("utf-8"))
|
||||
content = envelope["choices"][0]["message"]["content"]
|
||||
parsed = json.loads(content)
|
||||
return validate_decisions(parsed, [row["uid"] for row in rows]), envelope.get("usage", {})
|
||||
decisions = validate_decisions(parsed, list(original_by_transport))
|
||||
for decision in decisions:
|
||||
decision["uid"] = original_by_transport[decision["uid"]]
|
||||
return decisions, envelope.get("usage", {})
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
@@ -206,67 +267,80 @@ def add_suggestions(
|
||||
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, str]]] = {row["uid"]: [] for row in rows}
|
||||
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")
|
||||
for pass_index in range(passes):
|
||||
decisions, pass_usage = _post(
|
||||
rows=rows,
|
||||
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)
|
||||
for key, value in pass_usage.items():
|
||||
if isinstance(value, int):
|
||||
usage[key] += value
|
||||
for start in range(0, len(rows), batch_size):
|
||||
batch = rows[start:start + batch_size]
|
||||
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
|
||||
|
||||
output: list[dict[str, Any]] = []
|
||||
for source in rows:
|
||||
decisions = by_uid[source["uid"]]
|
||||
labels = {
|
||||
(decision["protocol_disposition"], decision["hard_negative_type"])
|
||||
for decision in decisions
|
||||
}
|
||||
labels = {(d["verdict"], d["event_type"], d["subtype"]) for d in decisions}
|
||||
unanimous = len(labels) == 1
|
||||
if unanimous:
|
||||
disposition, negative_type = next(iter(labels))
|
||||
confidence = (
|
||||
"high" if all(item["confidence"] == "high" for item in decisions) else "medium"
|
||||
)
|
||||
rationale = " | ".join(item["rationale"] for item in decisions)
|
||||
verdict, event_type, subtype = 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:
|
||||
disposition = "uncertain"
|
||||
negative_type = "uncertain"
|
||||
confidence = "low"
|
||||
rationale = "The independent DeepSeek passes disagreed."
|
||||
prompt_payload = {
|
||||
"prompt_version": PROMPT_VERSION,
|
||||
"model": model,
|
||||
"passes": passes,
|
||||
"candidate": candidate_for_prompt(source),
|
||||
}
|
||||
verdict, event_type, subtype = "UNSURE", None, None
|
||||
confidence = min(float(item["confidence"]) for item in decisions)
|
||||
reason = "The independent DeepSeek passes disagreed."
|
||||
needs_human_review = True
|
||||
enriched = dict(source)
|
||||
enriched["llm_suggestion"] = {
|
||||
"protocol_disposition": disposition,
|
||||
"hard_negative_type": negative_type,
|
||||
"verdict": verdict,
|
||||
"event_type": event_type,
|
||||
"subtype": subtype,
|
||||
"confidence": confidence,
|
||||
"rationale": rationale,
|
||||
"reason": reason,
|
||||
"needs_human_review": needs_human_review,
|
||||
"unanimous": unanimous,
|
||||
}
|
||||
enriched.update(
|
||||
ai_verdict=verdict,
|
||||
ai_event_type=event_type,
|
||||
ai_subtype=subtype,
|
||||
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,
|
||||
"prompt_sha256": hashlib.sha256(
|
||||
json.dumps(prompt_payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"request_prompt_sha256": prompt_hashes[source["uid"]],
|
||||
"decisions": decisions,
|
||||
"human_review_required": True,
|
||||
}
|
||||
@@ -281,6 +355,7 @@ def parse_args() -> argparse.Namespace:
|
||||
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)
|
||||
@@ -293,8 +368,8 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.passes <= 0 or args.max_retries < 0:
|
||||
raise ValueError("passes must be positive and max-retries non-negative")
|
||||
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")
|
||||
rows = read_candidates(args.input.expanduser().resolve())
|
||||
if args.max_events is not None:
|
||||
if args.max_events < 0:
|
||||
@@ -324,6 +399,7 @@ def main() -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user