Files
Jyotisha/frontend/tests/consultation-thinking-plan.test.ts
T
jesse-uxandJesse_Chen 5a589a9058 fix(consult): write the first turn as chat, not a sectioned report
Ordinary consult first turns drop Markdown headings and the required
action list. People are named in the paragraph, and pinned examples no
longer contain copyable advice. BUG-1244, BUG-1245.
2026-10-06 15:58:40 +08:00

347 lines
20 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 = [support, support, timing, action],含「这周可以做什么」
// 新值: 三节 heading = [support, support, timing],不再有 action 节
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):思考栏去掉「这周可以做什么」;heading 字段仍是 schema 必填,留给旧答案对上标题
assert.deepEqual(sections.map((section) => section.heading), [
REPORT_HEADING.support,
REPORT_HEADING.support,
REPORT_HEADING.timing,
]);
assert.equal(JSON.stringify(sections).includes("这周可以做什么"), false);
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"] });
// 原值: 口语开场还没有标题时首节 active
// 新值: 整篇没有二级标题时没有任何一节是 active(避免首轮一直卡在转圈)
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):首轮正文不写标题,进度改走结算时全部完成
const opening = applyThinkingSectionProgress(sections, "外松内紧。\n");
assert.equal(opening.some((section) => section.steps.some((step) => step.status === "active")), false);
assert.equal(opening[0]?.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("a heading-free first turn stays unstuck while streaming and finishes every section when settled (BUG-1244)", () => {
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
const text = "你今年事业上机会不少,同时事情多、要求严。\n现在这十年偏向慢慢攒。\n";
const streaming = applyThinkingSectionProgress(sections, text);
assert.equal(streaming.some((section) => section.steps.some((step) => step.status === "active")), false);
assert.equal(streaming.every((section) => section.steps.every((step) => step.status === "pending")), true);
const settled = applyThinkingSectionProgress(sections, text, { settled: true });
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
assert.equal(JSON.stringify(sections).includes("这周可以做什么"), false);
});
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`);
// 原值: ["done", "done", "done", "active"](时间节之后还有行动节)
// 新值: ["done", "done", "done"](行动节已删,时间节是最后一节)
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244)
assert.deepEqual(onTiming.map((section) => section.steps[0]?.status), ["done", "done", "done"]);
});
test("continue prompt asks to resume after the last complete heading", () => {
const prompt = consultationContinuePrompt("## 事业\n方向稳定。\n");
// 原值: /最后一个完整标题是:## 事业/,并要求续写补三个固定二级标题
// 新值: 认出已写出的最后一个二级标题,但要求改用段落续写,不再点名三个固定标题
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244):首轮不再有固定节名
assert.match(prompt, /最后一个二级标题是:## 事业/);
assert.doesNotMatch(prompt, /## 先回答你的问题、/);
assert.match(prompt, /不要写「先回答你的问题」这个标题/);
assert.doesNotMatch(prompt, new RegExp(`## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}`));
assert.match(prompt, /不要再补新的二级标题/);
assert.match(prompt, /不要重复已写出的段落/);
assert.match(prompt, /不要写思考过程清单/);
});
test("natal spoken answers default to paragraphs, not bold-label lists (BUG-1244)", () => {
// 原值: /Markdown bullet lists/(并列点必须是加粗标签列表)
// 新值: 默认写段落;只有用户要求列举或有三个以上时间窗才用列表
// 原因: TASK-consult-conversational-answer-20261006 D5
const natal = consultationSpokenHeadingRule("natal");
assert.match(natal, /Write paragraphs/);
assert.match(natal, /three or more candidate time windows/);
assert.doesNotMatch(natal, /Markdown bullet lists/);
assert.match(consultationSpokenHeadingRule("daily"), /Markdown bullet lists/);
assert.doesNotMatch(consultationSpokenHeadingRule("window"), /start with ## /);
});
test("think plan flattens natal sections into v2 steps", () => {
const steps = thinkPlanSteps(natalConsultationThinkingPlan({
domains: ["career", "wealth"],
}));
// 原值: ["question", "support", "timing", "action"]
// 新值: ["question", "support", "timing"]
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):删掉「这周可以做什么」
assert.deepEqual(steps.map((step) => step.id), ["question", "support", "timing"]);
});
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, /开场不要标题/);
// 原值: 三个固定标题都写进首轮指令(## 盘上依据 / 时间怎么看 / 这周可以做的一件事)
// 新值: 首轮指令不要求任何 ## 节名;多人分段落、行动不强制、通常三到六段
// 原因: TASK-consult-conversational-answer-20261006 D1–D3/D5(BUG-1244)
assert.doesNotMatch(natal, /## (盘上依据|时间怎么看|这周|你和妈妈)/);
assert.match(natal, /不写 ##/);
assert.match(natal, /段首用人话点名/);
assert.match(natal, /行动不强制/);
assert.match(natal, /三到六段/);
assert.equal(Object.values(REPORT_HEADING).length, 3);
// 原值: assert.match(natal, /## 你和妈妈、## 你和爸爸、## 他们怎么对你/) 与 /然后依次写:/
// 新值: 段首点名,不写二级标题;仍要求父母分开写
// 原因: 同上 D1:按对象的二级标题一并取消
assert.match(natal, /问题里点到几个人或几个子问题/);
assert.match(natal, /「你妈妈这边……」「爸爸那边……」/);
assert.match(natal, /问父母时妈妈和爸爸分开写/);
assert.doesNotMatch(natal, /然后依次写:/);
assert.match(natal, /开场不写行动清单/);
assert.match(natal, /全篇最多一处/);
// 原值: assert.match(natal, /不限字数,讲清楚为准;同一件事只说一遍/)
// 新值: 首轮通常三到六段、不设硬截断;仍不得出现「不超过 N 字」
// 原因: TASK-consult-conversational-answer-20261006 D3:推翻 10-01「首轮不限字数」
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, "直接回答这句话");
// 原值: ["question", "support", "timing", "action"]
// 新值: ["question", "support", "timing"]
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244)
assert.deepEqual(thinkPlanSteps(plan).map((step) => step.id), ["question", "support", "timing"]);
const settled = applyThinkingSectionProgress(plan, "不是。4 宫主落 10 宫,她把心力投在你的前途上。", { settled: true });
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
});
test("answers name things in Chinese only, one name per thing (BUG-1148)", () => {
// 10-01 real-model run over ten domains: sade sati, vargottama, rupas and
// Ketu leaked into the body, and 计都 / 子运 had three spellings each.
for (const instruction of [natalAnswerShapeInstruction(), followUpAnswerShapeInstruction()]) {
assert.match(instruction, /名词只用中文/);
assert.match(instruction, /不写英文或梵文音译/);
}
assert.match(natalAnswerShapeInstruction(), /罗睺、计都/);
assert.match(natalAnswerShapeInstruction(), /大运、子运、小运/);
});