- 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
228 lines
11 KiB
TypeScript
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}`);
|
|
}
|
|
});
|