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> = { 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; export type PublicThinkingSection = z.infer; 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[] { const seen = new Set(); 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 { 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 } { const slices: Record = {}; 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>, ): 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>; }): 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(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) }, ]; }