Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
185 lines
11 KiB
TypeScript
185 lines
11 KiB
TypeScript
// 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 { 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", () => {
|
||
// 原值: consultationDomainIds.filter((id) => !CONSULT_READING_BUDGET_BLOCKED.has(id))(年运、时运除外)
|
||
// 新值: 全部 12 个领域
|
||
// 原因: 产品 2026-10-02 决定年运 / 时运按事业 / 婚姻 / 财富同口径计字数(BUG-1160/1161 解除)
|
||
for (const domain of consultationDomainIds) {
|
||
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/);
|
||
// 原值: 7 行
|
||
// 新值: 8 行(第 8 行:traditional_yogas 只说明力量,不单独下结论,不算受冲也不抵消受冲)
|
||
// 原因: TASK-consult-career-yoga-functional-20261002 T3 / D3 定稿,通用读法加一行(BUG-1175)
|
||
assert.equal(CONDENSED_SHARED_READING_LINES.length, 8);
|
||
});
|
||
|
||
// 原值: test "T1/T2 budget blocker is pinned: a single annual or timing turn keeps its pre-brief method"
|
||
// (年运单领域只有 Shared mandatory baseline、年运列在 domains_without_strict_checklist;时运无通用读法)
|
||
// 新值: 年运、时运单领域轮第一段是通用读法;年运带本产品中文清单,时运带英文路由
|
||
// 原因: 产品 2026-10-02 决定年运 / 时运字数口径同事业 / 婚姻 / 财富(契约 + 卡 ≤ 12,000,含清单 ≤ 18,000),阻塞解除
|
||
test("annual and timing single-domain turns get the shared reading, and annual its list (budget decided 2026-10-02)", () => {
|
||
assert.deepEqual(titles(["annual"]), [CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", "年运 · consult-annual · 普通对话精简清单"]);
|
||
const annual = consultationMethodologyForDomains(["annual"])!;
|
||
assert.deepEqual(annual.domains_without_strict_checklist, []);
|
||
assert.equal(annual.sections[2]!.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
|
||
for (const line of CONSULTATION_CONDENSED_CHECKLISTS.annual!) assert.ok(annual.sections[2]!.text.includes(line), line.slice(0, 20));
|
||
assert.deepEqual(titles(["timing"]), [CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", "时运 · event-timing-strict"]);
|
||
});
|
||
|
||
// 原值: 标题「…(annual pending the budget)」,annual 期望 "",年运 / 时运跳过
|
||
// 新值: annual / timing 一并核对;annual 期望本产品清单标题
|
||
// 原因: 产品 2026-10-02 字数口径决定(BUG-1160/1161 解除)
|
||
test("T2: every domain section, in order, and no domain without a list", () => {
|
||
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: "年运 · consult-annual · 普通对话精简清单",
|
||
timing: "时运 · event-timing-strict",
|
||
general: "综合 · consult-general · 普通对话精简清单",
|
||
};
|
||
for (const domain of consultationDomainIds) {
|
||
const methodology = consultationMethodologyForDomains([domain])!;
|
||
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, /不得写成「有分量」「站得住」/)(父母第 2 行要求第一句写「可能不常在你身边」)
|
||
// 新值: 第 2 行按通用读法写范围、不断定在不在身边;「有分量」「站得住」的禁令移到通用读法(AFFLICTION_RANGE_RULE)
|
||
// 原因: 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在、几段感情、有没有病)
|
||
assert.match(parents, /盘分不出父母在不在身边,不替用户断定/);
|
||
assert.doesNotMatch(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 落第几宫要说出来")));
|
||
// 原值: 婚姻受冲行写「感情可能不止一段 / 可能有断续」
|
||
// 新值: 写「感情这条线上有波折的迹象」+ 范围,不断定有几段
|
||
// 原因: 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在、几段感情、有没有病)
|
||
assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.marriage!.some((line) => line.includes("感情这条线上有波折的迹象") && line.includes("不断定有几段") && line.includes("不预设用户现在有伴侣或单身")));
|
||
assert.ok(!CONSULTATION_CONDENSED_CHECKLISTS.marriage!.join("\n").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");
|
||
});
|