From 45f190a1d0712c59bb285801a39bb276481d7828 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 14:53:52 +0800 Subject: [PATCH] fix(report): extract D2/D11 varga aliases so wealth chapters pass final parse Engine keys like D2_Hora never became structured charts, so wealth write themes failed the document contract. Canonicalize document vargas, demote missing charts to blocked, and classify the parse failure. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ frontend/src/lib/personal-report-contract.ts | 2 +- .../src/lib/personal-report-generation.ts | 257 +++++++++++++----- frontend/src/mastra/personal-report.ts | 2 +- .../tests/personal-report-contract.test.ts | 14 + .../personal-report-generation-v2.test.ts | 43 +++ .../tests/personal-report-generation.test.ts | 59 ++++ 7 files changed, 318 insertions(+), 75 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 61a421b7..0bdc38a0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7472,3 +7472,19 @@ - 相关记录:BUG-441、BUG-449、BUG-461、BUG-469、BUG-471 - 复发自:BUG-469(opening 正文仍提问)与 BUG-471(槽位必须可见题干)同时生效 - 修复版本:待发布 + +## BUG-486 | 个人报告财富章有 D2/D11 回执却无结构化分盘,终态 parse 整份失败 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`buildReportEvidenceBundleV2`、`generatePersonalReport`、`readAllVargaCharts` / `readVargaHouses`、`POST /api/reports` 后台生成、`report_schema_invalid` +- 用户现象:计费配置可用后,完整个人报告创建成功但生成停在失败;列表 `status=failed`、`failureCode=report_schema_invalid`。日志内层是 `final_parse_rejected`,进度可到约 85%,财富章可能已写成 ready,事业/婚恋/时机没有章节行。 +- 触发条件:请求含财富主题;引擎 `modules.varga_full` 使用 `D2_Hora` / `D11_Rudramsa` 这类带流派后缀的键;工作流把 D2/D11 标成已执行。财富章因此被标成 write,终态文档却没有 `charts[].id` 为 D2/D11。 +- 根因:两层叠加。提取只把 `^D\d{1,3}$` 当成分盘 id,丢掉 `D2_Hora` / `D11_Rudramsa`,`readVargaHouses` 也只认 D9/D10。主题计划仍可用 D2/D11 回执闭合财富章。组装后 `findThemeCoverageViolations` 要求财富主题必须带结构化 D2 与 D11,最终 parse 失败,公开码压成 `report_schema_invalid`。 +- 修复:把文档允许的分盘键归一成 D2/D9/D10/D11/D24(含 `D2_Hora` 等别名),占据星体走 celestial allowlist。write 主题若缺少对应结构化分盘,降成 blocked disclosure,不再组装成会解析失败的文档。终态 parse 失败时把缺分盘与 hash 失配从笼统的 `final_parse_rejected` 分出来;日志仍只写 allowlist token,不含用户或模型原文。不放宽 `.strict()` 文档 schema,也不改 assertionLevel 规则。 +- 验证:`frontend/tests/personal-report-generation.test.ts` 用 `D2_Hora` / `D11_Rudramsa` 抽出 D2/D11 且财富章成为 claim;`frontend/tests/personal-report-generation-v2.test.ts` 财富 write 只有 D1 时 ready 且财富进 blocked;`frontend/tests/personal-report-contract.test.ts` 财富主题缺 D2/D11 仍拒绝。三组 focused 测试 85/85,`./node_modules/.bin/tsc --noEmit` 通过。未用登录会话在 staging 重生。 +- 防复发:文档分盘 id 必须从引擎 `varga_full` 别名归一,不得只认 `D2` 这种短键。财富/事业/婚恋/教育 write 主题缺对应结构化分盘时必须降级 blocked,不得把 `final_parse_rejected` 当正常完成。新增分盘别名必须同时覆盖提取与文档 `CHART_IDS`。 +- 相关记录:BUG-352、BUG-451、BUG-467 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/lib/personal-report-contract.ts b/frontend/src/lib/personal-report-contract.ts index fbe1b5d2..ef41edd6 100644 --- a/frontend/src/lib/personal-report-contract.ts +++ b/frontend/src/lib/personal-report-contract.ts @@ -479,7 +479,7 @@ export function findChartSetViolations(document: ReportDocument): readonly strin return violations; } -const REQUIRED_THEME_CHARTS: Readonly> = { +export const REQUIRED_THEME_CHARTS: Readonly> = { career: ["D10"], wealth: ["D2", "D11"], marriage: ["D9"], diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 612758c9..b50ccc31 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -3,12 +3,13 @@ import { computeEvidenceHash, safeParseServerReportDocument, } from "./personal-report-contract.server-core.ts"; -import type { - ClaimStatus, - EvidenceAppendix, - ReportDepth, - ReportDocumentV1, - ReportDocumentV2, +import { + REQUIRED_THEME_CHARTS, + type ClaimStatus, + type EvidenceAppendix, + type ReportDepth, + type ReportDocumentV1, + type ReportDocumentV2, } from "./personal-report-contract.ts"; import { PersonalReportAgentOutputError, @@ -307,51 +308,84 @@ function readHouses( return houses; } +const DOCUMENT_VARGA_CHART_IDS = ["D2", "D9", "D10", "D11", "D24"] as const; +type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number]; + +const VARGA_KEY_ALIASES: Readonly> = { + D2: ["D2_Hora", "D2"], + D9: ["D9_Navamsa", "D9"], + D10: ["D10_Dasamsa", "D10"], + D11: ["D11_Rudramsa", "D11"], + D24: ["D24_Siddhamsa", "D24"], +}; + +function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null { + const match = /^(D(?:24|11|10|9|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase()); + return match ? match[1] as DocumentVargaChartId : null; +} + /** * Divisional charts in the real engine live under modules.varga_full with keys - * D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx| - * sign}, : {sign_idx|sign}, _meta, _dignity, ...} — there are no house - * arrays. House signs are derived whole-sign from the divisional ascendant and - * occupants from each planet's sign index (the same derivation the engine uses - * for D11). Nothing is fabricated; missing varga data simply omits the chart. + * such as D2_Hora / D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 / + * D11). Each varga is {Ascendant: {sign_idx|sign}, : {sign_idx|sign}, + * _meta, _dignity, ...} — there are no house arrays. House signs are derived + * whole-sign from the divisional ascendant and occupants from each planet's + * sign index. Missing or non-document vargas are omitted; occupant names go + * through the celestial allowlist. */ +function deriveVargaHousesFromEngine( + varga: JsonRecord, +): Readonly<{ + ascIndex: number; + degree: number; + houses: ReportEvidencePacket["chart"]["houses"]; +}> | null { + const ascendant = record(varga.Ascendant); + const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; + if (ascIndex === null) return null; + const occupants: string[][] = Array.from({ length: 12 }, () => []); + for (const [name, item] of Object.entries(varga)) { + if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; + const row = record(item); + const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; + if (planetIndex === null) continue; + const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; + const safeName = safeCelestialName(name); + if (safeName) occupants[house - 1].push(safeName); + } + return { + ascIndex, + degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0, + houses: Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], + signDerived: true, + occupants: occupants[index].slice(0, 12), + })), + }; +} + function readVargaHouses( vargaFull: JsonRecord | null, ): ReportEvidencePacket["chart"]["vargaHouses"] { if (!vargaFull) return []; - const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = []; - const variants: Readonly> = { - D9: ["D9_Navamsa", "D9"], - D10: ["D10_Dasamsa", "D10"], + const result: ReportEvidencePacket["chart"]["vargaHouses"][number][] = []; + const seen = new Set(); + const take = (id: DocumentVargaChartId, raw: unknown): void => { + if (seen.has(id)) return; + const varga = record(raw); + if (!varga) return; + const derived = deriveVargaHousesFromEngine(varga); + if (!derived) return; + seen.add(id); + result.push({ id, houses: derived.houses }); }; - for (const [id, keys] of Object.entries(variants) as ReadonlyArray) { - const varga = keys - .map((key) => record(vargaFull[key])) - .find((entry): entry is JsonRecord => entry !== null); - if (!varga) continue; - const ascendant = record(varga.Ascendant); - const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; - if (ascIndex === null) continue; - const occupants: string[][] = Array.from({ length: 12 }, () => []); - for (const [name, item] of Object.entries(varga)) { - if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; - const row = record(item); - const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; - if (planetIndex === null) continue; - const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; - const safeName = safeCelestialName(name); - if (safeName) occupants[house - 1].push(safeName); - } - const houses: ReportEvidencePacket["chart"]["houses"] = Array.from( - { length: 12 }, - (_, index) => ({ - number: index + 1, - sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], - signDerived: true, - occupants: occupants[index].slice(0, 12), - }), - ); - result.push({ id, houses }); + for (const id of DOCUMENT_VARGA_CHART_IDS) { + for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]); + } + for (const [rawId, rawValue] of Object.entries(vargaFull)) { + const id = canonicalDocumentVargaChartId(rawId); + if (id) take(id, rawValue); } return result; } @@ -904,38 +938,31 @@ function readAllVargaCharts(workflow: JsonRecord): ReportChartFact[] { const vargaFull = record(modules.varga_full); if (!vargaFull) return []; const charts: ReportChartFact[] = []; - for (const [rawId, rawValue] of Object.entries(vargaFull)) { - const id = rawId.toUpperCase(); - if (!/^D\d{1,3}$/.test(id)) continue; - const varga = record(rawValue); - if (!varga) continue; - const ascendant = record(varga.Ascendant); - const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; - if (ascIndex === null) continue; - const occupants: string[][] = Array.from({ length: 12 }, () => []); - for (const [name, item] of Object.entries(varga)) { - if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; - const row = record(item); - const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; - if (planetIndex === null) continue; - const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; - occupants[house - 1].push(name); - } + const seen = new Set(); + const take = (id: DocumentVargaChartId, raw: unknown): void => { + if (seen.has(id)) return; + const varga = record(raw); + if (!varga) return; + const derived = deriveVargaHousesFromEngine(varga); + if (!derived) return; + seen.add(id); charts.push({ id, title: `${id} 分盘`, ascendant: { - sign: SIGNS[ascIndex], - degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0, + sign: SIGNS[derived.ascIndex], + degree: derived.degree, }, - houses: Array.from({ length: 12 }, (_, index) => ({ - number: index + 1, - sign: SIGNS[(ascIndex + index) % 12], - signDerived: true, - occupants: occupants[index].slice(0, 20), - })), + houses: derived.houses, planets: [], }); + }; + for (const id of DOCUMENT_VARGA_CHART_IDS) { + for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]); + } + for (const [rawId, rawValue] of Object.entries(vargaFull)) { + const id = canonicalDocumentVargaChartId(rawId); + if (id) take(id, rawValue); } return charts; } @@ -950,8 +977,10 @@ function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvid const d1 = bundle.charts.find((chart) => chart.id === "D1"); if (!d1?.ascendant) throw new ReportEvidenceInsufficientError("ascendant_missing"); const vargaHouses = bundle.charts - .filter((chart): chart is ReportChartFact & { id: "D9" | "D10" } => chart.id === "D9" || chart.id === "D10") - .map((chart) => ({ id: chart.id, houses: chart.houses })); + .flatMap((chart) => { + const id = canonicalDocumentVargaChartId(chart.id); + return id ? [{ id, houses: chart.houses }] : []; + }); const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt])); return { schemaVersion: "report_evidence_packet.v1", @@ -1875,7 +1904,7 @@ export function buildReportEvidenceBundleV2( ? "candidate_directional_only" : "reported_directional_only"; - return finalizeReportEvidenceBundleV2({ + return demoteThemesMissingRequiredCharts(finalizeReportEvidenceBundleV2({ schemaVersion: "report_evidence_bundle.v2", subject: input.subject, requestedThemes: requestedPlans.map((plan) => plan.theme), @@ -1909,6 +1938,74 @@ export function buildReportEvidenceBundleV2( birthTimePolicy, deterministicClaimsForbiddenFor, }, + })); +} + +function requiredStructuredChartsForTheme(theme: string): readonly string[] { + return REQUIRED_THEME_CHARTS[theme] ?? []; +} + +/** + * A write-theme claim without its required structured charts would assemble + * into a document that the final parse rejects. Demote that theme to a blocked + * disclosure instead of failing the whole report. + */ +function demoteThemesMissingRequiredCharts(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 { + const presentCharts = new Set(bundle.charts.map((chart) => chart.id)); + const demoted = bundle.claimCards.filter((card) => ( + requiredStructuredChartsForTheme(card.theme).some((chartId) => !presentCharts.has(chartId)) + )); + if (demoted.length === 0) return bundle; + + const extraReceipts: TechniqueExecutionReceipt[] = []; + const extraBlocked: ReportEvidenceBundleV2["blockedSections"][number][] = []; + const existingIds = new Set([ + ...bundle.executionLedger.map((receipt) => receipt.id), + ...bundle.blockedSections.map((section) => section.id), + ]); + for (const card of demoted) { + const missing = requiredStructuredChartsForTheme(card.theme) + .filter((chartId) => !presentCharts.has(chartId)); + const refs: string[] = []; + for (const chartId of missing) { + const technique = `${card.theme}:${chartId}`; + let id = `ev-tech-${evidenceSlug(technique, "missing")}`; + if (existingIds.has(id)) id = `ev-tech-${evidenceSlug(`${technique}_chart`, "missing")}`; + extraReceipts.push({ + id, + technique, + status: "blocked", + executed: false, + note: safeReceiptNote("blocked", false), + }); + existingIds.add(id); + refs.push(id); + } + const blockedId = existingIds.has(`ev-blocked-${evidenceSlug(card.theme, "theme")}`) + ? `ev-blocked-${evidenceSlug(`${card.theme}_charts`, "theme")}` + : `ev-blocked-${evidenceSlug(card.theme, "theme")}`; + extraBlocked.push({ + id: blockedId, + theme: card.theme, + section: card.section, + reason: `缺少结构化分盘:${missing.join("、")}`, + missingTechniqueRefs: refs, + }); + existingIds.add(blockedId); + } + const demotedThemes = new Set(demoted.map((card) => card.theme)); + const { bundleHash: _bundleHash, ...content } = bundle; + void _bundleHash; + return finalizeReportEvidenceBundleV2({ + ...content, + claimCards: bundle.claimCards.filter((card) => !demotedThemes.has(card.theme)), + blockedSections: [...bundle.blockedSections, ...extraBlocked], + executionLedger: [...bundle.executionLedger, ...extraReceipts], + evidenceRefs: [ + ...bundle.evidenceRefs, + ...extraReceipts.map(({ id, technique, status }) => ({ id, technique, status })), + ], + themeNarrativeSeeds: bundle.themeNarrativeSeeds.filter((seed) => !demotedThemes.has(seed.theme)), }); } @@ -2053,6 +2150,7 @@ export function assembleReportDocument( claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference", }]; for (const varga of packet.chart.vargaHouses) { + if (varga.id !== "D9" && varga.id !== "D10") continue; if (varga.houses.length === 0) continue; charts.push({ id: varga.id, @@ -2845,6 +2943,18 @@ export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInn return "schema_invalid_unclassified"; } +export function classifyFinalParseInnerReason( + errors: readonly Readonly<{ path: string; message: string }>[], +): ReportSchemaInnerReason { + if (errors.some((error) => error.message.includes("requires structured"))) { + return "final_parse_missing_divisional_chart"; + } + if (errors.some((error) => error.path === "provenance.evidenceHash")) { + return "final_parse_evidence_hash"; + } + return "final_parse_rejected"; +} + export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean { if (signal?.aborted) return true; return error instanceof Error && error.name === "AbortError"; @@ -3041,7 +3151,7 @@ async function generateSectionedPersonalReport( const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle)); if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" }; const parsed = safeParseServerReportDocument(guarded.document); - if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema("final_parse_rejected"); + if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors)); return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() }; } catch (error) { rethrowIfAborted(error, deps.signal); @@ -3063,6 +3173,7 @@ export async function generatePersonalReport( let bundle: ReportEvidenceBundleV2; try { bundle = validateReportEvidenceBundleV2(deps.bundle); + bundle = demoteThemesMissingRequiredCharts(bundle); } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema("bundle_invalid"); @@ -3142,7 +3253,7 @@ export async function generatePersonalReport( } const parsed = safeParseServerReportDocument(guarded.document); if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") { - return failSchema("final_parse_rejected"); + return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors)); } return { status: "ready", diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index b99637b4..2e1a82f5 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -39,7 +39,7 @@ export const claimStatusSchema = z.enum([ ]); export type ReportVargaHouses = Readonly<{ - id: "D9" | "D10"; + id: "D2" | "D9" | "D10" | "D11" | "D24"; houses: readonly ReportChartHouse[]; }>; diff --git a/frontend/tests/personal-report-contract.test.ts b/frontend/tests/personal-report-contract.test.ts index 9a01e19c..602942f6 100644 --- a/frontend/tests/personal-report-contract.test.ts +++ b/frontend/tests/personal-report-contract.test.ts @@ -283,6 +283,20 @@ test("v2 thematic sections require their real structured divisional charts", () }); assert.ok(findThemeCoverageViolations(unsupportedCareer).some((error) => error.includes("requires structured D10"))); assert.equal(safeParseReportDocument(unsupportedCareer).ok, false); + + const unsupportedWealth = cloneV2(); + unsupportedWealth.blockedConflictDisclosure = unsupportedWealth.blockedConflictDisclosure.filter( + (section) => section.theme !== "wealth", + ); + unsupportedWealth.thematicNarrative.push({ + ...structuredClone(unsupportedWealth.thematicNarrative[0]), + id: "theme-wealth", + theme: "wealth", + }); + const wealthViolations = findThemeCoverageViolations(unsupportedWealth); + assert.ok(wealthViolations.some((error) => error.includes("requires structured D2"))); + assert.ok(wealthViolations.some((error) => error.includes("requires structured D11"))); + assert.equal(safeParseReportDocument(unsupportedWealth).ok, false); assert.equal(safeParseReportDocument(v2Fixture).ok, true); }); diff --git a/frontend/tests/personal-report-generation-v2.test.ts b/frontend/tests/personal-report-generation-v2.test.ts index 13b25762..f3cc0445 100644 --- a/frontend/tests/personal-report-generation-v2.test.ts +++ b/frontend/tests/personal-report-generation-v2.test.ts @@ -14,6 +14,7 @@ import { type TechniqueExecutionReceipt, } from "../src/lib/report-evidence-bundle-v2.ts"; import { + classifyFinalParseInnerReason, classifyReportSchemaInnerReason, generatePersonalReport, type GeneratePersonalReportResult, @@ -606,6 +607,29 @@ test("plan binding failure consumes the single writer repair retry", async () => assert.equal(observed.calls, 2); }); +test("wealth write without structured D2/D11 charts demotes to blocked instead of failing schema parse", async () => { + const bundle = makeBundle({ + themes: [{ + theme: "wealth", + section: "财富结构", + refs: ["ev-tech-d2", "ev-tech-d11"], + }], + charts: [chart("D1")], + }); + const sectionCalls: string[] = []; + const result = await runSectioned( + bundle, + sectionedAgent({ sectionCalls }), + inMemorySectionService(), + ); + const document = readyV2(result); + assert.deepEqual(sectionCalls, []); + assert.deepEqual(document.thematicNarrative.map((section) => section.theme), []); + assert.equal(document.blockedConflictDisclosure.length, 1); + assert.equal(document.blockedConflictDisclosure[0].theme, "wealth"); + assert.match(document.blockedConflictDisclosure[0].reason, /D2|D11|结构化分盘/); +}); + test("schema inner reasons stay on the allowlist and never include model text", () => { assert.equal( classifyReportSchemaInnerReason(new Error("report_writer_theme_count_mismatch")), @@ -619,6 +643,25 @@ test("schema inner reasons stay on the allowlist and never include model text", classifyReportSchemaInnerReason(new Error("Unexpected token in JSON at position 12")), "schema_invalid_unclassified", ); + assert.equal( + classifyFinalParseInnerReason([ + { path: "(guard)", message: "thematic section wealth requires structured D2 chart data or a blocked disclosure" }, + { path: "(guard)", message: "thematic section wealth requires structured D11 chart data or a blocked disclosure" }, + ]), + "final_parse_missing_divisional_chart", + ); + assert.equal( + classifyFinalParseInnerReason([ + { path: "thematicNarrative.0.title", message: "String must contain at most 160 character(s)" }, + ]), + "final_parse_rejected", + ); + assert.equal( + classifyFinalParseInnerReason([ + { path: "provenance.evidenceHash", message: "does not match recomputed evidence hash" }, + ]), + "final_parse_evidence_hash", + ); }); test("accepted birth time never creates a fake candidate range", async () => { diff --git a/frontend/tests/personal-report-generation.test.ts b/frontend/tests/personal-report-generation.test.ts index c6bf0ac1..3b3f94cd 100644 --- a/frontend/tests/personal-report-generation.test.ts +++ b/frontend/tests/personal-report-generation.test.ts @@ -1306,6 +1306,65 @@ test("bundle v2 builder blocks incomplete wealth evidence without leaking workfl } }); +function wealthClosedWorkflow(): Record { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const modules = chart.modules as Record; + const vargaFull = modules.varga_full as Record; + vargaFull.D2_Hora = { + _meta: { div: 2 }, + Ascendant: { sign: "Cancer", sign_idx: 3 }, + Sun: { sign: "Leo", sign_idx: 4 }, + Moon: { sign: "Virgo", sign_idx: 5 }, + AuthorizationSecretPlanet: { sign: "Libra", sign_idx: 6 }, + }; + vargaFull.D11_Rudramsa = { + _meta: { div: 11 }, + Ascendant: { sign: "Scorpio", sign_idx: 7 }, + Sun: { sign: "Sagittarius", sign_idx: 8 }, + Moon: { sign: "Capricorn", sign_idx: 9 }, + }; + const machine = workflow.machine_evidence_packet as Record; + const sections = machine.sections as Record; + sections.D2 = { status: "used", source_path: "modules.varga_full.D2" }; + sections.D11 = { status: "used", source_path: "modules.varga_full.D11" }; + sections.Yoga = { status: "used", source_path: "modules.yogas" }; + sections.Ashtakavarga = { status: "used", source_path: "modules.ashtakavarga" }; + return workflow; +} + +test("bundle v2 canonicalizes D2_Hora and D11_Rudramsa into structured D2/D11 charts for wealth", () => { + const bundle = buildReportEvidenceBundleV2({ + workflows: [{ theme: "wealth", workflow: wealthClosedWorkflow() }], + subject: { + displayName: "测试用户", + birthTimeStatus: "accepted", + birthPlaceLabel: "北京", + }, + requestedThemes: ["wealth"], + reportType: "personal_thematic", + presentationMode: "default", + skillSnapshot: { + name: "jyotish-vedic-astrology", + version: "6.9.14", + sha256: "a".repeat(64), + sourceCommit: "b".repeat(40), + }, + }); + + assert.ok(bundle.charts.some((chart) => chart.id === "D2")); + assert.ok(bundle.charts.some((chart) => chart.id === "D11")); + assert.equal(bundle.charts.some((chart) => chart.id.includes("Hora") || chart.id.includes("Rudramsa")), false); + const d2 = bundle.charts.find((chart) => chart.id === "D2"); + assert.ok(d2); + assert.equal(d2.houses.length, 12); + assert.equal(d2.houses[0].sign, "Cancer"); + assert.ok(d2.houses[1].occupants.includes("Sun")); + assert.equal(d2.houses.some((house) => house.occupants.includes("AuthorizationSecretPlanet")), false); + assert.ok(bundle.claimCards.some((card) => card.theme === "wealth")); + assert.equal(bundle.blockedSections.some((section) => section.theme === "wealth"), false); +}); + test("bundle v2 never treats an available-only machine section as executed evidence", () => { const workflow = pythonStyleChartPayload() as Record; const consumer = workflow.consumer_context as Record;