Files
Jyotisha/frontend/src/lib/consultation-thinking-plan.ts
T

557 lines
32 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;
/**
* Headings of answers written before 2026-10-06. Kept so old messages still
* split and shorten. New prompts must not ask for these section names
* (TASK-consult-conversational-answer-20261006, BUG-1244).
*/
export const REPORT_HEADING = {
support: "盘上依据",
timing: "时间怎么看",
action: "这周可以做的一件事",
} as const;
/**
* The domain checklists and the shared reading are written for the model's
* reasoning (layers, numbered signals, rules about what strength does not
* mean). A 10-06 real-device marriage answer quoted them to the reader —
* 「心动这一层」「宫主落在从它数起的第 6 位」「这条只说这颗星自己有劲」 — and coined
* 「合力星」 for a card field that only has an English name (BUG-1253). One
* statement here: what the checklists are for, and the plain words for the
* card fields that have no everyday Chinese name.
*/
export const CHECKLIST_ANALYSIS_ONLY_NOTE = "(分析用:本清单的层名、编号、计数和规则原句只决定你怎么判断,不写进回答,也不对用户复述;说给用户的是判断结果,用生活里的话。)";
export const PLAIN_SPEECH_RULE = "分析清单只用来想,不用来说:工具结果里的清单(受冲信号的编号与条数、「分三件事看」的层名、「旺弱不说明顺不顺」这类规则原句、「从某宫数起第几宫」这类数法)只决定你怎么判断,不写进回答,也不对用户解释规则;说给用户的是判断的结果,用生活里的话。括号里的依据最多两条,挑最能撑住这句话的,用白话写,不把信号列全。盘上字段没有日常中文叫法时按这张对照说,对照里没有的名词就不提:功能吉星 → 对你这个上升来说帮得上忙的星;功能凶星 → 对你这个上升来说偏添麻烦的星;yogakaraka → 不提,或说这张盘里最帮你的那颗星,不自造译名;DK → 代表伴侣的星;UL、Vivah Saham、Punarphoo → 不提名字,说婚姻这条线、结婚的时机;AmK → 代表事业的星;MK、PiK、PK → 代表母亲、父亲、孩子的星;AL、A10 → 外人眼里的你、事业在外人眼里的样子;SAV、Shadbala → 只说强、中、弱;被照(aspected_by)→ 某星照着;燃烧(combust)→ 被太阳压住;落陷 → 力气弱;Narayana 大运 → 不提名字,说两套算法都指向这段时间。";
/**
* The one statement of the first-turn natal answer shape (TASK-consult-
* conversational-answer-20261006 D1–D3/D5/D6): 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, write prose paragraphs and no Markdown headings. The first sentence is the conclusion. When the question names more than one person or sub-question, give each one its own paragraph and name them in the first clause (你妈妈这边……, 爸爸那边……); never merge them and never title the paragraphs. One person or one matter is not split by person. The tool's checklists are for reasoning only: never quote their layer names, signal counts, numbered rules or house-counting, and say card fields in the plain words of the 白话对照 rule. Separate paragraphs with a blank line; a single line break does not start a new paragraph on screen. Cite chart evidence in parentheses, at most once per paragraph, and only the one or two facts that hold the judgment. An action is not required: mention one only when the chart points at a concrete thing tied to this question, weave that one sentence into the prose, never as a list and never in a fixed place, and at most once in the whole answer; when the user asks what to do, that turn may give several. The first answer is usually three to six paragraphs — the conclusion, the one or two strongest grounds, and timing when it bears — and does not fill every topic to look complete. There is no hard cutoff. Say each thing once. Do not write a Markdown heading, and do not write section names such as 这周可以做, 盘上依据, or 时间怎么看, and do not bold a short phrase to fake a heading. Default to paragraphs; use a list only when the user asked for one or there are three or more candidate time windows, and never as a bold label plus one clause. 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 [
// First-turn prose has no H2, so these sections do not wait on a body
// heading. `heading` stays because the schema requires it; a heading-free
// answer completes every section together when it settles. The stored
// heading strings still match an older answer that used them.
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" },
],
}),
];
}
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() ?? ""),
);
// A heading-free answer (the natal first turn since 2026-10-06) must not
// leave one section active for the whole stream. Settlement above marks
// every section done. Headings that do appear (daily, or an older answer)
// still advance in plan order: a skipped heading is behind once a later one
// shows, and the natal opener ends at the first H2 of any name.
if (present.size === 0) {
return sections.map((section) => ({
...section,
steps: section.steps.map((item) => ({
...item,
status: item.status === "done" ? "done" as const : "pending" as const,
})),
}));
}
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 [
"Write paragraphs. Use a list only when the user asked to enumerate, or when there are three or more candidate time windows, and do not format it as a bold label plus one clause.",
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 [
"Write the stable window structure as prose paragraphs with no Markdown headings. Do not open the body with a 先回答你的问题 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}。不要再补新的二级标题,把没说完的话写成段落。`
: "上一轮还没有二级标题。接着用段落写完,不要新起二级标题。",
"首轮正文不写二级标题,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。",
"已写出的末尾摘录:",
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.
* BUG-1182 (round-4 backtest): an afflicted mother was still portrayed as
* "managing / arranging / close" from the 4th lord's house or a benefic in
* the 4th, and two asks stacked; the rule now names both.
*/
export const AFFLICTION_RANGE_RULE = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(括号里只写最能说明问题的一两条,用白话,不列全),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;不拿宫主落哪一宫、宫里有吉星去描写这个人对你做什么(「在管」「在安排」「心思在你的前途上」「照顾是实的」都预设人在身边,宫主落宫和吉星只说这条线另有支撑的迹象),这一方的行动建议也不写给他打电话、去问他;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处,问过之后别的段落不再另问。";
/**
* Career answers (product decision 2026-10-02 on TASK-consult-career-yoga-
* functional-20261002, BUG-1176: "don't guess the field — ask"). The chart
* cannot separate a footballer from a consultant or a public figure from a
* back-office one (the 10th-house system of the backtest celebrities read as
* analysis / advice for actors, athletes and presidents alike), so the answer
* talks about strength, resistance and timing and asks what the user does.
* Same principle as AFFLICTION_RANGE_RULE. Defined here only; the career
* checklist quotes it.
* Round 5 (BUG-1176): weak placements of A10 / AL / Sun / 10th lord were
* still translated into "behind the scenes / go deep"; the rule names them.
*/
export const CAREER_FIELD_ASK_RULE = "事业题只讲盘上的力量、阻力和时间:哪几颗星、哪几个宫给事业撑力,阻力在哪,哪段时间推进;不替用户断定他做哪一行、是公众型还是幕后型(盘分不出,用户自己更清楚,同受冲规则);A10、AL、太阳、10 宫主落 4、6、8、12 宫只说这条线的阻力或代价(要经手、要绕路、来得晚),不翻成「不在台前」「不在台面上」「不靠曝光」「幕后」「不是站台式」「往深处做」,也不把落宫翻成行业(8 宫不等于研究、账目,12 宫不等于海外、幕后工作);不写「出头慢」「越往后越稳」「靠专业交付」这类人人适用的话,除非能指出具体盘面;事业这一段末尾用一句话问用户实际做什么(例:「你现在具体做哪一行、什么角色,告诉我一句,我按你的实际工作再看。」),这一句就是全篇唯一一处请用户说实际情况,受冲那段不再另问。";
/** BUG-1176: the user states their work in a follow-up; read the chart relative to it. */
export const CAREER_FOLLOW_UP_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-
* conversational-answer-20261006 D1–D6): prose paragraphs, no headings, an
* action only when the chart points at one, usually three to six paragraphs.
* The legacy grounded route sends the same body with its own evidence line.
*/
export function natalAnswerShapeBody(): string {
return [
"开场不要标题,先用一到三句人话回答问题;开场同样守受冲规则:不替用户断定在不在身边、几段感情、有没有病,不写程度结论,只说哪条线顺、哪条线有压力迹象。不给这件事起名字,开场不写行动清单。",
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,段首用人话点名(「你妈妈这边……」「爸爸那边……」),不写二级标题;问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
`${NATAL_SECTION_RULE}每段的判断后在括号里给撑住它的盘上依据,每段最多一处括号。`,
"行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单、不放固定位置、全篇最多一处;用户问「我该怎么办」或「怎么做」时,那一轮可以给几条。",
"首轮通常三到六段:讲清结论、最关键的一两条依据,相关时带上时间;不为显得周全把各方面写满。不设硬截断。同一件事只说一遍。",
"正文写成段落,段落之间空一行(单个换行在界面上不分段)。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。",
"首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。",
PLAIN_SPEECH_RULE,
RECTIFICATION_OFFER_RULE,
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
export function natalAnswerShapeInstruction(): string {
return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`;
}
/**
* C2 (BUG-1248) plus direct entry when this turn explicitly asks (BUG-1306).
* Never decided from keywords. Defined here once; the offer tool names the
* same parameters and does not restate this rule.
*/
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户这一轮明确要做校正,就在写回答之前调用 offer-birth-time-rectification 并传 explicitRequest=true;工具通过后只写那一句过渡,不写解读。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
/** 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,
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。",
PLAIN_SPEECH_RULE,
FOLLOW_UP_CORRECTION_RULE,
RECTIFICATION_OFFER_RULE,
CAREER_FOLLOW_UP_RULE,
"只有用户明确换了领域或要求完整看一下,才回到首轮形状:几段人话,多人就分段落、段首点名,不写二级标题。",
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 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 is a few prose paragraphs with no headings.
*/
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) },
];
}