108 lines
6.6 KiB
TypeScript
108 lines
6.6 KiB
TypeScript
// @ts-nocheck -- Node's native TypeScript runner resolves explicit .ts imports.
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
createAnnotation,
|
|
exportAnnotations,
|
|
exportTranscripts,
|
|
formatTime,
|
|
getHistory,
|
|
parseAnnotations,
|
|
parseTranscripts,
|
|
reasonOptions,
|
|
validateAnnotation,
|
|
} from "../lib/annotations.ts";
|
|
|
|
const transcriptHeader = "call_id,role,start_ms,end_ms,transcript";
|
|
const annotationHeader = "call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator";
|
|
const withoutId = (row) => Object.fromEntries(Object.entries(row).filter(([key]) => key !== "id"));
|
|
|
|
test("zero is a labeled negative example, and empty remains undecided", () => {
|
|
const rows = parseAnnotations(`\uFEFF${annotationHeader}\r\ndemo,5200,我明天下午,0,high,mid_turn_pause,A\r\ndemo,9400,听不清,,,audio_issue,A\r\n`);
|
|
assert.equal(rows[0].can_reply_now, "0");
|
|
assert.equal(rows[1].can_reply_now, "");
|
|
assert.equal(rows[1].confidence, "");
|
|
assert.notEqual(rows[0].id, rows[1].id);
|
|
assert.deepEqual(parseAnnotations(exportAnnotations(rows)).map(withoutId), rows.map(withoutId));
|
|
assert.equal(exportAnnotations(rows).split("\r\n")[0], annotationHeader);
|
|
assert.ok(!exportAnnotations(rows).includes(rows[0].id));
|
|
});
|
|
|
|
test("commas, doubled quotes and multiline UTF-8 text survive CSV round trips", () => {
|
|
const csv = `${transcriptHeader}\r\ndemo,user,100,800,"明天,\"\"下午\"\"\r\n都可以"\r\n`;
|
|
const rows = parseTranscripts(csv);
|
|
assert.equal(rows[0].transcript, '明天,"下午"\r\n都可以');
|
|
assert.deepEqual(parseTranscripts(exportTranscripts(rows)), rows);
|
|
const annotation = { ...createAnnotation("demo", 500), user_text_at_decision: '明天,"下午"\n先看看', annotator: 'A,"B"' };
|
|
assert.deepEqual(parseAnnotations(exportAnnotations([annotation])).map(withoutId), [withoutId(annotation)]);
|
|
});
|
|
|
|
test("required headers are validated while reordered columns remain usable", () => {
|
|
assert.equal(parseTranscripts("transcript,end_ms,role,call_id,start_ms\n可以,40,user,demo,20")[0].transcript, "可以");
|
|
assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms\ndemo,user,0,10"), /缺少字段.*transcript/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader},extra\ndemo,user,0,10,可以,x`), /未知字段.*extra/);
|
|
assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms,transcript,role"), /重复字段/);
|
|
assert.throws(() => parseAnnotations(""), /为空/);
|
|
assert.deepEqual(parseAnnotations(annotationHeader), []);
|
|
});
|
|
|
|
test("malformed CSV and incorrect column counts give useful line diagnostics", () => {
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"未闭合`), /第 2 行.*没有闭合/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"可以"额外`), /结束引号后/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,可以"`), /双引号.*转义/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,多,一列`), /第 2 行.*实际有 6 列/);
|
|
});
|
|
|
|
test("invalid times and roles are rejected instead of being silently coerced", () => {
|
|
for (const start of ["", "-1", "1.5", "1e3", "9007199254740992"]) {
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,${start},2000,可以`), /非负整数毫秒/);
|
|
}
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,assistant,0,100,可以`), /agent 或 user/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,100,100,可以`), /小于 end_ms/);
|
|
assert.throws(() => parseTranscripts(`${transcriptHeader}\n,user,0,100,可以`), /call_id 不能为空/);
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,-1,可以,1,high,completed_answer,A`), /非负整数毫秒/);
|
|
});
|
|
|
|
test("labels, confidence, known reasons and reason-label consistency are checked", () => {
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,2,high,completed_answer,A`), /can_reply_now 只能/);
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,strong,completed_answer,A`), /confidence 只能/);
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,freeform,A`), /未知的 reason/);
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,0,high,completed_answer,A`), /不一致/);
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,uncertain,A`), /不一致/);
|
|
for (const option of reasonOptions) {
|
|
const row = { ...createAnnotation("demo", 100), can_reply_now: option.can_reply_now, reason: option.value };
|
|
assert.equal(validateAnnotation(row), null);
|
|
}
|
|
});
|
|
|
|
test("duplicate decision positions are rejected within a call and allowed across calls", () => {
|
|
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,completed_answer,A\ndemo,100,等等,0,high,hesitation,B`), /已有标注/);
|
|
const first = createAnnotation("demo", 100);
|
|
const duplicate = createAnnotation("demo", 100);
|
|
assert.match(validateAnnotation(duplicate, [first]), /已有标注/);
|
|
assert.equal(validateAnnotation(first, [first]), null);
|
|
assert.throws(() => exportAnnotations([first, first]), /重复/);
|
|
assert.equal(parseAnnotations(`${annotationHeader}\ndemo,100,,,,,\nother,100,,,,,`).length, 2);
|
|
});
|
|
|
|
test("history uses all earlier completed records, excludes crossing and future records, and preserves tied order", () => {
|
|
const rows = parseTranscripts(`${transcriptHeader}\ndemo,user,300,440,我明天下午\ndemo,agent,100,240,什么时候方便\ndemo,user,400,700,不行后天可以\nother,user,0,5,其他通话\ndemo,user,300,400,同时出现的第二行\ndemo,agent,800,900,后天上午可以吗`);
|
|
const before = rows.slice();
|
|
assert.deepEqual(getHistory(rows, "demo", 520).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]);
|
|
assert.deepEqual(getHistory(rows, "demo", 440).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]);
|
|
assert.deepEqual(getHistory(rows, "demo", 100), []);
|
|
assert.deepEqual(rows, before);
|
|
});
|
|
|
|
test("new samples start undecided and time formatting preserves millisecond precision", () => {
|
|
const row = createAnnotation("demo", 0);
|
|
assert.equal(row.can_reply_now, "");
|
|
assert.equal(row.reason, "");
|
|
assert.equal(validateAnnotation(row), null);
|
|
assert.equal(formatTime(0), "00:00.000");
|
|
assert.equal(formatTime(5_200), "00:05.200");
|
|
assert.equal(formatTime(62_345), "01:02.345");
|
|
assert.equal(formatTime(3_600_000), "60:00.000");
|
|
assert.throws(() => createAnnotation("demo", -1), /非负整数/);
|
|
});
|