feat: add report evidence bundle v2

This commit is contained in:
Jesse_Chen
2026-08-15 02:15:45 +08:00
parent 83fef19779
commit 9d2d79801d
8 changed files with 1983 additions and 97 deletions
+15
View File
@@ -3182,3 +3182,18 @@
- 防复发:详细事件不得在 adapter 层压平;“有候选”不得等于“允许采用”;Web 不得自行推断执行技法或业务 tie;采用候选永远不能隐式确认精确分钟;candidate action 必须引用服务器持久化 result 中的 candidate UUID。
- 相关记录:BUG-172、BUG-179、BUG-184、BUG-186
- 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 staging,未部署)
## BUG-188 | 个人报告按 general 单次计算且 v1 证据包无法闭合主题结论
- 状态:resolved(本地候选,未部署)
- 首次发现:2026-08-14
- 最近更新:2026-08-14
- 影响面:个人完整/专题报告的主题计算编排、报告 Agent 输入、出生时间政策、主题证据引用与报告生成状态。
- 用户现象:完整报告即使请求多个主题也只执行一次 `general`,专题报告只使用首个主题;写作 Agent 只能获得缺少 Claim Card、Blocked Section 和执行账本的 `ReportEvidencePacket v1`,因此内容短、主题证据不足,且 accepted 时间曾通过零宽候选区间表达。
- 触发条件:创建包含多个 requested themes 的 `personal_full` 报告;请求 wealth 等需要专题分盘/技法但证据未闭合的报告;或使用 accepted 出生时间生成报告。
- 根因:报告路由把所有完整报告折叠到单次 `general` workflow,旧 packet 仅提供窄化盘面与技法状态,没有服务器持有的主题 Claim Graph、最低证据计划、blocked coverage、execution receipt 与规范化 hash;Agent 又被禁止自行推算未提供事实。
- 修复:新增 `ReportEvidenceBundle v2`、主题最低证据计划、规范化 hash 与强引用校验;路由按 requested themes 分别运行 workflow,并把缺失专题证据表示为 Blocked Section,而不是伪造已执行技法或让部分 blocked 主题拖垮整份报告。Agent 仅接收安全投影后的 Bundle;服务器继续通过兼容层组装现有 ReportDocument v1。accepted 状态改为 `accepted_directional_only`Bundle 不生成 `candidateRange`;缺失 D2/D11 等证据只能生成 `blocked + executed=false` receipt。
- 验证:个人报告 API/生成聚焦回归 77 passed、0 failed,覆盖多主题运行、每主题 Claim Card/Blocked Section、部分 blocked 仍 ready、引用闭合、consensus 降级、accepted 无假 candidate range、wealth 缺 D2/D11 不得升级、Bundle hash 稳定、直接恶意 Bundle 自由文本绕过被拒绝与敏感信息不泄露;目标 ESLint 0 error/0 warning`git diff --check` 通过。全库 `tsc --noEmit` 仅剩 5 个既有无关测试错误:`production-data-migration.test.ts` 两处 fixture 字段缺失、`staging-backend-workflows.test.ts` 三处低 target 正则 flag。
- 防复发:每个 requested theme 必须且只能由 Claim Card 或 Blocked Section 覆盖;未执行或 blocked/partial 技法不得升级为 verified/consensusAgent 不得接收 raw workflow、坐标、内部路径、secret、聊天历史或工具轨迹;Bundle hash 必须由服务器对规范化且排除自身 hash 的内容计算。
- 相关记录:BUG-152、BUG-159
- 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 staging,未部署)
+588 -8
View File
@@ -11,9 +11,19 @@ import type {
EvidenceRefStatus,
PersonalReportAgentOutput,
ReportAgentPort,
ReportEvidenceBundleV2,
ReportEvidencePacket,
ReportPlanetFact,
} from "@/mastra/personal-report";
import type {
EvidenceConflict,
ReportChartFact,
ReportClaimCard,
SafeReportSubject,
TechniqueExecutionReceipt,
} from "./report-evidence-bundle-v2.ts";
import { finalizeReportEvidenceBundleV2, validateReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
@@ -308,7 +318,8 @@ function readVargaHouses(
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 safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
const houses: ReportEvidencePacket["chart"]["houses"] = Array.from(
{ length: 12 },
@@ -633,6 +644,570 @@ export function canonicalTechniqueStatus(status: string): "verified" | "partial"
return "blocked";
}
export type BuildReportEvidenceBundleV2Input = Readonly<{
workflows: readonly Readonly<{ theme: string; workflow: unknown }>[];
subject: SafeReportSubject;
requestedThemes: readonly string[];
reportType: "personal_full" | "personal_thematic";
presentationMode: "default" | "research";
skillSnapshot: SkillSnapshot;
}>;
type BuiltThemePacket = Readonly<{
theme: string;
workflow: JsonRecord;
packet: ReportEvidencePacket;
}>;
const RECEIPT_STATUS_RANK: Readonly<Record<EvidenceRefStatus, number>> = {
blocked: 0,
partial: 1,
verified: 2,
};
function evidenceSlug(value: string, fallback: string): string {
const slug = value.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 48);
return slug || fallback;
}
function techniqueLookupKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
}
function techniqueMatches(receipt: TechniqueExecutionReceipt, aliases: readonly string[]): boolean {
const receiptKey = techniqueLookupKey(receipt.technique);
return aliases.some((alias) => {
const aliasKey = techniqueLookupKey(alias);
return receiptKey === aliasKey
|| receiptKey.endsWith(`_${aliasKey}`)
|| receiptKey.startsWith(`${aliasKey}_`);
});
}
const SAFE_CELESTIAL_NAMES = new Map([
"sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu",
"uranus", "neptune", "pluto", "ascendant", "lagna",
].map((name) => [name, name.charAt(0).toUpperCase() + name.slice(1)]));
const SAFE_WORKFLOW_TECHNIQUES = new Map<string, string>([
["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"],
["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"],
["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"],
["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"],
["amk", "AmK"], ["amatyakaraka", "AmK"], ["karma_pada", "A10"],
["vimshottari", "Vimshottari"], ["vimshottari_dasha", "Vimshottari"],
["dasha_boundaries", "Vimshottari"], ["narayana", "Narayana"],
["narayana_dasha", "Narayana"], ["transit", "Transit"], ["gochara", "Transit"],
["yoga", "Yoga"], ["yogas", "Yoga"], ["ashtakavarga", "Ashtakavarga"],
["functional_benefic_malefic", "Functional Benefic/Malefic"],
["planet_degrees", "Planet Degrees"], ["house_degrees", "House Degrees"],
]);
const SAFE_AYANAMSA = new Map<string, string>([
["lahiri", "Lahiri"], ["raman", "Raman"], ["kp", "Krishnamurti/KP"],
["krishnamurti", "Krishnamurti/KP"], ["krishnamurti/kp", "Krishnamurti/KP"],
["krishnamurti_paddhati", "Krishnamurti/KP"], ["fagan_bradley", "Fagan-Bradley"],
["djwhal_khul", "Djwhal Khul"], ["sassanian", "Sassanian"],
["true_citra", "True Citra"],
]);
const SAFE_NODE_MODES = new Map<string, string>([
["mean", "mean"], ["mean_node", "mean"], ["true", "true"], ["true_node", "true"],
]);
const SAFE_HOUSE_SYSTEMS = new Map<string, string>([
["equal", "equal"], ["placidus", "placidus"], ["porphyry", "porphyry"],
["sripati", "sripati"], ["whole_sign", "whole_sign"], ["koch", "koch"],
]);
const SAFE_POLICY_BOUNDARIES = new Set([
"timing",
"medical",
"investment",
"exact_dates",
"medical_diagnosis",
"investment_guarantees",
"kp_system",
"muhurta",
"gochara_event_timing",
"sahams",
"sphuta_trisphuta_family",
"tajika_yogas",
"conception_chart",
"relationship_combinations",
]);
function safeWorkflowTechnique(value: string): string | null {
return SAFE_WORKFLOW_TECHNIQUES.get(techniqueLookupKey(value)) ?? null;
}
type CalculationProfileField = "ayanamsa" | "nodeMode" | "houseSystem";
function safeCalculationLabel(value: string | null, field: CalculationProfileField): string | null {
if (!value) return null;
const key = value.trim().toLowerCase().replace(/[ -]+/g, "_");
if (field === "ayanamsa") return SAFE_AYANAMSA.get(key) ?? null;
if (field === "nodeMode") return SAFE_NODE_MODES.get(key) ?? null;
return SAFE_HOUSE_SYSTEMS.get(key) ?? null;
}
function safePolicyBoundary(
value: string,
): ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number] | null {
const candidate = value.trim().toLowerCase();
return SAFE_POLICY_BOUNDARIES.has(candidate)
? candidate as ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number]
: null;
}
function safeCelestialName(value: string): string | null {
const candidate = value.trim();
return SAFE_CELESTIAL_NAMES.get(candidate.toLowerCase()) ?? null;
}
function safeChartSign(value: string): string | null {
const index = signIndex(value);
return index === null ? null : SIGNS[index];
}
function safeDashaDate(value: string): string | null {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (dateOnly) {
const year = Number(dateOnly[1]);
if (year < 1600 || year > 2400) return null;
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
? value
: null;
}
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
return null;
}
const parsed = new Date(value);
const year = parsed.getUTCFullYear();
return Number.isFinite(parsed.getTime()) && year >= 1600 && year <= 2400 ? value : null;
}
function safeDashaPeriods(
periods: readonly Readonly<{ lord: string; start: string; end: string }>[] | null,
): readonly Readonly<{ lord: string; start: string; end: string }>[] | null {
if (!periods) return null;
const safe = periods.flatMap((period) => {
const lord = safeCelestialName(period.lord) ?? safeChartSign(period.lord);
const start = safeDashaDate(period.start);
const end = safeDashaDate(period.end);
return lord && start && end && Date.parse(start) < Date.parse(end)
? [{ lord, start, end }]
: [];
});
return safe.length > 0 ? safe : null;
}
function safeChartFact(chart: ReportChartFact): ReportChartFact | null {
const ascendantSign = chart.ascendant ? safeChartSign(chart.ascendant.sign) : null;
if (chart.id === "D1" && (!chart.ascendant || !ascendantSign)) return null;
const ascendantIndex = ascendantSign ? signIndex(ascendantSign) : null;
const planets = chart.planets.flatMap((planet) => {
const id = safeCelestialName(planet.id);
const sign = safeChartSign(planet.sign);
if (!id || !sign) return [];
return [{ ...planet, id, sign }];
});
const houses = chart.houses.flatMap((house) => {
if (!Number.isInteger(house.number) || house.number < 1 || house.number > 12) return [];
const sign = safeChartSign(house.sign)
?? (ascendantIndex === null ? null : SIGNS[(ascendantIndex + house.number - 1) % 12]);
if (!sign) return [];
return [{
...house,
sign,
signDerived: house.signDerived || safeChartSign(house.sign) === null,
occupants: house.occupants.flatMap((occupant) => {
const safe = safeCelestialName(occupant);
return safe ? [safe] : [];
}),
}];
});
return {
...chart,
ascendant: chart.ascendant && ascendantSign
? { sign: ascendantSign, degree: chart.ascendant.degree }
: null,
houses,
planets,
};
}
function safeReceiptNote(status: EvidenceRefStatus, executed: boolean): string {
if (!executed) return status === "blocked" ? "本次未取得该技法证据" : "仅识别为可用层,本次未执行";
return status === "verified" ? "本次服务器计算已验证" : "本次已执行,但证据确定性仍为部分";
}
const EXECUTED_MACHINE_SECTION_STATUSES = new Set([
"used",
"verified",
"partial",
"received_unverified",
"local_fallback",
"official_verified",
"executed",
"success",
"completed",
]);
function readMachineSectionExecuted(workflow: JsonRecord, technique: string): boolean | null {
const machinePacket = record(workflow.machine_evidence_packet);
const section = readSections(machinePacket).find((item) => item.name === technique);
if (!section) return null;
return EXECUTED_MACHINE_SECTION_STATUSES.has(section.status.trim().toLowerCase());
}
function readCalculationProfileText(
workflow: JsonRecord,
keys: readonly string[],
field: CalculationProfileField,
): string | null {
const chart = record(workflow.chart) ?? {};
const modules = record(chart.modules) ?? {};
const base = resolveBaseChart(chart);
const profile = record(workflow.calculation_profile) ?? record(chart.calculation_profile) ?? {};
for (const key of keys) {
const value = safeCalculationLabel(text(profile[key] ?? base[key] ?? chart[key] ?? modules[key]), field);
if (value) return value;
}
return null;
}
function readAllVargaCharts(workflow: JsonRecord): ReportChartFact[] {
const chart = record(workflow.chart) ?? {};
const modules = record(chart.modules) ?? {};
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);
}
charts.push({
id,
title: `${id} 分盘`,
ascendant: {
sign: SIGNS[ascIndex],
degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0,
},
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: SIGNS[(ascIndex + index) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 20),
})),
planets: [],
});
}
return charts;
}
function mergeChart(target: Map<string, ReportChartFact>, chart: ReportChartFact): void {
const existing = target.get(chart.id);
if (!existing || (existing.houses.length < 12 && chart.houses.length === 12)) target.set(chart.id, chart);
}
function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvidencePacket {
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 }));
const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
schemaVersion: "report_evidence_packet.v1",
subject: bundle.subject,
requestedThemes: bundle.requestedThemes,
reportType: bundle.reportType,
presentationMode: bundle.presentationMode,
chart: {
calculationHash: bundle.calculationProfile.calculationHash,
calculationHashDerived: bundle.calculationProfile.calculationHashDerived,
ascendant: d1.ascendant,
planets: d1.planets,
houses: d1.houses,
vimshottari: bundle.calculationProfile.vimshottari,
narayana: bundle.calculationProfile.narayana,
vargaHouses,
},
techniqueAudit: bundle.executionLedger.map((receipt) => ({
id: receipt.id,
technique: receipt.technique,
status: receipt.status,
note: receipt.note,
})),
conflicts: bundle.conflicts.map((conflict) => ({
id: conflict.id,
techniques: conflict.techniqueRefs.map((ref) => receiptById.get(ref)?.technique ?? ref),
summary: conflict.summary,
})),
blockedTechniques: bundle.executionLedger
.filter((receipt) => receipt.status === "blocked")
.map((receipt) => receipt.technique),
evidenceRefs: bundle.evidenceRefs,
candidateRange: null,
answerPolicy: {
canAnswerPreciseTiming: bundle.answerPolicy.canAnswerPreciseTiming,
deterministicClaimsForbiddenFor: bundle.answerPolicy.deterministicClaimsForbiddenFor,
},
skillName: bundle.skill.name,
skillVersion: bundle.skill.version,
skillSnapshotSha256: bundle.skill.sha256,
skillSourceCommit: bundle.skill.sourceCommit,
};
}
/**
* Builds the server-owned, allowlisted ReportEvidenceBundle v2 from one
* workflow result per requested theme. A missing thematic layer produces a
* blocked section, while an unusable D1 base fails closed.
*/
export function buildReportEvidenceBundleV2(
input: BuildReportEvidenceBundleV2Input,
): ReportEvidenceBundleV2 {
const requestedPlans = buildReportThemePlan(input.requestedThemes);
const packets: BuiltThemePacket[] = [];
for (const item of input.workflows) {
const workflow = record(item.workflow);
if (!workflow) continue;
try {
packets.push({
theme: normalizeReportTheme(item.theme),
workflow,
packet: buildReportEvidencePacket({
workflow,
subject: input.subject,
requestedThemes: [normalizeReportTheme(item.theme)],
reportType: input.reportType,
presentationMode: input.presentationMode,
candidateRange: null,
skillSnapshot: input.skillSnapshot,
}),
});
} catch (error) {
if (!(error instanceof ReportEvidenceInsufficientError)) throw error;
}
}
const base = packets[0];
if (!base) throw new ReportEvidenceInsufficientError("d1_base_unavailable");
const receiptsByKey = new Map<string, TechniqueExecutionReceipt>();
const upsertReceipt = (technique: string, status: EvidenceRefStatus, executed: boolean) => {
const key = techniqueLookupKey(technique) || "technique";
const next: TechniqueExecutionReceipt = {
id: `ev-tech-${evidenceSlug(technique, "technique")}`,
technique,
status,
executed,
note: safeReceiptNote(status, executed),
};
const current = receiptsByKey.get(key);
if (!current
|| Number(next.executed) > Number(current.executed)
|| (next.executed === current.executed && RECEIPT_STATUS_RANK[next.status] > RECEIPT_STATUS_RANK[current.status])) {
receiptsByKey.set(key, next);
}
return receiptsByKey.get(key)!;
};
for (const { packet, workflow } of packets) {
for (const row of packet.techniqueAudit) {
const technique = safeWorkflowTechnique(row.technique);
if (!technique) continue;
const status = canonicalTechniqueStatus(row.status);
const machineSectionExecuted = readMachineSectionExecuted(workflow, row.technique);
const executed = machineSectionExecuted
?? (row.status === "verified" || row.status === "used");
upsertReceipt(technique, status, executed && status !== "blocked");
}
}
upsertReceipt("D1", "verified", true);
if (base.packet.chart.vimshottari?.length) upsertReceipt("Vimshottari", "verified", true);
if (base.packet.chart.narayana?.length) upsertReceipt("Narayana", "verified", true);
const chartsById = new Map<string, ReportChartFact>();
const safeD1 = safeChartFact({
id: "D1",
title: "D1 本命盘",
ascendant: base.packet.chart.ascendant,
houses: base.packet.chart.houses,
planets: base.packet.chart.planets,
});
if (!safeD1) throw new ReportEvidenceInsufficientError("d1_safe_projection_unavailable");
mergeChart(chartsById, safeD1);
for (const packet of packets) {
for (const varga of packet.packet.chart.vargaHouses) {
const safeChart = safeChartFact({
id: varga.id,
title: `${varga.id} 分盘`,
houses: varga.houses,
planets: [],
});
if (safeChart) {
mergeChart(chartsById, safeChart);
upsertReceipt(varga.id, "verified", true);
}
}
for (const chart of readAllVargaCharts(packet.workflow)) {
const safeChart = safeChartFact(chart);
if (!safeChart) continue;
mergeChart(chartsById, safeChart);
upsertReceipt(safeChart.id, "verified", true);
}
}
const claimCards: ReportClaimCard[] = [];
const blockedSections: ReportEvidenceBundleV2["blockedSections"][number][] = [];
for (const plan of requestedPlans) {
const matched: TechniqueExecutionReceipt[] = [];
const missingRefs: string[] = [];
for (const group of plan.requiredTechniqueGroups) {
const receipt = [...receiptsByKey.values()].find((candidate) => (
candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, group.anyOf)
));
if (receipt) {
matched.push(receipt);
continue;
}
const missing = upsertReceipt(`${plan.theme}:${group.label}`, "blocked", false);
missingRefs.push(missing.id);
}
if (missingRefs.length > 0) {
blockedSections.push({
id: `ev-blocked-${evidenceSlug(plan.theme, "theme")}`,
theme: plan.theme,
section: plan.section,
reason: `缺少最低证据组:${plan.requiredTechniqueGroups
.filter((group) => missingRefs.some((ref) => ref.endsWith(evidenceSlug(`${plan.theme}:${group.label}`, "missing"))))
.map((group) => group.label)
.join("、") || "主题证据未闭合"}`,
missingTechniqueRefs: missingRefs,
});
continue;
}
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
? "multi_system_consensus"
: allVerified
? "single_system_inference"
: "parameter_sensitive";
claimCards.push({
id: `ev-claim-${evidenceSlug(plan.theme, "theme")}`,
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: [],
executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id),
assertionLevel,
timingBoundary: input.subject.birthTimeStatus === "confirmed"
? null
: "出生时间未达到 confirmed;时间结论仅允许方向性表达",
verificationQuestions: assertionLevel === "parameter_sensitive"
? ["建议结合真实经历核验本主题的部分证据结论"]
: [],
});
}
const conflicts: EvidenceConflict[] = [];
for (const { packet } of packets) {
for (const conflict of packet.conflicts) {
const techniqueRefs = conflict.techniques
.map((technique) => [...receiptsByKey.values()].find((receipt) => techniqueMatches(receipt, [technique]))?.id)
.filter((ref): ref is string => Boolean(ref));
const summary = techniqueRefs.length > 0
? "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
: "本次证据存在未闭合冲突,报告必须披露该边界。";
const id = `ev-conflict-${conflicts.length + 1}`;
if (!conflicts.some((item) => (
item.summary === summary
&& item.techniqueRefs.join("|") === techniqueRefs.join("|")
))) {
conflicts.push({ id, techniqueRefs, summary, resolutionStatus: "unresolved" });
}
}
}
const executionLedger = [...receiptsByKey.values()];
const deterministicClaimsForbiddenFor = [...new Set(packets.flatMap(({ packet }) => (
packet.answerPolicy.deterministicClaimsForbiddenFor.flatMap((value) => {
const safe = safePolicyBoundary(value);
return safe ? [safe] : [];
})
)))];
const canAnswerPreciseTiming = input.subject.birthTimeStatus === "confirmed"
&& packets.length > 0
&& packets.every(({ packet }) => packet.answerPolicy.canAnswerPreciseTiming);
const birthTimePolicy = input.subject.birthTimeStatus === "confirmed"
? "confirmed"
: input.subject.birthTimeStatus === "accepted"
? "accepted_directional_only"
: input.subject.birthTimeStatus === "candidate"
? "candidate_directional_only"
: "reported_directional_only";
return finalizeReportEvidenceBundleV2({
schemaVersion: "report_evidence_bundle.v2",
subject: input.subject,
requestedThemes: requestedPlans.map((plan) => plan.theme),
reportType: input.reportType,
presentationMode: input.presentationMode,
calculationProfile: {
calculationHash: base.packet.chart.calculationHash,
calculationHashDerived: base.packet.chart.calculationHashDerived,
birthTimeStatus: input.subject.birthTimeStatus,
ayanamsa: readCalculationProfileText(base.workflow, ["ayanamsa", "ayanamsa_name"], "ayanamsa"),
nodeMode: readCalculationProfileText(base.workflow, ["node_mode", "nodeMode"], "nodeMode"),
houseSystem: readCalculationProfileText(base.workflow, ["house_system", "houseSystem"], "houseSystem"),
vimshottari: safeDashaPeriods(base.packet.chart.vimshottari),
narayana: safeDashaPeriods(base.packet.chart.narayana),
},
skill: input.skillSnapshot,
charts: [...chartsById.values()],
claimCards,
blockedSections,
conflicts,
executionLedger,
evidenceRefs: executionLedger.map((receipt) => ({
id: receipt.id,
technique: receipt.technique,
status: receipt.status,
})),
answerPolicy: {
canAnswerPreciseTiming,
birthTimePolicy,
deterministicClaimsForbiddenFor,
},
});
}
function techniqueSlug(name: string, fallback: string): string {
const slug = name.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
@@ -695,7 +1270,7 @@ export function assembleReportDocument(
);
const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map(
(row, index) => {
const id = `ev-audit-${index + 1}`;
const id = row.id ?? `ev-audit-${index + 1}`;
return {
id,
techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`),
@@ -708,7 +1283,7 @@ export function assembleReportDocument(
);
const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({
id: `ev-conflict-${index + 1}`,
id: conflict.id ?? `ev-conflict-${index + 1}`,
description: conflict.summary.slice(0, 1000),
impact: "多技法结果不一致,相关结论已按确定性边界降级",
status: "unresolved",
@@ -1154,13 +1729,16 @@ export function applyReportGuard<D>(
// Generation pipeline: agent -> document -> guard -> canonical server parse
// ---------------------------------------------------------------------------
export type GeneratePersonalReportDeps = Readonly<{
type GeneratePersonalReportBaseDeps = Readonly<{
reportId: string;
packet: ReportEvidencePacket;
agent: ReportAgentPort;
now?: () => Date;
}>;
export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{
bundle: ReportEvidenceBundleV2;
}>;
export type GeneratePersonalReportResult = Readonly<
| { status: "ready"; document: ReportDocumentV1; evidenceHash: string }
| { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" }
@@ -1177,14 +1755,16 @@ export type GeneratePersonalReportResult = Readonly<
export async function generatePersonalReport(
deps: GeneratePersonalReportDeps,
): Promise<GeneratePersonalReportResult> {
const agentOutput = await deps.agent.generate(deps.packet);
const bundle = validateReportEvidenceBundleV2(deps.bundle);
const packet = buildLegacyPacketFromBundle(bundle);
const agentOutput = await deps.agent.generate(bundle);
const candidate = assembleReportDocument({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
packet: deps.packet,
packet,
agentOutput,
});
const guarded = applyReportGuard(candidate, deps.packet);
const guarded = applyReportGuard(candidate, packet);
if (!guarded.ok) {
return { status: "failed", failureCode: "report_guard_rejected" };
}
+25 -22
View File
@@ -14,10 +14,10 @@ import { z } from "zod";
import type { ConsultationInput } from "@/mastra";
import type {
ReportAgentPort,
ReportEvidencePacket,
ReportEvidenceBundleV2,
} from "@/mastra/personal-report";
import {
buildReportEvidencePacket,
buildReportEvidenceBundleV2,
computeRequestFingerprint,
generatePersonalReport,
type GeneratePersonalReportResult,
@@ -320,10 +320,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
};
}
const workflowTheme = payload.reportType === "personal_thematic"
? payload.themes[0]
: "general";
const workflowInput: ConsultationInput = {
const workflowInputs: ConsultationInput[] = payload.themes.map((theme) => ({
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
@@ -333,14 +330,19 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
question: `生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")}`,
theme: workflowTheme,
question: `为个人报告计算 ${theme} 主题证据`,
theme,
entryMode: "direct_chart",
};
}));
let workflow: unknown;
const workflows: { theme: string; workflow: unknown }[] = [];
try {
workflow = await deps.runWorkflow(workflowInput);
for (const workflowInput of workflowInputs) {
workflows.push({
theme: workflowInput.theme ?? "general",
workflow: await deps.runWorkflow(workflowInput),
});
}
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
@@ -348,8 +350,12 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const workflowRecord = record(workflow);
if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) {
const hasUsableBaseChart = workflows.some(({ workflow }) => {
const workflowRecord = record(workflow);
return workflowRecord?.success === true && Boolean(record(workflowRecord.chart));
});
if (!hasUsableBaseChart) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
@@ -357,10 +363,10 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
};
}
let packet: ReportEvidencePacket;
let bundle: ReportEvidenceBundleV2;
try {
packet = buildReportEvidencePacket({
workflow,
bundle = buildReportEvidenceBundleV2({
workflows,
subject: {
displayName,
birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted",
@@ -369,14 +375,11 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
requestedThemes: payload.themes,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
candidateRange: birthTimeStatus === "accepted"
? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" }
: null,
skillSnapshot: deps.skillSnapshot,
});
} catch (error) {
// Real evidence could not support an honest report: fail closed, never
// generate an empty or sample-backed report.
// D1/base evidence failure closes the whole report. A thematic evidence
// gap is represented inside the Bundle as a blocked section instead.
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
@@ -389,7 +392,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
packet,
bundle,
agent: deps.createAgent(deps.model),
now: deps.now,
});
@@ -0,0 +1,467 @@
import { createHash } from "node:crypto";
import { z } from "zod";
export type ClaimStatus =
| "multi_system_consensus"
| "single_system_inference"
| "parameter_sensitive"
| "unclosed_divisional_chart"
| "user_history_verification_required"
| "blocked";
export type EvidenceRefStatus = "verified" | "partial" | "blocked";
export type ReportEvidenceRef = Readonly<{
id: string;
technique: string;
status: EvidenceRefStatus;
}>;
export type ReportDashaPeriod = Readonly<{ lord: string; start: string; end: string }>;
export type ReportChartHouse = Readonly<{
number: number;
sign: string;
signDerived: boolean;
occupants: readonly string[];
}>;
export type ReportPlanetFact = Readonly<{
id: string;
sign: string;
degree: number;
house: number | null;
retrograde: boolean | null;
}>;
export type ReportChartFact = Readonly<{
id: string;
title: string;
ascendant?: Readonly<{ sign: string; degree: number }> | null;
houses: readonly ReportChartHouse[];
planets: readonly ReportPlanetFact[];
}>;
export type EvidenceFact = Readonly<{
id: string;
label: string;
value: string;
evidenceRef: string;
status: EvidenceRefStatus;
}>;
export type ReportClaimCard = Readonly<{
id: string;
theme: string;
section: string;
conclusion: string;
supportingFacts: readonly EvidenceFact[];
counterFacts: readonly EvidenceFact[];
executedTechniqueRefs: readonly string[];
assertionLevel: ClaimStatus;
timingBoundary: string | null;
verificationQuestions: readonly string[];
}>;
export type BlockedSection = Readonly<{
id: string;
theme: string;
section: string;
reason: string;
missingTechniqueRefs: readonly string[];
}>;
export type EvidenceConflict = Readonly<{
id: string;
techniqueRefs: readonly string[];
summary:
| "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
| "本次证据存在未闭合冲突,报告必须披露该边界。";
resolutionStatus: "unresolved" | "bounded";
}>;
export type TechniqueExecutionReceipt = Readonly<{
id: string;
technique: string;
status: EvidenceRefStatus;
executed: boolean;
note: string;
}>;
export type SafeReportSubject = Readonly<{
displayName: string;
birthTimeStatus: "reported" | "candidate" | "accepted" | "confirmed";
birthPlaceLabel: string;
}>;
export type CalculationProfileReceipt = Readonly<{
calculationHash: string;
calculationHashDerived: boolean;
birthTimeStatus: SafeReportSubject["birthTimeStatus"];
ayanamsa: string | null;
nodeMode: string | null;
houseSystem: string | null;
vimshottari: readonly ReportDashaPeriod[] | null;
narayana: readonly ReportDashaPeriod[] | null;
}>;
export type SkillPackageIdentity = Readonly<{
name: string;
version: string;
sha256: string;
sourceCommit: string | null;
}>;
export type ReportAnswerPolicy = Readonly<{
canAnswerPreciseTiming: boolean;
birthTimePolicy: "reported_directional_only" | "candidate_directional_only" | "accepted_directional_only" | "confirmed";
deterministicClaimsForbiddenFor: readonly (
| "timing"
| "medical"
| "investment"
| "exact_dates"
| "medical_diagnosis"
| "investment_guarantees"
| "kp_system"
| "muhurta"
| "gochara_event_timing"
| "sahams"
| "sphuta_trisphuta_family"
| "tajika_yogas"
| "conception_chart"
| "relationship_combinations"
)[];
}>;
export type ReportEvidenceBundleV2 = Readonly<{
schemaVersion: "report_evidence_bundle.v2";
bundleHash: string;
subject: SafeReportSubject;
requestedThemes: readonly string[];
reportType: "personal_full" | "personal_thematic";
presentationMode: "default" | "research";
calculationProfile: CalculationProfileReceipt;
skill: SkillPackageIdentity;
charts: readonly ReportChartFact[];
claimCards: readonly ReportClaimCard[];
blockedSections: readonly BlockedSection[];
conflicts: readonly EvidenceConflict[];
executionLedger: readonly TechniqueExecutionReceipt[];
evidenceRefs: readonly ReportEvidenceRef[];
answerPolicy: ReportAnswerPolicy;
}>;
const idSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/);
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/);
const shaSchema = z.string().regex(/^[0-9a-f]{64}$/);
const statusSchema = z.enum(["verified", "partial", "blocked"]);
const signSchema = z.enum([
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]);
const celestialSchema = z.enum([
"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn",
"Rahu", "Ketu", "Uranus", "Neptune", "Pluto", "Ascendant", "Lagna",
]);
const calculationAyanamsaSchema = z.enum([
"Lahiri", "Raman", "Krishnamurti/KP", "Fagan-Bradley",
"Djwhal Khul", "Sassanian", "True Citra",
]);
const calculationNodeModeSchema = z.enum(["mean", "true"]);
const calculationHouseSystemSchema = z.enum([
"equal", "placidus", "porphyry", "sripati", "whole_sign", "koch",
]);
const conflictSummarySchema = z.enum([
"所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。",
"本次证据存在未闭合冲突,报告必须披露该边界。",
]);
const deterministicClaimBoundarySchema = z.enum([
"timing",
"medical",
"investment",
"exact_dates",
"medical_diagnosis",
"investment_guarantees",
"kp_system",
"muhurta",
"gochara_event_timing",
"sahams",
"sphuta_trisphuta_family",
"tajika_yogas",
"conception_chart",
"relationship_combinations",
]);
const allowedTechniqueNames = new Set([
"D1", "D2", "D4", "D6", "D7", "D9", "D10", "D11", "D12", "D24", "D30",
"A7", "A10", "UL", "DK", "AmK",
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
]);
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6\/D30|D7\/D12|D9|D10|D11|D12|D24|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const techniqueSchema = z.string().max(80).refine(
(value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value),
"report_bundle_technique_not_allowlisted",
);
function isValidDashaDate(value: string): boolean {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (dateOnly) {
const year = Number(dateOnly[1]);
if (year < 1600 || year > 2400) return false;
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
return false;
}
const parsed = new Date(value);
const year = parsed.getUTCFullYear();
return Number.isFinite(parsed.getTime()) && year >= 1600 && year <= 2400;
}
const dashaDateSchema = z.string().refine(isValidDashaDate, "report_bundle_dasha_date_invalid");
const dashaPeriodSchema = z.object({
lord: z.union([celestialSchema, signSchema]),
start: dashaDateSchema,
end: dashaDateSchema,
}).strict().superRefine((period, context) => {
if (Date.parse(period.start) >= Date.parse(period.end)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_bundle_dasha_period_invalid" });
}
});
const claimStatusSchema = z.enum([
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
]);
const houseSchema = z.object({
number: z.number().int().min(1).max(12),
sign: signSchema,
signDerived: z.boolean(),
occupants: z.array(celestialSchema).max(20),
}).strict();
const planetSchema = z.object({
id: celestialSchema,
sign: signSchema,
degree: z.number().finite(),
house: z.number().int().min(1).max(12).nullable(),
retrograde: z.boolean().nullable(),
}).strict();
const factSchema = z.object({
id: evidenceIdSchema,
label: z.string().min(1).max(160),
value: z.string().min(1).max(800),
evidenceRef: evidenceIdSchema,
status: statusSchema,
}).strict();
export const reportEvidenceBundleV2Schema = z.object({
schemaVersion: z.literal("report_evidence_bundle.v2"),
bundleHash: shaSchema,
subject: z.object({
displayName: z.string().min(1).max(160),
birthTimeStatus: z.enum(["reported", "candidate", "accepted", "confirmed"]),
birthPlaceLabel: z.string().min(1).max(200),
}).strict(),
requestedThemes: z.array(idSchema).min(1).max(12),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]),
calculationProfile: z.object({
calculationHash: shaSchema,
calculationHashDerived: z.boolean(),
birthTimeStatus: z.enum(["reported", "candidate", "accepted", "confirmed"]),
ayanamsa: calculationAyanamsaSchema.nullable(),
nodeMode: calculationNodeModeSchema.nullable(),
houseSystem: calculationHouseSystemSchema.nullable(),
vimshottari: z.array(dashaPeriodSchema).nullable(),
narayana: z.array(dashaPeriodSchema).nullable(),
}).strict(),
skill: z.object({
name: z.string().min(1).max(120),
version: z.string().min(1).max(80),
sha256: shaSchema,
sourceCommit: z.string().regex(/^[0-9a-f]{40}$/).nullable(),
}).strict(),
charts: z.array(z.object({
id: z.string().regex(/^D[1-9][0-9]{0,2}$/),
title: z.string().min(1).max(160),
ascendant: z.object({
sign: signSchema,
degree: z.number().finite().min(0).max(360),
}).strict().nullable().optional(),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(20),
}).strict()).min(1).max(24),
claimCards: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
section: z.string().min(1).max(160),
conclusion: z.string().min(1).max(1200),
supportingFacts: z.array(factSchema).min(1).max(40),
counterFacts: z.array(factSchema).max(40),
executedTechniqueRefs: z.array(evidenceIdSchema).min(1).max(40),
assertionLevel: claimStatusSchema,
timingBoundary: z.string().max(200).nullable(),
verificationQuestions: z.array(z.string().min(1).max(300)).max(12),
}).strict()).max(48),
blockedSections: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
section: z.string().min(1).max(160),
reason: z.string().min(1).max(1000),
missingTechniqueRefs: z.array(z.string().min(1).max(120)).min(1).max(40),
}).strict()).max(24),
conflicts: z.array(z.object({
id: evidenceIdSchema,
techniqueRefs: z.array(evidenceIdSchema).max(40),
summary: conflictSummarySchema,
resolutionStatus: z.enum(["unresolved", "bounded"]),
}).strict()).max(100),
executionLedger: z.array(z.object({
id: evidenceIdSchema,
technique: techniqueSchema,
status: statusSchema,
executed: z.boolean(),
note: z.string().max(500),
}).strict()).min(1).max(200),
evidenceRefs: z.array(z.object({
id: evidenceIdSchema,
technique: techniqueSchema,
status: statusSchema,
}).strict()).min(1).max(300),
answerPolicy: z.object({
canAnswerPreciseTiming: z.boolean(),
birthTimePolicy: z.enum(["reported_directional_only", "candidate_directional_only", "accepted_directional_only", "confirmed"]),
deterministicClaimsForbiddenFor: z.array(deterministicClaimBoundarySchema).max(100),
}).strict(),
}).strict();
function canonicalSerialize(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(canonicalSerialize).join(",")}]`;
if (value !== null && typeof value === "object") {
const source = value as Record<string, unknown>;
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${canonicalSerialize(source[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sortedUnique<T extends string>(values: readonly T[]): T[] {
return [...new Set(values)].sort();
}
function sortedFacts(facts: readonly EvidenceFact[]): EvidenceFact[] {
return [...facts].sort((a, b) => a.id.localeCompare(b.id));
}
function sortedBundleContent(bundle: Omit<ReportEvidenceBundleV2, "bundleHash">) {
return {
...bundle,
requestedThemes: sortedUnique(bundle.requestedThemes),
calculationProfile: {
...bundle.calculationProfile,
vimshottari: bundle.calculationProfile.vimshottari
? [...bundle.calculationProfile.vimshottari].sort((a, b) => `${a.start}:${a.end}:${a.lord}`.localeCompare(`${b.start}:${b.end}:${b.lord}`))
: null,
narayana: bundle.calculationProfile.narayana
? [...bundle.calculationProfile.narayana].sort((a, b) => `${a.start}:${a.end}:${a.lord}`.localeCompare(`${b.start}:${b.end}:${b.lord}`))
: null,
},
charts: [...bundle.charts].map((chart) => ({
...chart,
houses: [...chart.houses].map((house) => ({
...house,
occupants: sortedUnique(house.occupants),
})).sort((a, b) => a.number - b.number),
planets: [...chart.planets].sort((a, b) => a.id.localeCompare(b.id)),
})).sort((a, b) => a.id.localeCompare(b.id)),
claimCards: [...bundle.claimCards].map((card) => ({
...card,
supportingFacts: sortedFacts(card.supportingFacts),
counterFacts: sortedFacts(card.counterFacts),
executedTechniqueRefs: sortedUnique(card.executedTechniqueRefs),
verificationQuestions: sortedUnique(card.verificationQuestions),
})).sort((a, b) => a.id.localeCompare(b.id)),
blockedSections: [...bundle.blockedSections].map((section) => ({
...section,
missingTechniqueRefs: sortedUnique(section.missingTechniqueRefs),
})).sort((a, b) => a.id.localeCompare(b.id)),
conflicts: [...bundle.conflicts].map((conflict) => ({
...conflict,
techniqueRefs: sortedUnique(conflict.techniqueRefs),
})).sort((a, b) => a.id.localeCompare(b.id)),
executionLedger: [...bundle.executionLedger].sort((a, b) => a.id.localeCompare(b.id)),
evidenceRefs: [...bundle.evidenceRefs].sort((a, b) => a.id.localeCompare(b.id)),
answerPolicy: {
...bundle.answerPolicy,
deterministicClaimsForbiddenFor: sortedUnique(bundle.answerPolicy.deterministicClaimsForbiddenFor),
},
};
}
export function computeReportEvidenceBundleHash(bundle: Omit<ReportEvidenceBundleV2, "bundleHash">): string {
return createHash("sha256").update(canonicalSerialize(sortedBundleContent(bundle))).digest("hex");
}
export function validateReportEvidenceBundleV2(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 {
const parsed = reportEvidenceBundleV2Schema.parse(bundle) as ReportEvidenceBundleV2;
if (parsed.calculationProfile.birthTimeStatus !== parsed.subject.birthTimeStatus) {
throw new Error("report_bundle_birth_time_status_mismatch");
}
const uniqueThemes = new Set(parsed.requestedThemes);
if (uniqueThemes.size !== parsed.requestedThemes.length) throw new Error("report_bundle_duplicate_theme");
const claimThemes = new Set(parsed.claimCards.map((card) => card.theme));
const blockedThemes = new Set(parsed.blockedSections.map((section) => section.theme));
if (claimThemes.size !== parsed.claimCards.length) throw new Error("report_bundle_duplicate_claim_theme");
if (blockedThemes.size !== parsed.blockedSections.length) throw new Error("report_bundle_duplicate_blocked_theme");
for (const theme of parsed.requestedThemes) {
const coverageCount = Number(claimThemes.has(theme)) + Number(blockedThemes.has(theme));
if (coverageCount !== 1) throw new Error(`report_bundle_theme_coverage_invalid:${theme}`);
}
const ledger = new Map(parsed.executionLedger.map((receipt) => [receipt.id, receipt]));
const evidenceRefs = new Map(parsed.evidenceRefs.map((ref) => [ref.id, ref]));
for (const receipt of parsed.executionLedger) {
if (receipt.status === "verified" && !receipt.executed) {
throw new Error(`report_bundle_verified_receipt_not_executed:${receipt.id}`);
}
const evidenceRef = evidenceRefs.get(receipt.id);
if (!evidenceRef || evidenceRef.status !== receipt.status || evidenceRef.technique !== receipt.technique) {
throw new Error(`report_bundle_receipt_evidence_mismatch:${receipt.id}`);
}
}
for (const section of parsed.blockedSections) {
for (const ref of section.missingTechniqueRefs) {
const receipt = ledger.get(ref);
if (!receipt || receipt.executed || receipt.status !== "blocked") {
throw new Error(`report_bundle_invalid_missing_technique_ref:${ref}`);
}
}
}
for (const card of parsed.claimCards) {
for (const ref of card.executedTechniqueRefs) {
const receipt = ledger.get(ref);
if (!receipt || !receipt.executed || receipt.status === "blocked") {
throw new Error(`report_bundle_invalid_technique_ref:${ref}`);
}
}
if (card.assertionLevel === "multi_system_consensus") {
const verified = card.executedTechniqueRefs.filter((ref) => ledger.get(ref)?.status === "verified");
if (verified.length < 2 || verified.length !== card.executedTechniqueRefs.length) {
throw new Error(`report_bundle_invalid_consensus:${card.id}`);
}
}
for (const fact of [...card.supportingFacts, ...card.counterFacts]) {
const evidenceRef = evidenceRefs.get(fact.evidenceRef);
if (!evidenceRef) throw new Error(`report_bundle_invalid_fact_ref:${fact.evidenceRef}`);
if (fact.status === "verified" && evidenceRef.status !== "verified") {
throw new Error(`report_bundle_fact_status_upgrade:${fact.id}`);
}
}
}
const { bundleHash: _bundleHash, ...content } = parsed;
void _bundleHash;
const expectedHash = computeReportEvidenceBundleHash(content);
if (expectedHash !== parsed.bundleHash) throw new Error("report_bundle_hash_mismatch");
return parsed;
}
export function finalizeReportEvidenceBundleV2(
bundle: Omit<ReportEvidenceBundleV2, "bundleHash">,
): ReportEvidenceBundleV2 {
const normalized = sortedBundleContent(bundle);
return validateReportEvidenceBundleV2({
...normalized,
bundleHash: computeReportEvidenceBundleHash(normalized),
});
}
@@ -0,0 +1,142 @@
export type ReportTheme =
| "career"
| "marriage"
| "wealth"
| "education"
| "migration_home"
| "family"
| "health_pressure"
| "timing"
| "general";
export type ReportThemeEvidencePlan = Readonly<{
theme: ReportTheme;
section: string;
requiredTechniqueGroups: readonly Readonly<{
label: string;
anyOf: readonly string[];
}>[];
chartIds: readonly string[];
}>;
const PLAN: Readonly<Record<ReportTheme, ReportThemeEvidencePlan>> = {
general: {
theme: "general",
section: "综合基础",
requiredTechniqueGroups: [{ label: "D1", anyOf: ["D1"] }],
chartIds: ["D1"],
},
career: {
theme: "career",
section: "事业发展",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D10", anyOf: ["D10"] },
{ label: "A10", anyOf: ["A10", "Karma Pada"] },
{ label: "AmK", anyOf: ["AmK", "Amatyakaraka"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
{ label: "Narayana", anyOf: ["Narayana", "narayana_dasha"] },
{ label: "Transit", anyOf: ["Transit", "Gochara"] },
],
chartIds: ["D1", "D10"],
},
marriage: {
theme: "marriage",
section: "婚恋关系",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D9", anyOf: ["D9"] },
{ label: "UL", anyOf: ["UL", "Upapada"] },
{ label: "DK", anyOf: ["DK", "Darakaraka"] },
{ label: "A7", anyOf: ["A7"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
],
chartIds: ["D1", "D9"],
},
wealth: {
theme: "wealth",
section: "财富结构",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D2", anyOf: ["D2"] },
{ label: "D11", anyOf: ["D11"] },
{ label: "Yoga", anyOf: ["Yoga", "yogas"] },
{ label: "Ashtakavarga", anyOf: ["Ashtakavarga", "ashtakavarga"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
],
chartIds: ["D1", "D2", "D11"],
},
education: {
theme: "education",
section: "教育学习",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D24", anyOf: ["D24"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
],
chartIds: ["D1", "D24"],
},
migration_home: {
theme: "migration_home",
section: "迁移与居所",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D4", anyOf: ["D4"] },
{ label: "D12", anyOf: ["D12"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
],
chartIds: ["D1", "D4", "D12"],
},
family: {
theme: "family",
section: "家庭与子女",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D7/D12", anyOf: ["D7", "D12"] },
],
chartIds: ["D1", "D7", "D12"],
},
health_pressure: {
theme: "health_pressure",
section: "健康压力(非医疗)",
requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] },
{ label: "D6/D30", anyOf: ["D6", "D30"] },
],
chartIds: ["D1", "D6", "D30"],
},
timing: {
theme: "timing",
section: "阶段与时机",
requiredTechniqueGroups: [
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
{ label: "Narayana", anyOf: ["Narayana", "narayana_dasha"] },
{ label: "Transit", anyOf: ["Transit", "Gochara"] },
],
chartIds: ["D1"],
},
};
const THEME_ALIASES: Readonly<Record<string, ReportTheme>> = {
career: "career",
marriage: "marriage",
wealth: "wealth",
education: "education",
migration: "migration_home",
home: "migration_home",
migration_home: "migration_home",
family: "family",
health: "health_pressure",
health_pressure: "health_pressure",
timing: "timing",
general: "general",
};
export function normalizeReportTheme(theme: string): ReportTheme {
return THEME_ALIASES[theme.trim().toLowerCase()] ?? "general";
}
export function buildReportThemePlan(themes: readonly string[]): readonly ReportThemeEvidencePlan[] {
const normalized = [...new Set(themes.map(normalizeReportTheme))].sort();
return normalized.map((theme) => PLAN[theme]);
}
+29 -51
View File
@@ -1,23 +1,31 @@
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import type { ResolvedLanguageModel } from "./model";
import type {
ReportChartHouse,
ReportDashaPeriod,
ReportEvidenceBundleV2,
ReportEvidenceRef,
ReportPlanetFact,
} from "@/lib/report-evidence-bundle-v2";
export type {
ClaimStatus,
EvidenceRefStatus,
ReportChartHouse,
ReportDashaPeriod,
ReportEvidenceBundleV2,
ReportEvidenceRef,
ReportPlanetFact,
} from "@/lib/report-evidence-bundle-v2";
/**
* Personal Report Agent — dedicated report writer, deliberately separate from
* the chat agent. It has NO skills, NO tools and NO memory: the model receives
* only the allowlisted facts inside `ReportEvidencePacket`. Chat history,
* only the allowlisted facts inside `ReportEvidenceBundleV2`. Chat history,
* SKILL.md source text, system prompts, tool traces, internal paths and error
* stacks must never reach this agent.
*/
export type ClaimStatus =
| "multi_system_consensus"
| "single_system_inference"
| "parameter_sensitive"
| "unclosed_divisional_chart"
| "user_history_verification_required"
| "blocked";
export const claimStatusSchema = z.enum([
"multi_system_consensus",
"single_system_inference",
@@ -27,43 +35,11 @@ export const claimStatusSchema = z.enum([
"blocked",
]);
export type EvidenceRefStatus = "verified" | "partial" | "blocked";
export type ReportEvidenceRef = Readonly<{
/** Canonical appendix id: `ev-audit-<n>` / `ev-conflict-<n>` / `ev-calc-<n>`. */
id: string;
technique: string;
status: EvidenceRefStatus;
}>;
export type ReportDashaPeriod = Readonly<{
lord: string;
start: string;
end: string;
}>;
export type ReportChartHouse = Readonly<{
number: number;
sign: string;
/** Whole-sign derivation from the ascendant when the source lacks a sign. */
signDerived: boolean;
/** Planet names occupying this house (whole-sign house numbers). */
occupants: readonly string[];
}>;
export type ReportVargaHouses = Readonly<{
id: "D9" | "D10";
houses: readonly ReportChartHouse[];
}>;
export type ReportPlanetFact = Readonly<{
id: string;
sign: string;
degree: number;
house: number | null;
retrograde: boolean | null;
}>;
export type ReportEvidencePacket = Readonly<{
schemaVersion: "report_evidence_packet.v1";
subject: Readonly<{
@@ -89,11 +65,13 @@ export type ReportEvidencePacket = Readonly<{
vargaHouses: readonly ReportVargaHouses[];
}>;
techniqueAudit: readonly Readonly<{
id?: string;
technique: string;
status: string;
note: string;
}>[];
conflicts: readonly Readonly<{
id?: string;
techniques: readonly string[];
summary: string;
}>[];
@@ -147,18 +125,18 @@ 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 minimal server-computed evidence packet. Use those facts exclusively. 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 the packet. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or any methodology detail unless the packet's technique audit requires disclosure.
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.
Truth boundaries are hard output contracts:
- A technique listed in blockedTechniques or with audit status blocked/partial in the packet must never be described as used or confirmed. If the packet answerPolicy.canAnswerPreciseTiming is false, give direction and structure only: never state a month, a date, a specific year, or a guaranteed timing outcome. Do not claim certainty or guaranteed outcomes anywhere.
- A candidate birth-time range is not a confirmed birth time. Never present it as confirmed, never pick a midpoint minute, and never give precise timing from it.
- 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.
- If answerPolicy.canAnswerPreciseTiming is false, give direction and structure only: never state a month, a date, a specific year, or a guaranteed timing outcome. accepted birth time remains directional-only unless the policy is confirmed.
- Do not provide medical, legal, investment or safety-critical advice. Never predict death, diagnosis, pregnancy outcomes, or guaranteed financial/legal outcomes, even as "必定/一定/肯定/保证/必然/百分之百" phrasing.
- Keep the disclaimer boundary: astrology is interpretive, not deterministic.
Structure rules:
- Produce exactly the JSON object described by the requested output schema. No Markdown fences, no commentary, no hidden fields.
- executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims.
- Section ids must be the lowercase theme keys (career, marriage, wealth, timing, general, or derived keys such as career_overview). Each thematicNarrative section must reference evidenceRefs using the exact "ev-..." ids listed in the packet's evidenceRefs. Every claim in a section must be traceable to those refs. If a section's evidence is only partial or blocked, choose claimStatus accordingly (blocked when the packet marks the underlying techniques blocked).
- Section ids must be the lowercase theme keys (career, marriage, wealth, timing, general, or derived keys such as career_overview). Each thematicNarrative section must correspond to a requested theme and reference only exact "ev-..." ids listed in the Bundle evidenceRefs. Every narrative conclusion must trace to a claimCard. For a blockedSections theme, write an explicit blocked disclosure rather than an astrological conclusion, and use claimStatus=blocked.
- Keep actions concrete and cautious; caveats must state limits honestly.
- Write formal, readable Simplified Chinese for a printed report.`;
@@ -191,15 +169,15 @@ export class PersonalReportAgentOutputError extends Error {
* text are structurally excluded by the agent definition (no skills, no tools,
* no memory).
*/
export function buildReportPrompt(packet: ReportEvidencePacket): string {
return `请根据以下唯一的事实包生成个人报告 JSON。只使用该事实包中的内容,严格按输出 schema 返回 JSON。
${JSON.stringify(packet)}`;
export function buildReportPrompt(bundle: ReportEvidenceBundleV2): string {
return `请根据以下唯一的 ReportEvidenceBundleV2 生成个人报告 JSON。只使用 claimCards 中的结论;blockedSections 必须如实披露;不得提升 assertionLevel。严格按输出 schema 返回 JSON。
${JSON.stringify(bundle)}`;
}
export type ReportAgentPort = Readonly<{
modelId: string;
generate(
packet: ReportEvidencePacket,
bundle: ReportEvidenceBundleV2,
signal?: AbortSignal,
): Promise<PersonalReportAgentOutput>;
}>;
@@ -216,9 +194,9 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
return {
modelId: model.id,
async generate(packet, signal) {
async generate(bundle, signal) {
const startedAt = Date.now();
const prompt = buildReportPrompt(packet);
const prompt = buildReportPrompt(bundle);
let repairAttempted = false;
try {
const first = await agent.generate(
+175 -3
View File
@@ -15,6 +15,7 @@ import type {
CreateGeneratingResult,
PersonalReportRecord,
} from "../src/lib/personal-report-service-core.ts";
import type { ReportEvidenceBundleV2 } from "../src/lib/report-evidence-bundle-v2.ts";
import type { ReportAgentPort, PersonalReportAgentOutput } from "../src/mastra/personal-report.ts";
const createRoute = readFileSync(
@@ -84,7 +85,7 @@ function chartPayload() {
};
}
function agentOutput(): PersonalReportAgentOutput {
function agentOutput(overrides: Partial<PersonalReportAgentOutput> = {}): PersonalReportAgentOutput {
return {
executiveSummary: {
headline: "综合盘面以事业发展为主线",
@@ -102,16 +103,61 @@ function agentOutput(): PersonalReportAgentOutput {
evidenceRefs: ["ev-audit-2"],
},
],
...overrides,
};
}
function agentOutputForBundle(bundle: ReportEvidenceBundleV2): PersonalReportAgentOutput {
const claim = bundle.claimCards[0];
if (claim) {
const evidenceRef = claim.supportingFacts[0]?.evidenceRef ?? claim.executedTechniqueRefs[0];
assert.ok(evidenceRef, "a claim card must expose at least one citeable evidence ref");
return agentOutput({
thematicNarrative: [{
id: claim.theme,
title: claim.section,
narrative: claim.conclusion,
actions: [],
caveats: [],
claimStatus: claim.assertionLevel,
evidenceRefs: [evidenceRef],
}],
});
}
const blocked = bundle.blockedSections[0];
const evidenceRef = blocked?.missingTechniqueRefs[0];
assert.ok(blocked && evidenceRef, "a blocked bundle must expose a blocked section and evidence ref");
return agentOutput({
thematicNarrative: [{
id: blocked.theme,
title: blocked.section,
narrative: blocked.reason,
actions: [],
caveats: [blocked.reason],
claimStatus: "blocked",
evidenceRefs: [evidenceRef],
}],
});
}
const fakeAgent: ReportAgentPort = {
modelId: "test-model",
async generate() {
return agentOutput();
async generate(bundle) {
return agentOutputForBundle(bundle);
},
};
function capturingAgent(capture: { input: unknown }): ReportAgentPort {
return {
modelId: "test-model",
async generate(bundle) {
capture.input = bundle;
return agentOutputForBundle(bundle);
},
};
}
const SKILL_SNAPSHOT = {
name: "jyotish-vedic-astrology",
version: "6.9.14",
@@ -533,6 +579,132 @@ test("core create: 201 ready with a document on the happy path", async () => {
assert.equal(row?.reportDocument?.provenance.skillSourceCommit, SKILL_SNAPSHOT.sourceCommit);
});
test("core create: full report runs every requested theme and sends only bundle v2 to the agent", async () => {
const requestedThemes = ["career", "marriage", "wealth", "timing"];
const workflowThemes: string[] = [];
const capture: { input: unknown } = { input: null };
const response = await resolveReportCreate(baseDeps({
rawBody: {
requestId: UUID_B,
reportType: "personal_full",
presentationMode: "default",
themes: requestedThemes,
},
runWorkflow: async (input) => {
workflowThemes.push(input.theme);
const workflow = chartPayload() as Record<string, unknown>;
const consumer = workflow.consumer_context as Record<string, unknown>;
consumer.route = input.theme;
return workflow;
},
createAgent: () => capturingAgent(capture),
}));
assert.equal(response.status, 201);
assert.deepEqual(workflowThemes, requestedThemes);
const bundle = capture.input as ReportEvidenceBundleV2;
assert.equal(bundle.schemaVersion, "report_evidence_bundle.v2");
const coveredThemes = new Set([
...bundle.claimCards.map((card) => card.theme),
...bundle.blockedSections.map((section) => section.theme),
]);
for (const theme of requestedThemes) {
assert.equal(coveredThemes.has(theme), true, `${theme} must have a claim card or blocked section`);
}
});
test("core create: accepted partial wealth evidence stays ready, blocks D2/D11 and leaks no raw internals", async () => {
const capture: { input: unknown } = { input: null };
const persistence = new MemoryPersistence();
const response = await resolveReportCreate(baseDeps({
persistence,
profile: profileFixture({ birth_time_status: "accepted" }),
rawBody: {
requestId: UUID_B,
reportType: "personal_thematic",
presentationMode: "default",
themes: ["wealth"],
},
runWorkflow: async (input) => {
assert.equal(input.theme, "wealth");
const workflow = chartPayload() as Record<string, unknown>;
workflow.api_key = "sk-private-report-secret";
workflow.authorization = "Bearer private-token";
workflow.cookie = "session=private-cookie";
workflow.database_url = "postgres://private-db";
workflow.internal_path = "/Users/private/project/engine.py";
workflow.runtime_path = "/opt/jyotisha-production/private.py";
workflow.latitude = 39.9;
workflow.longitude = 116.4;
workflow.prompt = "raw hidden prompt";
workflow.traceback = "Traceback: raw tool failure";
workflow.calculation_profile = {
ayanamsa: "AKIAIOSFODNN7EXAMPLE",
node_mode: "prod-db-01",
house_system: "internal-host-22",
};
const chart = workflow.chart as Record<string, unknown>;
const dasha = chart.dasha as Record<string, unknown>;
(dasha.mahadashas as Record<string, unknown>[]).push({
lord: "Moon",
start: "39.9000",
end: "116.4000",
});
const consumer = workflow.consumer_context as Record<string, unknown>;
consumer.route = "wealth";
consumer.available_layers = ["D1", "Vimshottari"];
consumer.missing_route_layers = ["D2", "D11"];
consumer.hard_blockers = [];
const answerPolicy = consumer.answer_policy as Record<string, unknown>;
answerPolicy.deterministic_claims_forbidden_for = ["timing", "prod-db-01"];
const machine = workflow.machine_evidence_packet as Record<string, unknown>;
machine.conflicts = [{
techniques: ["D1"],
summary: "Bearer opaque-conflict at /Users/private/trace.py",
}];
const sections = machine.sections as Record<string, unknown>;
sections.AKIAIOSFODNN7EXAMPLE = { status: "used", source_path: "modules.internal" };
return workflow;
},
createAgent: () => capturingAgent(capture),
}));
assert.equal(response.status, 201);
assert.equal(persistence.rows.get(REPORT_ID)?.status, "ready");
const bundle = capture.input as ReportEvidenceBundleV2;
assert.equal(bundle.schemaVersion, "report_evidence_bundle.v2");
assert.equal(bundle.subject.birthTimeStatus, "accepted");
assert.equal(bundle.answerPolicy.birthTimePolicy, "accepted_directional_only");
assert.equal("candidateRange" in bundle, false);
assert.ok(bundle.blockedSections.some((section) => section.theme === "wealth"));
for (const technique of ["D2", "D11"]) {
const receipt = bundle.executionLedger.find((entry) => entry.technique.toUpperCase() === technique);
assert.ok(receipt, `${technique} must have an explicit non-execution receipt`);
assert.equal(receipt.executed, false);
assert.notEqual(receipt.status, "verified");
}
const serialized = JSON.stringify(bundle);
for (const forbidden of [
"sk-private-report-secret",
"Bearer private-token",
"private-cookie",
"postgres://private-db",
"/Users/private",
"/opt/jyotisha-production",
"39.9",
"116.4",
"raw hidden prompt",
"Traceback: raw tool failure",
"AKIAIOSFODNN7EXAMPLE",
"prod-db-01",
"internal-host-22",
"opaque-conflict",
]) {
assert.equal(serialized.includes(forbidden), false, `bundle leaked ${forbidden}`);
}
});
test("core create: production deferral returns 202 before background generation completes", async () => {
const persistence = new MemoryPersistence();
let scheduled: (() => Promise<void>) | null = null;
+542 -13
View File
@@ -1,11 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { safeParseServerReportDocument, computeEvidenceHash } from "../src/lib/personal-report-contract.server-core.ts";
import {
computeReportEvidenceBundleHash,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
import {
REPORT_STABLE_CODES,
ReportEvidenceInsufficientError,
applyReportGuard,
assembleReportDocument,
buildReportEvidenceBundleV2,
buildReportEvidencePacket,
canonicalSerialize,
computeRequestFingerprint,
@@ -708,12 +715,22 @@ function fakeAgent(output: PersonalReportAgentOutput, calls: { count: number }):
}
test("generatePersonalReport returns a ready document that passes the server parse", async () => {
const packet = buildPacket();
const bundle = bundleV2Fixture();
const calls = { count: 0 };
const result = await generatePersonalReport({
reportId: "22222222-2222-4222-8222-222222222222",
packet,
agent: fakeAgent(agentOutput(), calls),
bundle,
agent: fakeAgent(agentOutput({
thematicNarrative: [{
id: "career",
title: "事业",
narrative: "事业主题存在可审慎表达的结构性线索。",
actions: [],
caveats: [],
claimStatus: "multi_system_consensus",
evidenceRefs: ["ev-tech-d1", "ev-tech-d10"],
}],
}), calls),
now: () => new Date("2026-08-06T00:00:00.000Z"),
});
assert.equal(calls.count, 1);
@@ -725,10 +742,10 @@ test("generatePersonalReport returns a ready document that passes the server par
});
test("generatePersonalReport fails with report_guard_rejected on guard rejection", async () => {
const packet = buildPacket();
const bundle = bundleV2Fixture();
const result = await generatePersonalReport({
reportId: "22222222-2222-4222-8222-222222222222",
packet,
bundle,
agent: fakeAgent(agentOutput({
thematicNarrative: [
{
@@ -738,7 +755,7 @@ test("generatePersonalReport fails with report_guard_rejected on guard rejection
actions: [],
caveats: [],
claimStatus: "single_system_inference",
evidenceRefs: ["ev-audit-1"],
evidenceRefs: ["ev-tech-d1"],
},
],
}), { count: 0 }),
@@ -747,16 +764,12 @@ test("generatePersonalReport fails with report_guard_rejected on guard rejection
});
test("generatePersonalReport fails with report_schema_invalid when the final parse rejects", async () => {
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
const consumer = workflow.consumer_context as Record<string, unknown>;
consumer.hard_blockers = ["Narayana"];
consumer.available_layers = ["D1", "Vimshottari"];
const packet = buildPacket({ workflow });
const blockedRef = packet.evidenceRefs.find((ref) => ref.status === "blocked");
const bundle = bundleV2Fixture();
const blockedRef = bundle.evidenceRefs.find((ref) => ref.status === "blocked");
assert.ok(blockedRef);
const result = await generatePersonalReport({
reportId: "22222222-2222-4222-8222-222222222222",
packet,
bundle,
agent: fakeAgent(agentOutput({
thematicNarrative: [
{
@@ -792,3 +805,519 @@ test("skill snapshot is the real packaged manifest sha256, never the literal unk
test("stable codes include the request-conflict mapping", () => {
assert.equal(REPORT_STABLE_CODES.requestConflict, "report_request_conflict");
});
// ---------------------------------------------------------------------------
// ReportEvidenceBundle v2 contract: hash, coverage and closed references
// ---------------------------------------------------------------------------
function bundleV2Fixture(): ReportEvidenceBundleV2 {
return finalizeReportEvidenceBundleV2({
schemaVersion: "report_evidence_bundle.v2",
subject: {
displayName: "测试用户",
birthTimeStatus: "accepted",
birthPlaceLabel: "北京",
},
requestedThemes: ["career", "wealth"],
reportType: "personal_full",
presentationMode: "default",
calculationProfile: {
calculationHash: "c".repeat(64),
calculationHashDerived: false,
birthTimeStatus: "accepted",
ayanamsa: "Lahiri",
nodeMode: "true",
houseSystem: "whole_sign",
vimshottari: [{ lord: "Moon", start: "2019-01-01", end: "2029-01-01" }],
narayana: [{ lord: "Sun", start: "2023-01-01", end: "2026-01-01" }],
},
skill: {
name: "jyotish-vedic-astrology",
version: "6.9.14",
sha256: "a".repeat(64),
sourceCommit: "b".repeat(40),
},
charts: [
{
id: "D1",
title: "D1 Rasi",
ascendant: { sign: "Aries", degree: 12.5 },
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index],
signDerived: false,
occupants: index === 0 ? ["Sun", "Mars"] : [],
})),
planets: [
{ id: "Sun", sign: "Aries", degree: 12.5, house: 1, retrograde: false },
{ id: "Mars", sign: "Aries", degree: 18.25, house: 1, retrograde: false },
],
},
{
id: "D10",
title: "D10 Dasamsa",
ascendant: { sign: "Capricorn", degree: 4.5 },
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: ["Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius"][index],
signDerived: false,
occupants: [],
})),
planets: [],
},
],
claimCards: [{
id: "ev-claim-career",
theme: "career",
section: "事业与方向",
conclusion: "事业主题存在可审慎陈述的结构性线索。",
supportingFacts: [
{
id: "ev-fact-career-d1",
label: "D1 十宫结构",
value: "十宫结构已由正式计算结果投影。",
evidenceRef: "ev-tech-d1",
status: "verified",
},
{
id: "ev-fact-career-d10",
label: "D10 事业分盘",
value: "D10 已由正式计算结果投影。",
evidenceRef: "ev-tech-d10",
status: "verified",
},
],
counterFacts: [],
executedTechniqueRefs: ["ev-tech-d1", "ev-tech-d10"],
assertionLevel: "multi_system_consensus",
timingBoundary: null,
verificationQuestions: [],
}],
blockedSections: [{
id: "ev-blocked-wealth",
theme: "wealth",
section: "财富结构",
reason: "缺少 D2 与 D11 正式分盘,不能形成财富结论。",
missingTechniqueRefs: ["ev-tech-d2", "ev-tech-d11"],
}],
conflicts: [],
executionLedger: [
{ id: "ev-tech-d1", technique: "D1", status: "verified", executed: true, note: "基础盘已执行" },
{ id: "ev-tech-d10", technique: "D10", status: "verified", executed: true, note: "事业分盘已执行" },
{ id: "ev-tech-d2", technique: "D2", status: "blocked", executed: false, note: "未提供" },
{ id: "ev-tech-d11", technique: "D11", status: "blocked", executed: false, note: "未提供" },
],
evidenceRefs: [
{ id: "ev-tech-d1", technique: "D1", status: "verified" },
{ id: "ev-tech-d10", technique: "D10", status: "verified" },
{ id: "ev-tech-d2", technique: "D2", status: "blocked" },
{ id: "ev-tech-d11", technique: "D11", status: "blocked" },
],
answerPolicy: {
canAnswerPreciseTiming: false,
birthTimePolicy: "accepted_directional_only",
deterministicClaimsForbiddenFor: ["timing", "medical", "investment"],
},
});
}
function withoutBundleHash(bundle: ReportEvidenceBundleV2): Omit<ReportEvidenceBundleV2, "bundleHash"> {
const { bundleHash, ...content } = bundle;
void bundleHash;
return content;
}
test("bundle v2 covers every requested theme with a claim card or blocked section", () => {
const bundle = bundleV2Fixture();
const coveredThemes = new Set([
...bundle.claimCards.map((card) => card.theme),
...bundle.blockedSections.map((section) => section.theme),
]);
assert.deepEqual([...coveredThemes].sort(), [...bundle.requestedThemes].sort());
assert.deepEqual(validateReportEvidenceBundleV2(bundle), bundle);
});
test("bundle v2 accepted birth time has an explicit directional policy and no fake candidateRange", () => {
const bundle = bundleV2Fixture();
assert.equal(bundle.subject.birthTimeStatus, "accepted");
assert.equal(bundle.calculationProfile.birthTimeStatus, "accepted");
assert.equal(bundle.answerPolicy.birthTimePolicy, "accepted_directional_only");
assert.equal(bundle.answerPolicy.canAnswerPreciseTiming, false);
assert.equal("candidateRange" in bundle, false);
assert.doesNotMatch(JSON.stringify(bundle), /candidateRange/);
});
test("bundle v2 schema rejects opaque techniques, calculation labels and fake dasha coordinates", () => {
const bundle = bundleV2Fixture();
const content = withoutBundleHash(bundle);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
executionLedger: content.executionLedger.map((receipt, index) => (
index === 0 ? { ...receipt, technique: "AKIAIOSFODNN7EXAMPLE" } : receipt
)),
evidenceRefs: content.evidenceRefs.map((ref, index) => (
index === 0 ? { ...ref, technique: "AKIAIOSFODNN7EXAMPLE" } : ref
)),
}), /report_bundle_technique_not_allowlisted/);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
calculationProfile: {
...content.calculationProfile,
nodeMode: "prod-db-01",
} as unknown as ReportEvidenceBundleV2["calculationProfile"],
}), /Invalid enum value/);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
calculationProfile: {
...content.calculationProfile,
vimshottari: [{ lord: "Moon", start: "39.9000", end: "116.4000" }],
},
}), /report_bundle_dasha_date_invalid/);
});
test("bundle v2 validator rejects direct free-text policy and conflict injection before Agent use", () => {
const bundle = bundleV2Fixture();
const content = withoutBundleHash(bundle);
const forgedConflictContent = {
...content,
conflicts: [{
id: "ev-conflict-1",
techniqueRefs: ["ev-tech-d1"],
summary: "Authorization: Bearer secret-token at /Users/private/trace.py",
resolutionStatus: "unresolved",
}],
} as unknown as Omit<ReportEvidenceBundleV2, "bundleHash">;
assert.throws(() => validateReportEvidenceBundleV2({
...forgedConflictContent,
bundleHash: computeReportEvidenceBundleHash(forgedConflictContent),
}), /Invalid enum value/);
const forgedPolicyContent = {
...content,
answerPolicy: {
...content.answerPolicy,
deterministicClaimsForbiddenFor: [
"timing",
"AKIAIOSFODNN7EXAMPLE",
"prod-db-01",
"internal-host-22",
],
},
} as unknown as Omit<ReportEvidenceBundleV2, "bundleHash">;
assert.throws(() => validateReportEvidenceBundleV2({
...forgedPolicyContent,
bundleHash: computeReportEvidenceBundleHash(forgedPolicyContent),
}), /Invalid enum value/);
});
test("bundle v2 claim technique and fact references are closed", () => {
const bundle = bundleV2Fixture();
const ledgerIds = new Set(bundle.executionLedger.map((receipt) => receipt.id));
const evidenceIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
for (const card of bundle.claimCards) {
for (const ref of card.executedTechniqueRefs) assert.equal(ledgerIds.has(ref), true);
for (const fact of [...card.supportingFacts, ...card.counterFacts]) {
assert.equal(evidenceIds.has(fact.evidenceRef), true);
}
}
const content = withoutBundleHash(bundle);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
executionLedger: content.executionLedger.filter((receipt) => receipt.id !== "ev-tech-d10"),
}), /report_bundle_invalid_technique_ref:ev-tech-d10/);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
claimCards: content.claimCards.map((card) => ({
...card,
supportingFacts: card.supportingFacts.map((fact) => ({
...fact,
evidenceRef: "ev-unknown-fact-ref",
})),
})),
}), /report_bundle_invalid_fact_ref:ev-unknown-fact-ref/);
});
test("bundle v2 blocked or partial techniques cannot support multi-system consensus", () => {
const bundle = bundleV2Fixture();
const content = withoutBundleHash(bundle);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
executionLedger: content.executionLedger.map((receipt) => (
receipt.id === "ev-tech-d10" ? { ...receipt, status: "partial" as const } : receipt
)),
evidenceRefs: content.evidenceRefs.map((ref) => (
ref.id === "ev-tech-d10" ? { ...ref, status: "partial" as const } : ref
)),
}), /report_bundle_invalid_consensus:ev-claim-career/);
assert.throws(() => finalizeReportEvidenceBundleV2({
...content,
executionLedger: content.executionLedger.map((receipt) => (
receipt.id === "ev-tech-d10"
? { ...receipt, status: "blocked" as const, executed: false }
: receipt
)),
evidenceRefs: content.evidenceRefs.map((ref) => (
ref.id === "ev-tech-d10" ? { ...ref, status: "blocked" as const } : ref
)),
}), /report_bundle_invalid_technique_ref:ev-tech-d10/);
});
test("bundle v2 hash is collection-order stable, content-sensitive and excludes bundleHash itself", () => {
const bundle = bundleV2Fixture();
const content = withoutBundleHash(bundle);
const reordered = {
...content,
requestedThemes: [...content.requestedThemes].reverse(),
calculationProfile: {
...content.calculationProfile,
vimshottari: content.calculationProfile.vimshottari
? [...content.calculationProfile.vimshottari].reverse()
: null,
narayana: content.calculationProfile.narayana
? [...content.calculationProfile.narayana].reverse()
: null,
},
charts: [...content.charts].reverse().map((chart) => ({
...chart,
houses: [...chart.houses].reverse().map((house) => ({
...house,
occupants: [...house.occupants].reverse(),
})),
planets: [...chart.planets].reverse(),
})),
claimCards: [...content.claimCards].reverse().map((card) => ({
...card,
supportingFacts: [...card.supportingFacts].reverse(),
counterFacts: [...card.counterFacts].reverse(),
executedTechniqueRefs: [...card.executedTechniqueRefs].reverse(),
verificationQuestions: [...card.verificationQuestions].reverse(),
})),
blockedSections: [...content.blockedSections].reverse().map((section) => ({
...section,
missingTechniqueRefs: [...section.missingTechniqueRefs].reverse(),
})),
conflicts: [...content.conflicts].reverse().map((conflict) => ({
...conflict,
techniqueRefs: [...conflict.techniqueRefs].reverse(),
})),
executionLedger: [...content.executionLedger].reverse(),
evidenceRefs: [...content.evidenceRefs].reverse(),
answerPolicy: {
...content.answerPolicy,
deterministicClaimsForbiddenFor: [...content.answerPolicy.deterministicClaimsForbiddenFor].reverse(),
},
};
assert.equal(computeReportEvidenceBundleHash(content), bundle.bundleHash);
assert.equal(computeReportEvidenceBundleHash(reordered), bundle.bundleHash);
const changed = {
...content,
claimCards: content.claimCards.map((card) => ({
...card,
conclusion: `${card.conclusion} 内容已改变。`,
})),
};
assert.notEqual(computeReportEvidenceBundleHash(changed), bundle.bundleHash);
const forgedHash = { ...bundle, bundleHash: "f".repeat(64) };
assert.equal(computeReportEvidenceBundleHash(withoutBundleHash(forgedHash)), bundle.bundleHash);
assert.throws(() => validateReportEvidenceBundleV2(forgedHash), /report_bundle_hash_mismatch/);
});
test("bundle v2 builder blocks incomplete wealth evidence without leaking workflow internals", () => {
const generalWorkflow = pythonStyleChartPayload() as Record<string, unknown>;
const generalChart = generalWorkflow.chart as Record<string, unknown>;
const generalPlanets = generalChart.planets as Record<string, unknown>;
generalPlanets["AuthorizationSecretPlanet"] = {
sign: "Leo",
degree: 142.5,
house: 1,
retrograde: false,
};
const generalDasha = generalChart.dasha as Record<string, unknown>;
(generalDasha.mahadashas as Record<string, unknown>[]).push(
{
lord: "/opt/private/dasha-secret",
start: "2029-01-01",
end: "2030-01-01",
},
{
lord: "Moon",
start: "39.9000",
end: "116.4000",
},
);
generalWorkflow.calculation_profile = {
ayanamsa: "/opt/private/calculation-secret",
node_mode: "prod-db-01",
house_system: "internal-host-22",
};
const wealthWorkflow = pythonStyleChartPayload() as Record<string, unknown>;
wealthWorkflow.api_key = "sk-private-report-secret";
wealthWorkflow.authorization = "Bearer private-token";
wealthWorkflow.database_url = "postgres://private-db";
wealthWorkflow.internal_path = "/Users/private/project/engine.py";
wealthWorkflow.runtime_path = "/opt/jyotisha-production/private.py";
wealthWorkflow.latitude = 39.9;
wealthWorkflow.longitude = 116.4;
wealthWorkflow.prompt = "raw hidden prompt";
wealthWorkflow.traceback = "Traceback: raw tool failure";
const wealthConsumer = wealthWorkflow.consumer_context as Record<string, unknown>;
const wealthPolicy = (wealthConsumer.answer_policy ?? {}) as Record<string, unknown>;
wealthPolicy.deterministic_claims_forbidden_for = [
"timing",
"/opt/private/policy-secret",
"secret_token",
];
wealthConsumer.answer_policy = wealthPolicy;
const wealthMachine = wealthWorkflow.machine_evidence_packet as Record<string, unknown>;
wealthMachine.conflicts = [{
techniques: ["D1"],
summary: "Authorization: Bearer secret-token at /Users/private/trace.py",
}];
const wealthSections = wealthMachine.sections as Record<string, unknown>;
wealthSections["Authorization: Bearer section-secret at /Users/private/tool.py"] = {
status: "used",
source_path: "modules.secret",
};
wealthSections.AKIAIOSFODNN7EXAMPLE = {
status: "used",
source_path: "modules.internal",
};
const bundle = buildReportEvidenceBundleV2({
workflows: [
{ theme: "general", workflow: generalWorkflow },
{ theme: "wealth", workflow: wealthWorkflow },
],
subject: {
displayName: "测试用户",
birthTimeStatus: "accepted",
birthPlaceLabel: "北京",
},
requestedThemes: ["general", "wealth"],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-vedic-astrology",
version: "6.9.14",
sha256: "a".repeat(64),
sourceCommit: "b".repeat(40),
},
});
assert.equal(bundle.schemaVersion, "report_evidence_bundle.v2");
assert.ok(bundle.claimCards.some((card) => card.theme === "general"));
assert.ok(bundle.blockedSections.some((section) => section.theme === "wealth"));
assert.equal("candidateRange" in bundle, false);
assert.equal(bundle.answerPolicy.birthTimePolicy, "accepted_directional_only");
assert.equal(bundle.answerPolicy.canAnswerPreciseTiming, false);
assert.equal(bundle.calculationProfile.ayanamsa, null);
assert.equal(bundle.calculationProfile.nodeMode, null);
assert.equal(bundle.calculationProfile.houseSystem, null);
assert.equal(bundle.charts.some((chart) => (
chart.planets.some((planet) => planet.id.includes("AuthorizationSecretPlanet"))
|| chart.houses.some((house) => house.occupants.includes("AuthorizationSecretPlanet"))
)), false);
assert.equal(bundle.calculationProfile.vimshottari?.some((period) => (
period.lord.includes("dasha-secret")
)), false);
assert.deepEqual(bundle.answerPolicy.deterministicClaimsForbiddenFor, ["timing"]);
assert.ok(bundle.conflicts.some((conflict) => (
conflict.techniqueRefs.length === 1
&& conflict.techniqueRefs[0] === "ev-tech-d1"
)));
assert.ok(bundle.conflicts.every((conflict) => (
conflict.summary === "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
&& conflict.resolutionStatus === "unresolved"
)));
assert.equal(
bundle.executionLedger.some((receipt) => receipt.technique.includes("section-secret")),
false,
);
for (const technique of ["D2", "D11"]) {
const receipts = bundle.executionLedger.filter((entry) => (
entry.technique.toUpperCase() === technique
|| entry.technique.toUpperCase().endsWith(`:${technique}`)
));
assert.ok(receipts.length > 0, `${technique} must have an explicit receipt`);
assert.equal(receipts.some((receipt) => receipt.executed || receipt.status === "verified"), false);
}
const ledgerIds = new Set(bundle.executionLedger.map((receipt) => receipt.id));
const evidenceIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
for (const card of bundle.claimCards) {
for (const ref of card.executedTechniqueRefs) assert.equal(ledgerIds.has(ref), true);
for (const fact of [...card.supportingFacts, ...card.counterFacts]) {
assert.equal(evidenceIds.has(fact.evidenceRef), true);
}
}
const serialized = JSON.stringify(bundle);
for (const forbidden of [
"sk-private-report-secret",
"Bearer private-token",
"postgres://private-db",
"/Users/private",
"/opt/jyotisha-production",
"39.9",
"116.4",
"raw hidden prompt",
"Traceback: raw tool failure",
"secret-token",
"/opt/private",
"secret_token",
"section-secret",
"AuthorizationSecretPlanet",
"dasha-secret",
"calculation-secret",
"AKIAIOSFODNN7EXAMPLE",
"prod-db-01",
"internal-host-22",
]) {
assert.equal(serialized.includes(forbidden), false, `bundle leaked ${forbidden}`);
}
});
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>;
consumer.available_layers = [
...(consumer.available_layers as string[]),
"Transit",
];
const machine = workflow.machine_evidence_packet as Record<string, unknown>;
const sections = machine.sections as Record<string, unknown>;
sections.Transit = { status: "available", source_path: "modules.transit" };
const bundle = buildReportEvidenceBundleV2({
workflows: [{ theme: "timing", workflow }],
subject: {
displayName: "测试用户",
birthTimeStatus: "confirmed",
birthPlaceLabel: "北京",
},
requestedThemes: ["timing"],
reportType: "personal_thematic",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-vedic-astrology",
version: "6.9.14",
sha256: "a".repeat(64),
sourceCommit: "b".repeat(40),
},
});
assert.equal(bundle.claimCards.some((card) => card.theme === "timing"), false);
assert.equal(bundle.blockedSections.some((section) => section.theme === "timing"), true);
const transitReceipts = bundle.executionLedger.filter((receipt) => (
receipt.technique === "Transit" || receipt.technique.endsWith(":Transit")
));
assert.ok(transitReceipts.length > 0);
assert.equal(transitReceipts.some((receipt) => receipt.executed), false);
});