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:
co-authored by
Claude Opus 5.5
parent
3b192f12a4
commit
5caf03844e
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user