Files
Jyotisha/frontend/tests/consult-condensed-checklist-20260927.test.ts
T
Jesse_ChenandClaude Fable 5.1 9a42333a55 feat(consult): answer the sentence asked — no motive guessing, follow-up turns answer directly, first turn says each thing once (BUG-1070~1073)
- Voice: contrast limited to chart structures; drop 「这句我按『……』理解了」; forbid motive/need sentences; yes/no answered first (BUG-1070)
- Follow-up turn (session already holds an answer): ≤200-char direct answer, no skeleton; decided from stored history, no intent regex; tool still runs each turn (BUG-1071)
- Thinking bar first section 「先回答你问的这件事」 (BUG-1072)
- First turn: opener without actions, body headings 盘里支持 / 时间怎么看 / 这周可以做的一件事 only, actions once, ≤900 chars (BUG-1073, D8)
- Checklist is a list to check, not paragraphs to write (T3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-27 23:31:33 +08:00

181 lines
9.1 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,
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", () => {
for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) {
assert.ok(lines!.length >= 5 && lines!.length <= 8, `${domain}: ${lines!.length} lines`);
}
assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), ["career", "marriage", "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: ["接触", "结构性机会", "公开落地", "事业必成", "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}`);
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$/);
const health = consultationMethodologyForDomains(["health"])!;
assert.match(health.sections.find((section) => section.title.includes("health-timing-strict"))!.source, /strict-workflow-router\.md$/);
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"]);
});
test("size: every single-domain turn stays within 12,000 characters, contract and checklist included", 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);
assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`);
}
}
});