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
This commit is contained in:
Jesse_Chen
2026-10-01 09:42:33 +08:00
co-authored by Claude Opus 5.5
parent 00420f89e2
commit 50deecbf40
11 changed files with 418 additions and 102 deletions
@@ -62,7 +62,11 @@ test("a follow-up turn still calculates once, and its short heading-free answer
// ② The writer's prompt carries the follow-up shape, not the skeleton.
const writerPrompt = JSON.stringify(run.prompts[1]);
assert.match(writerPrompt, /这是追问轮/);
assert.match(writerPrompt, /不超过 200 字/);
// 原值: assert.match(writerPrompt, /不超过 200 字/)
// 新值: 写作提示带「讲清楚为准,不限字数」,且没有任何「不超过 N 字」
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(追问轮仍不重开骨架)
assert.match(writerPrompt, /讲清楚为准,不限字数/);
assert.doesNotMatch(writerPrompt, /不超过 \d+ 字/);
assert.doesNotMatch(writerPrompt, /这三个二级标题:/);
assert.doesNotMatch(writerPrompt, /开场不写行动清单/);
// The answer has no heading and never crossed ANSWER_RELEASE_CHARS, yet it
@@ -111,5 +115,7 @@ test("the first turn of a session keeps the opener-plus-skeleton shape (control)
assert.ok(run.answer.startsWith(OPENER), "the opener has no heading");
const sections = run.events.filter((event) => event.type === "thinking.section") as unknown as Array<{ title: string; steps: Array<{ label: string }> }>;
const opener = sections.find((section) => section.title === OPENER_THINKING_TITLE);
assert.equal(opener?.steps[0]?.label, "用盘上的反差回答这个问题");
// 原值: "用盘上的反差回答这个问题";新值: "先用一句话回答"
// 原因: TASK-consult-plain-answer-20261001 D1/T2(BUG-1132):反差开场形状下线
assert.equal(opener?.steps[0]?.label, "先用一句话回答");
});
@@ -150,6 +150,9 @@ test("every card value is a value the projection or the engine already holds (no
// 新值: the same, except the two labels the v2 card writes itself
// (EVIDENCE_CARD_LABELS: 性别未知, 年盘未接入).
// 原因: TASK-consult-evidence-card-v2-20260927 decision table mandates both labels.
// 原值: 白名单是 EVIDENCE_CARD_LABELS 的两条(性别未知、年盘未接入)
// 新值: 同一来源多了 roleMother / roleFather(母亲、父亲),断言本身不变
// 原因: TASK-consult-plain-answer-20261001 D6(BUG-1133):父母卡标出每条依据属于谁,标签由卡自己写
const cardLabels = new Set(Object.values(EVIDENCE_CARD_LABELS).map((label) => JSON.stringify(label)));
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
@@ -307,3 +310,51 @@ test("citation counting keeps field ids only and needs a date, degree or planet-
assert.deepEqual(citedEvidenceCardFields(card, `月亮 ${moonSign}`), []);
assert.deepEqual(citedEvidenceCardFields(card, ""), []);
});
test("the parents section says which evidence is the mother's and which the father's (BUG-1133)", () => {
for (const chart of golden.charts) {
const card = cardFor(chart.workflow, "parents");
const section = card.domain_sections.parents!;
const roles = section.roles as Record<string, Json>;
const where = chart.id;
assert.deepEqual(Object.keys(roles), ["mother", "father"], where);
const mother = roles.mother!;
const father = roles.father!;
assert.equal(mother.label, EVIDENCE_CARD_LABELS.roleMother);
assert.equal(father.label, EVIDENCE_CARD_LABELS.roleFather);
assert.equal(mother.house, 4);
assert.equal(father.house, 9);
// House lords are the section's own 4H / 9H lords, copied, not re-derived.
assert.deepEqual(mother.house_lords, dig(section.houses, "4", "lords"), where);
assert.deepEqual(father.house_lords, dig(section.houses, "9", "lords"), where);
assert.deepEqual(mother.significators, ["Moon"]);
assert.deepEqual(father.significators, ["Sun"]);
// MK is on the card (7-karaka scheme); its value sits once in section.karakas.
assert.deepEqual(mother.karakas, ["MK"], where);
assert.equal(typeof dig(section.karakas, "MK", "planet"), "string", where);
// The engine's scheme has no PiK, so the father carries none and no gap is recorded.
assert.equal(father.karakas, undefined, where);
assert.equal(dig(section.karakas, "PiK"), undefined, where);
// D12 4th / 9th counted from the D12 lagna; occupants are D12 planets in that sign.
const d12 = section.vargas.D12 as { lagna: string; planets: Record<string, string> };
for (const [role, number] of [[mother, 4], [father, 9]] as const) {
const house = role.d12_house as { house: number; sign: string; occupants: string[] };
assert.equal(house.house, number);
const signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"];
assert.equal(house.sign, signs[(signs.indexOf(d12.lagna) + number - 1) % 12], where);
assert.deepEqual(house.occupants, Object.entries(d12.planets).filter(([, sign]) => sign === house.sign).map(([name]) => name), where);
}
assert.deepEqual(card.meta.gaps, [], where);
}
});
test("a parents card without MK records the gap and the mother names no karaka (BUG-1133)", () => {
const workflow = structuredClone(golden.charts[0]!.workflow) as Json;
const input = sources(workflow, "parents");
const table = dig(input.context, "local_layers", "jaimini", "chara_karakas") as Json;
delete table.MK;
delete table.Matrikaraka;
const card = buildEvidenceCard([input]);
assert.ok(card.meta.gaps.includes("parents.karaka.MK"));
assert.equal((card.domain_sections.parents!.roles as Record<string, Json>).mother!.karakas, undefined);
});
@@ -366,7 +366,10 @@ test("consult route expands an optional entrypoint for both Agent and tool input
// 原值: /先用 3–6 句口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 新值: /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 原因: 用户回合文案与新开场形状对齐(反差 / 谁推谁修 / 扮演哪个象 / 最多三条行动)。
assert.match(source, /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
// 原值(2): /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 新值(2): /natalAnswerShapeBody\(\)[\s\S]*?resolvedQuestion\.modelQuestion/ —— 用户回合引用唯一的形状定义
// 原因(2): TASK-consult-plain-answer-20261001 D7/D8:形状只在 consultation-thinking-plan 定义一处,且不再有字数上限
assert.match(source, /natalAnswerShapeBody\(\)[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.doesNotMatch(source, /JSON\.stringify\(toolInput\)/);
assert.match(source, /shouldLoadGeneralDailyPanchanga\(\{/);
assert.doesNotMatch(source, /用户明确选择的无出生分钟一般咨询/);
@@ -51,7 +51,10 @@ test("multi-domain thinking plan uses Chinese product labels and hides tool ids"
assert.equal(sections[0]?.title, OPENER_THINKING_TITLE);
assert.equal(sections[0]?.title, "先回答你问的这件事");
assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false);
assert.equal(sections[0]?.steps[0]?.label, "用盘上的反差回答这个问题");
// 原值: "用盘上的反差回答这个问题"
// 新值: "先用一句话回答"
// 原因: TASK-consult-plain-answer-20261001 D1/T2(BUG-1132):反差开场形状下线,开场改为第一句给结论
assert.equal(sections[0]?.steps[0]?.label, "先用一句话回答");
assert.ok(sections.some((section) => section.id === "support"));
assert.ok(JSON.stringify(sections).includes("事业"));
assert.equal(encoded.includes("run-jyotish"), false);
@@ -124,6 +127,17 @@ test("progress marks the current heading active and completed ones done", () =>
assert.equal(progressed[2]?.steps[0]?.status, "active");
});
test("the opener section closes at the first per-person heading and skipped optional headings close behind later ones (BUG-1132)", () => {
const sections = natalConsultationThinkingPlan({ domains: ["parents"] });
const opener = "你和父母不疏远,但亲近主要落在为你的前途操心上。\n";
const onPerson = applyThinkingSectionProgress(sections, `${opener}## 你和妈妈\n她对你的事很上心。\n`);
assert.equal(onPerson[0]?.steps[0]?.status, "done", "the opener ends at the first H2, whatever its name");
assert.equal(onPerson[1]?.steps[0]?.status, "active");
// 盘上依据 omitted: once 时间怎么看 shows, the support section is behind it.
const onTiming = applyThinkingSectionProgress(sections, `${opener}## 你和妈妈\n上心。\n## ${REPORT_HEADING.timing}\n这十年。\n`);
assert.deepEqual(onTiming.map((section) => section.steps[0]?.status), ["done", "done", "done", "active"]);
});
test("continue prompt asks to resume after the last complete heading", () => {
const prompt = consultationContinuePrompt("## 事业\n方向稳定。\n");
assert.match(prompt, /最后一个完整标题是:## 事业/);
@@ -160,12 +174,27 @@ test("section prompt asks for one heading and forbids another calculation", () =
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073
for (const heading of Object.values(REPORT_HEADING)) assert.ok(natal.includes(`## ${heading}`));
assert.equal(Object.values(REPORT_HEADING).length, 3);
assert.match(natal, /这三个二级标题/);
// 原值: assert.match(natal, /这三个二级标题/)
// 新值: 先按对象分段,再「依次写」三个固定标题(前两个可省)
// 原因: TASK-consult-plain-answer-20261001 D2(BUG-1132):问到几个人就分几段,固定标题跟在后面
assert.match(natal, /问题里点到几个人或几个子问题/);
assert.match(natal, /## 你和妈妈、## 你和爸爸、## 他们怎么对你/);
assert.match(natal, /问父母时妈妈和爸爸分开写/);
assert.match(natal, /然后依次写:/);
assert.match(natal, /开场不写行动清单/);
assert.match(natal, /只在这里出现一次/);
assert.match(natal, /全文不超过 900 字/);
// 原值: assert.match(natal, /全文不超过 900 字/)
// 新值: 不限字数、讲清楚为准,且不得出现任何「不超过 N 字」
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1073 的 900 字)
assert.match(natal, /不限字数,讲清楚为准;同一件事只说一遍/);
assert.doesNotMatch(natal, /不超过 \d+ 字/);
assert.match(natal, /不要写「先回答你的问题」这个标题/);
assert.match(natal, /反差只写盘上的结构/);
// 原值: assert.match(natal, /反差只写盘上的结构/)
// 新值: 人话自检与禁止清单、空宫规则
// 原因: TASK-consult-plain-answer-20261001 D1/D3/D4(BUG-1132/1134):反差形状下线;它防的「读心」仍由共享口吻的不揣测段负责
assert.match(natal, /把每句话括号里的内容删掉,不懂占星的人也要能看懂/);
assert.match(natal, /空宫不单独下结论,要说它的宫主落在哪/);
assert.doesNotMatch(natal, /反差|扮演|谁在推/);
assert.match(natal, /不要写「统一参数与原始结构」/);
assert.match(natal, /不要写技法审计表/);
assert.doesNotMatch(natal, /不要再调用排盘工具/, "the loop still has to call the tool before writing");
@@ -216,7 +245,11 @@ test("empty-retry section prompt asks to write the heading directly", () => {
test("a follow-up turn answers directly and the thinking bar says so (TASK-consult-answer-the-question-20260927 T2/T4)", () => {
const followUp = followUpAnswerShapeInstruction();
assert.match(followUp, /不超过 200 字/);
// 原值: assert.match(followUp, /不超过 200 字/)
// 新值: 不限字数、讲清楚为准,且不得出现任何「不超过 N 字」;仍是第一句就答、无标题、不重述
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1072 的 200 字,追问判定逻辑不变)
assert.match(followUp, /讲清楚为准,不限字数/);
assert.doesNotMatch(followUp, /不超过 \d+ 字/);
assert.match(followUp, /第一句就回答用户这句话/);
assert.match(followUp, /是非题先答「是」「不是」或「看情况:……」/);
assert.match(followUp, /不写二级标题/);
@@ -27,12 +27,18 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
// 原值: 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/);
// 原值(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 的象/)
// 原因: 旧形状(结论+要点+下一步)被反差→谁推谁修→扮演象→短行动取代。
assert.match(voice, /别去应 X 的象,去扮演 Y 的象/);
assert.match(voice, /Spoken-layer total ≤ 400 Chinese characters/);
// 原值(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/);
@@ -40,7 +46,10 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
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(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/)
@@ -55,18 +64,42 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
// 新值: 正文只有三个标题,「先回答你的问题」不再出现在合同里;开场不带行动、行动只在最后一节、首轮 ≤ 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, /## 盘里支持这个判断的地方, 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/);
assert.match(voice, /cite only the fields that support this judgment, and only what the opener did not already say/);
// 原值: 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/);
// 原值: 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/);
assert.match(binding, /the actions, once/);
// 原值: 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/);
@@ -106,25 +139,56 @@ test("consult user turn no longer dumps tool JSON that the model would parrot",
// 原值: /先用 3–6 句口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 新值: /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/
// 原因: 用户回合文案与新开场形状对齐,约束从句数改成字数加形状。
assert.match(chartBranch, /先用不超过 400 字口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.match(chartBranch, /别去应 X 的象,去扮演 Y 的象/);
// 原值(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", () => {
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 "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", () => {
@@ -139,29 +203,45 @@ test("persona is the sharp-but-reliable colleague, and hope never becomes a plat
assert.match(voice, /单义的问题直接答,不改写它/);
assert.match(voice, /HOPE DISCIPLINE/);
assert.match(voice, /answer_policy\.can_answer_precise_timing 允许精确应期时/);
assert.match(voice, /不是每个人都有转机,但每个人都有能扮演的象/);
// 原值: 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", () => {
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, /一切都会好|相信自己|加油|宇宙自有安排/);
// 原值: 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!, /像顾问报告:没有反差,没说谁在推谁在修,没有可扮演的象/);
assert.match(badLine!, /像顾问报告:只有结论,没有盘上依据/);
});
test("the voice answers the sentence asked: no motive guessing, yes/no first, contrast on the chart (BUG-1070)", () => {
@@ -172,12 +252,16 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
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, /反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思/);
// 原值(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.
assert.match(voice, /本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」/);
assert.match(voice, /无出生分钟与申报时段没有正文骨架,行动就写在开场结尾/);
// 原值(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 宫/);
@@ -188,4 +272,6 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
// 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/);
});