283 lines
16 KiB
TypeScript
283 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
GENERIC_COLLECT_QUESTION,
|
||
OPENING_COLLECT_DOMAINS,
|
||
RECTIFICATION_USER_COPY,
|
||
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
||
accidentCaseHardcodedTurns,
|
||
containsBoundarySemantics,
|
||
engineMeaningToDisplayCopy,
|
||
isAcceptableOpeningBody,
|
||
listUserVisibleCopy,
|
||
nonConvergingRangeNarration,
|
||
openingSpokenBody,
|
||
stopReasonPrefix,
|
||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
|
||
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
|
||
|
||
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
|
||
export const ACCIDENT_CASE_BEFORE_COPY = {
|
||
openingCollect: "请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。",
|
||
discriminatorRedirect: "请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。",
|
||
unclearFocusReply: "我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。",
|
||
staleQuestion: "当前问题已更新,请刷新后重新作答。",
|
||
intermediateRange: "当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。",
|
||
financeCollect: "有没有记得住时间的收入变化、大笔支出或欠债?",
|
||
deliveryAdopt: "当前可信区间是 05:00–05: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);
|
||
// 原值: 交付旁白带「已经从最初的 30 分钟收到…这 7 分钟」
|
||
// 新值: 三句交付(范围、对照经历、边界句);收窄进度只留在中间轮
|
||
// 原因: BUG-597 决策 1,不预标排盘用
|
||
assert.match(after.deliveryAdopt, /这次给出的范围 05:00–05:07/);
|
||
assert.doesNotMatch(after.deliveryAdopt, /排盘用/);
|
||
assert.match(after.deliveryAdopt, /对照了 \d+ 件经历/);
|
||
assert.doesNotMatch(after.deliveryAdopt, /30 分钟/);
|
||
assert.equal(containsBoundarySemantics(after.deliveryAdopt), true);
|
||
assert.doesNotMatch(after.intermediateRange, /不是已确认的唯一出生分钟/);
|
||
assert.match(after.intermediateRange, /05:00–05: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("a single-minute range is not spoken twice as range and representative", () => {
|
||
const text = nonConvergingRangeNarration({
|
||
credibleRange: ["05:15", "05:15"],
|
||
representativeTime: "05:15",
|
||
});
|
||
assert.match(text, /眼下更站得住的是 05:15/);
|
||
assert.doesNotMatch(text, /代表分钟 05:15/);
|
||
assert.doesNotMatch(text, /更站得住的范围是 05:15/);
|
||
assert.equal(containsBoundarySemantics(text), true);
|
||
assert.equal(RECTIFICATION_USER_COPY.adoptCue, "我按你说的经历认真分析过了,下面是这次的结果。");
|
||
});
|
||
|
||
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:47–04: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:02–14:43 这 41 分钟/);
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.insufficientEventsGate));
|
||
assert.equal(stopReasonPrefix("probe_pool_exhausted"), RECTIFICATION_USER_COPY.probePoolExhaustedStop);
|
||
});
|
||
|
||
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/);
|
||
// 旧:不要举大学、工作、搬家的例子 / 每轮正文 2-4 句 / 接下来我们继续
|
||
// 新:开场点六类、证据轮一句复述
|
||
// 原因:BUG-604 / BUG-606
|
||
assert.match(agent, /升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤/);
|
||
assert.match(agent, /证据轮正文只写一句复述/);
|
||
assert.match(agent, /记下了:2016 年 9 月入学、2020 年 6 月毕业/);
|
||
assert.match(agent, /正文不得断言界面当前状态/);
|
||
assert.doesNotMatch(agent, /每轮正文 2-4 句/);
|
||
assert.doesNotMatch(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("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus", () => {
|
||
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
|
||
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||
const followup = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
|
||
const copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
|
||
assert.doesNotMatch(tools, /\?\? GENERIC_COLLECT_QUESTION/);
|
||
assert.doesNotMatch(answerChoice, /\?\? GENERIC_COLLECT_QUESTION/);
|
||
assert.match(followup, /openingOther/);
|
||
assert.match(followup, /followup\.method_id === "dasha_events"/);
|
||
assert.match(followup, /collect:other:/);
|
||
const banned = "也可以再" + "说一件";
|
||
assert.equal(copy.includes(banned), false);
|
||
assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false);
|
||
});
|
||
|
||
test("settled assistant body with a focus has no question-mark sentences", () => {
|
||
const stem = "你大概是哪一年搬的家?";
|
||
const linked = attachQuestionsToTurns([{
|
||
id: TURN_ID,
|
||
role: "assistant",
|
||
text: "范围收到 05:00–05:10。你大概哪一年搬过家?",
|
||
}], [{
|
||
id: FOCUS_ID,
|
||
caseId: CASE_ID,
|
||
questionId: "collect:relocation:collect_method_evidence",
|
||
intent: "collect_method_evidence",
|
||
targetEvidenceId: null,
|
||
targetDomain: "relocation",
|
||
targetKind: null,
|
||
expectedAnswerSchema: { prompt: stem, collect: true },
|
||
status: "active",
|
||
askedAt: "2026-09-07T00:00:00.000Z",
|
||
resolvedAt: null,
|
||
askedTurnId: TURN_ID,
|
||
}]);
|
||
assert.doesNotMatch(linked[0]?.text ?? "", /[??]/);
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
|
||
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
|
||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||
assert.match(agentRun, /stripQuestionSentences/);
|
||
assert.match(agentRun, /withRangeChangedAfterEvidence/);
|
||
});
|
||
|
||
test("delivery narration is three sentences and the range card replaces minute cards", () => {
|
||
const choice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
const card = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
|
||
const accept = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8");
|
||
assert.doesNotMatch(choice, /withProspectiveWindows/);
|
||
assert.doesNotMatch(choice, /候选 A 预测/);
|
||
assert.doesNotMatch(choice, /withDeferredCareerWindow/);
|
||
assert.match(choice, /appendPostAdoptProspectiveWindows/);
|
||
assert.match(chat, /<RectificationRangeDelivery/);
|
||
assert.doesNotMatch(chat, /相对支持度/);
|
||
assert.doesNotMatch(card, /相对支持度|置信度/);
|
||
assert.match(card, /rangeDeliveryRelativeLikelihood/);
|
||
assert.match(card, /rangeDeliveryMoreLikeThis/);
|
||
assert.match(card, /rangeDeliveryNoWindow/);
|
||
assert.match(accept, /mode: z\.literal\("range"\)\.optional\(\)/);
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.deferredCareerWindow));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryAdopting));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryNoWindow));
|
||
});
|
||
|
||
test("probe explain tracks use the shared Vimshottari / Narayana labels", () => {
|
||
const explain = readFileSync(new URL("../src/lib/rectification-agentic/v9/probe-explain.ts", import.meta.url), "utf8");
|
||
const visible = listUserVisibleCopy().join("\n");
|
||
assert.doesNotMatch(explain, /毗湿奴多利|那罗延/);
|
||
assert.doesNotMatch(visible, /毗湿奴多利|那罗延/);
|
||
});
|
||
|
||
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/);
|
||
});
|
||
|
||
test("skill forbids computing D9/D10 signs from transition clocks", () => {
|
||
const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8");
|
||
const comparison = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/references/candidate-comparison.md", import.meta.url), "utf8");
|
||
const sentence = "分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算";
|
||
assert.match(skill, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||
assert.match(comparison, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||
});
|
||
|
||
test("opening body lists domains without years and the stem no longer lists year examples", () => {
|
||
const body = openingSpokenBody(["04:45", "05:15"]);
|
||
assert.equal(isAcceptableOpeningBody(body), true);
|
||
assert.ok(OPENING_COLLECT_DOMAINS.filter((domain) => body.includes(domain)).length >= 5);
|
||
assert.doesNotMatch(body, /(?:19|20)\d{2}/);
|
||
assert.match(body, /最后给区间和代表分钟,不给精确到秒/);
|
||
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
|
||
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
|
||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.firstDatedCollectInvite));
|
||
assert.doesNotMatch(listUserVisibleCopy().join("\n"), /还有吗|还能想起别的吗/);
|
||
assert.ok(MACHINE_VOICE_LEXICON.includes("领先"));
|
||
assert.ok(MACHINE_VOICE_LEXICON.includes("落后"));
|
||
});
|