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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -479,7 +479,7 @@ export function findChartSetViolations(document: ReportDocument): readonly strin
|
||||
return violations;
|
||||
}
|
||||
|
||||
const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
|
||||
export const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
|
||||
career: ["D10"],
|
||||
wealth: ["D2", "D11"],
|
||||
marriage: ["D9"],
|
||||
|
||||
@@ -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<Record<DocumentVargaChartId, readonly string[]>> = {
|
||||
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}, <planet>: {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}, <planet>: {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<Record<"D9" | "D10", readonly string[]>> = {
|
||||
D9: ["D9_Navamsa", "D9"],
|
||||
D10: ["D10_Dasamsa", "D10"],
|
||||
const result: ReportEvidencePacket["chart"]["vargaHouses"][number][] = [];
|
||||
const seen = new Set<DocumentVargaChartId>();
|
||||
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<readonly ["D9" | "D10", readonly string[]]>) {
|
||||
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<DocumentVargaChartId>();
|
||||
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",
|
||||
|
||||
@@ -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[];
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1306,6 +1306,65 @@ test("bundle v2 builder blocks incomplete wealth evidence without leaking workfl
|
||||
}
|
||||
});
|
||||
|
||||
function wealthClosedWorkflow(): Record<string, unknown> {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
const modules = chart.modules as Record<string, unknown>;
|
||||
const vargaFull = modules.varga_full as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const sections = machine.sections as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const consumer = workflow.consumer_context as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user