Files
Jyotisha/frontend/tests/consultation-voice-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 6a372bc3a8 refactor(consult): state the answer shape once in the natal prompt (BUG-1256 step 1)
The English shape summary sat in the system prompt four times (contract,
index, natal heading rule, skill skeleton) and the contract restated the
Chinese ANSWER SHAPE point by point. Keep the summary in the contract only and
drop the restatements; the Chinese ANSWER SHAPE stays the one full statement.
General answer-policy bullets no longer sit under the reference_transparency
header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 08:36:05 +08:00

439 lines
37 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 句」改成固定形状,句数不再是约束。
// 原值(2): assert.match(voice, /open with the OPENER SHAPE above, heading-free/)
// 新值(2): 合同改为跟随 ANSWER SHAPE,并引用唯一的形状摘要 NATAL_ANSWER_SHAPE_SUMMARY
// 原因(2): TASK-consult-plain-answer-20261001 D1/D7(BUG-1132):四步开场形状下线,形状只定义一处
assert.match(voice, /follow the ANSWER SHAPE above\. \$\{NATAL_ANSWER_SHAPE_SUMMARY\}/);
// 原值: assert.match(voice, /one conclusion sentence, then 2–3 short point-sentences/)
// 新值: assert.match(voice, /别去应 X 的象,去扮演 Y 的象/)
// 原因: 旧形状(结论+要点+下一步)被反差→谁推谁修→扮演象→短行动取代。
// 原值(2): assert.match(voice, /别去应 X 的象,去扮演 Y 的象/); assert.match(voice, /Spoken-layer total ≤ 400 Chinese characters/)
// 新值(2): 两句都不得再出现;改锁「不设字数上限,讲清楚为准」
// 原因(2): TASK-consult-plain-answer-20261001 D1/D8:扮演象修辞下线,产品取消字数上限
assert.doesNotMatch(voice, /扮演|≤ 400|Spoken-layer total/);
// 原值: assert.match(voice, /不设字数上限,讲清楚为准/) 与 /骨架不可省略,但必须以直接回应开场/
// 新值: 首轮通常三到六段、不设硬截断;合同里不再有「骨架不可省略」
// 原因: TASK-consult-conversational-answer-20261006 D1/D3(BUG-1244)
assert.match(voice, /首轮通常三到六段/);
assert.match(voice, /不设硬截断/);
assert.doesNotMatch(voice, /骨架不可省略/);
assert.match(mastra, /productConversationVoice/);
assert.match(mastra, /natalSpokenReportContract/);
// 原值: assert.match(mastra, /skill Level 2 report skeleton/)
// 新值: index 只指回「ANSWER SHAPE above, stated once there」
// 原因: 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.match(mastra, /The answer shape is the ANSWER SHAPE above, stated once there/);
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 as the evidence of the product's answer shape, not as the shape itself/)
// 新值: Level 2 模板只是依据来源,六步宫位和 Yoga 表不贴进正文标题下
// 原因: TASK-consult-conversational-answer-20261006 D1/D6(BUG-1244)
// 原值(2): /present its Level 2 report template as a source of evidence, not as the answer's shape\. \$\{NATAL_ANSWER_SHAPE_SUMMARY\}/、/they are not pasted under a heading/
// 新值(2): 骨架句只说 Level 2 模板是依据来源、形状在产品口吻里只说一次;不再嵌形状摘要
// 原因(2): 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.match(binding, /the Level 2 report template is a source of evidence, not the answer's shape: the shape is the ANSWER SHAPE stated once in the product voice/);
assert.doesNotMatch(binding, /NATAL_ANSWER_SHAPE_SUMMARY/);
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, /## ${REPORT_HEADING.support}…## ${REPORT_HEADING.timing}…## ${REPORT_HEADING.action}/)
// 新值: 多人各写一段、段首点名;合同不再要求三个固定 ## 标题
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244)
// 原值(2): /When the user names more than one person, one paragraph each …/(英文合同第 2 条)
// 新值(2): 同一规则只在中文 ANSWER SHAPE 第 2 条
// 原因(2): 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.match(voice, /问「父母」时妈妈和爸爸分开写,不揉成一段/);
assert.doesNotMatch(voice, /When the user names more than one person, one paragraph each/);
assert.doesNotMatch(voice, /## \$\{REPORT_HEADING/);
// 原值: assert.match(voice, /The opener carries no action list/)
// 新值: 「开场不写行动」写在共享 ANSWER SHAPE 第 1 步与形状摘要里
// 原因: TASK-consult-plain-answer-20261001 D2:合同不再复述开场细节,引用唯一定义
assert.match(voice, /开场不写标题、不写行动/);
// 原值: /Say each thing once \(D8\)/ 新值: 中文「同一件事只说一遍」 原因: 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.match(voice, /同一件事只说一遍/);
// 原值: assert.match(voice, /First-turn total ≤ 900 Chinese characters/)
// 新值: 没有字数上限,写到讲清楚为止
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1073 的 900 字,保留「只说一遍」)
assert.doesNotMatch(voice, /900/);
// 原值: assert.match(voice, /There is no length cap: write until it is clear, and stop there/)
// 新值: 首轮通常三到六段,不设硬截断
// 原因: TASK-consult-conversational-answer-20261006 D3(BUG-1244)
// 原值(2): /The first answer is usually three to six paragraphs/、/There is no hard cutoff/(合同英文复述)
// 新值(2): 只锁中文「首轮通常三到六段」「不设硬截断」(上方已断言),英文由形状摘要携带
// 原因(2): 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.doesNotMatch(voice, /The first answer is usually three to six paragraphs/);
// 原值: /cite only the fields that support this judgment, and only what the opener and the sections did not already cite/
// 新值: 依据留在括号里,六步宫位和 Yoga 表是来源,不是一节
// 原因: TASK-consult-conversational-answer-20261006 D6(BUG-1244)
assert.match(voice, /Raman six-step house judgment and the Yoga table are sources/);
// 原值: assert.match(voice, /One to three actions for this week, here and nowhere else/)
// 新值: 行动不强制,盘上指向具体事情时才顺口一句
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
// 原值(2): /An action is not required/(合同第 5 条) 新值(2): 中文 ANSWER SHAPE 第 4 条「行动不强制」 原因(2): 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.match(voice, /4\. 行动不强制/);
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/)
// 新值: 指令引用 NATAL_ANSWER_SHAPE_SUMMARY(摘要里写「无字数上限、只说一遍」),不再含 900
// 原因: TASK-consult-plain-answer-20261001 D7/D8
// 原值(2): /Write in Simplified Chinese\. \$\{NATAL_ANSWER_SHAPE_SUMMARY\}/
// 新值(2): index 不再嵌形状摘要
// 原因(2): 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.doesNotMatch(mastra, /\$\{NATAL_ANSWER_SHAPE_SUMMARY\}/);
assert.doesNotMatch(mastra, /≤ 900|≤ 400|反差|扮演/);
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/)
// 新值: 同一句改由引用的 NATAL_ANSWER_SHAPE_SUMMARY 携带(见下方 shape-summary 测试)
// 原因: TASK-consult-plain-answer-20261001 D7:形状只定义一处
assert.doesNotMatch(binding, /反差|扮演|≤ 400/);
// 原值: assert.match(binding, /the actions appear once/)
// 新值: 骨架句不再要求行动出现一次;行动是否写由形状摘要决定
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
assert.doesNotMatch(binding, /the actions appear 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/
// 原因: 用户回合文案与新开场形状对齐,约束从句数改成字数加形状。
// 原值(3): /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/ 与 /别去应 X 的象,去扮演 Y 的象/
// 新值(3): 用户回合引用 natalAnswerShapeBody(),路由源码里不再有字数上限和扮演象
// 原因(3): TASK-consult-plain-answer-20261001 D1/D7/D8
assert.match(chartBranch, /natalAnswerShapeBody\(\)[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.doesNotMatch(route, /扮演|不超过 400 字|不超过 900 字/);
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"(锁 OPENER SHAPE 四步、≤ 20 字行动、降级路线「第 3 步照给」)
// 新值: 同位置改锁 ANSWER SHAPE:先答、按对象分段、人话自检与禁止清单、空宫规则、无字数上限、降级路线两条
// 原因: TASK-consult-plain-answer-20261001 D1–D4/D8(BUG-1132/1134):产品推翻 09-17 四步开场形状
test("the answer is a shape — conclusion first, one section per person, plain words checked, no length cap (BUG-1132)", () => {
assert.match(voice, /ANSWER SHAPE \(natal \/ general \/ declared-window 三种模式共用\)/);
assert.doesNotMatch(voice, /OPENER SHAPE|表面 A,底下 B|谁在推|命名成一个格局|外松内紧/);
// 原值: assert.match(voice, /不设字数上限,讲清楚为准。同一件事只说一遍/)
// 新值: 首轮三到六段、不设硬截断;追问轮仍讲清楚为准;同一件事只说一遍
// 原因: TASK-consult-conversational-answer-20261006 D3(BUG-1244)
assert.match(voice, /首轮通常三到六段,讲清结论、最关键的一两条依据,相关时带上时间,细节等追问;不设硬截断。追问轮讲清楚为准。同一件事只说一遍/);
assert.match(voice, /开场一到三句人话,第一句就是结论/);
// 原值: /按对象分段:问题里点到几个对象/
// 新值: /按对象分段落:问题里点到几个对象/,段首点名、不写二级标题
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244)
assert.match(voice, /按对象分段落:问题里点到几个对象/);
assert.match(voice, /段首用人话点名/);
assert.match(voice, /问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段/);
assert.match(voice, /然后在括号里给出撑住这段判断的盘上依据/);
assert.match(voice, /每段最多一处括号/);
assert.match(voice, /人话自检:把每句话括号里的内容删掉,一个完全不懂占星的人仍然能看懂它在说什么/);
for (const banned of ["自造格局名、四字概括", "把大运、行运写成拟人的角色", "把星名当形容词的主人", "括号外每段最多一个占星术语"]) {
assert.ok(voice.includes(banned), banned);
}
assert.match(voice, /推断边界:空宫不单独下结论——要说这个宫,就说它的宫主落在哪、什么状态/);
// 原值: assert.match(voice, /一到三条破折号短句/)
// 新值: 行动不强制
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
assert.match(voice, /行动不强制:只有盘上确实指向一件和问题相关的具体事情时/);
assert.doesNotMatch(voice, /一到三条破折号短句/);
// 降级路线:申报时段与无出生分钟都不编月份;形状照给。
assert.match(voice, /申报时段(declared birth window):依据只取窗口内稳定的那一层[\s\S]*?也不写月份。/);
assert.match(voice, /无出生分钟(no birth minute):时间只讲公开日历这一天的大势,不点个人大运,不写月份/);
assert.doesNotMatch(voice, /≤ 20 Chinese characters|≤ 400|≤ 900/);
});
test("one shape definition: contract, thinking plan, user turn and agent prompts share the headings and summary (D7)", async () => {
const { NATAL_ANSWER_SHAPE_SUMMARY, REPORT_HEADING, consultationSpokenHeadingRule, natalAnswerShapeBody } = await import("../src/lib/consultation-thinking-plan.ts");
const { natalSpokenReportContract } = await import("../src/mastra/product-voice.ts");
assert.equal(REPORT_HEADING.support, "盘上依据");
// 原值: 形状摘要、合同、首轮正文都含 `## ${heading}`
// 新值: 三处都引用同一份无标题摘要,且不要求这些 ## 节名
// 原因: TASK-consult-conversational-answer-20261006 D1/D7(BUG-1244)
for (const heading of Object.values(REPORT_HEADING)) {
assert.equal(NATAL_ANSWER_SHAPE_SUMMARY.includes(`## ${heading}`), false, heading);
assert.equal(natalSpokenReportContract.includes(`## ${heading}`), false, heading);
assert.equal(natalAnswerShapeBody().includes(`## ${heading}`), false, heading);
}
// 原值: 合同与 natal 标题规则都包含形状摘要(另有 index、skill 骨架两处,系统提示里共 4 份)
// 新值: 只有合同包含;标题规则、index、skill 骨架都不再嵌入
// 原因: 2026-10-07 提示词去重(BUG-1256):英文形状摘要只在本命合同里保留一份,其余三处与合同里逐条复述中文 ANSWER SHAPE 的句子删除
assert.ok(natalSpokenReportContract.includes(NATAL_ANSWER_SHAPE_SUMMARY));
assert.equal(consultationSpokenHeadingRule("natal").includes(NATAL_ANSWER_SHAPE_SUMMARY), false);
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /usually three to six paragraphs/);
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /no hard cutoff/);
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /no conclusion from an empty house alone/);
for (const text of [NATAL_ANSWER_SHAPE_SUMMARY, natalSpokenReportContract, natalAnswerShapeBody(), consultationSpokenHeadingRule("natal")]) {
assert.doesNotMatch(text, /扮演|谁在推|命名成一个格局|外松内紧|≤ 400|≤ 900|不超过 \d+ 字/);
}
});
test("persona is the warm, knowing older sister, and hope never becomes a platitude", () => {
assert.match(voice, /把对面的人当一个人认真对待/);
// 原值: /嘴有点毒但靠谱的同事/、/毒只对处境,不对人/
// 新值: 懂行、说话温和的姐姐;不顺照实说但说法顾着对方;不说教不催;温柔不靠语气词
// 原因: 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /像一个懂行、说话温和的姐姐/);
assert.doesNotMatch(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, /不是每个人都有转机,但每个人都有能扮演的象/)
// 新值: assert.match(voice, /不是每个人都有转机,但每个人这段时间都有能做的事/)
// 原因: TASK-consult-plain-answer-20261001 D5:希望纪律保留,删「扮演哪个象」说法
assert.match(voice, /不是每个人都有转机,但每个人这段时间都有能做的事/);
// 原值: /每次都要说清这段时间能做什么/
// 新值: 不顺的时候说清这段时间可以怎么过,一句话说进正文
// 原因: 与「行动不强制」(BUG-1244)对齐;2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /不顺的时候说清这段时间可以怎么过/);
assert.doesNotMatch(voice, /每次都要说清这段时间能做什么/);
// 空话必须留在禁令里,但不得出现在 Good 例里。
assert.match(voice, /Never write 一切都会好, 相信自己, 加油, 你值得更好的, or 宇宙自有安排。?/);
// 原值: /No 亲爱的, no 感谢你的信任, no emoji/(英文 Five principles)
// 新值: 中文「说话的方式」;删掉与「只说一遍」冲突的 restate 一条和校正用的 say progress 一条
// 原因: 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.doesNotMatch(voice, /Five principles|restate the same constraint|say progress when/);
assert.match(voice, /说话的方式:先回答人,再展开方法/);
assert.match(voice, /不写「感谢你的信任」/);
});
// 原值: test "the pinned Good example carries the new shape; the old consultant-report line is now Bad"(锁「外松内紧」定稿样例、扮演土星的守、顾问报告 Bad 的理由「没有反差…没有可扮演的象」)
// 新值: 锁父母题按对象分段的 Good、单对象不拆段的 Good、格局名/大运拟人的 Bad;顾问报告 Bad 保留、理由改成「没有盘上依据」
// 原因: TASK-consult-plain-answer-20261001 D1/D2/T1(BUG-1132):09-17 定稿样例随形状一起下线
test("the pinned Good examples split by person and stay plain; the coined-pattern opener is Bad", () => {
// 原值: indexOf("Good (父母题首轮,按对象分段;盘上数据是示意)") / indexOf("Good (单对象事业题首轮,不拆段;盘上数据是示意)")
// 新值: 两个标签改为「括号里是依据的占位,只演示口气与结构,不是读法」,事业例再加「不预设读者在上班、做哪类工作」
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D3/D7(BUG-1165/1168):示例不得携带读法规则与职业形态
// 原值: indexOf("Good (父母题首轮,按对象分段;…)")
// 新值: 标签改为「分段落、段首点名」
// 原因: TASK-consult-conversational-answer-20261006 D1/D4(BUG-1245)
const parentsStart = voice.indexOf("Good (父母题首轮,分段落、段首点名;括号里是依据的占位,只演示口气与结构,不是读法)");
const careerStart = voice.indexOf("Good (单对象事业题首轮,不拆段;括号里是依据的占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作)");
const badStart = voice.indexOf("Bad (多对象题,改写示意)");
assert.ok(parentsStart > -1 && careerStart > parentsStart && badStart > careerStart);
const parents = voice.slice(parentsStart, careerStart);
// 原值: 父母示范含 ## 你和妈妈 / 你和爸爸 / 他们怎么对你 / 时间怎么看 / 这周可以做的一件事
// 新值: 段首点名,示范里没有任何 ##
// 原因: TASK-consult-conversational-answer-20261006 D1/D4(BUG-1245)
assert.match(parents, /你妈妈这边/);
assert.match(parents, /爸爸那边/);
assert.doesNotMatch(parents, /^## /m);
// 原值: assert.match(parents, /(4 宫母亲宫的宫主水星落在 10 宫事业宫;代表母亲的月亮也在 10 宫)/)
// 新值: 括号只放占位(4 宫主落 10 宫;月亮在 10 宫);爸爸段演示「受冲先说、用可能、段末请确认」;不再有「分量重」「问爸爸的意见」
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D1/D3(BUG-1164/1165):旧例预设父亲在场,回测里缺席的父亲被照抄成「话少、分量重」
// 原值(3): /(4 宫主落 10 宫;月亮在 10 宫)/
// 新值(3): /(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/
// 原因(3): 第五轮回测前修(BUG-1182):梦露盘 4 宫主正落 10 宫且母亲受冲两个以上,模型照抄「上心、前途」;示例只在没有受冲时演示「近、上心」
assert.match(parents, /(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/);
// 原值(2): /他可能不常在你身边[\s\S]*如果实际情况不是这样,告诉我,我按真实情况重看。/
// 新值(2): 爸爸段写「有距离的迹象」+ 范围 + 「实际是哪一种,告诉我一句」,不断定在不在身边
// 原因(2): 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在身边)
assert.match(parents, /爸爸这条线有距离的迹象[\s\S]*现实里可能是他不常在你身边,也可能人在、只是话少、关系紧。实际是哪一种,告诉我一句,我按真实情况再看。/);
assert.doesNotMatch(parents, /分量重|标杆|爸爸的意见|靠做事/);
const career = voice.slice(careerStart, badStart);
assert.doesNotMatch(career, /## 你和/);
// 原值: assert.match(career, /## 盘上依据/)
// 新值: 单对象示范也是段落,不写 ## 盘上依据
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1245)
assert.doesNotMatch(career, /^## /m);
assert.match(career, /(木星行运 10 宫;土星行运 6 宫)/);
for (const good of [parents, career]) {
assert.doesNotMatch(good, /一切都会好|相信自己|加油|宇宙自有安排|扮演|格局/);
// No coined 外X内Y pattern names and no empty-house verdicts in the pinned examples.
assert.doesNotMatch(good, /「外.内.」|空宫/);
}
const bad = voice.slice(badStart, voice.indexOf("\n", badStart));
assert.match(bad, /自造格局名、大运写成角色、星名当形容词,爸妈揉成一段/);
// 原值: /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:这份工作还值得留/
// 新值: /…说直接点:现在这条路还值得走/
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D7(BUG-1168):示例不预设读者在上班
// 原值(3): /…说直接点:现在这条路还值得走/
// 新值(3): /…我换个说法:这条路还值得走/
// 原因(3): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /Good \(追问轮,用户说「你在说什么鬼」\): 我换个说法:这条路还值得走/);
// 原值: startsWith("Bad: 你更适合把已经积累的专业能力做成长期事业")
// 新值: startsWith("Bad: 你更适合长期做一件事"),理由不变;全篇示例不再有「专业能力」「升职」「做深」「现有工作」
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D7/T2b(BUG-1168):Bad 例的职业形态词被模型反向吸收
assert.doesNotMatch(voice, /专业能力|升职|做深|现有工作|这份工作/);
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 first, then may read feelings within three rules (BUG-1070 → BUG-1255)", () => {
// 原值: 「不揣测」一段禁止任何替用户或当事人陈述动机、需求、感受的句子
// 新值: 「读心(可以读,守三条)」:先答再读;只用可能/多半、一句、不当事实、不改写成需求题;读当事人要有盘上依据且那条线没受冲,受冲只给范围
// 原因: 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
const forbidden = ["你其实想问的是", "你真正在意的是", "你要的是", "你是否需要", "你问这个是因为", "你更想知道"];
const discipline = voice.slice(voice.indexOf("读心(可以读,守三条)"), voice.indexOf("说话的方式:"));
assert.ok(discipline.length > 0, "the mind-reading block sits in the shared voice before the speaking style");
for (const phrase of forbidden) assert.ok(discipline.includes(`「${phrase}」`), phrase);
assert.match(discipline, /先回答问题再读心/);
assert.match(discipline, /只用「可能」「多半」,一句话,不当成事实/);
assert.match(discipline, /用户自己的心全篇最多读一句/);
assert.match(discipline, /读当事人(妈妈、伴侣、老板……)的心要有盘上依据,每人最多一句,且只在这条线没有受冲时写暖的那一面/);
assert.match(discipline, /受冲两个及以上按受冲规则只给范围/);
assert.match(discipline, /不写「她问的是结果,你要的是被听」这种替用户总结需求的话/);
assert.match(discipline, /是非题(是不是 \/ 会不会 \/ 能不能 \/ 有没有 \/ ……吗)第一句就是「是」「不是」或「看情况:……」/);
// 原值(2): /A 和 B 都必须是盘上的结构——…/ 与 /反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思/
// 新值(2): 反差形状下线;「问别人时写宫位与代表星,不写心思」移到对象段规则里
// 原因(2): TASK-consult-plain-answer-20261001 D1(BUG-1070 的目的保留)
// 原值(4): /问的是别人时,…不写那个人的心思/
// 新值(4): 先写宫位与代表星,要读那个人的心按读心第 3 条
// 原因(4): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /问的是别人时,先写那个人在你盘上对应的宫位与代表星说明了什么;要读那个人的心,按上面读心第 3 条/);
// 原值: /本命解读里只写在最后一节「这周可以做的一件事」,出现一次/、/行动就写在回答结尾/
// 新值: 行动不强制,无出生分钟与申报时段也不交固定清单
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
assert.match(voice, /行动不强制:只有盘上确实指向一件和问题相关的具体事情时/);
assert.match(voice, /无出生分钟与申报时段同样不交固定行动清单/);
assert.doesNotMatch(voice, /这周可以做的一件事/);
// Yes/no Good and Bad pair, fictional chart.
assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/);
// 原值: assert.match(voice, /不是。4 宫主水星逆行落 10 宫/)
// 新值: 「不是。(4 宫主落 10 宫)」;示例与提示词里不再有「逆行」,已婚预设的婚姻 Good 例删除
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D2/D3(BUG-1165):「逆行」被照抄成「反复、打回来」
// 原值(2): /不是。(4 宫主落 10 宫)她把心力放在你的前途上/
// 新值(2): /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她把心力放在你的前途上/
// 原因(2): BUG-1182:受冲两个以上的母亲被照抄成「心力在你前途上」;示例括号写明这一读法只在没有受冲时成立
// 原值(3): /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她把心力放在你的前途上/
// 新值(3): 同一前提下读母亲的心与用户的心各一句
// 原因(3): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。会这么问,可能你心里已经憋了一阵子了。/);
assert.doesNotMatch(voice, /逆行|已经在一起的关系/);
assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/);
// 原值: "…wants, needs, or feels; state where the chart puts them"(Never 列表里第二句读成也被禁止)
// 新值: 两句拆开,第二句是替代做法 原因: Claude 验收改措辞
// 原值(5): /State what the user or the person asked about wants, needs, or feels\. Say where the chart puts them instead/
// 新值(5): 禁的是把猜测当事实、改写成需求;读心只在答完后、可能/多半、一句、当事人须有依据且未受冲
// 原因(5): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
assert.match(voice, /State a guess about what the user or another person feels as fact, or rewrite the user's question into a need\./);
// 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("读心(可以读,守三条)"));
// BUG-1134: the empty-house rule is also in the natal Never list.
assert.match(voice, /Draw a conclusion from an empty house alone\. Say where its lord sits and in what state instead/);
});
test("pinned examples carry no copyable action and no weekly heading (BUG-1245)", () => {
const banned = [
"口头答应的事,补一份书面确认",
"身体的小毛病这周就去看",
"给妈妈打电话时先问她最近怎样",
];
for (const phrase of banned) assert.equal(voice.includes(phrase), false, phrase);
assert.equal(voice.includes("## 这周"), false);
assert.match(voice, /Bad \(汇报体,只示意结构\)/);
const goodParents = voice.slice(
voice.indexOf("Good (父母题首轮"),
voice.indexOf("Good (单对象事业题首轮"),
);
const goodCareer = voice.slice(
voice.indexOf("Good (单对象事业题首轮"),
voice.indexOf("Bad (多对象题"),
);
assert.doesNotMatch(`${goodParents}\n${goodCareer}`, /^## /m);
assert.doesNotMatch(`${goodParents}\n${goodCareer}`, /^- /m);
});
test("example paragraphs are blank-line separated and the shape says so (BUG-1244 fix F2)", async () => {
const { NATAL_ANSWER_SHAPE_SUMMARY, natalAnswerShapeBody } = await import("../src/lib/consultation-thinking-plan.ts");
// 聊天正文 `.message p` 是 pre-wrap:单个换行只折行,段间距只给空一行分开的段落。
const goodParents = voice.slice(voice.indexOf("Good (父母题首轮"), voice.indexOf("Good (单对象事业题首轮"));
const goodCareer = voice.slice(voice.indexOf("Good (单对象事业题首轮"), voice.indexOf("Bad (多对象题"));
assert.match(goodParents, /\n\n你妈妈这边/);
assert.match(goodParents, /\n\n爸爸那边/);
assert.match(goodCareer, /\n\n机会和帮你的人/);
assert.match(voice, /段落之间空一行/);
assert.match(natalAnswerShapeBody(), /段落之间空一行/);
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /Separate paragraphs with a blank line/);
});
test("the voice pins Chinese-only terms and one spelling per thing (BUG-1148)", () => {
assert.match(voice, /名词只用中文,同一个东西全篇一个叫法/);
for (const term of ["克图", "凯图", "中运", "sade sati", "vargottama", "rupas"]) {
assert.ok(voice.includes(term), `the voice names ${term} as a spelling not to use`);
}
assert.match(voice, /力量只说强、中、弱/);
});