Add manuscript-aligned EasyCom EOT INT review workflow
This commit is contained in:
@@ -304,6 +304,9 @@ Do not reorganize them physically without compatibility wrappers.
|
|||||||
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
|
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
|
||||||
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
|
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
|
||||||
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
|
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
|
||||||
|
| `prepare_easycom_eot_int_review.py` | Build manuscript-aligned EOT-response/INT-yield positive and hard-negative review regions |
|
||||||
|
| `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 |
|
| `prepare_easycom_oracle_features.py` | Build explicit past-only oracle person-activity sidecars and analysis-only addressee controls |
|
||||||
|
|
||||||
### Local features
|
### Local features
|
||||||
|
|||||||
339
adjudicate_easycom_eot_int_candidates.py
Normal file
339
adjudicate_easycom_eot_int_candidates.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add conservative DeepSeek suggestions to EOT/INT review 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Any, Sequence
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_VERSION = "easycom-manuscript-eot-int-adjudicator-v1"
|
||||||
|
DEFAULT_MODEL = "deepseek-v4-flash"
|
||||||
|
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
||||||
|
DISPOSITIONS = {"positive", "hard_negative", "exclude", "uncertain"}
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
|
||||||
|
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 row.get("task") not in {"eot_response", "int_yield"}:
|
||||||
|
raise ValueError(f"Unsupported task at {path}:{line_number}: {row.get('task')!r}")
|
||||||
|
rows.append(row)
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(f"No candidates in {path}")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
fields = (
|
||||||
|
"uid",
|
||||||
|
"task",
|
||||||
|
"automatic_protocol_label",
|
||||||
|
"automatic_hard_negative_type",
|
||||||
|
"anchor_type",
|
||||||
|
"anchor_dense_seconds",
|
||||||
|
"behavior_anchor_type",
|
||||||
|
"behavior_seconds",
|
||||||
|
"region_start_seconds",
|
||||||
|
"region_end_seconds",
|
||||||
|
"region_end_reason",
|
||||||
|
"previous_other_participant_ids",
|
||||||
|
"incoming_other_participant_ids",
|
||||||
|
"automatic_reason",
|
||||||
|
"context",
|
||||||
|
)
|
||||||
|
return {field: row.get(field) for field in fields}
|
||||||
|
|
||||||
|
|
||||||
|
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 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 an object with an items list")
|
||||||
|
expected = set(expected_uids)
|
||||||
|
decisions: dict[str, dict[str, str]] = {}
|
||||||
|
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")
|
||||||
|
confidence = item.get("confidence")
|
||||||
|
rationale = item.get("rationale")
|
||||||
|
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}")
|
||||||
|
decisions[str(uid)] = {
|
||||||
|
"uid": str(uid),
|
||||||
|
"protocol_disposition": str(disposition),
|
||||||
|
"hard_negative_type": str(negative_type),
|
||||||
|
"confidence": str(confidence),
|
||||||
|
"rationale": rationale.strip(),
|
||||||
|
}
|
||||||
|
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, str]], dict[str, Any]]:
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": build_user_prompt(rows, 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)
|
||||||
|
return validate_decisions(parsed, [row["uid"] for row in rows]), 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,
|
||||||
|
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}
|
||||||
|
usage: Counter[str] = Counter()
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
enriched = dict(source)
|
||||||
|
enriched["llm_suggestion"] = {
|
||||||
|
"protocol_disposition": disposition,
|
||||||
|
"hard_negative_type": negative_type,
|
||||||
|
"confidence": confidence,
|
||||||
|
"rationale": rationale,
|
||||||
|
"unanimous": unanimous,
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
"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("--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.max_retries < 0:
|
||||||
|
raise ValueError("passes 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:
|
||||||
|
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)} 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,
|
||||||
|
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()
|
||||||
@@ -33,6 +33,7 @@ tests.
|
|||||||
| [`easycom_oracle_information.md`](easycom_oracle_information.md) | Causal oracle per-person speaking-history upper bound and Session-12 screening commands |
|
| [`easycom_oracle_information.md`](easycom_oracle_information.md) | Causal oracle per-person speaking-history upper bound and Session-12 screening commands |
|
||||||
| [`easycom_competing_risk_floor_evaluation.md`](easycom_competing_risk_floor_evaluation.md) | Running and auditing the competing-risk entry/yield evaluator with training-side threshold calibration |
|
| [`easycom_competing_risk_floor_evaluation.md`](easycom_competing_risk_floor_evaluation.md) | Running and auditing the competing-risk entry/yield evaluator with training-side threshold calibration |
|
||||||
| [`easycom_floor_event_diagnostics.md`](easycom_floor_event_diagnostics.md) | Separating boundary observability, event-objective mismatch and addressee information with remote commands |
|
| [`easycom_floor_event_diagnostics.md`](easycom_floor_event_diagnostics.md) | Separating boundary observability, event-objective mismatch and addressee information with remote commands |
|
||||||
|
| [`easycom_event_review_tool.md`](easycom_event_review_tool.md) | Reviewing automatically mined EasyCom floor-event candidates against local video and transcripts |
|
||||||
|
|
||||||
## Design history and implementation options
|
## Design history and implementation options
|
||||||
|
|
||||||
|
|||||||
97
docs/easycom_event_review_tool.md
Normal file
97
docs/easycom_event_review_tool.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# EasyCom EOT/INT review workspace
|
||||||
|
|
||||||
|
`review_floor_events.py` is a local annotation workspace for reviewing the
|
||||||
|
manuscript-aligned regions produced by `prepare_easycom_eot_int_review.py`.
|
||||||
|
It can also open the older timing/transcript proxy reports from
|
||||||
|
`derive_easycom_interaction_events.py` and `audit_easycom_floor_events.py`, but
|
||||||
|
those older reports do not have the directionality or hard-negative region
|
||||||
|
contract required by the current manuscript. Raw EasyCom media stays read-only;
|
||||||
|
human decisions are stored separately as JSONL and CSV.
|
||||||
|
|
||||||
|
## Prepare manuscript-aligned candidates
|
||||||
|
|
||||||
|
Develop the annotation guide on training-side Sessions 4--12:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 prepare_easycom_eot_int_review.py \
|
||||||
|
--dataset-root /path/to/easycom \
|
||||||
|
--sessions Session_4 Session_5 Session_6 Session_7 Session_8 Session_9 Session_10 Session_11 Session_12 \
|
||||||
|
--output-dir reports/easycom_floor_event_candidates
|
||||||
|
```
|
||||||
|
|
||||||
|
For a one-event pilot, add `--max-candidates 1`. The generator follows the
|
||||||
|
manuscript direction exactly:
|
||||||
|
|
||||||
|
- EOT-response begins at an aggregate-other offset while the wearer is silent.
|
||||||
|
- INT-yield begins at an aggregate-other onset while the wearer is active.
|
||||||
|
- An observed wearer transition before the competing other transition is a
|
||||||
|
timing-positive candidate.
|
||||||
|
- A competing other transition, or a fully observed no-transition window, is a
|
||||||
|
timing hard-negative candidate.
|
||||||
|
- A source boundary without a complete outcome/window is marked for exclusion.
|
||||||
|
|
||||||
|
Automatic labels remain candidates until independent human semantic review.
|
||||||
|
|
||||||
|
## Optional DeepSeek suggestion
|
||||||
|
|
||||||
|
DeepSeek suggestions are triage metadata and never count as a human label. The
|
||||||
|
script sends only the candidate transcript excerpt and timing fields. Inspect
|
||||||
|
the exact request without network access first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 adjudicate_easycom_eot_int_candidates.py \
|
||||||
|
--input reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \
|
||||||
|
--output reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.jsonl \
|
||||||
|
--model deepseek-v4-flash \
|
||||||
|
--max-events 1 \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
After setting `DEEPSEEK_API_KEY`, make the authorized request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 adjudicate_easycom_eot_int_candidates.py \
|
||||||
|
--input reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \
|
||||||
|
--output reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.jsonl \
|
||||||
|
--model deepseek-v4-flash \
|
||||||
|
--max-events 1 \
|
||||||
|
--allow-external-transcript-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the review server at the DeepSeek-enriched JSONL to show its suggestion
|
||||||
|
beside the video. Keep suggestions hidden from an initial independent human
|
||||||
|
pass if anchoring bias is a concern.
|
||||||
|
|
||||||
|
## Start the local workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 review_floor_events.py \
|
||||||
|
--dataset-root /path/to/easycom \
|
||||||
|
--events reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \
|
||||||
|
--output reports/easycom_event_reviews/reviewer_1.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8765`. The page resolves the source MP4 and transcript,
|
||||||
|
seeks to a six-second event context, and supports session/split/task/status
|
||||||
|
filtering.
|
||||||
|
|
||||||
|
Each save atomically rewrites both JSONL and CSV. The review explicitly records
|
||||||
|
`positive`, `hard_negative`, `exclude`, or `uncertain`; the hard-negative
|
||||||
|
subtype; boundary validity; onset function; conversational routing; offset
|
||||||
|
outcome; three 200 ms anchor/region corrections; confidence; and reviewer ID.
|
||||||
|
|
||||||
|
## Freeze discipline
|
||||||
|
|
||||||
|
Develop the annotation guide and resolve category disagreements on training
|
||||||
|
Sessions 4--12. Freeze the guide before reviewing official test Sessions 1--3.
|
||||||
|
Do not use test reviews to change candidate windows, label categories, or the
|
||||||
|
R=.8 threshold calibration rule.
|
||||||
|
|
||||||
|
For paper-quality labels, use two independent reviewers with separate
|
||||||
|
`--output` files and adjudicate disagreements in a third output. A non-empty
|
||||||
|
reviewer ID is required on every saved row. DeepSeek is not one of the two
|
||||||
|
reviewers.
|
||||||
|
|
||||||
|
This workspace produces audited labels. It does not yet convert them into the
|
||||||
|
final committed-event matching metrics; that integration follows only after
|
||||||
|
the schema and real-data pilot are frozen.
|
||||||
303
floor_event_review_web/app.css
Normal file
303
floor_event_review_web/app.css
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #07101d;
|
||||||
|
--panel: #0d1728;
|
||||||
|
--panel-2: #111d30;
|
||||||
|
--line: #24334b;
|
||||||
|
--line-strong: #344865;
|
||||||
|
--text: #edf5ff;
|
||||||
|
--muted: #98a8bd;
|
||||||
|
--accent: #5eead4;
|
||||||
|
--accent-strong: #2dd4bf;
|
||||||
|
--accent-dim: rgba(94, 234, 212, 0.12);
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--danger: #fb7185;
|
||||||
|
--success: #34d399;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 18px 44px rgba(0, 0, 0, 0.24);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body { min-height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 45% -20%, rgba(30, 64, 175, 0.18), transparent 42%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button, a, select, input, textarea { outline-offset: 3px; }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(280px, 440px) minmax(220px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
background: rgba(7, 16, 29, 0.92);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 9px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 0 0 5px var(--accent-dim);
|
||||||
|
}
|
||||||
|
.brand-mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
top: 14px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 5px -6px 0 -0.5px var(--accent), 11px 5px 0 -0.5px var(--accent);
|
||||||
|
transform: skewY(-35deg);
|
||||||
|
}
|
||||||
|
.brand h1 { font-size: 1rem; margin: 0; letter-spacing: 0.01em; }
|
||||||
|
.brand p { color: var(--muted); font-size: 0.8rem; margin: 2px 0 0; }
|
||||||
|
|
||||||
|
.progress-block { display: grid; gap: 7px; }
|
||||||
|
.progress-copy { display: flex; justify-content: space-between; gap: 20px; font-size: 0.86rem; }
|
||||||
|
.progress-copy span { color: var(--muted); }
|
||||||
|
.progress-track { height: 5px; border-radius: 8px; background: #17243a; overflow: hidden; }
|
||||||
|
.progress-track span { display: block; height: 100%; width: 0; background: var(--accent); transition: width 180ms ease; }
|
||||||
|
|
||||||
|
.export-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.export-actions a {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.export-actions a:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(130px, 175px)) minmax(190px, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #091321;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label, .inline-fields label, .text-field { display: grid; gap: 6px; }
|
||||||
|
.filters label > span, .inline-fields label > span, .text-field > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input[type="search"], input[type="text"], .text-field input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text);
|
||||||
|
background: #0a1525;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
min-height: 39px;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; line-height: 1.45; }
|
||||||
|
::placeholder { color: #65758d; }
|
||||||
|
|
||||||
|
.button {
|
||||||
|
min-height: 39px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.button:hover { border-color: var(--accent); }
|
||||||
|
.button.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: #04221f; font-weight: 800; }
|
||||||
|
.button.primary:hover { background: var(--accent); }
|
||||||
|
.button:disabled { cursor: wait; opacity: 0.55; }
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-height: calc(100vh - 142px);
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 0.66fr) minmax(460px, 1.65fr) minmax(365px, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, rgba(17, 29, 48, 0.96), rgba(10, 20, 35, 0.96));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.queue-panel, .review-panel { position: sticky; top: 152px; max-height: calc(100vh - 166px); }
|
||||||
|
.queue-panel { display: flex; flex-direction: column; }
|
||||||
|
.review-panel { overflow-y: auto; }
|
||||||
|
|
||||||
|
.panel-heading, .event-header, .section-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.panel-heading { padding: 15px 16px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
.panel-heading h2, .section-heading h3 { margin: 2px 0 0; font-size: 1rem; }
|
||||||
|
.eyebrow { color: var(--accent); font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; }
|
||||||
|
.count-pill, .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 25px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.accent { color: var(--accent); border-color: rgba(94, 234, 212, 0.45); background: var(--accent-dim); }
|
||||||
|
|
||||||
|
.queue-list { overflow-y: auto; padding: 7px; }
|
||||||
|
.queue-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.queue-item:hover { background: rgba(255,255,255,0.035); }
|
||||||
|
.queue-item.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||||
|
.queue-item.reviewed .queue-status { background: var(--success); }
|
||||||
|
.queue-line { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.queue-line strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.83rem; }
|
||||||
|
.queue-status { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: #516078; }
|
||||||
|
.queue-meta { color: var(--muted); font-size: 0.74rem; display: flex; gap: 8px; }
|
||||||
|
.queue-label { font-size: 0.73rem; color: #cbd7e7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.queue-limit { margin: 0; padding: 10px 14px; color: var(--muted); font-size: 0.75rem; border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.event-header { padding: 16px 18px; }
|
||||||
|
.event-header h2 { margin: 8px 0 3px; font-size: 1.2rem; }
|
||||||
|
.event-header p { margin: 0; color: var(--muted); font-size: 0.82rem; }
|
||||||
|
.event-badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.llm-suggestion {
|
||||||
|
margin: 0 18px 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(94, 234, 212, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(94, 234, 212, 0.07);
|
||||||
|
color: #c9f8ef;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; }
|
||||||
|
.event-position { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.video-shell { background: #02050a; aspect-ratio: 16 / 9; position: relative; border-block: 1px solid var(--line); }
|
||||||
|
.video-shell video { width: 100%; height: 100%; display: block; object-fit: contain; }
|
||||||
|
.empty-state { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 7px; color: var(--muted); }
|
||||||
|
.empty-state strong { color: var(--text); }
|
||||||
|
.time-marker { position: absolute; pointer-events: none; left: 50%; top: 10px; bottom: 44px; border-left: 1px dashed rgba(251,191,36,.7); opacity: 0; }
|
||||||
|
.video-shell:hover .time-marker { opacity: .55; }
|
||||||
|
|
||||||
|
.transport { padding: 10px 14px; display: flex; gap: 7px; align-items: center; border-bottom: 1px solid var(--line); }
|
||||||
|
.transport button { color: var(--muted); border: 1px solid var(--line); background: #0a1525; border-radius: 7px; padding: 6px 9px; cursor: pointer; }
|
||||||
|
.transport button:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.transport .jump-event { color: var(--amber); }
|
||||||
|
#timeReadout { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.transcript-section { padding: 16px 18px 8px; }
|
||||||
|
.section-heading { margin-bottom: 10px; }
|
||||||
|
.section-heading > span { color: var(--muted); font-size: .78rem; }
|
||||||
|
.transcript-list { display: grid; gap: 7px; max-height: 270px; overflow-y: auto; padding-right: 3px; }
|
||||||
|
.transcript-row { display: grid; grid-template-columns: 72px minmax(0,1fr); gap: 10px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: rgba(2, 8, 18, 0.36); }
|
||||||
|
.transcript-row.anchor { border-color: rgba(251,191,36,.65); background: rgba(251,191,36,.08); }
|
||||||
|
.speaker { color: var(--accent); font-size: .75rem; font-weight: 800; }
|
||||||
|
.speaker small { color: var(--muted); display: block; margin-top: 3px; font-weight: 500; }
|
||||||
|
.utterance { line-height: 1.46; font-size: .9rem; }
|
||||||
|
.utterance small { display: block; color: var(--muted); margin-top: 4px; font-size: .72rem; }
|
||||||
|
.empty-copy { color: var(--muted); padding: 22px; text-align: center; border: 1px dashed var(--line-strong); border-radius: 8px; }
|
||||||
|
|
||||||
|
.raw-details { margin: 10px 18px 18px; border-top: 1px solid var(--line); padding-top: 12px; }
|
||||||
|
.raw-details summary { color: var(--muted); cursor: pointer; font-size: .82rem; }
|
||||||
|
.raw-details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 260px; overflow-y: auto; color: #bfd0e7; font-size: .74rem; line-height: 1.5; }
|
||||||
|
|
||||||
|
.review-heading { background: rgba(94,234,212,.035); }
|
||||||
|
.save-state { color: var(--muted); font-size: .75rem; }
|
||||||
|
.save-state.saved { color: var(--success); }
|
||||||
|
#reviewForm { padding: 13px 15px 16px; display: grid; gap: 14px; }
|
||||||
|
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 8px; }
|
||||||
|
legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; }
|
||||||
|
.required { color: var(--muted); font-size: .66rem; font-weight: 500; }
|
||||||
|
.choice-grid { display: grid; gap: 7px; }
|
||||||
|
.choice-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.choice-grid.two { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.choice-grid label { cursor: pointer; min-width: 0; }
|
||||||
|
.choice-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.choice-grid label span { min-height: 52px; border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: grid; align-content: center; gap: 2px; background: rgba(2,8,18,.28); }
|
||||||
|
.choice-grid.compact label span { min-height: 40px; }
|
||||||
|
.choice-grid label:hover span { border-color: var(--line-strong); }
|
||||||
|
.choice-grid input:checked + span { border-color: var(--accent); background: var(--accent-dim); box-shadow: inset 0 0 0 1px rgba(94,234,212,.18); }
|
||||||
|
.choice-grid input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.choice-grid b { font-size: .8rem; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.choice-grid small { color: var(--muted); font-size: .67rem; line-height: 1.25; }
|
||||||
|
.inline-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||||
|
.inline-fields select { font-size: .82rem; }
|
||||||
|
.text-field input, .text-field textarea { font-size: .84rem; }
|
||||||
|
.form-error { color: #fecdd3; background: rgba(251,113,133,.1); border: 1px solid rgba(251,113,133,.4); border-radius: 8px; padding: 9px; font-size: .8rem; }
|
||||||
|
.review-actions { display: grid; grid-template-columns: .9fr 1.4fr; gap: 8px; }
|
||||||
|
kbd { border: 1px solid rgba(3,24,22,.35); border-bottom-width: 2px; border-radius: 4px; padding: 1px 4px; font-size: .68rem; font-family: inherit; }
|
||||||
|
.shortcuts { border-top: 1px solid var(--line); padding: 11px 15px 14px; display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--muted); font-size: .71rem; }
|
||||||
|
.shortcuts kbd { color: #cbd7e7; border-color: var(--line-strong); margin-right: 2px; }
|
||||||
|
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 20px); background: #d9fff9; color: #05251f; border-radius: 9px; padding: 10px 15px; font-weight: 750; font-size: .84rem; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transition: 160ms ease; z-index: 40; }
|
||||||
|
.toast.visible { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.topbar { grid-template-columns: 1fr minmax(250px, 380px); }
|
||||||
|
.export-actions { display: none; }
|
||||||
|
.workspace { grid-template-columns: 220px minmax(430px, 1fr); }
|
||||||
|
.review-panel { position: static; max-height: none; grid-column: 1 / -1; }
|
||||||
|
.queue-panel { top: 152px; }
|
||||||
|
.filters { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.search-field { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar { position: static; grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.filters { grid-template-columns: 1fr 1fr; padding: 10px; }
|
||||||
|
.search-field { grid-column: 1 / -1; }
|
||||||
|
.workspace { grid-template-columns: 1fr; padding: 10px; }
|
||||||
|
.queue-panel, .review-panel { position: static; max-height: none; }
|
||||||
|
.queue-panel { max-height: 340px; }
|
||||||
|
.transport { flex-wrap: wrap; }
|
||||||
|
#timeReadout { width: 100%; margin: 2px 0 0; }
|
||||||
|
.choice-grid.three { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
433
floor_event_review_web/app.js
Normal file
433
floor_event_review_web/app.js
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
candidates: [],
|
||||||
|
filtered: [],
|
||||||
|
reviews: {},
|
||||||
|
currentId: null,
|
||||||
|
currentDetail: null,
|
||||||
|
saving: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = Object.fromEntries(
|
||||||
|
[
|
||||||
|
"progressText", "progressMeta", "progressBar", "sessionFilter", "splitFilter",
|
||||||
|
"familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle",
|
||||||
|
"queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge",
|
||||||
|
"autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition",
|
||||||
|
"llmSuggestion", "llmSuggestionText",
|
||||||
|
"eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel",
|
||||||
|
"transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError",
|
||||||
|
"saveButton", "uncertainButton", "saveState", "toast",
|
||||||
|
].map((id) => [id, document.getElementById(id)])
|
||||||
|
);
|
||||||
|
|
||||||
|
const familyNames = {
|
||||||
|
eot: "EOT-RESPONSE",
|
||||||
|
int: "INT-YIELD",
|
||||||
|
onset: "WEARER ONSET",
|
||||||
|
offset: "LEGACY EOT PROXY",
|
||||||
|
interaction: "LEGACY INTERACTION",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeText(value) {
|
||||||
|
return String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
|
||||||
|
const seconds = Number(value);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${(seconds % 60).toFixed(1).padStart(4, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message) {
|
||||||
|
elements.toast.textContent = message;
|
||||||
|
elements.toast.classList.add("visible");
|
||||||
|
window.clearTimeout(toast.timeout);
|
||||||
|
toast.timeout = window.setTimeout(() => elements.toast.classList.remove("visible"), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(url, options) {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error || `请求失败 (${response.status})`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
const total = state.candidates.length;
|
||||||
|
const reviewed = Object.keys(state.reviews).length;
|
||||||
|
const percent = total ? (reviewed / total) * 100 : 0;
|
||||||
|
elements.progressText.textContent = `${reviewed} / ${total} 已审核`;
|
||||||
|
elements.progressMeta.textContent = `${percent.toFixed(1)}%`;
|
||||||
|
elements.progressBar.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSessions() {
|
||||||
|
const sessions = [...new Set(state.candidates.map((item) => item.session))].sort((a, b) =>
|
||||||
|
a.localeCompare(b, undefined, { numeric: true })
|
||||||
|
);
|
||||||
|
for (const session of sessions) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = session;
|
||||||
|
option.textContent = session;
|
||||||
|
elements.sessionFilter.append(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters({ keepCurrent = true } = {}) {
|
||||||
|
const search = elements.searchFilter.value.trim().toLowerCase();
|
||||||
|
state.filtered = state.candidates.filter((item) => {
|
||||||
|
const reviewed = Boolean(state.reviews[item.candidate_id]);
|
||||||
|
const status = elements.statusFilter.value;
|
||||||
|
return (
|
||||||
|
(elements.sessionFilter.value === "all" || item.session === elements.sessionFilter.value) &&
|
||||||
|
(elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) &&
|
||||||
|
(elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) &&
|
||||||
|
(status === "all" || (status === "reviewed" ? reviewed : !reviewed)) &&
|
||||||
|
(!search || `${item.uid} ${item.automatic_label} ${item.automatic_protocol_label || ""} ${item.automatic_hard_negative_type || ""} ${item.session}`.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
renderQueue();
|
||||||
|
if (!keepCurrent || !state.filtered.some((item) => item.candidate_id === state.currentId)) {
|
||||||
|
selectCandidate(state.filtered[0]?.candidate_id || null);
|
||||||
|
} else {
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueue() {
|
||||||
|
elements.queueList.replaceChildren();
|
||||||
|
elements.queueCount.textContent = String(state.filtered.length);
|
||||||
|
elements.queueTitle.textContent = elements.statusFilter.value === "unreviewed" ? "待审核候选" : "候选事件";
|
||||||
|
const limit = 250;
|
||||||
|
for (const item of state.filtered.slice(0, limit)) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = `queue-item${item.candidate_id === state.currentId ? " active" : ""}${state.reviews[item.candidate_id] ? " reviewed" : ""}`;
|
||||||
|
button.dataset.id = item.candidate_id;
|
||||||
|
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "queue-line";
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "queue-status";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = item.uid;
|
||||||
|
line.append(dot, title);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "queue-meta";
|
||||||
|
meta.textContent = `${item.session} · ${familyNames[item.family] || item.family} · ${formatTime(item.event_time_seconds)}`;
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "queue-label";
|
||||||
|
label.textContent = item.automatic_protocol_label
|
||||||
|
? `${item.automatic_protocol_label} · ${item.automatic_hard_negative_type || item.automatic_label}`
|
||||||
|
: item.automatic_label;
|
||||||
|
button.append(line, meta, label);
|
||||||
|
button.addEventListener("click", () => selectCandidate(item.candidate_id));
|
||||||
|
elements.queueList.append(button);
|
||||||
|
}
|
||||||
|
elements.queueLimit.hidden = state.filtered.length <= limit;
|
||||||
|
elements.queueLimit.textContent = state.filtered.length > limit ? `列表显示前 ${limit} 条;可用筛选缩小范围。` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId);
|
||||||
|
elements.eventPosition.textContent = index < 0 ? `— / ${state.filtered.length}` : `${index + 1} / ${state.filtered.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCandidate(candidateId) {
|
||||||
|
state.currentId = candidateId;
|
||||||
|
renderQueue();
|
||||||
|
updatePosition();
|
||||||
|
if (!candidateId) {
|
||||||
|
elements.eventTitle.textContent = "当前筛选下没有候选";
|
||||||
|
elements.eventSubtitle.textContent = "调整筛选条件后继续。";
|
||||||
|
elements.eventVideo.removeAttribute("src");
|
||||||
|
elements.eventVideo.load();
|
||||||
|
elements.mediaEmpty.hidden = false;
|
||||||
|
elements.llmSuggestion.hidden = true;
|
||||||
|
elements.reviewForm.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.eventTitle.textContent = "载入事件…";
|
||||||
|
elements.saveButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const detail = await request(`/api/event?id=${encodeURIComponent(candidateId)}`);
|
||||||
|
if (state.currentId !== candidateId) return;
|
||||||
|
state.currentDetail = detail;
|
||||||
|
renderDetail(detail);
|
||||||
|
restoreForm(detail.review);
|
||||||
|
} catch (error) {
|
||||||
|
elements.eventTitle.textContent = "事件载入失败";
|
||||||
|
elements.eventSubtitle.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
elements.saveButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(detail) {
|
||||||
|
elements.familyBadge.textContent = familyNames[detail.family] || detail.family;
|
||||||
|
elements.splitBadge.textContent = `${detail.split} · ${detail.session}`;
|
||||||
|
elements.autoConfidenceBadge.textContent = detail.automatic_confidence ? `auto ${detail.automatic_confidence}` : "auto confidence —";
|
||||||
|
elements.eventTitle.textContent = detail.automatic_label;
|
||||||
|
elements.eventSubtitle.textContent = `${detail.uid} · ${detail.source_file}:${detail.source_row}`;
|
||||||
|
elements.clipLabel.textContent = detail.clip_stem ? `${detail.session} / ${detail.clip_stem}` : detail.session;
|
||||||
|
elements.rawCandidate.textContent = JSON.stringify(detail.candidate, null, 2);
|
||||||
|
elements.saveState.textContent = detail.review ? "已保存" : "未保存";
|
||||||
|
elements.saveState.classList.toggle("saved", Boolean(detail.review));
|
||||||
|
|
||||||
|
const suggestion = detail.llm_suggestion;
|
||||||
|
elements.llmSuggestion.hidden = !suggestion;
|
||||||
|
if (suggestion) {
|
||||||
|
const disposition = suggestion.protocol_disposition || suggestion.final_protocol_disposition || "uncertain";
|
||||||
|
const subtype = suggestion.hard_negative_type || suggestion.final_hard_negative_type || "uncertain";
|
||||||
|
const confidence = suggestion.confidence || suggestion.final_confidence || "—";
|
||||||
|
const rationale = suggestion.rationale || suggestion.final_rationale || "";
|
||||||
|
elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.mediaEmpty.hidden = detail.media_available;
|
||||||
|
if (detail.media_available) {
|
||||||
|
elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`;
|
||||||
|
elements.eventVideo.load();
|
||||||
|
} else {
|
||||||
|
elements.eventVideo.removeAttribute("src");
|
||||||
|
elements.eventVideo.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.transcriptList.replaceChildren();
|
||||||
|
if (!detail.transcript.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty-copy";
|
||||||
|
empty.textContent = "当前上下文未找到 transcript。";
|
||||||
|
elements.transcriptList.append(empty);
|
||||||
|
} else {
|
||||||
|
for (const row of detail.transcript) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `transcript-row${row.is_anchor ? " anchor" : ""}`;
|
||||||
|
const speaker = document.createElement("div");
|
||||||
|
speaker.className = "speaker";
|
||||||
|
speaker.textContent = `P${row.participant_id ?? "?"}`;
|
||||||
|
const time = document.createElement("small");
|
||||||
|
time.textContent = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)}`;
|
||||||
|
speaker.append(time);
|
||||||
|
const utterance = document.createElement("div");
|
||||||
|
utterance.className = "utterance";
|
||||||
|
utterance.textContent = escapeText(row.transcription);
|
||||||
|
const target = document.createElement("small");
|
||||||
|
target.textContent = `Target: ${typeof row.target_of_speech === "string" ? row.target_of_speech : JSON.stringify(row.target_of_speech ?? "unknown")}`;
|
||||||
|
utterance.append(target);
|
||||||
|
item.append(speaker, utterance);
|
||||||
|
elements.transcriptList.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTimeReadout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreForm(review) {
|
||||||
|
elements.reviewForm.reset();
|
||||||
|
elements.formError.hidden = true;
|
||||||
|
const reviewer = review?.reviewer || localStorage.getItem("floor-reviewer") || "";
|
||||||
|
elements.reviewerInput.value = reviewer;
|
||||||
|
if (!review) {
|
||||||
|
elements.reviewForm.elements.anchor_adjustment_bins.value = "0";
|
||||||
|
elements.reviewForm.elements.behavior_adjustment_bins.value = "0";
|
||||||
|
elements.reviewForm.elements.region_end_adjustment_bins.value = "0";
|
||||||
|
elements.reviewForm.elements.confidence.value = "medium";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(review)) {
|
||||||
|
const control = elements.reviewForm.elements[name];
|
||||||
|
if (!control) continue;
|
||||||
|
if (control instanceof RadioNodeList) control.value = String(value);
|
||||||
|
else control.value = value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formPayload() {
|
||||||
|
const data = new FormData(elements.reviewForm);
|
||||||
|
const adjustment = (name) => {
|
||||||
|
const value = data.get(name);
|
||||||
|
return value === "" ? null : Number(value);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
protocol_disposition: data.get("protocol_disposition"),
|
||||||
|
hard_negative_type: data.get("hard_negative_type"),
|
||||||
|
validity: data.get("validity"),
|
||||||
|
onset_function: data.get("onset_function"),
|
||||||
|
context: data.get("context"),
|
||||||
|
offset_outcome: data.get("offset_outcome"),
|
||||||
|
anchor_adjustment_bins: adjustment("anchor_adjustment_bins"),
|
||||||
|
behavior_adjustment_bins: adjustment("behavior_adjustment_bins"),
|
||||||
|
region_end_adjustment_bins: adjustment("region_end_adjustment_bins"),
|
||||||
|
confidence: data.get("confidence"),
|
||||||
|
reviewer: String(data.get("reviewer") || "").trim(),
|
||||||
|
notes: String(data.get("notes") || "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReview(review) {
|
||||||
|
const missing = [];
|
||||||
|
if (!review.protocol_disposition) missing.push("协议结论");
|
||||||
|
if (!review.hard_negative_type) missing.push("hard-negative 类型");
|
||||||
|
if (!review.validity) missing.push("活动边界");
|
||||||
|
if (!review.onset_function) missing.push("onset 功能");
|
||||||
|
if (!review.context) missing.push("对话场景");
|
||||||
|
if (!review.offset_outcome) missing.push("offset 结果");
|
||||||
|
if (!review.reviewer) missing.push("审核者");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrent({ advance = true } = {}) {
|
||||||
|
if (!state.currentId || state.saving) return;
|
||||||
|
const review = formPayload();
|
||||||
|
const missing = validateReview(review);
|
||||||
|
if (missing.length) {
|
||||||
|
elements.formError.textContent = `请完成:${missing.join("、")}`;
|
||||||
|
elements.formError.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.formError.hidden = true;
|
||||||
|
state.saving = true;
|
||||||
|
elements.saveButton.disabled = true;
|
||||||
|
elements.saveState.textContent = "保存中…";
|
||||||
|
try {
|
||||||
|
await request("/api/review", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ candidate_id: state.currentId, review }),
|
||||||
|
});
|
||||||
|
state.reviews[state.currentId] = review;
|
||||||
|
localStorage.setItem("floor-reviewer", review.reviewer);
|
||||||
|
elements.saveState.textContent = "已保存";
|
||||||
|
elements.saveState.classList.add("saved");
|
||||||
|
updateProgress();
|
||||||
|
toast("审核结果已保存");
|
||||||
|
if (advance) {
|
||||||
|
const previousCandidates = [...state.filtered];
|
||||||
|
const index = previousCandidates.findIndex((item) => item.candidate_id === state.currentId);
|
||||||
|
applyFilters();
|
||||||
|
const next = state.filtered[index] || state.filtered[index - 1] || state.filtered[0];
|
||||||
|
if (elements.statusFilter.value === "all") {
|
||||||
|
const sequential = previousCandidates[index + 1] || previousCandidates[index];
|
||||||
|
selectCandidate(sequential?.candidate_id || null);
|
||||||
|
} else {
|
||||||
|
selectCandidate(next?.candidate_id || null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
elements.formError.textContent = error.message;
|
||||||
|
elements.formError.hidden = false;
|
||||||
|
elements.saveState.textContent = "保存失败";
|
||||||
|
} finally {
|
||||||
|
state.saving = false;
|
||||||
|
elements.saveButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCandidate(delta) {
|
||||||
|
if (!state.filtered.length) return;
|
||||||
|
const index = state.filtered.findIndex((item) => item.candidate_id === state.currentId);
|
||||||
|
const nextIndex = Math.max(0, Math.min(state.filtered.length - 1, (index < 0 ? 0 : index) + delta));
|
||||||
|
selectCandidate(state.filtered[nextIndex].candidate_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(delta) {
|
||||||
|
const video = elements.eventVideo;
|
||||||
|
if (!Number.isFinite(video.duration)) return;
|
||||||
|
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToEvent() {
|
||||||
|
const value = state.currentDetail?.event_local_seconds;
|
||||||
|
if (value !== null && value !== undefined) elements.eventVideo.currentTime = Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeReadout() {
|
||||||
|
const eventTime = state.currentDetail?.event_local_seconds;
|
||||||
|
const current = Number.isFinite(elements.eventVideo.currentTime) ? elements.eventVideo.currentTime : 0;
|
||||||
|
const delta = eventTime === null || eventTime === undefined ? null : current - Number(eventTime);
|
||||||
|
elements.timeReadout.textContent = delta === null ? formatTime(current) : `${formatTime(current)} · event ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target) {
|
||||||
|
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
for (const control of [elements.sessionFilter, elements.splitFilter, elements.familyFilter, elements.statusFilter]) {
|
||||||
|
control.addEventListener("change", () => applyFilters({ keepCurrent: false }));
|
||||||
|
}
|
||||||
|
elements.searchFilter.addEventListener("input", () => applyFilters({ keepCurrent: false }));
|
||||||
|
elements.clearFilters.addEventListener("click", () => {
|
||||||
|
elements.sessionFilter.value = "all";
|
||||||
|
elements.splitFilter.value = "all";
|
||||||
|
elements.familyFilter.value = "all";
|
||||||
|
elements.statusFilter.value = "unreviewed";
|
||||||
|
elements.searchFilter.value = "";
|
||||||
|
applyFilters({ keepCurrent: false });
|
||||||
|
});
|
||||||
|
elements.reviewForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
saveCurrent();
|
||||||
|
});
|
||||||
|
elements.uncertainButton.addEventListener("click", () => {
|
||||||
|
elements.reviewForm.elements.protocol_disposition.value = "uncertain";
|
||||||
|
elements.reviewForm.elements.hard_negative_type.value = "uncertain";
|
||||||
|
elements.reviewForm.elements.validity.value = "uncertain";
|
||||||
|
elements.reviewForm.elements.onset_function.value = "uncertain";
|
||||||
|
elements.reviewForm.elements.context.value = "unknown";
|
||||||
|
elements.reviewForm.elements.offset_outcome.value = "uncertain";
|
||||||
|
elements.reviewForm.elements.confidence.value = "low";
|
||||||
|
});
|
||||||
|
elements.jumpEvent.addEventListener("click", jumpToEvent);
|
||||||
|
document.querySelectorAll("[data-seek]").forEach((button) =>
|
||||||
|
button.addEventListener("click", () => seek(Number(button.dataset.seek)))
|
||||||
|
);
|
||||||
|
elements.eventVideo.addEventListener("loadedmetadata", () => {
|
||||||
|
const start = state.currentDetail?.context_start_seconds;
|
||||||
|
if (start !== null && start !== undefined) elements.eventVideo.currentTime = Number(start);
|
||||||
|
});
|
||||||
|
elements.eventVideo.addEventListener("timeupdate", updateTimeReadout);
|
||||||
|
elements.eventVideo.addEventListener("error", () => { elements.mediaEmpty.hidden = false; });
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
saveCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTypingTarget(event.target)) return;
|
||||||
|
if (event.key === "ArrowLeft") { event.preventDefault(); moveCandidate(-1); }
|
||||||
|
else if (event.key === "ArrowRight") { event.preventDefault(); moveCandidate(1); }
|
||||||
|
else if (event.key === "[") { event.preventDefault(); seek(-0.2); }
|
||||||
|
else if (event.key === "]") { event.preventDefault(); seek(0.2); }
|
||||||
|
else if (event.code === "Space") {
|
||||||
|
event.preventDefault();
|
||||||
|
elements.eventVideo.paused ? elements.eventVideo.play() : elements.eventVideo.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
bindEvents();
|
||||||
|
try {
|
||||||
|
const payload = await request("/api/bootstrap");
|
||||||
|
state.candidates = payload.candidates;
|
||||||
|
state.reviews = payload.reviews || {};
|
||||||
|
populateSessions();
|
||||||
|
updateProgress();
|
||||||
|
applyFilters({ keepCurrent: false });
|
||||||
|
} catch (error) {
|
||||||
|
elements.progressText.textContent = "载入失败";
|
||||||
|
elements.progressMeta.textContent = error.message;
|
||||||
|
elements.eventTitle.textContent = "无法载入候选事件";
|
||||||
|
elements.eventSubtitle.textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize();
|
||||||
285
floor_event_review_web/index.html
Normal file
285
floor_event_review_web/index.html
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="EasyCom wearer floor-event local review workspace" />
|
||||||
|
<title>Floor Event Review</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230b1324'/%3E%3Cpath d='M14 39h8l5-17 9 29 6-22 4 10h5' fill='none' stroke='%235eead4' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||||
|
<link rel="stylesheet" href="/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<h1>Floor Event Review</h1>
|
||||||
|
<p>EasyCom 事件审核</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-block" aria-live="polite">
|
||||||
|
<div class="progress-copy">
|
||||||
|
<strong id="progressText">载入中</strong>
|
||||||
|
<span id="progressMeta">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track"><span id="progressBar"></span></div>
|
||||||
|
</div>
|
||||||
|
<nav class="export-actions" aria-label="导出审核结果">
|
||||||
|
<a href="/api/export.jsonl" download>导出 JSONL</a>
|
||||||
|
<a href="/api/export.csv" download>导出 CSV</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="filters" aria-label="候选筛选">
|
||||||
|
<label>
|
||||||
|
<span>Session</span>
|
||||||
|
<select id="sessionFilter"><option value="all">全部</option></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Split</span>
|
||||||
|
<select id="splitFilter">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="train">train</option>
|
||||||
|
<option value="test">test</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>事件族</span>
|
||||||
|
<select id="familyFilter">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="eot">EOT-response</option>
|
||||||
|
<option value="int">INT-yield</option>
|
||||||
|
<option value="onset">wearer onset</option>
|
||||||
|
<option value="offset">旧 EOT proxy</option>
|
||||||
|
<option value="interaction">旧 interaction proxy</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>审核状态</span>
|
||||||
|
<select id="statusFilter">
|
||||||
|
<option value="unreviewed">未审核</option>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="reviewed">已审核</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="search-field">
|
||||||
|
<span>搜索</span>
|
||||||
|
<input id="searchFilter" type="search" placeholder="UID、类别、session" />
|
||||||
|
</label>
|
||||||
|
<button id="clearFilters" class="button ghost" type="button">清除筛选</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<aside class="queue-panel panel" aria-label="事件候选列表">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">QUEUE</span>
|
||||||
|
<h2 id="queueTitle">候选</h2>
|
||||||
|
</div>
|
||||||
|
<span id="queueCount" class="count-pill">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="queueList" class="queue-list"></div>
|
||||||
|
<p id="queueLimit" class="queue-limit" hidden></p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="evidence-panel panel" aria-label="事件证据">
|
||||||
|
<div class="event-header">
|
||||||
|
<div>
|
||||||
|
<div class="event-badges">
|
||||||
|
<span id="familyBadge" class="badge accent">—</span>
|
||||||
|
<span id="splitBadge" class="badge">—</span>
|
||||||
|
<span id="autoConfidenceBadge" class="badge">—</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="eventTitle">请选择一个候选事件</h2>
|
||||||
|
<p id="eventSubtitle">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="event-position"><strong id="eventPosition">0 / 0</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="llmSuggestion" class="llm-suggestion" hidden>
|
||||||
|
<strong>DeepSeek 预标注(仅供复核)</strong>
|
||||||
|
<span id="llmSuggestionText">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-shell">
|
||||||
|
<video id="eventVideo" controls preload="metadata"></video>
|
||||||
|
<div id="mediaEmpty" class="empty-state" hidden>
|
||||||
|
<strong>当前候选未找到视频</strong>
|
||||||
|
<span>检查 dataset root、session 和 clip stem。</span>
|
||||||
|
</div>
|
||||||
|
<div class="time-marker" aria-hidden="true"><span></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="transport" aria-label="视频定位">
|
||||||
|
<button type="button" data-seek="-2">−2 s</button>
|
||||||
|
<button type="button" data-seek="-0.2">−0.2 s</button>
|
||||||
|
<button id="jumpEvent" class="jump-event" type="button">回到事件</button>
|
||||||
|
<button type="button" data-seek="0.2">+0.2 s</button>
|
||||||
|
<button type="button" data-seek="2">+2 s</button>
|
||||||
|
<span id="timeReadout">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="transcript-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">TRANSCRIPT</span>
|
||||||
|
<h3>事件上下文</h3>
|
||||||
|
</div>
|
||||||
|
<span id="clipLabel">—</span>
|
||||||
|
</div>
|
||||||
|
<div id="transcriptList" class="transcript-list">
|
||||||
|
<div class="empty-copy">选择事件后显示 transcript。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="raw-details">
|
||||||
|
<summary>查看自动规则与原始候选字段</summary>
|
||||||
|
<pre id="rawCandidate">{}</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="review-panel panel" aria-label="人工审核表单">
|
||||||
|
<div class="panel-heading review-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">HUMAN REVIEW</span>
|
||||||
|
<h2>审核结论</h2>
|
||||||
|
</div>
|
||||||
|
<span id="saveState" class="save-state">未保存</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="reviewForm">
|
||||||
|
<fieldset>
|
||||||
|
<legend>1. 稿件协议结论 <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid two compact">
|
||||||
|
<label><input type="radio" name="protocol_disposition" value="positive" /><span><b>Positive</b><small>EOT-response / INT-yield 正例</small></span></label>
|
||||||
|
<label><input type="radio" name="protocol_disposition" value="hard_negative" /><span><b>Hard negative</b><small>错误 commit 的计分区间</small></span></label>
|
||||||
|
<label><input type="radio" name="protocol_disposition" value="exclude" /><span><b>Exclude</b><small>歧义或标注损坏</small></span></label>
|
||||||
|
<label><input type="radio" name="protocol_disposition" value="uncertain" /><span><b>不确定</b><small>需要裁决</small></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>2. Hard-negative 类型 <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid two compact">
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="eot_midturn_pause" /><span><b>EOT 句中停顿</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="eot_other_to_other" /><span><b>EOT other→other</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="eot_no_response" /><span><b>EOT 保持沉默</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="int_backchannel" /><span><b>INT backchannel</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="int_non_takeover_overlap" /><span><b>INT 非接管重叠</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="int_retain_floor" /><span><b>INT 保持话轮</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="other" /><span><b>其他</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||||
|
<label><input type="radio" name="hard_negative_type" value="uncertain" /><span><b>不确定</b></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>3. 活动边界是否真实? <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid three">
|
||||||
|
<label><input type="radio" name="validity" value="valid" /><span><b>真实</b><small>有效活动边界</small></span></label>
|
||||||
|
<label><input type="radio" name="validity" value="artifact" /><span><b>伪边界</b><small>噪声、断点或错标</small></span></label>
|
||||||
|
<label><input type="radio" name="validity" value="uncertain" /><span><b>不确定</b><small>证据不足</small></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>4. Onset 的 floor 功能 <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid two compact">
|
||||||
|
<label><input type="radio" name="onset_function" value="floor_taking" /><span><b>取得话轮</b></span></label>
|
||||||
|
<label><input type="radio" name="onset_function" value="backchannel" /><span><b>Backchannel</b></span></label>
|
||||||
|
<label><input type="radio" name="onset_function" value="within_turn_resume" /><span><b>句内恢复</b></span></label>
|
||||||
|
<label><input type="radio" name="onset_function" value="nonverbal" /><span><b>非语言发声</b></span></label>
|
||||||
|
<label><input type="radio" name="onset_function" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||||
|
<label><input type="radio" name="onset_function" value="uncertain" /><span><b>不确定</b></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>5. 对话指向/场景 <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid two compact">
|
||||||
|
<label><input type="radio" name="context" value="addressed_wearer" /><span><b>对 wearer</b></span></label>
|
||||||
|
<label><input type="radio" name="context" value="third_party" /><span><b>第三方交谈</b></span></label>
|
||||||
|
<label><input type="radio" name="context" value="group" /><span><b>群体</b></span></label>
|
||||||
|
<label><input type="radio" name="context" value="unknown" /><span><b>未知</b></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>6. Offset 的结果 <span class="required">必填</span></legend>
|
||||||
|
<div class="choice-grid two compact">
|
||||||
|
<label><input type="radio" name="offset_outcome" value="yield_to_other" /><span><b>让给 other</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="completion_to_silence" /><span><b>结束后静音</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="temporary_pause" /><span><b>短暂停顿</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="backchannel_end" /><span><b>Backchannel 结束</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="cutoff" /><span><b>截断/边界</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="not_applicable" /><span><b>不适用</b></span></label>
|
||||||
|
<label><input type="radio" name="offset_outcome" value="uncertain" /><span><b>不确定</b></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="inline-fields">
|
||||||
|
<label>
|
||||||
|
<span>other 锚点校正</span>
|
||||||
|
<select name="anchor_adjustment_bins">
|
||||||
|
<option value="">无法判断</option>
|
||||||
|
<option value="-1">−1 bin(−200 ms)</option>
|
||||||
|
<option value="0" selected>原 bin</option>
|
||||||
|
<option value="1">+1 bin(+200 ms)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>wearer 行为锚点校正</span>
|
||||||
|
<select name="behavior_adjustment_bins">
|
||||||
|
<option value="">不适用/无法判断</option>
|
||||||
|
<option value="-1">−1 bin(−200 ms)</option>
|
||||||
|
<option value="0" selected>原 bin</option>
|
||||||
|
<option value="1">+1 bin(+200 ms)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>负区间终点校正</span>
|
||||||
|
<select name="region_end_adjustment_bins">
|
||||||
|
<option value="">不适用/无法判断</option>
|
||||||
|
<option value="-1">−1 bin(−200 ms)</option>
|
||||||
|
<option value="0" selected>原 bin</option>
|
||||||
|
<option value="1">+1 bin(+200 ms)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>置信度</span>
|
||||||
|
<select name="confidence" required>
|
||||||
|
<option value="high">高</option>
|
||||||
|
<option value="medium" selected>中</option>
|
||||||
|
<option value="low">低</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="text-field">
|
||||||
|
<span>审核者</span>
|
||||||
|
<input id="reviewerInput" name="reviewer" maxlength="100" placeholder="姓名或代号" required />
|
||||||
|
</label>
|
||||||
|
<label class="text-field">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea name="notes" maxlength="4000" rows="3" placeholder="记录歧义、说话对象或边界问题"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="formError" class="form-error" role="alert" hidden></div>
|
||||||
|
<div class="review-actions">
|
||||||
|
<button id="uncertainButton" class="button ghost" type="button">全部标为不确定</button>
|
||||||
|
<button id="saveButton" class="button primary" type="submit">保存并看下一个 <kbd>⌘↵</kbd></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="shortcuts">
|
||||||
|
<span><kbd>←</kbd><kbd>→</kbd> 前后事件</span>
|
||||||
|
<span><kbd>[</kbd><kbd>]</kbd> ±200 ms</span>
|
||||||
|
<span><kbd>Space</kbd> 播放/暂停</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
571
prepare_easycom_eot_int_review.py
Normal file
571
prepare_easycom_eot_int_review.py
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare manuscript-aligned EasyCom EOT/INT regions for human review.
|
||||||
|
|
||||||
|
The released EasyCom annotations provide participant-resolved speech intervals,
|
||||||
|
not semantic EOT/INT ground truth. This script therefore creates auditable
|
||||||
|
candidate regions from the original 20 Hz timeline. It keeps the two wearer-
|
||||||
|
relative directions distinct:
|
||||||
|
|
||||||
|
* EOT-response: an aggregate non-wearer offset while the wearer is silent;
|
||||||
|
* INT-yield: an aggregate non-wearer onset while the wearer is speaking.
|
||||||
|
|
||||||
|
Observed wearer transitions form timing-positive candidates. Competing other
|
||||||
|
transitions and fully observed no-transition windows form hard-negative
|
||||||
|
candidates. Source-boundary cases are retained as exclusions. Every output
|
||||||
|
row remains an automatic candidate until it is independently human reviewed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
from typing import Any, Iterable, Sequence
|
||||||
|
|
||||||
|
from derive_easycom_interaction_events import (
|
||||||
|
CAMERA_WEARER_ID,
|
||||||
|
SOURCE_FPS,
|
||||||
|
Clip,
|
||||||
|
Rules,
|
||||||
|
Utterance,
|
||||||
|
build_clips,
|
||||||
|
is_lexical_feedback,
|
||||||
|
is_pure_nonverbal,
|
||||||
|
load_utterances,
|
||||||
|
)
|
||||||
|
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
PROTOCOL_VERSION = "easycom-manuscript-eot-int-v1"
|
||||||
|
DEFAULT_WINDOW_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProtocolCandidate:
|
||||||
|
schema_version: int
|
||||||
|
protocol_version: str
|
||||||
|
uid: str
|
||||||
|
session: str
|
||||||
|
split: str
|
||||||
|
task: str
|
||||||
|
event_type: str
|
||||||
|
automatic_protocol_label: str
|
||||||
|
automatic_hard_negative_type: str
|
||||||
|
automatic_confidence: str
|
||||||
|
anchor_type: str
|
||||||
|
anchor_dense_frame: int
|
||||||
|
anchor_dense_seconds: float
|
||||||
|
behavior_anchor_type: str | None
|
||||||
|
behavior_dense_frame: int | None
|
||||||
|
behavior_seconds: float | None
|
||||||
|
region_start_seconds: float
|
||||||
|
region_end_seconds: float
|
||||||
|
region_end_reason: str
|
||||||
|
previous_other_participant_ids: tuple[int, ...]
|
||||||
|
incoming_other_participant_ids: tuple[int, ...]
|
||||||
|
clip_stem: str
|
||||||
|
source_start_frame: int
|
||||||
|
context: tuple[dict[str, Any], ...]
|
||||||
|
automatic_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_state(
|
||||||
|
rows: Sequence[Utterance],
|
||||||
|
*,
|
||||||
|
segment_start: int,
|
||||||
|
segment_end: int,
|
||||||
|
wearer_id: int,
|
||||||
|
) -> tuple[bytearray, bytearray]:
|
||||||
|
length = segment_end - segment_start
|
||||||
|
wearer = bytearray(length)
|
||||||
|
others = bytearray(length)
|
||||||
|
for row in rows:
|
||||||
|
left = max(segment_start, row.dense_start_frame) - segment_start
|
||||||
|
right = min(segment_end, row.dense_end_frame_exclusive) - segment_start
|
||||||
|
if right <= left:
|
||||||
|
continue
|
||||||
|
target = wearer if row.participant_id == wearer_id else others
|
||||||
|
target[left:right] = b"\x01" * (right - left)
|
||||||
|
return wearer, others
|
||||||
|
|
||||||
|
|
||||||
|
def _first_transition(
|
||||||
|
state: bytearray,
|
||||||
|
*,
|
||||||
|
start: int,
|
||||||
|
stop: int,
|
||||||
|
rising: bool,
|
||||||
|
) -> int | None:
|
||||||
|
for index in range(max(1, start), min(stop, len(state))):
|
||||||
|
before = bool(state[index - 1])
|
||||||
|
after = bool(state[index])
|
||||||
|
if (not before and after) if rising else (before and not after):
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_other_ids(
|
||||||
|
rows: Sequence[Utterance],
|
||||||
|
dense_frame: int,
|
||||||
|
*,
|
||||||
|
wearer_id: int,
|
||||||
|
) -> tuple[int, ...]:
|
||||||
|
return tuple(sorted({
|
||||||
|
row.participant_id
|
||||||
|
for row in rows
|
||||||
|
if row.participant_id != wearer_id
|
||||||
|
and row.dense_start_frame <= dense_frame < row.dense_end_frame_exclusive
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_for_frame(clips: Sequence[Clip], dense_frame: int) -> Clip | None:
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
clip
|
||||||
|
for clip in clips
|
||||||
|
if clip.dense_start_frame <= dense_frame < clip.dense_start_frame + clip.num_frames
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _context_rows(
|
||||||
|
rows: Sequence[Utterance],
|
||||||
|
*,
|
||||||
|
anchor_frame: int,
|
||||||
|
context_frames: int,
|
||||||
|
wearer_id: int,
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
left = anchor_frame - context_frames
|
||||||
|
right = anchor_frame + context_frames
|
||||||
|
selected = []
|
||||||
|
for row in rows:
|
||||||
|
if row.dense_end_frame_exclusive <= left or row.dense_start_frame >= right:
|
||||||
|
continue
|
||||||
|
selected.append(
|
||||||
|
{
|
||||||
|
"uid": row.uid,
|
||||||
|
"participant_id": row.participant_id,
|
||||||
|
"participant_role": "wearer" if row.participant_id == wearer_id else "other",
|
||||||
|
"start_seconds": row.dense_start_seconds,
|
||||||
|
"end_seconds": row.dense_end_seconds,
|
||||||
|
"transcription": row.transcription,
|
||||||
|
"target_of_speech": list(row.target_of_speech),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _incoming_is_backchannel(
|
||||||
|
rows: Sequence[Utterance],
|
||||||
|
*,
|
||||||
|
dense_frame: int,
|
||||||
|
wearer_id: int,
|
||||||
|
max_seconds: float,
|
||||||
|
) -> bool:
|
||||||
|
incoming = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if row.participant_id != wearer_id
|
||||||
|
and row.dense_start_frame <= dense_frame < row.dense_end_frame_exclusive
|
||||||
|
and row.dense_start_frame >= dense_frame - 1
|
||||||
|
]
|
||||||
|
return bool(incoming) and all(row.duration_seconds <= max_seconds for row in incoming) and any(
|
||||||
|
is_lexical_feedback(row.transcription) or is_pure_nonverbal(row.transcription)
|
||||||
|
for row in incoming
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(
|
||||||
|
*,
|
||||||
|
session: str,
|
||||||
|
task: str,
|
||||||
|
automatic_protocol_label: str,
|
||||||
|
automatic_hard_negative_type: str,
|
||||||
|
anchor_frame: int,
|
||||||
|
behavior_frame: int | None,
|
||||||
|
region_end_frame: int,
|
||||||
|
region_end_reason: str,
|
||||||
|
previous_ids: tuple[int, ...],
|
||||||
|
incoming_ids: tuple[int, ...],
|
||||||
|
clips: Sequence[Clip],
|
||||||
|
rows: Sequence[Utterance],
|
||||||
|
wearer_id: int,
|
||||||
|
context_frames: int,
|
||||||
|
reason: str,
|
||||||
|
) -> ProtocolCandidate:
|
||||||
|
clip = _clip_for_frame(clips, anchor_frame)
|
||||||
|
if clip is None:
|
||||||
|
raise ValueError(f"No source clip for {session} dense frame {anchor_frame}")
|
||||||
|
event_type = "eot_response_candidate" if task == "eot_response" else "int_yield_candidate"
|
||||||
|
anchor_type = "other_offset" if task == "eot_response" else "other_onset"
|
||||||
|
behavior_type = None
|
||||||
|
if behavior_frame is not None:
|
||||||
|
behavior_type = "wearer_onset" if task == "eot_response" else "wearer_offset"
|
||||||
|
return ProtocolCandidate(
|
||||||
|
schema_version=SCHEMA_VERSION,
|
||||||
|
protocol_version=PROTOCOL_VERSION,
|
||||||
|
uid=f"{session}/{task}/{anchor_frame:08d}",
|
||||||
|
session=session,
|
||||||
|
split="test" if session in TEST_SESSIONS else "train",
|
||||||
|
task=task,
|
||||||
|
event_type=event_type,
|
||||||
|
automatic_protocol_label=automatic_protocol_label,
|
||||||
|
automatic_hard_negative_type=automatic_hard_negative_type,
|
||||||
|
automatic_confidence="timing_candidate",
|
||||||
|
anchor_type=anchor_type,
|
||||||
|
anchor_dense_frame=anchor_frame,
|
||||||
|
anchor_dense_seconds=anchor_frame / SOURCE_FPS,
|
||||||
|
behavior_anchor_type=behavior_type,
|
||||||
|
behavior_dense_frame=behavior_frame,
|
||||||
|
behavior_seconds=None if behavior_frame is None else behavior_frame / SOURCE_FPS,
|
||||||
|
region_start_seconds=anchor_frame / SOURCE_FPS,
|
||||||
|
region_end_seconds=region_end_frame / SOURCE_FPS,
|
||||||
|
region_end_reason=region_end_reason,
|
||||||
|
previous_other_participant_ids=previous_ids,
|
||||||
|
incoming_other_participant_ids=incoming_ids,
|
||||||
|
clip_stem=clip.stem,
|
||||||
|
source_start_frame=anchor_frame - clip.dense_start_frame + 1,
|
||||||
|
context=_context_rows(
|
||||||
|
rows,
|
||||||
|
anchor_frame=anchor_frame,
|
||||||
|
context_frames=context_frames,
|
||||||
|
wearer_id=wearer_id,
|
||||||
|
),
|
||||||
|
automatic_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_protocol_candidates(
|
||||||
|
utterances: Sequence[Utterance],
|
||||||
|
clips: Sequence[Clip],
|
||||||
|
*,
|
||||||
|
session: str,
|
||||||
|
wearer_id: int = CAMERA_WEARER_ID,
|
||||||
|
window_seconds: float = DEFAULT_WINDOW_SECONDS,
|
||||||
|
context_seconds: float = 3.0,
|
||||||
|
) -> list[ProtocolCandidate]:
|
||||||
|
"""Construct timing candidates with manuscript-aligned directionality."""
|
||||||
|
|
||||||
|
if window_seconds <= 0 or context_seconds < 0:
|
||||||
|
raise ValueError("window_seconds must be positive and context_seconds non-negative")
|
||||||
|
window_frames = max(1, round(window_seconds * SOURCE_FPS))
|
||||||
|
context_frames = max(0, round(context_seconds * SOURCE_FPS))
|
||||||
|
rows_by_segment: dict[int, list[Utterance]] = defaultdict(list)
|
||||||
|
clips_by_segment: dict[int, list[Clip]] = defaultdict(list)
|
||||||
|
for row in utterances:
|
||||||
|
rows_by_segment[row.segment_id].append(row)
|
||||||
|
for clip in clips:
|
||||||
|
clips_by_segment[clip.segment_id].append(clip)
|
||||||
|
|
||||||
|
candidates: list[ProtocolCandidate] = []
|
||||||
|
for segment_id, segment_clips in sorted(clips_by_segment.items()):
|
||||||
|
segment_clips.sort(key=lambda clip: clip.dense_start_frame)
|
||||||
|
segment_rows = sorted(
|
||||||
|
rows_by_segment.get(segment_id, []),
|
||||||
|
key=lambda row: (row.dense_start_frame, row.dense_end_frame_exclusive, row.uid),
|
||||||
|
)
|
||||||
|
segment_start = segment_clips[0].dense_start_frame
|
||||||
|
segment_end = segment_clips[-1].dense_start_frame + segment_clips[-1].num_frames
|
||||||
|
wearer, others = _fill_state(
|
||||||
|
segment_rows,
|
||||||
|
segment_start=segment_start,
|
||||||
|
segment_end=segment_end,
|
||||||
|
wearer_id=wearer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(1, len(others)):
|
||||||
|
dense_frame = segment_start + index
|
||||||
|
if others[index - 1] and not others[index] and not wearer[index - 1]:
|
||||||
|
competing = _first_transition(
|
||||||
|
others,
|
||||||
|
start=index + 1,
|
||||||
|
stop=min(len(others), index + window_frames + 1),
|
||||||
|
rising=True,
|
||||||
|
)
|
||||||
|
nominal_end = index + window_frames
|
||||||
|
observable_end = min(nominal_end, len(others))
|
||||||
|
effective_end = min(observable_end, competing if competing is not None else observable_end)
|
||||||
|
outcome = _first_transition(
|
||||||
|
wearer,
|
||||||
|
start=index,
|
||||||
|
stop=effective_end,
|
||||||
|
rising=True,
|
||||||
|
)
|
||||||
|
previous_ids = _active_other_ids(
|
||||||
|
segment_rows, dense_frame - 1, wearer_id=wearer_id
|
||||||
|
)
|
||||||
|
incoming_ids = (
|
||||||
|
()
|
||||||
|
if competing is None
|
||||||
|
else _active_other_ids(
|
||||||
|
segment_rows, segment_start + competing, wearer_id=wearer_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if outcome is not None:
|
||||||
|
label = "positive"
|
||||||
|
subtype = "not_applicable"
|
||||||
|
end_index = outcome
|
||||||
|
end_reason = "wearer_onset"
|
||||||
|
reason = "wearer onset observed after other offset and before competing other onset"
|
||||||
|
elif competing is not None:
|
||||||
|
label = "hard_negative"
|
||||||
|
subtype = (
|
||||||
|
"eot_midturn_pause"
|
||||||
|
if set(previous_ids) & set(incoming_ids)
|
||||||
|
else "eot_other_to_other"
|
||||||
|
)
|
||||||
|
end_index = competing
|
||||||
|
end_reason = "competing_other_onset"
|
||||||
|
reason = "wearer remains silent until another non-wearer onset"
|
||||||
|
elif nominal_end <= len(others):
|
||||||
|
label = "hard_negative"
|
||||||
|
subtype = "eot_no_response"
|
||||||
|
end_index = nominal_end
|
||||||
|
end_reason = "full_window"
|
||||||
|
reason = "wearer remains silent for the full observable response window"
|
||||||
|
else:
|
||||||
|
label = "exclude"
|
||||||
|
subtype = "other"
|
||||||
|
end_index = len(others)
|
||||||
|
end_reason = "right_censored"
|
||||||
|
reason = "recording segment ends before a wearer outcome or full window"
|
||||||
|
candidates.append(_candidate(
|
||||||
|
session=session,
|
||||||
|
task="eot_response",
|
||||||
|
automatic_protocol_label=label,
|
||||||
|
automatic_hard_negative_type=subtype,
|
||||||
|
anchor_frame=dense_frame,
|
||||||
|
behavior_frame=None if outcome is None else segment_start + outcome,
|
||||||
|
region_end_frame=segment_start + end_index,
|
||||||
|
region_end_reason=end_reason,
|
||||||
|
previous_ids=previous_ids,
|
||||||
|
incoming_ids=incoming_ids,
|
||||||
|
clips=segment_clips,
|
||||||
|
rows=segment_rows,
|
||||||
|
wearer_id=wearer_id,
|
||||||
|
context_frames=context_frames,
|
||||||
|
reason=reason,
|
||||||
|
))
|
||||||
|
|
||||||
|
if not others[index - 1] and others[index] and wearer[index - 1]:
|
||||||
|
competing = _first_transition(
|
||||||
|
others,
|
||||||
|
start=index + 1,
|
||||||
|
stop=min(len(others), index + window_frames + 1),
|
||||||
|
rising=False,
|
||||||
|
)
|
||||||
|
nominal_end = index + window_frames
|
||||||
|
observable_end = min(nominal_end, len(others))
|
||||||
|
effective_end = min(observable_end, competing if competing is not None else observable_end)
|
||||||
|
outcome = _first_transition(
|
||||||
|
wearer,
|
||||||
|
start=index,
|
||||||
|
stop=effective_end,
|
||||||
|
rising=False,
|
||||||
|
)
|
||||||
|
previous_ids = _active_other_ids(
|
||||||
|
segment_rows, dense_frame - 1, wearer_id=wearer_id
|
||||||
|
)
|
||||||
|
incoming_ids = _active_other_ids(
|
||||||
|
segment_rows, dense_frame, wearer_id=wearer_id
|
||||||
|
)
|
||||||
|
if outcome is not None:
|
||||||
|
label = "positive"
|
||||||
|
subtype = "not_applicable"
|
||||||
|
end_index = outcome
|
||||||
|
end_reason = "wearer_offset"
|
||||||
|
reason = "wearer offset observed after other onset and before competing other offset"
|
||||||
|
elif competing is not None:
|
||||||
|
label = "hard_negative"
|
||||||
|
subtype = (
|
||||||
|
"int_backchannel"
|
||||||
|
if _incoming_is_backchannel(
|
||||||
|
segment_rows,
|
||||||
|
dense_frame=dense_frame,
|
||||||
|
wearer_id=wearer_id,
|
||||||
|
max_seconds=1.0,
|
||||||
|
)
|
||||||
|
else "int_non_takeover_overlap"
|
||||||
|
)
|
||||||
|
end_index = competing
|
||||||
|
end_reason = "competing_other_offset"
|
||||||
|
reason = "wearer retains the floor until the incoming other activity ends"
|
||||||
|
elif nominal_end <= len(others):
|
||||||
|
label = "hard_negative"
|
||||||
|
subtype = "int_retain_floor"
|
||||||
|
end_index = nominal_end
|
||||||
|
end_reason = "full_window"
|
||||||
|
reason = "wearer remains active through the full observable yield window"
|
||||||
|
else:
|
||||||
|
label = "exclude"
|
||||||
|
subtype = "other"
|
||||||
|
end_index = len(others)
|
||||||
|
end_reason = "right_censored"
|
||||||
|
reason = "recording segment ends before a wearer outcome or full window"
|
||||||
|
candidates.append(_candidate(
|
||||||
|
session=session,
|
||||||
|
task="int_yield",
|
||||||
|
automatic_protocol_label=label,
|
||||||
|
automatic_hard_negative_type=subtype,
|
||||||
|
anchor_frame=dense_frame,
|
||||||
|
behavior_frame=None if outcome is None else segment_start + outcome,
|
||||||
|
region_end_frame=segment_start + end_index,
|
||||||
|
region_end_reason=end_reason,
|
||||||
|
previous_ids=previous_ids,
|
||||||
|
incoming_ids=incoming_ids,
|
||||||
|
clips=segment_clips,
|
||||||
|
rows=segment_rows,
|
||||||
|
wearer_id=wearer_id,
|
||||||
|
context_frames=context_frames,
|
||||||
|
reason=reason,
|
||||||
|
))
|
||||||
|
|
||||||
|
return sorted(candidates, key=lambda row: (row.anchor_dense_frame, row.task, row.uid))
|
||||||
|
|
||||||
|
|
||||||
|
def stratified_limit(
|
||||||
|
rows: Sequence[ProtocolCandidate],
|
||||||
|
limit: int | None,
|
||||||
|
*,
|
||||||
|
seed: int,
|
||||||
|
) -> list[ProtocolCandidate]:
|
||||||
|
if limit is None or limit >= len(rows):
|
||||||
|
return list(rows)
|
||||||
|
if limit < 0:
|
||||||
|
raise ValueError("--max-candidates must be non-negative")
|
||||||
|
rng = random.Random(seed)
|
||||||
|
buckets: dict[tuple[str, str, str], list[ProtocolCandidate]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
buckets[(row.task, row.automatic_protocol_label, row.automatic_hard_negative_type)].append(row)
|
||||||
|
keys = sorted(buckets)
|
||||||
|
for key in keys:
|
||||||
|
rng.shuffle(buckets[key])
|
||||||
|
rng.shuffle(keys)
|
||||||
|
selected: list[ProtocolCandidate] = []
|
||||||
|
while len(selected) < limit:
|
||||||
|
progressed = False
|
||||||
|
for key in keys:
|
||||||
|
if buckets[key]:
|
||||||
|
selected.append(buckets[key].pop())
|
||||||
|
progressed = True
|
||||||
|
if len(selected) == limit:
|
||||||
|
break
|
||||||
|
if not progressed:
|
||||||
|
break
|
||||||
|
return sorted(selected, key=lambda row: (row.session, row.anchor_dense_frame, row.task))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_jsonl(path: Path, rows: Iterable[ProtocolCandidate]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(json.dumps(asdict(row), ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
|
||||||
|
parser.add_argument("--camera-wearer-id", type=int, default=CAMERA_WEARER_ID)
|
||||||
|
parser.add_argument("--window-seconds", type=float, default=DEFAULT_WINDOW_SECONDS)
|
||||||
|
parser.add_argument("--context-seconds", type=float, default=3.0)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tasks",
|
||||||
|
nargs="+",
|
||||||
|
choices=("eot_response", "int_yield"),
|
||||||
|
default=("eot_response", "int_yield"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--automatic-labels",
|
||||||
|
nargs="+",
|
||||||
|
choices=("positive", "hard_negative", "exclude"),
|
||||||
|
default=("positive", "hard_negative", "exclude"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--max-candidates", type=int)
|
||||||
|
parser.add_argument("--selection-seed", type=int, default=2027)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
|
||||||
|
dataset_root = args.dataset_root.expanduser().resolve()
|
||||||
|
output_dir = args.output_dir.expanduser().resolve()
|
||||||
|
all_rows: list[ProtocolCandidate] = []
|
||||||
|
rules = Rules()
|
||||||
|
for session in args.sessions:
|
||||||
|
clips = build_clips(dataset_root, session, rules)
|
||||||
|
utterances = load_utterances(session, clips)
|
||||||
|
rows = derive_protocol_candidates(
|
||||||
|
utterances,
|
||||||
|
clips,
|
||||||
|
session=session,
|
||||||
|
wearer_id=args.camera_wearer_id,
|
||||||
|
window_seconds=args.window_seconds,
|
||||||
|
context_seconds=args.context_seconds,
|
||||||
|
)
|
||||||
|
all_rows.extend(rows)
|
||||||
|
counts = Counter((row.task, row.automatic_protocol_label) for row in rows)
|
||||||
|
print(f"{session}: candidates={len(rows)} counts={dict(sorted(counts.items()))}")
|
||||||
|
|
||||||
|
eligible = [
|
||||||
|
row
|
||||||
|
for row in all_rows
|
||||||
|
if row.task in set(args.tasks)
|
||||||
|
and row.automatic_protocol_label in set(args.automatic_labels)
|
||||||
|
]
|
||||||
|
selected = stratified_limit(eligible, args.max_candidates, seed=args.selection_seed)
|
||||||
|
output_path = output_dir / "easycom_eot_int_candidates.jsonl"
|
||||||
|
_write_jsonl(output_path, selected)
|
||||||
|
counts = Counter(
|
||||||
|
(row.split, row.task, row.automatic_protocol_label, row.automatic_hard_negative_type)
|
||||||
|
for row in selected
|
||||||
|
)
|
||||||
|
summary = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"protocol_version": PROTOCOL_VERSION,
|
||||||
|
"dataset": "EasyCom",
|
||||||
|
"semantic_warning": (
|
||||||
|
"Automatic labels are timing candidates for independent human audit; "
|
||||||
|
"they are not semantic EOT/INT ground truth."
|
||||||
|
),
|
||||||
|
"sessions": list(args.sessions),
|
||||||
|
"camera_wearer_id": args.camera_wearer_id,
|
||||||
|
"window_seconds": args.window_seconds,
|
||||||
|
"context_seconds": args.context_seconds,
|
||||||
|
"selection_seed": args.selection_seed,
|
||||||
|
"max_candidates": args.max_candidates,
|
||||||
|
"tasks": list(args.tasks),
|
||||||
|
"automatic_labels": list(args.automatic_labels),
|
||||||
|
"num_candidates_before_filter": len(all_rows),
|
||||||
|
"num_candidates_before_limit": len(eligible),
|
||||||
|
"num_candidates_written": len(selected),
|
||||||
|
"counts": [
|
||||||
|
{
|
||||||
|
"split": key[0],
|
||||||
|
"task": key[1],
|
||||||
|
"automatic_protocol_label": key[2],
|
||||||
|
"automatic_hard_negative_type": key[3],
|
||||||
|
"count": value,
|
||||||
|
}
|
||||||
|
for key, value in sorted(counts.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(output_dir / "summary.json").write_text(
|
||||||
|
json.dumps(summary, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"Saved {len(selected)} manuscript-aligned review candidates to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
793
review_floor_events.py
Normal file
793
review_floor_events.py
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Serve a local, event-centered EasyCom annotation review workspace.
|
||||||
|
|
||||||
|
The server reads JSONL candidates produced by
|
||||||
|
``derive_easycom_interaction_events.py`` or ``audit_easycom_floor_events.py``.
|
||||||
|
It serves only media inside the supplied EasyCom dataset root and stores human
|
||||||
|
reviews outside the source dataset as an atomically rewritten JSONL file plus
|
||||||
|
a CSV companion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
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 = 2
|
||||||
|
MAX_REQUEST_BYTES = 128 * 1024
|
||||||
|
|
||||||
|
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude", "uncertain"}
|
||||||
|
HARD_NEGATIVE_TYPE_VALUES = {
|
||||||
|
"eot_midturn_pause",
|
||||||
|
"eot_other_to_other",
|
||||||
|
"eot_no_response",
|
||||||
|
"int_backchannel",
|
||||||
|
"int_non_takeover_overlap",
|
||||||
|
"int_retain_floor",
|
||||||
|
"other",
|
||||||
|
"not_applicable",
|
||||||
|
"uncertain",
|
||||||
|
}
|
||||||
|
VALIDITY_VALUES = {"valid", "artifact", "uncertain"}
|
||||||
|
ONSET_FUNCTION_VALUES = {
|
||||||
|
"floor_taking",
|
||||||
|
"backchannel",
|
||||||
|
"within_turn_resume",
|
||||||
|
"nonverbal",
|
||||||
|
"not_applicable",
|
||||||
|
"uncertain",
|
||||||
|
}
|
||||||
|
CONTEXT_VALUES = {"addressed_wearer", "third_party", "group", "unknown"}
|
||||||
|
OFFSET_OUTCOME_VALUES = {
|
||||||
|
"yield_to_other",
|
||||||
|
"completion_to_silence",
|
||||||
|
"temporary_pause",
|
||||||
|
"backchannel_end",
|
||||||
|
"cutoff",
|
||||||
|
"not_applicable",
|
||||||
|
"uncertain",
|
||||||
|
}
|
||||||
|
CONFIDENCE_VALUES = {"high", "medium", "low"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
candidate_id: str
|
||||||
|
source_path: Path
|
||||||
|
source_row: int
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uid(self) -> str:
|
||||||
|
return str(
|
||||||
|
self.payload.get("uid")
|
||||||
|
or self.payload.get("holder_uid")
|
||||||
|
or f"{self.source_path.name}:{self.source_row}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> str:
|
||||||
|
return str(self.payload.get("session", "unknown"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def split(self) -> str:
|
||||||
|
return str(self.payload.get("split", "unknown"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def automatic_label(self) -> str:
|
||||||
|
return str(
|
||||||
|
self.payload.get("event_type")
|
||||||
|
or self.payload.get("category")
|
||||||
|
or self.payload.get("proxy_group")
|
||||||
|
or "unclassified"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task(self) -> str | None:
|
||||||
|
value = self.payload.get("task")
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family(self) -> str:
|
||||||
|
if self.task == "eot_response":
|
||||||
|
return "eot"
|
||||||
|
if self.task == "int_yield":
|
||||||
|
return "int"
|
||||||
|
if "holder_uid" in self.payload or self.automatic_label.startswith("eot_"):
|
||||||
|
return "offset"
|
||||||
|
if self.payload.get("participant_role") == "wearer":
|
||||||
|
return "onset"
|
||||||
|
return "interaction"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> Any:
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return str(value)
|
||||||
|
raise TypeError(f"Cannot serialize {type(value).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line_number, raw_line in enumerate(handle, 1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise ValueError(f"Invalid JSON at {path}:{line_number}: {error}") from error
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ValueError(f"Expected a JSON object at {path}:{line_number}")
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def expand_candidate_paths(values: Iterable[Path], *, output_path: Path) -> list[Path]:
|
||||||
|
paths: list[Path] = []
|
||||||
|
output_resolved = output_path.expanduser().resolve()
|
||||||
|
for raw_path in values:
|
||||||
|
path = raw_path.expanduser().resolve()
|
||||||
|
if path.is_dir():
|
||||||
|
paths.extend(sorted(path.glob("Session_*.events.jsonl")))
|
||||||
|
paths.extend(sorted(path.glob("easycom_*_candidates.jsonl")))
|
||||||
|
elif path.is_file():
|
||||||
|
paths.append(path)
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
unique: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for path in paths:
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved == output_resolved or resolved in seen:
|
||||||
|
continue
|
||||||
|
if resolved.suffix.lower() != ".jsonl":
|
||||||
|
raise ValueError(f"Candidate input must be JSONL: {resolved}")
|
||||||
|
seen.add(resolved)
|
||||||
|
unique.append(resolved)
|
||||||
|
if not unique:
|
||||||
|
raise FileNotFoundError("No candidate JSONL files found")
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_digest(path: Path, source_row: int, payload: dict[str, Any]) -> str:
|
||||||
|
identity = {
|
||||||
|
"source_name": path.name,
|
||||||
|
"source_row": source_row,
|
||||||
|
"uid": payload.get("uid") or payload.get("holder_uid"),
|
||||||
|
"session": payload.get("session"),
|
||||||
|
"event_type": payload.get("event_type"),
|
||||||
|
"task": payload.get("task"),
|
||||||
|
"category": payload.get("category"),
|
||||||
|
"dense_start_seconds": payload.get("dense_start_seconds"),
|
||||||
|
"anchor_dense_seconds": payload.get("anchor_dense_seconds"),
|
||||||
|
"boundary_seconds": payload.get("boundary_seconds"),
|
||||||
|
}
|
||||||
|
encoded = json.dumps(identity, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()[:20]
|
||||||
|
|
||||||
|
|
||||||
|
def load_candidates(paths: Iterable[Path]) -> list[Candidate]:
|
||||||
|
candidates: list[Candidate] = []
|
||||||
|
ids: set[str] = set()
|
||||||
|
for path in paths:
|
||||||
|
for source_row, payload in enumerate(_read_jsonl(path), 1):
|
||||||
|
candidate_id = _candidate_digest(path, source_row, payload)
|
||||||
|
if candidate_id in ids:
|
||||||
|
raise ValueError(f"Duplicate candidate identity in {path}:{source_row}")
|
||||||
|
ids.add(candidate_id)
|
||||||
|
candidates.append(Candidate(candidate_id, path, source_row, payload))
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("Candidate files contain no rows")
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_parts(value: Any) -> tuple[str, str, int] | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
parts = value.split("/")
|
||||||
|
if len(parts) < 3 or not parts[-1].isdigit():
|
||||||
|
return None
|
||||||
|
return parts[-3], parts[-2], int(parts[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _within_root(path: Path, root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(root.resolve())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
candidates: list[Candidate],
|
||||||
|
dataset_root: Path,
|
||||||
|
*,
|
||||||
|
context_before_seconds: float,
|
||||||
|
context_after_seconds: float,
|
||||||
|
) -> None:
|
||||||
|
self.candidates = candidates
|
||||||
|
self.by_id = {candidate.candidate_id: candidate for candidate in candidates}
|
||||||
|
self.dataset_root = dataset_root.expanduser().resolve()
|
||||||
|
self.context_before_seconds = context_before_seconds
|
||||||
|
self.context_after_seconds = context_after_seconds
|
||||||
|
self._transcript_cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
def _transcript(self, session: str, clip_stem: str) -> list[dict[str, Any]]:
|
||||||
|
key = (session, clip_stem)
|
||||||
|
if key not in self._transcript_cache:
|
||||||
|
path = self.dataset_root.joinpath(
|
||||||
|
*TRANSCRIPT_ROOT_PARTS, session, f"{clip_stem}.json"
|
||||||
|
)
|
||||||
|
if not path.is_file() or not _within_root(path, self.dataset_root):
|
||||||
|
self._transcript_cache[key] = []
|
||||||
|
else:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError(f"Expected transcript list: {path}")
|
||||||
|
self._transcript_cache[key] = [
|
||||||
|
row for row in payload if isinstance(row, dict)
|
||||||
|
]
|
||||||
|
return self._transcript_cache[key]
|
||||||
|
|
||||||
|
def _location(self, candidate: Candidate) -> tuple[str | None, float | None, int | None]:
|
||||||
|
row = candidate.payload
|
||||||
|
clip_stem = row.get("clip_stem")
|
||||||
|
transcript_index: int | None = None
|
||||||
|
uid = _uid_parts(row.get("uid")) or _uid_parts(row.get("holder_uid"))
|
||||||
|
if uid is not None:
|
||||||
|
uid_session, uid_stem, transcript_index = uid
|
||||||
|
if uid_session == candidate.session:
|
||||||
|
clip_stem = clip_stem or uid_stem
|
||||||
|
|
||||||
|
local_seconds: float | None = None
|
||||||
|
if row.get("source_start_frame") is not None:
|
||||||
|
local_seconds = max(0.0, (float(row["source_start_frame"]) - 1.0) / SOURCE_FPS)
|
||||||
|
elif clip_stem and transcript_index is not None:
|
||||||
|
transcript = self._transcript(candidate.session, str(clip_stem))
|
||||||
|
if 0 <= transcript_index < len(transcript):
|
||||||
|
source_row = transcript[transcript_index]
|
||||||
|
frame_key = "End_Frame" if candidate.family == "offset" else "Start_Frame"
|
||||||
|
if source_row.get(frame_key) is not None:
|
||||||
|
frame = float(source_row[frame_key])
|
||||||
|
local_seconds = max(
|
||||||
|
0.0,
|
||||||
|
frame / SOURCE_FPS if frame_key == "End_Frame" else (frame - 1.0) / SOURCE_FPS,
|
||||||
|
)
|
||||||
|
return (None if clip_stem is None else str(clip_stem), local_seconds, transcript_index)
|
||||||
|
|
||||||
|
def media_path(self, candidate: Candidate) -> Path | None:
|
||||||
|
clip_stem, _, _ = self._location(candidate)
|
||||||
|
if clip_stem is None:
|
||||||
|
return None
|
||||||
|
path = self.dataset_root.joinpath(
|
||||||
|
*VIDEO_ROOT_PARTS, candidate.session, f"{clip_stem}.mp4"
|
||||||
|
).resolve()
|
||||||
|
if not _within_root(path, self.dataset_root) or not path.is_file():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
def summary(self, candidate: Candidate, reviewed: bool) -> dict[str, Any]:
|
||||||
|
row = candidate.payload
|
||||||
|
event_time = row.get(
|
||||||
|
"anchor_dense_seconds",
|
||||||
|
row.get("dense_start_seconds", row.get("boundary_seconds")),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"candidate_id": candidate.candidate_id,
|
||||||
|
"uid": candidate.uid,
|
||||||
|
"session": candidate.session,
|
||||||
|
"split": candidate.split,
|
||||||
|
"family": candidate.family,
|
||||||
|
"task": candidate.task,
|
||||||
|
"automatic_label": candidate.automatic_label,
|
||||||
|
"automatic_protocol_label": row.get("automatic_protocol_label"),
|
||||||
|
"automatic_hard_negative_type": row.get("automatic_hard_negative_type"),
|
||||||
|
"automatic_confidence": row.get("automatic_confidence", row.get("confidence")),
|
||||||
|
"llm_suggestion": row.get("llm_suggestion"),
|
||||||
|
"participant_role": row.get("participant_role") or row.get("holder_role"),
|
||||||
|
"event_time_seconds": event_time,
|
||||||
|
"reviewed": reviewed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def detail(self, candidate: Candidate) -> dict[str, Any]:
|
||||||
|
clip_stem, local_seconds, transcript_index = self._location(candidate)
|
||||||
|
transcript_rows: list[dict[str, Any]] = []
|
||||||
|
if clip_stem is not None:
|
||||||
|
for index, row in enumerate(self._transcript(candidate.session, clip_stem)):
|
||||||
|
try:
|
||||||
|
start = max(0.0, (float(row.get("Start_Frame", 1)) - 1.0) / SOURCE_FPS)
|
||||||
|
end = max(start, float(row.get("End_Frame", 1)) / SOURCE_FPS)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if local_seconds is not None and (
|
||||||
|
end < local_seconds - self.context_before_seconds
|
||||||
|
or start > local_seconds + self.context_after_seconds
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
transcript_rows.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"start_seconds": start,
|
||||||
|
"end_seconds": end,
|
||||||
|
"participant_id": row.get("Participant_ID"),
|
||||||
|
"target_of_speech": row.get("Target_of_Speech"),
|
||||||
|
"transcription": str(row.get("Transcription", "")),
|
||||||
|
"is_anchor": (
|
||||||
|
index == transcript_index
|
||||||
|
or (
|
||||||
|
transcript_index is None
|
||||||
|
and local_seconds is not None
|
||||||
|
and start <= local_seconds <= end
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
media_path = self.media_path(candidate)
|
||||||
|
return {
|
||||||
|
**self.summary(candidate, reviewed=False),
|
||||||
|
"source_file": candidate.source_path.name,
|
||||||
|
"source_row": candidate.source_row,
|
||||||
|
"clip_stem": clip_stem,
|
||||||
|
"event_local_seconds": local_seconds,
|
||||||
|
"context_start_seconds": (
|
||||||
|
None
|
||||||
|
if local_seconds is None
|
||||||
|
else max(0.0, local_seconds - self.context_before_seconds)
|
||||||
|
),
|
||||||
|
"context_end_seconds": (
|
||||||
|
None
|
||||||
|
if local_seconds is None
|
||||||
|
else local_seconds + self.context_after_seconds
|
||||||
|
),
|
||||||
|
"media_available": media_path is not None,
|
||||||
|
"transcript_available": bool(transcript_rows),
|
||||||
|
"transcript": transcript_rows,
|
||||||
|
"candidate": candidate.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewStore:
|
||||||
|
def __init__(self, output_path: Path, candidates: list[Candidate]) -> None:
|
||||||
|
self.output_path = output_path.expanduser().resolve()
|
||||||
|
self.csv_path = self.output_path.with_suffix(".csv")
|
||||||
|
self.candidates = {candidate.candidate_id: candidate for candidate in candidates}
|
||||||
|
self.order = {candidate.candidate_id: index for index, candidate in enumerate(candidates)}
|
||||||
|
self.lock = Lock()
|
||||||
|
self.reviews: dict[str, dict[str, Any]] = {}
|
||||||
|
if self.output_path.is_file():
|
||||||
|
for row in _read_jsonl(self.output_path):
|
||||||
|
candidate_id = row.get("candidate_id")
|
||||||
|
if isinstance(candidate_id, str) and candidate_id in self.candidates:
|
||||||
|
self.reviews[candidate_id] = row
|
||||||
|
|
||||||
|
def _validated_review(self, candidate_id: str, payload: Any) -> dict[str, Any]:
|
||||||
|
if candidate_id not in self.candidates:
|
||||||
|
raise ValueError("Unknown candidate_id")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("review must be an object")
|
||||||
|
|
||||||
|
required_choices = {
|
||||||
|
"protocol_disposition": PROTOCOL_DISPOSITION_VALUES,
|
||||||
|
"hard_negative_type": HARD_NEGATIVE_TYPE_VALUES,
|
||||||
|
"validity": VALIDITY_VALUES,
|
||||||
|
"onset_function": ONSET_FUNCTION_VALUES,
|
||||||
|
"context": CONTEXT_VALUES,
|
||||||
|
"offset_outcome": OFFSET_OUTCOME_VALUES,
|
||||||
|
"confidence": CONFIDENCE_VALUES,
|
||||||
|
}
|
||||||
|
normalized: dict[str, Any] = {}
|
||||||
|
for key, allowed in required_choices.items():
|
||||||
|
value = payload.get(key)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"Invalid {key}: {value!r}")
|
||||||
|
normalized[key] = value
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"anchor_adjustment_bins",
|
||||||
|
"behavior_adjustment_bins",
|
||||||
|
"region_end_adjustment_bins",
|
||||||
|
):
|
||||||
|
adjustment = payload.get(key)
|
||||||
|
if adjustment not in {-1, 0, 1, None}:
|
||||||
|
raise ValueError(f"{key} must be -1, 0, 1, or null")
|
||||||
|
normalized[key] = adjustment
|
||||||
|
|
||||||
|
reviewer = str(payload.get("reviewer", "")).strip()
|
||||||
|
notes = str(payload.get("notes", "")).strip()
|
||||||
|
if len(reviewer) > 100:
|
||||||
|
raise ValueError("reviewer is too long")
|
||||||
|
if not reviewer:
|
||||||
|
raise ValueError("reviewer is required")
|
||||||
|
if len(notes) > 4000:
|
||||||
|
raise ValueError("notes are too long")
|
||||||
|
normalized["reviewer"] = reviewer
|
||||||
|
normalized["notes"] = notes
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def save(self, candidate_id: str, review: Any) -> dict[str, Any]:
|
||||||
|
normalized = self._validated_review(candidate_id, review)
|
||||||
|
candidate = self.candidates[candidate_id]
|
||||||
|
record = {
|
||||||
|
"schema_version": REVIEW_SCHEMA_VERSION,
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"candidate_uid": candidate.uid,
|
||||||
|
"source_file": candidate.source_path.name,
|
||||||
|
"source_row": candidate.source_row,
|
||||||
|
"session": candidate.session,
|
||||||
|
"split": candidate.split,
|
||||||
|
"candidate_family": candidate.family,
|
||||||
|
"task": candidate.task,
|
||||||
|
"automatic_label": candidate.automatic_label,
|
||||||
|
"automatic_protocol_label": candidate.payload.get("automatic_protocol_label"),
|
||||||
|
"automatic_hard_negative_type": candidate.payload.get(
|
||||||
|
"automatic_hard_negative_type"
|
||||||
|
),
|
||||||
|
"review": normalized,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"candidate": candidate.payload,
|
||||||
|
}
|
||||||
|
with self.lock:
|
||||||
|
self.reviews[candidate_id] = record
|
||||||
|
self._write_locked()
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _ordered(self) -> list[dict[str, Any]]:
|
||||||
|
return sorted(
|
||||||
|
self.reviews.values(),
|
||||||
|
key=lambda row: self.order.get(str(row.get("candidate_id")), 10**12),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _atomic_text(self, path: Path, write: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary_name: str | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w", encoding="utf-8", newline="", dir=path.parent, delete=False
|
||||||
|
) as handle:
|
||||||
|
temporary_name = handle.name
|
||||||
|
write(handle)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary_name, path)
|
||||||
|
finally:
|
||||||
|
if temporary_name is not None and Path(temporary_name).exists():
|
||||||
|
Path(temporary_name).unlink()
|
||||||
|
|
||||||
|
def _write_locked(self) -> None:
|
||||||
|
rows = self._ordered()
|
||||||
|
|
||||||
|
def write_jsonl(handle: Any) -> None:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(json.dumps(row, ensure_ascii=False, default=_json_default) + "\n")
|
||||||
|
|
||||||
|
self._atomic_text(self.output_path, write_jsonl)
|
||||||
|
|
||||||
|
fieldnames = [
|
||||||
|
"candidate_id",
|
||||||
|
"candidate_uid",
|
||||||
|
"source_file",
|
||||||
|
"source_row",
|
||||||
|
"session",
|
||||||
|
"split",
|
||||||
|
"candidate_family",
|
||||||
|
"task",
|
||||||
|
"automatic_label",
|
||||||
|
"automatic_protocol_label",
|
||||||
|
"automatic_hard_negative_type",
|
||||||
|
"protocol_disposition",
|
||||||
|
"hard_negative_type",
|
||||||
|
"validity",
|
||||||
|
"onset_function",
|
||||||
|
"context",
|
||||||
|
"offset_outcome",
|
||||||
|
"anchor_adjustment_bins",
|
||||||
|
"behavior_adjustment_bins",
|
||||||
|
"region_end_adjustment_bins",
|
||||||
|
"confidence",
|
||||||
|
"reviewer",
|
||||||
|
"notes",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
def write_csv(handle: Any) -> None:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
review = row["review"]
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
key: review.get(key) if key in review else row.get(key)
|
||||||
|
for key in fieldnames
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self._atomic_text(self.csv_path, write_csv)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_byte_range(value: str | None, size: int) -> tuple[int, int] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("Invalid Range header")
|
||||||
|
start_text, end_text = match.groups()
|
||||||
|
if not start_text and not end_text:
|
||||||
|
raise ValueError("Invalid empty Range header")
|
||||||
|
if not start_text:
|
||||||
|
suffix = int(end_text)
|
||||||
|
if suffix <= 0:
|
||||||
|
raise ValueError("Invalid suffix Range")
|
||||||
|
return max(0, size - suffix), size - 1
|
||||||
|
start = int(start_text)
|
||||||
|
end = size - 1 if not end_text else int(end_text)
|
||||||
|
if start >= size or start < 0 or end < start:
|
||||||
|
raise ValueError("Unsatisfiable Range")
|
||||||
|
return start, min(end, size - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
address: tuple[str, int],
|
||||||
|
repository: CandidateRepository,
|
||||||
|
store: ReviewStore,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(address, ReviewRequestHandler)
|
||||||
|
self.repository = repository
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server: ReviewHTTPServer
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
print(f"[{self.log_date_time_string()}] {format % args}")
|
||||||
|
|
||||||
|
def _json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, default=_json_default).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _error(self, message: str, status: int = HTTPStatus.BAD_REQUEST) -> None:
|
||||||
|
self._json({"error": message}, status)
|
||||||
|
|
||||||
|
def _candidate_from_query(self, query: dict[str, list[str]]) -> Candidate | None:
|
||||||
|
candidate_id = query.get("id", [""])[0]
|
||||||
|
return self.server.repository.by_id.get(candidate_id)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
if parsed.path == "/api/bootstrap":
|
||||||
|
store = self.server.store
|
||||||
|
repository = self.server.repository
|
||||||
|
self._json(
|
||||||
|
{
|
||||||
|
"schema_version": REVIEW_SCHEMA_VERSION,
|
||||||
|
"candidate_count": len(repository.candidates),
|
||||||
|
"reviewed_count": len(store.reviews),
|
||||||
|
"output_name": store.output_path.name,
|
||||||
|
"candidates": [
|
||||||
|
repository.summary(
|
||||||
|
candidate, candidate.candidate_id in store.reviews
|
||||||
|
)
|
||||||
|
for candidate in repository.candidates
|
||||||
|
],
|
||||||
|
"reviews": {
|
||||||
|
candidate_id: row["review"]
|
||||||
|
for candidate_id, row in store.reviews.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/event":
|
||||||
|
candidate = self._candidate_from_query(query)
|
||||||
|
if candidate is None:
|
||||||
|
self._error("Unknown candidate", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
detail = self.server.repository.detail(candidate)
|
||||||
|
detail["review"] = self.server.store.reviews.get(candidate.candidate_id, {}).get(
|
||||||
|
"review"
|
||||||
|
)
|
||||||
|
self._json(detail)
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/export.jsonl":
|
||||||
|
self._send_download(self.server.store.output_path, "application/x-ndjson")
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/export.csv":
|
||||||
|
self._send_download(self.server.store.csv_path, "text/csv; charset=utf-8")
|
||||||
|
return
|
||||||
|
if parsed.path == "/media":
|
||||||
|
candidate = self._candidate_from_query(query)
|
||||||
|
if candidate is None:
|
||||||
|
self._error("Unknown candidate", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
media_path = self.server.repository.media_path(candidate)
|
||||||
|
if media_path is None:
|
||||||
|
self._error("Media unavailable", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
self._send_media(media_path)
|
||||||
|
return
|
||||||
|
self._send_static(parsed.path)
|
||||||
|
|
||||||
|
def do_HEAD(self) -> None:
|
||||||
|
self.do_GET()
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path != "/api/review":
|
||||||
|
self._error("Unknown endpoint", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
self._error("Invalid Content-Length")
|
||||||
|
return
|
||||||
|
if length <= 0 or length > MAX_REQUEST_BYTES:
|
||||||
|
self._error("Invalid request size")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Payload must be an object")
|
||||||
|
candidate_id = str(payload.get("candidate_id", ""))
|
||||||
|
record = self.server.store.save(candidate_id, payload.get("review"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error:
|
||||||
|
self._error(str(error))
|
||||||
|
return
|
||||||
|
self._json({"saved": True, "record": record})
|
||||||
|
|
||||||
|
def _send_static(self, request_path: str) -> None:
|
||||||
|
name = "index.html" if request_path in {"", "/"} else request_path.lstrip("/")
|
||||||
|
if name not in {"index.html", "app.css", "app.js"}:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
path = (STATIC_ROOT / name).resolve()
|
||||||
|
if not _within_root(path, STATIC_ROOT) or not path.is_file():
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
body = path.read_bytes()
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_download(self, path: Path, content_type: str) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
self._error("No reviews have been saved yet", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
body = path.read_bytes()
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_media(self, path: Path) -> None:
|
||||||
|
size = path.stat().st_size
|
||||||
|
try:
|
||||||
|
byte_range = parse_byte_range(self.headers.get("Range"), size)
|
||||||
|
except ValueError:
|
||||||
|
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||||
|
self.send_header("Content-Range", f"bytes */{size}")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
start, end = byte_range or (0, size - 1)
|
||||||
|
status = HTTPStatus.PARTIAL_CONTENT if byte_range is not None else HTTPStatus.OK
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", mimetypes.guess_type(path.name)[0] or "video/mp4")
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
self.send_header("Content-Length", str(end - start + 1))
|
||||||
|
if byte_range is not None:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||||
|
self.end_headers()
|
||||||
|
if self.command == "HEAD":
|
||||||
|
return
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(start)
|
||||||
|
remaining = end - start + 1
|
||||||
|
while remaining:
|
||||||
|
chunk = handle.read(min(64 * 1024, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--events",
|
||||||
|
type=Path,
|
||||||
|
nargs="+",
|
||||||
|
required=True,
|
||||||
|
help="Candidate JSONL files or directories containing generated candidates.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("reports/easycom_event_reviews/reviews.jsonl"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--context-before-seconds", type=float, default=3.0)
|
||||||
|
parser.add_argument("--context-after-seconds", type=float, default=3.0)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
if not STATIC_ROOT.is_dir():
|
||||||
|
raise FileNotFoundError(f"Review UI assets are missing: {STATIC_ROOT}")
|
||||||
|
dataset_root = args.dataset_root.expanduser().resolve()
|
||||||
|
if not dataset_root.is_dir():
|
||||||
|
raise FileNotFoundError(dataset_root)
|
||||||
|
if not 1 <= args.port <= 65535:
|
||||||
|
raise ValueError("--port must be in [1, 65535]")
|
||||||
|
if args.context_before_seconds < 0 or args.context_after_seconds < 0:
|
||||||
|
raise ValueError("Context durations must be non-negative")
|
||||||
|
|
||||||
|
output_path = args.output.expanduser().resolve()
|
||||||
|
candidate_paths = expand_candidate_paths(args.events, output_path=output_path)
|
||||||
|
candidates = load_candidates(candidate_paths)
|
||||||
|
repository = CandidateRepository(
|
||||||
|
candidates,
|
||||||
|
dataset_root,
|
||||||
|
context_before_seconds=args.context_before_seconds,
|
||||||
|
context_after_seconds=args.context_after_seconds,
|
||||||
|
)
|
||||||
|
store = ReviewStore(output_path, candidates)
|
||||||
|
server = ReviewHTTPServer((args.host, args.port), repository, store)
|
||||||
|
actual_host, actual_port = server.server_address[:2]
|
||||||
|
print(f"Loaded {len(candidates)} candidates from {len(candidate_paths)} JSONL file(s)")
|
||||||
|
print(f"Resuming {len(store.reviews)} saved reviews")
|
||||||
|
print(f"Review workspace: http://{actual_host}:{actual_port}")
|
||||||
|
print(f"Review output: {store.output_path}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopping review workspace")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
38
test_adjudicate_easycom_eot_int_candidates.py
Normal file
38
test_adjudicate_easycom_eot_int_candidates.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests for EOT/INT DeepSeek suggestion validation without network calls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from adjudicate_easycom_eot_int_candidates import build_user_prompt, validate_decisions
|
||||||
|
|
||||||
|
|
||||||
|
class EOTINTAdjudicationTests(unittest.TestCase):
|
||||||
|
def test_prompt_contains_task_and_context(self) -> None:
|
||||||
|
prompt = build_user_prompt(
|
||||||
|
[{"uid": "u1", "task": "eot_response", "context": [{"transcription": "Yes."}]}],
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.assertIn("eot_response", prompt)
|
||||||
|
self.assertIn("Yes.", prompt)
|
||||||
|
|
||||||
|
def test_decisions_require_exact_uid_and_vocabulary(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"protocol_disposition": "hard_negative",
|
||||||
|
"hard_negative_type": "int_backchannel",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": "Brief feedback while the wearer continues.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
decisions = validate_decisions(payload, ["u1"])
|
||||||
|
self.assertEqual(decisions[0]["hard_negative_type"], "int_backchannel")
|
||||||
|
with self.assertRaisesRegex(ValueError, "mismatch"):
|
||||||
|
validate_decisions({"items": []}, ["u1"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
115
test_prepare_easycom_eot_int_review.py
Normal file
115
test_prepare_easycom_eot_int_review.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Tests for manuscript-aligned EasyCom EOT/INT review regions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from derive_easycom_interaction_events import Clip, Utterance
|
||||||
|
from prepare_easycom_eot_int_review import derive_protocol_candidates
|
||||||
|
|
||||||
|
|
||||||
|
def utterance(uid: str, participant: int, start: float, end: float, text: str) -> Utterance:
|
||||||
|
return Utterance(
|
||||||
|
uid=uid,
|
||||||
|
session="Session_4",
|
||||||
|
clip_stem="00-00-000",
|
||||||
|
clip_index=0,
|
||||||
|
segment_id=0,
|
||||||
|
participant_id=participant,
|
||||||
|
target_of_speech=("Group",),
|
||||||
|
transcription=text,
|
||||||
|
source_start_frame=round(start * 20) + 1,
|
||||||
|
source_end_frame=round(end * 20),
|
||||||
|
dense_start_frame=round(start * 20),
|
||||||
|
dense_end_frame_exclusive=round(end * 20),
|
||||||
|
dense_start_seconds=start,
|
||||||
|
dense_end_seconds=end,
|
||||||
|
duration_seconds=end - start,
|
||||||
|
starts_at_clip_boundary=start == 0,
|
||||||
|
ends_at_clip_boundary=False,
|
||||||
|
annotation_end_frame_clamped_by=0,
|
||||||
|
is_cross_clip_continuation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clip(seconds: float = 5.0) -> Clip:
|
||||||
|
return Clip(
|
||||||
|
path="/dataset/Main/Video_Compressed/Session_4/00-00-000.mp4",
|
||||||
|
transcript_path="/dataset/Main/Speech_Transcriptions/Session_4/00-00-000.json",
|
||||||
|
stem="00-00-000",
|
||||||
|
source_start_seconds=0.0,
|
||||||
|
source_end_seconds=seconds,
|
||||||
|
dense_start_frame=0,
|
||||||
|
num_frames=round(seconds * 20),
|
||||||
|
segment_id=0,
|
||||||
|
discontinuity_before_seconds=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EOTINTReviewCandidateTests(unittest.TestCase):
|
||||||
|
def derive(self, rows: list[Utterance]):
|
||||||
|
return derive_protocol_candidates(rows, [clip()], session="Session_4")
|
||||||
|
|
||||||
|
def test_eot_response_is_anchored_by_wearer_onset(self) -> None:
|
||||||
|
rows = [
|
||||||
|
utterance("other", 4, 0.0, 1.0, "Your turn."),
|
||||||
|
utterance("wearer", 2, 1.2, 2.0, "My response."),
|
||||||
|
]
|
||||||
|
event = next(row for row in self.derive(rows) if row.task == "eot_response")
|
||||||
|
self.assertEqual(event.automatic_protocol_label, "positive")
|
||||||
|
self.assertEqual(event.anchor_type, "other_offset")
|
||||||
|
self.assertEqual(event.behavior_anchor_type, "wearer_onset")
|
||||||
|
self.assertAlmostEqual(event.anchor_dense_seconds, 1.0)
|
||||||
|
self.assertAlmostEqual(event.behavior_seconds or -1, 1.2)
|
||||||
|
|
||||||
|
def test_same_other_resume_is_eot_midturn_hard_negative(self) -> None:
|
||||||
|
rows = [
|
||||||
|
utterance("first", 4, 0.0, 1.0, "First clause."),
|
||||||
|
utterance("resume", 4, 1.3, 2.5, "Same turn continues."),
|
||||||
|
]
|
||||||
|
event = next(
|
||||||
|
row
|
||||||
|
for row in self.derive(rows)
|
||||||
|
if row.task == "eot_response" and row.anchor_dense_seconds == 1.0
|
||||||
|
)
|
||||||
|
self.assertEqual(event.automatic_protocol_label, "hard_negative")
|
||||||
|
self.assertEqual(event.automatic_hard_negative_type, "eot_midturn_pause")
|
||||||
|
self.assertEqual(event.region_end_reason, "competing_other_onset")
|
||||||
|
|
||||||
|
def test_other_to_other_transfer_is_eot_hard_negative(self) -> None:
|
||||||
|
rows = [
|
||||||
|
utterance("first", 4, 0.0, 1.0, "First speaker."),
|
||||||
|
utterance("next", 3, 1.2, 2.5, "Second speaker."),
|
||||||
|
]
|
||||||
|
event = next(
|
||||||
|
row
|
||||||
|
for row in self.derive(rows)
|
||||||
|
if row.task == "eot_response" and row.anchor_dense_seconds == 1.0
|
||||||
|
)
|
||||||
|
self.assertEqual(event.automatic_protocol_label, "hard_negative")
|
||||||
|
self.assertEqual(event.automatic_hard_negative_type, "eot_other_to_other")
|
||||||
|
|
||||||
|
def test_int_yield_is_anchored_by_wearer_offset(self) -> None:
|
||||||
|
rows = [
|
||||||
|
utterance("wearer", 2, 0.0, 2.0, "Wearer is speaking."),
|
||||||
|
utterance("other", 4, 1.0, 3.0, "I will take over now."),
|
||||||
|
]
|
||||||
|
event = next(row for row in self.derive(rows) if row.task == "int_yield")
|
||||||
|
self.assertEqual(event.automatic_protocol_label, "positive")
|
||||||
|
self.assertEqual(event.anchor_type, "other_onset")
|
||||||
|
self.assertEqual(event.behavior_anchor_type, "wearer_offset")
|
||||||
|
self.assertAlmostEqual(event.behavior_seconds or -1, 2.0)
|
||||||
|
|
||||||
|
def test_brief_feedback_is_int_backchannel_hard_negative(self) -> None:
|
||||||
|
rows = [
|
||||||
|
utterance("wearer", 2, 0.0, 3.0, "Wearer keeps the floor."),
|
||||||
|
utterance("other", 4, 1.0, 1.4, "Yeah."),
|
||||||
|
]
|
||||||
|
event = next(row for row in self.derive(rows) if row.task == "int_yield")
|
||||||
|
self.assertEqual(event.automatic_protocol_label, "hard_negative")
|
||||||
|
self.assertEqual(event.automatic_hard_negative_type, "int_backchannel")
|
||||||
|
self.assertEqual(event.region_end_reason, "competing_other_offset")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
134
test_review_floor_events.py
Normal file
134
test_review_floor_events.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from review_floor_events import (
|
||||||
|
CandidateRepository,
|
||||||
|
ReviewStore,
|
||||||
|
expand_candidate_paths,
|
||||||
|
load_candidates,
|
||||||
|
parse_byte_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FloorEventReviewTests(unittest.TestCase):
|
||||||
|
def _fixture(self, root: Path) -> tuple[Path, Path]:
|
||||||
|
dataset = root / "dataset"
|
||||||
|
video_dir = dataset / "Main" / "Video_Compressed" / "Session_4"
|
||||||
|
transcript_dir = dataset / "Main" / "Speech_Transcriptions" / "Session_4"
|
||||||
|
video_dir.mkdir(parents=True)
|
||||||
|
transcript_dir.mkdir(parents=True)
|
||||||
|
(video_dir / "00-00-000.mp4").write_bytes(b"mock-video")
|
||||||
|
(transcript_dir / "00-00-000.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Start_Frame": 1,
|
||||||
|
"End_Frame": 20,
|
||||||
|
"Participant_ID": 4,
|
||||||
|
"Transcription": "Are you coming?",
|
||||||
|
"Target_of_Speech": [2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Start_Frame": 25,
|
||||||
|
"End_Frame": 40,
|
||||||
|
"Participant_ID": 2,
|
||||||
|
"Transcription": "Yes.",
|
||||||
|
"Target_of_Speech": [4],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
events = root / "candidates.jsonl"
|
||||||
|
events.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"uid": "Session_4/00-00-000/0001",
|
||||||
|
"session": "Session_4",
|
||||||
|
"split": "train",
|
||||||
|
"event_type": "ordinary_switch",
|
||||||
|
"confidence": "timing",
|
||||||
|
"participant_role": "wearer",
|
||||||
|
"clip_stem": "00-00-000",
|
||||||
|
"source_start_frame": 25,
|
||||||
|
"dense_start_seconds": 1.2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return dataset, events
|
||||||
|
|
||||||
|
def test_candidate_detail_resolves_media_and_transcript(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
dataset, events = self._fixture(root)
|
||||||
|
candidates = load_candidates([events])
|
||||||
|
repository = CandidateRepository(
|
||||||
|
candidates,
|
||||||
|
dataset,
|
||||||
|
context_before_seconds=3.0,
|
||||||
|
context_after_seconds=3.0,
|
||||||
|
)
|
||||||
|
detail = repository.detail(candidates[0])
|
||||||
|
self.assertTrue(detail["media_available"])
|
||||||
|
self.assertEqual(detail["event_local_seconds"], 1.2)
|
||||||
|
self.assertEqual(len(detail["transcript"]), 2)
|
||||||
|
self.assertTrue(detail["transcript"][1]["is_anchor"])
|
||||||
|
|
||||||
|
def test_review_store_writes_resumable_jsonl_and_csv(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
_, events = self._fixture(root)
|
||||||
|
candidates = load_candidates([events])
|
||||||
|
output = root / "reviews" / "reviews.jsonl"
|
||||||
|
store = ReviewStore(output, candidates)
|
||||||
|
review = {
|
||||||
|
"protocol_disposition": "positive",
|
||||||
|
"hard_negative_type": "not_applicable",
|
||||||
|
"validity": "valid",
|
||||||
|
"onset_function": "backchannel",
|
||||||
|
"context": "addressed_wearer",
|
||||||
|
"offset_outcome": "not_applicable",
|
||||||
|
"anchor_adjustment_bins": 0,
|
||||||
|
"behavior_adjustment_bins": 0,
|
||||||
|
"region_end_adjustment_bins": None,
|
||||||
|
"confidence": "high",
|
||||||
|
"reviewer": "r1",
|
||||||
|
"notes": "clear acknowledgement",
|
||||||
|
}
|
||||||
|
store.save(candidates[0].candidate_id, review)
|
||||||
|
self.assertTrue(output.is_file())
|
||||||
|
self.assertTrue(output.with_suffix(".csv").is_file())
|
||||||
|
resumed = ReviewStore(output, candidates)
|
||||||
|
self.assertEqual(
|
||||||
|
resumed.reviews[candidates[0].candidate_id]["review"]["onset_function"],
|
||||||
|
"backchannel",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_directory_expansion_excludes_output(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
candidate = root / "Session_4.events.jsonl"
|
||||||
|
output = root / "reviews.jsonl"
|
||||||
|
candidate.write_text("{}\n", encoding="utf-8")
|
||||||
|
output.write_text("{}\n", encoding="utf-8")
|
||||||
|
self.assertEqual(
|
||||||
|
expand_candidate_paths([root], output_path=output),
|
||||||
|
[candidate.resolve()],
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
self.assertEqual(parse_byte_range("bytes=-3", 10), (7, 9))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_byte_range("bytes=20-30", 10)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user