Files
Jyotisha/frontend/tests/consult-condensed-checklist-20260927.test.ts
T

211 lines
12 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.
// 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}`);
}
}
}
});