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: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); assert.match(after.deliveryAdopt, /05:00–05: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: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("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)); }); 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/); });