Files
Jyotisha/frontend/tests/personal-report-plan.test.ts
T
Jesse_Chen 90bad10d6f feat(report): carry engine interpretive facts into the evidence bundle
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
2026-09-01 20:35:02 +00:00

106 lines
5.2 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
buildPersonalReportSectionPlan,
validatePersonalReportSectionPlan,
} from "../src/lib/personal-report-plan.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
function bundleFixture(): ReportEvidenceBundleV2 {
return finalizeReportEvidenceBundleV2({
schemaVersion: "report_evidence_bundle.v2",
// v2 bundle gained the server-owned interpretive layer (2026-09-01);
// fixtures that carry no engine snapshot declare it empty.
interpretiveFacts: emptyReportInterpretiveFacts(),
themeNarrativeSeeds: [],
subject: { displayName: "测试用户", birthTimeStatus: "accepted", birthPlaceLabel: "北京" },
requestedThemes: ["career", "wealth", "timing"],
reportType: "personal_full",
presentationMode: "default",
calculationProfile: {
calculationHash: "a".repeat(64), calculationHashDerived: false,
birthTimeStatus: "accepted", ayanamsa: "Lahiri", nodeMode: "mean", houseSystem: "whole_sign",
vimshottari: null, narayana: null,
},
skill: { name: "jyotish-personal-report", version: "1.0.0", sha256: "b".repeat(64), sourceCommit: null },
charts: [{
id: "D1", title: "本命盘 D1", ascendant: { sign: "Leo", degree: 20.5 },
houses: Array.from({ length: 12 }, (_, index) => ({ number: index + 1, sign: "Leo", signDerived: false, occupants: [] })),
planets: [],
}],
claimCards: [{
id: "ev-claim-career", theme: "career", section: "事业", conclusion: "事业结论",
supportingFacts: [{ id: "ev-fact-career", label: "D10", value: "已执行", evidenceRef: "ev-tech-d10", status: "verified" }],
counterFacts: [], executedTechniqueRefs: ["ev-tech-d10"], assertionLevel: "single_system_inference",
timingBoundary: null, verificationQuestions: [],
}],
blockedSections: [
{ id: "ev-blocked-wealth", theme: "wealth", section: "财富", reason: "缺少 D2", missingTechniqueRefs: ["ev-tech-d2"] },
{ id: "ev-blocked-timing", theme: "timing", section: "应期", reason: "时间证据不足", missingTechniqueRefs: ["ev-tech-vimshottari"] },
],
conflicts: [],
executionLedger: [
{ id: "ev-tech-d10", technique: "D10", status: "verified", executed: true, note: "已执行" },
{ id: "ev-tech-d2", technique: "D2", status: "blocked", executed: false, note: "未提供" },
{ id: "ev-tech-vimshottari", technique: "Vimshottari", status: "blocked", executed: false, note: "未提供" },
],
evidenceRefs: [
{ id: "ev-tech-d10", technique: "D10", status: "verified" },
{ id: "ev-tech-d2", technique: "D2", status: "blocked" },
{ id: "ev-tech-vimshottari", technique: "Vimshottari", status: "blocked" },
],
answerPolicy: {
canAnswerPreciseTiming: false,
birthTimePolicy: "accepted_directional_only",
deterministicClaimsForbiddenFor: ["timing"],
},
});
}
test("section plan covers every requested theme exactly once and preserves blocked themes", () => {
const bundle = bundleFixture();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
const thematic = plan.sections.filter((section) => section.kind === "thematic");
assert.deepEqual(thematic.map((section) => section.theme), bundle.requestedThemes);
assert.equal(thematic.find((section) => section.theme === "career")?.disposition, "write");
assert.equal(thematic.find((section) => section.theme === "wealth")?.disposition, "blocked");
assert.equal(thematic.find((section) => section.theme === "timing")?.disposition, "blocked");
assert.equal(plan.sections.find((section) => section.kind === "current_phase")?.disposition, "blocked");
});
test("four report depths use bounded configurable ranges instead of one-character completeness", () => {
const bundle = bundleFixture();
const depths = ["concise", "standard", "deep", "research"] as const;
const minimums = depths.map((depth) => {
const plan = buildPersonalReportSectionPlan(bundle, depth);
return plan.sections.find((section) => section.kind === "thematic")!.targetCharacters.min;
});
assert.deepEqual(minimums, [120, 260, 480, 600]);
assert.ok(minimums.every((minimum) => minimum > 1));
});
test("plan validation rejects omitted themes, wrong blocked disposition and dangling refs", () => {
const bundle = bundleFixture();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
assert.throws(() => validatePersonalReportSectionPlan({
...plan,
sections: plan.sections.filter((section) => section.theme !== "wealth"),
}, bundle), /report_plan_theme_coverage_invalid:wealth/);
assert.throws(() => validatePersonalReportSectionPlan({
...plan,
sections: plan.sections.map((section) => section.theme === "wealth"
? { ...section, disposition: "write" }
: section),
}, bundle), /report_plan_theme_disposition_invalid:wealth/);
assert.throws(() => validatePersonalReportSectionPlan({
...plan,
sections: plan.sections.map((section) => section.theme === "career"
? { ...section, evidenceRefs: ["ev-missing"] }
: section),
}, bundle), /report_plan_dangling_evidence_ref:ev-missing/);
});