Files
Jyotisha/frontend/tests/personal-report-generation-v2.test.ts
T
2026-08-15 05:10:37 +08:00

532 lines
18 KiB
TypeScript

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<Record<string, string>> = {
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<string, EvidenceRefStatus>();
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<GeneratePersonalReportResult> {
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<void>((resolve) => {
writerStarted = resolve;
});
const agent: ReportAgentPort = {
modelId: "abort-aware-report-writer",
async generate(_inputBundle, _plan, options) {
observedSignal = options?.signal;
writerStarted();
return new Promise<PersonalReportAgentOutput>((_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);
});
});