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:
@@ -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[];
|
||||
}>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user