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.
347 lines
20 KiB
TypeScript
347 lines
20 KiB
TypeScript
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(), /大运、子运、小运/);
|
||
});
|