feat(consult): plain answer shape — conclusion first, one section per person, no word caps (BUG-1132~1134)

Retire the 09-17 opener shape (coined pattern name, push/tidy periods,
play-a-role) that produced riddles; one shape definition in
consultation-thinking-plan; parents card names mother/father roles with MK;
empty-house rule; thinking bar closes the opener at any H2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-01 09:42:35 +08:00
co-authored by Claude Opus 5.5
parent c304cf3a02
commit 2ec2762dcf
11 changed files with 418 additions and 102 deletions
+79 -1
View File
@@ -43,6 +43,9 @@ export const EVIDENCE_CARD_LABELS = {
genderMale: "男",
/** The annual pack could not be produced reliably; natal data is never put in its place. */
annualNotAttached: "年盘未接入",
/** Who a parents-section entry is about (TASK-consult-plain-answer-20261001 D6). */
roleMother: "母亲",
roleFather: "父亲",
} as const;
/** KP stays off every card: exact cusps are still blocked (decision table). */
@@ -86,6 +89,24 @@ export type EvidenceCardDomainSpec = Readonly<{
annualHouses: boolean;
/** Vargas follow the other domain of the turn; `vargas` is the default when alone. */
followVargas: boolean;
/**
* Who each piece of the section is about (parents: mother / father), so the
* answer can keep the people apart (TASK-consult-plain-answer-20261001 D6).
*/
roles: readonly EvidenceCardRole[];
}>;
export type EvidenceCardRole = Readonly<{
key: string;
label: string;
house: number;
planets: readonly string[];
/** Chara karakas that must be on the card; a missing one is a gap. */
karakas: readonly string[];
/** Chara karakas taken only when the engine's scheme has them (PiK is 8-karaka only). */
optionalKarakas: readonly string[];
/** The varga whose same-numbered house is named for this role. */
varga?: string;
}>;
const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSpec, "vargas">): EvidenceCardDomainSpec => ({
@@ -107,6 +128,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
annualChart: false,
annualHouses: false,
followVargas: false,
roles: [],
...value,
});
@@ -141,7 +163,13 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
education: spec({ vargas: ["D5", "D24"], houses: [5, 9], planets: ["Mercury", "Jupiter"] }),
migration: spec({ vargas: ["D4", "D12"], houses: [4, 12] }),
family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9] }),
parents: spec({ vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"] }),
parents: spec({
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"],
roles: [
{ key: "mother", label: EVIDENCE_CARD_LABELS.roleMother, house: 4, planets: ["Moon"], karakas: ["MK"], optionalKarakas: [], varga: "D12" },
{ key: "father", label: EVIDENCE_CARD_LABELS.roleFather, house: 9, planets: ["Sun"], karakas: [], optionalKarakas: ["PiK"], varga: "D12" },
],
}),
children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"] }),
annual: spec({ vargas: ["D1"], houses: [1], annualChart: true, annualHouses: true, slowTransits: "key_dates" }),
timing: spec({
@@ -237,6 +265,8 @@ export type EvidenceCardDomainSection = {
house_basis?: JsonRecord;
/** The turn's other domain whose section already carries the vargas this section follows. */
vargas_follow?: ConsultationDomain;
/** Per-person index into this section (parents: mother / father). */
roles?: JsonRecord;
/**
* Present only when this domain's own base differs from the card's base
* (the first domain's). The same chart gives the same base; a difference is
@@ -343,6 +373,23 @@ const FUNCTIONAL_KEYS = [
const PERIOD_KEYS = ["lord", "start", "end"] as const;
const NARAYANA_PERIOD_KEYS = ["sign", "lord", "years", "start_age", "end_age"] as const;
const KARAKA_KEYS = ["planet", "degree_in_sign", "rank"] as const;
const ZODIAC_SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
/** The sign and occupants of house `number` counted from a varga's own lagna. */
function vargaHouse(row: unknown, number: number): JsonRecord | undefined {
const lagna = record(row).lagna;
const start = typeof lagna === "string" ? ZODIAC_SIGNS.indexOf(lagna as (typeof ZODIAC_SIGNS)[number]) : -1;
if (start < 0) return undefined;
const sign = ZODIAC_SIGNS[(start + number - 1) % 12]!;
const occupants = Object.entries(record(record(row).planets))
.filter(([, value]) => value === sign)
.map(([name]) => name);
return { house: number, sign, occupants };
}
const ARUDHA_KEYS = ["sign", "lord"] as const;
/** The engine writes the Arudha Lagna as pada A1 (its own name: "Arudha Lagna (AL)"). */
const ARUDHA_ENGINE_KEY: Readonly<Record<string, string>> = { AL: "A1" };
@@ -538,6 +585,37 @@ function domainSection(
}
section.karakas = karakas;
}
if (rule.roles.length) {
const table = record(record(localLayers.jaimini).chara_karakas);
const roles: JsonRecord = {};
for (const role of rule.roles) {
const houseRow = record(section.houses[String(role.house)]);
// The role names its karakas; their values sit once in section.karakas.
// A required one missing there is already a gap; an optional one (PiK)
// joins section.karakas only when the engine's scheme has it.
const karakas = role.karakas.filter((code) => section.karakas?.[code] !== undefined);
for (const code of role.optionalKarakas) {
const picked = pickKeys(table[code], KARAKA_KEYS);
if (!nonEmpty(picked)) continue;
section.karakas = { ...(section.karakas ?? {}), [code]: picked };
karakas.push(code);
}
const entry: JsonRecord = {
label: role.label,
house: role.house,
house_lords: Array.isArray(houseRow.lords) ? houseRow.lords : [],
significators: role.planets.filter((name) => caseInsensitive(base.natal.planets, name)),
};
if (karakas.length) entry.karakas = karakas;
if (role.varga) {
const varga = vargaHouse(section.vargas[role.varga], role.house);
if (varga) entry[`${role.varga.toLowerCase()}_house`] = varga;
else gaps.push(`${domain}.role.${role.key}.${role.varga}`);
}
roles[role.key] = entry;
}
section.roles = roles;
}
if (rule.yogas) {
const yogas = record(natal.yogas);
const rows = Array.isArray(yogas.yogas) ? yogas.yogas.map(record) : [];
+44 -17
View File
@@ -12,15 +12,24 @@ 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.
* 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: "盘里支持这个判断的地方",
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.`;
/** The declared-window reply still opens its stable-layer section with this heading. */
export const WINDOW_OPEN_HEADING = "先回答你的问题";
@@ -124,7 +133,8 @@ export function natalConsultationThinkingPlan(input: {
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).
// 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,
@@ -132,7 +142,7 @@ export function natalConsultationThinkingPlan(input: {
steps: [
{
id: "question-read",
label: input.followUp ? "直接回答这句话" : "用盘上的反差回答这个问题",
label: input.followUp ? "直接回答这句话" : "先用一句话回答",
status: "pending",
},
],
@@ -155,7 +165,7 @@ export function natalConsultationThinkingPlan(input: {
}),
publicThinkingSectionSchema.parse({
id: "action",
title: "这周可以扮演哪个象",
title: "这周可以做什么",
heading: REPORT_HEADING.action,
steps: [
{ id: "action-write", label: "给出可立即执行的一两件事", status: "pending" },
@@ -236,9 +246,14 @@ export function applyThinkingSectionProgress(
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) => {
const headingPresent = present.has(section.heading);
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) {
@@ -294,7 +309,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo
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.`,
`${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,
@@ -342,7 +357,7 @@ export function consultationContinuePrompt(output: string): string {
return [
"上一轮用户可见正文因长度在标题处停下。从最后一个完整二级标题之后继续写完,不要重复已写出的段落,不要写思考过程清单。",
last ? `最后一个完整标题是:${last}` : "上一轮还没有写出完整的二级标题。",
`必须继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。`,
`问题里点到的人或子问题还没写到的,先按对象分段写完;然后继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。`,
"已写出的末尾摘录:",
output.slice(-800),
].join("\n");
@@ -356,16 +371,28 @@ export function consultationContinuePrompt(output: string): string {
*/
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 {
/**
* 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 [
ANSWER_FROM_EVIDENCE,
"开场不要标题,先用不超过 400 字口语回答问题:反差只写盘上的结构(表面 A、底下 B 都是星、宫、大运或分盘),说清谁在推谁在修,然后「别去应 X 的象,去扮演 Y 的象」;开场不写行动清单。",
`然后一次写完这三个二级标题:## ${REPORT_HEADING.support}(只写开场没说过的依据,表格与正文二选一,不把开场的判断再列一遍)、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}(最多三条行动,只在这里出现一次)。全文不超过 900 字。`,
"开场不要标题,先用一到三句人话回答问题,第一句就是结论;不给这件事起名字,开场不写行动清单。",
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,二级标题用问题里的词(例:## 你和妈妈、## 你和爸爸、## 他们怎么对你);问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
"每段先用人话讲清楚这个人是什么样、你们怎么相处、事情会怎么走,再在括号里给撑住这段判断的盘上依据。",
`然后依次写:## ${REPORT_HEADING.support}(只写上面没引过、判断又需要的依据,表格与正文二选一;没有新依据就省掉这一节)、## ${REPORT_HEADING.timing}(卡里没有相关大运或行运就省掉)、## ${REPORT_HEADING.action}(一到三条行动,只在这里出现一次)。`,
"不限字数,讲清楚为准;同一件事只说一遍。",
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。",
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
export function natalAnswerShapeInstruction(): string {
return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`;
}
/**
* Follow-up turn shape (TASK-consult-answer-the-question-20260927 D3 /
* BUG-1071): the session already holds a natal answer, so this turn answers
@@ -377,8 +404,8 @@ export function natalAnswerShapeInstruction(): string {
export function followUpAnswerShapeInstruction(): string {
return [
ANSWER_FROM_EVIDENCE,
"这是追问轮:本会话前面已经写过完整解读。不超过 200 字,第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据);只引与这句话直接相关的盘面事实,最多三处;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
`只有用户明确换了领域或要求完整看一下,才回到口语开场加三个二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})的完整形状。`,
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
`只有用户明确换了领域或要求完整看一下,才回到完整形状:口语开场、按对象分段,再接二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})。`,
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}