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
106 lines
5.2 KiB
TypeScript
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/);
|
|
});
|