feat(consult): marriage card and checklist follow the current subject's gender (T3)

The marriage evidence card's gender field is the bound subject's own value
(女 / 男, 性别未知 when not filled). The condensed checklist keeps the
gender-unknown rule only when unset; otherwise it names the spouse
significator per strict-workflow-router.md §4 and the repo's gender
interpretation contract (supplement only, core stack gender-neutral). The
methodology cache is keyed by gender so one person's line never serves
another. Tests drive the real getJyotishAgent with a prompt-recording model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-27 18:30:57 +08:00
co-authored by Claude Opus 5.5
parent 3b192f12a4
commit 5caf03844e
7 changed files with 318 additions and 20 deletions
@@ -1,4 +1,5 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* Condensed consultation checklists (TASK-consult-evidence-card-v2-20260927
@@ -20,6 +21,21 @@ import type { ConsultationDomain } from "./consultation-domain-registry.ts";
export const CONDENSED_CHECKLIST_SOURCE =
"consultation condensed checklist (from references/strict-workflow-router.md §2–§5 and references/event_judgment_*.md)";
/**
* The marriage list's gender line (TASK-consult-gender-optional-20260927, G3).
* `unknown` applies only when the current subject's gender is not filled.
* When it is filled, the line follows strict-workflow-router.md §4 ("Confirm
* gender and day/night context before spouse significator analysis") and the
* repo's gender interpretation contract
* (references/oracle/gender_interpretation_contract_2026_07_19.json): the core
* stack stays gender-neutral and the spouse significator is a supplement.
*/
export const CONDENSED_GENDER_LINES = {
unknown: "性别未知:金星与木星都要看,不按性别指定「夫星」「妻星」下定论。",
female: "性别:女(卡上 gender)。先确认性别与昼夜盘(卡上 day_night)再取夫星:夫星以木星为主,部分传承兼看火星;它只是补充层,核心仍是 7 宫、7 宫主、D9、UL、DK,不得只因夫星的大运或过运就定婚期。",
male: "性别:男(卡上 gender)。先确认性别与昼夜盘(卡上 day_night)再取妻星:妻星取金星;它只是补充层,核心仍是 7 宫、7 宫主、D9、UL、DK,不得只因金星的大运或过运就定婚期。",
} as const satisfies Record<ProfileGender | "unknown", string>;
export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly<Partial<Record<ConsultationDomain, readonly string[]>>> = {
career: [
"必看:D1 10 宫与 10 宫主、AmK;卡上 d9 段确认 10 宫主与 AmK 的旺弱、Vargottama、D1/D9 反转;D10 上升与 10 宫;AL 与 A10;10 宫 SAV 与木星、土星所在宫的 SAV。",
@@ -33,7 +49,7 @@ export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly<Partial<Record<Consulta
"必看:D1 7 宫与 7 宫主、5 宫与 5 宫主、金星、木星、DK、UL;卡上 d9 段(D9 上升、金木与 DK 的旺弱);昼夜盘;Vivah Saham 的度数与落宫。",
"分三层说:①心动接触:5 宫、5 宫主、Punarphoo 观察 ②关系成对:7 宫主、DK、UL ③社会法律落地:D9 + UL + Vivah Saham + 双大运同向。",
"时间:Vimshottari 与 Narayana 双轨;双重过运(木星、土星同时激活 7 宫 / 7 宫主 / DK / UL)只是激活窗,不是事件日。",
"性别未知:金星与木星都要看,不按性别指定「夫星」「妻星」下定论。",
CONDENSED_GENDER_LINES.unknown,
"禁写:5 宫主大运 ≠ 法律婚;金星过本命月亮 ≠ 心动月;Punarphoo 不得写成结婚;土星回到本命月亮是观察项,不是结婚。",
"卡外按需补取一次:Chara 大运、KP 7 宫副主星(KP 精确宫头仍 blocked,只作参考、不作依据)。",
],
@@ -46,8 +62,20 @@ export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly<Partial<Record<Consulta
],
};
/** The condensed list for one domain, or null when the domain keeps its full route. */
export function condensedConsultationChecklist(domain: ConsultationDomain): string | null {
/**
* The condensed list for one domain, or null when the domain keeps its full
* route. `gender` is the current subject's optional gender: the gender-unknown
* line is swapped for the female / male line only when it is filled.
*/
export function condensedConsultationChecklist(
domain: ConsultationDomain,
options: Readonly<{ gender?: ProfileGender | null }> = {},
): string | null {
const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain];
return lines ? lines.map((line, index) => `${index + 1}. ${line}`).join("\n") : null;
if (!lines) return null;
const genderLine = options.gender ? CONDENSED_GENDER_LINES[options.gender] : CONDENSED_GENDER_LINES.unknown;
return lines
.map((line) => (line === CONDENSED_GENDER_LINES.unknown ? genderLine : line))
.map((line, index) => `${index + 1}. ${line}`)
.join("\n");
}
+39 -9
View File
@@ -1,5 +1,6 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* The evidence card: what the answer-writing model reads about the chart
@@ -33,8 +34,13 @@ export const EVIDENCE_CARD_VERSION = "evidence-card-v2" as const;
/** The only strings the card writes itself (decision table, 2026-09-27). */
export const EVIDENCE_CARD_LABELS = {
/** The product does not record gender yet (TASK-consult-gender-optional follows). */
/**
* The current subject's optional gender (TASK-consult-gender-optional-20260927):
* 「性别未知」 when it is not filled, otherwise 「女」 / 「男」.
*/
genderUnknown: "性别未知",
genderFemale: "女",
genderMale: "男",
/** The annual pack could not be produced reliably; natal data is never put in its place. */
annualNotAttached: "年盘未接入",
} as const;
@@ -72,7 +78,8 @@ export type EvidenceCardDomainSpec = Readonly<{
punarphoo: boolean;
dayNight: boolean;
vivahSaham: boolean;
genderUnknown: boolean;
/** The section carries the current subject's gender (「性别未知」 when not filled). */
gender: boolean;
/** The annual Tajika chart (annual route only). */
annualChart: boolean;
/** Houses of the running and next AD lords and of the year's Jupiter / Saturn transits. */
@@ -96,7 +103,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
punarphoo: false,
dayNight: false,
vivahSaham: false,
genderUnknown: false,
gender: false,
annualChart: false,
annualHouses: false,
followVargas: false,
@@ -110,7 +117,8 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
* v2 decision table on top (TASK-consult-evidence-card-v2-20260927):
* - career: + AL, 10H SAV, SAV of the Jupiter / Saturn transit signs;
* - marriage: + 5H / 5L, day / night, Punarphoo (observation_only), Double
* Transit on 7H / 7L / DK / UL, Vivah Saham, gender unknown;
* Transit on 7H / 7L / DK / UL, Vivah Saham, the subject's gender (「性别未知」
* when not filled);
* - wealth: 8H / 12H named;
* - annual: the annual Tajika chart, houses of the running / next AD lords and
* of the year's Jupiter / Saturn transits, ingress and station dates only;
@@ -126,7 +134,7 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
}),
marriage: spec({
vargas: ["D9"], houses: [7, 5], arudha: ["UL", "A7"], karakas: ["DK"], planets: ["Venus", "Jupiter"], transits: true,
punarphoo: true, dayNight: true, vivahSaham: true, doubleTransit: "seventh_and_karakas", genderUnknown: true,
punarphoo: true, dayNight: true, vivahSaham: true, doubleTransit: "seventh_and_karakas", gender: true,
}),
wealth: spec({ vargas: ["D2", "D11"], houses: [2, 11, 9, 5, 8, 12], yogas: true, ashtakavarga: true, shadbala: true }),
health: spec({ vargas: ["D6", "D8", "D30"], houses: [6, 8], transits: true, shadbala: true }),
@@ -223,7 +231,7 @@ export type EvidenceCardDomainSection = {
punarphoo?: Json;
day_night?: Json;
vivah_saham?: Json;
gender?: typeof EVIDENCE_CARD_LABELS.genderUnknown;
gender?: EvidenceCardGenderLabel;
annual_chart?: Json;
/** Why each annual house is on the card (AD lords' houses, the year's Jupiter / Saturn transits). */
house_basis?: JsonRecord;
@@ -262,6 +270,24 @@ export type EvidenceCard = {
};
};
export type EvidenceCardGenderLabel =
| typeof EVIDENCE_CARD_LABELS.genderUnknown
| typeof EVIDENCE_CARD_LABELS.genderFemale
| typeof EVIDENCE_CARD_LABELS.genderMale;
/** The card label for the current subject's gender; not filled → 「性别未知」. */
export function evidenceCardGenderLabel(gender: ProfileGender | null | undefined): EvidenceCardGenderLabel {
if (gender === "female") return EVIDENCE_CARD_LABELS.genderFemale;
if (gender === "male") return EVIDENCE_CARD_LABELS.genderMale;
return EVIDENCE_CARD_LABELS.genderUnknown;
}
/** Card-wide inputs that are not engine output (the bound subject's profile). */
export type EvidenceCardOptions = Readonly<{
/** The current subject's optional gender; null / undefined = not filled. */
gender?: ProfileGender | null;
}>;
/** One domain's inputs: the projected packet and the agent context behind it. */
export type EvidenceCardSource = Readonly<{
domain: ConsultationDomain;
@@ -423,6 +449,7 @@ function domainSection(
base: EvidenceCard["base"],
gaps: string[],
vargaPlan?: EvidenceCardVargaPlan,
options: EvidenceCardOptions = {},
): EvidenceCardDomainSection {
const rule = EVIDENCE_CARD_SPECS[domain];
const natal = claimEvidence(source.packet, "natal_foundation");
@@ -649,7 +676,7 @@ function domainSection(
if (typeof vivah.house === "number") section.vivah_saham = vivah;
else gaps.push(`${domain}.vivah_saham`);
}
if (rule.genderUnknown) section.gender = EVIDENCE_CARD_LABELS.genderUnknown;
if (rule.gender) section.gender = evidenceCardGenderLabel(options.gender);
if (rule.annualChart) {
const annual = record(timing.annual_tajika);
if (annual.status === "parameter_sensitive") {
@@ -688,7 +715,10 @@ function onCard(card: Pick<EvidenceCard, "domain_sections" | "base">, section: E
* Build the card for up to two domains (the existing per-turn cap). The base
* section is taken once, from the first domain's sources; it is the same chart.
*/
export function buildEvidenceCard(sources: readonly EvidenceCardSource[]): EvidenceCard {
export function buildEvidenceCard(
sources: readonly EvidenceCardSource[],
options: EvidenceCardOptions = {},
): EvidenceCard {
if (sources.length === 0) throw new Error("evidence_card_needs_a_domain");
const gaps: string[] = [];
const first = sources[0]!;
@@ -726,7 +756,7 @@ export function buildEvidenceCard(sources: readonly EvidenceCardSource[]): Evide
gaps.push(`${source.domain}.base_differs`, ...ownGaps.map((gap) => `${source.domain}.${gap}`));
}
}
const section = domainSection(source.domain, source, own, gaps, planFor(source.domain));
const section = domainSection(source.domain, source, own, gaps, planFor(source.domain), options);
if (own !== base) section.base = own;
domainSections[source.domain] = section;
}
+7 -2
View File
@@ -8,6 +8,7 @@ import {
} from "./consultation-domain-registry.ts";
import { resolveLiveJyotishSkill } from "./skill-package-registry.ts";
import { CONDENSED_CHECKLIST_SOURCE, condensedConsultationChecklist } from "./consultation-condensed-checklist.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* The strict checklist each consultation domain must be read against, named in
@@ -142,9 +143,13 @@ const planCache = new Map<string, ConsultationMethodology>();
*/
export function consultationMethodologyForDomains(
domains: readonly ConsultationDomain[],
options: Readonly<{ gender?: ProfileGender | null }> = {},
): ConsultationMethodology | null {
const unique = [...new Set(domains)];
const key = unique.join(",");
// The marriage checklist depends on the current subject's gender, so the
// cache is per gender too: one person's value never reaches another's turn.
const gender = options.gender ?? null;
const key = `${unique.join(",")}|${gender ?? "unknown"}`;
const hit = planCache.get(key);
if (hit) return hit;
@@ -172,7 +177,7 @@ export function consultationMethodologyForDomains(
const withoutChecklist: ConsultationDomain[] = [];
for (const domain of unique) {
const { strictRoute, eventJudgment } = domainMethodology[domain];
const condensed = condensedConsultationChecklist(domain);
const condensed = condensedConsultationChecklist(domain, { gender });
if (condensed) {
push(`${consultationDomainDefinition(domain).label} · ${strictRoute} · 普通对话精简清单`, CONDENSED_CHECKLIST_SOURCE, condensed);
continue;
+9 -3
View File
@@ -27,6 +27,7 @@ import {
type EvidenceLookupSection,
} from "../lib/consultation-evidence-card.ts";
import { pinsConsultationDomains, type ConsultationEntrypoint } from "../lib/consultation-entrypoint.ts";
import type { ProfileGender } from "../lib/profile-gender.ts";
import {
dailyConsultationThinkingPlan,
natalConsultationThinkingPlan,
@@ -760,6 +761,7 @@ function hoistSharedNatalFoundation(consultations: readonly DomainConsultation[]
function toModelDomainPlanContext(
executions: readonly DomainExecution[],
omittedDomains: readonly ConsultationDomain[],
gender: ProfileGender | null,
) {
const domains = executions.map((execution) => execution.domain);
const consultations: DomainConsultation[] = executions.map((execution) => ({
@@ -767,7 +769,7 @@ function toModelDomainPlanContext(
...execution.modelOutput,
}));
const success = executions.every((execution) => execution.context.success);
const methodology = consultationMethodologyForDomains(domains);
const methodology = consultationMethodologyForDomains(domains, { gender });
const plan = {
success,
domains,
@@ -1082,13 +1084,17 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
ctx.state.consultationToolCompleted = true;
ctx.state.consultationToolSuccessCount += 1;
appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs });
const modelContext = toModelDomainPlanContext(executions, omittedDomains);
// The bound subject's own optional gender (self: profiles; other: that
// person's chart_profiles row). It only picks the marriage card label
// and checklist line; the engine never receives it.
const subjectGender = ctx.serverChart.gender ?? null;
const modelContext = toModelDomainPlanContext(executions, omittedDomains, subjectGender);
lookupCache = { executions };
const card = buildEvidenceCard(executions.map((execution) => ({
domain: execution.domain,
packet: execution.modelOutput,
context: execution.context,
})));
})), { gender: subjectGender });
const modelView = toModelEvidenceView(modelContext, card);
ctx.state.evidenceCard = card;
ctx.state.evidenceCardChars = JSON.stringify({
+1 -1
View File
@@ -44,7 +44,7 @@ When reference_transparency is present:
- When similar_public_cases.coverage.requested_uncovered_domains is non-empty, say the current public-case catalog does not yet cover those themes; do not infer that no comparable real-world case exists.
- When method_variants applies, present parallel methods and their source paths rather than silently picking one result as the only truth.
- Treat Shadbala/Ashtakavarga component differences under production_tuning_allowed=false as method boundaries, not absolute calculation errors. Use no_majority_vote and method_variant_not_majority_vote: do not decide truth by engine count, and do not say one school is wrong unless a pinned authoritative worked example is present.
- If gender or sex is present in future profile context, use it only for relationship/spouse interpretation language and weighting: gender-specific spouse significators are supplements, not chart-calculation switches. For relationship questions, keep the core stack gender-neutral (7th house, 7th lord, D9, UL, Darakaraka); male charts may supplement Venus, female charts may supplement Jupiter/Mars, and unknown/nonbinary/prefer-not-to-say uses the gender-neutral stack.
- If the subject's gender is present (the marriage evidence card's gender field: 女 / 男; 性别未知 means it was not filled), use it only for relationship/spouse interpretation language and weighting: gender-specific spouse significators are supplements, not chart-calculation switches. For relationship questions, keep the core stack gender-neutral (7th house, 7th lord, D9, UL, Darakaraka); male charts may supplement Venus, female charts may supplement Jupiter/Mars, and unknown/nonbinary/prefer-not-to-say uses the gender-neutral stack.
- When consulting references/oracle/effective_skill_capability_view_2026_07_19.json or any derived skill map, use effective_status, not registry_status. Do not promote reference_only or blocked techniques into mastered/covered claims.
- If should_lead_with_limitations is false, do not lead with limitations. If a limitation is relevant, put it in one short sentence at the end.
- Only say the chart calculation failed when evidence_contract.hard_blockers is non-empty.
@@ -0,0 +1,227 @@
// 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 = "今年婚恋怎么样?";
const ANSWER = [
"关系这件事,你不是没机会,是挑得狠。",
"",
`## ${REPORT_HEADING.question}`,
"能谈成,但要分步。",
"",
`## ${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}`);
}
});
@@ -132,6 +132,8 @@ export async function runNatalAgent(
question?: string;
toolPhaseMs?: number;
answerMs?: number;
/** The bound subject's server chart; defaults to the public AA chart without a gender. */
serverChart?: Record<string, unknown>;
},
) {
seq += 1;
@@ -146,7 +148,7 @@ export async function runNatalAgent(
const answerSignals: AbortSignal[] = [];
const agentContext = createConsultationAgentContext({
userId: "u", sessionId: "s", requestId: `natal-agent-${seq}`, consultationMode: "verified_chart",
theme: options.theme ?? "parents", serverChart: publicServerChart as never, abortSignal: clock.toolSignal, state,
theme: options.theme ?? "parents", serverChart: (options.serverChart ?? publicServerChart) as never, abortSignal: clock.toolSignal, state,
runWorkflow: async () => {
workflowRuns += 1;
return structuredClone(options.workflow) as never;