Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
508 lines
25 KiB
TypeScript
508 lines
25 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;
|
||
|
||
/**
|
||
* 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 to write when one person or matter carries two or more affliction
|
||
* signals. Product decision 2026-10-02 (after the celebrity backtest: present
|
||
* and absent fathers score the same signal count, control false positives
|
||
* went 1 -> 15): say the line shows pressure or distance, give the reality as a
|
||
* range, never decide a biographical fact the user knows better, never write
|
||
* the opposite reassurance, and ask once. Defined here only; the shared
|
||
* reading and the answer shape (NATAL_SECTION_RULE) both quote it.
|
||
*/
|
||
export const AFFLICTION_RANGE_RULE = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(括号里写是哪几条),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处。";
|
||
|
||
/**
|
||
* 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. Since
|
||
* the product decision of 2026-10-02 the two-or-more-signals part is
|
||
* AFFLICTION_RANGE_RULE (pressure signs + a range + one ask, never a
|
||
* biographical verdict), shared with the shared reading.
|
||
*/
|
||
export const NATAL_SECTION_RULE = `每段先说盘上最强的一两个信号说明了什么;盘上没有依据的方面不写,不用日常场景凑段落。${AFFLICTION_RANGE_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) },
|
||
];
|
||
}
|