Files
Jyotisha/frontend/src/lib/consultation-thinking-plan.ts
T
Jesse_ChenandClaude Fable 5.1 9a42333a55 feat(consult): answer the sentence asked — no motive guessing, follow-up turns answer directly, first turn says each thing once (BUG-1070~1073)
- Voice: contrast limited to chart structures; drop 「这句我按『……』理解了」; forbid motive/need sentences; yes/no answered first (BUG-1070)
- Follow-up turn (session already holds an answer): ≤200-char direct answer, no skeleton; decided from stored history, no intent regex; tool still runs each turn (BUG-1071)
- Thinking bar first section 「先回答你问的这件事」 (BUG-1072)
- First turn: opener without actions, body headings 盘里支持 / 时间怎么看 / 这周可以做的一件事 only, actions once, ≤900 chars (BUG-1073, D8)
- Checklist is a list to check, not paragraphs to write (T3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-27 23:31:33 +08:00

444 lines
19 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 「先回答你的问题」: it holds only what the opener did not
* say, the timing, and the actions, once.
*/
export const REPORT_HEADING = {
support: "盘里支持这个判断的地方",
timing: "时间怎么看",
action: "这周可以做的一件事",
} as const;
/** 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, so this section shares the support heading (BUG-1072/1073).
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() ?? ""),
);
let activeAssigned = false;
return sections.map((section) => {
const headingPresent = present.has(section.heading);
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 [
`On the first natal answer of a session: after a 3-6 sentence spoken reply with no heading that answers the user's question (反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象; no action list in the opener; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.support}, then ## ${REPORT_HEADING.timing}, then ## ${REPORT_HEADING.action}. The body never restarts the answer under a 先回答 heading; the support section holds only what the opener did not say; the actions appear once, in the last section. 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 = "拿到本轮计算结果后直接写给用户的回答,只用结果里的盘面事实下判断;调用工具前后都不要写过程说明。";
/** Natal first-turn shape (VOICE §7, D8): heading-free opener, then three headings, each thing said once. */
export function natalAnswerShapeInstruction(): string {
return [
ANSWER_FROM_EVIDENCE,
"开场不要标题,先用不超过 400 字口语回答问题:反差只写盘上的结构(表面 A、底下 B 都是星、宫、大运或分盘),说清谁在推谁在修,然后「别去应 X 的象,去扮演 Y 的象」;开场不写行动清单。",
`然后一次写完这三个二级标题:## ${REPORT_HEADING.support}(只写开场没说过的依据,表格与正文二选一,不把开场的判断再列一遍)、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}(最多三条行动,只在这里出现一次)。全文不超过 900 字。`,
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
/**
* 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,
"这是追问轮:本会话前面已经写过完整解读。不超过 200 字,第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据);只引与这句话直接相关的盘面事实,最多三处;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
`只有用户明确换了领域或要求完整看一下,才回到口语开场加三个二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})的完整形状。`,
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
/** The stored history already holds an assistant answer: this turn is a follow-up (D6, no intent regex). */
export function hasPriorAssistantAnswer(
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string }>>,
): boolean {
return history.some((message) => message.role === "assistant" && 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 }>>;
}): string {
if (input.entrypoint === "daily_starlanguage") return dailyAnswerShapeInstruction();
return hasPriorAssistantAnswer(input.history)
? 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) },
];
}