04463e9af3
Keep provider thinking on a separate channel so process talk is not billed as the spoken reply (BUG-359). Co-authored-by: Cursor <cursoragent@cursor.com>
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
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;
|
|
|
|
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 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"): 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 [
|
|
`Write the spoken answer 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}.`,
|
|
"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 === "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 function consultationSectionPrompt(heading: string, priorOutput: string): string {
|
|
const title = heading.trim() || REPORT_HEADING.foundation;
|
|
return [
|
|
"服务器计算已经完成。不要再调用排盘工具,不要重算,不要读取其他二级标题。",
|
|
`只写这一个二级标题及其正文:## ${title}`,
|
|
"不要写其他 ## 标题,不要复述已经写出的段落,不要写思考过程清单。",
|
|
priorOutput.trim()
|
|
? `已经写出的上文(冻结,勿重复):\n${priorOutput.slice(-4000)}`
|
|
: "这是正文的第一节。",
|
|
].join("\n");
|
|
}
|