feat(consult): shared affliction-first reading, lists for every domain, health renamed

- T1 (BUG-1160): 「通用读法 · 先看受冲」 opens every turn's methodology once.
- T2 (BUG-1161): Chinese lists for parents, children, education, migration,
  family, annual, general, health; health replaces the English route in the
  web consultation only.
- T3 (BUG-1162): career / marriage / wealth each gain an affliction line.
- T4 (BUG-1163): health label 健康, prompt asks about the constitution first.
- Single-domain annual / timing turns keep the pre-brief method
  (CONSULT_READING_BUDGET_BLOCKED): they would exceed the 12,000-character
  assertions, which this brief may not change.

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:14:54 +08:00
co-authored by Claude Opus 5.5
parent 509c88c436
commit 401796509a
11 changed files with 384 additions and 36 deletions
@@ -0,0 +1,167 @@
// TASK-consult-affliction-reading-20261001 T1–T4 (BUG-1160~1163): every
// consultation turn opens with the shared "afflictions before dignities"
// reading once; every domain but timing carries a local consultation list;
// career / marriage / wealth gain an affliction line; health is named 健康 and
// asks about the constitution first. Checklists may only cite field names the
// evidence card really carries (hard red line 3), checked against the cards
// built from the real-engine golden fixture.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONDENSED_GENDER_LINES,
CONDENSED_SHARED_READING_LINES,
CONDENSED_SHARED_READING_TITLE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
CONSULTATION_CONDENSED_CHECKLISTS,
} from "../src/lib/consultation-condensed-checklist.ts";
import {
CONSULT_READING_BUDGET_BLOCKED,
consultationMethodologyForDomains,
} from "../src/lib/consultation-methodology.ts";
import {
consultationDomainDefinition,
consultationDomainIds,
type ConsultationDomain,
} from "../src/lib/consultation-domain-registry.ts";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
type Json = Record<string, unknown>;
type GoldenChart = { id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json };
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: GoldenChart[] };
const titles = (domains: ConsultationDomain[]) => consultationMethodologyForDomains(domains)!.sections.map((section) => section.title);
test("T1: the shared reading is the first section, once per turn, whatever the plan", () => {
for (const domain of consultationDomainIds.filter((id) => !CONSULT_READING_BUDGET_BLOCKED.has(id))) {
const list = titles([domain]);
assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, domain);
assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, domain);
}
for (const plan of [["parents", "children"], ["career", "health", "marriage"], ["annual", "parents"], ["timing", "career"]] as ConsultationDomain[][]) {
const list = titles(plan);
assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, plan.join("+"));
assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, plan.join("+"));
}
const text = consultationMethodologyForDomains(["parents"])!.sections[0]!.text;
assert.ok(text.startsWith("1. 先看受冲,再看旺弱。"));
assert.match(text, /两个及以上受冲信号/);
assert.match(text, /逆行不等于「反复、打回来」/);
// The line added on top of the brief's six: only hits are yogas this chart has.
assert.match(text, /瑜伽只认 yogas\.hits/);
assert.equal(CONDENSED_SHARED_READING_LINES.length, 7);
});
test("T1/T2 budget blocker is pinned: a single annual or timing turn keeps its pre-brief method", () => {
// BLOCKED on the 12,000-character ceiling (see CONSULT_READING_BUDGET_BLOCKED); this
// test documents the gap so lifting it is a deliberate change.
assert.deepEqual([...CONSULT_READING_BUDGET_BLOCKED].sort(), ["annual", "timing"]);
assert.deepEqual(titles(["annual"]), ["Shared mandatory baseline"]);
assert.deepEqual(consultationMethodologyForDomains(["annual"])!.domains_without_strict_checklist, ["annual"]);
assert.deepEqual(titles(["timing"]), ["Shared mandatory baseline", "时运 · event-timing-strict"]);
});
test("T2: every domain section, in order, and no domain without a list (annual pending the budget)", () => {
const expected: Record<ConsultationDomain, string> = {
career: "事业 · career-timing-strict · 普通对话精简清单",
marriage: "关系 · relationship-timing-strict · 普通对话精简清单",
wealth: "财富 · finance-timing-strict · 普通对话精简清单",
health: "健康 · consult-health · 普通对话精简清单",
education: "学习 · consult-education · 普通对话精简清单",
migration: "迁居 · consult-migration · 普通对话精简清单",
family: "家庭 · consult-family · 普通对话精简清单",
parents: "父母 · consult-parents · 普通对话精简清单",
children: "子女 · consult-children · 普通对话精简清单",
annual: "",
timing: "时运 · event-timing-strict",
general: "综合 · consult-general · 普通对话精简清单",
};
for (const domain of consultationDomainIds) {
const methodology = consultationMethodologyForDomains([domain])!;
if (CONSULT_READING_BUDGET_BLOCKED.has(domain)) continue;
assert.deepEqual(methodology.sections.map((section) => section.title), [
CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", expected[domain],
], domain);
assert.deepEqual(methodology.domains_without_strict_checklist, [], domain);
}
const mixed = consultationMethodologyForDomains(["parents", "children", "education", "migration", "family", "general"])!;
assert.deepEqual(mixed.domains_without_strict_checklist, []);
for (const domain of ["parents", "children", "education", "migration", "family", "general", "health"] as const) {
const section = consultationMethodologyForDomains([domain])!.sections[2]!;
assert.equal(section.source, CONSULT_BRIEF_CHECKLIST_SOURCE, domain);
for (const line of CONSULTATION_CONDENSED_CHECKLISTS[domain]!) assert.ok(section.text.includes(line), `${domain}: ${line.slice(0, 20)}`);
}
const parents = CONSULTATION_CONDENSED_CHECKLISTS.parents!.join("\n");
assert.match(parents, /不得写成「有分量」「站得住」/);
assert.match(parents, /照顾你长大的可能不止一位/);
assert.match(CONSULTATION_CONDENSED_CHECKLISTS.health!.join("\n"), /这不是医疗意见/);
assert.match(CONSULTATION_CONDENSED_CHECKLISTS.children!.join("\n"), /孩子性别不写/);
});
test("T3: career, marriage and wealth each carry their affliction line", () => {
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.career!.some((line) => line.startsWith("AL 落第几宫要说出来")));
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.marriage!.some((line) => line.includes("感情可能不止一段 / 可能有断续") && line.includes("不预设用户现在有伴侣或单身")));
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.wealth!.some((line) => line.startsWith("8 宫也管版税、继承、意外之财")));
for (const domain of ["career", "marriage", "wealth"] as const) {
const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!;
assert.ok(lines.length >= 5 && lines.length <= 8, domain);
}
});
function cardKeys(): Set<string> {
const keys = new Set<string>();
const walk = (value: unknown) => {
if (Array.isArray(value)) value.forEach(walk);
else if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
keys.add(key);
walk(child);
}
}
};
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
const workflow = domain === "annual" ? chart.annual_workflow : domain === "timing" ? chart.timing_workflow : chart.workflow;
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain);
walk(buildEvidenceCard([{ domain, packet, context }]));
}
}
return keys;
}
test("hard red line 3: every field name the lists cite is a key on a real card", () => {
const keys = cardKeys();
const texts = [
...CONDENSED_SHARED_READING_LINES,
...Object.values(CONSULTATION_CONDENSED_CHECKLISTS).flat(),
...Object.values(CONDENSED_GENDER_LINES),
].join("\n");
// Lower-case identifiers in these Chinese texts are field names; "blocked" is the
// only prose word (KP 精确宫头仍 blocked). Static text scan, not intent detection.
const prose = new Set(["blocked"]);
const cited = new Set([...texts.matchAll(/(?<![A-Za-z0-9_])[a-z][a-z0-9_]*/g)].map((match) => match[0]).filter((word) => !prose.has(word)));
for (const required of ["conjunct_with", "aspected_by", "combust", "lord_rows", "significator_rows", "saturn_from_moon_house", "hits", "functional"]) {
assert.ok(cited.has(required), `the lists cite ${required}`);
}
for (const word of cited) assert.ok(keys.has(word), `cited field ${word} is on a card`);
});
test("T4: health is 健康 and asks about the constitution first; its boundary and cap are unchanged", () => {
const health = consultationDomainDefinition("health");
assert.equal(health.label, "健康");
assert.equal(health.prompt, "我的身体底子怎样,哪些方面要多留意?");
assert.ok(health.aliases.includes("身心压力"));
assert.equal(health.claimBoundary, "wellbeing_pressure_patterns_not_medical_diagnosis");
assert.equal(health.confidenceCap, "low");
});
@@ -13,6 +13,7 @@ import test from "node:test";
import {
CONDENSED_CHECKLIST_SOURCE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
CONSULTATION_CONDENSED_CHECKLISTS,
} from "../src/lib/consultation-condensed-checklist.ts";
import { consultationMethodologyForDomains, sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts";
@@ -75,10 +76,19 @@ async function writerPrompt(domain: "career" | "marriage" | "wealth") {
}
test("each condensed list is 5–8 lines", () => {
for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) {
assert.ok(lines!.length >= 5 && lines!.length <= 8, `${domain}: ${lines!.length} lines`);
// 原值:全部清单 5–8 行,且只有 career / marriage / wealth 三份;
// 新值:这三份仍 5–8 行;另有 TASK-consult-affliction-reading-20261001 定稿的 8 份(2–6 行,按任务书原文,不凑行数),
// 只有 timing 没有本地清单;原因:该任务书 T2 / 决策 D5 补齐清单,定稿文本不得改意思。
for (const domain of ["career", "marriage", "wealth"] as const) {
const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!;
assert.ok(lines.length >= 5 && lines.length <= 8, `${domain}: ${lines.length} lines`);
}
assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), ["career", "marriage", "wealth"]);
for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) {
assert.ok(lines!.length >= 2 && lines!.length <= 8, `${domain}: ${lines!.length} lines`);
}
assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), [
"annual", "career", "children", "education", "family", "general", "health", "marriage", "migration", "parents", "wealth",
]);
});
test("the writer's prompt carries the condensed list with the three layers and the forbidden statements, not the long checklist", async () => {
@@ -114,8 +124,12 @@ test("other routes still quote the live skill, and the bound system method is un
const eventTiming = timing.sections.find((section) => section.title.includes("event-timing-strict"));
assert.ok(eventTiming);
assert.match(eventTiming.source, /references\/strict-workflow-router\.md$/);
// 原值:health 段标题含 health-timing-strict、来源是 strict-workflow-router.md;
// 新值:普通对话的 health 段是 consult-health 中文清单,来源是任务书,不再发路由英文段;
// 原因:TASK-consult-affliction-reading-20261001 决策 D4/D5(报告面仍用路由,不受影响)。
const health = consultationMethodologyForDomains(["health"])!;
assert.match(health.sections.find((section) => section.title.includes("health-timing-strict"))!.source, /strict-workflow-router\.md$/);
assert.equal(health.sections.some((section) => section.title.includes("health-timing-strict")), false);
assert.equal(health.sections.find((section) => section.title.includes("consult-health"))!.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
for (const domain of consultationDomainIds.filter((id) => !(id in CONSULTATION_CONDENSED_CHECKLISTS))) {
const methodology = consultationMethodologyForDomains([domain])!;
assert.equal(methodology.sections.some((section) => section.source === CONDENSED_CHECKLIST_SOURCE), false, domain);
@@ -100,13 +100,18 @@ test("a stored session theme can never be parents or children (chat_sessions.the
assert.equal(chatSessionCreateSchema.safeParse({ ...base, theme: "children" }).success, false);
});
test("methodology names no strict checklist for parents or children instead of substituting one", () => {
test("methodology gives parents and children their own consultation lists after the shared reading", () => {
const methodology = consultationMethodologyForDomains(["parents"]);
assert.ok(methodology);
assert.deepEqual(methodology.domains_without_strict_checklist, ["parents"]);
assert.equal(methodology.sections[0]?.title, "Shared mandatory baseline");
assert.match(methodology.sections[0]?.text ?? "", /D7 for children, D12 for family/);
assert.deepEqual(consultationMethodologyForDomains(["children"])?.domains_without_strict_checklist, ["children"]);
// 原值:domains_without_strict_checklist = ["parents"] / ["children"],sections[0] 是 Shared mandatory baseline;
// 新值:两者为 [],sections[0] 是「通用读法 · 先看受冲」,baseline 顺延到 sections[1];
// 原因:TASK-consult-affliction-reading-20261001 T1(通用读法每轮最前)与决策 D6(父母、子女有本产品清单)。
assert.deepEqual(methodology.domains_without_strict_checklist, []);
assert.equal(methodology.sections[0]?.title, "通用读法 · 先看受冲");
assert.equal(methodology.sections[1]?.title, "Shared mandatory baseline");
assert.match(methodology.sections[1]?.text ?? "", /D7 for children, D12 for family/);
assert.ok(methodology.sections.some((section) => section.title.includes("consult-parents")));
assert.deepEqual(consultationMethodologyForDomains(["children"])?.domains_without_strict_checklist, []);
});
test("receipts and the thinking plan show 父母 / 子女", () => {
@@ -79,7 +79,9 @@ test("general guided topics keep user-centered prompts and the same claim bounda
"请帮我梳理目前的事业方向和下一步重点。",
"请帮我看看我在关系中容易重复什么模式。",
"请帮我分析目前的财富重点、风险和更稳妥的选择。",
"请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
// 原值「请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。」→ 新值如下;
// 原因:TASK-consult-affliction-reading-20261001 D4 健康改口径,先讲体质底子。
"请从非医疗诊断的角度,帮我看看我的身体底子怎样,哪些方面要多留意。",
"请帮我看看我更适合怎样学习,以及如何安排下一步。",
"请帮我分析现阶段是否适合迁居、置业或考虑海外发展。",
"请帮我看看家庭关系中最需要处理的问题和责任边界。",
@@ -6,7 +6,11 @@ import {
METHODOLOGY_SECTION_MAX_CHARS,
METHODOLOGY_TOTAL_MAX_CHARS,
} from "../src/lib/consultation-methodology.ts";
import { CONDENSED_CHECKLIST_SOURCE } from "../src/lib/consultation-condensed-checklist.ts";
import {
CONDENSED_CHECKLIST_SOURCE,
CONDENSED_SHARED_READING_TITLE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
} from "../src/lib/consultation-condensed-checklist.ts";
import { consultationDomainIds } from "../src/lib/consultation-domain-registry.ts";
import { resolveLiveJyotishSkill } from "../src/lib/skill-package-registry.ts";
@@ -37,16 +41,25 @@ test("the route's own strict checklist reaches the answer, not just the package
)).length,
0,
);
const domainSections = methodology.sections.filter((section) => !section.title.includes("Shared mandatory baseline"));
// 原值:只排除 Shared mandatory baseline;新值:同时排除每轮一次的「通用读法 · 先看受冲」;
// 原因:TASK-consult-affliction-reading-20261001 T1 把通用读法放在 sections 最前面,它不是领域清单。
const domainSections = methodology.sections.filter((section) => (
!section.title.includes("Shared mandatory baseline") && section.title !== CONDENSED_SHARED_READING_TITLE
));
// Former: 2 (strict route + event judgment). New: 1 (condensed list). Reason: as above.
assert.equal(domainSections.length, 1);
assert.deepEqual(methodology.domains_without_strict_checklist, []);
});
test("a route the skill declares no checklist for is reported, not filled in with another route's", () => {
test("a route the skill declares no checklist for gets this product's own list, never another route's", () => {
const methodology = consultationMethodologyForDomains(["education"]);
assert.ok(methodology);
assert.deepEqual(methodology.domains_without_strict_checklist, ["education"]);
// 原值:["education"](路由没声明清单就报缺、不替代);新值:[],学业发本产品自己的中文清单;
// 原因:TASK-consult-affliction-reading-20261001 决策 D6 对普通对话推翻「报缺不替代」。
assert.deepEqual(methodology.domains_without_strict_checklist, []);
const education = methodology.sections.find((section) => section.title.includes("consult-education"));
assert.ok(education);
assert.equal(education.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
assert.ok(
!methodology.sections.some((section) => section.title.includes("timing-strict") || section.title.includes("verification-strict")),
"no strict section may be substituted for a route that has none",
@@ -63,12 +76,17 @@ test("a route the skill declares no checklist for is reported, not filled in wit
);
});
test("health uses the skill's health-timing-strict checklist", () => {
test("health uses the consultation's Chinese health list in place of health-timing-strict", () => {
const methodology = consultationMethodologyForDomains(["health"]);
assert.ok(methodology);
assert.deepEqual(methodology.domains_without_strict_checklist, []);
const health = methodology.sections.find((section) => section.title.includes("health-timing-strict"));
// 原值:标题含 health-timing-strict(路由英文清单);新值:consult-health 中文清单,来源是任务书;
// 原因:TASK-consult-affliction-reading-20261001 决策 D4/D5,普通对话健康改口径(报告面不变)。
assert.equal(methodology.sections.some((section) => section.title.includes("health-timing-strict")), false);
const health = methodology.sections.find((section) => section.title.includes("consult-health"));
assert.ok(health);
assert.equal(health.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
assert.match(health.text, /体质底子/);
assert.match(health.text, /D6/);
assert.match(health.text, /D30/);
});