feat(consult): sections presuppose nothing, examples carry no reading rule or job shape, corrections are acknowledged

- natalAnswerShapeBody: NATAL_SECTION_RULE replaces 「这个人是什么样、你们怎么相处、
  事情会怎么走」; the shared voice quotes the same constant (BUG-1164)
- product-voice / VOICE.md: no 逆行 in examples, parents example leads with the
  affliction and asks to confirm, married-presupposing Good example removed (BUG-1165)
- follow-up shape: a user correction is acknowledged, never defended (BUG-1166)
- career examples carry no job shape (升职 / 专业能力 / 做深现有工作) (BUG-1168)
- biography backtest golden regenerated with the real engine so the card carries
  brief 1's aspects, combustion, yoga domains and saturn_from_moon_house;
  backtest script gains --correction follow-up turns

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-02 00:38:01 +08:00
co-authored by Claude Opus 5.5
parent 585c7370f8
commit 955bab691a
7 changed files with 215 additions and 42 deletions
@@ -0,0 +1,83 @@
// TASK-consult-no-presupposition-and-backtest-20261001 T1–T3 (BUG-1164~1166, BUG-1168).
// The section rule no longer presupposes the person is there; examples carry no
// reading rule and no job shape; a correction turn acknowledges, never defends.
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import {
FOLLOW_UP_CORRECTION_RULE,
NATAL_SECTION_RULE,
followUpAnswerShapeInstruction,
natalAnswerShapeBody,
natalAnswerShapeInstruction,
} from "../src/lib/consultation-thinking-plan.ts";
import { productConversationVoice } from "../src/mastra/product-voice.ts";
const OLD_SECTION_RULE = /这个人是什么样、你们怎么相处、事情会怎么走/;
const SECTION_RULE_TEXT = "每段先说盘上最强的一两个信号说明了什么,包括这个人在不在你身边、近不近;盘上没有依据的方面不写,不用日常场景凑段落。受冲信号两个及以上时,这一段第一句就写这一面,用「可能」,段末用一句话请用户确认(例:「如果实际情况不是这样,告诉我,我按真实情况重看。」),全篇这类确认最多一处。不预设有没有伴侣、是否已婚、是否在上班、做哪类工作、孩子性别、父母是否在身边;性别未知时不用「他」「她」称伴侣和孩子。";
function source(relative: string) {
return readFileSync(new URL(`../${relative}`, import.meta.url), "utf8");
}
/** Every prompt-bearing module: src/mastra/*.ts and src/lib/consultation-*.ts. */
function promptSources() {
const files = [
...readdirSync(new URL("../src/mastra/", import.meta.url)).filter((name) => name.endsWith(".ts")).map((name) => `src/mastra/${name}`),
...readdirSync(new URL("../src/lib/", import.meta.url)).filter((name) => /^consultation-.*\.ts$/.test(name)).map((name) => `src/lib/${name}`),
];
return files.map((file) => ({ file, text: source(file) }));
}
test("T1: each section leads with the strongest signal, asks to confirm an affliction, presupposes nothing (BUG-1164)", () => {
assert.equal(NATAL_SECTION_RULE, SECTION_RULE_TEXT);
for (const point of ["在不在你身边", "请用户确认", "不预设", "是否在上班、做哪类工作", "性别未知时不用「他」「她」称伴侣和孩子", "全篇这类确认最多一处"]) {
assert.ok(NATAL_SECTION_RULE.includes(point), point);
}
const body = natalAnswerShapeBody();
assert.ok(body.includes(NATAL_SECTION_RULE));
assert.match(body, /每段的判断后在括号里给撑住它的盘上依据/);
assert.doesNotMatch(body, OLD_SECTION_RULE);
// The shared voice quotes the same constant; the rule text lives in one file.
assert.ok(productConversationVoice.includes(NATAL_SECTION_RULE));
assert.doesNotMatch(productConversationVoice, OLD_SECTION_RULE);
assert.match(source("src/mastra/product-voice.ts"), /3\. \$\{NATAL_SECTION_RULE\}/);
const holders = promptSources().filter(({ text }) => text.includes("包括这个人在不在你身边")).map(({ file }) => file);
assert.deepEqual(holders, ["src/lib/consultation-thinking-plan.ts"]);
for (const { file, text } of promptSources()) assert.doesNotMatch(text, OLD_SECTION_RULE, file);
});
test("T2: no 逆行 in prompts or examples except the prohibition line and technique names (BUG-1165)", () => {
const hits = promptSources().flatMap(({ file, text }) => text.split("\n").flatMap((line, index) => (
line.includes("逆行") ? [`${file}:${index + 1}:${line.trim()}`] : []
)));
const allowed = hits.filter((hit) => (
// Brief 2's shared reading forbids the 逆行 = 反复 inference; technique labels are names, not readings.
/consultation-condensed-checklist\.ts:.*逆行不等于「反复、打回来」/.test(hit)
|| /consultation-technique-audit\.ts:.*"逆行(次限|太阳弧)"/.test(hit)
));
assert.deepEqual(hits, allowed);
assert.equal(allowed.length, 3);
assert.match(productConversationVoice, /不是。(4 宫主落 10 宫)她把心力放在你的前途上/);
assert.doesNotMatch(productConversationVoice, /已经在一起的关系|这段婚姻/);
});
test("T2b: career examples carry no job shape (BUG-1168)", () => {
assert.doesNotMatch(productConversationVoice, /升职|专业能力|做深|现有工作|这份工作|换公司|投简历|涨薪/);
const voiceDoc = source("docs/VOICE.md");
for (const phrase of ["之后再谈升职更有把握", "把已经积累的专业能力做成长期事业", "这份工作还值得留"]) {
assert.ok(!voiceDoc.includes(phrase), phrase);
}
assert.match(productConversationVoice, /不预设读者在上班、做哪类工作/);
});
test("T3: a correction turn acknowledges first and does not defend the reading (BUG-1166)", () => {
assert.equal(
FOLLOW_UP_CORRECTION_RULE,
"用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再说盘上哪几条和它对得上、原来哪句读偏了;不为原判断辩护。",
);
assert.ok(followUpAnswerShapeInstruction().includes(FOLLOW_UP_CORRECTION_RULE));
assert.ok(!natalAnswerShapeInstruction().includes(FOLLOW_UP_CORRECTION_RULE));
});
@@ -217,15 +217,23 @@ test("persona is the sharp-but-reliable colleague, and hope never becomes a plat
// 新值: 锁父母题按对象分段的 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 (单对象事业题首轮,不拆段;盘上数据是示意)");
// 原值: indexOf("Good (父母题首轮,按对象分段;盘上数据是示意)") / indexOf("Good (单对象事业题首轮,不拆段;盘上数据是示意)")
// 新值: 两个标签改为「括号里是依据的占位,只演示口气与结构,不是读法」,事业例再加「不预设读者在上班、做哪类工作」
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D3/D7(BUG-1165/1168):示例不得携带读法规则与职业形态
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 宫)/);
// 原值: assert.match(parents, /(4 宫母亲宫的宫主水星落在 10 宫事业宫;代表母亲的月亮也在 10 宫)/)
// 新值: 括号只放占位(4 宫主落 10 宫;月亮在 10 宫);爸爸段演示「受冲先说、用可能、段末请确认」;不再有「分量重」「问爸爸的意见」
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D1/D3(BUG-1164/1165):旧例预设父亲在场,回测里缺席的父亲被照抄成「话少、分量重」
assert.match(parents, /(4 宫主落 10 宫;月亮在 10 宫)/);
assert.match(parents, /他可能不常在你身边[\s\S]*如果实际情况不是这样,告诉我,我按真实情况重看。/);
assert.doesNotMatch(parents, /分量重|标杆|爸爸的意见|靠做事/);
const career = voice.slice(careerStart, badStart);
assert.doesNotMatch(career, /## 你和/);
assert.match(career, /## 盘上依据/);
@@ -237,9 +245,16 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
const bad = voice.slice(badStart, voice.indexOf("\n", badStart));
assert.match(bad, /自造格局名、大运写成角色、星名当形容词,爸妈揉成一段/);
assert.match(voice, /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:这份工作还值得留/);
// 原值: /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:这份工作还值得留/
// 新值: /…说直接点:现在这条路还值得走/
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D7(BUG-1168):示例不预设读者在上班
assert.match(voice, /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:现在这条路还值得走/);
const badLine = voice.split("\n").find((line) => line.startsWith("Bad: 你更适合把已经积累的专业能力做成长期事业"));
// 原值: 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!, /像顾问报告:只有结论,没有盘上依据/);
});
@@ -264,7 +279,11 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
assert.match(voice, /无出生分钟与申报时段没有正文骨架,行动就写在回答结尾/);
// Yes/no Good and Bad pair, fictional chart.
assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/);
assert.match(voice, /不是。4 宫主水星逆行落 10 宫/);
// 原值: assert.match(voice, /不是。4 宫主水星逆行落 10 宫/)
// 新值: 「不是。(4 宫主落 10 宫)」;示例与提示词里不再有「逆行」,已婚预设的婚姻 Good 例删除
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D2/D3(BUG-1165):「逆行」被照抄成「反复、打回来」
assert.match(voice, /不是。(4 宫主落 10 宫)她把心力放在你的前途上/);
assert.doesNotMatch(voice, /逆行|已经在一起的关系/);
assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/);
// 原值: "…wants, needs, or feels; state where the chart puts them"(Never 列表里第二句读成也被禁止)
// 新值: 两句拆开,第二句是替代做法 原因: Claude 验收改措辞
File diff suppressed because one or more lines are too long