Files
Jyotisha/frontend/tests/personal-report-generation-v2.test.ts
T
Jesse_Chen 90bad10d6f feat(report): carry engine interpretive facts into the evidence bundle
The consultation workflow already returns a functional benefic/malefic
table, a shadbala ranking, SAV scores, the current maha/antardasha,
detected yogas and guided-topic copy. The report extraction layer threw
all of it away, so claim cards could only say "the server closed the
minimum evidence group" and the writer had no conclusions to work from.

- ReportEvidenceBundleV2 gains interpretiveFacts (yogas, functionalRoles,
  shadbalaRanking, savScores/savTotal, currentDasha, convergenceDomains)
  and themeNarrativeSeeds. Both are required, allow empty, keep .strict(),
  are covered by the canonical sort + bundleHash, and fail closed on
  dangling refs, duplicate ranks/houses/themes and out-of-bound text.
- Extraction is allowlist-style: closed enums for yoga category and
  functional role, safeCelestialName for planets, sign->whole-sign-house
  projection for SAV, and a forbidden-token scrub that drops any seed line
  naming an external provider or internal route.
- Claim card conclusions and supportingFacts are now deterministic
  astrological statements built from those facts; risks become
  counterFacts. assertionLevel derivation is unchanged, and a theme with
  no seed keeps the old receipt wording with consensus capped down.
- filterReportEvidenceBundleForSection trims seeds and SAV houses to the
  chapter's theme while letting the chart-wide interpretive receipts ride
  along, so every section can cite them.

Contract snapshot taken from a real local /api/consultation_workflow call
with fictional smoke birth data; the new fixture test locks the shapes
that call actually returns, including the fields that are absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-01 20:35:02 +00:00

