Files
Jyotisha/frontend/src/lib/consultation-thinking-plan.ts
T
Jesse_ChenandClaude Opus 5.5 955bab691a feat(consult): sections presuppose nothing, examples carry no reading rule or job shape, corrections are acknowledged
- natalAnswerShapeBody: NATAL_SECTION_RULE replaces 「这个人是什么样、你们怎么相处、
  事情会怎么走」; the shared voice quotes the same constant (BUG-1164)
- product-voice / VOICE.md: no 逆行 in examples, parents example leads with the
  affliction and asks to confirm, married-presupposing Good example removed (BUG-1165)
- follow-up shape: a user correction is acknowledged, never defended (BUG-1166)
- career examples carry no job shape (升职 / 专业能力 / 做深现有工作) (BUG-1168)
- biography backtest golden regenerated with the real engine so the card carries
  brief 1's aspects, combustion, yoga domains and saturn_from_moon_house;
  backtest script gains --correction follow-up turns

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-02 00:38:01 +08:00

494 lines
24 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 { z } from "zod";
import {
consultationDomainDefinition,
type ConsultationDomain,
} from "./consultation-domain-registry.ts";
export const THINKING_STEP_STATUSES = ["pending", "active", "done"] as const;
export type ThinkingStepStatus = (typeof THINKING_STEP_STATUSES)[number];
export const VISIBLE_THINKING_STEP_LIMIT = 4;
/**
* The natal body headings (TASK-consult-answer-the-question-20260927 D8 /
* BUG-1073). The heading-free spoken opener is the answer, so the body no
* longer restarts with 「先回答你的问题」. Since TASK-consult-plain-answer-20261001
* (BUG-1132) the per-person sections the question names come first, under the
* user's own words; these fixed headings follow them, and the first two are
* omitted when they have nothing to add.
*/
export const REPORT_HEADING = {
support: "盘上依据",
timing: "时间怎么看",
action: "这周可以做的一件事",
} as const;
/**
* The one statement of the first-turn natal answer shape (TASK-consult-plain-
* answer-20261001 D2/D7/D8): every English prompt that names the shape quotes
* this, and the Chinese user-turn instruction below says the same thing.
*/
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session: a heading-free opener of one to three plain sentences whose first sentence is the conclusion, with no action list in the opener; then one H2 section per person or sub-question the user named, titled with the user's own words (## 你和妈妈, ## 你和爸爸, ## 他们怎么对你), each in plain words with its chart evidence in parentheses; then ## ${REPORT_HEADING.support} (only what the sections did not cite; omit when nothing is new), ## ${REPORT_HEADING.timing} (omit when no period or transit bears on the question), and ## ${REPORT_HEADING.action} (one to three actions, once). A question about one person or one thing is not split. There is no length cap: write until it is clear and say each thing once. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
/** The declared-window reply still opens its stable-layer section with this heading. */
export const WINDOW_OPEN_HEADING = "先回答你的问题";
/** The thinking-bar section that stands for the spoken opener (D5: it answers the question, it does not guess it). */
export const OPENER_THINKING_TITLE = "先回答你问的这件事";
export const DAILY_HEADING = {
trend: "今日趋势",
actAvoid: "适合推进 / 需要避开",
action: "一个行动",
} as const;
const BLOCK_STEP_LABELS: Readonly<Record<string, string>> = {
raw_structure: "对照盘上已经算好的结构",
raman_six_step: "按六步宫位判断问题宫",
yoga_table: "核对应 Yoga 的成立与落空",
timing: "对照当前大运与行运",
synthesis: "综合强弱与下一步",
};
const TOOLISH_STEP_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i;
export const publicThinkingStepSchema = z.object({
id: z.string().min(1).max(80),
label: z.string().min(1).max(80),
status: z.enum(THINKING_STEP_STATUSES),
}).strict();
export const publicThinkingSectionSchema = z.object({
id: z.string().min(1).max(80),
title: z.string().min(1).max(120),
heading: z.string().min(1).max(80),
steps: z.array(publicThinkingStepSchema).min(1).max(12),
}).strict();
export type PublicThinkingStep = z.infer<typeof publicThinkingStepSchema>;
export type PublicThinkingSection = z.infer<typeof publicThinkingSectionSchema>;
function step(id: string, label: string, status: ThinkingStepStatus = "pending"): PublicThinkingStep | null {
const cleaned = label.replace(/\s+/g, " ").trim();
if (!cleaned || TOOLISH_STEP_RE.test(cleaned)) return null;
return { id, label: cleaned.slice(0, 80), status };
}
function uniqueSteps(steps: ReadonlyArray<PublicThinkingStep | null>): PublicThinkingStep[] {
const seen = new Set<string>();
const kept: PublicThinkingStep[] = [];
for (const item of steps) {
if (!item || seen.has(item.label)) continue;
seen.add(item.label);
kept.push(item);
if (kept.length >= 12) break;
}
return kept;
}
function layerSteps(layers: readonly string[] | undefined, prefix: string): Array<PublicThinkingStep | null> {
return (layers ?? []).slice(0, 6).map((layer, index) => (
step(`${prefix}-${index}`, `对照 ${layer.replace(/[_]/g, " ").trim()}`)
));
}
export function consultationReportHeadings(_domains: readonly ConsultationDomain[] = []): string[] {
return [
REPORT_HEADING.support,
REPORT_HEADING.timing,
REPORT_HEADING.action,
];
}
export function thinkPlanSteps(
plan: readonly PublicThinkingSection[],
): Array<{ id: string; title: string }> {
return plan.map((section) => ({ id: section.id, title: section.title }));
}
export function natalConsultationThinkingPlan(input: {
domains: readonly ConsultationDomain[];
requiredBlocks?: readonly string[];
mustUseLayers?: readonly string[];
/** A follow-up turn (the session already holds a natal answer): the opener step is the whole answer. */
followUp?: boolean;
}): PublicThinkingSection[] {
const domains = input.domains.slice(0, 6);
const blocks = new Set(input.requiredBlocks ?? Object.keys(BLOCK_STEP_LABELS));
const domainBlocks = ["raman_six_step", "yoga_table", "timing", "synthesis"]
.filter((block) => blocks.has(block));
const supportSteps = uniqueSteps([
step("support-raw", BLOCK_STEP_LABELS.raw_structure ?? "对照盘上已经算好的结构"),
...layerSteps(input.mustUseLayers, "support-layer"),
...domains.flatMap((domain) => {
const definition = consultationDomainDefinition(domain);
return [
...domainBlocks.map((block) => step(`${domain}-${block}`, `${definition.label}:${BLOCK_STEP_LABELS[block] ?? block}`)),
...definition.evidencePreview.slice(0, 3).map((item, index) => (
step(`${domain}-preview-${index}`, `对照 ${item}`)
)),
];
}),
]);
return [
// The opener has no heading of its own; it is finished when the first body
// H2 appears — a per-person section or a fixed heading (BUG-1132), see
// applyThinkingSectionProgress. It keeps the support heading as its key.
publicThinkingSectionSchema.parse({
id: "question",
title: OPENER_THINKING_TITLE,
heading: REPORT_HEADING.support,
steps: [
{
id: "question-read",
label: input.followUp ? "直接回答这句话" : "先用一句话回答",
status: "pending",
},
],
}),
publicThinkingSectionSchema.parse({
id: "support",
title: "把判断钉在盘上",
heading: REPORT_HEADING.support,
steps: supportSteps.length > 0
? supportSteps
: [{ id: "support-read", label: "对照盘上已经算好的结构", status: "pending" }],
}),
publicThinkingSectionSchema.parse({
id: "timing",
title: "时间窗口怎么看",
heading: REPORT_HEADING.timing,
steps: [
{ id: "timing-dasha", label: "对照当前大运与行运", status: "pending" },
],
}),
publicThinkingSectionSchema.parse({
id: "action",
title: "这周可以做什么",
heading: REPORT_HEADING.action,
steps: [
{ id: "action-write", label: "给出可立即执行的一两件事", status: "pending" },
],
}),
];
}
export function dailyConsultationThinkingPlan(): PublicThinkingSection[] {
return [
publicThinkingSectionSchema.parse({
id: "daily-trend",
title: DAILY_HEADING.trend,
heading: DAILY_HEADING.trend,
steps: [
{ id: "trend-read", label: "对照今日过境与大运纹理", status: "pending" },
{ id: "trend-write", label: "写出今日趋势", status: "pending" },
],
}),
publicThinkingSectionSchema.parse({
id: "daily-act-avoid",
title: DAILY_HEADING.actAvoid,
heading: DAILY_HEADING.actAvoid,
steps: [
{ id: "act", label: "列出适合推进的事", status: "pending" },
{ id: "avoid", label: "列出需要避开的事", status: "pending" },
],
}),
publicThinkingSectionSchema.parse({
id: "daily-action",
title: "一个行动 + 边界句",
heading: DAILY_HEADING.action,
steps: [
{ id: "action", label: "给出一个可立即执行的行动", status: "pending" },
{ id: "boundary", label: "写上探索性日提示,不是确定预测", status: "pending" },
],
}),
];
}
export function generalConsultationThinkingPlan(): PublicThinkingSection[] {
return [publicThinkingSectionSchema.parse({
id: "answer",
title: "接下来组织这轮回答",
heading: "回答",
steps: [
{ id: "method", label: "读取分析方法", status: "done" },
{ id: "compose", label: "组织回答", status: "pending" },
],
})];
}
export function windowConsultationThinkingPlan(): PublicThinkingSection[] {
return [publicThinkingSectionSchema.parse({
id: "window",
title: "接下来根据声明窗口整理稳定层",
heading: WINDOW_OPEN_HEADING,
steps: [
{ id: "compare", label: "比较声明窗口内的稳定层", status: "pending" },
{ id: "boundary", label: "核对应答边界", status: "pending" },
{ id: "compose", label: "组织回答", status: "pending" },
],
})];
}
export function applyThinkingSectionProgress(
sections: readonly PublicThinkingSection[],
answerText: string,
options?: { settled?: boolean },
): PublicThinkingSection[] {
if (sections.length === 0) return [];
if (options?.settled && answerText.trim()) {
return sections.map((section) => ({
...section,
steps: section.steps.map((item) => ({ ...item, status: "done" as const })),
}));
}
const present = new Set(
[...answerText.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? ""),
);
// Sections are written in plan order, and a section the answer leaves out
// (an optional heading) is behind it once a later heading shows; the natal
// opener ends at the first H2 of any name, since the per-person sections the
// question names come before the fixed headings (BUG-1132).
const lastPresent = sections.reduce((last, section, index) => (present.has(section.heading) ? index : last), -1);
let activeAssigned = false;
return sections.map((section, index) => {
const headingPresent = index <= lastPresent || (section.id === "question" && present.size > 0);
let status: ThinkingStepStatus = "pending";
if (headingPresent) status = "done";
else if (!activeAssigned) {
status = "active";
activeAssigned = true;
}
return {
...section,
steps: section.steps.map((item) => ({
...item,
status: status === "done" ? "done" : status === "active" && item.status === "done" ? "done" : status,
})),
};
});
}
export function splitAnswerByHeadings(
text: string,
headings: readonly string[],
): { preamble: string; slices: Record<string, string> } {
const slices: Record<string, string> = {};
for (const heading of headings) slices[heading] = "";
if (!text.trim()) return { preamble: "", slices };
const parts = text.split(/(?=^## )/m);
let preamble = "";
for (const part of parts) {
const heading = /^##\s+(.+?)\s*$/m.exec(part)?.[1]?.trim();
if (!heading) {
preamble += part;
continue;
}
if (heading in slices) slices[heading] += part;
else preamble += part;
}
return { preamble: preamble.trim(), slices };
}
export function visibleThinkingSteps(steps: readonly PublicThinkingStep[]): {
visible: PublicThinkingStep[];
hiddenCount: number;
} {
if (steps.length <= VISIBLE_THINKING_STEP_LIMIT) {
return { visible: [...steps], hiddenCount: 0 };
}
return {
visible: steps.slice(0, VISIBLE_THINKING_STEP_LIMIT),
hiddenCount: steps.length - VISIBLE_THINKING_STEP_LIMIT,
};
}
export function consultationSpokenHeadingRule(kind: "natal" | "general" | "window" | "daily"): string {
const secrets = "Never put tool names, error codes, parameters, internal IDs, scores, or secrets in the body.";
const activity = "Do not invent a thinking-process checklist. Activity, progress, and receipts are server-owned.";
if (kind === "natal") {
return [
`${NATAL_ANSWER_SHAPE_SUMMARY} The body never restarts the answer under a 先回答 heading. Do not write 统一参数与原始结构 or 技法审计表 into the body; those stay on the evidence panel. A follow-up turn follows the shape instruction in the user turn instead.`,
"Parallel points such as today's transits, 适合推进, 需要避开, and candidate windows must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.",
activity,
secrets,
].join(" ");
}
if (kind === "daily") {
return [
`After a 3-6 sentence spoken reply with no heading that answers the user's question (one conclusion, 2–3 short point-sentences of at most 30 characters each, then one next-step; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${DAILY_HEADING.trend}, then ## ${DAILY_HEADING.actAvoid}, then ## ${DAILY_HEADING.action}. Put the Technique Audit Table and the sentence 「探索性日提示,不是确定预测」 inside the last section. The skeleton must not be omitted.`,
"Parallel points such as 适合推进 and 需要避开 must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.",
activity,
secrets,
].join(" ");
}
if (kind === "window") {
return [
`When describing stable window structure, start with ## ${WINDOW_OPEN_HEADING}.`,
activity,
secrets,
].join(" ");
}
return `${activity} ${secrets}`;
}
export function upsertThinkingSection(
sections: readonly PublicThinkingSection[],
next: PublicThinkingSection,
): PublicThinkingSection[] {
const parsed = publicThinkingSectionSchema.parse(next);
const index = sections.findIndex((section) => section.id === parsed.id);
if (index < 0) return [...sections, parsed];
return sections.map((section, current) => (current === index ? parsed : section));
}
export function parsePublicThinkingSections(value: unknown): PublicThinkingSection[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
const parsed = publicThinkingSectionSchema.safeParse(item);
return parsed.success ? [parsed.data] : [];
});
}
export function consultationContinuePrompt(output: string): string {
const headings = [...output.matchAll(/^## .+$/gm)].map((match) => match[0]);
const last = headings.at(-1);
return [
"上一轮用户可见正文因长度在标题处停下。从最后一个完整二级标题之后继续写完,不要重复已写出的段落,不要写思考过程清单。",
last ? `最后一个完整标题是:${last}` : "上一轮还没有写出完整的二级标题。",
`问题里点到的人或子问题还没写到的,先按对象分段写完;然后继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。`,
"已写出的末尾摘录:",
output.slice(-800),
].join("\n");
}
/**
* The line that grounds the answer in this turn's calculation. Since BUG-1053
* the loop's own step after the calculation result writes the answer; there is
* no separate compose stream (its prompt said 「服务器计算已经完成」 while it
* never saw the result), so the writing instructions travel with the user turn.
*/
const ANSWER_FROM_EVIDENCE = "拿到本轮计算结果后直接写给用户的回答,只用结果里的盘面事实下判断;调用工具前后都不要写过程说明。";
/**
* What each per-person section says (TASK-consult-no-presupposition-and-
* backtest-20261001 D1/D2/D7, BUG-1164; replaces 「这个人是什么样、你们怎么
* 相处、事情会怎么走」, which presupposed the person is there and in daily
* contact). Defined here once; the shared voice quotes this constant.
*/
export const NATAL_SECTION_RULE = "每段先说盘上最强的一两个信号说明了什么,包括这个人在不在你身边、近不近;盘上没有依据的方面不写,不用日常场景凑段落。受冲信号两个及以上时,这一段第一句就写这一面,用「可能」,段末用一句话请用户确认(例:「如果实际情况不是这样,告诉我,我按真实情况重看。」),全篇这类确认最多一处。不预设有没有伴侣、是否已婚、是否在上班、做哪类工作、孩子性别、父母是否在身边;性别未知时不用「他」「她」称伴侣和孩子。";
/**
* Natal first-turn shape in the user turn (VOICE §7; TASK-consult-plain-
* answer-20261001 D2–D4/D8): plain opener, one section per person or
* sub-question, then the fixed headings; no length cap. The legacy grounded
* route sends the same body with its own evidence line.
*/
export function natalAnswerShapeBody(): string {
return [
"开场不要标题,先用一到三句人话回答问题,第一句就是结论;不给这件事起名字,开场不写行动清单。",
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,二级标题用问题里的词(例:## 你和妈妈、## 你和爸爸、## 他们怎么对你);问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
`${NATAL_SECTION_RULE}每段的判断后在括号里给撑住它的盘上依据。`,
`然后依次写:## ${REPORT_HEADING.support}(只写上面没引过、判断又需要的依据,表格与正文二选一;没有新依据就省掉这一节)、## ${REPORT_HEADING.timing}(卡里没有相关大运或行运就省掉)、## ${REPORT_HEADING.action}(一到三条行动,只在这里出现一次)。`,
"不限字数,讲清楚为准;同一件事只说一遍。",
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
export function natalAnswerShapeInstruction(): string {
return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`;
}
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再说盘上哪几条和它对得上、原来哪句读偏了;不为原判断辩护。";
/**
* Follow-up turn shape (TASK-consult-answer-the-question-20260927 D3 /
* BUG-1071): the session already holds a natal answer, so this turn answers
* the sentence that was asked and does not reopen the skeleton. The server
* decides that this is a follow-up from the stored history alone
* (`hasPriorAssistantAnswer`); whether the sentence is a yes/no question or a
* change of domain is the model's reading, never a regex.
*/
export function followUpAnswerShapeInstruction(): string {
return [
ANSWER_FROM_EVIDENCE,
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
FOLLOW_UP_CORRECTION_RULE,
`只有用户明确换了领域或要求完整看一下,才回到完整形状:口语开场、按对象分段,再接二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})。`,
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
/**
* The session already holds a natal answer, so this turn is a follow-up (D6,
* no intent regex). A stored smalltalk reply (「你好」 fast path) is an
* assistant message but not an answer, so it does not count; a session
* context summary only exists after earlier consultation turns were
* summarized away, so it counts even when the tail no longer holds them.
*/
export function hasPriorAssistantAnswer(
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string; responseKind?: "smalltalk" }>>,
options: Readonly<{ summaryText?: string | null }> = {},
): boolean {
if (options.summaryText?.trim()) return true;
return history.some((message) => (
message.role === "assistant" && message.responseKind !== "smalltalk" && message.text.trim().length > 0
));
}
/**
* The shape instruction the natal user turn carries: the daily entrypoint keeps
* its three sections; a natal follow-up turn answers directly; the first natal
* answer of a session writes the opener and the three-heading skeleton.
*/
export function natalUserTurnShape(input: {
entrypoint?: string;
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string; responseKind?: "smalltalk" }>>;
summaryText?: string | null;
}): string {
if (input.entrypoint === "daily_starlanguage") return dailyAnswerShapeInstruction();
return hasPriorAssistantAnswer(input.history, { summaryText: input.summaryText })
? followUpAnswerShapeInstruction()
: natalAnswerShapeInstruction();
}
/** Daily entrypoint answer shape: three sections, audit table and boundary line in the last. */
export function dailyAnswerShapeInstruction(): string {
return [
ANSWER_FROM_EVIDENCE,
`按三节写:${DAILY_HEADING.trend}、${DAILY_HEADING.actAvoid}、${DAILY_HEADING.action}(把技法审计表和「探索性日提示,不是确定预测」放进最后一节)。`,
"不要复述内部 JSON 字段。",
].join("");
}
/**
* The calculation result handed to a length continuation (BUG-1053): the
* continuation is a new stream and the agent keeps no memory between streams,
* so without this it would continue the answer blind.
*/
export function consultationContinueEvidencePrompt(evidence: unknown): string {
return [
"本轮服务器计算结果(同一请求已完成,不要重算)。续写时只用这里的盘面事实:",
JSON.stringify(evidence),
].join("\n");
}
/**
* The messages a length continuation streams with: the run's own messages, the
* calculation result when the loop fetched one, the answer so far, and the
* continue instruction. One builder for every consultation route.
*/
export function consultationContinueMessages<M>(base: readonly M[], output: string, evidence?: unknown) {
return [
...base,
...(evidence === undefined
? []
: [{ role: "user" as const, content: consultationContinueEvidencePrompt(evidence) }]),
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
];
}