Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
211 lines
12 KiB
TypeScript
211 lines
12 KiB
TypeScript
// TASK-consult-evidence-card-v2-20260927 T4: the web consultation hands the
|
||
// answer model a condensed checklist for career, marriage and wealth (must-see
|
||
// items, the three layers, forbidden statements, the gender-unknown rule), and
|
||
// nothing else changes: the router file, the event-judgment files, the bound
|
||
// system method and every other route are as before.
|
||
//
|
||
// Real `getJyotishAgent` + prompt-recording fake model; the calculation is the
|
||
// golden engine capture of public AA charts (AGENTS §7.4).
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync, readdirSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
CONDENSED_CHECKLIST_SOURCE,
|
||
CONSULT_BRIEF_CHECKLIST_SOURCE,
|
||
CONSULTATION_CONDENSED_CHECKLISTS,
|
||
} from "../src/lib/consultation-condensed-checklist.ts";
|
||
import { consultationMethodologyForDomains, sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts";
|
||
import { consultationDomainIds, type ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
|
||
import { REPORT_HEADING } from "../src/lib/consultation-thinking-plan.ts";
|
||
import { createConsultationRuntimeState, createConsultationTools } from "../src/mastra/consultation-tools.ts";
|
||
import { firstPromptWithToolResult, pieces, runNatalAgent, type Turn } from "./consult-natal-agent-test-support.ts";
|
||
|
||
type Json = Record<string, unknown>;
|
||
type GoldenChart = { id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json };
|
||
const golden = JSON.parse(readFileSync(
|
||
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
|
||
"utf8",
|
||
)) as { charts: GoldenChart[] };
|
||
|
||
const QUESTIONS: Record<"career" | "marriage" | "wealth", string> = {
|
||
career: "未来一年,事业该关注什么?",
|
||
marriage: "今年婚恋怎么样?",
|
||
wealth: "我的财富增长方式是什么?",
|
||
};
|
||
|
||
// 原值: 夹具里有 `## ${REPORT_HEADING.question}` 一节
|
||
// 新值: 该节删除,夹具从口语开场直接到 `## ${REPORT_HEADING.support}`
|
||
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,正文不再从「先回答你的问题」重来
|
||
const ANSWER = [
|
||
"表面是一个人在扛,底下是一群人在推,这叫「借力成事」。",
|
||
"",
|
||
`## ${REPORT_HEADING.support}`,
|
||
"十宫主在九分盘里站得住。",
|
||
"",
|
||
`## ${REPORT_HEADING.timing}`,
|
||
"先看接下来这段子运。",
|
||
"",
|
||
`## ${REPORT_HEADING.action}`,
|
||
"- **这周**:把要谈的事写下来。",
|
||
"",
|
||
].join("\n");
|
||
|
||
function toolResultText(prompt: unknown[] | undefined) {
|
||
return JSON.stringify((prompt ?? []).filter((message) => {
|
||
const value = message as { role?: string; content?: unknown };
|
||
return value.role === "tool" && JSON.stringify(value.content).includes("run-jyotish-consultation");
|
||
}));
|
||
}
|
||
|
||
async function writerPrompt(domain: "career" | "marriage" | "wealth") {
|
||
const calc: Turn = {
|
||
parts: [{ tool: { name: "run-jyotish-consultation", input: { question: QUESTIONS[domain], domains: [domain] } } }],
|
||
finish: "tool-calls",
|
||
};
|
||
const result = await runNatalAgent([calc, { parts: pieces(ANSWER), finish: "stop" }], {
|
||
workflow: golden.charts[0]!.workflow,
|
||
theme: domain,
|
||
question: QUESTIONS[domain],
|
||
});
|
||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||
const writer = firstPromptWithToolResult(result.prompts, "run-jyotish-consultation");
|
||
assert.ok(writer > 0);
|
||
return toolResultText(result.prompts[writer]);
|
||
}
|
||
|
||
test("each condensed list is 5–8 lines", () => {
|
||
// 原值:全部清单 5–8 行,且只有 career / marriage / wealth 三份;
|
||
// 新值:这三份仍 5–8 行;另有 TASK-consult-affliction-reading-20261001 定稿的 8 份(2–6 行,按任务书原文,不凑行数),
|
||
// 只有 timing 没有本地清单;原因:该任务书 T2 / 决策 D5 补齐清单,定稿文本不得改意思。
|
||
for (const domain of ["career", "marriage", "wealth"] as const) {
|
||
const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!;
|
||
assert.ok(lines.length >= 5 && lines.length <= 8, `${domain}: ${lines.length} lines`);
|
||
}
|
||
for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) {
|
||
assert.ok(lines!.length >= 2 && lines!.length <= 8, `${domain}: ${lines!.length} lines`);
|
||
}
|
||
assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), [
|
||
"annual", "career", "children", "education", "family", "general", "health", "marriage", "migration", "parents", "wealth",
|
||
]);
|
||
});
|
||
|
||
test("the writer's prompt carries the condensed list with the three layers and the forbidden statements, not the long checklist", async () => {
|
||
const required: Record<"career" | "marriage" | "wealth", string[]> = {
|
||
// 原值: career: ["接触", "结构性机会", "公开落地", …]
|
||
// 新值: 三层改为「机会出现 / 成形 / 公开落地」,加「事业形态」行;不得再有「面试」「职位」
|
||
// 原因: 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在、几段感情、有没有病);第 3 层的「面试 / 正式职位」把读者设成求职者(BUG-1168)
|
||
// 原值(2): career 必含「事业形态」
|
||
// 新值(2): 改为必含「不替用户断定他做哪一行」(CAREER_FIELD_ASK_RULE)
|
||
// 原因(2): 产品 2026-10-02「行业不猜,问用户」,删掉「先讲一生事业形态:公众型还是幕后型」(BUG-1176)
|
||
career: ["接触", "机会出现", "成形", "公开落地", "不替用户断定他做哪一行", "事业必成", "d9 段", "KP 精确宫头仍 blocked"],
|
||
marriage: [
|
||
"心动接触", "关系成对", "社会法律落地",
|
||
"5 宫主大运 ≠ 法律婚", "金星过本命月亮 ≠ 心动月", "Punarphoo 不得写成结婚", "土星回到本命月亮是观察项,不是结婚",
|
||
"性别未知:金星与木星都要看", "夫星", "Vivah Saham",
|
||
],
|
||
wealth: ["8 宫", "12 宫", "挣钱机会", "到账变现", "发财", "D10"],
|
||
};
|
||
const longChecklist = [
|
||
"Opportunity contact", "Mandatory modules", "Career timing output", "Relationship timing output",
|
||
"Marriage Event Adjudicator", "Wealth Event Adjudicator", "Career Event Adjudicator", "Route Freeze",
|
||
"Evidence Ledger Roles",
|
||
];
|
||
for (const domain of ["career", "marriage", "wealth"] as const) {
|
||
const prompt = await writerPrompt(domain);
|
||
assert.ok(prompt.includes(CONDENSED_CHECKLIST_SOURCE), `${domain}: condensed source`);
|
||
for (const line of CONSULTATION_CONDENSED_CHECKLISTS[domain]!) {
|
||
assert.ok(prompt.includes(line.slice(0, 24)), `${domain}: ${line.slice(0, 24)}`);
|
||
}
|
||
for (const phrase of required[domain]) assert.ok(prompt.includes(phrase), `${domain}: ${phrase}`);
|
||
if (domain === "career") assert.doesNotMatch(CONSULTATION_CONDENSED_CHECKLISTS.career!.join("\n"), /面试|职位|简历/);
|
||
for (const phrase of longChecklist) assert.equal(prompt.includes(phrase), false, `${domain}: long checklist text "${phrase}"`);
|
||
// The shared baseline still travels with the result.
|
||
assert.ok(prompt.includes("Shared mandatory baseline"), `${domain}: shared baseline`);
|
||
}
|
||
});
|
||
|
||
test("other routes still quote the live skill, and the bound system method is unchanged", () => {
|
||
const timing = consultationMethodologyForDomains(["timing"])!;
|
||
const eventTiming = timing.sections.find((section) => section.title.includes("event-timing-strict"));
|
||
assert.ok(eventTiming);
|
||
assert.match(eventTiming.source, /references\/strict-workflow-router\.md$/);
|
||
// 原值:health 段标题含 health-timing-strict、来源是 strict-workflow-router.md;
|
||
// 新值:普通对话的 health 段是 consult-health 中文清单,来源是任务书,不再发路由英文段;
|
||
// 原因:TASK-consult-affliction-reading-20261001 决策 D4/D5(报告面仍用路由,不受影响)。
|
||
const health = consultationMethodologyForDomains(["health"])!;
|
||
assert.equal(health.sections.some((section) => section.title.includes("health-timing-strict")), false);
|
||
assert.equal(health.sections.find((section) => section.title.includes("consult-health"))!.source, CONSULT_BRIEF_CHECKLIST_SOURCE);
|
||
for (const domain of consultationDomainIds.filter((id) => !(id in CONSULTATION_CONDENSED_CHECKLISTS))) {
|
||
const methodology = consultationMethodologyForDomains([domain])!;
|
||
assert.equal(methodology.sections.some((section) => section.source === CONDENSED_CHECKLIST_SOURCE), false, domain);
|
||
}
|
||
const shared = sharedConsultationMethodMarkdown();
|
||
assert.match(shared, /Full-Spectrum Invocation Contract/);
|
||
assert.match(shared, /Event Judgment Skeleton|event judgment/i);
|
||
});
|
||
|
||
test("only the consultation tool builds the methodology: reports and other surfaces never read it", () => {
|
||
const root = new URL("../src/", import.meta.url).pathname;
|
||
const users: string[] = [];
|
||
const walk = (dir: string) => {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const path = join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(path);
|
||
else if (/\.(ts|tsx)$/.test(entry.name) && readFileSync(path, "utf8").includes("consultationMethodologyForDomains")) {
|
||
users.push(path.slice(root.length));
|
||
}
|
||
}
|
||
};
|
||
walk(root);
|
||
assert.deepEqual(users.sort(), ["lib/consultation-methodology.ts", "mastra/consultation-tools.ts"]);
|
||
const condensedUsers: string[] = [];
|
||
const walkCondensed = (dir: string) => {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const path = join(dir, entry.name);
|
||
if (entry.isDirectory()) walkCondensed(path);
|
||
else if (/\.(ts|tsx)$/.test(entry.name) && readFileSync(path, "utf8").includes("consultation-condensed-checklist")) {
|
||
condensedUsers.push(path.slice(root.length));
|
||
}
|
||
}
|
||
};
|
||
walkCondensed(root);
|
||
assert.deepEqual(condensedUsers, ["lib/consultation-methodology.ts"]);
|
||
});
|
||
|
||
// 原值: 标题「every single-domain turn stays within 12,000 characters, contract and checklist included」;全部 12 个领域 total ≤ 12,000
|
||
// 新值: 年运、时运按事业 / 婚姻 / 财富同口径:契约 + 卡 ≤ 12,000、含清单 total ≤ 18,000;其余领域 total 仍 ≤ 12,000
|
||
// 原因: 产品 2026-10-02 决定(BUG-1160/1161):年运 / 时运单领域轮加通用读法与年运清单后约 12,980,原口径无余量
|
||
test("size: single-domain turns stay within 12,000 characters with the checklist; annual and timing within 12,000 without it and 18,000 with it", async () => {
|
||
const serverChart = {
|
||
name: "public",
|
||
toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" },
|
||
truth: { birthTimeSource: "reported" },
|
||
};
|
||
for (const chart of golden.charts) {
|
||
for (const [workflow, domain] of [
|
||
...consultationDomainIds.filter((id) => id !== "annual" && id !== "timing").map((id) => [chart.workflow, id] as const),
|
||
[chart.annual_workflow, "annual"] as const,
|
||
[chart.timing_workflow, "timing"] as const,
|
||
] as Array<readonly [Json, ConsultationDomain]>) {
|
||
const state = createConsultationRuntimeState();
|
||
const tools = createConsultationTools({
|
||
userId: "u", sessionId: "s", requestId: `size-${chart.id}-${domain}`, consultationMode: "verified_chart",
|
||
serverChart: serverChart as never, state,
|
||
runWorkflow: async () => structuredClone(workflow) as never,
|
||
});
|
||
const view = await tools["run-jyotish-consultation"].execute!({ question: "q", domains: [domain] } as never, { writer: { custom: async () => {} } } as never);
|
||
const total = JSON.stringify(view).length;
|
||
assert.equal(state.modelVisibleChars, total);
|
||
if (domain === "annual" || domain === "timing") {
|
||
const contractAndCard = JSON.stringify({ ...(view as Json), methodology: undefined }).length;
|
||
assert.ok(contractAndCard <= 12_000, `${chart.id}/${domain}: contract + card ${contractAndCard}`);
|
||
assert.ok(total <= 18_000, `${chart.id}/${domain}: ${total}`);
|
||
} else {
|
||
assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`);
|
||
}
|
||
}
|
||
}
|
||
});
|