90bad10d6f
The consultation workflow already returns a functional benefic/malefic table, a shadbala ranking, SAV scores, the current maha/antardasha, detected yogas and guided-topic copy. The report extraction layer threw all of it away, so claim cards could only say "the server closed the minimum evidence group" and the writer had no conclusions to work from. - ReportEvidenceBundleV2 gains interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores/savTotal, currentDasha, convergenceDomains) and themeNarrativeSeeds. Both are required, allow empty, keep .strict(), are covered by the canonical sort + bundleHash, and fail closed on dangling refs, duplicate ranks/houses/themes and out-of-bound text. - Extraction is allowlist-style: closed enums for yoga category and functional role, safeCelestialName for planets, sign->whole-sign-house projection for SAV, and a forbidden-token scrub that drops any seed line naming an external provider or internal route. - Claim card conclusions and supportingFacts are now deterministic astrological statements built from those facts; risks become counterFacts. assertionLevel derivation is unchanged, and a theme with no seed keeps the old receipt wording with consensus capped down. - filterReportEvidenceBundleForSection trims seeds and SAV houses to the chapter's theme while letting the chart-wide interpretive receipts ride along, so every section can cite them. Contract snapshot taken from a real local /api/consultation_workflow call with fictional smoke birth data; the new fixture test locks the shapes that call actually returns, including the fields that are absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
520 lines
22 KiB
TypeScript
520 lines
22 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
buildReportEvidenceBundleV2,
|
|
filterReportEvidenceBundleForSection,
|
|
} from "../src/lib/personal-report-generation.ts";
|
|
import { buildPersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
|
|
import {
|
|
emptyReportInterpretiveFacts,
|
|
finalizeReportEvidenceBundleV2,
|
|
validateReportEvidenceBundleV2,
|
|
type ReportEvidenceBundleV2,
|
|
} from "../src/lib/report-evidence-bundle-v2.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine response contract fixture (2026-09-01 snapshot).
|
|
//
|
|
// Synthetic data only — no real birth details. The shapes below mirror what a
|
|
// live POST /api/consultation_workflow actually returns today:
|
|
// chart.ai_prompt_pack.evidence_snapshot.functional_benefic_malefic present
|
|
// chart.ai_prompt_pack.evidence_snapshot.strength.shadbala_ranking present
|
|
// ({planet, rupas, level})
|
|
// chart.modules.ashtakavarga.sav.{scores,total} present
|
|
// (scores keyed by SIGN, not house)
|
|
// chart.modules.dasha_sub_periods.current.{mahadasha,antardasha} present
|
|
// chart.yogas present
|
|
// chart.modules.yogas.yogas candidate
|
|
// rule table with hit:true/false
|
|
// chart.modules.guided_topics present
|
|
// evidence_snapshot.timing.vimshottari / .convergence_top_domains ABSENT
|
|
// evidence_snapshot.strength.sav_scores ABSENT
|
|
// evidence_snapshot.{career,relationship,finance}_narrative ABSENT
|
|
// The narrative payloads and the sign-keyed SAV fallback are still covered here
|
|
// because the engine's own `_build_ai_prompt_pack` emits them on other routes;
|
|
// the extraction layer must read either shape without inventing data.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SIGNS = [
|
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
|
] as const;
|
|
|
|
const ASC_INDEX = 3; // Cancer
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function basePlanets(): JsonRecord {
|
|
return {
|
|
Sun: { sign: "Cancer", degree_raw: 100.5, house: 1, retrograde: false },
|
|
Moon: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: false },
|
|
Mars: { sign: "Virgo", degree_raw: 160.5, house: 3, retrograde: false },
|
|
Mercury: { sign: "Libra", degree_raw: 190.5, house: 4, retrograde: true },
|
|
Jupiter: { sign: "Scorpio", degree_raw: 220.5, house: 5, retrograde: false },
|
|
Venus: { sign: "Sagittarius", degree_raw: 250.5, house: 6, retrograde: false },
|
|
Saturn: { sign: "Capricorn", degree_raw: 280.5, house: 7, retrograde: true },
|
|
Rahu: { sign: "Aquarius", degree_raw: 310.5, house: 8, retrograde: true },
|
|
Ketu: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: true },
|
|
};
|
|
}
|
|
|
|
function baseHouses(): JsonRecord {
|
|
const houses: JsonRecord = {};
|
|
for (let index = 0; index < 12; index += 1) {
|
|
houses[`house_${index + 1}`] = {
|
|
cusp_sign: SIGNS[(ASC_INDEX + index) % 12],
|
|
cusp_degree: 100.5 + index * 30,
|
|
};
|
|
}
|
|
return houses;
|
|
}
|
|
|
|
const AVAILABLE_LAYERS = [
|
|
"D1", "D2", "D11", "D24", "yogas", "ashtakavarga",
|
|
"functional_benefic_malefic", "vimshottari",
|
|
];
|
|
|
|
function machineSections(): JsonRecord {
|
|
return {
|
|
D1: { status: "used", source_path: "chart.planets+chart.ascendant" },
|
|
D2: { status: "used", source_path: "modules.varga_full.D2" },
|
|
D11: { status: "used", source_path: "modules.varga_full.D11" },
|
|
// Verified layers make the education theme reach multi_system_consensus so
|
|
// the seed-less fallback can be observed lowering it.
|
|
D24: { status: "verified", source_path: "modules.varga_full.D24" },
|
|
yogas: { status: "used", source_path: "modules.yogas" },
|
|
ashtakavarga: { status: "used", source_path: "modules.ashtakavarga" },
|
|
functional_benefic_malefic: { status: "used", source_path: "modules.functional_benefic_malefic" },
|
|
vimshottari: { status: "verified", source_path: "modules.dasha" },
|
|
planet_degrees: { status: "used", source_path: "chart.planets" },
|
|
house_degrees: { status: "used", source_path: "chart.houses" },
|
|
};
|
|
}
|
|
|
|
type FixtureOptions = Readonly<{
|
|
interpretive?: boolean;
|
|
financeNarrative?: boolean;
|
|
pollutedNames?: boolean;
|
|
}>;
|
|
|
|
function workflowFixture(options: FixtureOptions = {}): JsonRecord {
|
|
const interpretive = options.interpretive !== false;
|
|
const chart: JsonRecord = {
|
|
ascendant: { sign: "Cancer", degree: 10.5, degree_in_sign: 10.5 },
|
|
planets: basePlanets(),
|
|
houses: baseHouses(),
|
|
dasha: {
|
|
mahadashas: [
|
|
{ lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
|
|
{ lord: "Jupiter", start: "2029-11-11", end: "2045-11-11" },
|
|
],
|
|
},
|
|
modules: {},
|
|
};
|
|
const modules = chart.modules as JsonRecord;
|
|
if (interpretive) {
|
|
chart.yogas = [
|
|
{ name: "Amala Yoga", planets: ["Mercury"], category: "extended" },
|
|
// Duplicate name from the second detection pass must collapse to one row.
|
|
{ name: "Amala Yoga", planets: [], cat: "extended", desc: "..." },
|
|
{ name: "Gaja Kesari Yoga", planets: ["Jupiter", "Moon"], category: "raja" },
|
|
// Unknown category folds into "other"; unknown planets are dropped.
|
|
{ name: "Budha Aditya Yoga", planets: ["Mercury", "Nibiru"], category: "wholly_invented" },
|
|
// No usable name: dropped rather than guessed.
|
|
{ name: " ", planets: ["Sun"], category: "raja" },
|
|
];
|
|
modules.yogas = {
|
|
status: "ok",
|
|
count: 3,
|
|
yogas: [
|
|
{ name: "Sunaphaa Yoga", rule_id: "bvr_002", hit: false },
|
|
{ name: "Chandra Mangala Yoga", rule_id: "bvr_010", hit: true, category: "dhana" },
|
|
],
|
|
};
|
|
modules.ashtakavarga = {
|
|
sav: {
|
|
// Real engine keys SAV by sign; the projection maps sign -> whole-sign house.
|
|
scores: {
|
|
Cancer: 24, Leo: 27, Virgo: 28, Libra: 25, Scorpio: 35, Sagittarius: 30,
|
|
Capricorn: 26, Aquarius: 28, Pisces: 32, Aries: 30, Taurus: 28, Gemini: 24,
|
|
},
|
|
total: 337,
|
|
},
|
|
};
|
|
modules.dasha_sub_periods = {
|
|
current: {
|
|
mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
|
|
antardasha: { lord: "Sun", start: "2026-05-30", end: "2027-04-24" },
|
|
},
|
|
};
|
|
modules.guided_topics = [
|
|
{
|
|
id: "wealth_risk",
|
|
title: "财富、借贷和交易风险怎样用数据拆开",
|
|
reality_value: "帮助用户把收入、现金流、借贷、买卖和投资风险分开判断。",
|
|
why_worth_exploring: "财富主题必须同时看2宫、11宫、D2/D11 与时间层。",
|
|
evidence: [{ label: "required vargas", value: "D2 / D11" }],
|
|
confidence: "low",
|
|
},
|
|
// Product-flow topic: intentionally not mapped onto a report theme.
|
|
{ id: "birth_time_rectification", title: "出生时间是否需要微调", reality_value: "x", why_worth_exploring: "y" },
|
|
];
|
|
chart.ai_prompt_pack = {
|
|
schema_version: 1,
|
|
evidence_snapshot: {
|
|
functional_benefic_malefic: {
|
|
status: "used",
|
|
ascendant: "Cancer",
|
|
functional_benefics: options.pollutedNames
|
|
? ["Jupiter", "Moon", "Nibiru", "'; DROP TABLE --"]
|
|
: ["Jupiter", "Moon"],
|
|
functional_malefics: ["Mercury", "Saturn", "Venus"],
|
|
functional_neutrals: ["Sun"],
|
|
yogakarakas: ["Mars"],
|
|
owned_houses: {
|
|
Jupiter: [6, 9], Mars: [5, 10], Mercury: [3, 12], Moon: [1],
|
|
Saturn: [7, 8], Sun: [2], Venus: [4, 11], Nibiru: [13],
|
|
},
|
|
},
|
|
strength: {
|
|
shadbala_ranking: [
|
|
{ planet: "Venus", rupas: 8.35, level: "极强" },
|
|
{ planet: "Saturn", rupas: 7.56, level: "极强" },
|
|
{ planet: "Mercury", rupas: 7.03, level: "充足" },
|
|
{ planet: "Jupiter", rupas: 6.97, level: "充足" },
|
|
{ planet: "Sun", rupas: 6.42, level: "强" },
|
|
{ planet: "Moon", rupas: 6.42, level: "充足" },
|
|
{ planet: "Mars", rupas: 5.79, level: "充足" },
|
|
{ planet: "Nibiru", rupas: 99, level: "极强" },
|
|
],
|
|
},
|
|
...(options.financeNarrative
|
|
? {
|
|
finance_narrative: {
|
|
headline: `财富承诺已经成立,${"细".repeat(600)}`,
|
|
strengths: ["二宫与十一宫同时得到吉星支持,收入结构偏向可持续积累。"],
|
|
risks: [
|
|
"支出节奏比收入节奏更快,现金留存能力弱于账面收入。",
|
|
"VedAstro 官方财富日窗口尚未取得验证,不能作为时间依据。",
|
|
],
|
|
boundaries: ["未完成本命财富承诺与双重大运交叉前,不得写成高置信度结论。"],
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
success: true,
|
|
chart,
|
|
consumer_context: {
|
|
route: "general",
|
|
core_status: "ready",
|
|
available_layers: AVAILABLE_LAYERS,
|
|
missing_route_layers: [],
|
|
hard_blockers: [],
|
|
answer_policy: { can_answer_direction: true, can_answer_precise_timing: false },
|
|
},
|
|
machine_evidence_packet: { sections: machineSections(), conflicts: [] },
|
|
};
|
|
}
|
|
|
|
const THEMES = ["general", "wealth", "education"] as const;
|
|
|
|
function buildBundle(options: FixtureOptions = {}): ReportEvidenceBundleV2 {
|
|
const workflow = workflowFixture(options);
|
|
return buildReportEvidenceBundleV2({
|
|
workflows: THEMES.map((theme) => ({ theme, workflow })),
|
|
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
|
|
requestedThemes: [...THEMES],
|
|
reportType: "personal_full",
|
|
presentationMode: "default",
|
|
skillSnapshot: {
|
|
name: "jyotish-personal-report",
|
|
version: "1.0.0",
|
|
sha256: "a".repeat(64),
|
|
sourceCommit: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 0/1: the extraction layer reads every field the contract snapshot has
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("bundle carries the interpretive layers the engine actually computes", () => {
|
|
const bundle = buildBundle();
|
|
const facts = bundle.interpretiveFacts;
|
|
|
|
// Yogas: chart.yogas detections + modules.yogas rows with hit:true only.
|
|
assert.deepEqual(
|
|
facts.yogas.map((yoga) => `${yoga.name}/${yoga.category}`).sort(),
|
|
[
|
|
"Amala Yoga/extended",
|
|
"Budha Aditya Yoga/other",
|
|
"Chandra Mangala Yoga/dhana",
|
|
"Gaja Kesari Yoga/raja",
|
|
],
|
|
);
|
|
assert.ok(!facts.yogas.some((yoga) => yoga.name === "Sunaphaa Yoga"), "hit:false rules are not detections");
|
|
const budha = facts.yogas.find((yoga) => yoga.name === "Budha Aditya Yoga")!;
|
|
assert.deepEqual([...budha.planets], ["Mercury"], "unknown celestial names are dropped");
|
|
assert.ok(facts.yogas.every((yoga) => yoga.evidenceRef === "ev-tech-yoga"));
|
|
|
|
// Functional roles: yogakaraka wins over the benefic/malefic lists.
|
|
assert.equal(facts.functionalRoles.length, 7);
|
|
const mars = facts.functionalRoles.find((role) => role.planet === "Mars")!;
|
|
assert.equal(mars.role, "yogakaraka");
|
|
assert.deepEqual([...mars.ownedHouses], [5, 10]);
|
|
assert.deepEqual(
|
|
facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet),
|
|
["Mercury", "Saturn", "Venus"],
|
|
);
|
|
|
|
// Shadbala: ranks are re-derived from the ordering, unknown planets dropped.
|
|
assert.deepEqual(
|
|
facts.shadbalaRanking.map((row) => [row.planet, row.rank]),
|
|
// Sun and Moon tie at 6.42 rupas; the tie breaks alphabetically so the
|
|
// ranking (and therefore the bundle hash) is reproducible.
|
|
[["Venus", 1], ["Saturn", 2], ["Mercury", 3], ["Jupiter", 4], ["Moon", 5], ["Sun", 6], ["Mars", 7]],
|
|
);
|
|
assert.equal(facts.shadbalaRanking[0].rupa, 8.35);
|
|
|
|
// SAV: sign-keyed engine scores become whole-sign house scores.
|
|
assert.equal(facts.savScores.length, 12);
|
|
assert.equal(facts.savScores.find((row) => row.house === 1)!.score, 24, "house 1 == ascendant sign Cancer");
|
|
assert.equal(facts.savScores.find((row) => row.house === 2)!.score, 27, "house 2 == Leo");
|
|
assert.equal(facts.savTotal, 337);
|
|
|
|
assert.deepEqual(facts.currentDasha, {
|
|
mahadasha: "Rahu", antardasha: "Sun", start: "2011-11-11", end: "2029-11-11",
|
|
});
|
|
|
|
// Absent in the live consultation contract: reported as empty, never faked.
|
|
assert.deepEqual([...facts.convergenceDomains], []);
|
|
});
|
|
|
|
test("interpretive extraction rejects polluted planet names and over-long narrative text", () => {
|
|
const bundle = buildBundle({ pollutedNames: true, financeNarrative: true });
|
|
const benefics = bundle.interpretiveFacts.functionalRoles
|
|
.filter((role) => role.role === "benefic")
|
|
.map((role) => role.planet);
|
|
assert.deepEqual(benefics, ["Jupiter", "Moon"], "non-allowlisted names never enter the bundle");
|
|
|
|
const wealthSeed = bundle.themeNarrativeSeeds.find((seed) => seed.theme === "wealth")!;
|
|
assert.equal(wealthSeed.headline.length, 300, "headline is truncated to the schema bound");
|
|
assert.ok(wealthSeed.headline.startsWith("财富承诺已经成立"));
|
|
assert.deepEqual(
|
|
wealthSeed.risks,
|
|
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
|
|
"seed lines naming an external provider are dropped, not rewritten",
|
|
);
|
|
});
|
|
|
|
test("bundle hash is stable under interpretive field ordering", () => {
|
|
const bundle = buildBundle();
|
|
const { bundleHash: _hash, ...content } = bundle;
|
|
void _hash;
|
|
const shuffled = finalizeReportEvidenceBundleV2({
|
|
...content,
|
|
interpretiveFacts: {
|
|
...content.interpretiveFacts,
|
|
yogas: [...content.interpretiveFacts.yogas].reverse(),
|
|
functionalRoles: [...content.interpretiveFacts.functionalRoles].reverse(),
|
|
shadbalaRanking: [...content.interpretiveFacts.shadbalaRanking].reverse(),
|
|
savScores: [...content.interpretiveFacts.savScores].reverse(),
|
|
},
|
|
themeNarrativeSeeds: [...content.themeNarrativeSeeds].reverse().map((seed) => ({
|
|
...seed,
|
|
evidenceRefs: [...seed.evidenceRefs].reverse(),
|
|
})),
|
|
});
|
|
assert.equal(shuffled.bundleHash, bundle.bundleHash);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 1: fail-closed validation of the new fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("validation fails closed on out-of-bounds interpretive facts and seeds", () => {
|
|
const bundle = buildBundle();
|
|
const mutate = (patch: Partial<ReportEvidenceBundleV2>) => (
|
|
{ ...bundle, ...patch } as ReportEvidenceBundleV2
|
|
);
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
interpretiveFacts: {
|
|
...bundle.interpretiveFacts,
|
|
yogas: [{ name: "Ghost Yoga", category: "raja", planets: [], evidenceRef: "ev-tech-not-real" }],
|
|
},
|
|
})), /report_bundle_invalid_interpretive_ref/);
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
interpretiveFacts: {
|
|
...bundle.interpretiveFacts,
|
|
shadbalaRanking: [
|
|
{ planet: "Venus", rank: 1, rupa: 1 },
|
|
{ planet: "Saturn", rank: 1, rupa: 1 },
|
|
],
|
|
},
|
|
})), /report_bundle_duplicate_shadbala_rank/);
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
interpretiveFacts: {
|
|
...bundle.interpretiveFacts,
|
|
functionalRoles: [{
|
|
planet: "Mars",
|
|
// @ts-expect-error unknown role must be rejected by the closed enum
|
|
role: "super_benefic",
|
|
ownedHouses: [1],
|
|
evidenceRef: "ev-tech-functional_benefic_malefic",
|
|
}],
|
|
},
|
|
})));
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
interpretiveFacts: { ...bundle.interpretiveFacts, savScores: [{ house: 13, score: 1 }] },
|
|
})));
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
themeNarrativeSeeds: [{
|
|
theme: "marriage",
|
|
headline: "未被请求的主题不得携带种子",
|
|
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
|
|
}],
|
|
})), /report_bundle_seed_theme_not_requested/);
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
themeNarrativeSeeds: [{
|
|
theme: "general",
|
|
headline: "越界".repeat(200),
|
|
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
|
|
}],
|
|
})));
|
|
|
|
assert.throws(() => validateReportEvidenceBundleV2(mutate({
|
|
themeNarrativeSeeds: [{
|
|
theme: "general",
|
|
headline: "引用不存在的证据",
|
|
strengths: [], risks: [], boundaries: [], evidenceRefs: ["ev-tech-not-real"],
|
|
}],
|
|
})), /report_bundle_invalid_seed_ref/);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 2: claim cards carry conclusions, not receipts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("claim card conclusions state real chart facts instead of an execution receipt", () => {
|
|
const bundle = buildBundle();
|
|
for (const card of bundle.claimCards) {
|
|
assert.ok(
|
|
!card.conclusion.includes("服务器已闭合"),
|
|
`${card.theme} still uses the receipt template`,
|
|
);
|
|
assert.ok(card.conclusion.includes("Cancer"), `${card.theme} conclusion names the ascendant`);
|
|
}
|
|
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
|
|
assert.ok(wealth.conclusion.includes("第 2 宫"), "wealth conclusion cites its own SAV houses");
|
|
assert.ok(wealth.conclusion.includes("第 11 宫"));
|
|
const health = bundle.claimCards.find((card) => card.theme === "general");
|
|
assert.ok(health && !health.conclusion.includes("第 2 宫"), "themes do not share one canned conclusion");
|
|
|
|
// Supporting facts are astrological statements bound to real receipts.
|
|
const values = wealth.supportingFacts.map((fact) => fact.value);
|
|
assert.ok(values.some((value) => value.includes("Rahu 主运")), "current dasha reaches the claim card");
|
|
assert.ok(values.some((value) => value.includes("Amala Yoga")), "detected yogas reach the claim card");
|
|
const refIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
|
|
for (const fact of [...wealth.supportingFacts, ...wealth.counterFacts]) {
|
|
assert.ok(refIds.has(fact.evidenceRef), `${fact.id} must bind a real evidence ref`);
|
|
}
|
|
});
|
|
|
|
test("engine risk lines become counter facts on the matching claim card", () => {
|
|
const bundle = buildBundle({ financeNarrative: true });
|
|
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
|
|
assert.deepEqual(
|
|
wealth.counterFacts.map((fact) => fact.value),
|
|
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
|
|
);
|
|
});
|
|
|
|
test("a theme without any narrative seed keeps the receipt wording and loses consensus", () => {
|
|
const seeded = buildBundle();
|
|
const bare = buildBundle({ interpretive: false });
|
|
|
|
const seededEducation = seeded.claimCards.find((card) => card.theme === "education")!;
|
|
assert.equal(seededEducation.assertionLevel, "multi_system_consensus");
|
|
|
|
const bareEducation = bare.claimCards.find((card) => card.theme === "education")!;
|
|
assert.equal(bare.themeNarrativeSeeds.length, 0, "no interpretive source means no seed");
|
|
assert.ok(bareEducation.conclusion.includes("服务器已闭合"));
|
|
assert.equal(
|
|
bareEducation.assertionLevel,
|
|
"single_system_inference",
|
|
"seed-less themes are capped below consensus",
|
|
);
|
|
});
|
|
|
|
test("consensus guard still rejects an upgraded assertion level", () => {
|
|
const bundle = buildBundle();
|
|
const card = bundle.claimCards.find((entry) => entry.theme === "wealth")!;
|
|
assert.notEqual(card.assertionLevel, "multi_system_consensus");
|
|
assert.throws(() => validateReportEvidenceBundleV2({
|
|
...bundle,
|
|
claimCards: bundle.claimCards.map((entry) => (
|
|
entry.theme === "wealth" ? { ...entry, assertionLevel: "multi_system_consensus" as const } : entry
|
|
)),
|
|
}), /report_bundle_invalid_consensus/);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 1: per-section trimming
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("section filtering keeps the universal interpretive layer and trims the rest by theme", () => {
|
|
const bundle = buildBundle();
|
|
const plan = buildPersonalReportSectionPlan(bundle, "standard");
|
|
const wealthSection = plan.sections.find((section) => section.theme === "wealth")!;
|
|
const generalSection = plan.sections.find((section) => section.theme === "general")!;
|
|
|
|
const wealthBundle = filterReportEvidenceBundleForSection(bundle, wealthSection);
|
|
assert.deepEqual(wealthBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["wealth"]);
|
|
assert.deepEqual(
|
|
wealthBundle.interpretiveFacts.savScores.map((row) => row.house),
|
|
[2, 11],
|
|
"only the theme's own SAV houses survive",
|
|
);
|
|
assert.equal(wealthBundle.interpretiveFacts.yogas.length, bundle.interpretiveFacts.yogas.length);
|
|
assert.equal(
|
|
wealthBundle.interpretiveFacts.functionalRoles.length,
|
|
bundle.interpretiveFacts.functionalRoles.length,
|
|
);
|
|
|
|
// general only requires D1, yet the chart-wide interpretive receipts ride along.
|
|
assert.ok(!generalSection.evidenceRefs.includes("ev-tech-functional_benefic_malefic"));
|
|
const generalBundle = filterReportEvidenceBundleForSection(bundle, generalSection);
|
|
const generalRefs = generalBundle.evidenceRefs.map((ref) => ref.id);
|
|
assert.ok(generalRefs.includes("ev-tech-functional_benefic_malefic"));
|
|
assert.ok(generalRefs.includes("ev-tech-yoga"));
|
|
assert.equal(generalBundle.interpretiveFacts.functionalRoles.length, 7);
|
|
assert.deepEqual(
|
|
generalBundle.interpretiveFacts.savScores.map((row) => row.house),
|
|
[1, 10],
|
|
);
|
|
assert.deepEqual(generalBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["general"]);
|
|
});
|
|
|
|
test("an empty interpretive block still finalizes and hashes", () => {
|
|
const bundle = buildBundle();
|
|
const { bundleHash: _hash, ...content } = bundle;
|
|
void _hash;
|
|
const stripped = finalizeReportEvidenceBundleV2({
|
|
...content,
|
|
interpretiveFacts: emptyReportInterpretiveFacts(),
|
|
themeNarrativeSeeds: [],
|
|
});
|
|
assert.equal(stripped.interpretiveFacts.savTotal, null);
|
|
assert.notEqual(stripped.bundleHash, bundle.bundleHash, "the hash covers the new fields");
|
|
});
|