Files
Jyotisha/frontend/tests/consultation-voice-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 2ec2762dcf feat(consult): plain answer shape — conclusion first, one section per person, no word caps (BUG-1132~1134)
Retire the 09-17 opener shape (coined pattern name, push/tidy periods,
play-a-role) that produced riddles; one shape definition in
consultation-thinking-plan; parents card names mother/father roles with MK;
empty-house rule; thinking bar closes the opener at any H2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 09:42:35 +08:00

278 lines
23 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, /不设字数上限,讲清楚为准/);
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/)
// 新值: Level 2 模板是回答形状的依据层,形状引用 NATAL_ANSWER_SHAPE_SUMMARY
// 原因: TASK-consult-plain-answer-20261001 D2/D7:先答、按对象分段,Level 2 内容落在「盘上依据」
assert.match(binding, /present its Level 2 report template as the evidence of the product's answer shape, not as the shape itself\. \$\{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, /## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事/)
// 新值: 按对象分段之后才是三个固定标题,标题取自 REPORT_HEADING(「盘里支持这个判断的地方」改名「盘上依据」),前两个可省
// 原因: TASK-consult-plain-answer-20261001 D2/D7(BUG-1132)
assert.match(voice, /One section per person or sub-question the user named \(妈妈 and 爸爸 are two sections, never one\)/);
assert.match(voice, /## \$\{REPORT_HEADING\.support\}[\s\S]*Omit the section when there is nothing new to cite[\s\S]*## \$\{REPORT_HEADING\.timing\}[\s\S]*## \$\{REPORT_HEADING\.action\}/);
// 原值: assert.match(voice, /The opener carries no action list/)
// 新值: 「开场不写行动」写在共享 ANSWER SHAPE 第 1 步与形状摘要里
// 原因: TASK-consult-plain-answer-20261001 D2:合同不再复述开场细节,引用唯一定义
assert.match(voice, /开场不写标题、不写行动/);
assert.match(voice, /Say each thing once \(D8\)/);
// 原值: 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/);
// 原值: /cite only the fields that support this judgment, and only what the opener did not already say/
// 新值: 「只写开场与对象段都没引过的」
// 原因: TASK-consult-plain-answer-20261001 D2:对象段已经在括号里给过依据
assert.match(voice, /cite only the fields that support this judgment, and only what the opener and the sections did not already cite/);
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/)
// 新值: 指令引用 NATAL_ANSWER_SHAPE_SUMMARY(摘要里写「无字数上限、只说一遍」),不再含 900
// 原因: TASK-consult-plain-answer-20261001 D7/D8
assert.match(mastra, /Write in Simplified Chinese\. \$\{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, once/)
// 新值: assert.match(binding, /the actions appear once/)
// 原因: TASK-consult-plain-answer-20261001 D7:骨架句改写,语义不变
assert.match(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, /不设字数上限,讲清楚为准。同一件事只说一遍/);
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, /一到三条破折号短句/);
// 降级路线:申报时段与无出生分钟都不编月份;形状照给。
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, "盘上依据");
for (const heading of Object.values(REPORT_HEADING)) {
assert.ok(NATAL_ANSWER_SHAPE_SUMMARY.includes(`## ${heading}`), heading);
assert.ok(natalSpokenReportContract.includes(`## ${heading}`), heading);
assert.ok(natalAnswerShapeBody().includes(`## ${heading}`), heading);
}
assert.ok(natalSpokenReportContract.includes(NATAL_ANSWER_SHAPE_SUMMARY));
assert.ok(consultationSpokenHeadingRule("natal").includes(NATAL_ANSWER_SHAPE_SUMMARY));
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /There is no length cap/);
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 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, /不是每个人都有转机,但每个人都有能扮演的象/)
// 新值: assert.match(voice, /不是每个人都有转机,但每个人这段时间都有能做的事/)
// 原因: TASK-consult-plain-answer-20261001 D5:希望纪律保留,删「扮演哪个象」说法
assert.match(voice, /不是每个人都有转机,但每个人这段时间都有能做的事/);
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"(锁「外松内紧」定稿样例、扮演土星的守、顾问报告 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", () => {
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);
for (const heading of ["## 你和妈妈", "## 你和爸爸", "## 他们怎么对你", "## 时间怎么看", "## 这周可以做的一件事"]) {
assert.ok(parents.includes(heading), heading);
}
assert.match(parents, /(4 宫母亲宫的宫主水星落在 10 宫事业宫;代表母亲的月亮也在 10 宫)/);
const career = voice.slice(careerStart, badStart);
assert.doesNotMatch(career, /## 你和/);
assert.match(career, /## 盘上依据/);
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, /自造格局名、大运写成角色、星名当形容词,爸妈揉成一段/);
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, /是非题(是不是 \/ 会不会 \/ 能不能 \/ 有没有 \/ ……吗):第一句就是「是」「不是」或「看情况:……」/);
// 原值(2): /A 和 B 都必须是盘上的结构——…/ 与 /反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思/
// 新值(2): 反差形状下线;「问别人时写宫位与代表星,不写心思」移到对象段规则里
// 原因(2): TASK-consult-plain-answer-20261001 D1(BUG-1070 的目的保留)
assert.match(voice, /问的是别人时,写那个人在你盘上对应的宫位与代表星说明了什么,不写那个人的心思/);
// Actions stay in the opener only where there is no body skeleton.
// 原值(2): /本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」/、/…行动就写在开场结尾/
// 新值(2): /本命解读里只写在最后一节「这周可以做的一件事」,出现一次/、/…行动就写在回答结尾/
// 原因(2): TASK-consult-plain-answer-20261001 D2:开场之后还有对象段,「开场结尾」改「回答结尾」
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("不揣测(回答对象是用户问的那句话)"));
// 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/);
});