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); // ① 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); });