532 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|