Files
Jyotisha/frontend/tests/consultation-thinking-plan.test.ts
T
Jesse_ChenandClaude Fable 5.1 9d01757f27
Independent Staging Quality Gate / validate (push) Successful in 14m58s
Independent Staging Quality Gate / publish (push) Successful in 3m40s
fix(consult): a stored smalltalk reply is not a prior answer; summary counts as one (acceptance fix for BUG-1071)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-27 23:43:57 +08:00

268 lines
14 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);
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("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, /这三个二级标题/);
assert.match(natal, /开场不写行动清单/);
assert.match(natal, /只在这里出现一次/);
assert.match(natal, /全文不超过 900 字/);
assert.match(natal, /不要写「先回答你的问题」这个标题/);
assert.match(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 字/);
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);
});