新增 labels 下的韩泰 EOT 标注工作台

This commit is contained in:
Xin Wang
2026-10-09 13:12:52 +08:00
parent 1ab51c8bc1
commit 138b08a8e4
23 changed files with 4326 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
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")}`;
}