Files
Jyotisha/frontend/tests/consult-card-full-source-20261007.test.ts
T
jesse-ux d6eb0cfec1 fix(consult): keep career row hints off the planet nature
Career significator and house-lord rows now carry one presentation sentence. Values stay the same. A second three-repeat biography run still has one field conflict, so this is not accepted.

Not pushed.
2026-10-09 16:25:59 +08:00

279 lines
16 KiB
TypeScript

// Judgment table assembly (TASK-consult-card-full-source-20261007 T2).
// Leaves are copied. The count is not a verdict. Rahu and Ketu stay unjudged.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { assembleJudgmentTable, judgmentTableModelView, JUDGMENT_TABLE_LABELS } from "../src/lib/consultation-judgment-table.ts";
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
// Assertion change (F4 / BUG-1299):
// 原值: this file built `facts` by hand, and the size cap was measured on an empty claim card.
// 新值: chart bodies are read from consult-card-facts-20261009.json, frozen by
// `python scripts/research/freeze_consult_card_facts_fixture.py` (reference date 2026-09-27).
// Jobs, Obama and Taylor also attach those facts to the biography golden workflow.
// 原因: contract fixtures must come from the engine, and the 20,000-character cap is the card
// the model reads, including the natal base. The handwritten test only locks assembly.
const PRECISION = "出生时间精度不够,不参与";
function leaf(path: string, value: unknown) {
return { status: "value", path, value, check: "path" };
}
const facts = {
schema: "consult-card-facts-v1",
domain: "career",
header_note: JUDGMENT_TABLE_LABELS.rahuNote,
domain_varga: "D10",
planets: {
Sun: {
present: true,
d1_sign: leaf("core_chart.planets.Sun.sign", "Aries"),
d1_house: leaf("core_chart.planets.Sun.house", 10),
d1_dignity: leaf("core_chart.planets.Sun.status", "落陷(Debilitated)"),
rasi_lord: leaf("core_chart.planets.Sun.rasi_lord", "Mars"),
combust: leaf("core_chart.planets.Sun.combust", false),
aspected_by: { status: "gap", path: "core_chart.planets.Sun.aspected_by", check: "path" },
functional_role: { status: "value", path: "worksheets.strengths_and_scores.functional_benefic_malefic.functional_malefics", value: "functional_malefics", check: "membership" },
shadbala_rupas: leaf("worksheets.strengths_and_scores.shadbala.planets.Sun.total_rupas", 6.25),
shadbala_min: leaf("worksheets.strengths_and_scores.shadbala.planets.Sun.min_required", 5),
bav: { status: "value", path: "worksheets.strengths_and_scores.ashtakavarga.bav.Sun.bindus", index: 0, value: 4, check: "index" },
d9_sign: leaf("worksheets.divisional_and_special_charts.varga_full.D9_Navamsa.Sun.sign", "Aries"),
d9_dignity: leaf("worksheets.divisional_and_special_charts.varga_full.D9_Navamsa._dignity.Sun", "Own"),
co_tenants: { status: "value", check: "selection", value: ["Saturn"] },
domain_varga_house: { status: "withheld", value: PRECISION, check: "withheld" },
},
Saturn: {
present: true,
d1_sign: leaf("core_chart.planets.Saturn.sign", "Aries"),
d1_house: leaf("core_chart.planets.Saturn.house", 10),
d1_dignity: leaf("core_chart.planets.Saturn.status", "中性(Neutral)"),
functional_role: { status: "value", path: "functional.functional_benefics", value: "functional_benefics", check: "membership" },
co_tenants: { status: "value", check: "selection", value: ["Sun"] },
domain_varga_house: { status: "gap", check: "path" },
},
Rahu: {
present: true,
d1_sign: leaf("core_chart.planets.Rahu.sign", "Leo"),
d1_house: leaf("core_chart.planets.Rahu.house", 1),
d1_dignity: leaf("core_chart.planets.Rahu.status", "中性(Neutral)"),
rasi_lord: leaf("core_chart.planets.Rahu.rasi_lord", "Sun"),
functional_role: { status: "value", path: "functional", value: null, check: "node_role", note: JUDGMENT_TABLE_LABELS.rahuNote },
co_tenants: { status: "value", check: "selection", value: ["Mars"] },
aspected_by: leaf("core_chart.planets.Rahu.aspected_by", ["Saturn"]),
domain_varga_house: { status: "gap", check: "path" },
},
},
houses: {
"10": {
sign: leaf("core_chart.houses.house_10.cusp_sign", "Aries"),
lord: leaf("core_chart.houses.house_10.lord", "Sun"),
sav_score: leaf("worksheets.strengths_and_scores.ashtakavarga.house_scores.house_10.sav_score", 30),
sav_sodhita: leaf("worksheets.strengths_and_scores.ashtakavarga.house_scores.house_10.sav_sodhita", 28),
occupants: { status: "value", check: "selection", value: ["Sun", "Saturn"] },
},
},
factors: [
{ id: "house:10", kind: "house", house: 10, citation: "references/event_judgment_career.md §2 10H" },
{ id: "natural:Saturn", kind: "planet", planet: "Saturn", citation: "references/event_judgment_career.md §2 Saturn", role: "natural" },
{ id: "natural:Rahu", kind: "planet", planet: "Rahu", citation: "references/house-domain-planet-mapping.md", role: "natural" },
{ id: "jaimini", kind: "blocked", citation: "references/jaimini-complete-system.md 未给出" },
],
participation: [
{ id: "D24", status: "withheld", value: PRECISION, check: "withheld", citation: "references/strict-workflow-router.md" },
],
};
test("the judgment table copies leaves and does not turn the count into a verdict", () => {
const table = assembleJudgmentTable(facts);
const encoded = JSON.stringify(table);
assert.equal(encoded.includes("threshold"), false);
assert.equal(encoded.includes("两个及以上"), false);
assert.equal(table.header_note, JUDGMENT_TABLE_LABELS.rahuNote);
const rows = table.rows as Array<Record<string, unknown>>;
const lord = rows.find((row) => row.id === "house:10:lord") as Record<string, unknown>;
assert.equal(lord.planet, "Sun");
assert.equal((lord.d1_sign as { value: string }).value, "Aries");
assert.equal((lord.d1_house as { value: number }).value, 10);
assert.equal((lord.d1_dignity as { value: string }).value, "落陷(Debilitated)");
assert.equal((lord.shadbala_rupas as { value: number }).value, 6.25);
assert.equal((lord.shadbala_min as { value: number }).value, 5);
assert.equal((lord.bav as { value: number }).value, 4);
assert.equal((lord.vargottama as { value: boolean }).value, true);
assert.deepEqual((lord.vargottama as { derived_from: string[] }).derived_from, [
"core_chart.planets.Sun.sign",
"worksheets.divisional_and_special_charts.varga_full.D9_Navamsa.Sun.sign",
]);
assert.equal((lord.domain_varga_house as { value: string }).value, PRECISION);
const relations = lord.relations as Array<{ house: number; labels: string[] }>;
assert.deepEqual(relations[0]?.labels, [JUDGMENT_TABLE_LABELS.relationLord, JUDGMENT_TABLE_LABELS.relationOccupant]);
assert.deepEqual(lord.relation_gaps, [JUDGMENT_TABLE_LABELS.relationAspect]);
const affliction = lord.affliction as { count: number; details: Array<{ signal: number }> };
assert.equal(typeof affliction.count, "number");
assert.ok(affliction.details.some((detail) => detail.signal === 1));
assert.ok(affliction.details.some((detail) => detail.signal === 4));
assert.equal(affliction.details.some((detail) => detail.signal === 5), false);
const rahu = rows.find((row) => row.planet === "Rahu") as Record<string, unknown>;
assert.equal((rahu.functional_role as { value: null }).value, null);
assert.equal((rahu.rasi_lord as { value: string }).value, "Sun");
assert.deepEqual((rahu.co_tenants as { value: string[] }).value, ["Mars"]);
const blocked = rows.find((row) => row.id === "jaimini");
assert.equal(blocked?.kind, "blocked");
const house = rows.find((row) => row.id === "house:10");
assert.equal(house?.reading, undefined);
assert.equal(lord.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
assert.equal(rahu.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
});
// Assertion change:
// 原值: 事业判断表的行星行没有呈现句;重复出现的行星只留 id、角色、引用和受冲计数。
// 新值: 事业的自然代表星、Jaimini 代表星、大运主星、Narayana 主星和宫主行带同一句呈现文字。婚恋表不带。重复行仍丢掉度数,但留下这句。行星的星座、宫位、旺弱不变。
// 原因: 清单句之后仍有 1 次按星性写方向。模型读的是事业宫主行。只加呈现文字,不改取值(TASK-consult-career-type-residual-20261009)。
test("career significator rows name strength only, and other domains stay unchanged", () => {
const career = assembleJudgmentTable({
...facts,
factors: [
...facts.factors,
{ id: "jaimini:AmK", kind: "planet", planet: "Saturn", citation: "jaimini", role: "AmK" },
{ id: "jaimini:DK", kind: "planet", planet: "Saturn", citation: "jaimini", role: "DK" },
{ id: "vimshottari:md", kind: "planet", planet: "Sun", citation: "vim", role: "Vimshottari MD" },
{ id: "narayana:lord", kind: "planet", planet: "Sun", citation: "narayana", role: "Narayana MD lord" },
],
});
const rows = career.rows as Array<Record<string, unknown>>;
for (const id of ["house:10:lord", "natural:Saturn", "jaimini:AmK", "jaimini:DK", "vimshottari:md", "narayana:lord"]) {
assert.equal(rows.find((row) => row.id === id)?.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading, id);
}
const lord = rows.find((row) => row.id === "house:10:lord") as Record<string, unknown>;
assert.equal(lord.planet, "Sun");
assert.equal((lord.d1_sign as { value: string }).value, "Aries");
assert.equal((lord.d1_house as { value: number }).value, 10);
assert.equal((lord.d1_dignity as { value: string }).value, "落陷(Debilitated)");
const marriage = assembleJudgmentTable({ ...facts, domain: "marriage" });
const marriageEncoded = JSON.stringify(marriage);
assert.equal(marriageEncoded.includes(JUDGMENT_TABLE_LABELS.careerSignificatorReading), false);
assert.equal((marriage.rows as Array<Record<string, unknown>>).find((row) => row.id === "house:10:lord")?.planet, "Sun");
const view = judgmentTableModelView(career) as { rows: Array<Record<string, unknown>> };
const repeat = view.rows.find((row) => row.id === "vimshottari:md");
assert.equal(repeat?.planet, "Sun");
assert.equal(repeat?.role, "Vimshottari MD");
assert.equal(repeat?.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
assert.equal(repeat?.d1_sign, undefined);
assert.equal(repeat?.d1_dignity, undefined);
});
test("a card without facts keeps the old section, and facts attach one table", () => {
const bare = buildEvidenceCard([{ domain: "career", packet: { claim_cards: [] }, context: {} }]);
assert.equal(bare.domain_sections.career?.judgment_table, undefined);
assert.equal(bare.card_version, "evidence-card-v2");
const withFacts = buildEvidenceCard([{
domain: "career",
packet: { claim_cards: [] },
context: { consult_card_facts: { schema: "consult-card-facts-v1", domains: [facts] } },
}]);
const section = withFacts.domain_sections.career;
assert.equal(section?.judgment_table?.header_note, JUDGMENT_TABLE_LABELS.rahuNote);
assert.equal((section?.participation as Array<{ value: string }>)[0]?.value, PRECISION);
const encoded = JSON.stringify(section);
assert.equal(encoded.includes("threshold"), false);
assert.equal(encoded.includes("两个及以上"), false);
// 原值: the card repeated path and check. 新值: those stay on the server facts.
// 原因: the model must not see internal field paths (F2).
assert.equal(encoded.includes("worksheets."), false);
assert.equal(encoded.includes("core_chart."), false);
assert.equal(encoded.includes("\"check\""), false);
});
const FIGURE_FOR_CHART: Record<string, string> = {
jobs: "steve_jobs",
obama: "barack_obama",
taylor: "elizabeth_taylor",
};
const biographyGolden = JSON.parse(readFileSync(join(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"consult-biography-backtest-golden.json",
), "utf8")) as { figures: Array<{ id: string; shared_chart: Record<string, unknown>; routes: Record<string, Record<string, unknown>> }> };
function biographyWorkflow(figureId: string, domain: string) {
const figure = biographyGolden.figures.find((item) => item.id === figureId);
if (!figure) throw new Error(`missing biography figure ${figureId}`);
const routeDomain = ["parents", "marriage", "health", "career"].includes(domain) ? domain : "career";
const route = figure.routes[routeDomain];
if (!route) throw new Error(`missing biography route ${figureId} ${routeDomain}`);
const { chart: chartOverrides, chart_modules: moduleOverrides, ...rest } = structuredClone(route);
const shared = structuredClone(figure.shared_chart);
const chart = {
...shared,
...((chartOverrides as Record<string, unknown> | undefined) ?? {}),
modules: {
...((shared.modules as Record<string, unknown> | undefined) ?? {}),
...((moduleOverrides as Record<string, unknown> | undefined) ?? {}),
},
};
return consultationWorkflowResponseSchema.parse({ ...rest, chart });
}
test("engine facts make a model card without internal paths", () => {
const golden = JSON.parse(readFileSync(join(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"consult-card-facts-20261009.json",
), "utf8")) as {
charts: Record<string, { domains: Record<string, { planets: Record<string, { functional_role?: { value?: unknown } }> }> }>;
};
for (const [chart, body] of Object.entries(golden.charts)) {
for (const [domain, domainFacts] of Object.entries(body.domains)) {
assert.equal(domainFacts.planets.Rahu?.functional_role?.value ?? null, null, chart);
assert.equal(domainFacts.planets.Ketu?.functional_role?.value ?? null, null, chart);
const factsContext = { consult_card_facts: { schema: "consult-card-facts-v1", domains: [domainFacts] } };
const figureId = FIGURE_FOR_CHART[chart];
const card = figureId
? (() => {
const parsed = biographyWorkflow(figureId, domain);
const context = toAgentConsultationContext({ ...parsed, ...factsContext });
const packet = toModelOutput(
context,
createConsultationPlan({ userIntent: "q", theme: domain as ConsultationDomain }),
domain,
);
return buildEvidenceCard([{ domain: domain as ConsultationDomain, packet, context }]);
})()
: buildEvidenceCard([{
domain: domain as ConsultationDomain,
packet: { claim_cards: [] },
context: factsContext,
}]);
const encoded = JSON.stringify(card);
assert.equal(encoded.includes("worksheets."), false, `${chart} ${domain}`);
assert.equal(encoded.includes("core_chart."), false, `${chart} ${domain}`);
assert.equal(encoded.includes("\"check\""), false, `${chart} ${domain}`);
assert.equal(encoded.includes("两个及以上"), false);
assert.equal(
encoded.includes(JUDGMENT_TABLE_LABELS.careerSignificatorReading),
domain === "career",
`${chart} ${domain}`,
);
assert.ok(encoded.length <= 20_000, `${chart} ${domain} ${encoded.length}`);
const rows = (card.domain_sections[domain as ConsultationDomain]?.judgment_table?.rows ?? []) as Array<Record<string, unknown>>;
const hasAspect = rows.some((row) => (
(Array.isArray(row.aspected_by) && row.aspected_by.length > 0)
|| (Array.isArray(row.aspects_houses) && row.aspects_houses.length > 0)
|| JSON.stringify(row.relations ?? "").includes("照")
));
assert.equal(hasAspect, true, `${chart} ${domain}`);
}
}
});