Files
xiaobailing-turn-detection/labels/eot-studio/scripts/convert-hantai.py
T

144 lines
6.6 KiB
Python

"""Convert Hantai XLSX rows to an editable, approximate CSV draft.
Requires ffmpeg and numpy. Never writes the source workbook or 工作数据.
"""
import csv
import json
import math
import subprocess
import uuid
import zipfile
from collections import defaultdict
from pathlib import Path
from xml.etree import ElementTree as ET
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx"
OUTPUT = ROOT / "labels/EOT标注/韩泰初稿"
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
def source_rows():
with zipfile.ZipFile(SOURCE) as archive:
strings = []
if "xl/sharedStrings.xml" in archive.namelist():
strings = ["".join(item.itertext()) for item in ET.fromstring(archive.read("xl/sharedStrings.xml"))]
rows = []
for row in ET.fromstring(archive.read("xl/worksheets/sheet1.xml")).findall(".//s:row", NS):
values = {}
for cell in row.findall("s:c", NS):
value = cell.find("s:v", NS)
text = value.text if value is not None else ""
if cell.get("t") == "s":
text = strings[int(text)]
elif cell.get("t") == "inlineStr":
text = "".join(cell.find("s:is", NS).itertext())
values[cell.get("r").rstrip("0123456789")] = text or ""
try:
uuid.UUID(values.get("A", ""))
except ValueError:
continue
rows.append({"row": int(row.get("r")), **values})
return rows
def speech_spans(audio):
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", str(audio), "-f", "f32le", "-ac", "2", "-ar", "8000", "pipe:1"])
samples = np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)
frames = len(samples) // 160
rms = np.sqrt((samples[:frames * 160].reshape(frames, 160, 2) ** 2).mean(axis=1))
channels = []
for channel in range(2):
threshold = max(.004, float(np.quantile(rms[:, channel], .95)) * .16)
runs = []
for index in np.flatnonzero(rms[:, channel] > threshold):
if runs and index - runs[-1][1] < 35:
runs[-1][1] = int(index)
else:
runs.append([int(index), int(index)])
channels.append([(a * 20, (b + 1) * 20) for a, b in runs if b - a >= 12])
return channels, round(len(samples) / 8)
def match_prompts(groups, spans):
# Greeting is absent from the source. Match remaining prompts in order,
# allowing extra robot speech (e.g. a retry) to be skipped.
candidates = spans[1:]
cache = {}
def solve(i, j):
if i == len(groups):
return .35 * (len(candidates) - j), []
if len(candidates) - j < len(groups) - i:
return float("inf"), []
if (i, j) in cache:
return cache[i, j]
expected = max(.6, len(groups[i][0]["C"]) / 6.7)
actual = (candidates[j][1] - candidates[j][0]) / 1000
score, rest = solve(i + 1, j + 1)
best = (score + abs(math.log(actual / expected)), [candidates[j]] + rest)
if len(candidates) - j > len(groups) - i:
score, rest = solve(i, j + 1)
if score + .35 < best[0]:
best = score + .35, rest
cache[i, j] = best
return best
matches = solve(0, 0)[1]
if len(matches) != len(groups):
raise ValueError("Not enough robot speech spans; inspect the recording before conversion.")
return matches
def main():
grouped = defaultdict(list)
for row in source_rows():
grouped[row["A"]].append(row)
transcripts, annotations, provenance = [], [], []
for call, rows in grouped.items():
channels, duration = speech_spans(ROOT / f"data/韩泰机器人抢话/音频/{call}.mp3")
groups = []
for row in rows:
if groups and groups[-1][0]["C"] == row["C"]:
groups[-1].append(row)
else:
groups.append([row])
prompts = match_prompts(groups, channels[1])
for index, (group, (start, end)) in enumerate(zip(groups, prompts)):
transcripts.append([call, "agent", start, end, group[0]["C"]])
window_end = prompts[index + 1][0] if index + 1 < len(prompts) else duration
user_spans = [(a, b) for a, b in channels[0] if a >= end - 300 and a < window_end]
user_start = user_spans[0][0] if user_spans else min(end + 300, window_end - 1)
user_end = user_spans[-1][1] if user_spans else window_end
# Multiple answers to the same robot prompt share its response window.
# These boundaries are provisional, not word-level forced alignment.
weights = [max(1, len(row["B"])) for row in group]
cursor = user_start
for offset, row in enumerate(group):
finish = user_end if offset == len(group) - 1 else user_start + round((user_end - user_start) * sum(weights[:offset + 1]) / sum(weights))
finish = max(cursor + 1, min(duration, finish))
transcripts.append([call, "user", cursor, finish, row["B"]])
conflict = row.get("E") in ("是", "否") and row["E"] != row["D"]
label = "" if conflict else "0" if row["D"] == "是" else "1" if row["D"] == "否" else ""
reason = "uncertain" if label == "" else "unfinished_expression" if label == "0" else "completed_answer"
annotations.append([call, finish, row["B"], label, "low", reason, "xlsx_draft"])
provenance.append({"call_id": call, "source_row": row["row"], "decision_ms": finish, "original_interrupted": row["D"], "unheaded_column_e": row.get("E", ""), "conflict": conflict})
cursor = finish
OUTPUT.mkdir(parents=True, exist_ok=True)
for name, header, records in [
("asr_history.csv", ["call_id", "role", "start_ms", "end_ms", "transcript"], sorted(transcripts, key=lambda r: (r[0], r[2], r[1]))),
("eot_labels.csv", ["call_id", "decision_ms", "user_text_at_decision", "can_reply_now", "confidence", "reason", "annotator"], annotations),
]:
with (OUTPUT / name).open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(header)
writer.writerows(records)
(OUTPUT / "source_rows.json").write_text(json.dumps(provenance, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({"calls": len(grouped), "transcripts": len(transcripts), "annotations": len(annotations), "conflicts": sum(row["conflict"] for row in provenance)}))
if __name__ == "__main__":
main()