Files
Jyotisha/frontend/tests/consultation-thinking-plan.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

301 lines
17 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 test from "node:test";
import {
applyThinkingSectionProgress,
consultationContinueMessages,
consultationContinuePrompt,
consultationReportHeadings,
consultationSpokenHeadingRule,
dailyAnswerShapeInstruction,
dailyConsultationThinkingPlan,
followUpAnswerShapeInstruction,
hasPriorAssistantAnswer,
natalAnswerShapeInstruction,
natalUserTurnShape,
DAILY_HEADING,
natalConsultationThinkingPlan,
OPENER_THINKING_TITLE,
REPORT_HEADING,
splitAnswerByHeadings,
thinkPlanSteps,
visibleThinkingSteps,
WINDOW_OPEN_HEADING,
windowConsultationThinkingPlan,
} from "../src/lib/consultation-thinking-plan.ts";
test("multi-domain thinking plan uses Chinese product labels and hides tool ids", () => {
const sections = natalConsultationThinkingPlan({
domains: ["career", "wealth"],
requiredBlocks: [
"raw_structure",
"raman_six_step",
"yoga_table",
"timing",
"synthesis",
"technique_audit_table",
"modern_wrap",
],
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
});
const encoded = JSON.stringify(sections);
// 原值: 四节 heading = [question, support, timing, action]
// 新值: 开场节的 heading 与「盘里支持」共用(开场没有自己的标题,第一个正文 H2 出现即算写完);正文三节
// 原因: TASK-consult-answer-the-question-20260927 D5/D8(BUG-1072/1073):删「先回答你的问题」节,开场节标题改「先回答你问的这件事」
assert.deepEqual(sections.map((section) => section.heading), [
REPORT_HEADING.support,
REPORT_HEADING.support,
REPORT_HEADING.timing,
REPORT_HEADING.action,
]);
assert.equal(sections[0]?.title, OPENER_THINKING_TITLE);
assert.equal(sections[0]?.title, "先回答你问的这件事");
assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false);
// 原值: "用盘上的反差回答这个问题"
// 新值: "先用一句话回答"
// 原因: 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);
assert.equal(encoded.includes("skill_read"), false);
assert.ok(sections.some((section) => section.steps.some((step) => step.label.includes("D1"))));
});
test("window thinking plan stays on stable-layer copy", () => {
const [section] = windowConsultationThinkingPlan();
// 原值: REPORT_HEADING.question;新值: WINDOW_OPEN_HEADING(同一字面「先回答你的问题」)
// 原因: 本命正文标题表删掉了 question;申报窗口模式不在本单范围,标题字面不变
assert.equal(section?.heading, WINDOW_OPEN_HEADING);
assert.equal(section?.heading, "先回答你的问题");
assert.equal(JSON.stringify(section).includes("run-jyotish"), false);
});
test("visible steps collapse extras into a remaining count", () => {
const steps = Array.from({ length: 6 }, (_, index) => ({
id: `s${index}`,
label: `步骤 ${index + 1}`,
status: "pending" as const,
}));
const visible = visibleThinkingSteps(steps);
assert.equal(visible.visible.length, 4);
assert.equal(visible.hiddenCount, 2);
});
test("answer split follows the required heading order", () => {
const headings = consultationReportHeadings(["career", "wealth"]);
// 原值: 四个标题,首节 question;新值: 三个标题,口语开场留在 preamble
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073
assert.deepEqual(headings, [REPORT_HEADING.support, REPORT_HEADING.timing, REPORT_HEADING.action]);
const text = [
"外松内紧。",
`## ${REPORT_HEADING.support}`,
"事业宫被土星压着。",
`## ${REPORT_HEADING.timing}`,
"这轮大运还没过完。",
`## ${REPORT_HEADING.action}`,
"这周只把合同条款写清楚。",
].join("\n");
const { preamble, slices } = splitAnswerByHeadings(text, headings);
assert.match(preamble, /外松内紧/);
assert.match(slices[REPORT_HEADING.support] ?? "", /土星/);
assert.match(slices[REPORT_HEADING.timing] ?? "", /大运/);
assert.match(slices[REPORT_HEADING.action] ?? "", /合同条款/);
});
test("settled answers mark leftover thinking sections done", () => {
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
const progressed = applyThinkingSectionProgress(
sections,
"今日行运已经写完,但没有领域二级标题。",
{ settled: true },
);
assert.equal(progressed.every((section) => section.steps.every((step) => step.status === "done")), true);
});
test("progress marks the current heading active and completed ones done", () => {
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
// 原值: 写出 `## 先回答你的问题` → 首节 done、第二节 active
// 新值: 口语开场还没有标题时首节 active;写出 `## 盘里支持` 后开场节与依据节都 done、时间节 active
// 原因: 开场没有自己的 H2,它与依据节共用标题(D8)
const opening = applyThinkingSectionProgress(sections, "外松内紧。\n");
assert.equal(opening[0]?.steps[0]?.status, "active");
assert.equal(opening[1]?.steps[0]?.status, "pending");
const progressed = applyThinkingSectionProgress(sections, `外松内紧。\n## ${REPORT_HEADING.support}\n事业宫被土星压着。\n`);
assert.equal(progressed[0]?.steps[0]?.status, "done");
assert.equal(progressed[1]?.steps[0]?.status, "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, /最后一个完整标题是:## 事业/);
// 续写的标题清单跟着正文三标题走,不再让续写补出「先回答你的问题」(D8)。
assert.doesNotMatch(prompt, /## 先回答你的问题、/);
assert.match(prompt, /不要写「先回答你的问题」这个标题/);
assert.match(prompt, new RegExp(`## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}`));
assert.match(prompt, /不要重复已写出的段落/);
assert.match(prompt, /不要写思考过程清单/);
});
test("natal spoken answers must use markdown lists for parallel points", () => {
assert.match(consultationSpokenHeadingRule("natal"), /Markdown bullet lists/);
});
test("think plan flattens natal sections into v2 steps", () => {
const steps = thinkPlanSteps(natalConsultationThinkingPlan({
domains: ["career", "wealth"],
}));
assert.deepEqual(steps.map((step) => step.id), ["question", "support", "timing", "action"]);
});
test("section prompt asks for one heading and forbids another calculation", () => {
// 原值: 锁 consultationSectionPrompt(只写一节、不要再调用排盘工具)与 consultationComposePrompt 禁写统一参数
// 新值: 两个提示词都已删除;改锁搬进用户轮的本命写作要求:只用本轮计算结果、开场无标题、
// 一次写完四个标题、禁写统一参数与技法审计表
// 原因: BUG-1053 删除单独的 compose 流(分段写作早在 BUG-944 已取消,consultationSectionPrompt 是死代码);
// 写作形态要求改由主循环看到的用户轮携带。测试名保留,避免名单无说明地消失
const natal = natalAnswerShapeInstruction();
assert.match(natal, /只用结果里的盘面事实/);
assert.match(natal, /开场不要标题/);
// 原值: 一次写完四个标题(含 ## 先回答你的问题)
// 新值: 三个标题;开场不写行动、行动只在最后一节出现一次、全文不超过 900 字、不写「先回答你的问题」
// 原因: 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, /这三个二级标题/)
// 新值: 先按对象分段,再「依次写」三个固定标题(前两个可省)
// 原因: 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 字/)
// 新值: 不限字数、讲清楚为准,且不得出现任何「不超过 N 字」
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1073 的 900 字)
assert.match(natal, /不限字数,讲清楚为准;同一件事只说一遍/);
assert.doesNotMatch(natal, /不超过 \d+ 字/);
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");
});
test("daily thinking plan is three sections without a close heading", () => {
const plan = dailyConsultationThinkingPlan();
assert.deepEqual(plan.map((section) => section.heading), [
DAILY_HEADING.trend,
DAILY_HEADING.actAvoid,
DAILY_HEADING.action,
]);
assert.equal(plan.at(-1)?.title, "一个行动 + 边界句");
assert.equal(plan.some((section) => section.id === "close"), false);
assert.deepEqual(
thinkPlanSteps(plan).map((step) => step.id),
["daily-trend", "daily-act-avoid", "daily-action"],
);
const written = [
`## ${DAILY_HEADING.trend}\n今日宜把已谈妥的细节落成文字。`,
`## ${DAILY_HEADING.actAvoid}\n适合推进合同核对,避开新开战场。`,
`## ${DAILY_HEADING.action}\n今晚只做一件:把待确认条款列成清单。探索性日提示,不是确定预测。`,
].join("\n\n");
const next = applyThinkingSectionProgress(plan, written);
assert.equal(next.every((section) => section.steps.every((step) => step.status === "done")), true);
assert.equal(written.startsWith(`## ${DAILY_HEADING.trend}`), true);
assert.match(consultationSpokenHeadingRule("daily"), /今日趋势/);
});
test("empty-retry section prompt asks to write the heading directly", () => {
// 原值: 锁 consultationSectionPrompt 的 empty-retry 分支(「上一段没有输出正文,请直接写这一节」)
// 新值: 该提示词已删除;改锁今日入口的写作要求(三节标题、审计表与边界句进最后一节)和
// 续写消息:带上本轮计算结果,再接已写正文与续写要求
// 原因: BUG-1053 删除 compose 提示词链;空回答走 answer-retry,续写不得看不到证据。测试名保留
const daily = dailyAnswerShapeInstruction();
assert.match(daily, new RegExp(`${DAILY_HEADING.trend}、${DAILY_HEADING.actAvoid.replace(/[/]/g, "\\/")}、${DAILY_HEADING.action}`));
assert.match(daily, /探索性日提示,不是确定预测/);
const base = [{ role: "user" as const, content: "问题" }];
// 原值: 已写正文夹具用 REPORT_HEADING.question;新值: REPORT_HEADING.support(只需任意一个标题)
// 原因: TASK-consult-answer-the-question-20260927 D8 删除该标题
const withEvidence = consultationContinueMessages(base, `## ${REPORT_HEADING.support}\n岁差`, { moon: 3.2259 });
assert.equal(withEvidence.length, 4);
assert.match(String(withEvidence[1]?.content), /本轮服务器计算结果[\s\S]*3\.2259/);
assert.equal(withEvidence[2]?.role, "assistant");
assert.equal(withEvidence[3]?.content, consultationContinuePrompt(`## ${REPORT_HEADING.support}\n岁差`));
assert.equal(consultationContinueMessages(base, "x").length, 3, "no evidence, no evidence message");
});
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 字/)
// 新值: 不限字数、讲清楚为准,且不得出现任何「不超过 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, /不写二级标题/);
assert.match(followUp, /不重述上一轮已经写过的结构/);
assert.match(followUp, /不解释用户为什么问/);
assert.doesNotMatch(followUp, /这三个二级标题/, "the follow-up turn never asks for the first-turn skeleton");
// The server decides follow-up from the stored history, not from the question (D6).
assert.equal(hasPriorAssistantAnswer([]), false);
assert.equal(hasPriorAssistantAnswer([{ role: "user", text: "我和父母的亲疏关系如何" }]), false);
assert.equal(hasPriorAssistantAnswer([{ role: "assistant", text: " " }]), false);
assert.equal(hasPriorAssistantAnswer([
{ role: "user", text: "我和父母的亲疏关系如何" },
{ role: "assistant", text: "外顺内远。" },
]), true);
// Claude acceptance fix: a stored smalltalk reply (「你好」 fast path, responseKind "smalltalk")
// is an assistant message but not a natal answer, so the first real question after it
// still gets the first-turn shape; a session summary means earlier answers were folded away.
assert.equal(hasPriorAssistantAnswer([
{ role: "user", text: "你好" },
{ role: "assistant", text: "你好,想聊什么都可以", responseKind: "smalltalk" },
]), false);
assert.equal(hasPriorAssistantAnswer([], { summaryText: "此前一轮讲过父母:外顺内远。" }), true);
assert.equal(hasPriorAssistantAnswer([], { summaryText: " " }), false);
assert.equal(
natalUserTurnShape({ history: [{ role: "user", text: "你好" }, { role: "assistant", text: "你好", responseKind: "smalltalk" }] }),
natalAnswerShapeInstruction(),
);
assert.equal(natalUserTurnShape({ history: [], summaryText: "此前一轮讲过父母。" }), followUpAnswerShapeInstruction());
// ① empty history → first-turn shape; ② prior answer → follow-up; ④ daily is untouched by history.
const first = natalUserTurnShape({ history: [] });
assert.equal(first, natalAnswerShapeInstruction());
const later = natalUserTurnShape({ history: [{ role: "user", text: "问" }, { role: "assistant", text: "答" }] });
assert.equal(later, followUpAnswerShapeInstruction());
assert.equal(natalUserTurnShape({ entrypoint: "daily_starlanguage", history: [] }), dailyAnswerShapeInstruction());
assert.equal(
natalUserTurnShape({ entrypoint: "daily_starlanguage", history: [{ role: "assistant", text: "答" }] }),
dailyAnswerShapeInstruction(),
);
const plan = natalConsultationThinkingPlan({ domains: ["parents"], followUp: true });
assert.equal(plan[0]?.title, OPENER_THINKING_TITLE);
assert.equal(plan[0]?.steps[0]?.label, "直接回答这句话");
assert.deepEqual(thinkPlanSteps(plan).map((step) => step.id), ["question", "support", "timing", "action"]);
const settled = applyThinkingSectionProgress(plan, "不是。4 宫主落 10 宫,她把心力投在你的前途上。", { settled: true });
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
});