feat(report): generate grounded reports with Mastra
This commit is contained in:
@@ -0,0 +1,819 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { computeRequestFingerprint } from "../src/lib/personal-report-generation.ts";
|
||||
import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts";
|
||||
import {
|
||||
resolveReportCreate,
|
||||
resolveReportDelete,
|
||||
resolveReportRead,
|
||||
type ReportCreateCoreDeps,
|
||||
type ReportServicePort,
|
||||
} from "../src/lib/personal-report-route-core.ts";
|
||||
import type {
|
||||
CreateGeneratingInput,
|
||||
CreateGeneratingResult,
|
||||
PersonalReportRecord,
|
||||
} from "../src/lib/personal-report-service-core.ts";
|
||||
import type { ReportAgentPort, PersonalReportAgentOutput } from "../src/mastra/personal-report.ts";
|
||||
|
||||
const createRoute = readFileSync(
|
||||
new URL("../src/app/api/reports/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const itemRoute = readFileSync(
|
||||
new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const generationSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-generation.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const codesSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-codes.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const coreSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-route-core.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UUID_A = "11111111-1111-4111-8111-111111111111";
|
||||
const UUID_B = "22222222-2222-4222-8222-222222222222";
|
||||
const REPORT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function chartPayload() {
|
||||
const planets = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
|
||||
.map((name, index) => ({
|
||||
id: name,
|
||||
sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius"][index],
|
||||
degree: 12.5 + index * 10,
|
||||
house: index + 1,
|
||||
retrograde: index === 6,
|
||||
}));
|
||||
const houses = Array.from({ length: 12 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index],
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
chart: {
|
||||
ascendant: { sign: "Leo", degree: 12.5 },
|
||||
planets,
|
||||
houses,
|
||||
dasha: { mahadashas: [{ lord: "Moon", start: "2019-01-01", end: "2029-01-01" }] },
|
||||
modules: { varga_full: { d9: { houses } }, narayana_dasha: { periods: [] } },
|
||||
},
|
||||
consumer_context: {
|
||||
route: "general",
|
||||
core_status: "ready",
|
||||
available_layers: ["D1", "Vimshottari"],
|
||||
missing_route_layers: [],
|
||||
hard_blockers: [],
|
||||
answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] },
|
||||
},
|
||||
machine_evidence_packet: {
|
||||
conflicts: [],
|
||||
sections: [{ name: "Functional Benefic/Malefic", status: "verified", note: "" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agentOutput(): PersonalReportAgentOutput {
|
||||
return {
|
||||
executiveSummary: {
|
||||
headline: "综合盘面以事业发展为主线",
|
||||
summary: "事业结构稳定,财富与婚恋需结合分盘审慎解读。",
|
||||
priorities: ["先聚焦职业方向"],
|
||||
},
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定。",
|
||||
actions: ["在稳定领域深耕"],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fakeAgent: ReportAgentPort = {
|
||||
modelId: "test-model",
|
||||
async generate() {
|
||||
return agentOutput();
|
||||
},
|
||||
};
|
||||
|
||||
const SKILL_SNAPSHOT = { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) };
|
||||
|
||||
function profileFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "测试用户",
|
||||
birth_date: "1997-08-08",
|
||||
active_birth_time: "05:30:00",
|
||||
birth_time_status: "confirmed",
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
timezone_offset: 8,
|
||||
birth_place_label: "北京",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryPersistence implements ReportServicePort {
|
||||
rows = new Map<string, PersonalReportRecord>();
|
||||
|
||||
constructor(seed: PersonalReportRecord[] = []) {
|
||||
for (const row of seed) this.rows.set(row.id, row);
|
||||
}
|
||||
|
||||
record(input: CreateGeneratingInput, id: string, status: "generating" | "failed"): PersonalReportRecord {
|
||||
return {
|
||||
id,
|
||||
userId: input.userId,
|
||||
sessionId: input.sessionId ?? null,
|
||||
chartProfileId: input.chartProfileId ?? null,
|
||||
requestId: input.requestId,
|
||||
requestFingerprint: input.requestFingerprint,
|
||||
reportType: input.reportType,
|
||||
status,
|
||||
schemaVersion: "report_document.v1",
|
||||
presentationMode: input.presentationMode,
|
||||
requestedThemes: input.requestedThemes ?? [],
|
||||
reportDocument: null,
|
||||
calculationHash: null,
|
||||
evidenceHash: null,
|
||||
skillSourceCommit: input.skillSourceCommit ?? null,
|
||||
skillSnapshotSha256: input.skillSnapshotSha256,
|
||||
failureCode: status === "failed" ? "calculation_unavailable" : null,
|
||||
createdAt: "2026-08-06T00:00:00.000Z",
|
||||
updatedAt: "2026-08-06T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getByUserAndRequestId(userId: string, requestId: string) {
|
||||
for (const row of this.rows.values()) {
|
||||
if (row.userId === userId && row.requestId === requestId) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult> {
|
||||
const existing = await this.getByUserAndRequestId(input.userId, input.requestId);
|
||||
if (existing) {
|
||||
if (existing.requestFingerprint === input.requestFingerprint) {
|
||||
return { kind: "replayed", record: existing };
|
||||
}
|
||||
return { kind: "request_conflict", record: existing };
|
||||
}
|
||||
const inFlight = [...this.rows.values()].find(
|
||||
(row) => row.userId === input.userId && row.status === "generating",
|
||||
);
|
||||
if (inFlight) return { kind: "generation_in_progress", record: inFlight };
|
||||
const row = this.record(input, REPORT_ID, "generating");
|
||||
this.rows.set(row.id, row);
|
||||
return { kind: "created", record: row };
|
||||
}
|
||||
|
||||
async completeReady(userId: string, reportId: string, document: unknown) {
|
||||
const row = this.rows.get(reportId);
|
||||
assert.ok(row && row.userId === userId && row.status === "generating");
|
||||
const readyRow: PersonalReportRecord = {
|
||||
...row,
|
||||
status: "ready",
|
||||
reportDocument: document as PersonalReportRecord["reportDocument"],
|
||||
completedAt: "2026-08-06T00:01:00.000Z",
|
||||
};
|
||||
this.rows.set(reportId, readyRow);
|
||||
return readyRow;
|
||||
}
|
||||
|
||||
async markFailed(userId: string, reportId: string, failureCode: string) {
|
||||
const row = this.rows.get(reportId);
|
||||
assert.ok(row && row.userId === userId);
|
||||
const failedRow: PersonalReportRecord = {
|
||||
...row,
|
||||
status: "failed",
|
||||
failureCode: failureCode as PersonalReportRecord["failureCode"],
|
||||
completedAt: "2026-08-06T00:01:00.000Z",
|
||||
};
|
||||
this.rows.set(reportId, failedRow);
|
||||
return failedRow;
|
||||
}
|
||||
|
||||
async getOwnedById(userId: string, reportId: string) {
|
||||
const row = this.rows.get(reportId);
|
||||
return row && row.userId === userId ? row : null;
|
||||
}
|
||||
|
||||
async deleteOwned(userId: string, reportId: string) {
|
||||
const row = this.rows.get(reportId);
|
||||
if (!row || row.userId !== userId) return false;
|
||||
this.rows.delete(reportId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function baseDeps(overrides: Partial<ReportCreateCoreDeps> = {}): ReportCreateCoreDeps {
|
||||
const persistence = new MemoryPersistence();
|
||||
return {
|
||||
requestUrl: "https://jyotisha.chat/api/reports",
|
||||
origin: "https://jyotisha.chat",
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
rawBody: {
|
||||
requestId: UUID_B,
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "marriage", "wealth", "timing"],
|
||||
},
|
||||
profile: profileFixture(),
|
||||
checkSessionOwned: async () => true,
|
||||
checkChartProfileOwned: async () => true,
|
||||
featureEnabled: true,
|
||||
dailyLimit: 5,
|
||||
counts: {
|
||||
countGenerating: async () => 0,
|
||||
countCreatedToday: async () => 0,
|
||||
},
|
||||
persistence,
|
||||
model: { id: "test-model" },
|
||||
runWorkflow: async () => chartPayload(),
|
||||
createAgent: () => fakeAgent,
|
||||
skillSnapshot: SKILL_SNAPSHOT,
|
||||
now: () => new Date("2026-08-06T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executable route core behavior (no network, no model)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("core create: 401 when not logged in", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({ userId: null }));
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("core create: 403 on cross-origin", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({ origin: "https://evil.example" }));
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(response.body.code, "report_resource_forbidden");
|
||||
});
|
||||
|
||||
test("core create: 400 on invalid payload", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({ rawBody: { requestId: "not-a-uuid" } }));
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(response.body.code, "invalid_request");
|
||||
});
|
||||
|
||||
test("core create: 422 profile_incomplete without a profile", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({ profile: null }));
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "profile_incomplete");
|
||||
});
|
||||
|
||||
test("core create: 422 birth_time_not_usable for reported status", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
profile: profileFixture({ birth_time_status: "reported", active_birth_time: "05:30:00" }),
|
||||
}));
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "birth_time_not_usable");
|
||||
});
|
||||
|
||||
test("core create: 422 birth_time_not_usable for incomplete profile fields", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
profile: profileFixture({ latitude: null, longitude: null }),
|
||||
}));
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "birth_time_not_usable");
|
||||
});
|
||||
|
||||
test("core create: 403 when the session or chart profile is not owned", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
rawBody: {
|
||||
requestId: UUID_B,
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career"],
|
||||
sessionId: SESSION_ID,
|
||||
},
|
||||
checkSessionOwned: async () => false,
|
||||
}));
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(response.body.code, "report_resource_forbidden");
|
||||
});
|
||||
|
||||
test("core create: 403 when the feature is disabled", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({ featureEnabled: false }));
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(response.body.code, "report_export_disabled");
|
||||
});
|
||||
|
||||
test("core create: 409 request conflict for a different payload under the same requestId", async () => {
|
||||
const fingerprintA = computeRequestFingerprint({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "marriage", "wealth", "timing"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
});
|
||||
const fingerprintB = computeRequestFingerprint({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
});
|
||||
assert.notEqual(fingerprintA, fingerprintB);
|
||||
const seeded = new MemoryPersistence([{
|
||||
id: REPORT_ID,
|
||||
userId: UUID_A,
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
requestId: UUID_B,
|
||||
requestFingerprint: fingerprintA,
|
||||
reportType: "personal_full",
|
||||
status: "ready",
|
||||
schemaVersion: "report_document.v1",
|
||||
presentationMode: "default",
|
||||
requestedThemes: ["career", "marriage", "wealth", "timing"],
|
||||
reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"],
|
||||
calculationHash: "c".repeat(64),
|
||||
evidenceHash: "d".repeat(64),
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "a".repeat(64),
|
||||
failureCode: null,
|
||||
createdAt: "2026-08-06T00:00:00.000Z",
|
||||
updatedAt: "2026-08-06T00:00:00.000Z",
|
||||
completedAt: "2026-08-06T00:00:00.000Z",
|
||||
}]);
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
rawBody: { requestId: UUID_B, reportType: "personal_full", presentationMode: "default", themes: ["career"] },
|
||||
persistence: seeded,
|
||||
}));
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal(response.body.code, "report_request_conflict");
|
||||
});
|
||||
|
||||
test("core create: 200 replay for a ready row with the same fingerprint", async () => {
|
||||
const fingerprint = computeRequestFingerprint({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "marriage", "wealth", "timing"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
});
|
||||
const seeded = new MemoryPersistence([{
|
||||
id: REPORT_ID,
|
||||
userId: UUID_A,
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
requestId: UUID_B,
|
||||
requestFingerprint: fingerprint,
|
||||
reportType: "personal_full",
|
||||
status: "ready",
|
||||
schemaVersion: "report_document.v1",
|
||||
presentationMode: "default",
|
||||
requestedThemes: ["career", "marriage", "wealth", "timing"],
|
||||
reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"],
|
||||
calculationHash: "c".repeat(64),
|
||||
evidenceHash: "d".repeat(64),
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "a".repeat(64),
|
||||
failureCode: null,
|
||||
createdAt: "2026-08-06T00:00:00.000Z",
|
||||
updatedAt: "2026-08-06T00:00:00.000Z",
|
||||
completedAt: "2026-08-06T00:00:00.000Z",
|
||||
}]);
|
||||
const response = await resolveReportCreate(baseDeps({ persistence: seeded }));
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body.reportDocument, { ok: true });
|
||||
});
|
||||
|
||||
test("core create: 409 when a generation is already in progress", async () => {
|
||||
const generating = new MemoryPersistence();
|
||||
generating.rows.set("55555555-5555-4555-8555-555555555555", {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
userId: UUID_A,
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
requestId: "66666666-6666-4666-8666-666666666666",
|
||||
requestFingerprint: "e".repeat(64),
|
||||
reportType: "personal_full",
|
||||
status: "generating",
|
||||
schemaVersion: "report_document.v1",
|
||||
presentationMode: "default",
|
||||
requestedThemes: [],
|
||||
reportDocument: null,
|
||||
calculationHash: null,
|
||||
evidenceHash: null,
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "a".repeat(64),
|
||||
failureCode: null,
|
||||
createdAt: "2026-08-06T00:00:00.000Z",
|
||||
updatedAt: "2026-08-06T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
});
|
||||
const response = await resolveReportCreate(baseDeps({ persistence: generating }));
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal(response.body.code, "report_generation_in_progress");
|
||||
});
|
||||
|
||||
test("core create: 429 at the daily limit", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
counts: { countGenerating: async () => 0, countCreatedToday: async () => 5 },
|
||||
}));
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(response.body.code, "report_rate_limited");
|
||||
});
|
||||
|
||||
test("core create: 502 model_unavailable when no model is configured", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
model: null,
|
||||
persistence,
|
||||
}));
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal(response.body.code, "model_unavailable");
|
||||
const row = persistence.rows.get(REPORT_ID);
|
||||
assert.equal(row?.status, "failed");
|
||||
assert.equal(row?.failureCode, "model_unavailable");
|
||||
});
|
||||
|
||||
test("core create: 502 calculation_unavailable when the workflow throws", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
persistence,
|
||||
runWorkflow: async () => {
|
||||
throw new Error("engine down");
|
||||
},
|
||||
}));
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal(response.body.code, "calculation_unavailable");
|
||||
assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed");
|
||||
});
|
||||
|
||||
test("core create: 502 when the workflow returns no usable chart", async () => {
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
runWorkflow: async () => ({ success: false }),
|
||||
}));
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal(response.body.code, "calculation_unavailable");
|
||||
});
|
||||
|
||||
test("core create: 422 when the real evidence cannot support a report (fail closed)", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const workflow = chartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
chart.houses = (chart.houses as unknown[]).slice(0, 6);
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
persistence,
|
||||
runWorkflow: async () => workflow,
|
||||
}));
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "calculation_unavailable");
|
||||
assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed");
|
||||
});
|
||||
|
||||
test("core create: 201 ready with a document on the happy path", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const response = await resolveReportCreate(baseDeps({ persistence }));
|
||||
assert.equal(response.status, 201);
|
||||
assert.ok(response.body.reportDocument);
|
||||
const row = persistence.rows.get(REPORT_ID);
|
||||
assert.equal(row?.status, "ready");
|
||||
assert.ok(row?.reportDocument);
|
||||
});
|
||||
|
||||
test("core create: 422 report_guard_rejected when the agent output violates the guard", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const violatingAgent: ReportAgentPort = {
|
||||
modelId: "test-model",
|
||||
async generate(): Promise<PersonalReportAgentOutput> {
|
||||
return {
|
||||
executiveSummary: {
|
||||
headline: "综合盘面",
|
||||
summary: "结构稳定。",
|
||||
priorities: [],
|
||||
},
|
||||
thematicNarrative: [{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "你必定会胜诉。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-2"],
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
persistence,
|
||||
createAgent: () => violatingAgent,
|
||||
}));
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "report_guard_rejected");
|
||||
assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed");
|
||||
});
|
||||
|
||||
test("core read: 401 without user, 404 for non-owned or missing reports", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const readyRow = await createReadyRow(persistence);
|
||||
const unauthenticated = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: null,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
|
||||
const otherUser = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_B,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(otherUser.status, 404);
|
||||
|
||||
const missing = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: "99999999-9999-4999-8999-999999999999",
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
assert.equal(missing.body.code, "report_not_found");
|
||||
assert.equal(readyRow, true);
|
||||
});
|
||||
|
||||
test("core read: ready returns the document, generating returns status only", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const row = await createReadyRow(persistence);
|
||||
const ready = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(ready.status, 200);
|
||||
assert.ok(ready.body.reportDocument);
|
||||
assert.equal(row, true);
|
||||
|
||||
const generatingPersistence = new MemoryPersistence();
|
||||
generatingPersistence.rows.set(REPORT_ID, {
|
||||
...seedRecord(),
|
||||
status: "generating",
|
||||
reportDocument: null,
|
||||
completedAt: null,
|
||||
});
|
||||
const generating = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence: generatingPersistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(generating.status, 200);
|
||||
assert.equal("reportDocument" in generating.body, false);
|
||||
assert.equal((generating.body.report as { status: string }).status, "generating");
|
||||
});
|
||||
|
||||
test("core read: rejects a polluted stored ready document via canonical re-validation", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
persistence.rows.set(REPORT_ID, {
|
||||
...seedRecord(),
|
||||
reportDocument: { hacked: true } as unknown as PersonalReportRecord["reportDocument"],
|
||||
});
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: (document) => {
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
return parsed.ok ? { ok: true, document: parsed.document } : { ok: false };
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.code, "report_schema_invalid");
|
||||
assert.equal("reportDocument" in response.body, false);
|
||||
assert.equal((response.body.report as { status: string }).status, "ready");
|
||||
});
|
||||
|
||||
test("core read: returns a legitimate ready document after canonical re-validation", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
await createReadyRow(persistence);
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: (document) => {
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
return parsed.ok ? { ok: true, document: parsed.document } : { ok: false };
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(response.body.reportDocument);
|
||||
});
|
||||
|
||||
test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
await createReadyRow(persistence);
|
||||
const otherUser = await resolveReportDelete({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_B,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
});
|
||||
assert.equal(otherUser.status, 404);
|
||||
|
||||
const owner = await resolveReportDelete({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
});
|
||||
assert.equal(owner.status, 200);
|
||||
assert.deepEqual(owner.body, { ok: true });
|
||||
assert.equal(persistence.rows.has(REPORT_ID), false);
|
||||
});
|
||||
|
||||
function acceptAnyDocument(document: unknown): { ok: true; document: unknown } | { ok: false } {
|
||||
return { ok: true, document };
|
||||
}
|
||||
|
||||
async function createReadyRow(persistence: MemoryPersistence): Promise<boolean> {
|
||||
const fingerprint = computeRequestFingerprint({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "marriage", "wealth", "timing"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
});
|
||||
const response = await resolveReportCreate(baseDeps({ persistence }));
|
||||
if (response.status !== 201) return false;
|
||||
const row = persistence.rows.get(REPORT_ID);
|
||||
assert.equal(row?.requestFingerprint, fingerprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
function seedRecord(): PersonalReportRecord {
|
||||
return {
|
||||
id: REPORT_ID,
|
||||
userId: UUID_A,
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
requestId: UUID_B,
|
||||
requestFingerprint: "f".repeat(64),
|
||||
reportType: "personal_full",
|
||||
status: "ready",
|
||||
schemaVersion: "report_document.v1",
|
||||
presentationMode: "default",
|
||||
requestedThemes: ["career", "marriage", "wealth", "timing"],
|
||||
reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"],
|
||||
calculationHash: "c".repeat(64),
|
||||
evidenceHash: "d".repeat(64),
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "a".repeat(64),
|
||||
failureCode: null,
|
||||
createdAt: "2026-08-06T00:00:00.000Z",
|
||||
updatedAt: "2026-08-06T00:00:00.000Z",
|
||||
completedAt: "2026-08-06T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-level production wiring checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("POST route uses dual clients: authenticated reads + admin persistence", () => {
|
||||
assert.match(createRoute, /createServerSupabaseClient\(\)/);
|
||||
assert.match(createRoute, /\.from\("profiles"\)/);
|
||||
assert.match(createRoute, /createAdminSupabaseClient\(\)/);
|
||||
assert.match(createRoute, /createSupabasePersonalReportService\(admin\)/);
|
||||
assert.match(createRoute, /createPersonalReportDataClient\(admin\)/);
|
||||
assert.doesNotMatch(createRoute, /resolveReportPersistencePort|resolveReportContractPort/);
|
||||
assert.doesNotMatch(createRoute, /ReportPersistenceUnavailableError|ReportContractUnavailableError/);
|
||||
assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/);
|
||||
});
|
||||
|
||||
test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => {
|
||||
assert.match(itemRoute, /createServerSupabaseClient\(\)/);
|
||||
assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/);
|
||||
assert.match(itemRoute, /resolveReportRead/);
|
||||
assert.match(itemRoute, /resolveReportDelete/);
|
||||
assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/);
|
||||
});
|
||||
|
||||
test("route core enforces same-origin and never leaks raw exception text", () => {
|
||||
assert.match(coreSource, /checkSameOrigin/);
|
||||
assert.match(coreSource, /REPORT_STABLE_CODES\.resourceForbidden/);
|
||||
assert.doesNotMatch(createRoute, /error\.message\)/);
|
||||
assert.doesNotMatch(itemRoute, /error\.message\)/);
|
||||
assert.doesNotMatch(createRoute, /\.stack/);
|
||||
assert.doesNotMatch(itemRoute, /\.stack/);
|
||||
});
|
||||
|
||||
test("POST route reads the daily limit from env and resolves a real skill snapshot", () => {
|
||||
assert.match(createRoute, /readPersonalReportDailyLimit\(process\.env\)/);
|
||||
assert.match(createRoute, /resolveSkillSnapshot\(\)/);
|
||||
assert.doesNotMatch(createRoute, /每日.*上限.*\d|PERSONAL_REPORT_DAILY_LIMIT.*\?\?\s*["']\d/);
|
||||
});
|
||||
|
||||
test("GET route re-validates stored ready documents through the canonical server parse", () => {
|
||||
assert.match(itemRoute, /safeParseServerReportDocument\(document\)/);
|
||||
assert.match(itemRoute, /validateReadyDocument/);
|
||||
assert.match(coreSource, /validateReadyDocument/);
|
||||
assert.match(coreSource, /canonical server parse/);
|
||||
assert.match(coreSource, /REPORT_STABLE_CODES\.schemaInvalid/);
|
||||
assert.doesNotMatch(coreSource, /client.*validation|validate.*client/i);
|
||||
});
|
||||
|
||||
test("POST route never generates HTML/PDF/base64 or local paths", () => {
|
||||
assert.doesNotMatch(createRoute, /window\.print|html2canvas|jsPDF|base64|\.pdf/);
|
||||
assert.doesNotMatch(createRoute, /sendFile|createWriteStream|\/opt\/|\/var\/|\/Users\//);
|
||||
});
|
||||
|
||||
test("stable error codes live in the dependency-free codes module", () => {
|
||||
for (const code of [
|
||||
"profile_incomplete",
|
||||
"birth_time_not_usable",
|
||||
"report_generation_in_progress",
|
||||
"report_rate_limited",
|
||||
"calculation_unavailable",
|
||||
"model_unavailable",
|
||||
"report_schema_invalid",
|
||||
"report_guard_rejected",
|
||||
"report_not_found",
|
||||
"report_request_conflict",
|
||||
]) {
|
||||
assert.ok(codesSource.includes(`"${code}"`), `missing stable code ${code}`);
|
||||
}
|
||||
// The codes module must stay free of heavy imports so route handlers that
|
||||
// only need codes never trace the generation/skill-snapshot logic.
|
||||
assert.doesNotMatch(codesSource, /node:fs|node:path|node:crypto|readdirSync|readFileSync/);
|
||||
assert.doesNotMatch(createRoute, /dangerouslySetInnerHTML/);
|
||||
});
|
||||
|
||||
test("GET route imports codes from the pure module, never the generation module", () => {
|
||||
assert.match(itemRoute, /personal-report-codes/);
|
||||
assert.doesNotMatch(itemRoute, /personal-report-generation/);
|
||||
assert.doesNotMatch(itemRoute, /resolveSkillSnapshot|buildReportEvidencePacket|canonicalSerialize/);
|
||||
assert.match(generationSource, /personal-report-codes/);
|
||||
});
|
||||
|
||||
test("generation pipeline re-validates with the canonical server parse after the guard", () => {
|
||||
assert.match(generationSource, /safeParseServerReportDocument\(guarded\.document\)/);
|
||||
assert.match(generationSource, /assembleReportDocument/);
|
||||
});
|
||||
|
||||
test("evidence hash is the canonical appendix hash, never a model self-report", () => {
|
||||
assert.match(generationSource, /computeEvidenceHash\(parsed\.document\.evidenceAppendix\)/);
|
||||
assert.match(generationSource, /computeEvidenceHash\(appendix\)/);
|
||||
});
|
||||
|
||||
test("skill snapshot resolution fails closed without a real source", () => {
|
||||
assert.match(generationSource, /SkillSnapshotUnavailableError/);
|
||||
assert.match(generationSource, /source-manifest\.json/);
|
||||
assert.doesNotMatch(generationSource, /skill_snapshot_unavailable.*digest/);
|
||||
});
|
||||
|
||||
test("generation module has no filesystem/path scanning (static manifest import only)", () => {
|
||||
assert.doesNotMatch(generationSource, /node:fs|node:path|readdirSync|readFileSync/);
|
||||
assert.doesNotMatch(generationSource, /\bskillDirectory\b|\brepoRoot\b|turbopackIgnore/);
|
||||
assert.match(generationSource, /source-manifest\.json/);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEFAULT_PERSONAL_REPORT_DAILY_LIMIT,
|
||||
REPORT_EXPORT_PERSONAL_CAPABILITY_KEY,
|
||||
checkPersonalReportEntitlement,
|
||||
checkSameOrigin,
|
||||
isPersonalReportFeatureEnabled,
|
||||
readPersonalReportDailyLimit,
|
||||
resolveAllowedReportOrigins,
|
||||
} from "../src/lib/personal-report-entitlement.ts";
|
||||
|
||||
test("entitlement exposes the report.export.personal capability key", () => {
|
||||
assert.equal(REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, "report.export.personal");
|
||||
});
|
||||
|
||||
test("feature flag is enabled only by explicit env true", () => {
|
||||
assert.equal(isPersonalReportFeatureEnabled({}), false);
|
||||
assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "false" }), false);
|
||||
assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "TRUE" }), false);
|
||||
assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "true" }), true);
|
||||
});
|
||||
|
||||
test("daily limit is read from env and never hardcoded in the module UI surface", () => {
|
||||
assert.equal(readPersonalReportDailyLimit({}), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT);
|
||||
assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "3" }), 3);
|
||||
assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "0" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT);
|
||||
assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "-1" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT);
|
||||
assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "abc" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT);
|
||||
});
|
||||
|
||||
test("allowed origins are parsed from the comma-separated env list", () => {
|
||||
assert.deepEqual(resolveAllowedReportOrigins({}), []);
|
||||
assert.deepEqual(
|
||||
resolveAllowedReportOrigins({ PERSONAL_REPORT_ALLOWED_ORIGINS: " https://a.example ,https://b.example, " }),
|
||||
["https://a.example", "https://b.example"],
|
||||
);
|
||||
});
|
||||
|
||||
test("same-origin check accepts absent origin and same request origin", () => {
|
||||
assert.deepEqual(checkSameOrigin("https://jyotisha.chat/api/reports", null, []), { ok: true });
|
||||
assert.deepEqual(
|
||||
checkSameOrigin("https://jyotisha.chat/api/reports", "https://jyotisha.chat", []),
|
||||
{ ok: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("same-origin check rejects cross-origin and accepts a trusted allowlist", () => {
|
||||
assert.deepEqual(
|
||||
checkSameOrigin("https://jyotisha.chat/api/reports", "https://evil.example", []),
|
||||
{ ok: false, code: "cross_origin_forbidden" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
checkSameOrigin(
|
||||
"https://jyotisha.chat/api/reports",
|
||||
"https://trusted-proxy.example",
|
||||
["https://trusted-proxy.example"],
|
||||
),
|
||||
{ ok: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("entitlement blocks when the feature is disabled", async () => {
|
||||
const result = await checkPersonalReportEntitlement({
|
||||
userId: "u1",
|
||||
featureEnabled: false,
|
||||
dailyLimit: 5,
|
||||
countGenerating: async () => 0,
|
||||
countCreatedToday: async () => 0,
|
||||
});
|
||||
assert.deepEqual(result, { allowed: false, code: "report_export_disabled", httpStatus: 403 });
|
||||
});
|
||||
|
||||
test("entitlement blocks a second concurrent generation with 409", async () => {
|
||||
const result = await checkPersonalReportEntitlement({
|
||||
userId: "u1",
|
||||
featureEnabled: true,
|
||||
dailyLimit: 5,
|
||||
countGenerating: async () => 1,
|
||||
countCreatedToday: async () => 0,
|
||||
});
|
||||
assert.deepEqual(result, { allowed: false, code: "report_generation_in_progress", httpStatus: 409 });
|
||||
});
|
||||
|
||||
test("entitlement blocks at the daily limit with 429", async () => {
|
||||
const result = await checkPersonalReportEntitlement({
|
||||
userId: "u1",
|
||||
featureEnabled: true,
|
||||
dailyLimit: 2,
|
||||
countGenerating: async () => 0,
|
||||
countCreatedToday: async () => 2,
|
||||
});
|
||||
assert.deepEqual(result, { allowed: false, code: "report_rate_limited", httpStatus: 429 });
|
||||
});
|
||||
|
||||
test("entitlement allows a fresh generation within limits", async () => {
|
||||
const result = await checkPersonalReportEntitlement({
|
||||
userId: "u1",
|
||||
featureEnabled: true,
|
||||
dailyLimit: 5,
|
||||
countGenerating: async () => 0,
|
||||
countCreatedToday: async () => 1,
|
||||
});
|
||||
assert.deepEqual(result, { allowed: true });
|
||||
});
|
||||
|
||||
test("report API routes never hardcode the daily limit in the UI-facing module", () => {
|
||||
const entitlementSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-entitlement.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(entitlementSource, /REPORT_DAILY_LIMIT_ENV/);
|
||||
// The limit must be read from env at request time, not baked as a literal
|
||||
// default inside the route response mapping.
|
||||
assert.doesNotMatch(entitlementSource, /每日|上限/);
|
||||
});
|
||||
@@ -0,0 +1,748 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { safeParseServerReportDocument, computeEvidenceHash } from "../src/lib/personal-report-contract.server-core.ts";
|
||||
import upstreamSourceManifest from "../../references/upstream/yinduzhanxing/source-manifest.json";
|
||||
import {
|
||||
REPORT_STABLE_CODES,
|
||||
ReportEvidenceInsufficientError,
|
||||
applyReportGuard,
|
||||
assembleReportDocument,
|
||||
buildReportEvidencePacket,
|
||||
canonicalSerialize,
|
||||
computeRequestFingerprint,
|
||||
findForbiddenDeterministicClaims,
|
||||
generatePersonalReport,
|
||||
redactDeterministicSentences,
|
||||
resolveSkillSnapshot,
|
||||
sha256Hex,
|
||||
type SkillSnapshot,
|
||||
} from "../src/lib/personal-report-generation.ts";
|
||||
import { PRECISE_TIMING_PATTERNS } from "../src/lib/personal-report-generation.ts";
|
||||
import type {
|
||||
PersonalReportAgentOutput,
|
||||
ReportAgentPort,
|
||||
ReportEvidencePacket,
|
||||
} from "../src/mastra/personal-report.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: a real-shaped workflow response matching the Python main chain
|
||||
// (object-map planets/houses/sections, varga_full D9_Navamsa/D10_Dasamsa)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pythonStyleChartPayload() {
|
||||
const signNames = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"];
|
||||
const ascIndex = 4; // Leo
|
||||
const planetsMap: Record<string, Record<string, unknown>> = {
|
||||
Sun: { sign: "Leo", degree: 142.5, degree_raw: 142.5, degree_in_sign: 22.5, house: 1, retrograde: false, speed: 1.0 },
|
||||
Moon: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: false, speed: 13.0 },
|
||||
Mars: { sign: "Libra", degree: 202.5, degree_raw: 202.5, degree_in_sign: 22.5, house: 3, retrograde: false, speed: 0.6 },
|
||||
Mercury: { sign: "Scorpio", degree: 232.5, degree_raw: 232.5, degree_in_sign: 22.5, house: 4, retrograde: true, speed: -0.5 },
|
||||
Jupiter: { sign: "Sagittarius", degree: 262.5, degree_raw: 262.5, degree_in_sign: 22.5, house: 5, retrograde: false, speed: 0.2 },
|
||||
Venus: { sign: "Capricorn", degree: 292.5, degree_raw: 292.5, degree_in_sign: 22.5, house: 6, retrograde: false, speed: 1.1 },
|
||||
Saturn: { sign: "Aquarius", degree: 322.5, degree_raw: 322.5, degree_in_sign: 22.5, house: 7, retrograde: true, speed: -0.1 },
|
||||
Rahu: { sign: "Pisces", degree: 352.5, degree_raw: 352.5, degree_in_sign: 22.5, house: 8, retrograde: true, speed: -0.05 },
|
||||
Ketu: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: true, speed: -0.05 },
|
||||
};
|
||||
const housesMap: Record<string, Record<string, unknown>> = {};
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
housesMap[`house_${index + 1}`] = {
|
||||
cusp_sign: signNames[(ascIndex + index) % 12],
|
||||
cusp_degree: 140.5 + index * 30,
|
||||
lord: signNames[(ascIndex + index) % 12],
|
||||
};
|
||||
}
|
||||
const d9Planets: Record<string, Record<string, unknown>> = {
|
||||
Sun: { sign: "Leo", sign_idx: 4 },
|
||||
Moon: { sign: "Virgo", sign_idx: 5 },
|
||||
Mars: { sign: "Cancer", sign_idx: 3 },
|
||||
};
|
||||
const d10Planets: Record<string, Record<string, unknown>> = {
|
||||
Sun: { sign: "Taurus", sign_idx: 1 },
|
||||
Moon: { sign: "Gemini", sign_idx: 2 },
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
chart: {
|
||||
ascendant: { sign: "Leo", degree: 20.5, degree_raw: 140.5, lon: 140.5, sign_cn: "狮子座", lord: "Sun" },
|
||||
planets: planetsMap,
|
||||
houses: housesMap,
|
||||
dasha: {
|
||||
mahadashas: [
|
||||
{ lord: "Moon", start: "2019-01-01", end: "2029-01-01" },
|
||||
{ lord: "Mars", start: "2029-01-01", end: "2036-01-01" },
|
||||
],
|
||||
},
|
||||
modules: {
|
||||
varga_full: {
|
||||
D9_Navamsa: {
|
||||
_meta: { div: 9 },
|
||||
Ascendant: { sign: "Leo", sign_idx: 4 },
|
||||
...d9Planets,
|
||||
_dignity: {},
|
||||
},
|
||||
D10_Dasamsa: {
|
||||
_meta: { div: 10 },
|
||||
Ascendant: { sign: "Taurus", sign_idx: 1 },
|
||||
...d10Planets,
|
||||
},
|
||||
},
|
||||
narayana_dasha: {
|
||||
periods: [{ lord: "Sun", start: "2023-01-01", end: "2026-01-01" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
consumer_context: {
|
||||
route: "general",
|
||||
core_status: "ready",
|
||||
available_layers: ["D1", "D9", "D10", "Vimshottari", "Narayana"],
|
||||
missing_route_layers: [],
|
||||
hard_blockers: [],
|
||||
answer_policy: {
|
||||
can_answer_precise_timing: true,
|
||||
deterministic_claims_forbidden_for: [],
|
||||
},
|
||||
},
|
||||
machine_evidence_packet: {
|
||||
conflicts: [
|
||||
{ techniques: ["Vimshottari", "Narayana"], summary: "两个大运系统给出的阶段边界不一致" },
|
||||
],
|
||||
sections: {
|
||||
D1: { status: "used", source_path: "chart.planets+chart.ascendant" },
|
||||
D9: { status: "used", source_path: "modules.varga_full.D9" },
|
||||
D10: { status: "used", source_path: "modules.varga_full.D10" },
|
||||
D2: { status: "missing", source_path: "modules.varga_full.D2" },
|
||||
planet_degrees: { status: "used", source_path: "chart.planets" },
|
||||
house_degrees: { status: "used", source_path: "chart.houses" },
|
||||
dasha_boundaries: { status: "used", source_path: "modules.dasha" },
|
||||
narayana_dasha: { status: "used", source_path: "modules.narayana_dasha" },
|
||||
external_oracle_status: { status: "used", source_path: "vedastro_official.runtime_truth" },
|
||||
vedastro_official_raw_response: { status: "missing", source_path: "vedastro_official.raw_response" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPacket(overrides: Partial<BuildPacketOverrides> = {}): ReportEvidencePacket {
|
||||
const workflow = overrides.workflow ?? pythonStyleChartPayload();
|
||||
return buildReportEvidencePacket({
|
||||
workflow,
|
||||
subject: {
|
||||
displayName: "测试用户",
|
||||
birthTimeStatus: "confirmed",
|
||||
birthPlaceLabel: "北京",
|
||||
},
|
||||
requestedThemes: ["career", "marriage", "wealth", "timing"],
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
candidateRange: null,
|
||||
skillSnapshot: { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) },
|
||||
});
|
||||
}
|
||||
|
||||
type BuildPacketOverrides = {
|
||||
workflow: unknown;
|
||||
};
|
||||
|
||||
function agentOutput(overrides: Partial<PersonalReportAgentOutput> = {}): PersonalReportAgentOutput {
|
||||
return {
|
||||
executiveSummary: {
|
||||
headline: "综合盘面以事业发展为主线",
|
||||
summary: "命盘显示事业层面具备稳定的结构,财富与婚恋需结合分盘审慎解读。",
|
||||
priorities: ["先聚焦职业方向", "再核对感情与财富主题"],
|
||||
},
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定,具体应期需要结合大运边界观察。",
|
||||
actions: ["在稳定领域深耕"],
|
||||
caveats: ["该部分为方向性描述"],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-1", "ev-audit-3"],
|
||||
},
|
||||
{
|
||||
id: "marriage",
|
||||
title: "婚恋",
|
||||
narrative: "婚恋部分以七宫与 D9 为主,呈现结构特征,不构成确定性结论。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-2"],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet builder: allowlist + fail-closed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("packet builder extracts only allowlisted facts, never internal noise", () => {
|
||||
const workflow = pythonStyleChartPayload();
|
||||
(workflow as Record<string, unknown>).internal_path = "/opt/app/private/engine.py";
|
||||
(workflow as Record<string, unknown>).prompt_text = "系统提示词原文";
|
||||
(workflow as Record<string, unknown>).traceback = "Traceback (most recent call last)";
|
||||
(workflow as Record<string, unknown>).raw_payload = { anything: true };
|
||||
const packet = buildPacket({ workflow });
|
||||
const serialized = JSON.stringify(packet);
|
||||
assert.doesNotMatch(serialized, /internal_path|prompt_text|traceback|raw_payload|\/opt\/app/);
|
||||
assert.match(serialized, /ev-audit-/);
|
||||
assert.match(serialized, /ev-conflict-/);
|
||||
});
|
||||
|
||||
test("packet builder computes a canonical 64-hex calculation hash and marks derivation", () => {
|
||||
const packet = buildPacket();
|
||||
assert.match(packet.chart.calculationHash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(packet.chart.calculationHashDerived, true);
|
||||
});
|
||||
|
||||
test("packet builder keeps a real engine hash when present", () => {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
chart.result_hash = "c".repeat(64);
|
||||
const packet = buildPacket({ workflow });
|
||||
assert.equal(packet.chart.calculationHash, "c".repeat(64));
|
||||
assert.equal(packet.chart.calculationHashDerived, false);
|
||||
});
|
||||
|
||||
test("packet builder fails closed without real D1 houses", () => {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
const houses = chart.houses as Record<string, unknown>;
|
||||
delete houses["house_9"];
|
||||
delete houses["house_10"];
|
||||
delete houses["house_11"];
|
||||
delete houses["house_12"];
|
||||
assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError);
|
||||
});
|
||||
|
||||
test("packet builder fails closed without retrograde facts", () => {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
const planets = chart.planets as Record<string, Record<string, unknown>>;
|
||||
planets.Sun.retrograde = undefined;
|
||||
assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError);
|
||||
});
|
||||
|
||||
test("packet builder fails closed without ascendant or evidence refs", () => {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
delete chart.ascendant;
|
||||
assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError);
|
||||
|
||||
const workflow2 = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const consumer = workflow2.consumer_context as Record<string, unknown>;
|
||||
consumer.available_layers = [];
|
||||
const machine = workflow2.machine_evidence_packet as Record<string, unknown>;
|
||||
machine.sections = {};
|
||||
machine.conflicts = [];
|
||||
assert.throws(() => buildPacket({ workflow: workflow2 }), ReportEvidenceInsufficientError);
|
||||
});
|
||||
|
||||
test("no mock fallback: an empty workflow never yields a usable packet", () => {
|
||||
assert.throws(() => buildPacket({ workflow: {} }), ReportEvidenceInsufficientError);
|
||||
});
|
||||
|
||||
test("packet builder normalizes the real Python object-map shapes", () => {
|
||||
const packet = buildPacket();
|
||||
// planets object map -> facts, absolute longitude from degree/degree_raw.
|
||||
assert.equal(packet.chart.planets.length, 9);
|
||||
const sun = packet.chart.planets.find((planet) => planet.id === "Sun");
|
||||
assert.ok(sun);
|
||||
assert.equal(sun.sign, "Leo");
|
||||
assert.equal(sun.degree, 142.5);
|
||||
assert.equal(sun.house, 1);
|
||||
assert.equal(sun.retrograde, false);
|
||||
assert.equal(packet.chart.planets.find((planet) => planet.id === "Saturn")?.retrograde, true);
|
||||
// houses object map (cusp_sign only) -> whole-sign derived signs, marked.
|
||||
assert.equal(packet.chart.houses.length, 12);
|
||||
assert.equal(packet.chart.houses[0].sign, "Leo");
|
||||
assert.equal(packet.chart.houses[0].signDerived, true);
|
||||
assert.deepEqual(packet.chart.houses[0].occupants, ["Sun"]);
|
||||
assert.equal(packet.chart.houses[1].sign, "Virgo");
|
||||
assert.deepEqual([...packet.chart.houses[1].occupants].sort(), ["Ketu", "Moon"]);
|
||||
// sections object map -> deterministic statuses: core calculation sections
|
||||
// verified, internal layers partial, external/missing degraded.
|
||||
const refs = packet.evidenceRefs;
|
||||
const verified = refs.filter((ref) => ref.status === "verified").map((ref) => ref.technique);
|
||||
assert.ok(verified.includes("planet_degrees"));
|
||||
assert.ok(verified.includes("house_degrees"));
|
||||
const blocked = refs.filter((ref) => ref.status === "blocked").map((ref) => ref.technique);
|
||||
assert.ok(blocked.includes("D2"));
|
||||
assert.ok(blocked.includes("vedastro_official_raw_response"));
|
||||
const partial = refs.filter((ref) => ref.status === "partial").map((ref) => ref.technique);
|
||||
assert.ok(partial.includes("dasha_boundaries"));
|
||||
assert.ok(partial.includes("external_oracle_status"));
|
||||
// conflicts produce canonical ev-conflict refs.
|
||||
assert.ok(refs.some((ref) => ref.id.startsWith("ev-conflict-")));
|
||||
});
|
||||
|
||||
test("packet builder resolves the base chart from modules.chart and nested chart", () => {
|
||||
const base = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chartData = base.chart as Record<string, unknown>;
|
||||
// modules.chart wins over nested chart over top level.
|
||||
const modulesChart = {
|
||||
...chartData,
|
||||
planets: { Sun: { sign: "Aries", degree: 10.5, degree_raw: 10.5, house: 1, retrograde: false } },
|
||||
};
|
||||
const modules = chartData.modules as Record<string, unknown>;
|
||||
modules.chart = modulesChart;
|
||||
const viaModules = buildPacket({ workflow: base });
|
||||
assert.equal(viaModules.chart.planets.length, 1);
|
||||
assert.equal(viaModules.chart.planets[0].sign, "Aries");
|
||||
delete modules.chart;
|
||||
|
||||
// nested chart_data.chart is the orchestrator's second choice.
|
||||
const nested = {
|
||||
...chartData,
|
||||
planets: { Moon: { sign: "Pisces", degree: 350.5, degree_raw: 350.5, house: 12, retrograde: false } },
|
||||
};
|
||||
chartData.chart = nested;
|
||||
const viaNested = buildPacket({ workflow: base });
|
||||
assert.equal(viaNested.chart.planets.length, 1);
|
||||
assert.equal(viaNested.chart.planets[0].id, "Moon");
|
||||
});
|
||||
|
||||
test("varga houses are whole-sign derived from the divisional ascendant, never fabricated", () => {
|
||||
const packet = buildPacket();
|
||||
const d9 = packet.chart.vargaHouses.find((varga) => varga.id === "D9");
|
||||
assert.ok(d9);
|
||||
assert.equal(d9.houses.length, 12);
|
||||
// D9 ascendant is Leo (index 4): house 1 Leo, house 2 Virgo.
|
||||
assert.equal(d9.houses[0].sign, "Leo");
|
||||
assert.equal(d9.houses[1].sign, "Virgo");
|
||||
// Moon sits in Virgo (index 5) -> whole-sign house 2 of D9.
|
||||
assert.ok(d9.houses[1].occupants.includes("Moon"));
|
||||
assert.ok(d9.houses.every((house) => house.signDerived === true));
|
||||
const d10 = packet.chart.vargaHouses.find((varga) => varga.id === "D10");
|
||||
assert.ok(d10);
|
||||
// D10 ascendant is Taurus (index 1): house 1 Taurus, house 2 Gemini.
|
||||
assert.equal(d10.houses[0].sign, "Taurus");
|
||||
assert.equal(d10.houses[1].sign, "Gemini");
|
||||
assert.ok(d10.houses[1].occupants.includes("Moon"));
|
||||
});
|
||||
|
||||
test("array-shaped chart data remains supported", () => {
|
||||
const workflow = {
|
||||
success: true,
|
||||
chart: {
|
||||
ascendant: { sign: "Leo", degree: 12.5 },
|
||||
planets: [
|
||||
{ id: "Sun", sign: "Leo", degree: 142.5, house: 1, retrograde: false },
|
||||
{ id: "Moon", sign: "Virgo", degree: 172.5, house: 2, retrograde: false },
|
||||
],
|
||||
houses: Array.from({ length: 12 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index],
|
||||
})),
|
||||
},
|
||||
consumer_context: {
|
||||
route: "general",
|
||||
core_status: "ready",
|
||||
available_layers: ["D1"],
|
||||
missing_route_layers: [],
|
||||
hard_blockers: [],
|
||||
answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] },
|
||||
},
|
||||
machine_evidence_packet: {
|
||||
conflicts: [],
|
||||
sections: [{ name: "planet_degrees", status: "verified", note: "" }],
|
||||
},
|
||||
};
|
||||
const packet = buildPacket({ workflow });
|
||||
assert.equal(packet.chart.planets.length, 2);
|
||||
assert.equal(packet.chart.houses.length, 12);
|
||||
// Array houses carry a real sign: not derived.
|
||||
assert.equal(packet.chart.houses[0].signDerived, false);
|
||||
assert.equal(packet.chart.houses[0].sign, "Aries");
|
||||
assert.ok(packet.evidenceRefs.some((ref) => ref.status === "verified"));
|
||||
});
|
||||
|
||||
test("section status mapping is deterministic (used core sections verified, external degraded)", () => {
|
||||
const packet = buildPacket();
|
||||
const byTechnique = new Map(packet.evidenceRefs.map((ref) => [ref.technique, ref.status]));
|
||||
assert.equal(byTechnique.get("D1"), "verified");
|
||||
assert.equal(byTechnique.get("planet_degrees"), "verified");
|
||||
assert.equal(byTechnique.get("house_degrees"), "verified");
|
||||
assert.equal(byTechnique.get("dasha_boundaries"), "partial");
|
||||
assert.equal(byTechnique.get("external_oracle_status"), "partial");
|
||||
assert.equal(byTechnique.get("D2"), "blocked");
|
||||
assert.equal(byTechnique.get("vedastro_official_raw_response"), "blocked");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fingerprint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("request fingerprint is canonical: sorted, deduped themes, no requestId", () => {
|
||||
const base = {
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["wealth", "career", "wealth", "timing"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
};
|
||||
const fingerprintA = computeRequestFingerprint(base);
|
||||
const fingerprintB = computeRequestFingerprint({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "timing", "wealth"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
});
|
||||
assert.equal(fingerprintA, fingerprintB);
|
||||
assert.equal(fingerprintA, sha256Hex(canonicalSerialize({
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
themes: ["career", "timing", "wealth"],
|
||||
sessionId: null,
|
||||
chartProfileId: null,
|
||||
})));
|
||||
const differentType = computeRequestFingerprint({
|
||||
...base,
|
||||
reportType: "personal_thematic",
|
||||
});
|
||||
assert.notEqual(fingerprintA, differentType);
|
||||
const withSession = computeRequestFingerprint({
|
||||
...base,
|
||||
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
assert.notEqual(fingerprintA, withSession);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assembly: canonical contract shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("assembled document passes the canonical server parse with a D1 of 12 houses", () => {
|
||||
const packet = buildPacket();
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput(),
|
||||
});
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) return;
|
||||
assert.equal(parsed.document.charts.length, 3);
|
||||
const d1 = parsed.document.charts.find((chart) => chart.id === "D1");
|
||||
assert.ok(d1);
|
||||
assert.equal(d1.houses.length, 12);
|
||||
assert.deepEqual(
|
||||
d1.houses.map((house) => house.houseNumber).sort((a, b) => a - b),
|
||||
Array.from({ length: 12 }, (_, index) => index + 1),
|
||||
);
|
||||
assert.ok(d1.planets && d1.planets.length === 9);
|
||||
assert.equal(d1.planets[0].retrograde, false);
|
||||
assert.equal(d1.planets[6].retrograde, true);
|
||||
// Appendix ids are canonical ev- ids and globally unique.
|
||||
const ids = [
|
||||
...parsed.document.evidenceAppendix.techniqueAudit.map((row) => row.id),
|
||||
...parsed.document.evidenceAppendix.conflicts.map((row) => row.id),
|
||||
...parsed.document.evidenceAppendix.calculationEvidence.map((row) => row.id),
|
||||
];
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
assert.ok(ids.every((id) => /^ev-[a-z0-9_-]{1,63}$/.test(id)));
|
||||
// evidenceHash matches the canonical recomputation from the appendix.
|
||||
assert.equal(
|
||||
parsed.document.provenance.evidenceHash,
|
||||
computeEvidenceHash(parsed.document.evidenceAppendix),
|
||||
);
|
||||
});
|
||||
|
||||
test("D9/D10 charts are omitted when no real divisional houses exist", () => {
|
||||
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
|
||||
const chart = workflow.chart as Record<string, unknown>;
|
||||
const modules = chart.modules as Record<string, unknown>;
|
||||
modules.varga_full = {};
|
||||
const packet = buildPacket({ workflow });
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput(),
|
||||
});
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) return;
|
||||
assert.deepEqual(parsed.document.charts.map((chartRow) => chartRow.id), ["D1"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("guard rejects dangling evidence refs", () => {
|
||||
const packet = buildPacket();
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "稳定结构。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-999"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, packet);
|
||||
assert.equal(guarded.ok, false);
|
||||
if (!guarded.ok) assert.match(guarded.reason, /unresolved_evidence_ref/);
|
||||
});
|
||||
|
||||
test("guard redacts precise timing and downgrades the section when timing is blocked", () => {
|
||||
const packet = buildPacket();
|
||||
const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } };
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet: timingBlockedPacket,
|
||||
agentOutput: agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。",
|
||||
actions: ["2027年3月跳槽"],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, timingBlockedPacket);
|
||||
assert.equal(guarded.ok, true);
|
||||
if (!guarded.ok) return;
|
||||
const section = (guarded.document as unknown as { thematicNarrative: { narrative: string; claimStatus: string; caveats: string[] }[] })
|
||||
.thematicNarrative[0];
|
||||
assert.doesNotMatch(section.narrative, /2027年3月/);
|
||||
assert.equal(section.claimStatus, "blocked");
|
||||
assert.ok(section.caveats.some((caveat) => caveat.includes("确定性边界")));
|
||||
});
|
||||
|
||||
test("guard rejects medical deterministic claims outright", () => {
|
||||
const packet = buildPacket();
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "你一定会患上心脏病。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, packet);
|
||||
assert.equal(guarded.ok, false);
|
||||
if (!guarded.ok) assert.match(guarded.reason, /deterministic_medical_claim/);
|
||||
});
|
||||
|
||||
test("guard rejects investment deterministic claims hidden in actions", () => {
|
||||
const packet = buildPacket();
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "wealth",
|
||||
title: "财富",
|
||||
narrative: "财富结构稳定。",
|
||||
actions: ["买入股票必然大涨"],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, packet);
|
||||
assert.equal(guarded.ok, false);
|
||||
if (!guarded.ok) assert.match(guarded.reason, /deterministic_investment_claim/);
|
||||
});
|
||||
|
||||
test("guard forces blocked when every evidence ref is blocked", () => {
|
||||
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");
|
||||
assert.ok(blockedRef);
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet,
|
||||
agentOutput: agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "timing",
|
||||
title: "时机",
|
||||
narrative: "该部分仅保留方向性说明。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: [blockedRef.id],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, packet);
|
||||
assert.equal(guarded.ok, true);
|
||||
if (!guarded.ok) return;
|
||||
const section = (guarded.document as unknown as { thematicNarrative: { id: string; claimStatus: string }[] })
|
||||
.thematicNarrative[0];
|
||||
assert.equal(section.claimStatus, "blocked");
|
||||
});
|
||||
|
||||
test("guard redacts timing from the summary and blocks the report-level status", () => {
|
||||
const packet = buildPacket();
|
||||
const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } };
|
||||
const document = assembleReportDocument({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
generatedAt: "2026-08-06T00:00:00.000Z",
|
||||
packet: timingBlockedPacket,
|
||||
agentOutput: agentOutput({
|
||||
executiveSummary: {
|
||||
headline: "综合盘面以事业发展为主线",
|
||||
summary: "明年3月将迎来关键转折,整体结构稳定。",
|
||||
priorities: ["先聚焦职业方向"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const guarded = applyReportGuard(document, timingBlockedPacket);
|
||||
assert.equal(guarded.ok, true);
|
||||
if (!guarded.ok) return;
|
||||
const summary = (guarded.document as unknown as { executiveSummary: { summary: string; overallClaimStatus: string } })
|
||||
.executiveSummary;
|
||||
assert.doesNotMatch(summary.summary, /明年3月/);
|
||||
assert.equal(summary.overallClaimStatus, "blocked");
|
||||
});
|
||||
|
||||
test("findForbiddenDeterministicClaims and redaction are deterministic", () => {
|
||||
assert.ok(findForbiddenDeterministicClaims("2027年3月会发生转折").some((claim) => claim.domain === "timing"));
|
||||
assert.ok(findForbiddenDeterministicClaims("投资必然赚钱").some((claim) => claim.domain === "investment"));
|
||||
assert.equal(findForbiddenDeterministicClaims("方向性判断稳定").length, 0);
|
||||
const redacted = redactDeterministicSentences("方向稳定。2027年3月转折。", PRECISE_TIMING_PATTERNS);
|
||||
assert.equal(redacted.removedCount, 1);
|
||||
assert.doesNotMatch(redacted.text, /2027年3月/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation pipeline (fake agent, real canonical parse)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fakeAgent(output: PersonalReportAgentOutput, calls: { count: number }): ReportAgentPort {
|
||||
return {
|
||||
modelId: "test-model",
|
||||
async generate() {
|
||||
calls.count += 1;
|
||||
return output;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("generatePersonalReport returns a ready document that passes the server parse", async () => {
|
||||
const packet = buildPacket();
|
||||
const calls = { count: 0 };
|
||||
const result = await generatePersonalReport({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
packet,
|
||||
agent: fakeAgent(agentOutput(), calls),
|
||||
now: () => new Date("2026-08-06T00:00:00.000Z"),
|
||||
});
|
||||
assert.equal(calls.count, 1);
|
||||
assert.equal(result.status, "ready");
|
||||
if (result.status !== "ready") return;
|
||||
assert.match(result.evidenceHash, /^[0-9a-f]{64}$/);
|
||||
const reparsed = safeParseServerReportDocument(result.document);
|
||||
assert.equal(reparsed.ok, true);
|
||||
});
|
||||
|
||||
test("generatePersonalReport fails with report_guard_rejected on guard rejection", async () => {
|
||||
const packet = buildPacket();
|
||||
const result = await generatePersonalReport({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
packet,
|
||||
agent: fakeAgent(agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "career",
|
||||
title: "事业",
|
||||
narrative: "你必定会胜诉。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: ["ev-audit-1"],
|
||||
},
|
||||
],
|
||||
}), { count: 0 }),
|
||||
});
|
||||
assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" });
|
||||
});
|
||||
|
||||
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");
|
||||
assert.ok(blockedRef);
|
||||
const result = await generatePersonalReport({
|
||||
reportId: "22222222-2222-4222-8222-222222222222",
|
||||
packet,
|
||||
agent: fakeAgent(agentOutput({
|
||||
thematicNarrative: [
|
||||
{
|
||||
id: "timing",
|
||||
title: "时机",
|
||||
narrative: "该部分必然会成功,结构稳定。",
|
||||
actions: [],
|
||||
caveats: [],
|
||||
claimStatus: "single_system_inference",
|
||||
evidenceRefs: [blockedRef.id],
|
||||
},
|
||||
],
|
||||
}), { count: 0 }),
|
||||
now: () => new Date("2026-08-06T00:00:00.000Z"),
|
||||
});
|
||||
// The guard downgrades the section to blocked (all refs blocked); the
|
||||
// blocked section still contains the deterministic phrase 必然, so the FINAL
|
||||
// canonical server parse rejects it. Guard mutations are always re-validated.
|
||||
assert.equal(result.status, "failed");
|
||||
if (result.status === "failed") assert.equal(result.failureCode, "report_schema_invalid");
|
||||
});
|
||||
|
||||
test("skill snapshot is the real packaged manifest sha256, never the literal unknown", async () => {
|
||||
const snapshot: SkillSnapshot = resolveSkillSnapshot();
|
||||
const manifest = upstreamSourceManifest as { skill_sha256?: string };
|
||||
assert.match(snapshot.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(snapshot.sha256, "unknown");
|
||||
assert.equal(snapshot.sha256, manifest.skill_sha256);
|
||||
const again: SkillSnapshot = resolveSkillSnapshot();
|
||||
assert.equal(snapshot.sha256, again.sha256);
|
||||
});
|
||||
|
||||
test("stable codes include the request-conflict mapping", () => {
|
||||
assert.equal(REPORT_STABLE_CODES.requestConflict, "report_request_conflict");
|
||||
});
|
||||
Reference in New Issue
Block a user