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:
co-authored by
Claude Opus 5.5
parent
c304cf3a02
commit
2ec2762dcf
@@ -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) : [];
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user