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 |
|
||||
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
|
||||
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
|
||||
| `prepare_easycom_eot_int_review.py` | 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 |
|
||||
|
||||
### 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_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_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
|
||||
|
||||
|
||||
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