import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; 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 { finalizeReportEvidenceBundleV2, type BlockedSection, type EvidenceRefStatus, type ReportChartFact, type ReportClaimCard, type ReportEvidenceBundleV2, type TechniqueExecutionReceipt, } from "../src/lib/report-evidence-bundle-v2.ts"; import { generatePersonalReport, type GeneratePersonalReportResult, } from "../src/lib/personal-report-generation.ts"; import type { PersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts"; import type { PersonalReportAgentOutput, ReportAgentPort, } from "../src/mastra/personal-report.ts"; const REPORT_ID = "22222222-2222-4222-8222-222222222222"; const GENERATED_AT = "2026-08-14T00:00:00.000Z"; const SIGNS = [ "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", ] as const; type ThemeSpec = Readonly<{ theme: string; section: string; refs: readonly string[]; assertionLevel?: ReportClaimCard["assertionLevel"]; }>; type BlockedThemeSpec = Readonly<{ theme: string; section: string; refs: readonly string[]; }>; function chart(id: ReportChartFact["id"], offset = 0): ReportChartFact { return { id, title: `${id} 正式分盘`, ascendant: { sign: SIGNS[offset % 12], degree: 10 + offset }, houses: Array.from({ length: 12 }, (_, index) => ({ number: index + 1, sign: SIGNS[(index + offset) % 12], signDerived: false, occupants: index === 0 ? ["Sun"] : [], })), planets: [{ id: "Sun", sign: SIGNS[offset % 12], degree: 10 + offset, house: 1, retrograde: false, }], }; } function techniqueName(ref: string): string { const slug = ref.replace(/^ev-tech-/, ""); const canonical: Readonly> = { vimshottari: "Vimshottari", narayana: "Narayana", transit: "Transit", }; return canonical[slug] ?? slug.toUpperCase(); } function makeClaim(spec: ThemeSpec): ReportClaimCard { return { id: `ev-claim-${spec.theme.replaceAll(".", "-")}`, theme: spec.theme, section: spec.section, conclusion: `${spec.section}存在可由正式计算证据支持的方向性结构。`, supportingFacts: spec.refs.map((ref, index) => ({ id: `ev-fact-${spec.theme.replaceAll(".", "-")}-${index + 1}`, label: `${spec.section}证据 ${index + 1}`, value: `${techniqueName(ref)} 已由正式计算结果投影。`, evidenceRef: ref, status: "verified" as const, })), counterFacts: [], executedTechniqueRefs: [...spec.refs], assertionLevel: spec.assertionLevel ?? (spec.refs.length > 1 ? "multi_system_consensus" : "single_system_inference"), timingBoundary: null, verificationQuestions: [], }; } function makeBundle(input: Readonly<{ themes: readonly ThemeSpec[]; blocked?: readonly BlockedThemeSpec[]; charts?: readonly ReportChartFact[]; birthTimeStatus?: "reported" | "candidate" | "accepted" | "confirmed"; }>): ReportEvidenceBundleV2 { const blocked = input.blocked ?? []; const statuses = new Map(); statuses.set("ev-tech-d1", "verified"); for (const theme of input.themes) { for (const ref of theme.refs) statuses.set(ref, "verified"); } for (const theme of blocked) { for (const ref of theme.refs) statuses.set(ref, "blocked"); } const executionLedger: TechniqueExecutionReceipt[] = [...statuses].map(([id, status]) => ({ id, technique: techniqueName(id), status, executed: status !== "blocked", note: status === "blocked" ? "正式证据缺失" : "正式计算已执行", })); const birthTimeStatus = input.birthTimeStatus ?? "accepted"; const blockedSections: BlockedSection[] = blocked.map((spec) => ({ id: `ev-blocked-${spec.theme.replaceAll(".", "-")}`, theme: spec.theme, section: spec.section, reason: `${spec.section}缺少正式证据,不能形成结论。`, missingTechniqueRefs: [...spec.refs], })); return finalizeReportEvidenceBundleV2({ schemaVersion: "report_evidence_bundle.v2", subject: { displayName: "测试用户", birthTimeStatus, birthPlaceLabel: "北京", }, requestedThemes: [ ...input.themes.map((entry) => entry.theme), ...blocked.map((entry) => entry.theme), ], reportType: "personal_full", presentationMode: "research", calculationProfile: { calculationHash: "c".repeat(64), calculationHashDerived: false, birthTimeStatus, 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-personal-report", version: "1.0.0", sha256: "a".repeat(64), sourceCommit: "b".repeat(40), }, charts: input.charts ?? [chart("D1")], claimCards: input.themes.map(makeClaim), blockedSections, conflicts: [], executionLedger, evidenceRefs: executionLedger.map(({ id, technique, status }) => ({ id, technique, status })), answerPolicy: { canAnswerPreciseTiming: birthTimeStatus === "confirmed", birthTimePolicy: birthTimeStatus === "confirmed" ? "confirmed" : `${birthTimeStatus}_directional_only`, deterministicClaimsForbiddenFor: ["timing", "medical", "investment"], }, }); } function writerOutputFor( bundle: ReportEvidenceBundleV2, plan: PersonalReportSectionPlan, ): PersonalReportAgentOutput { const cardsByTheme = new Map(bundle.claimCards.map((card) => [card.theme, card])); return { executiveSummary: { headline: "正式证据支持多个主题的审慎方向性判断", summary: "本报告只整理服务器已验证的 Claim Card,并对缺失证据的主题保持阻断。", priorities: ["先核对最重要的现实问题", "再观察方向性线索是否与经历一致"], }, thematicNarrative: plan.sections .filter((section) => section.kind === "thematic" && section.disposition === "write") .map((section) => { assert.ok(section.theme); const card = cardsByTheme.get(section.theme); assert.ok(card); return { id: section.id, theme: section.theme, title: card.section, narrative: card.conclusion, actions: [`围绕${card.section}记录可验证的现实反馈`], caveats: ["该结论不得脱离所列证据引用。"], claimStatus: card.assertionLevel, evidenceRefs: [...section.evidenceRefs], }; }), }; } function fakeWriter( produce: (bundle: ReportEvidenceBundleV2, plan: PersonalReportSectionPlan) => PersonalReportAgentOutput, observed?: { calls: number; plan: PersonalReportSectionPlan | null }, ): ReportAgentPort { return { modelId: "test-report-writer", async generate(bundle, plan) { if (observed) { observed.calls += 1; observed.plan = plan; } assert.ok(plan, "producer must pass the deterministic section plan to the writer"); return produce(bundle, plan); }, }; } async function run( bundle: ReportEvidenceBundleV2, agent: ReportAgentPort, depth: "concise" | "standard" | "deep" | "research" = "research", ): Promise { return generatePersonalReport({ reportId: REPORT_ID, bundle, depth, agent, now: () => new Date(GENERATED_AT), }); } function readyV2(result: GeneratePersonalReportResult): ReportDocumentV2 { if (result.status !== "ready") throw new Error(`expected ready, got ${result.failureCode}`); assert.equal(result.status, "ready"); assert.equal(result.document.schemaVersion, "report_document.v2"); const parsed = safeParseServerReportDocument(result.document); assert.equal(parsed.ok, true); return result.document as ReportDocumentV2; } function expectSchemaRejected(result: GeneratePersonalReportResult): void { assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid" }); } const fullThemes: readonly ThemeSpec[] = [ { theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }, { theme: "marriage", section: "关系与婚恋", refs: ["ev-tech-d1", "ev-tech-d9"] }, { theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"] }, { theme: "education", section: "学习与成长", refs: ["ev-tech-d1", "ev-tech-d24"] }, ]; test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real charts, depth and report-skill provenance", async () => { const bundle = makeBundle({ themes: fullThemes, charts: [ chart("D1", 0), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4), chart("D24", 5), ], }); const observed = { calls: 0, plan: null as PersonalReportSectionPlan | null }; const result = await run(bundle, fakeWriter(writerOutputFor, observed)); const document = readyV2(result); assert.equal(observed.calls, 1); assert.equal(observed.plan?.depth, "research"); assert.deepEqual( observed.plan?.sections .filter((section) => section.kind === "thematic") .map((section) => [section.id, section.theme, section.disposition]), bundle.requestedThemes.map((theme) => [`theme-${theme}`, theme, "write"]), ); assert.equal(document.depth, "research"); assert.deepEqual(document.requestedThemes, bundle.requestedThemes); assert.deepEqual(document.thematicNarrative.map((section) => section.theme), document.requestedThemes); assert.deepEqual(document.blockedConflictDisclosure, []); assert.deepEqual( [...document.charts.map((entry) => entry.id)].sort(), ["D1", "D2", "D9", "D10", "D11", "D24"].sort(), ); assert.ok(document.charts.every((entry) => entry.evidenceRefs.length > 0)); assert.equal(document.provenance.skillName, "jyotish-personal-report"); assert.equal(document.provenance.reportContractVersion, "2"); assert.equal(document.schemaVersion, "report_document.v2"); }); test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"], assertionLevel: "single_system_inference", }], charts: [chart("D1"), chart("D10", 3)], }); const controller = new AbortController(); let observedSignal: AbortSignal | undefined; let writerStarted!: () => void; const started = new Promise((resolve) => { writerStarted = resolve; }); const agent: ReportAgentPort = { modelId: "abort-aware-report-writer", async generate(_inputBundle, _plan, options) { observedSignal = options?.signal; writerStarted(); return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }); }); }, }; const pending = generatePersonalReport({ reportId: REPORT_ID, bundle, depth: "standard", agent, signal: controller.signal, now: () => new Date(GENERATED_AT), }); await started; assert.equal(observedSignal, controller.signal); controller.abort(new Error("lease_lost")); assert.deepEqual(await pending, { status: "failed", failureCode: "report_schema_invalid" }); assert.equal(observedSignal?.aborted, true); }); test("production writer and worker keep the same signal on initial and repair model calls", () => { const agentSource = readFileSync( new URL("../src/mastra/personal-report.ts", import.meta.url), "utf8", ); const workerSource = readFileSync( new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8", ); assert.equal(agentSource.match(/abortSignal: signal/g)?.length, 2); assert.match(agentSource, /const signal = options\?\.signal/); assert.match(workerSource, /generatePersonalReport\(\{[\s\S]*signal: context\.signal,[\s\S]*\}\)/); }); test("an all-blocked plan still produces a valid v2 document with honest disclosures and no invented thematic section", async () => { const bundle = makeBundle({ themes: [], blocked: [ { theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"] }, { theme: "timing", section: "当前阶段", refs: ["ev-tech-vimshottari", "ev-tech-transit"] }, ], charts: [chart("D1")], }); const observed = { calls: 0, plan: null as PersonalReportSectionPlan | null }; const result = await run(bundle, fakeWriter(writerOutputFor, observed), "standard"); const document = readyV2(result); assert.equal(observed.calls, 1); assert.ok(observed.plan?.sections .filter((section) => section.kind === "thematic") .every((section) => section.disposition === "blocked")); assert.equal(document.depth, "standard"); assert.deepEqual(document.thematicNarrative, []); assert.equal(document.currentPhase, null); assert.deepEqual( document.blockedConflictDisclosure.map((section) => section.theme), [...bundle.requestedThemes], ); assert.ok(document.blockedConflictDisclosure.every((section) => section.claimStatus === "blocked")); assert.equal(document.charts.length, 1); assert.equal(document.charts[0].id, "D1"); }); test("writer extra, duplicate and blocked themes are rejected", async (t) => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"], assertionLevel: "single_system_inference", }], blocked: [{ theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"] }], charts: [chart("D1"), chart("D10", 3)], }); const cases: readonly Readonly<{ name: string; mutate: (output: PersonalReportAgentOutput) => PersonalReportAgentOutput; }>[] = [ { name: "extra unrequested theme", mutate: (output) => ({ ...output, thematicNarrative: [...output.thematicNarrative, { ...output.thematicNarrative[0], id: "theme-health", theme: "health", }], }), }, { name: "duplicate requested theme", mutate: (output) => ({ ...output, thematicNarrative: [...output.thematicNarrative, { ...output.thematicNarrative[0] }], }), }, { name: "server-blocked theme emitted by writer", mutate: (output) => ({ ...output, thematicNarrative: [...output.thematicNarrative, { ...output.thematicNarrative[0], id: "theme-wealth", theme: "wealth", title: "财富结构", evidenceRefs: ["ev-tech-d2", "ev-tech-d11"], claimStatus: "blocked", }], }), }, ]; for (const entry of cases) { await t.test(entry.name, async () => { const result = await run(bundle, fakeWriter((inputBundle, plan) => ( entry.mutate(writerOutputFor(inputBundle, plan)) ))); expectSchemaRejected(result); }); } }); test("writer section id, evidence refs and claim-status upgrades are rejected", async (t) => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"], assertionLevel: "single_system_inference", }], charts: [chart("D1"), chart("D10", 3)], }); const cases: readonly Readonly<{ name: string; mutate: (section: PersonalReportAgentOutput["thematicNarrative"][number]) => PersonalReportAgentOutput["thematicNarrative"][number]; }>[] = [ { name: "id differs from deterministic plan", mutate: (section) => ({ ...section, id: "career" }), }, { name: "evidence refs are only a subset of the deterministic plan", mutate: (section) => ({ ...section, evidenceRefs: ["ev-tech-d1"] }), }, { name: "claim status is stronger than the Claim Card", mutate: (section) => ({ ...section, claimStatus: "multi_system_consensus" }), }, ]; for (const entry of cases) { await t.test(entry.name, async () => { const result = await run(bundle, fakeWriter((inputBundle, plan) => { const output = writerOutputFor(inputBundle, plan); return { ...output, thematicNarrative: [entry.mutate(output.thematicNarrative[0])], }; })); expectSchemaRejected(result); }); } }); test("accepted birth time never creates a fake candidate range", async () => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }], charts: [chart("D1"), chart("D10", 3)], birthTimeStatus: "accepted", }); const document = readyV2(await run(bundle, fakeWriter(writerOutputFor), "concise")); const serialized = JSON.stringify(document); assert.equal(document.subject.birthTimeStatus, "accepted"); assert.doesNotMatch(serialized, /candidateRange|candidate_range/); assert.doesNotMatch(serialized, /"start"\s*:\s*"([^"]+)"\s*,\s*"end"\s*:\s*"\1"/); }); test("deterministic guard rejection and final schema rejection remain distinct terminal failures", async (t) => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }], charts: [chart("D1"), chart("D10", 3)], }); await t.test("guard rejects a deterministic legal claim", async () => { const result = await run(bundle, fakeWriter((inputBundle, plan) => { const output = writerOutputFor(inputBundle, plan); return { ...output, thematicNarrative: [{ ...output.thematicNarrative[0], narrative: "你必定会胜诉。", }], }; })); assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" }); }); await t.test("schema rejects an overlong writer field", async () => { const result = await run(bundle, fakeWriter((inputBundle, plan) => { const output = writerOutputFor(inputBundle, plan); return { ...output, thematicNarrative: [{ ...output.thematicNarrative[0], title: "过".repeat(161), }], }; })); expectSchemaRejected(result); }); });