Files
Jyotisha/frontend/src/lib/consultation-thinking-plan.ts
T
Jesse_ChenandClaude Fable 5.1 84b293fb47
Independent Staging Quality Gate / validate (push) Failing after 7m5s
Independent Staging Quality Gate / publish (push) Skipped
feat(consult): 对话口气改成反差与扮演象的形状
产品判定现有人设(懂行、可靠、说人话的占星师朋友)出来的是顾问报告。
人设改成把人当一个人认真对待、行动力很强、嘴有点毒但靠谱的同事:直接、
有立场、带一点锋利,毒只对处境不对人且每句锋利都要有盘上的证据。

开场从「一句结论 + 2–3 条短要点 + 一句下一步」换成固定形状,三种模式共用:
反差(表面 A 底下 B,命名成一个格局)→ 谁在推、谁在修(大运主星在推,
行运只负责把结果修得体面)→ 别去应 X 的象,去扮演 Y 的象 → 最多三条短行动
(破折号短句,各 ≤ 20 字)。仍无标题、总长 ≤ 400 字。术语当场用引号里的
白话套住。申报时段与无出生分钟两条降级路线形状照给,只把「谁在推谁在修」
换成窗口内稳定层或公开日历,不编月份。

新增希望纪律:盘上有转机且 answer_policy 允许精确应期时说到月份;没有就说
这段时间是拿来干什么的、可以扮演哪个象。禁「一切都会好 / 相信自己 / 加油 /
你值得更好的 / 宇宙自有安排」。

零业务逻辑改动,Skill 版本不变。tsc 0 错、lint 0 error(118 warning 不变)、
npm test 3468→3471 条且 36 条失败与基线 ff0427cf 逐条相同、/ 仍 Static、
首屏 gzip 两侧字节相同。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 16:18:07 +00:00

364 lines
14 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;
export const REPORT_HEADING = {
foundation: "统一参数与原始结构",
audit: "技法审计表",
wrap: "现代生活",
} as const;
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: "综合强弱与下一步",
technique_audit_table: "贴上技法审计表",
modern_wrap: "用现代生活语言收口",
};
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.foundation,
...domains.map((domain) => consultationDomainDefinition(domain).label),
REPORT_HEADING.audit,
REPORT_HEADING.wrap,
];
}
export function consultationComposeHeadingGroups(
plan: readonly PublicThinkingSection[],
): Array<{ section: PublicThinkingSection; headings: readonly string[] }> {
return plan.map((section) => (
section.id === "close"
? { section, headings: [REPORT_HEADING.audit, REPORT_HEADING.wrap] }
: { section, headings: [section.heading] }
));
}
export function natalConsultationThinkingPlan(input: {
domains: readonly ConsultationDomain[];
requiredBlocks?: readonly string[];
mustUseLayers?: readonly string[];
}): PublicThinkingSection[] {
const domains = input.domains.slice(0, 6);
const blocks = new Set(input.requiredBlocks ?? Object.keys(BLOCK_STEP_LABELS));
const foundationSteps = uniqueSteps([
step("foundation-raw", BLOCK_STEP_LABELS.raw_structure ?? "列出岁差、上升与宫位结构"),
...layerSteps(input.mustUseLayers, "foundation-layer"),
]);
const domainBlocks = ["raman_six_step", "yoga_table", "timing", "synthesis"]
.filter((block) => blocks.has(block));
const closeSteps = uniqueSteps([
blocks.has("technique_audit_table")
? step("close-audit", BLOCK_STEP_LABELS.technique_audit_table ?? "贴上技法审计表")
: null,
blocks.has("modern_wrap")
? step("close-wrap", BLOCK_STEP_LABELS.modern_wrap ?? "用现代生活语言收口")
: null,
]);
const sections: PublicThinkingSection[] = [
publicThinkingSectionSchema.parse({
id: "foundation",
title: "先整理本盘的统一参数",
heading: REPORT_HEADING.foundation,
steps: foundationSteps.length > 0
? foundationSteps
: [{ id: "foundation-raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}),
];
for (const domain of domains) {
const definition = consultationDomainDefinition(domain);
const steps = uniqueSteps([
...domainBlocks.map((block) => step(`${domain}-${block}`, BLOCK_STEP_LABELS[block] ?? block)),
...definition.evidencePreview.slice(0, 6).map((item, index) => (
step(`${domain}-preview-${index}`, `对照 ${item}`)
)),
]);
sections.push(publicThinkingSectionSchema.parse({
id: `domain-${domain}`,
title: `接下来分析你的${definition.label}`,
heading: definition.label,
steps: steps.length > 0
? steps
: [{ id: `${domain}-read`, label: `整理${definition.label}相关宫位`, status: "pending" }],
}));
}
sections.push(publicThinkingSectionSchema.parse({
id: "close",
title: "用审计表收口后再落到生活",
heading: REPORT_HEADING.audit,
steps: closeSteps.length > 0
? closeSteps
: [
{ id: "close-audit", label: "贴上技法审计表", status: "pending" },
{ id: "close-wrap", label: "用现代生活语言收口", status: "pending" },
],
}));
return sections;
}
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: "audit", 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: REPORT_HEADING.foundation,
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 [
`After a 3-6 sentence spoken reply with no heading that answers the user's question (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.foundation}, then ## {the Chinese label of each executed domain in tool order, such as 事业 / 财富 / 关系}, then ## ${REPORT_HEADING.audit}, then ## ${REPORT_HEADING.wrap}. The skeleton must not be omitted.`,
"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, 23 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 ## ${REPORT_HEADING.foundation}.`,
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.foundation}、各已执行领域的中文名、## ${REPORT_HEADING.audit}、## ${REPORT_HEADING.wrap}。`,
"已写出的末尾摘录:",
output.slice(-800),
].join("\n");
}
export type ConsultationSectionPromptReason = "write" | "empty-retry";
export function consultationSectionPrompt(
heading: string,
priorOutput: string,
options?: { reason?: ConsultationSectionPromptReason },
): string {
const title = heading.trim() || REPORT_HEADING.foundation;
return [
...(options?.reason === "empty-retry" ? ["上一段没有输出正文,请直接写这一节。"] : []),
"服务器计算已经完成。不要再调用排盘工具,不要重算,不要读取其他二级标题。",
`只写这一个二级标题及其正文:## ${title}`,
"不要写其他 ## 标题,不要复述已经写出的段落,不要写思考过程清单。",
priorOutput.trim()
? `已经写出的上文(冻结,勿重复):\n${priorOutput.slice(-4000)}`
: "这是正文的第一节。",
].join("\n");
}