Files
xiaobailing-turn-detection/labels/eot-studio/lib/annotations.ts
T

296 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type ReplyLabel = "1" | "0" | "";
export type Confidence = "high" | "medium" | "low" | "";
export interface Transcript {
call_id: string;
role: "agent" | "user";
start_ms: number;
end_ms: number;
transcript: string;
}
export interface Annotation {
/** Local editor identity. This field is never written to the CSV. */
id: string;
call_id: string;
decision_ms: number;
user_text_at_decision: string;
can_reply_now: ReplyLabel;
confidence: Confidence;
reason: string;
annotator: string;
}
export interface ReasonOption {
value: string;
label: string;
can_reply_now: ReplyLabel;
}
export const reasonOptions: readonly ReasonOption[] = [
{ value: "completed_turn", label: "当前话轮表达完成", can_reply_now: "1" },
{ value: "completed_answer", label: "已完成当前问题的回答", can_reply_now: "1" },
{ value: "mid_turn_pause", label: "同一话轮内停顿", can_reply_now: "0" },
{ value: "hesitation", label: "犹豫或组织语言", can_reply_now: "0" },
{ value: "unfinished_expression", label: "表达尚未完成", can_reply_now: "0" },
{ value: "uncertain", label: "有争议或上下文不足", can_reply_now: "" },
{ value: "audio_issue", label: "音频质量或录音缺失", can_reply_now: "" },
{ value: "interrupted_unclear", label: "被插话后停止,意图不明确", can_reply_now: "" },
];
const transcriptHeaders = ["call_id", "role", "start_ms", "end_ms", "transcript"];
const annotationHeaders = [
"call_id", "decision_ms", "user_text_at_decision", "can_reply_now",
"confidence", "reason", "annotator",
];
interface CSVRecord {
cells: string[];
line: number;
}
/** Read RFC-style CSV without guessing types or interpreting an empty label as 0. */
function readCSV(source: string): CSVRecord[] {
const csv = source.replace(/^\uFEFF/, "");
const records: CSVRecord[] = [];
let cells: string[] = [];
let field = "";
let inQuotes = false;
let afterQuote = false;
let recordQuoted = false;
let line = 1;
let recordLine = 1;
const finishField = () => {
cells.push(field);
field = "";
afterQuote = false;
};
const finishRecord = () => {
finishField();
// Empty physical lines are harmless, but a quoted empty record is data.
if (cells.length !== 1 || cells[0] !== "" || recordQuoted) {
records.push({ cells, line: recordLine });
}
cells = [];
recordQuoted = false;
};
for (let i = 0; i < csv.length; i += 1) {
const char = csv[i];
if (inQuotes) {
if (char === '"') {
if (csv[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
afterQuote = true;
}
} else if (char === "\r" || char === "\n") {
field += char;
if (char === "\r" && csv[i + 1] === "\n") {
field += "\n";
i += 1;
}
line += 1;
} else {
field += char;
}
continue;
}
if (char === ",") {
finishField();
} else if (char === "\r" || char === "\n") {
finishRecord();
if (char === "\r" && csv[i + 1] === "\n") i += 1;
line += 1;
recordLine = line;
} else if (afterQuote) {
throw new Error(`CSV 第 ${line} 行:结束引号后只能接逗号或换行。`);
} else if (char === '"') {
if (field !== "") {
throw new Error(`CSV 第 ${line} 行:字段中的双引号需要用双引号包裹并转义。`);
}
inQuotes = true;
recordQuoted = true;
} else {
field += char;
}
}
if (inQuotes) throw new Error(`CSV 第 ${recordLine} 行:双引号字段没有闭合。`);
if (field !== "" || cells.length > 0 || afterQuote || recordQuoted) finishRecord();
return records;
}
function readTable(source: string, expected: readonly string[]) {
const records = readCSV(source);
if (records.length === 0) throw new Error("CSV 为空,请包含表头。");
const headers = records[0].cells.map((header) => header.trim());
if (new Set(headers).size !== headers.length) throw new Error("CSV 表头包含重复字段。");
const missing = expected.filter((header) => !headers.includes(header));
if (missing.length) throw new Error(`CSV 表头缺少字段:${missing.join("、")}。`);
const unexpected = headers.filter((header) => !expected.includes(header));
if (unexpected.length) throw new Error(`CSV 表头包含未知字段:${unexpected.join("、")}。`);
const indexes = new Map(headers.map((header, index) => [header, index]));
const rows = records.slice(1).map((record) => {
if (record.cells.length !== headers.length) {
throw new Error(`CSV 第 ${record.line} 行:需要 ${headers.length} 列,实际有 ${record.cells.length} 列。`);
}
return {
line: record.line,
get: (name: string) => record.cells[indexes.get(name)!],
};
});
return rows;
}
function parseMilliseconds(value: string, name: string, line: number): number {
const trimmed = value.trim();
const parsed = Number(trimmed);
if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(parsed)) {
throw new Error(`CSV 第 ${line} 行:${name} 必须是非负整数毫秒。`);
}
return parsed;
}
function validMilliseconds(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
function validateTranscript(row: Transcript): string | null {
if (typeof row.call_id !== "string" || !row.call_id.trim()) return "call_id 不能为空。";
if (row.role !== "agent" && row.role !== "user") return "role 只能是 agent 或 user。";
if (!validMilliseconds(row.start_ms) || !validMilliseconds(row.end_ms)) {
return "start_ms 和 end_ms 必须是非负整数毫秒。";
}
if (row.start_ms >= row.end_ms) return "start_ms 必须小于 end_ms。";
if (typeof row.transcript !== "string") return "transcript 必须是文本。";
return null;
}
export function parseTranscripts(csv: string): Transcript[] {
return readTable(csv, transcriptHeaders).map(({ line, get }) => {
const row: Transcript = {
call_id: get("call_id").trim(),
role: get("role").trim() as Transcript["role"],
start_ms: parseMilliseconds(get("start_ms"), "start_ms", line),
end_ms: parseMilliseconds(get("end_ms"), "end_ms", line),
transcript: get("transcript"),
};
const error = validateTranscript(row);
if (error) throw new Error(`CSV 第 ${line} 行:${error}`);
return row;
});
}
let localIdSequence = 0;
function localId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
localIdSequence += 1;
return `eot-${Date.now().toString(36)}-${localIdSequence.toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function createAnnotation(callId: string, decisionMs: number): Annotation {
const row: Annotation = {
id: localId(),
call_id: callId.trim(),
decision_ms: decisionMs,
user_text_at_decision: "",
can_reply_now: "",
confidence: "",
reason: "",
annotator: "",
};
const error = validateAnnotation(row);
if (error) throw new Error(error);
return row;
}
/** Empty label is an undecided sample, while the string "0" is a valid negative label. */
export function validateAnnotation(row: Annotation, others: readonly Annotation[] = []): string | null {
if (typeof row.call_id !== "string" || !row.call_id.trim()) return "call_id 不能为空。";
if (!validMilliseconds(row.decision_ms)) return "decision_ms 必须是非负整数毫秒。";
if (!["1", "0", ""].includes(row.can_reply_now)) return "can_reply_now 只能是 1、0 或空。";
if (!["high", "medium", "low", ""].includes(row.confidence)) return "confidence 只能是 high、medium、low 或空。";
if (typeof row.user_text_at_decision !== "string") return "user_text_at_decision 必须是文本。";
if (typeof row.annotator !== "string") return "annotator 必须是文本。";
if (typeof row.reason !== "string") return "reason 必须是文本。";
if (row.reason) {
const option = reasonOptions.find((item) => item.value === row.reason);
if (!option) return `未知的 reason:${row.reason}。`;
if (option.can_reply_now !== row.can_reply_now) return "reason 与 can_reply_now 不一致,请选择对应的判断原因。";
}
if (others.some((other) => other.id !== row.id
&& other.call_id.trim() === row.call_id.trim()
&& other.decision_ms === row.decision_ms)) {
return `通话 ${row.call_id} 在 ${row.decision_ms} ms 已有标注;同一判断位置只能保存一条。`;
}
return null;
}
export function parseAnnotations(csv: string): Annotation[] {
const annotations: Annotation[] = [];
for (const { line, get } of readTable(csv, annotationHeaders)) {
const row: Annotation = {
id: localId(),
call_id: get("call_id").trim(),
decision_ms: parseMilliseconds(get("decision_ms"), "decision_ms", line),
user_text_at_decision: get("user_text_at_decision"),
can_reply_now: get("can_reply_now").trim() as ReplyLabel,
confidence: get("confidence").trim() as Confidence,
reason: get("reason").trim(),
annotator: get("annotator").trim(),
};
const error = validateAnnotation(row, annotations);
if (error) throw new Error(`CSV 第 ${line} 行:${error}`);
annotations.push(row);
}
return annotations;
}
function writeCSV(headers: readonly string[], rows: readonly (readonly (string | number)[])[]): string {
const escape = (value: string | number) => {
const text = String(value);
return /[,"\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
};
return [headers, ...rows].map((row) => row.map(escape).join(",")).join("\r\n") + "\r\n";
}
export function exportTranscripts(rows: readonly Transcript[]): string {
return writeCSV(transcriptHeaders, rows.map((row, index) => {
const error = validateTranscript(row);
if (error) throw new Error(`转写第 ${index + 1} 条:${error}`);
return [row.call_id, row.role, row.start_ms, row.end_ms, row.transcript];
}));
}
export function exportAnnotations(rows: readonly Annotation[]): string {
// Validate against preceding rows so a duplicate cannot be exported even if UI ids match.
const seen = new Set<string>();
return writeCSV(annotationHeaders, rows.map((row, index) => {
const error = validateAnnotation(row);
if (error) throw new Error(`标注第 ${index + 1} 条:${error}`);
const key = JSON.stringify([row.call_id.trim(), row.decision_ms]);
if (seen.has(key)) throw new Error(`标注第 ${index + 1} 条:call_id + decision_ms 重复。`);
seen.add(key);
return [row.call_id, row.decision_ms, row.user_text_at_decision, row.can_reply_now, row.confidence, row.reason, row.annotator];
}));
}
export function getHistory(rows: readonly Transcript[], callId: string, decisionMs: number): Transcript[] {
return rows.map((row, index) => ({ row, index }))
.filter(({ row }) => row.call_id === callId && row.end_ms <= decisionMs)
.sort((a, b) => a.row.start_ms - b.row.start_ms || a.index - b.index)
.map(({ row }) => row);
}
export function formatTime(ms: number): string {
const integer = Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0;
const minutes = Math.floor(integer / 60_000);
const seconds = Math.floor(integer / 1_000) % 60;
const milliseconds = integer % 1_000;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
}