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.
279 lines
16 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
});
|