Files
Jyotisha/frontend/tests/consult-gender-card-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

228 lines
11 KiB
TypeScript

// TASK-consult-gender-optional-20260927 T3: the marriage evidence card carries
// the current subject's optional gender, and the condensed checklist's
// gender-unknown rule applies only when it is not filled.
//
// Real `getJyotishAgent` + prompt-recording fake model; the calculation is the
// golden engine capture of public AA charts (AGENTS §7.4). The people are
// fictional; only the server chart's `gender` differs between runs.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONDENSED_GENDER_LINES,
CONSULTATION_CONDENSED_CHECKLISTS,
condensedConsultationChecklist,
} from "../src/lib/consultation-condensed-checklist.ts";
import {
EVIDENCE_CARD_LABELS,
EVIDENCE_CARD_SPECS,
buildEvidenceCard,
citedEvidenceCardFields,
} from "../src/lib/consultation-evidence-card.ts";
import { consultationMethodologyForDomains } from "../src/lib/consultation-methodology.ts";
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
import type { ProfileGender } from "../src/lib/profile-gender.ts";
import { REPORT_HEADING } from "../src/lib/consultation-thinking-plan.ts";
import { consultationEvidenceCardTelemetry } from "../src/mastra/consultation-tools.ts";
import {
firstPromptWithToolResult,
pieces,
publicServerChart,
runNatalAgent,
type Turn,
} from "./consult-natal-agent-test-support.ts";
type Json = Record<string, unknown>;
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: Array<{ id: string; workflow: Json }> };
const QUESTION = "今年婚恋怎么样?";
// 原值: 夹具里有 `## ${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");
/** Every JSON object in `value`, including ones serialized inside strings. */
function* objects(value: unknown): Generator<Json> {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
yield* objects(JSON.parse(trimmed));
} catch {
// Not JSON.
}
}
return;
}
if (Array.isArray(value)) {
for (const item of value) yield* objects(item);
return;
}
if (value && typeof value === "object") {
yield value as Json;
for (const item of Object.values(value)) yield* objects(item);
}
}
function toolMessages(prompt: unknown[] | undefined) {
return (prompt ?? []).filter((message) => {
const value = message as { role?: string; content?: unknown };
return value.role === "tool" && JSON.stringify(value.content).includes("run-jyotish-consultation");
});
}
/** The marriage section of the evidence card the writer read. */
function marriageSection(prompt: unknown[] | undefined): Json {
// claim_cards[category = domain].evidence is the card's domain_sections.
for (const object of objects(toolMessages(prompt))) {
const sections = object.category === "domain" ? object.evidence as Json | undefined : undefined;
if (sections && typeof sections === "object" && sections.marriage) return sections.marriage as Json;
}
throw new Error("marriage section not found in the writer prompt");
}
async function writerPrompt(serverChart: Record<string, unknown>) {
const calc: Turn = {
parts: [{ tool: { name: "run-jyotish-consultation", input: { question: QUESTION, domains: ["marriage"] } } }],
finish: "tool-calls",
};
const result = await runNatalAgent([calc, { parts: pieces(ANSWER), finish: "stop" }], {
workflow: golden.charts[0]!.workflow,
theme: "marriage",
question: QUESTION,
serverChart,
});
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
const writer = firstPromptWithToolResult(result.prompts, "run-jyotish-consultation");
assert.ok(writer > 0);
const prompt = result.prompts[writer];
return { prompt, text: JSON.stringify(toolMessages(prompt)), state: result.state };
}
function chartWith(gender: ProfileGender | null | undefined) {
return gender === undefined ? { ...publicServerChart } : { ...publicServerChart, gender };
}
test("only the marriage section carries gender; the label is 女 / 男 / 性别未知", () => {
const withGender = Object.entries(EVIDENCE_CARD_SPECS).filter(([, spec]) => spec.gender).map(([domain]) => domain);
assert.deepEqual(withGender, ["marriage"]);
const workflow = golden.charts[0]!.workflow;
// Pure builder, same inputs the tool uses; the golden packet stands in for toModelOutput.
const source = { domain: "marriage" as const, packet: workflow, context: workflow };
assert.equal(buildEvidenceCard([source]).domain_sections.marriage?.gender, EVIDENCE_CARD_LABELS.genderUnknown);
assert.equal(buildEvidenceCard([source], { gender: null }).domain_sections.marriage?.gender, "性别未知");
assert.equal(buildEvidenceCard([source], { gender: "female" }).domain_sections.marriage?.gender, "女");
assert.equal(buildEvidenceCard([source], { gender: "male" }).domain_sections.marriage?.gender, "男");
assert.equal(buildEvidenceCard([{ ...source, domain: "career" }], { gender: "female" }).domain_sections.career?.gender, undefined);
});
test("the checklist swaps the gender-unknown line only when gender is filled", () => {
const unknown = condensedConsultationChecklist("marriage")!;
assert.ok(unknown.includes(CONDENSED_GENDER_LINES.unknown));
assert.equal(condensedConsultationChecklist("marriage", { gender: null }), unknown);
for (const gender of ["female", "male"] as const) {
const filled = condensedConsultationChecklist("marriage", { gender })!;
assert.equal(filled.includes("性别未知"), false, gender);
assert.ok(filled.includes(CONDENSED_GENDER_LINES[gender]), gender);
assert.equal(filled.split("\n").length, CONSULTATION_CONDENSED_CHECKLISTS.marriage!.length, "same line count");
// router §4: gender and day/night first; the core stack stays gender-neutral.
assert.match(CONDENSED_GENDER_LINES[gender], /昼夜盘/);
assert.match(CONDENSED_GENDER_LINES[gender], /7 宫、7 宫主、D9、UL、DK/);
}
assert.match(CONDENSED_GENDER_LINES.female, /夫星以木星为主/);
assert.match(CONDENSED_GENDER_LINES.male, /妻星取金星/);
// Career and wealth never carry a gender line.
for (const domain of ["career", "wealth"] as const) {
assert.equal(condensedConsultationChecklist(domain, { gender: "female" }), condensedConsultationChecklist(domain));
}
// The methodology cache is per gender: one subject's line never serves another.
const female = JSON.stringify(consultationMethodologyForDomains(["marriage"], { gender: "female" }));
const unset = JSON.stringify(consultationMethodologyForDomains(["marriage"]));
const male = JSON.stringify(consultationMethodologyForDomains(["marriage"], { gender: "male" }));
assert.ok(female.includes("夫星以木星为主") && !female.includes("性别未知"));
assert.ok(unset.includes("性别未知") && !unset.includes("夫星以木星为主"));
assert.ok(male.includes("妻星取金星") && !male.includes("性别未知"));
});
test("writer prompt, gender filled: the card has the gender and the checklist has no gender-unknown rule", async () => {
for (const [gender, label] of [["female", "女"], ["male", "男"]] as const) {
const { prompt, text, state } = await writerPrompt(chartWith(gender));
assert.equal(marriageSection(prompt).gender, label, gender);
assert.equal(text.includes("性别未知"), false, `${gender}: no gender-unknown rule or label`);
assert.ok(text.includes(CONDENSED_GENDER_LINES[gender].slice(0, 24)), `${gender}: gender line`);
// G4: telemetry carries field ids only, never the value.
const telemetry = consultationEvidenceCardTelemetry(state, `${label} ${ANSWER}`);
assert.ok(telemetry);
assert.equal(JSON.stringify(telemetry).includes(label), false);
assert.equal(citedEvidenceCardFields(state.evidenceCard!, label).length, 0);
}
});
test("writer prompt, gender not filled: 性别未知 on the card and the gender-unknown rule in the checklist", async () => {
for (const serverChart of [chartWith(undefined), chartWith(null)]) {
const { prompt, text } = await writerPrompt(serverChart);
assert.equal(marriageSection(prompt).gender, "性别未知");
assert.ok(text.includes(CONDENSED_GENDER_LINES.unknown.slice(0, 24)));
assert.equal(text.includes(CONDENSED_GENDER_LINES.female.slice(0, 24)), false);
assert.equal(text.includes(CONDENSED_GENDER_LINES.male.slice(0, 24)), false);
}
});
test("switching subject: each turn uses the bound person's own gender, never the owner's", async () => {
const owner = Object.freeze({
name: "虚构户主", birth_date: "1990-01-02", reported_birth_time: "08:15:00", active_birth_time: null,
birth_time_source: "family_exact", birth_time_status: "reported", birth_place_label: "虚构城",
latitude: 31.2, longitude: 121.5, timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "raman",
gender: "female",
});
const person = (gender?: ProfileGender) => ({
id: "00000000-0000-4000-8000-000000000009", userId: "user-1", role: "other",
profile: {
name: "虚构丙", birth_date: "1988-03-04", reported_birth_time: "09:20:00", birth_time_source: "family_exact",
birth_time_status: "reported", birth_place_label: "虚构港", latitude: 22.3, longitude: 114.2,
timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "lahiri", ...(gender ? { gender } : {}),
},
});
const prepare = async (chartProfileId: string, role: "self" | "other", gender?: ProfileGender) => (
await prepareConsultationRoute({
userId: "user-1",
mode: "unverified_birth_time",
subject: {
binding: { chartProfileId, chartProfileRole: role, chartProfileName: null },
loadOwnedChartProfile: async () => person(gender),
},
loadProfile: async () => owner,
resolveTimezoneOffset: async (value) => value,
reserve: async () => "reserved",
})
).serverChart!;
const turns: Array<[Record<string, unknown>, string]> = [
[await prepare("self", "self"), "女"],
[await prepare("00000000-0000-4000-8000-000000000009", "other"), "性别未知"],
[await prepare("00000000-0000-4000-8000-000000000009", "other", "male"), "男"],
[await prepare("self", "self"), "女"],
];
for (const [serverChart, label] of turns) {
const { prompt, text } = await writerPrompt(serverChart);
assert.equal(marriageSection(prompt).gender, label);
assert.equal(text.includes("性别未知"), label === "性别未知", `rule follows ${label}`);
}
});