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
This commit is contained in:
Jesse_Chen
2026-09-01 20:35:02 +00:00
co-authored by Claude Fable 5
parent 551d6317ae
commit 90bad10d6f
7 changed files with 1478 additions and 16 deletions
+741 -15
View File
@@ -23,10 +23,23 @@ import type {
EvidenceConflict,
ReportChartFact,
ReportClaimCard,
ReportCurrentDashaFact,
ReportFunctionalRole,
ReportFunctionalRoleFact,
ReportInterpretiveFacts,
ReportSavScoreFact,
ReportShadbalaRankFact,
ReportThemeNarrativeSeed,
ReportYogaCategory,
ReportYogaFact,
SafeReportSubject,
TechniqueExecutionReceipt,
} from "./report-evidence-bundle-v2.ts";
import { finalizeReportEvidenceBundleV2, validateReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import {
REPORT_YOGA_CATEGORIES,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
} from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan, type ReportSectionPlanEntry } from "./personal-report-plan.ts";
@@ -983,6 +996,503 @@ function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvid
};
}
// ---------------------------------------------------------------------------
// Interpretive facts + theme narrative seeds (server-owned allowlist)
//
// The engine already computes a functional benefic/malefic table, a shadbala
// ranking, SAV scores, the current Vimshottari maha/antardasha, detected yogas
// and guided-topic copy. Before this layer the report packet threw all of it
// away, so the claim cards could only carry execution receipts. Extraction is
// allowlist-style: unknown keys are never copied, planet/sign/house values go
// through the existing safe projections, and free text is length-capped and
// scrubbed of internal vocabulary before it can reach the writer prompt.
// ---------------------------------------------------------------------------
/**
* Substrings that disqualify a whole seed line. Server-generated narratives can
* mention internal routes, vendor names or blocked-layer bookkeeping; the
* writer prompt must never see those, so a line containing any of them is
* dropped rather than rewritten.
*/
const FORBIDDEN_SEED_TOKENS = [
"vedastro", "mevg", "workflow", "references/", "scripts/", "modules.",
"skill", "prompt", "swisseph", "ephemeris", "oracle", "engine", "sha256",
"schema", "fallback", "blocked", "not_found", "not_available", "json",
"http", "localhost", "strict_", "_strict", "api", "pipeline", "backend",
];
/** Trims, flattens whitespace and rejects lines carrying internal vocabulary. */
function safeSeedText(value: unknown, max: number): string | null {
if (typeof value !== "string") return null;
const flattened = value
.replace(/[\u0000-\u001f\u007f]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (flattened.length === 0) return null;
const lowered = flattened.toLowerCase();
if (FORBIDDEN_SEED_TOKENS.some((token) => lowered.includes(token))) return null;
if (!/[\p{L}\p{N}]/u.test(flattened)) return null;
return flattened.slice(0, max);
}
/** Same bound, but for machine-ish domain labels (convergence buckets). */
function safeDomainLabel(value: unknown): string | null {
const raw = typeof value === "string" ? value : text(record(value)?.domain);
if (!raw) return null;
const cleaned = raw.replace(/[^\p{L}\p{N}_ /·-]+/gu, "").trim();
return cleaned.length > 0 ? cleaned.slice(0, 80) : null;
}
const YOGA_CATEGORY_ALIASES: Readonly<Record<string, ReportYogaCategory>> = {
raja: "raja", raja_yoga: "raja", neecha_bhanga: "raja",
dhana: "dhana", dhana_yoga: "dhana", wealth: "dhana",
mahapurusha: "mahapurusha", pancha_mahapurusha: "mahapurusha",
nabhasa: "nabhasa",
chandra: "lunar", chandra_extended: "lunar", lunar_yoga: "lunar", lunar: "lunar",
surya: "solar", solar_yoga: "solar", solar: "solar",
kalatra: "relationship", relationship: "relationship", marriage: "relationship",
putra: "progeny", progeny: "progeny",
vidya: "education", education: "education",
ayur: "health", health: "health", ari: "health",
moksha: "spiritual", guru: "spiritual", aryama: "spiritual", asha: "spiritual",
shakti: "spiritual", karma: "spiritual", spiritual: "spiritual",
durbhaga: "affliction", affliction: "affliction",
conjunction: "conjunction",
auspicious: "auspicious",
special: "special",
extended: "extended",
};
function safeYogaCategory(value: unknown): ReportYogaCategory {
const raw = text(value);
if (!raw) return "other";
const key = raw.trim().toLowerCase().replace(/[ -]+/g, "_");
const alias = YOGA_CATEGORY_ALIASES[key];
if (alias) return alias;
return (REPORT_YOGA_CATEGORIES as readonly string[]).includes(key)
? key as ReportYogaCategory
: "other";
}
const YOGA_NAME_PATTERN = /^[\p{L}\p{N} ()/·,.'’++-]{1,80}$/u;
function safeYogaName(value: unknown): string | null {
const raw = text(value);
if (!raw) return null;
const trimmed = raw.replace(/\s+/g, " ").trim().slice(0, 80);
return YOGA_NAME_PATTERN.test(trimmed) ? trimmed : null;
}
function safeHouseNumbers(value: unknown): number[] {
if (!Array.isArray(value)) return [];
const houses = value
.map((item) => finiteNumber(item))
.filter((item): item is number => item !== null && Number.isInteger(item) && item >= 1 && item <= 12);
return [...new Set(houses)].sort((a, b) => a - b);
}
function readEvidenceSnapshot(workflow: JsonRecord): JsonRecord | null {
const chart = record(workflow.chart) ?? {};
const pack = record(chart.ai_prompt_pack);
return pack ? record(pack.evidence_snapshot) : null;
}
function readChartModules(workflow: JsonRecord): JsonRecord {
return record(record(workflow.chart)?.modules) ?? {};
}
/**
* Functional benefic/malefic table. `yogakarakas` wins over the benefic and
* malefic lists so a planet is never reported twice.
*/
function readFunctionalRoles(workflow: JsonRecord, evidenceRef: string): ReportFunctionalRoleFact[] {
const snapshot = readEvidenceSnapshot(workflow);
const modules = readChartModules(workflow);
const layer = record(snapshot?.functional_benefic_malefic)
?? record(modules.functional_benefic_malefic);
if (!layer) return [];
if (text(layer.status) === "blocked") return [];
const owned = record(layer.owned_houses) ?? {};
const buckets: readonly Readonly<[ReportFunctionalRole, unknown]>[] = [
["yogakaraka", layer.yogakarakas],
["benefic", layer.functional_benefics],
["malefic", layer.functional_malefics],
["neutral", layer.functional_neutrals],
];
const roles = new Map<string, ReportFunctionalRoleFact>();
for (const [role, source] of buckets) {
for (const raw of stringArray(source)) {
const planet = safeCelestialName(raw);
if (!planet || roles.has(planet)) continue;
roles.set(planet, {
planet,
role,
ownedHouses: safeHouseNumbers(owned[planet] ?? owned[raw]),
evidenceRef,
});
}
}
return [...roles.values()].slice(0, 12);
}
/**
* Detected yogas only. The engine also ships the full candidate rule table
* under modules.yogas with `hit: false`; those are NOT present in the chart and
* must never be copied.
*/
function readYogaFacts(workflow: JsonRecord, evidenceRef: string): ReportYogaFact[] {
const chart = record(workflow.chart) ?? {};
const modules = readChartModules(workflow);
const rows: unknown[] = [];
if (Array.isArray(chart.yogas)) rows.push(...chart.yogas);
const yogaModule = record(modules.yogas) ?? record(modules.yoga);
if (yogaModule && Array.isArray(yogaModule.yogas)) {
rows.push(...yogaModule.yogas.filter((item) => record(item)?.hit === true));
}
const facts = new Map<string, ReportYogaFact>();
for (const item of rows) {
const row = record(item);
if (!row) continue;
const name = safeYogaName(row.name ?? row.yoga ?? row.title);
if (!name) continue;
const key = name.toLowerCase();
if (facts.has(key)) continue;
const planets = stringArray(row.planets)
.flatMap((raw) => {
const safe = safeCelestialName(raw);
return safe ? [safe] : [];
});
facts.set(key, {
name,
category: safeYogaCategory(row.category ?? row.cat),
planets: [...new Set(planets)].slice(0, 9),
evidenceRef,
});
if (facts.size >= 40) break;
}
return [...facts.values()];
}
function readShadbalaRanking(workflow: JsonRecord): ReportShadbalaRankFact[] {
const snapshot = readEvidenceSnapshot(workflow);
const chart = record(workflow.chart) ?? {};
const strength = record(snapshot?.strength);
const rawRows: Readonly<{ planet: unknown; rank: unknown; rupa: unknown }>[] = [];
if (Array.isArray(strength?.shadbala_ranking)) {
for (const item of strength.shadbala_ranking) {
const row = record(item);
if (!row) continue;
rawRows.push({ planet: row.planet ?? row.name, rank: row.rank, rupa: row.total_rupas ?? row.rupas });
}
} else {
for (const [planet, item] of Object.entries(record(chart.shadbala) ?? {})) {
const row = record(item);
if (!row) continue;
rawRows.push({ planet, rank: row.rank, rupa: row.total_rupas ?? row.rupas });
}
}
const parsed = rawRows.flatMap((row) => {
const planetName = text(row.planet);
const planet = planetName ? safeCelestialName(planetName) : null;
if (!planet) return [];
const rupa = finiteNumber(row.rupa);
const declaredRank = finiteNumber(row.rank);
return [{
planet,
declaredRank: declaredRank !== null && Number.isInteger(declaredRank) && declaredRank >= 1 && declaredRank <= 9
? declaredRank
: null,
rupa: rupa !== null && rupa >= 0 && rupa <= 100 ? rupa : null,
}];
});
const deduped = [...new Map(parsed.map((row) => [row.planet, row])).values()];
const ordered = [...deduped].sort((a, b) => {
if (a.declaredRank !== null && b.declaredRank !== null) return a.declaredRank - b.declaredRank;
return (b.rupa ?? -1) - (a.rupa ?? -1) || a.planet.localeCompare(b.planet);
});
// Ranks are re-derived from the ordering so a partial or duplicated engine
// rank can never produce two planets at the same rank.
return ordered.slice(0, 9).map((row, index): ReportShadbalaRankFact => ({
planet: row.planet,
rank: index + 1,
rupa: row.rupa,
}));
}
function readSavFacts(
workflow: JsonRecord,
ascendantSignIndex: number | null,
): Readonly<{ scores: ReportSavScoreFact[]; total: number | null }> {
const snapshot = readEvidenceSnapshot(workflow);
const modules = readChartModules(workflow);
const sav = record(record(modules.ashtakavarga)?.sav);
const strength = record(snapshot?.strength);
const rawScores = record(sav?.scores) ?? record(strength?.sav_scores);
const rawTotal = finiteNumber(sav?.total ?? strength?.sav_total);
const scores = new Map<number, number>();
for (const [key, value] of Object.entries(rawScores ?? {})) {
const score = finiteNumber(value);
if (score === null || score < 0 || score > 100) continue;
const houseKey = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null);
let house: number | null = null;
if (houseKey) {
const parsed = Number.parseInt(houseKey, 10);
house = parsed >= 1 && parsed <= 12 ? parsed : null;
} else if (ascendantSignIndex !== null) {
const index = signIndex(key);
house = index === null ? null : (((index - ascendantSignIndex) % 12) + 12) % 12 + 1;
}
if (house === null || scores.has(house)) continue;
scores.set(house, score);
}
return {
scores: [...scores.entries()].map(([house, score]) => ({ house, score })).sort((a, b) => a.house - b.house),
total: rawTotal !== null && rawTotal >= 0 && rawTotal <= 1000 ? rawTotal : null,
};
}
function readCurrentDasha(workflow: JsonRecord): ReportCurrentDashaFact | null {
const snapshot = readEvidenceSnapshot(workflow);
const modules = readChartModules(workflow);
const subPeriods = record(record(modules.dasha_sub_periods)?.current);
const timingVimshottari = record(record(snapshot?.timing)?.vimshottari);
const dashaCurrent = record(record(modules.dasha)?.current_dasha);
const candidates: Readonly<{
maha: unknown; antar: unknown; start: unknown; end: unknown;
}>[] = [];
if (subPeriods) {
const maha = record(subPeriods.mahadasha);
const antar = record(subPeriods.antardasha);
candidates.push({
maha: maha?.lord, antar: antar?.lord, start: maha?.start, end: maha?.end,
});
}
if (timingVimshottari) {
candidates.push({
maha: timingVimshottari.mahadasha,
antar: timingVimshottari.antardasha,
start: timingVimshottari.start,
end: timingVimshottari.end,
});
}
if (dashaCurrent) {
candidates.push({
maha: dashaCurrent.lord,
antar: record(dashaCurrent.antardasha)?.lord,
start: dashaCurrent.start,
end: dashaCurrent.end,
});
}
for (const candidate of candidates) {
const mahaName = text(candidate.maha);
const mahadasha = mahaName ? safeCelestialName(mahaName) : null;
const antarName = text(candidate.antar);
const antardasha = antarName ? safeCelestialName(antarName) : null;
const startText = text(candidate.start);
const endText = text(candidate.end);
const start = startText ? safeDashaDate(startText) : null;
const end = endText ? safeDashaDate(endText) : null;
if (mahadasha && start && end && Date.parse(start) < Date.parse(end)) {
return { mahadasha, antardasha, start, end };
}
}
return null;
}
function readConvergenceDomains(workflow: JsonRecord): string[] {
const snapshot = readEvidenceSnapshot(workflow);
const modules = readChartModules(workflow);
const rows = Array.isArray(record(snapshot?.timing)?.convergence_top_domains)
? record(snapshot?.timing)!.convergence_top_domains as unknown[]
: Array.isArray(record(modules.dasa_convergence)?.top_convergent_domains)
? record(modules.dasa_convergence)!.top_convergent_domains as unknown[]
: [];
const domains: string[] = [];
for (const item of rows) {
const label = safeDomainLabel(item);
if (label && !domains.includes(label)) domains.push(label);
if (domains.length >= 6) break;
}
return domains;
}
/**
* Server-side thematic narrative payloads. Present in the engine's own
* `_build_ai_prompt_pack`; the consultation API's chart prompt pack currently
* omits them, so this returns an empty map there instead of inventing text.
*/
const NARRATIVE_SNAPSHOT_THEMES: Readonly<Record<string, string>> = {
career_narrative: "career",
relationship_narrative: "marriage",
finance_narrative: "wealth",
};
const STRICT_MODULE_THEMES: Readonly<Record<string, string>> = {
career_strict_evidence: "career",
relationship_strict_evidence: "marriage",
finance_strict_evidence: "wealth",
};
type NarrativeDraft = { headline: string | null; strengths: string[]; risks: string[]; boundaries: string[] };
function readNarrativePayload(value: unknown): NarrativeDraft | null {
const row = record(value);
if (!row) return null;
const headline = safeSeedText(row.headline, 300);
const pick = (source: unknown) => stringArray(source)
.flatMap((item) => {
const safe = safeSeedText(item, 400);
return safe ? [safe] : [];
})
.slice(0, 8);
const strengths = pick(row.strengths);
const risks = pick(row.risks);
const boundaries = pick(row.boundaries);
if (!headline && strengths.length === 0 && risks.length === 0 && boundaries.length === 0) return null;
return { headline, strengths, risks, boundaries };
}
function readEngineNarratives(workflow: JsonRecord): Map<string, NarrativeDraft> {
const snapshot = readEvidenceSnapshot(workflow);
const modules = readChartModules(workflow);
const drafts = new Map<string, NarrativeDraft>();
for (const [key, theme] of Object.entries(NARRATIVE_SNAPSHOT_THEMES)) {
const draft = readNarrativePayload(snapshot?.[key]);
if (draft) drafts.set(theme, draft);
}
for (const [key, theme] of Object.entries(STRICT_MODULE_THEMES)) {
if (drafts.has(theme)) continue;
const draft = readNarrativePayload(record(modules[key])?.user_narrative);
if (draft) drafts.set(theme, draft);
}
return drafts;
}
/**
* Guided-topic copy. Only the three topics that map onto a report theme are
* used; `birth_time_rectification` is a product-flow topic, not a chart
* reading, and is deliberately skipped.
*/
const GUIDED_TOPIC_THEMES: Readonly<Record<string, string>> = {
relationship_partnership: "marriage",
career_direction: "career",
wealth_risk: "wealth",
};
function readGuidedTopicDrafts(workflow: JsonRecord): Map<string, NarrativeDraft> {
const modules = readChartModules(workflow);
const topics = Array.isArray(modules.guided_topics) ? modules.guided_topics : [];
const drafts = new Map<string, NarrativeDraft>();
for (const item of topics) {
const row = record(item);
if (!row) continue;
const theme = GUIDED_TOPIC_THEMES[text(row.id) ?? ""];
if (!theme || drafts.has(theme)) continue;
const headline = safeSeedText(row.title, 300);
const strengths = [row.reality_value, row.why_worth_exploring]
.flatMap((value) => {
const safe = safeSeedText(value, 400);
return safe ? [safe] : [];
});
const monthly = record(row.monthly_adjudication_summary);
const risks = [record(monthly?.friction_source)?.value]
.flatMap((value) => {
const safe = safeSeedText(value, 400);
return safe ? [safe] : [];
});
const boundaries = [record(monthly?.time_confidence)?.value]
.flatMap((value) => {
const safe = safeSeedText(value, 400);
return safe ? [safe] : [];
});
if (!headline && strengths.length === 0) continue;
drafts.set(theme, {
headline,
strengths: strengths.slice(0, 8),
risks: risks.slice(0, 8),
boundaries: boundaries.slice(0, 8),
});
}
return drafts;
}
/** SAV houses that actually carry the theme's meaning. */
const THEME_SAV_HOUSES: Readonly<Record<string, readonly number[]>> = {
career: [10, 6],
marriage: [7],
wealth: [2, 11],
education: [4, 5],
migration_home: [4, 12],
family: [4, 5],
health_pressure: [6],
timing: [1],
general: [1, 10],
};
function joinNames(values: readonly string[], limit: number): string {
return values.slice(0, limit).join("、");
}
/**
* Deterministic interpretive lines composed from the structured facts above.
* Every value is a closed-vocabulary planet/sign name, a house number or an
* engine-computed number — no model output and no free text from the request.
*/
function buildInterpretiveThemeLines(
theme: string,
facts: ReportInterpretiveFacts,
ascendantSign: string | null,
): string[] {
const lines: string[] = [];
const benefics = facts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet);
const malefics = facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet);
const yogakarakas = facts.functionalRoles.filter((role) => role.role === "yogakaraka");
if (ascendantSign && facts.functionalRoles.length > 0) {
lines.push(
`本命上升为 ${ascendantSign};按宫主判定的功能吉星为 ${joinNames(benefics, 4) || "无"},功能凶星为 ${joinNames(malefics, 4) || "无"}。`,
);
}
// Theme-specific lines come first so the two-line claim conclusion differs
// per chapter instead of repeating the chart identity everywhere.
const savHouses = THEME_SAV_HOUSES[theme] ?? [];
const savLine = facts.savScores
.filter((row) => savHouses.includes(row.house))
.map((row) => `第 ${row.house} 宫 ${row.score} 分`)
.join(",");
if (savLine) {
const total = facts.savTotal === null ? "" : `(全盘合计 ${facts.savTotal} 分)`;
lines.push(`本主题相关宫位的八分力总分:${savLine}${total}。`);
}
if (facts.currentDasha) {
const antar = facts.currentDasha.antardasha ? `、副运 ${facts.currentDasha.antardasha}` : "";
lines.push(
`当前处于 ${facts.currentDasha.mahadasha} 主运${antar},主运区间 ${facts.currentDasha.start} 至 ${facts.currentDasha.end}。`,
);
}
for (const karaka of yogakarakas.slice(0, 2)) {
const houses = karaka.ownedHouses.length > 0
? `同时主管第 ${karaka.ownedHouses.join("、")} 宫,`
: "";
lines.push(`${karaka.planet} ${houses}在本盘属 yogakaraka,是最值得依靠的行动主轴。`);
}
if (facts.yogas.length > 0) {
lines.push(`本盘已成立的组合:${joinNames(facts.yogas.map((yoga) => yoga.name), 4)}。`);
}
if (facts.shadbalaRanking.length >= 3) {
const strongest = facts.shadbalaRanking.slice(0, 3).map((row) => row.planet);
const weakest = facts.shadbalaRanking[facts.shadbalaRanking.length - 1];
lines.push(`六分力相对强弱:${joinNames(strongest, 3)} 居前,${weakest.planet} 垫底,说明发力顺序而非绝对好坏。`);
}
if (facts.convergenceDomains.length > 0) {
lines.push(`多套时间系统同时指向的领域:${joinNames(facts.convergenceDomains, 4)}。`);
}
return lines.flatMap((line) => {
const safe = safeSeedText(line, 400);
return safe ? [safe] : [];
});
}
/**
* Builds the server-owned, allowlisted ReportEvidenceBundle v2 from one
* workflow result per requested theme. A missing thematic layer produces a
@@ -1082,7 +1592,114 @@ export function buildReportEvidenceBundleV2(
}
}
// --- interpretive facts -------------------------------------------------
// Bound to receipts that already exist in the ledger; a layer without a
// receipt is skipped rather than given a synthetic ref.
const receiptIdFor = (aliases: readonly string[]): string | null => {
const receipt = [...receiptsByKey.values()].find((candidate) => (
candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, aliases)
));
return receipt?.id ?? null;
};
const functionalRef = receiptIdFor(["Functional Benefic/Malefic"]);
const yogaRef = receiptIdFor(["Yoga"]);
const ashtakavargaRef = receiptIdFor(["Ashtakavarga"]);
const vimshottariRef = receiptIdFor(["Vimshottari"]);
const d1Ref = receiptIdFor(["D1"]);
const baseAscendantSignIndex = base.packet.chart.ascendant
? signIndex(base.packet.chart.ascendant.sign)
: null;
const baseAscendantSign = base.packet.chart.ascendant
? safeChartSign(base.packet.chart.ascendant.sign)
: null;
const collectFirst = <T>(read: (workflow: JsonRecord) => readonly T[]): T[] => {
for (const { workflow } of packets) {
const rows = read(workflow);
if (rows.length > 0) return [...rows];
}
return [];
};
const savFacts = (() => {
for (const { workflow } of packets) {
const result = readSavFacts(workflow, baseAscendantSignIndex);
if (result.scores.length > 0 || result.total !== null) return result;
}
return { scores: [] as ReportSavScoreFact[], total: null as number | null };
})();
const currentDasha = (() => {
for (const { workflow } of packets) {
const dasha = readCurrentDasha(workflow);
if (dasha) return dasha;
}
return null;
})();
const interpretiveFacts: ReportInterpretiveFacts = {
yogas: yogaRef ? collectFirst((workflow) => readYogaFacts(workflow, yogaRef)) : [],
functionalRoles: functionalRef
? collectFirst((workflow) => readFunctionalRoles(workflow, functionalRef))
: [],
shadbalaRanking: collectFirst(readShadbalaRanking),
savScores: ashtakavargaRef ? savFacts.scores : [],
savTotal: ashtakavargaRef ? savFacts.total : null,
currentDasha: vimshottariRef ? currentDasha : null,
convergenceDomains: collectFirst(readConvergenceDomains),
};
// --- theme narrative seeds ----------------------------------------------
const engineNarratives = new Map<string, NarrativeDraft>();
const guidedDrafts = new Map<string, NarrativeDraft>();
for (const { workflow } of packets) {
for (const [theme, draft] of readEngineNarratives(workflow)) {
if (!engineNarratives.has(theme)) engineNarratives.set(theme, draft);
}
for (const [theme, draft] of readGuidedTopicDrafts(workflow)) {
if (!guidedDrafts.has(theme)) guidedDrafts.set(theme, draft);
}
}
const seedEvidenceRefs = [
...(d1Ref ? [d1Ref] : []),
...(functionalRef ? [functionalRef] : []),
...(yogaRef ? [yogaRef] : []),
...(ashtakavargaRef ? [ashtakavargaRef] : []),
...(vimshottariRef ? [vimshottariRef] : []),
];
const buildThemeSeed = (theme: string): ReportThemeNarrativeSeed | null => {
const engineDraft = engineNarratives.get(theme) ?? null;
const guidedDraft = guidedDrafts.get(theme) ?? null;
const interpretiveLines = buildInterpretiveThemeLines(theme, interpretiveFacts, baseAscendantSign);
// The engine's own thematic narrative headline is already conclusion
// shaped, so it wins. Without one, the first chart-specific interpretive
// line becomes the headline; guided-topic copy is generic framing and only
// ever lands in the tail of strengths.
const [headline, leadStrengths] = engineDraft?.headline
? [engineDraft.headline, interpretiveLines]
: interpretiveLines.length > 0
? [interpretiveLines[0], interpretiveLines.slice(1)]
: [guidedDraft?.headline ?? null, []];
if (!headline) return null;
const strengths = [
...leadStrengths,
...(engineDraft?.strengths ?? []),
...(guidedDraft?.strengths ?? []),
].slice(0, 8);
return {
theme,
headline,
strengths,
risks: [...(engineDraft?.risks ?? []), ...(guidedDraft?.risks ?? [])].slice(0, 8),
boundaries: [...(engineDraft?.boundaries ?? []), ...(guidedDraft?.boundaries ?? [])].slice(0, 8),
evidenceRefs: [...new Set(seedEvidenceRefs)].slice(0, 24),
};
};
/** Sentence-joins deterministic seed lines without gluing clauses together. */
const joinSeedSentences = (parts: readonly string[]): string => parts
.map((part) => (/[。!?;.!?;]$/.test(part) ? part : `${part}。`))
.join("");
const claimCards: ReportClaimCard[] = [];
const themeNarrativeSeeds: ReportThemeNarrativeSeed[] = [];
const blockedSections: ReportEvidenceBundleV2["blockedSections"][number][] = [];
for (const plan of requestedPlans) {
const matched: TechniqueExecutionReceipt[] = [];
@@ -1113,26 +1730,103 @@ export function buildReportEvidenceBundleV2(
}
const uniqueMatched = [...new Map(matched.map((receipt) => [receipt.id, receipt])).values()];
const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified");
const assertionLevel = allVerified && uniqueMatched.length >= 2
// The assertionLevel derivation is unchanged: richer content must never
// raise certainty. Only the seed-less fallback may lower it.
const derivedLevel: ClaimStatus = allVerified && uniqueMatched.length >= 2
? "multi_system_consensus"
: allVerified
? "single_system_inference"
: "parameter_sensitive";
const seed = buildThemeSeed(plan.theme);
if (seed) themeNarrativeSeeds.push(seed);
const conclusion = seed
? joinSeedSentences([seed.headline, ...seed.strengths.slice(0, 2)]).slice(0, 1200)
: `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`;
const assertionLevel: ClaimStatus = seed
? derivedLevel
: derivedLevel === "multi_system_consensus"
? "single_system_inference"
: derivedLevel;
const themeSlug = evidenceSlug(plan.theme, "theme");
const receiptFactValue = (technique: string): string => {
if (technique === "D1" && baseAscendantSign) {
const roles = interpretiveFacts.functionalRoles.length > 0
? `;功能吉星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet), 4) || "无"},功能凶星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet), 4) || "无"}`
: "";
return `本命上升 ${baseAscendantSign}${roles}`;
}
if (technique === "Vimshottari" && interpretiveFacts.currentDasha) {
const antar = interpretiveFacts.currentDasha.antardasha
? `、副运 ${interpretiveFacts.currentDasha.antardasha}`
: "";
return `当前 ${interpretiveFacts.currentDasha.mahadasha} 主运${antar}(${interpretiveFacts.currentDasha.start} 至 ${interpretiveFacts.currentDasha.end})`;
}
if (technique === "Yoga" && interpretiveFacts.yogas.length > 0) {
return `已成立组合:${joinNames(interpretiveFacts.yogas.map((yoga) => yoga.name), 4)}`;
}
if (technique === "Ashtakavarga" && interpretiveFacts.savScores.length > 0) {
const houses = THEME_SAV_HOUSES[plan.theme] ?? [];
const scored = interpretiveFacts.savScores
.filter((row) => houses.includes(row.house))
.map((row) => `第 ${row.house} 宫 ${row.score} 分`)
.join(",");
const total = interpretiveFacts.savTotal === null ? "" : `,全盘合计 ${interpretiveFacts.savTotal} 分`;
return `八分力:${scored || "本主题宫位未取得分值"}${total}`;
}
if (technique === "Functional Benefic/Malefic" && interpretiveFacts.functionalRoles.length > 0) {
const karaka = interpretiveFacts.functionalRoles.find((role) => role.role === "yogakaraka");
return `功能吉凶已判定${karaka ? `,${karaka.planet} 为 yogakaraka` : ""}`;
}
return `${technique} 已执行并纳入本主题证据计划`;
};
const supportingFacts = uniqueMatched.map((receipt, index) => ({
id: `ev-fact-${themeSlug}-${index + 1}`,
label: receipt.technique.slice(0, 160),
value: receiptFactValue(receipt.technique).slice(0, 800),
evidenceRef: receipt.id,
status: receipt.status,
}));
// Universal interpretive layers ride along even when the theme's minimum
// evidence group does not require them; they stay bound to their own
// receipt so nothing is attributed to a technique that did not run.
const matchedIds = new Set(uniqueMatched.map((receipt) => receipt.id));
const universalLayers: readonly Readonly<[string | null, string]>[] = [
[functionalRef, "Functional Benefic/Malefic"],
[yogaRef, "Yoga"],
[ashtakavargaRef, "Ashtakavarga"],
[vimshottariRef, "Vimshottari"],
];
for (const [ref, technique] of universalLayers) {
if (!ref || matchedIds.has(ref)) continue;
const receipt = [...receiptsByKey.values()].find((candidate) => candidate.id === ref);
if (!receipt) continue;
const value = receiptFactValue(technique);
if (value === `${technique} 已执行并纳入本主题证据计划`) continue;
supportingFacts.push({
id: `ev-fact-${themeSlug}-extra-${evidenceSlug(technique, "layer")}`,
label: technique.slice(0, 160),
value: value.slice(0, 800),
evidenceRef: ref,
status: receipt.status,
});
}
const counterFactRef = uniqueMatched[0];
const counterFacts = counterFactRef
? (seed?.risks ?? []).slice(0, 8).map((risk, index) => ({
id: `ev-cfact-${themeSlug}-${index + 1}`,
label: "风险边界",
value: risk.slice(0, 800),
evidenceRef: counterFactRef.id,
status: counterFactRef.status,
}))
: [];
claimCards.push({
id: `ev-claim-${evidenceSlug(plan.theme, "theme")}`,
id: `ev-claim-${themeSlug}`,
theme: plan.theme,
section: plan.section,
conclusion: `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`,
supportingFacts: uniqueMatched.map((receipt, index) => ({
id: `ev-fact-${evidenceSlug(plan.theme, "theme")}-${index + 1}`,
label: receipt.technique,
value: receipt.technique === "D1" && base.packet.chart.ascendant
? `D1 上升为 ${base.packet.chart.ascendant.sign},本次基础盘已建立`
: `${receipt.technique} 已执行并纳入本主题证据计划`,
evidenceRef: receipt.id,
status: receipt.status,
})),
counterFacts: [],
conclusion,
supportingFacts: supportingFacts.slice(0, 40),
counterFacts,
executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id),
assertionLevel,
timingBoundary: input.subject.birthTimeStatus === "confirmed"
@@ -1200,6 +1894,8 @@ export function buildReportEvidenceBundleV2(
skill: input.skillSnapshot,
charts: [...chartsById.values()],
claimCards,
interpretiveFacts,
themeNarrativeSeeds,
blockedSections,
conflicts,
executionLedger,
@@ -2175,11 +2871,36 @@ export function filterReportEvidenceBundleForSection(
source: ReportEvidenceBundleV2,
section: ReportSectionPlanEntry,
): ReportEvidenceBundleV2 {
const requestedRefs = new Set(section.evidenceRefs);
const claim = source.claimCards.find((card) => card.theme === section.theme);
const seeds = source.themeNarrativeSeeds.filter((seed) => seed.theme === section.theme);
// The universal interpretive layers (functional benefic/malefic, yogas,
// ashtakavarga, current dasha) are chart-wide, not theme-specific, so their
// receipts ride along with every section. Everything else still comes from
// the plan's own evidenceRefs.
const sourceRefIds = new Set(source.evidenceRefs.map((ref) => ref.id));
const interpretiveRefs = [
...source.interpretiveFacts.yogas.map((yoga) => yoga.evidenceRef),
...source.interpretiveFacts.functionalRoles.map((role) => role.evidenceRef),
...seeds.flatMap((seed) => seed.evidenceRefs),
].filter((ref) => sourceRefIds.has(ref));
const requestedRefs = new Set([...section.evidenceRefs, ...interpretiveRefs]);
const ledger = source.executionLedger.filter((receipt) => requestedRefs.has(receipt.id));
const evidenceRefs = source.evidenceRefs.filter((ref) => requestedRefs.has(ref.id));
const selectedRefIds = new Set(evidenceRefs.map((ref) => ref.id));
const savHouses = THEME_SAV_HOUSES[section.theme ?? ""] ?? [];
const interpretiveFacts: ReportInterpretiveFacts = {
yogas: source.interpretiveFacts.yogas.filter((yoga) => selectedRefIds.has(yoga.evidenceRef)),
functionalRoles: source.interpretiveFacts.functionalRoles
.filter((role) => selectedRefIds.has(role.evidenceRef)),
shadbalaRanking: source.interpretiveFacts.shadbalaRanking,
// Only the houses this chapter actually reads survive the trim.
savScores: savHouses.length > 0
? source.interpretiveFacts.savScores.filter((row) => savHouses.includes(row.house))
: source.interpretiveFacts.savScores,
savTotal: source.interpretiveFacts.savTotal,
currentDasha: source.interpretiveFacts.currentDasha,
convergenceDomains: source.interpretiveFacts.convergenceDomains,
};
const charts = source.charts.filter((chart) => (chart.id === "D1"
|| ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id)));
const conflicts = source.conflicts.filter((conflict) => (
@@ -2199,6 +2920,11 @@ export function filterReportEvidenceBundleForSection(
supportingFacts: claim.supportingFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
counterFacts: claim.counterFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
}] : [],
interpretiveFacts,
themeNarrativeSeeds: seeds.map((seed) => ({
...seed,
evidenceRefs: seed.evidenceRefs.filter((ref) => selectedRefIds.has(ref)),
})),
blockedSections: source.blockedSections.filter((blocked) => blocked.theme === section.theme),
conflicts,
executionLedger: ledger,
@@ -121,6 +121,76 @@ export type ReportAnswerPolicy = Readonly<{
)[];
}>;
/**
* Closed vocabulary for yoga families. The engine emits dozens of raw rule
* categories; the extraction layer folds them into this closed set and maps
* anything unrecognised to "other" so the bundle never carries a free string.
*/
export const REPORT_YOGA_CATEGORIES = [
"raja", "dhana", "mahapurusha", "nabhasa", "lunar", "solar",
"relationship", "progeny", "education", "health", "spiritual",
"affliction", "conjunction", "auspicious", "special", "extended", "other",
] as const;
export type ReportYogaCategory = (typeof REPORT_YOGA_CATEGORIES)[number];
export const REPORT_FUNCTIONAL_ROLES = ["benefic", "malefic", "neutral", "yogakaraka"] as const;
export type ReportFunctionalRole = (typeof REPORT_FUNCTIONAL_ROLES)[number];
export type ReportYogaFact = Readonly<{
name: string;
category: ReportYogaCategory;
planets: readonly string[];
evidenceRef: string;
}>;
export type ReportFunctionalRoleFact = Readonly<{
planet: string;
role: ReportFunctionalRole;
ownedHouses: readonly number[];
evidenceRef: string;
}>;
export type ReportShadbalaRankFact = Readonly<{
planet: string;
rank: number;
rupa: number | null;
}>;
export type ReportSavScoreFact = Readonly<{ house: number; score: number }>;
export type ReportCurrentDashaFact = Readonly<{
mahadasha: string;
antardasha: string | null;
start: string;
end: string;
}>;
/**
* Server-computed interpretive facts. Every entry is a closed vocabulary value
* or a bounded number projected deterministically from the engine response;
* no model output and no user text may ever reach these fields.
*/
export type ReportInterpretiveFacts = Readonly<{
yogas: readonly ReportYogaFact[];
functionalRoles: readonly ReportFunctionalRoleFact[];
shadbalaRanking: readonly ReportShadbalaRankFact[];
savScores: readonly ReportSavScoreFact[];
savTotal: number | null;
currentDasha: ReportCurrentDashaFact | null;
convergenceDomains: readonly string[];
}>;
/**
* Per-theme narrative seed. Free text here is only ever copied from a
* server-side generator (thematic narrative payloads / guided-topic copy) or
* deterministically composed from the interpretive facts above. Chat history,
* the user question and model output are structurally excluded.
*/
export type ReportThemeNarrativeSeed = Readonly<{
theme: string;
headline: string;
strengths: readonly string[];
risks: readonly string[];
boundaries: readonly string[];
evidenceRefs: readonly string[];
}>;
export type ReportEvidenceBundleV2 = Readonly<{
schemaVersion: "report_evidence_bundle.v2";
bundleHash: string;
@@ -132,6 +202,8 @@ export type ReportEvidenceBundleV2 = Readonly<{
skill: SkillPackageIdentity;
charts: readonly ReportChartFact[];
claimCards: readonly ReportClaimCard[];
interpretiveFacts: ReportInterpretiveFacts;
themeNarrativeSeeds: readonly ReportThemeNarrativeSeed[];
blockedSections: readonly BlockedSection[];
conflicts: readonly EvidenceConflict[];
executionLedger: readonly TechniqueExecutionReceipt[];
@@ -238,6 +310,56 @@ const planetSchema = z.object({
house: z.number().int().min(1).max(12).nullable(),
retrograde: z.boolean().nullable(),
}).strict();
/** Bounded, control-character-free interpretive prose. */
const seedTextSchema = (max: number) => z.string().min(1).max(max)
.refine((value) => !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(value), "report_bundle_seed_text_control_char");
const yogaNameSchema = z.string().min(1).max(80)
.regex(/^[\p{L}\p{N} ()/·,.'’++-]{1,80}$/u, "report_bundle_yoga_name_invalid");
const yogaCategorySchema = z.enum(REPORT_YOGA_CATEGORIES);
const functionalRoleSchema = z.enum(REPORT_FUNCTIONAL_ROLES);
const houseNumberSchema = z.number().int().min(1).max(12);
const interpretiveFactsSchema = z.object({
yogas: z.array(z.object({
name: yogaNameSchema,
category: yogaCategorySchema,
planets: z.array(celestialSchema).max(9),
evidenceRef: evidenceIdSchema,
}).strict()).max(40),
functionalRoles: z.array(z.object({
planet: celestialSchema,
role: functionalRoleSchema,
ownedHouses: z.array(houseNumberSchema).max(12),
evidenceRef: evidenceIdSchema,
}).strict()).max(12),
shadbalaRanking: z.array(z.object({
planet: celestialSchema,
rank: z.number().int().min(1).max(9),
rupa: z.number().finite().min(0).max(100).nullable(),
}).strict()).max(9),
savScores: z.array(z.object({
house: houseNumberSchema,
score: z.number().finite().min(0).max(100),
}).strict()).max(12),
savTotal: z.number().finite().min(0).max(1000).nullable(),
currentDasha: z.object({
mahadasha: celestialSchema,
antardasha: celestialSchema.nullable(),
start: dashaDateSchema,
end: dashaDateSchema,
}).strict().nullable(),
convergenceDomains: z.array(seedTextSchema(80)).max(6),
}).strict();
const themeNarrativeSeedSchema = z.object({
theme: idSchema,
headline: seedTextSchema(300),
strengths: z.array(seedTextSchema(400)).max(8),
risks: z.array(seedTextSchema(400)).max(8),
boundaries: z.array(seedTextSchema(400)).max(8),
evidenceRefs: z.array(evidenceIdSchema).max(24),
}).strict();
const factSchema = z.object({
id: evidenceIdSchema,
label: z.string().min(1).max(160),
@@ -295,6 +417,8 @@ export const reportEvidenceBundleV2Schema = z.object({
timingBoundary: z.string().max(200).nullable(),
verificationQuestions: z.array(z.string().min(1).max(300)).max(12),
}).strict()).max(48),
interpretiveFacts: interpretiveFactsSchema,
themeNarrativeSeeds: z.array(themeNarrativeSeedSchema).max(12),
blockedSections: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
@@ -327,6 +451,23 @@ export const reportEvidenceBundleV2Schema = z.object({
}).strict(),
}).strict();
/**
* Empty interpretive-fact block. Used by callers that build a bundle without an
* engine snapshot (tests, legacy fixtures); a missing layer is represented as
* empty, never fabricated.
*/
export function emptyReportInterpretiveFacts(): ReportInterpretiveFacts {
return {
yogas: [],
functionalRoles: [],
shadbalaRanking: [],
savScores: [],
savTotal: null,
currentDasha: null,
convergenceDomains: [],
};
}
function canonicalSerialize(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(canonicalSerialize).join(",")}]`;
@@ -373,6 +514,23 @@ function sortedBundleContent(bundle: Omit<ReportEvidenceBundleV2, "bundleHash">)
executedTechniqueRefs: sortedUnique(card.executedTechniqueRefs),
verificationQuestions: sortedUnique(card.verificationQuestions),
})).sort((a, b) => a.id.localeCompare(b.id)),
interpretiveFacts: {
...bundle.interpretiveFacts,
yogas: [...bundle.interpretiveFacts.yogas]
.map((yoga) => ({ ...yoga, planets: sortedUnique(yoga.planets) }))
.sort((a, b) => `${a.name}:${a.category}`.localeCompare(`${b.name}:${b.category}`)),
functionalRoles: [...bundle.interpretiveFacts.functionalRoles]
.map((role) => ({ ...role, ownedHouses: [...new Set(role.ownedHouses)].sort((a, b) => a - b) }))
.sort((a, b) => a.planet.localeCompare(b.planet)),
shadbalaRanking: [...bundle.interpretiveFacts.shadbalaRanking].sort((a, b) => (
a.rank - b.rank || a.planet.localeCompare(b.planet)
)),
savScores: [...bundle.interpretiveFacts.savScores].sort((a, b) => a.house - b.house),
convergenceDomains: sortedUnique(bundle.interpretiveFacts.convergenceDomains),
},
themeNarrativeSeeds: [...bundle.themeNarrativeSeeds]
.map((seed) => ({ ...seed, evidenceRefs: sortedUnique(seed.evidenceRefs) }))
.sort((a, b) => a.theme.localeCompare(b.theme)),
blockedSections: [...bundle.blockedSections].map((section) => ({
...section,
missingTechniqueRefs: sortedUnique(section.missingTechniqueRefs),
@@ -449,6 +607,50 @@ export function validateReportEvidenceBundleV2(bundle: ReportEvidenceBundleV2):
}
}
}
// Interpretive facts and narrative seeds never widen the evidence surface:
// every ref must already exist in evidenceRefs, and every seed must belong to
// a requested theme. Anything else fails closed.
const facts = parsed.interpretiveFacts;
for (const yoga of facts.yogas) {
if (!evidenceRefs.has(yoga.evidenceRef)) {
throw new Error(`report_bundle_invalid_interpretive_ref:${yoga.evidenceRef}`);
}
}
const seenRolePlanets = new Set<string>();
for (const role of facts.functionalRoles) {
if (!evidenceRefs.has(role.evidenceRef)) {
throw new Error(`report_bundle_invalid_interpretive_ref:${role.evidenceRef}`);
}
if (seenRolePlanets.has(role.planet)) throw new Error(`report_bundle_duplicate_functional_role:${role.planet}`);
seenRolePlanets.add(role.planet);
}
const seenRanks = new Set<number>();
const seenRankPlanets = new Set<string>();
for (const entry of facts.shadbalaRanking) {
if (seenRanks.has(entry.rank)) throw new Error(`report_bundle_duplicate_shadbala_rank:${entry.rank}`);
if (seenRankPlanets.has(entry.planet)) throw new Error(`report_bundle_duplicate_shadbala_planet:${entry.planet}`);
seenRanks.add(entry.rank);
seenRankPlanets.add(entry.planet);
}
const seenSavHouses = new Set<number>();
for (const entry of facts.savScores) {
if (seenSavHouses.has(entry.house)) throw new Error(`report_bundle_duplicate_sav_house:${entry.house}`);
seenSavHouses.add(entry.house);
}
if (facts.currentDasha && Date.parse(facts.currentDasha.start) >= Date.parse(facts.currentDasha.end)) {
throw new Error("report_bundle_current_dasha_invalid");
}
const seenSeedThemes = new Set<string>();
for (const seed of parsed.themeNarrativeSeeds) {
if (seenSeedThemes.has(seed.theme)) throw new Error(`report_bundle_duplicate_seed_theme:${seed.theme}`);
seenSeedThemes.add(seed.theme);
if (!parsed.requestedThemes.includes(seed.theme)) {
throw new Error(`report_bundle_seed_theme_not_requested:${seed.theme}`);
}
for (const ref of seed.evidenceRefs) {
if (!evidenceRefs.has(ref)) throw new Error(`report_bundle_invalid_seed_ref:${ref}`);
}
}
const { bundleHash: _bundleHash, ...content } = parsed;
void _bundleHash;
const expectedHash = computeReportEvidenceBundleHash(content);
+1 -1
View File
@@ -151,7 +151,7 @@ export type PersonalReportAgentTelemetry = Readonly<{
const personalReportInstructions = `You are the dedicated Personal Report writer for a Vedic astrology product. You write long structured report sections in Simplified Chinese. This is a report, not a chat: do not use chat-style short paragraphs, do not ask follow-up questions, and do not append hidden blocks.
The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those claim cards. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details.
The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Its interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores, currentDasha, convergenceDomains) and themeNarrativeSeeds are the supporting fact layer: you may quote, order, group and explain them, but they never authorise a conclusion that the matching claimCard does not already state. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those fields. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details.
Truth boundaries are hard output contracts:
- A blockedSections entry must be disclosed honestly and must not be rewritten as a conclusion. A partial or blocked executionLedger receipt can never be upgraded to verified or consensus. Never raise a claimCard assertionLevel; output the same or a stricter level.
@@ -4,6 +4,7 @@ import test from "node:test";
import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts";
import type { ReportDocumentV2 } from "../src/lib/personal-report-contract.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
type BlockedSection,
type EvidenceRefStatus,
@@ -133,6 +134,10 @@ function makeBundle(input: Readonly<{
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,
@@ -3,6 +3,7 @@ import test from "node:test";
import { safeParseServerReportDocument, computeEvidenceHash } from "../src/lib/personal-report-contract.server-core.ts";
import {
computeReportEvidenceBundleHash,
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
@@ -828,6 +829,10 @@ test("stable codes include the request-conflict mapping", () => {
function bundleV2Fixture(): 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",
@@ -5,6 +5,7 @@ import {
validatePersonalReportSectionPlan,
} from "../src/lib/personal-report-plan.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
@@ -12,6 +13,10 @@ import {
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",
@@ -0,0 +1,519 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildReportEvidenceBundleV2,
filterReportEvidenceBundleForSection,
} from "../src/lib/personal-report-generation.ts";
import { buildPersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
// ---------------------------------------------------------------------------
// Engine response contract fixture (2026-09-01 snapshot).
//
// Synthetic data only — no real birth details. The shapes below mirror what a
// live POST /api/consultation_workflow actually returns today:
// chart.ai_prompt_pack.evidence_snapshot.functional_benefic_malefic present
// chart.ai_prompt_pack.evidence_snapshot.strength.shadbala_ranking present
// ({planet, rupas, level})
// chart.modules.ashtakavarga.sav.{scores,total} present
// (scores keyed by SIGN, not house)
// chart.modules.dasha_sub_periods.current.{mahadasha,antardasha} present
// chart.yogas present
// chart.modules.yogas.yogas candidate
// rule table with hit:true/false
// chart.modules.guided_topics present
// evidence_snapshot.timing.vimshottari / .convergence_top_domains ABSENT
// evidence_snapshot.strength.sav_scores ABSENT
// evidence_snapshot.{career,relationship,finance}_narrative ABSENT
// The narrative payloads and the sign-keyed SAV fallback are still covered here
// because the engine's own `_build_ai_prompt_pack` emits them on other routes;
// the extraction layer must read either shape without inventing data.
// ---------------------------------------------------------------------------
const SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
const ASC_INDEX = 3; // Cancer
type JsonRecord = Record<string, unknown>;
function basePlanets(): JsonRecord {
return {
Sun: { sign: "Cancer", degree_raw: 100.5, house: 1, retrograde: false },
Moon: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: false },
Mars: { sign: "Virgo", degree_raw: 160.5, house: 3, retrograde: false },
Mercury: { sign: "Libra", degree_raw: 190.5, house: 4, retrograde: true },
Jupiter: { sign: "Scorpio", degree_raw: 220.5, house: 5, retrograde: false },
Venus: { sign: "Sagittarius", degree_raw: 250.5, house: 6, retrograde: false },
Saturn: { sign: "Capricorn", degree_raw: 280.5, house: 7, retrograde: true },
Rahu: { sign: "Aquarius", degree_raw: 310.5, house: 8, retrograde: true },
Ketu: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: true },
};
}
function baseHouses(): JsonRecord {
const houses: JsonRecord = {};
for (let index = 0; index < 12; index += 1) {
houses[`house_${index + 1}`] = {
cusp_sign: SIGNS[(ASC_INDEX + index) % 12],
cusp_degree: 100.5 + index * 30,
};
}
return houses;
}
const AVAILABLE_LAYERS = [
"D1", "D2", "D11", "D24", "yogas", "ashtakavarga",
"functional_benefic_malefic", "vimshottari",
];
function machineSections(): JsonRecord {
return {
D1: { status: "used", source_path: "chart.planets+chart.ascendant" },
D2: { status: "used", source_path: "modules.varga_full.D2" },
D11: { status: "used", source_path: "modules.varga_full.D11" },
// Verified layers make the education theme reach multi_system_consensus so
// the seed-less fallback can be observed lowering it.
D24: { status: "verified", source_path: "modules.varga_full.D24" },
yogas: { status: "used", source_path: "modules.yogas" },
ashtakavarga: { status: "used", source_path: "modules.ashtakavarga" },
functional_benefic_malefic: { status: "used", source_path: "modules.functional_benefic_malefic" },
vimshottari: { status: "verified", source_path: "modules.dasha" },
planet_degrees: { status: "used", source_path: "chart.planets" },
house_degrees: { status: "used", source_path: "chart.houses" },
};
}
type FixtureOptions = Readonly<{
interpretive?: boolean;
financeNarrative?: boolean;
pollutedNames?: boolean;
}>;
function workflowFixture(options: FixtureOptions = {}): JsonRecord {
const interpretive = options.interpretive !== false;
const chart: JsonRecord = {
ascendant: { sign: "Cancer", degree: 10.5, degree_in_sign: 10.5 },
planets: basePlanets(),
houses: baseHouses(),
dasha: {
mahadashas: [
{ lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
{ lord: "Jupiter", start: "2029-11-11", end: "2045-11-11" },
],
},
modules: {},
};
const modules = chart.modules as JsonRecord;
if (interpretive) {
chart.yogas = [
{ name: "Amala Yoga", planets: ["Mercury"], category: "extended" },
// Duplicate name from the second detection pass must collapse to one row.
{ name: "Amala Yoga", planets: [], cat: "extended", desc: "..." },
{ name: "Gaja Kesari Yoga", planets: ["Jupiter", "Moon"], category: "raja" },
// Unknown category folds into "other"; unknown planets are dropped.
{ name: "Budha Aditya Yoga", planets: ["Mercury", "Nibiru"], category: "wholly_invented" },
// No usable name: dropped rather than guessed.
{ name: " ", planets: ["Sun"], category: "raja" },
];
modules.yogas = {
status: "ok",
count: 3,
yogas: [
{ name: "Sunaphaa Yoga", rule_id: "bvr_002", hit: false },
{ name: "Chandra Mangala Yoga", rule_id: "bvr_010", hit: true, category: "dhana" },
],
};
modules.ashtakavarga = {
sav: {
// Real engine keys SAV by sign; the projection maps sign -> whole-sign house.
scores: {
Cancer: 24, Leo: 27, Virgo: 28, Libra: 25, Scorpio: 35, Sagittarius: 30,
Capricorn: 26, Aquarius: 28, Pisces: 32, Aries: 30, Taurus: 28, Gemini: 24,
},
total: 337,
},
};
modules.dasha_sub_periods = {
current: {
mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
antardasha: { lord: "Sun", start: "2026-05-30", end: "2027-04-24" },
},
};
modules.guided_topics = [
{
id: "wealth_risk",
title: "财富、借贷和交易风险怎样用数据拆开",
reality_value: "帮助用户把收入、现金流、借贷、买卖和投资风险分开判断。",
why_worth_exploring: "财富主题必须同时看2宫、11宫、D2/D11 与时间层。",
evidence: [{ label: "required vargas", value: "D2 / D11" }],
confidence: "low",
},
// Product-flow topic: intentionally not mapped onto a report theme.
{ id: "birth_time_rectification", title: "出生时间是否需要微调", reality_value: "x", why_worth_exploring: "y" },
];
chart.ai_prompt_pack = {
schema_version: 1,
evidence_snapshot: {
functional_benefic_malefic: {
status: "used",
ascendant: "Cancer",
functional_benefics: options.pollutedNames
? ["Jupiter", "Moon", "Nibiru", "'; DROP TABLE --"]
: ["Jupiter", "Moon"],
functional_malefics: ["Mercury", "Saturn", "Venus"],
functional_neutrals: ["Sun"],
yogakarakas: ["Mars"],
owned_houses: {
Jupiter: [6, 9], Mars: [5, 10], Mercury: [3, 12], Moon: [1],
Saturn: [7, 8], Sun: [2], Venus: [4, 11], Nibiru: [13],
},
},
strength: {
shadbala_ranking: [
{ planet: "Venus", rupas: 8.35, level: "极强" },
{ planet: "Saturn", rupas: 7.56, level: "极强" },
{ planet: "Mercury", rupas: 7.03, level: "充足" },
{ planet: "Jupiter", rupas: 6.97, level: "充足" },
{ planet: "Sun", rupas: 6.42, level: "强" },
{ planet: "Moon", rupas: 6.42, level: "充足" },
{ planet: "Mars", rupas: 5.79, level: "充足" },
{ planet: "Nibiru", rupas: 99, level: "极强" },
],
},
...(options.financeNarrative
? {
finance_narrative: {
headline: `财富承诺已经成立,${"细".repeat(600)}`,
strengths: ["二宫与十一宫同时得到吉星支持,收入结构偏向可持续积累。"],
risks: [
"支出节奏比收入节奏更快,现金留存能力弱于账面收入。",
"VedAstro 官方财富日窗口尚未取得验证,不能作为时间依据。",
],
boundaries: ["未完成本命财富承诺与双重大运交叉前,不得写成高置信度结论。"],
},
}
: {}),
},
};
}
return {
success: true,
chart,
consumer_context: {
route: "general",
core_status: "ready",
available_layers: AVAILABLE_LAYERS,
missing_route_layers: [],
hard_blockers: [],
answer_policy: { can_answer_direction: true, can_answer_precise_timing: false },
},
machine_evidence_packet: { sections: machineSections(), conflicts: [] },
};
}
const THEMES = ["general", "wealth", "education"] as const;
function buildBundle(options: FixtureOptions = {}): ReportEvidenceBundleV2 {
const workflow = workflowFixture(options);
return buildReportEvidenceBundleV2({
workflows: THEMES.map((theme) => ({ theme, workflow })),
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
requestedThemes: [...THEMES],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-personal-report",
version: "1.0.0",
sha256: "a".repeat(64),
sourceCommit: null,
},
});
}
// ---------------------------------------------------------------------------
// Task 0/1: the extraction layer reads every field the contract snapshot has
// ---------------------------------------------------------------------------
test("bundle carries the interpretive layers the engine actually computes", () => {
const bundle = buildBundle();
const facts = bundle.interpretiveFacts;
// Yogas: chart.yogas detections + modules.yogas rows with hit:true only.
assert.deepEqual(
facts.yogas.map((yoga) => `${yoga.name}/${yoga.category}`).sort(),
[
"Amala Yoga/extended",
"Budha Aditya Yoga/other",
"Chandra Mangala Yoga/dhana",
"Gaja Kesari Yoga/raja",
],
);
assert.ok(!facts.yogas.some((yoga) => yoga.name === "Sunaphaa Yoga"), "hit:false rules are not detections");
const budha = facts.yogas.find((yoga) => yoga.name === "Budha Aditya Yoga")!;
assert.deepEqual([...budha.planets], ["Mercury"], "unknown celestial names are dropped");
assert.ok(facts.yogas.every((yoga) => yoga.evidenceRef === "ev-tech-yoga"));
// Functional roles: yogakaraka wins over the benefic/malefic lists.
assert.equal(facts.functionalRoles.length, 7);
const mars = facts.functionalRoles.find((role) => role.planet === "Mars")!;
assert.equal(mars.role, "yogakaraka");
assert.deepEqual([...mars.ownedHouses], [5, 10]);
assert.deepEqual(
facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet),
["Mercury", "Saturn", "Venus"],
);
// Shadbala: ranks are re-derived from the ordering, unknown planets dropped.
assert.deepEqual(
facts.shadbalaRanking.map((row) => [row.planet, row.rank]),
// Sun and Moon tie at 6.42 rupas; the tie breaks alphabetically so the
// ranking (and therefore the bundle hash) is reproducible.
[["Venus", 1], ["Saturn", 2], ["Mercury", 3], ["Jupiter", 4], ["Moon", 5], ["Sun", 6], ["Mars", 7]],
);
assert.equal(facts.shadbalaRanking[0].rupa, 8.35);
// SAV: sign-keyed engine scores become whole-sign house scores.
assert.equal(facts.savScores.length, 12);
assert.equal(facts.savScores.find((row) => row.house === 1)!.score, 24, "house 1 == ascendant sign Cancer");
assert.equal(facts.savScores.find((row) => row.house === 2)!.score, 27, "house 2 == Leo");
assert.equal(facts.savTotal, 337);
assert.deepEqual(facts.currentDasha, {
mahadasha: "Rahu", antardasha: "Sun", start: "2011-11-11", end: "2029-11-11",
});
// Absent in the live consultation contract: reported as empty, never faked.
assert.deepEqual([...facts.convergenceDomains], []);
});
test("interpretive extraction rejects polluted planet names and over-long narrative text", () => {
const bundle = buildBundle({ pollutedNames: true, financeNarrative: true });
const benefics = bundle.interpretiveFacts.functionalRoles
.filter((role) => role.role === "benefic")
.map((role) => role.planet);
assert.deepEqual(benefics, ["Jupiter", "Moon"], "non-allowlisted names never enter the bundle");
const wealthSeed = bundle.themeNarrativeSeeds.find((seed) => seed.theme === "wealth")!;
assert.equal(wealthSeed.headline.length, 300, "headline is truncated to the schema bound");
assert.ok(wealthSeed.headline.startsWith("财富承诺已经成立"));
assert.deepEqual(
wealthSeed.risks,
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
"seed lines naming an external provider are dropped, not rewritten",
);
});
test("bundle hash is stable under interpretive field ordering", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const shuffled = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: {
...content.interpretiveFacts,
yogas: [...content.interpretiveFacts.yogas].reverse(),
functionalRoles: [...content.interpretiveFacts.functionalRoles].reverse(),
shadbalaRanking: [...content.interpretiveFacts.shadbalaRanking].reverse(),
savScores: [...content.interpretiveFacts.savScores].reverse(),
},
themeNarrativeSeeds: [...content.themeNarrativeSeeds].reverse().map((seed) => ({
...seed,
evidenceRefs: [...seed.evidenceRefs].reverse(),
})),
});
assert.equal(shuffled.bundleHash, bundle.bundleHash);
});
// ---------------------------------------------------------------------------
// Task 1: fail-closed validation of the new fields
// ---------------------------------------------------------------------------
test("validation fails closed on out-of-bounds interpretive facts and seeds", () => {
const bundle = buildBundle();
const mutate = (patch: Partial<ReportEvidenceBundleV2>) => (
{ ...bundle, ...patch } as ReportEvidenceBundleV2
);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
yogas: [{ name: "Ghost Yoga", category: "raja", planets: [], evidenceRef: "ev-tech-not-real" }],
},
})), /report_bundle_invalid_interpretive_ref/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
shadbalaRanking: [
{ planet: "Venus", rank: 1, rupa: 1 },
{ planet: "Saturn", rank: 1, rupa: 1 },
],
},
})), /report_bundle_duplicate_shadbala_rank/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
functionalRoles: [{
planet: "Mars",
// @ts-expect-error unknown role must be rejected by the closed enum
role: "super_benefic",
ownedHouses: [1],
evidenceRef: "ev-tech-functional_benefic_malefic",
}],
},
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: { ...bundle.interpretiveFacts, savScores: [{ house: 13, score: 1 }] },
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "marriage",
headline: "未被请求的主题不得携带种子",
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})), /report_bundle_seed_theme_not_requested/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "越界".repeat(200),
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "引用不存在的证据",
strengths: [], risks: [], boundaries: [], evidenceRefs: ["ev-tech-not-real"],
}],
})), /report_bundle_invalid_seed_ref/);
});
// ---------------------------------------------------------------------------
// Task 2: claim cards carry conclusions, not receipts
// ---------------------------------------------------------------------------
test("claim card conclusions state real chart facts instead of an execution receipt", () => {
const bundle = buildBundle();
for (const card of bundle.claimCards) {
assert.ok(
!card.conclusion.includes("服务器已闭合"),
`${card.theme} still uses the receipt template`,
);
assert.ok(card.conclusion.includes("Cancer"), `${card.theme} conclusion names the ascendant`);
}
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.ok(wealth.conclusion.includes("第 2 宫"), "wealth conclusion cites its own SAV houses");
assert.ok(wealth.conclusion.includes("第 11 宫"));
const health = bundle.claimCards.find((card) => card.theme === "general");
assert.ok(health && !health.conclusion.includes("第 2 宫"), "themes do not share one canned conclusion");
// Supporting facts are astrological statements bound to real receipts.
const values = wealth.supportingFacts.map((fact) => fact.value);
assert.ok(values.some((value) => value.includes("Rahu 主运")), "current dasha reaches the claim card");
assert.ok(values.some((value) => value.includes("Amala Yoga")), "detected yogas reach the claim card");
const refIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
for (const fact of [...wealth.supportingFacts, ...wealth.counterFacts]) {
assert.ok(refIds.has(fact.evidenceRef), `${fact.id} must bind a real evidence ref`);
}
});
test("engine risk lines become counter facts on the matching claim card", () => {
const bundle = buildBundle({ financeNarrative: true });
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.deepEqual(
wealth.counterFacts.map((fact) => fact.value),
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
);
});
test("a theme without any narrative seed keeps the receipt wording and loses consensus", () => {
const seeded = buildBundle();
const bare = buildBundle({ interpretive: false });
const seededEducation = seeded.claimCards.find((card) => card.theme === "education")!;
assert.equal(seededEducation.assertionLevel, "multi_system_consensus");
const bareEducation = bare.claimCards.find((card) => card.theme === "education")!;
assert.equal(bare.themeNarrativeSeeds.length, 0, "no interpretive source means no seed");
assert.ok(bareEducation.conclusion.includes("服务器已闭合"));
assert.equal(
bareEducation.assertionLevel,
"single_system_inference",
"seed-less themes are capped below consensus",
);
});
test("consensus guard still rejects an upgraded assertion level", () => {
const bundle = buildBundle();
const card = bundle.claimCards.find((entry) => entry.theme === "wealth")!;
assert.notEqual(card.assertionLevel, "multi_system_consensus");
assert.throws(() => validateReportEvidenceBundleV2({
...bundle,
claimCards: bundle.claimCards.map((entry) => (
entry.theme === "wealth" ? { ...entry, assertionLevel: "multi_system_consensus" as const } : entry
)),
}), /report_bundle_invalid_consensus/);
});
// ---------------------------------------------------------------------------
// Task 1: per-section trimming
// ---------------------------------------------------------------------------
test("section filtering keeps the universal interpretive layer and trims the rest by theme", () => {
const bundle = buildBundle();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
const wealthSection = plan.sections.find((section) => section.theme === "wealth")!;
const generalSection = plan.sections.find((section) => section.theme === "general")!;
const wealthBundle = filterReportEvidenceBundleForSection(bundle, wealthSection);
assert.deepEqual(wealthBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["wealth"]);
assert.deepEqual(
wealthBundle.interpretiveFacts.savScores.map((row) => row.house),
[2, 11],
"only the theme's own SAV houses survive",
);
assert.equal(wealthBundle.interpretiveFacts.yogas.length, bundle.interpretiveFacts.yogas.length);
assert.equal(
wealthBundle.interpretiveFacts.functionalRoles.length,
bundle.interpretiveFacts.functionalRoles.length,
);
// general only requires D1, yet the chart-wide interpretive receipts ride along.
assert.ok(!generalSection.evidenceRefs.includes("ev-tech-functional_benefic_malefic"));
const generalBundle = filterReportEvidenceBundleForSection(bundle, generalSection);
const generalRefs = generalBundle.evidenceRefs.map((ref) => ref.id);
assert.ok(generalRefs.includes("ev-tech-functional_benefic_malefic"));
assert.ok(generalRefs.includes("ev-tech-yoga"));
assert.equal(generalBundle.interpretiveFacts.functionalRoles.length, 7);
assert.deepEqual(
generalBundle.interpretiveFacts.savScores.map((row) => row.house),
[1, 10],
);
assert.deepEqual(generalBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["general"]);
});
test("an empty interpretive block still finalizes and hashes", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const stripped = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: emptyReportInterpretiveFacts(),
themeNarrativeSeeds: [],
});
assert.equal(stripped.interpretiveFacts.savTotal, null);
assert.notEqual(stripped.bundleHash, bundle.bundleHash, "the hash covers the new fields");
});