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; 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) => ( { ...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"); });