From 5caf03844e8b57a6f07a5c74b533abb59f18ed18 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 27 Sep 2026 18:03:08 +0800 Subject: [PATCH] feat(consult): marriage card and checklist follow the current subject's gender (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- .../lib/consultation-condensed-checklist.ts | 36 ++- .../src/lib/consultation-evidence-card.ts | 48 +++- frontend/src/lib/consultation-methodology.ts | 9 +- frontend/src/mastra/consultation-tools.ts | 12 +- frontend/src/mastra/index.ts | 2 +- .../consult-gender-card-20260927.test.ts | 227 ++++++++++++++++++ .../tests/consult-natal-agent-test-support.ts | 4 +- 7 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 frontend/tests/consult-gender-card-20260927.test.ts diff --git a/frontend/src/lib/consultation-condensed-checklist.ts b/frontend/src/lib/consultation-condensed-checklist.ts index 85dbb69c..d9944bec 100644 --- a/frontend/src/lib/consultation-condensed-checklist.ts +++ b/frontend/src/lib/consultation-condensed-checklist.ts @@ -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; + export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly>> = { 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 = {}, +): 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"); } diff --git a/frontend/src/lib/consultation-evidence-card.ts b/frontend/src/lib/consultation-evidence-card.ts index 428175a3..12a74e9d 100644 --- a/frontend/src/lib/consultation-evidence-card.ts +++ b/frontend/src/lib/consultation-evidence-card.ts @@ -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 & Pick & Pick; + /** 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, 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; } diff --git a/frontend/src/lib/consultation-methodology.ts b/frontend/src/lib/consultation-methodology.ts index 907194ff..0afa214d 100644 --- a/frontend/src/lib/consultation-methodology.ts +++ b/frontend/src/lib/consultation-methodology.ts @@ -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(); */ 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; diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 3b357dcf..a9ade76f 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -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({ diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index c5b59eec..c52d547d 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -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. diff --git a/frontend/tests/consult-gender-card-20260927.test.ts b/frontend/tests/consult-gender-card-20260927.test.ts new file mode 100644 index 00000000..0e96f4a5 --- /dev/null +++ b/frontend/tests/consult-gender-card-20260927.test.ts @@ -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; +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 { + 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) { + 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]> = [ + [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}`); + } +}); diff --git a/frontend/tests/consult-natal-agent-test-support.ts b/frontend/tests/consult-natal-agent-test-support.ts index 64247794..c0268443 100644 --- a/frontend/tests/consult-natal-agent-test-support.ts +++ b/frontend/tests/consult-natal-agent-test-support.ts @@ -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; }, ) { 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;