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 { CHART_IDS, REPORT_DOCUMENT_V2_ACTION_NOTES_MAX } from "../src/lib/personal-report-contract.ts"; import { classifyFinalParseInnerReason, 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> = { vimshottari: "Vimshottari", narayana: "Narayana", transit: "Transit", }; return canonical[slug] ?? slug.toUpperCase(); } function makeClaim(spec: ThemeSpec): ReportClaimCard { return { id: `ev-claim-${spec.theme.replaceAll(".", "-")}`, theme: spec.theme, section: spec.section, conclusion: `${spec.section}存在可由正式计算证据支持的方向性结构。`, supportingFacts: spec.refs.map((ref, index) => ({ id: `ev-fact-${spec.theme.replaceAll(".", "-")}-${index + 1}`, label: `${spec.section}证据 ${index + 1}`, value: `${techniqueName(ref)} 已由正式计算结果投影。`, evidenceRef: ref, status: "verified" as const, })), counterFacts: [], executedTechniqueRefs: [...spec.refs], assertionLevel: spec.assertionLevel ?? (spec.refs.length > 1 ? "multi_system_consensus" : "single_system_inference"), timingBoundary: null, verificationQuestions: [], }; } function makeBundle(input: Readonly<{ themes: readonly ThemeSpec[]; blocked?: readonly BlockedThemeSpec[]; charts?: readonly ReportChartFact[]; birthTimeStatus?: "reported" | "candidate" | "accepted" | "confirmed"; }>): ReportEvidenceBundleV2 { const blocked = input.blocked ?? []; const statuses = new Map(); statuses.set("ev-tech-d1", "verified"); for (const theme of input.themes) { for (const ref of theme.refs) statuses.set(ref, "verified"); } for (const theme of blocked) { for (const ref of theme.refs) statuses.set(ref, "blocked"); } const executionLedger: TechniqueExecutionReceipt[] = [...statuses].map(([id, status]) => ({ id, technique: techniqueName(id), status, executed: status !== "blocked", note: status === "blocked" ? "正式证据缺失" : "正式计算已执行", })); const birthTimeStatus = input.birthTimeStatus ?? "accepted"; const blockedSections: BlockedSection[] = blocked.map((spec) => ({ id: `ev-blocked-${spec.theme.replaceAll(".", "-")}`, theme: spec.theme, section: spec.section, reason: `${spec.section}缺少正式证据,不能形成结论。`, missingTechniqueRefs: [...spec.refs], })); return finalizeReportEvidenceBundleV2({ schemaVersion: "report_evidence_bundle.v2", // 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 { 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"] }, ]; const standardFiveThemes: 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: "timing", section: "当前阶段", refs: ["ev-tech-vimshottari", "ev-tech-narayana"] }, { theme: "health", section: "压力与恢复", refs: ["ev-tech-d6", "ev-tech-d8", "ev-tech-d24", "ev-tech-d30"] }, ]; 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("five-theme sectioned pipeline with the full CHART_IDS set finishes READY", async () => { const bundle = makeBundle({ themes: standardFiveThemes, charts: CHART_IDS.map((id, offset) => chart(id, offset)), }); const sectionCalls: string[] = []; const result = await runSectioned( bundle, sectionedAgent({ sectionCalls }), inMemorySectionService(), ); const document = readyV2(result); assert.deepEqual( document.thematicNarrative.map((section) => section.theme), ["career", "health", "marriage", "timing", "wealth"], ); assert.deepEqual(document.blockedConflictDisclosure, []); assert.deepEqual( [...document.charts.map((entry) => entry.id)].sort(), [...CHART_IDS].sort(), ); assert.equal(document.charts.length, CHART_IDS.length); assert.deepEqual(sectionCalls, ["career", "health", "marriage", "timing", "wealth"]); }); test("five-theme assemble keeps actionNotes within the document cap", async () => { const bundle = makeBundle({ themes: standardFiveThemes, charts: CHART_IDS.map((id, offset) => chart(id, offset)), }); const inner = sectionedAgent({}); const agent: ReportAgentPort = { ...inner, async generateSection(sectionBundle, section, completedTitles, options) { const output = await inner.generateSection!(sectionBundle, section, completedTitles, options); return { ...output, actions: Array.from({ length: 6 }, (_, index) => ( `围绕${output.title}记录可验证的现实反馈 ${index + 1}` )), }; }, }; const document = readyV2(await runSectioned(bundle, agent, inMemorySectionService())); assert.equal(document.actionNotes.length, REPORT_DOCUMENT_V2_ACTION_NOTES_MAX); assert.equal(document.blockedConflictDisclosure.length, 0); }); test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"], assertionLevel: "single_system_inference", }], charts: [chart("D1"), chart("D10", 3)], }); const controller = new AbortController(); let observedSignal: AbortSignal | undefined; let writerStarted!: () => void; const started = new Promise((resolve) => { writerStarted = resolve; }); const agent: ReportAgentPort = { modelId: "abort-aware-report-writer", async generate(_inputBundle, _plan, options) { observedSignal = options?.signal; writerStarted(); return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }); }); }, }; const pending = generatePersonalReport({ reportId: REPORT_ID, bundle, depth: "standard", agent, signal: controller.signal, now: () => new Date(GENERATED_AT), }); await started; assert.equal(observedSignal, controller.signal); controller.abort(new Error("lease_lost")); 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, /writerRepairPromptSuffix/); assert.match(agentSource, /runOnce\(`\$\{prompt\}\$\{repairSuffix\}`\)/); assert.match(agentSource, /const signal = options\?\.signal/); assert.match(workerSource, /generatePersonalReport\(\{[\s\S]*signal: context\.signal,[\s\S]*\}\)/); assert.match(workerSource, /leaseSeconds: 600/); assert.match(workerSource, /heartbeatIntervalMs: 20_000/); const workerCore = readFileSync( new URL("../src/lib/personal-report-worker-core.ts", import.meta.url), "utf8", ); assert.match(workerCore, /onProgress: async \(progress\) => \{\s*try \{\s*await refreshLease\(\)/); assert.doesNotMatch(workerCore, /timerUnref\(heartbeatTimer\)/); const generationSource = readFileSync( new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8", ); assert.match(generationSource, /const emitProgress = async \(phase: string, completed: number\)/); assert.match(generationSource, /rethrowIfAborted\(error, deps\.signal\);/); }); 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("wealth write without structured D2/D11 charts demotes to blocked instead of failing schema parse", async () => { const bundle = makeBundle({ themes: [{ theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"], }], charts: [chart("D1")], }); const sectionCalls: string[] = []; const result = await runSectioned( bundle, sectionedAgent({ sectionCalls }), inMemorySectionService(), ); const document = readyV2(result); assert.deepEqual(sectionCalls, []); assert.deepEqual(document.thematicNarrative.map((section) => section.theme), []); assert.equal(document.blockedConflictDisclosure.length, 1); assert.equal(document.blockedConflictDisclosure[0].theme, "wealth"); assert.match(document.blockedConflictDisclosure[0].reason, /D2|D11|结构化分盘/); }); 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", ); assert.equal( classifyFinalParseInnerReason([ { path: "(guard)", message: "thematic section wealth requires structured D2 chart data or a blocked disclosure" }, { path: "(guard)", message: "thematic section wealth requires structured D11 chart data or a blocked disclosure" }, ]), "final_parse_missing_divisional_chart", ); assert.equal( classifyFinalParseInnerReason([ { path: "thematicNarrative.0.title", message: "String must contain at most 160 character(s)" }, ]), "final_parse_rejected", ); assert.equal( classifyFinalParseInnerReason([ { path: "provenance.evidenceHash", message: "does not match recomputed evidence hash" }, ]), "final_parse_evidence_hash", ); }); 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"); }); }); test("final parse rejection logs issue paths without document body", async () => { const bundle = makeBundle({ themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }], charts: [chart("D1"), chart("D10", 3)], }); const poison = "过".repeat(161); const logs: unknown[][] = []; const originalInfo = console.info; console.info = (...args: unknown[]) => logs.push(args); let result: GeneratePersonalReportResult; try { result = await run(bundle, fakeWriter((inputBundle, plan) => { const output = writerOutputFor(inputBundle, plan); return { ...output, thematicNarrative: [{ ...output.thematicNarrative[0], title: poison, }], }; })); } finally { console.info = originalInfo; } expectSchemaRejected(result, "final_parse_rejected"); const payload = logs .filter((args) => args[0] === "[personal-report]") .map((args) => JSON.parse(String(args[1])) as { event?: string; innerReason?: string; parsePaths?: readonly Readonly<{ path: string; code: string }>[]; }) .find((entry) => entry.event === "generation_failed"); assert.ok(payload); assert.equal(payload.innerReason, "final_parse_rejected"); assert.ok(payload.parsePaths?.some((entry) => ( entry.path === "thematicNarrative.0.title" && entry.code === "too_big" ))); const serialized = JSON.stringify(payload); assert.equal("message" in (payload.parsePaths?.[0] ?? {}), false); assert.doesNotMatch(serialized, new RegExp(poison)); assert.doesNotMatch(serialized, /正式证据支持多个主题/); }); 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; titles?: string[][]; sectionCalls?: string[]; failThemes?: ReadonlySet; mismatchRefsThemes?: ReadonlySet; 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); const candidate = input.mismatchRefsThemes?.has(section.theme ?? "") ? { ...output, evidenceRefs: [...output.evidenceRefs, "ev-tech-extra"] } : output; options?.assertWriterOutput?.(candidate); return candidate; }, 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 { 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 = []; 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 generation still assembles when onProgress throws after a persisted section", 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 result = await runSectioned( bundle, sectionedAgent({}), inMemorySectionService(), async () => { throw new Error("progress_projection_failed"); }, ); const document = readyV2(result); assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "marriage", "wealth"]); }); 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> = []; 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); }); test("evidenceRefs mismatch is classified separately from evidence insufficient and other sections continue", 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 service = inMemorySectionService(); const result = await runSectioned( bundle, sectionedAgent({ mismatchRefsThemes: new Set(["career"]) }), service, ); const document = readyV2(result); const rows = await service.list("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); assert.equal(rows.find((row) => row.sectionId === "theme-career")?.lastErrorCode, "section_refs_mismatch"); assert.equal(rows.find((row) => row.sectionId === "theme-career")?.status, "blocked"); assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["marriage", "wealth"]); }); test("sectioned generation never uses English character-halving for output token budgets", () => { const generationSource = readFileSync( new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8", ); assert.match(generationSource, /sectionOutputTokenBudget\(entry\.targetCharacters\.max\)/); assert.doesNotMatch(generationSource, /targetCharacters\.max\s*\/\s*2/); });