Files
Jyotisha/frontend/tests/consultation-voice-contract.test.ts
T
Jesse_ChenandClaude Fable 5.1 9d01757f27
Independent Staging Quality Gate / validate (push) Successful in 14m58s
Independent Staging Quality Gate / publish (push) Successful in 3m40s
fix(consult): a stored smalltalk reply is not a prior answer; summary counts as one (acceptance fix for BUG-1071)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-27 23:43:57 +08:00

192 lines
14 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";
const voice = readFileSync(new URL("../src/mastra/product-voice.ts", import.meta.url), "utf8");
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const binding = readFileSync(new URL("../src/mastra/skill-binding.ts", import.meta.url), "utf8");
const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8");
test("product voice uses a spoken opener then the skill Level 2 report skeleton", () => {
assert.match(voice, /VISIBLE VOICE/);
assert.match(voice, /natalSpokenReportContract/);
assert.match(voice, /governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES/);
assert.match(voice, /Raman six-step house judgment/);
assert.match(voice, /Yoga table/);
assert.match(voice, /Do not write 统一参数与原始结构 or 技法审计表 into the body/);
assert.match(voice, /governed_support_only_candidate_ready/);
// 原值: assert.match(voice, /Skip an executed layer the question needs/)
// 新值: 「不许为了短而漏掉问题需要的卡上事实」;写答案的模型只读数据卡
// 原因: TASK-consult-evidence-card-20260927 D1/T4(推翻「用尽每个已执行层」的口径)
assert.match(voice, /Skip a card fact the question needs/);
assert.doesNotMatch(voice, /Skip an executed layer/);
// 原值: assert.match(voice, /你更适合把已经积累的专业能力做成长期事业/) —— 它是 Good 例。
// 新值: 同一句改断言成 Bad 例(下方 voice-opener-shape 测试里锁住它在 Bad 段)。
// 原因: 产品判定这句像顾问报告——没有反差、没说谁在推谁在修、没有可扮演的象。
// 原值: assert.match(voice, /3–6 heading-free sentences/)
// 新值: assert.match(voice, /open with the OPENER SHAPE above, heading-free/)
// 原因: 开场从「3–6 句」改成固定形状,句数不再是约束。
assert.match(voice, /open with the OPENER SHAPE above, heading-free/);
// 原值: assert.match(voice, /one conclusion sentence, then 2–3 short point-sentences/)
// 新值: assert.match(voice, /别去应 X 的象,去扮演 Y 的象/)
// 原因: 旧形状(结论+要点+下一步)被反差→谁推谁修→扮演象→短行动取代。
assert.match(voice, /别去应 X 的象,去扮演 Y 的象/);
assert.match(voice, /Spoken-layer total ≤ 400 Chinese characters/);
assert.match(voice, /骨架不可省略,但必须以直接回应开场/);
assert.match(mastra, /productConversationVoice/);
assert.match(mastra, /natalSpokenReportContract/);
assert.match(mastra, /skill Level 2 report skeleton/);
assert.match(mastra, /Do not paste the Technique Audit Table into the answer body/);
assert.match(binding, /Follow this method and its truth boundaries/);
assert.match(binding, /present its Level 2 report template in the chat body after a 3-6 sentence spoken reply/);
assert.match(mastra, /The bound skill method is this product's answering contract/);
assert.match(mastra, /Do not use a restricted technique/);
// 原值: assert.match(mastra, /must_use_layers is the executed shortlist/)
// 新值: 数据卡是这次回答的清单;卡外数据只能用补取工具取一次
// 原因: TASK-consult-evidence-card-20260927 D1/D3/T4,must_use_layers 与审计表不再进模型上下文
assert.match(mastra, /The evidence card is the shortlist for this answer/);
assert.match(mastra, /call read-consultation-evidence once, before writing any answer text/);
assert.doesNotMatch(mastra, /must_use_layers is the executed shortlist|Use every executed layer/);
assert.match(mastra, /do not invent it from model knowledge/);
assert.match(mastra, /search_period is the searched observation window/);
// 原值: assert.match(voice, /## 先回答你的问题/)
// 新值: 正文只有三个标题,「先回答你的问题」不再出现在合同里;开场不带行动、行动只在最后一节、首轮 ≤ 900 字
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,同一判断不再说三遍
assert.doesNotMatch(voice, /## 先回答你的问题/);
assert.match(voice, /## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事/);
assert.match(voice, /The opener carries no action list/);
assert.match(voice, /Say each thing once \(D8\)/);
assert.match(voice, /First-turn total ≤ 900 Chinese characters/);
assert.match(voice, /cite only the fields that support this judgment, and only what the opener did not already say/);
assert.match(voice, /One to three actions for this week, here and nowhere else/);
assert.doesNotMatch(voice, /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton above/);
assert.match(voice, /a follow-up turn follows the shape instruction carried in the user turn/);
assert.match(mastra, /each thing said once, first-turn total ≤ 900 characters/);
assert.match(mastra, /The checklist is a list of things to check, not a list of paragraphs to write/);
assert.match(binding, /no action list in the opener/);
assert.match(binding, /the actions, once/);
assert.match(mastra, /consultationSpokenHeadingRule\("natal"\)/);
assert.match(workflow, /template: "skill_level_2"/);
assert.match(workflow, /raman_six_step/);
assert.doesNotMatch(mastra, /Name the domains you did cover, say plainly that the remaining ones were not calculated/);
assert.doesNotMatch(mastra, /2-5 short paragraphs/);
});
test("general and window get voice without the natal Level 2 skeleton", () => {
// 原值: 另切 onboardingInstructions,断言它没有 natal 骨架。
// 新值: onboarding agent 已删除;general 与 window 仍无骨架。
// 原因: 建议问题生成接口下线。
const generalStart = mastra.indexOf("const generalJyotishInstructions");
const windowStart = mastra.indexOf("const windowJyotishInstructions");
const natalStart = mastra.indexOf("const jyotishInstructions");
const general = mastra.slice(generalStart, mastra.indexOf("const dailyStarlanguageInstructions"));
const window = mastra.slice(windowStart, mastra.indexOf("export function getWindowJyotishAgent"));
const natal = mastra.slice(natalStart, mastra.indexOf("export function getJyotishAgent"));
assert.match(natal, /natalSpokenReportContract/);
// 新增: natal 也必须带 productConversationVoice —— 开场形状对三种模式共用。
assert.match(natal, /productConversationVoice/);
assert.match(general, /productConversationVoice/);
assert.doesNotMatch(general, /natalSpokenReportContract/);
assert.match(window, /productConversationVoice/);
const exemption = "只是打招呼、道谢、告别时,回一句话,不要套开场形状";
const sharedVoice = voice.slice(0, voice.indexOf("export const natalSpokenReportContract"));
for (const instructions of [natal, general, window]) {
// Expand only the shared voice literal; no runtime Skill filesystem is needed.
assert.ok(instructions.replace("${productConversationVoice}", sharedVoice).includes(exemption));
}
assert.doesNotMatch(window, /natalSpokenReportContract/);
assert.doesNotMatch(mastra, /const onboardingInstructions/);
});
test("consult user turn no longer dumps tool JSON that the model would parrot", () => {
const chartBranch = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse"));
// 原值: /先用 3–6 句口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 新值: /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 原因: 用户回合文案与新开场形状对齐,约束从句数改成字数加形状。
assert.match(chartBranch, /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.match(chartBranch, /别去应 X 的象,去扮演 Y 的象/);
assert.doesNotMatch(chartBranch, /JSON\.stringify\(toolInput\)/);
assert.doesNotMatch(chartBranch, /经过服务端校验的工具参数/);
assert.match(route, /文末技法审计表/);
assert.match(chartBranch, /await runConsultationWorkflow\(toolInput/);
});
test("the opener is a shape — 反差, 谁推谁修, 扮演哪个象, then at most three actions", () => {
assert.match(voice, /OPENER SHAPE \(natal \/ general \/ declared-window 三种模式共用\)/);
assert.match(voice, /反差/);
assert.match(voice, /表面 A,底下 B/);
assert.match(voice, /谁在推、谁在修/);
assert.match(voice, /扮演/);
assert.match(voice, /最多三条短行动/);
assert.match(voice, /≤ 20 Chinese characters/);
// 降级路线:申报时段与无出生分钟都不编月份,但「扮演哪个象」照给。
assert.match(voice, /申报时段(declared birth window)[\s\S]*?也不写月份。第 3 步照给/);
assert.match(voice, /无出生分钟(no birth minute)[\s\S]*?不写月份。第 3 步照给/);
});
test("persona is the sharp-but-reliable colleague, and hope never becomes a platitude", () => {
assert.match(voice, /把对面的人当一个人认真对待/);
assert.match(voice, /嘴有点毒但靠谱的同事/);
assert.match(voice, /毒只对处境,不对人/);
// 原值: assert.match(voice, /这句我按『……』理解了,不对你纠正我/)
// 新值: 该规则删除;只有两种实质不同读法时才写「我按 A 答」,单义问题直接答、不改写
// 原因: TASK-consult-answer-the-question-20260927 D4 / BUG-1070:这条规则让模型先改写问题再答(「是不是 X」→「你是否需要 Y」)
assert.doesNotMatch(voice, /这句我按『……』理解了/);
assert.match(voice, /只有当这句话有两种实质不同的读法时,才写一句「我按 A 答」/);
assert.match(voice, /单义的问题直接答,不改写它/);
assert.match(voice, /HOPE DISCIPLINE/);
assert.match(voice, /answer_policy\.can_answer_precise_timing 允许精确应期时/);
assert.match(voice, /不是每个人都有转机,但每个人都有能扮演的象/);
// 空话必须留在禁令里,但不得出现在 Good 例里。
assert.match(voice, /Never write 一切都会好, 相信自己, 加油, 你值得更好的, or 宇宙自有安排。?/);
assert.match(voice, /No 亲爱的, no 感谢你的信任, no emoji/);
});
test("the pinned Good example carries the new shape; the old consultant-report line is now Bad", () => {
const goodStart = voice.indexOf("Good (口气与形状的定稿样例");
const goodEnd = voice.indexOf("Good (追问轮");
assert.ok(goodStart > -1 && goodEnd > goodStart);
const good = voice.slice(goodStart, goodEnd);
assert.match(good, /今年你的事业是「外松内紧」/);
assert.match(good, /推动这一年的是土星,不是木星/);
assert.match(good, /去扮演土星的「守」/);
assert.match(good, /- 9 月木星换宫之后再谈升职,之前谈是白谈。/);
// 拍板样例里不得出现空话安慰;禁令本身仍写在 HOPE DISCIPLINE 段。
assert.doesNotMatch(good, /一切都会好|相信自己|加油|宇宙自有安排/);
assert.match(voice, /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:这份工作还值得留/);
const badLine = voice.split("\n").find((line) => line.startsWith("Bad: 你更适合把已经积累的专业能力做成长期事业"));
assert.ok(badLine, "旧 Good 例必须改挂在 Bad 上");
assert.match(badLine!, /像顾问报告:没有反差,没说谁在推谁在修,没有可扮演的象/);
});
test("the voice answers the sentence asked: no motive guessing, yes/no first, contrast on the chart (BUG-1070)", () => {
const forbidden = ["你其实想问的是", "你真正在意的是", "你要的是", "你是否需要", "你问这个是因为", "你更想知道"];
const discipline = voice.slice(voice.indexOf("不揣测(回答对象是用户问的那句话)"), voice.indexOf("Five principles"));
assert.ok(discipline.length > 0, "the no-guessing block sits in the shared voice before the principles");
for (const phrase of forbidden) assert.ok(discipline.includes(`「${phrase}」`), phrase);
assert.match(discipline, /任何替用户或当事人陈述动机、需求、感受的句子/);
assert.match(discipline, /不得写「她问的是结果,你要的是被听」/);
assert.match(discipline, /是非题(是不是 \/ 会不会 \/ 能不能 \/ 有没有 \/ ……吗):第一句就是「是」「不是」或「看情况:……」/);
// The contrast is a chart structure, never the person's mind (D2).
assert.match(voice, /A 和 B 都必须是盘上的结构——哪颗星、哪个宫、哪段大运、哪张分盘/);
assert.match(voice, /反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思/);
// Actions stay in the opener only where there is no body skeleton.
assert.match(voice, /本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」/);
assert.match(voice, /无出生分钟与申报时段没有正文骨架,行动就写在开场结尾/);
// Yes/no Good and Bad pair, fictional chart.
assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/);
assert.match(voice, /不是。4 宫主水星逆行落 10 宫/);
assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/);
// 原值: "…wants, needs, or feels; state where the chart puts them"(Never 列表里第二句读成也被禁止)
// 新值: 两句拆开,第二句是替代做法 原因: Claude 验收改措辞
assert.match(voice, /State what the user or the person asked about wants, needs, or feels\. Say where the chart puts them instead/);
// The three-mode shared voice still carries all of it (general and window expand the same literal).
const sharedVoice = voice.slice(0, voice.indexOf("export const natalSpokenReportContract"));
assert.ok(sharedVoice.includes("不揣测(回答对象是用户问的那句话)"));
});