829 lines
30 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 {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
type BlockedSection,
type EvidenceRefStatus,
type ReportChartFact,
type ReportClaimCard,
type ReportEvidenceBundleV2,
type TechniqueExecutionReceipt,
} from "../src/lib/report-evidence-bundle-v2.ts";
import {
classifyReportSchemaInnerReason,
generatePersonalReport,
type GeneratePersonalReportResult,
type GeneratePersonalReportDeps,
} from "../src/lib/personal-report-generation.ts";
import type { PersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "../src/lib/personal-report-section-service-core.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",
// v2 bundle gained the server-owned interpretive layer (2026-09-01);
// fixtures that carry no engine snapshot declare it empty.
interpretiveFacts: emptyReportInterpretiveFacts(),
themeNarrativeSeeds: [],
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 inMemorySectionService(
initial: readonly PersonalReportSectionRecord[] = [],
): PersonalReportSectionService {
const rows = new Map(initial.map((row) => [row.sectionId, { ...row }]));
const timestamp = GENERATED_AT;
return {
async ensure(input) {
const existing = rows.get(input.sectionId);
if (existing) return existing;
const created: PersonalReportSectionRecord = {
userId: input.userId,
requestId: input.requestId,
sectionId: input.sectionId,
payload: null,
status: "pending",
attemptCount: 0,
maxAttempts: input.maxAttempts,
lastErrorCode: null,
createdAt: timestamp,
updatedAt: timestamp,
};
rows.set(input.sectionId, created);
return created;
},
async list() {
return [...rows.values()];
},
async start(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending" || current.attemptCount >= current.maxAttempts) return null;
const next = { ...current, attemptCount: current.attemptCount + 1, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
async complete(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending") return null;
const next = { ...current, status: "ready" as const, payload: input.payload, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
async block(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending") return null;
const next = { ...current, status: "blocked" as const, payload: null, lastErrorCode: input.errorCode, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
};
}
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, innerReason?: string): void {
assert.equal(result.status, "failed");
if (result.status !== "failed") return;
assert.equal(result.failureCode, "report_schema_invalid");
if (result.failureCode !== "report_schema_invalid") return;
assert.equal(typeof result.innerReason, "string");
assert.ok(result.innerReason.length > 0);
if (innerReason) assert.equal(result.innerReason, innerReason);
}
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"));
await assert.rejects(pending, (error: unknown) => {
assert.equal(error instanceof Error && error.message === "lease_lost", true);
return true;
});
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.match(agentSource, /abortSignal: signal/);
assert.match(agentSource, /assertWriterOutput/);
assert.match(agentSource, /runOnce\(`\$\{prompt\}\$\{REPAIR_PROMPT_SUFFIX\}`\)/);
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-d11", "ev-tech-d2"],
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, "report_writer_theme_count_mismatch");
});
}
});
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("writer evidence ref order differences still bind to the deterministic plan", 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 observed = { calls: 0, plan: null as PersonalReportSectionPlan | null };
const result = await run(bundle, fakeWriter((inputBundle, plan) => {
const output = writerOutputFor(inputBundle, plan);
return {
...output,
thematicNarrative: [{
...output.thematicNarrative[0],
evidenceRefs: [...output.thematicNarrative[0].evidenceRefs].reverse(),
}],
};
}, observed));
readyV2(result);
assert.equal(observed.calls, 1);
});
test("plan binding failure consumes the single writer repair retry", 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 observed = { calls: 0, plan: null as PersonalReportSectionPlan | null };
const result = await run(bundle, fakeWriter((inputBundle, plan) => {
const output = writerOutputFor(inputBundle, plan);
if (observed.calls === 1) {
return {
...output,
thematicNarrative: [...output.thematicNarrative, {
...output.thematicNarrative[0],
id: "theme-health",
theme: "health",
}],
};
}
return output;
}, observed));
readyV2(result);
assert.equal(observed.calls, 2);
});
test("schema inner reasons stay on the allowlist and never include model text", () => {
assert.equal(
classifyReportSchemaInnerReason(new Error("report_writer_theme_count_mismatch")),
"report_writer_theme_count_mismatch",
);
assert.equal(
classifyReportSchemaInnerReason(new Error("report_writer_section_id_mismatch:career")),
"report_writer_section_id_mismatch:career",
);
assert.equal(
classifyReportSchemaInnerReason(new Error("Unexpected token in JSON at position 12")),
"schema_invalid_unclassified",
);
});
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, "final_parse_rejected");
});
});
function sectionPayloadFor(
bundle: ReportEvidenceBundleV2,
section: Readonly<{ id: string; theme: string | null; evidenceRefs: readonly string[] }>,
): PersonalReportAgentOutput["thematicNarrative"][number] {
assert.ok(section.theme);
const card = bundle.claimCards.find((entry) => entry.theme === 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 sectionedAgent(input: Readonly<{
bundleCalls?: Array<readonly string[]>;
titles?: string[][];
sectionCalls?: string[];
failThemes?: ReadonlySet<string>;
abortAfterTheme?: string;
signalToAbort?: AbortController;
}>): ReportAgentPort {
return {
modelId: "sectioned-test-writer",
async generate() {
throw new Error("legacy generate must not be used for sectioned reports");
},
async generateSection(bundle, section, completedTitles, options) {
input.bundleCalls?.push(bundle.evidenceRefs.map((entry) => entry.id));
input.titles?.push([...completedTitles]);
input.sectionCalls?.push(section.theme ?? section.id);
if (input.abortAfterTheme === section.theme) {
input.signalToAbort?.abort();
const error = new Error("aborted");
error.name = "AbortError";
throw error;
}
if (input.failThemes?.has(section.theme ?? "")) {
throw new Error("section_output_invalid");
}
const output = sectionPayloadFor(bundle, section);
options?.assertWriterOutput?.(output);
return output;
},
async generateSummary(sections) {
return {
headline: "正式证据支持多个主题的审慎方向性判断",
summary: `已完成主题:${sections.map((section) => section.title).join("、")}`,
priorities: ["先核对最重要的现实问题", "再观察方向性线索是否与经历一致"],
};
},
};
}
function runSectioned(
bundle: ReportEvidenceBundleV2,
agent: ReportAgentPort,
sectionService: PersonalReportSectionService,
onProgress?: GeneratePersonalReportDeps["onProgress"],
): Promise<GeneratePersonalReportResult> {
return generatePersonalReport({
reportId: REPORT_ID,
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
bundle,
depth: "research",
agent,
sectionService,
onProgress,
now: () => new Date(GENERATED_AT),
});
}
test("sectioned generation makes one filtered call per write theme, then summary", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
const bundleCalls: Array<readonly string[]> = [];
const sectionCalls: string[] = [];
const titles: string[][] = [];
const result = await runSectioned(
bundle,
sectionedAgent({ bundleCalls, sectionCalls, titles }),
inMemorySectionService(),
);
const document = readyV2(result);
assert.deepEqual(sectionCalls, ["career", "marriage", "wealth"]);
assert.equal(bundleCalls.length, 3);
assert.deepEqual(bundleCalls, [
["ev-tech-d1", "ev-tech-d10"],
["ev-tech-d1", "ev-tech-d9"],
["ev-tech-d11", "ev-tech-d2"],
]);
assert.deepEqual(titles, [[], ["事业与方向"], ["事业与方向", "关系与婚恋"]]);
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "marriage", "wealth"]);
assert.match(document.executiveSummary.summary, /事业与方向/);
});
test("sectioned resume skips ready sections after an interruption", async () => {
const bundle = makeBundle({ themes: fullThemes, charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4), chart("D24", 5)] });
const service = inMemorySectionService();
const firstRunCalls: string[] = [];
const firstController = new AbortController();
await assert.rejects(
() => runSectioned(
bundle,
sectionedAgent({ sectionCalls: firstRunCalls, abortAfterTheme: "wealth", signalToAbort: firstController }),
service,
),
(error: unknown) => error instanceof Error && error.name === "AbortError",
);
const resumedCalls: string[] = [];
const result = await runSectioned(bundle, sectionedAgent({ sectionCalls: resumedCalls }), service);
readyV2(result);
assert.deepEqual(firstRunCalls, ["career", "education", "marriage", "wealth"]);
assert.deepEqual(resumedCalls, ["wealth"]);
});
test("a section becomes blocked after its own retry budget while other sections still deliver", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
const calls: string[] = [];
const progress: Array<Readonly<{ phase: string; completed: number; total: number }>> = [];
const result = await runSectioned(
bundle,
sectionedAgent({ sectionCalls: calls, failThemes: new Set(["marriage"]) }),
inMemorySectionService(),
(value) => { progress.push(value); },
);
const document = readyV2(result);
assert.deepEqual(calls, ["career", "marriage", "marriage", "wealth"]);
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "wealth"]);
assert.equal(document.blockedConflictDisclosure.length, 1);
assert.match(document.blockedConflictDisclosure[0].reason, /未能生成/);
assert.deepEqual(progress.map((entry) => entry.completed), [1, 2, 3, 3, 3]);
});
test("all blocked sections fail before summary", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 2), charts: [chart("D1"), chart("D9", 2)] });
let summaryCalls = 0;
const agent = sectionedAgent({ failThemes: new Set(["career", "marriage"]) });
const result = await runSectioned(bundle, {
...agent,
async generateSummary() {
summaryCalls += 1;
throw new Error("summary should not run");
},
}, inMemorySectionService());
assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid", innerReason: "all_sections_blocked" });
assert.equal(summaryCalls, 0);
});