296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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")}`;
|
||
}
|