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(); 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")}`; }