Files
Jyotisha/frontend/tests/agent-voice-copy-contract.test.ts
T
Jesse_Chen 0c0df42679
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 1m52s
fix(rectification): require three answers before uncertainty stop
One "一时说不好" no longer ends the interview. Plateau streaks use the
same sample floor. Delivery copy states why the range stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:08:37 +08:00

157 lines
8.3 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.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
accidentCaseHardcodedTurns,
containsBoundarySemantics,
engineMeaningToDisplayCopy,
listUserVisibleCopy,
nonConvergingRangeNarration,
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
export const ACCIDENT_CASE_BEFORE_COPY = {
openingCollect: "请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。",
discriminatorRedirect: "请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。",
unclearFocusReply: "我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。",
staleQuestion: "当前问题已更新,请刷新后重新作答。",
intermediateRange: "当前可信区间是 05:0005:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。",
financeCollect: "有没有记得住时间的收入变化、大笔支出或欠债?",
deliveryAdopt: "当前可信区间是 05:0005:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 可以从下面的时间里选一个采用。",
marriageConsultOpen: [
"## 统一参数与原始结构",
"## 关系",
"## 技法审计表",
],
} as const;
const ACCIDENT_SHAPE = {
openingRange: ["04:45", "05:15"] as const,
credibleRange: ["05:00", "05:07"] as const,
representativeTime: "05:00",
};
test("user-visible copy rejects the machine-voice lexicon", () => {
assert.ok(MACHINE_VOICE_LEXICON.length >= 8);
const visible = listUserVisibleCopy().join("\n");
for (const phrase of MACHINE_VOICE_LEXICON) {
assert.equal(visible.includes(phrase), false, phrase);
}
});
test("delivery and adopt turns keep representative-minute boundary semantics", () => {
const after = accidentCaseHardcodedTurns(ACCIDENT_SHAPE);
assert.equal(containsBoundarySemantics(REPRESENTATIVE_MINUTE_DISCLAIMER), true);
assert.equal(containsBoundarySemantics(after.deliveryAdopt), true);
assert.match(after.deliveryAdopt, /05:0005:07/);
assert.match(after.deliveryAdopt, /05:00/);
assert.match(after.deliveryAdopt, /30 分钟/);
assert.match(after.deliveryAdopt, /7 分钟/);
assert.equal(containsBoundarySemantics(after.deliveryAdopt), true);
assert.doesNotMatch(after.intermediateRange, /不是已确认的唯一出生分钟/);
assert.match(after.intermediateRange, /05:0005:07|7 分钟/);
});
test("accident-case after copy is rebuilt from the shared module", () => {
const after = accidentCaseHardcodedTurns(ACCIDENT_SHAPE);
assert.equal(after.openingCollect, GENERIC_COLLECT_QUESTION);
assert.equal(after.discriminatorRedirect, RECTIFICATION_USER_COPY.choicePrompt);
assert.equal(after.unclearFocusReply, RECTIFICATION_USER_COPY.unclearFocusReply);
assert.equal(after.staleQuestion, RECTIFICATION_USER_COPY.questionUpdated);
assert.equal(after.financeCollect, "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?");
assert.notEqual(after.openingCollect, ACCIDENT_CASE_BEFORE_COPY.openingCollect);
assert.notEqual(after.financeCollect, ACCIDENT_CASE_BEFORE_COPY.financeCollect);
assert.ok(containsBoundarySemantics(after.deliveryAdopt));
});
test("default range narration stays semantic even without an opening window", () => {
const text = nonConvergingRangeNarration({
credibleRange: ["04:47", "04:53"],
representativeTime: "04:51",
});
assert.match(text, /04:4704:53/);
assert.match(text, /04:51/);
assert.equal(containsBoundarySemantics(text), true);
});
test("delivery range narration puts the stop reason before the progress clause", () => {
assert.equal(
stopReasonPrefix("user_uncertainty_too_high"),
RECTIFICATION_USER_COPY.uncertaintyStop,
);
assert.equal(stopReasonPrefix("tied_first"), RECTIFICATION_USER_COPY.tiedFirstStop);
const text = nonConvergingRangeNarration({
credibleRange: ["14:02", "14:43"],
representativeTime: "14:43",
openingRange: ["14:00", "15:00"],
stopReason: "user_uncertainty_too_high",
});
assert.match(text, /^前面几道题你多半选了"说不好"/);
assert.match(text, /已经从最初的 60 分钟收到 14:0214:43 这 41 分钟/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
});
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.match(agent, /开场轮:先 set-focus 写采集题的 spokenPrompt/);
assert.match(agent, /不要举大学、工作、搬家的例子/);
assert.match(agent, /每轮正文 2-4 句/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(agent, /collect_spoken 题干由服务器接在同一条正文末尾/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.doesNotMatch(agent, /每轮正文只输出一句确认或承接/);
assert.doesNotMatch(route, /接下来请点选下面这一问/);
assert.doesNotMatch(exit, /接下来请点选下面这一问/);
assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/);
assert.doesNotMatch(exit, /action === ["']opening["']/);
assert.match(route, /askedTurnId: result\.turnId/);
assert.doesNotMatch(exit, /assistantMessage\.(?:includes|match|search)/);
assert.doesNotMatch(exit, /question\.prompt\.(?:includes|match|search)/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.match(chat, /afterAnswer/);
assert.match(chat, /rectification-message-question/);
});
test("collect prompt skip no longer writes a second assistant turn", () => {
const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
assert.match(exit, /persistNextInterviewIfIdle/);
assert.match(exit, /askedTurnId: input\.askedTurnId/);
assert.doesNotMatch(exit, /persistV9DeterministicTurn/);
assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/);
});
test("E-2 display copy keeps engine meaning out of instruction tone", () => {
const dasha = engineMeaningToDisplayCopy(
"主限更偏向 05:00,分盘大运更偏向 04:54。冲突时不能按更高把握收口。",
);
assert.match(dasha, /05:00/);
assert.match(dasha, /04:54/);
assert.doesNotMatch(dasha, /不得/);
assert.doesNotMatch(dasha, /不能按更高把握收口/);
const natal = engineMeaningToDisplayCopy(
"本命宫位已按 05:07 重算(上升 金牛座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。",
);
assert.match(natal, /05:07/);
assert.doesNotMatch(natal, /不能当作唯一分钟确认/);
const instruct = engineMeaningToDisplayCopy("可以采用代表性候选作当前排盘;不得把它描述为已确认的唯一出生分钟。");
assert.doesNotMatch(instruct, /不得/);
const board = readFileSync(new URL("../src/components/rectification-board.tsx", import.meta.url), "utf8");
const gate = readFileSync(new URL("../src/components/technique-audit-disclosure.tsx", import.meta.url), "utf8");
assert.match(board, /engineMeaningToDisplayCopy/);
assert.match(gate, /engineMeaningToDisplayCopy/);
});