请选择一个候选事件
-—
-请选择候选
—
事件上下文
-查看自动规则与原始候选字段
-{}
- diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 199072b..1107d0d 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -304,7 +304,7 @@ 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 | +| `prepare_easycom_eot_int_review.py` | Build wearer-transition and independent hard-negative candidates with structured timing evidence; semantic labels remain AI/human review outputs | | `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 | diff --git a/adjudicate_easycom_eot_int_candidates.py b/adjudicate_easycom_eot_int_candidates.py index a2f0460..5477d0f 100644 --- a/adjudicate_easycom_eot_int_candidates.py +++ b/adjudicate_easycom_eot_int_candidates.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Add conservative DeepSeek suggestions to EOT/INT review candidates. +"""Add conservative DeepSeek pre-labels to wearer-centric EOT/INT candidates. -Only transcript excerpts and timing metadata are sent. Suggestions are never -treated as gold labels: the output remains input to ``review_floor_events.py`` -and requires an identified human reviewer. The API key is read only from the -``DEEPSEEK_API_KEY`` environment variable and is never persisted. +The model receives the candidate source, structured participant timing and the +transcript excerpt. It cannot change any source timestamp. Its output is +stored as an auditable suggestion beside the untouched rule candidate and must +still be accepted, relabeled, excluded, or sent to second review by a human. """ from __future__ import annotations @@ -16,49 +16,65 @@ import json import os from pathlib import Path import random +import re import time from typing import Any, Sequence import urllib.error import urllib.request -PROMPT_VERSION = "easycom-manuscript-eot-int-adjudicator-v1" +PROMPT_VERSION = "easycom-wearer-centric-eot-int-prelabel-v2" DEFAULT_MODEL = "deepseek-v4-flash" DEFAULT_BASE_URL = "https://api.deepseek.com" -DISPOSITIONS = {"positive", "hard_negative", "exclude", "uncertain"} +VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "NOT_APPLICABLE", "EXCLUDE", "UNSURE"} +EVENT_TYPES = {"EOT_RESPONSE", "INT_YIELD"} HARD_NEGATIVE_TYPES = { - "eot_midturn_pause", - "eot_other_to_other", - "eot_no_response", - "int_backchannel", - "int_non_takeover_overlap", - "int_retain_floor", - "other", - "not_applicable", - "uncertain", + "EOT_MIDTURN_PAUSE", + "EOT_OTHER_TO_OTHER", + "EOT_WEARER_BACKCHANNEL", + "INT_OTHER_BACKCHANNEL", + "INT_NON_TAKEOVER_OVERLAP", + "INT_WEARER_HOLDS_FLOOR", + "OTHER_HARD_NEGATIVE", } -CONFIDENCES = {"high", "medium", "low"} -SYSTEM_PROMPT = """You audit candidate regions for a scientific study of wearer-relative floor control. -Use only the supplied participant-resolved transcript and timing evidence. Do not infer prosody, gaze, intention, or acoustic interruption cues that are not supplied. +SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientific study of wearer-relative conversational floor control. -Tasks: -- eot_response positive: a non-wearer releases the floor and the wearer makes a genuine floor-taking response. -- int_yield positive: a non-wearer takes over during wearer speech and the wearer genuinely yields the floor. +Evidence rules: +1. Use candidate source, structured participant timing, per-speaker activity, and transcript together. Timing evidence has priority over a speculative transcript interpretation. +2. Never alter or propose an event timestamp. Do not infer prosody, gaze, intention, or acoustic cues that were not supplied. +3. A wearer onset is not automatically a floor take; a wearer offset is not automatically a yield; an other onset is not automatically a takeover. A pause is not automatically EOT, overlap is not automatically interruption, and words such as "yeah" are not automatically backchannels. Check who speaks sustainably afterward and who resumes. +4. Use UNSURE when the evidence does not establish conversational function. Never force a label for coverage. -Hard negatives: -- eot_midturn_pause: the same non-wearer resumes after a within-turn pause. -- eot_other_to_other: a different non-wearer takes the floor while the wearer remains silent. -- eot_no_response: the wearer correctly remains silent for the labeled region. -- int_backchannel: brief feedback during wearer speech that does not take the floor. -- int_non_takeover_overlap: overlapping other speech without takeover. -- int_retain_floor: the wearer correctly retains the floor for the labeled region. +Top-level verdicts: +- POSITIVE: the system should commit. +- HARD_NEGATIVE: a valid, interpretable region where the system should not commit. +- NOT_APPLICABLE: a real speech transition outside the committed EOT/INT task, such as a wearer starting after long silence or naturally ending into silence. +- EXCLUDE: the region cannot be scored reliably because of annotation corruption, a recording boundary, missing context, a non-speech artifact, timestamp corruption, or another unresolvable defect. +- UNSURE: evidence is insufficient and a second human review is needed. -Use exclude for annotation corruption or an unusable boundary. Use uncertain whenever transcript/timing evidence cannot establish the semantic function. Timing alone does not prove EOT, interruption, backchannel, or takeover. +POSITIVE event_type must be exactly EOT_RESPONSE or INT_YIELD. EOT_RESPONSE means a non-wearer genuinely releases the floor and the wearer then takes it with a sustained response. INT_YIELD means a non-wearer genuinely takes over during wearer speech and the wearer relinquishes the floor. + +HARD_NEGATIVE subtype must be exactly one of EOT_MIDTURN_PAUSE, EOT_OTHER_TO_OTHER, EOT_WEARER_BACKCHANNEL, INT_OTHER_BACKCHANNEL, INT_NON_TAKEOVER_OVERLAP, INT_WEARER_HOLDS_FLOOR, or OTHER_HARD_NEGATIVE. Return exactly one JSON object with this shape and no markdown: -{"items":[{"uid":"...","protocol_disposition":"uncertain","hard_negative_type":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]} -Return every supplied uid exactly once.""" +{"items":[{"uid":"...","verdict":"UNSURE","event_type":null,"subtype":null,"confidence":0.25,"reason":"brief evidence-based sentence","needs_human_review":true}]} +Return every supplied uid exactly once. Copy uid byte-for-byte and never add a backslash before an underscore. confidence must be a number from 0 to 1.""" + + +def _normalize_returned_uid(value: Any) -> Any: + """Undo DeepSeek's occasional Markdown escaping without weakening identity checks.""" + if not isinstance(value, str): + return value + return re.sub(r"\\+_", "_", value) + + +def _participant_uid_suffix(row: dict[str, Any], ordinal: int) -> str: + previous = row.get("previous_other_participant_ids") or [] + incoming = row.get("incoming_other_participant_ids") or [] + previous_key = "-".join(f"p{x}" for x in previous) or "none" + incoming_key = "-".join(f"p{x}" for x in incoming) or "none" + return f"prev-{previous_key}__in-{incoming_key}__row-{ordinal}" def read_candidates(path: Path) -> list[dict[str, Any]]: @@ -70,33 +86,50 @@ def read_candidates(path: Path) -> list[dict[str, Any]]: row = json.loads(line) if not isinstance(row, dict) or not isinstance(row.get("uid"), str): raise ValueError(f"Expected candidate object with uid at {path}:{line_number}") - if row.get("task") not in {"eot_response", "int_yield"}: - raise ValueError(f"Unsupported task at {path}:{line_number}: {row.get('task')!r}") + if not (row.get("candidate_source") or row.get("task")): + raise ValueError(f"Missing candidate source/task at {path}:{line_number}") rows.append(row) if not rows: raise ValueError(f"No candidates in {path}") + by_uid: dict[str, list[int]] = {} + for index, row in enumerate(rows): + by_uid.setdefault(row["uid"], []).append(index) + for original_uid, indices in by_uid.items(): + if len(indices) == 1: + continue + for ordinal, index in enumerate(indices, 1): + row = dict(rows[index]) + row["uid_disambiguated_from"] = original_uid + row["uid"] = f"{original_uid}/{_participant_uid_suffix(row, ordinal)}" + row["event_id"] = row["uid"] + rows[index] = row + if len({row["uid"] for row in rows}) != len(rows): + raise ValueError(f"Could not deterministically disambiguate duplicate candidate UIDs in {path}") return rows def candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]: fields = ( "uid", + "candidate_source", + "rule_candidate_type", "task", - "automatic_protocol_label", - "automatic_hard_negative_type", "anchor_type", "anchor_dense_seconds", - "behavior_anchor_type", - "behavior_seconds", + "event_time", "region_start_seconds", "region_end_seconds", - "region_end_reason", - "previous_other_participant_ids", - "incoming_other_participant_ids", - "automatic_reason", + "temporal_evidence", + "speaker_timeline", "context", ) - return {field: row.get(field) for field in fields} + payload = {field: row.get(field) for field in fields if row.get(field) is not None} + # Older v1 files remain reviewable and can still be pre-labeled. + if "candidate_source" not in payload: + payload["candidate_source"] = row.get("anchor_type", row.get("event_type", "UNKNOWN")) + if "event_time" not in payload: + payload["event_time"] = row.get("anchor_dense_seconds") + return payload def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str: @@ -112,35 +145,59 @@ def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str: ) -def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, str]]: +def _transport_rows(rows: Sequence[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, str]]: + """Use simple batch-local IDs so the model never has to copy path-like UIDs.""" + transported: list[dict[str, Any]] = [] + original_by_transport: dict[str, str] = {} + for index, row in enumerate(rows): + transport_uid = f"item-{index:04d}" + original_by_transport[transport_uid] = row["uid"] + transported.append({**row, "uid": transport_uid}) + return transported, original_by_transport + + +def validate_decisions(payload: Any, expected_uids: Sequence[str]) -> list[dict[str, Any]]: if not isinstance(payload, dict) or not isinstance(payload.get("items"), list): raise ValueError("Response must be an object with an items list") expected = set(expected_uids) - decisions: dict[str, dict[str, str]] = {} + decisions: dict[str, dict[str, Any]] = {} for item in payload["items"]: if not isinstance(item, dict): raise ValueError("Every decision must be an object") - uid = item.get("uid") - disposition = item.get("protocol_disposition") - negative_type = item.get("hard_negative_type") + raw_uid = item.get("uid") + uid = _normalize_returned_uid(raw_uid) + verdict = item.get("verdict") + event_type = item.get("event_type") + subtype = item.get("subtype") confidence = item.get("confidence") - rationale = item.get("rationale") + reason = item.get("reason") + needs_human_review = item.get("needs_human_review") if uid not in expected or uid in decisions: - raise ValueError(f"Unexpected or duplicate uid: {uid!r}") - if disposition not in DISPOSITIONS: - raise ValueError(f"Invalid protocol_disposition for {uid}: {disposition!r}") - if negative_type not in HARD_NEGATIVE_TYPES: - raise ValueError(f"Invalid hard_negative_type for {uid}: {negative_type!r}") - if confidence not in CONFIDENCES: - raise ValueError(f"Invalid confidence for {uid}: {confidence!r}") - if not isinstance(rationale, str) or not rationale.strip(): - raise ValueError(f"Missing rationale for {uid}") + raise ValueError(f"Unexpected or duplicate uid: {raw_uid!r}") + if verdict not in VERDICTS: + raise ValueError(f"Invalid verdict for {uid}: {verdict!r}") + if verdict == "POSITIVE" and event_type not in EVENT_TYPES: + raise ValueError(f"POSITIVE requires EOT_RESPONSE or INT_YIELD for {uid}") + if verdict != "POSITIVE" and event_type is not None: + raise ValueError(f"Only POSITIVE may have event_type for {uid}") + if verdict == "HARD_NEGATIVE" and subtype not in HARD_NEGATIVE_TYPES: + raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}") + if verdict != "HARD_NEGATIVE" and subtype is not None: + raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}") + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1: + raise ValueError(f"confidence must be in [0, 1] for {uid}") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"Missing reason for {uid}") + if not isinstance(needs_human_review, bool): + raise ValueError(f"needs_human_review must be boolean for {uid}") decisions[str(uid)] = { "uid": str(uid), - "protocol_disposition": str(disposition), - "hard_negative_type": str(negative_type), - "confidence": str(confidence), - "rationale": rationale.strip(), + "verdict": str(verdict), + "event_type": event_type, + "subtype": subtype, + "confidence": float(confidence), + "reason": reason.strip(), + "needs_human_review": needs_human_review, } if set(decisions) != expected: raise ValueError(f"Response uid mismatch; missing={sorted(expected - set(decisions))}") @@ -156,12 +213,13 @@ def _post( base_url: str, timeout_seconds: float, max_retries: int, -) -> tuple[list[dict[str, str]], dict[str, Any]]: +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + transported, original_by_transport = _transport_rows(rows) body = { "model": model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": build_user_prompt(rows, pass_index)}, + {"role": "user", "content": build_user_prompt(transported, pass_index)}, ], "response_format": {"type": "json_object"}, "thinking": {"type": "disabled"}, @@ -182,7 +240,10 @@ def _post( envelope = json.loads(response.read().decode("utf-8")) content = envelope["choices"][0]["message"]["content"] parsed = json.loads(content) - return validate_decisions(parsed, [row["uid"] for row in rows]), envelope.get("usage", {}) + decisions = validate_decisions(parsed, list(original_by_transport)) + for decision in decisions: + decision["uid"] = original_by_transport[decision["uid"]] + return decisions, envelope.get("usage", {}) except ( urllib.error.HTTPError, urllib.error.URLError, @@ -206,67 +267,80 @@ def add_suggestions( model: str, base_url: str, passes: int, + batch_size: int = 8, timeout_seconds: float, max_retries: int, ) -> tuple[list[dict[str, Any]], Counter[str]]: - by_uid: dict[str, list[dict[str, str]]] = {row["uid"]: [] for row in rows} + by_uid: dict[str, list[dict[str, Any]]] = {row["uid"]: [] for row in rows} + prompt_hashes: dict[str, list[str]] = {row["uid"]: [] for row in rows} usage: Counter[str] = Counter() + if batch_size <= 0: + raise ValueError("batch_size must be positive") for pass_index in range(passes): - decisions, pass_usage = _post( - rows=rows, - pass_index=pass_index, - api_key=api_key, - model=model, - base_url=base_url, - timeout_seconds=timeout_seconds, - max_retries=max_retries, - ) - for decision in decisions: - by_uid[decision["uid"]].append(decision) - for key, value in pass_usage.items(): - if isinstance(value, int): - usage[key] += value + for start in range(0, len(rows), batch_size): + batch = rows[start:start + batch_size] + transported, _ = _transport_rows(batch) + prompt_hash = hashlib.sha256( + (SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8") + ).hexdigest() + decisions, pass_usage = _post( + rows=batch, + pass_index=pass_index, + api_key=api_key, + model=model, + base_url=base_url, + timeout_seconds=timeout_seconds, + max_retries=max_retries, + ) + for decision in decisions: + by_uid[decision["uid"]].append(decision) + prompt_hashes[decision["uid"]].append(prompt_hash) + for key, value in pass_usage.items(): + if isinstance(value, int): + usage[key] += value output: list[dict[str, Any]] = [] for source in rows: decisions = by_uid[source["uid"]] - labels = { - (decision["protocol_disposition"], decision["hard_negative_type"]) - for decision in decisions - } + labels = {(d["verdict"], d["event_type"], d["subtype"]) for d in decisions} unanimous = len(labels) == 1 if unanimous: - disposition, negative_type = next(iter(labels)) - confidence = ( - "high" if all(item["confidence"] == "high" for item in decisions) else "medium" - ) - rationale = " | ".join(item["rationale"] for item in decisions) + verdict, event_type, subtype = next(iter(labels)) + confidence = min(float(item["confidence"]) for item in decisions) + reason = " | ".join(item["reason"] for item in decisions) + needs_human_review = any(item["needs_human_review"] for item in decisions) else: - disposition = "uncertain" - negative_type = "uncertain" - confidence = "low" - rationale = "The independent DeepSeek passes disagreed." - prompt_payload = { - "prompt_version": PROMPT_VERSION, - "model": model, - "passes": passes, - "candidate": candidate_for_prompt(source), - } + verdict, event_type, subtype = "UNSURE", None, None + confidence = min(float(item["confidence"]) for item in decisions) + reason = "The independent DeepSeek passes disagreed." + needs_human_review = True enriched = dict(source) enriched["llm_suggestion"] = { - "protocol_disposition": disposition, - "hard_negative_type": negative_type, + "verdict": verdict, + "event_type": event_type, + "subtype": subtype, "confidence": confidence, - "rationale": rationale, + "reason": reason, + "needs_human_review": needs_human_review, "unanimous": unanimous, } + enriched.update( + ai_verdict=verdict, + ai_event_type=event_type, + ai_subtype=subtype, + ai_confidence=confidence, + ai_reason=reason, + human_status="UNREVIEWED", + human_verdict=None, + human_event_type=None, + human_subtype=None, + exclude_reason=None, + ) enriched["llm_adjudication"] = { "model": model, "prompt_version": PROMPT_VERSION, "passes": passes, - "prompt_sha256": hashlib.sha256( - json.dumps(prompt_payload, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest(), + "request_prompt_sha256": prompt_hashes[source["uid"]], "decisions": decisions, "human_review_required": True, } @@ -281,6 +355,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--model", default=os.environ.get("DEEPSEEK_MODEL", DEFAULT_MODEL)) parser.add_argument("--base-url", default=os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_BASE_URL)) parser.add_argument("--passes", type=int, default=2) + parser.add_argument("--batch-size", type=int, default=8) parser.add_argument("--max-events", type=int) parser.add_argument("--selection-seed", type=int, default=2027) parser.add_argument("--timeout-seconds", type=float, default=120.0) @@ -293,8 +368,8 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() - if args.passes <= 0 or args.max_retries < 0: - raise ValueError("passes must be positive and max-retries non-negative") + if args.passes <= 0 or args.batch_size <= 0 or args.max_retries < 0: + raise ValueError("passes/batch-size must be positive and max-retries non-negative") rows = read_candidates(args.input.expanduser().resolve()) if args.max_events is not None: if args.max_events < 0: @@ -324,6 +399,7 @@ def main() -> None: model=args.model, base_url=args.base_url, passes=args.passes, + batch_size=args.batch_size, timeout_seconds=args.timeout_seconds, max_retries=args.max_retries, ) diff --git a/docs/easycom_event_review_tool.md b/docs/easycom_event_review_tool.md index d56cbe8..49a1bd5 100644 --- a/docs/easycom_event_review_tool.md +++ b/docs/easycom_event_review_tool.md @@ -1,16 +1,19 @@ -# EasyCom EOT/INT review workspace +# EasyCom wearer-centric EOT/INT pre-label and review pipeline -`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. +The workflow has three explicit stages. Speech activity timestamps are +read-only throughout; reviewed events never modify the dense 5 Hz activity +ground truth. -## Prepare manuscript-aligned candidates +```text +EasyCom participant speech intervals + -> rule candidates + structured timing evidence + -> DeepSeek semantic pre-label (optional but recommended) + -> human Accept / Relabel / Exclude / Second review +``` -Develop the annotation guide on training-side Sessions 4--12: +## 1. Generate candidates + +Develop the rules on training-side Sessions 4--12: ```bash python3 prepare_easycom_eot_int_review.py \ @@ -19,24 +22,39 @@ python3 prepare_easycom_eot_int_review.py \ --output-dir reports/easycom_floor_event_candidates ``` -For a one-event pilot, add `--max-candidates 1`. The generator follows the -manuscript direction exactly: +The generator emits two independent sources: -- 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. +- every wearer `0->1` (`WEARER_ONSET`, `ENTRY_CANDIDATE`) and `1->0` + (`WEARER_OFFSET`, `YIELD_CANDIDATE`) transition; +- hard-negative regions that need no wearer transition: + `SAME_SPEAKER_PAUSE`, `OTHER_TO_OTHER`, `OTHER_BACKCHANNEL`, and + `NON_TAKEOVER_OVERLAP`. -Automatic labels remain candidates until independent human semantic review. +Wearer transitions are initially `unsure`; timing alone is not a semantic +positive label. Hard-negative scans are rule hints and still require AI and +human review. The JSONL retains candidate source, original event time, +participant-level activity, raw durations/gaps/overlap evidence, transcript, +and `Target_of_Speech` when present. `Target_of_Speech` is annotation assistance +only and cannot be a deployable model input. -## Optional DeepSeek suggestion +All heuristic durations are configurable rather than frozen paper rules: -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: +```text +--relation-window-seconds +--same-speaker-pause-max-seconds +--other-handover-max-seconds +--short-vocalization-max-seconds +--continuation-min-seconds +``` + +Use `--max-candidates 1` for a one-event smoke test. `summary.json` records the +exact configuration and candidate counts. `easycom_session_timelines.json` +stores the read-only participant speech lanes used by the full-session web +timeline; it is not sent to DeepSeek. + +## 2. Add DeepSeek pre-labels + +First inspect exactly what would be uploaded: ```bash python3 adjudicate_easycom_eot_int_candidates.py \ @@ -47,51 +65,52 @@ python3 adjudicate_easycom_eot_int_candidates.py \ --dry-run ``` -After setting `DEEPSEEK_API_KEY`, make the authorized request: +After setting `DEEPSEEK_API_KEY` and obtaining authorization to upload the +candidate excerpts: ```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. +DeepSeek sees candidate source, structured timing, per-speaker activity, and +transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`, +`NOT_APPLICABLE`, `EXCLUDE`, or `UNSURE`; positives additionally receive +`EOT_RESPONSE` or `INT_YIELD`, and hard negatives receive a named subtype. It +cannot modify timestamps. The output preserves both the original rule candidate +and the complete AI decision/provenance. -## Start the local workspace +## 3. Human review + +Run the web workspace on the AI-enriched JSONL: ```bash python3 review_floor_events.py \ --dataset-root /path/to/easycom \ - --events reports/easycom_floor_event_candidates/easycom_eot_int_candidates.jsonl \ + --events reports/easycom_floor_event_candidates/easycom_eot_int_deepseek.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. +Open `http://127.0.0.1:8765`. The page shows a session candidate overview, +participant activity near the event, video/audio, transcript, structured timing, +and the AI verdict/confidence/reason. The common path is one operation: -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. +- **Accept** keeps the AI verdict/type; +- **Relabel** expands verdict and subtype controls; +- **Exclude** requires a concrete data-quality reason; +- **Unsure / Second review** preserves the event for adjudication. + +Boundary correction is hidden under an optional disclosure and is limited to +one 200 ms bin. The original timestamp is always retained. Each save atomically +rewrites JSONL and CSV while preserving the rule candidate, AI decision, human +decision, relabel flag, exclusion state, reviewer, and provenance. ## 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. +Develop the guide and resolve disagreements only on Sessions 4--12. Freeze +categories and thresholds before reviewing official test Sessions 1--3. Use two +independent human review files plus a separate adjudication output for paper- +quality labels. DeepSeek is pre-label metadata, not an independent reviewer. diff --git a/floor_event_review_web/app.css b/floor_event_review_web/app.css index 90487cb..bd6046d 100644 --- a/floor_event_review_web/app.css +++ b/floor_event_review_web/app.css @@ -220,6 +220,27 @@ textarea { resize: vertical; line-height: 1.45; } .llm-suggestion strong { display: block; color: var(--accent); margin-bottom: 3px; } .event-position { color: var(--muted); font-size: 0.8rem; } +.timeline-section { padding: 0 18px 16px; display: grid; gap: 10px; } +.timeline-section .section-heading { margin-top: 2px; } +.local-heading { margin-top: 8px !important; } +.session-timeline { min-height: 34px; border: 1px solid var(--line); border-radius: 8px; position: relative; background: #07101d; padding: 6px 7px 28px; display: grid; gap: 3px; overflow: hidden; } +.session-lane { display: grid; grid-template-columns: 28px 1fr; gap: 5px; align-items: center; min-height: 10px; } +.session-lane > strong { color: var(--muted); text-align: right; font-size: .58rem; } +.session-track { height: 8px; position: relative; background: rgba(255,255,255,.025); } +.session-speech { position: absolute; top: 1px; height: 6px; min-width: 1px; background: #60a5fa; opacity: .75; } +.session-speech.wearer { background: var(--accent); } +.session-events { position: absolute; left: 40px; right: 7px; bottom: 4px; height: 20px; border-top: 1px solid var(--line-strong); } +.session-marker { position: absolute; top: 3px; width: 7px; height: 15px; margin-left: -3px; padding: 0; border: 0; border-radius: 4px; background: var(--amber); cursor: pointer; opacity: .68; } +.session-marker.int { background: #a78bfa; } +.session-marker.current { opacity: 1; box-shadow: 0 0 0 2px white; z-index: 2; } +.speaker-timeline { display: grid; gap: 6px; color: var(--muted); font-size: .76rem; } +.speaker-lane { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 8px; } +.speaker-lane > strong { color: var(--muted); text-align: right; font-size: .72rem; } +.speaker-track { height: 22px; position: relative; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; background: #07101d; } +.speaker-track > i { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--amber); z-index: 3; } +.speech-segment { position: absolute; top: 5px; height: 10px; min-width: 2px; border-radius: 3px; background: #60a5fa; } +.speech-segment.wearer { background: var(--accent); } + .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); } @@ -273,6 +294,11 @@ legend { font-size: .84rem; font-weight: 750; margin-bottom: 8px; } .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; } +.conditional-fields { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(94,234,212,.035); display: grid; gap: 9px; } +.conditional-fields label, .boundary-details label { display: grid; gap: 6px; } +.conditional-fields label > span, .boundary-details label > span { color: var(--muted); font-size: .72rem; font-weight: 700; } +.boundary-details { border-top: 1px solid var(--line); padding-top: 10px; } +.boundary-details summary { color: var(--muted); cursor: pointer; font-size: .78rem; margin-bottom: 9px; } 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; } diff --git a/floor_event_review_web/app.js b/floor_event_review_web/app.js index d199902..d747528 100644 --- a/floor_event_review_web/app.js +++ b/floor_event_review_web/app.js @@ -7,6 +7,7 @@ const state = { currentId: null, currentDetail: null, saving: false, + sessionSpeech: {}, }; const elements = Object.fromEntries( @@ -15,10 +16,11 @@ const elements = Object.fromEntries( "familyFilter", "statusFilter", "searchFilter", "clearFilters", "queueTitle", "queueCount", "queueList", "queueLimit", "familyBadge", "splitBadge", "autoConfidenceBadge", "eventTitle", "eventSubtitle", "eventPosition", - "llmSuggestion", "llmSuggestionText", + "llmSuggestion", "llmSuggestionText", "sessionTimeline", "speakerTimeline", "eventVideo", "mediaEmpty", "jumpEvent", "timeReadout", "clipLabel", "transcriptList", "rawCandidate", "reviewForm", "reviewerInput", "formError", - "saveButton", "uncertainButton", "saveState", "toast", + "saveButton", "uncertainButton", "saveState", "toast", "relabelFields", + "excludeFields", "eventTypeField", "subtypeField", ].map((id) => [id, document.getElementById(id)]) ); @@ -86,7 +88,7 @@ function applyFilters({ keepCurrent = true } = {}) { (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)) + (!search || `${item.uid} ${item.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${item.session}`.toLowerCase().includes(search)) ); }); renderQueue(); @@ -121,9 +123,9 @@ function renderQueue() { 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; + label.textContent = item.ai_verdict + ? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || "—"}` + : `${item.rule_candidate_type || item.automatic_label} · 待 AI 粗标`; button.append(line, meta, label); button.addEventListener("click", () => selectCandidate(item.candidate_id)); elements.queueList.append(button); @@ -171,7 +173,7 @@ 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.eventTitle.textContent = detail.candidate_source || 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); @@ -181,13 +183,16 @@ function renderDetail(detail) { 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 disposition = suggestion.verdict || suggestion.protocol_disposition || "UNSURE"; + const subtype = suggestion.event_type || suggestion.subtype || suggestion.hard_negative_type || "—"; const confidence = suggestion.confidence || suggestion.final_confidence || "—"; - const rationale = suggestion.rationale || suggestion.final_rationale || ""; + const rationale = suggestion.reason || suggestion.rationale || ""; elements.llmSuggestionText.textContent = `${disposition} · ${subtype} · ${confidence}${rationale ? ` — ${rationale}` : ""}`; } + renderSessionTimeline(detail); + renderSpeakerTimeline(detail); + elements.mediaEmpty.hidden = detail.media_available; if (detail.media_available) { elements.eventVideo.src = `/media?id=${encodeURIComponent(detail.candidate_id)}`; @@ -226,16 +231,87 @@ function renderDetail(detail) { updateTimeReadout(); } +async function renderSessionTimeline(detail) { + elements.sessionTimeline.replaceChildren(); + if (!state.sessionSpeech[detail.session]) { + try { + const payload = await request(`/api/session-timeline?session=${encodeURIComponent(detail.session)}`); + state.sessionSpeech[detail.session] = payload.speech || []; + } catch (_) { + state.sessionSpeech[detail.session] = []; + } + } + if (state.currentId !== detail.candidate_id) return; + const events = state.candidates.filter((item) => item.session === detail.session); + const speech = state.sessionSpeech[detail.session]; + const end = Math.max(1, + ...events.map((item) => Number(item.event_time_seconds) || 0), + ...speech.map((row) => Number(row.end_seconds) || 0)); + const speakers = [...new Set(speech.map((row) => row.participant_role === "wearer" ? "W" : `P${row.participant_id}`))]; + for (const speakerId of speakers) { + const lane = document.createElement("div"); lane.className = "session-lane"; + const label = document.createElement("strong"); label.textContent = speakerId; + const track = document.createElement("div"); track.className = "session-track"; + for (const row of speech.filter((row) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) { + const segment = document.createElement("span"); + segment.className = `session-speech${speakerId === "W" ? " wearer" : ""}`; + segment.style.left = `${100 * Number(row.start_seconds) / end}%`; + segment.style.width = `${Math.max(0.08, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / end)}%`; + track.append(segment); + } + lane.append(label, track); elements.sessionTimeline.append(lane); + } + const eventTrack = document.createElement("div"); eventTrack.className = "session-events"; + for (const item of events) { + const marker = document.createElement("button"); + marker.type = "button"; + marker.className = `session-marker ${item.family}${item.candidate_id === detail.candidate_id ? " current" : ""}`; + marker.style.left = `${Math.min(100, 100 * (Number(item.event_time_seconds) || 0) / end)}%`; + marker.title = `${formatTime(item.event_time_seconds)} · ${item.candidate_source || item.automatic_label} · ${item.ai_verdict || "未粗标"}`; + marker.addEventListener("click", () => selectCandidate(item.candidate_id)); + eventTrack.append(marker); + } + elements.sessionTimeline.append(eventTrack); +} + +function renderSpeakerTimeline(detail) { + elements.speakerTimeline.replaceChildren(); + const rows = detail.candidate.speaker_timeline || detail.candidate.context || []; + if (!rows.length) { + elements.speakerTimeline.textContent = "没有 participant activity evidence。"; + return; + } + const eventTime = Number(detail.candidate.event_time ?? detail.candidate.anchor_dense_seconds); + const left = Math.min(...rows.map((row) => Number(row.start_seconds)), eventTime - 3); + const right = Math.max(...rows.map((row) => Number(row.end_seconds)), eventTime + 3); + const span = Math.max(0.001, right - left); + const speakers = [...new Set(rows.map((row) => `${row.participant_role === "wearer" ? "W" : `P${row.participant_id}`}`))]; + for (const speakerId of speakers) { + const lane = document.createElement("div"); + lane.className = "speaker-lane"; + const label = document.createElement("strong"); label.textContent = speakerId; + const track = document.createElement("div"); track.className = "speaker-track"; + for (const row of rows.filter((row) => (row.participant_role === "wearer" ? "W" : `P${row.participant_id}`) === speakerId)) { + const segment = document.createElement("span"); + segment.className = `speech-segment${speakerId === "W" ? " wearer" : ""}`; + segment.style.left = `${100 * (Number(row.start_seconds) - left) / span}%`; + segment.style.width = `${Math.max(0.8, 100 * (Number(row.end_seconds) - Number(row.start_seconds)) / span)}%`; + segment.title = `${formatTime(row.start_seconds)}–${formatTime(row.end_seconds)} ${row.transcription || ""}`; + track.append(segment); + } + const anchor = document.createElement("i"); anchor.style.left = `${100 * (eventTime - left) / span}%`; track.append(anchor); + lane.append(label, track); elements.speakerTimeline.append(lane); + } +} + 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"; + updateConditionalFields(); return; } for (const [name, value] of Object.entries(review)) { @@ -244,24 +320,20 @@ function restoreForm(review) { if (control instanceof RadioNodeList) control.value = String(value); else control.value = value === null ? "" : String(value); } + updateConditionalFields(); } function formPayload() { const data = new FormData(elements.reviewForm); - const adjustment = (name) => { - const value = data.get(name); - return value === "" ? null : Number(value); - }; + const nullable = (name) => data.get(name) || null; + const correction = data.get("boundary_correction_seconds"); 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"), + human_action: data.get("human_action"), + human_verdict: nullable("human_verdict"), + human_event_type: nullable("human_event_type"), + human_subtype: nullable("human_subtype"), + exclude_reason: nullable("exclude_reason"), + boundary_correction_seconds: correction === "" ? null : Number(correction), confidence: data.get("confidence"), reviewer: String(data.get("reviewer") || "").trim(), notes: String(data.get("notes") || "").trim(), @@ -270,16 +342,25 @@ function formPayload() { 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.human_action) missing.push("主操作"); + if (review.human_action === "relabel" && !review.human_verdict) missing.push("一级 verdict"); + if (review.human_action === "relabel" && review.human_verdict === "POSITIVE" && !review.human_event_type) missing.push("positive event"); + if (review.human_action === "relabel" && review.human_verdict === "HARD_NEGATIVE" && !review.human_subtype) missing.push("hard-negative subtype"); + if (review.human_action === "exclude" && !review.exclude_reason) missing.push("排除原因"); + if (review.human_action === "accept" && !state.currentDetail?.candidate?.ai_verdict && !state.currentDetail?.llm_suggestion?.verdict) missing.push("AI 预标注(尚未运行,需 Relabel)"); if (!review.reviewer) missing.push("审核者"); return missing; } +function updateConditionalFields() { + const action = elements.reviewForm.elements.human_action.value; + const verdict = elements.reviewForm.elements.human_verdict.value; + elements.relabelFields.hidden = action !== "relabel"; + elements.excludeFields.hidden = action !== "exclude"; + elements.eventTypeField.hidden = action !== "relabel" || verdict !== "POSITIVE"; + elements.subtypeField.hidden = action !== "relabel" || verdict !== "HARD_NEGATIVE"; +} + async function saveCurrent({ advance = true } = {}) { if (!state.currentId || state.saving) return; const review = formPayload(); @@ -376,14 +457,12 @@ function bindEvents() { 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.human_action.value = "second_review"; elements.reviewForm.elements.confidence.value = "low"; + updateConditionalFields(); }); + elements.reviewForm.elements.human_action.forEach((control) => control.addEventListener("change", updateConditionalFields)); + elements.reviewForm.elements.human_verdict.addEventListener("change", updateConditionalFields); elements.jumpEvent.addEventListener("click", jumpToEvent); document.querySelectorAll("[data-seek]").forEach((button) => button.addEventListener("click", () => seek(Number(button.dataset.seek))) diff --git a/floor_event_review_web/index.html b/floor_event_review_web/index.html index f4f0b5c..81b0962 100644 --- a/floor_event_review_web/index.html +++ b/floor_event_review_web/index.html @@ -1,285 +1,82 @@ -
- - - -EasyCom 事件审核
-AI 粗标注 → 人工确认
—
-—
{}
-