fix(report): bind v2 chart cap to CHART_IDS.length
Five-theme personal_full assembled nine charts after health vargas landed, but the document schema still capped at six. Bind the count to the enum, align JSON/Python contracts, and log final-parse paths without values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,7 @@ export function safeParseServerReportDocument(input: unknown): ReportDocumentPar
|
||||
{
|
||||
path: "provenance.evidenceHash",
|
||||
message: `does not match recomputed evidence hash ${recomputed}`,
|
||||
code: "evidence_hash",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -282,7 +282,7 @@ export const reportDocumentV2Schema = z.strictObject({
|
||||
natalFoundation: natalFoundationSchema,
|
||||
currentPhase: currentPhaseSchema.nullable(),
|
||||
actionNotes: z.array(actionNoteSchema).min(1).max(24),
|
||||
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
|
||||
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
|
||||
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
|
||||
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
|
||||
evidenceAppendix: evidenceAppendixSchema,
|
||||
@@ -310,6 +310,7 @@ export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDiscl
|
||||
export type ReportDocumentParseError = Readonly<{
|
||||
path: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export class ReportDocumentValidationError extends Error {
|
||||
@@ -592,6 +593,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
|
||||
errors: parsed.error.issues.map((issue) => ({
|
||||
path: issue.path.join(".") || "(root)",
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -600,7 +602,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
|
||||
if (guardErrors.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
|
||||
errors: guardErrors.map((message) => ({ path: "(guard)", message, code: "guard" })),
|
||||
};
|
||||
}
|
||||
return { ok: true, document };
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
safeParseServerReportDocument,
|
||||
} from "./personal-report-contract.server-core.ts";
|
||||
import {
|
||||
CHART_IDS,
|
||||
REQUIRED_THEME_CHARTS,
|
||||
type ClaimStatus,
|
||||
type EvidenceAppendix,
|
||||
type ReportDepth,
|
||||
type ReportDocumentParseError,
|
||||
type ReportDocumentV1,
|
||||
type ReportDocumentV2,
|
||||
} from "./personal-report-contract.ts";
|
||||
@@ -318,8 +320,10 @@ function readHouses(
|
||||
return houses;
|
||||
}
|
||||
|
||||
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
|
||||
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
|
||||
type DocumentVargaChartId = Exclude<(typeof CHART_IDS)[number], "D1">;
|
||||
const DOCUMENT_VARGA_CHART_IDS = CHART_IDS.filter(
|
||||
(id): id is DocumentVargaChartId => id !== "D1",
|
||||
);
|
||||
|
||||
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
|
||||
D2: ["D2_Hora", "D2"],
|
||||
@@ -2691,7 +2695,7 @@ export function assembleReportDocumentV2(
|
||||
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
|
||||
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
|
||||
|
||||
const chartIds = new Set(["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"]);
|
||||
const chartIds = new Set<string>(CHART_IDS);
|
||||
const charts: ReportDocumentV2["charts"] = [];
|
||||
for (const chart of bundle.charts) {
|
||||
if (!chartIds.has(chart.id)) continue;
|
||||
@@ -3220,15 +3224,39 @@ export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSi
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult {
|
||||
console.info("[personal-report]", JSON.stringify({
|
||||
function failSchema(
|
||||
innerReason: ReportSchemaInnerReason,
|
||||
parseErrors?: readonly ReportDocumentParseError[],
|
||||
): GeneratePersonalReportResult {
|
||||
const payload: {
|
||||
event: "generation_failed";
|
||||
failureCode: "report_schema_invalid";
|
||||
innerReason: ReportSchemaInnerReason;
|
||||
parsePaths?: readonly Readonly<{ path: string; code: string }>[];
|
||||
} = {
|
||||
event: "generation_failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason,
|
||||
}));
|
||||
};
|
||||
if (parseErrors && parseErrors.length > 0) {
|
||||
payload.parsePaths = parseErrors.map((error) => ({
|
||||
path: error.path,
|
||||
code: error.code,
|
||||
}));
|
||||
}
|
||||
console.info("[personal-report]", JSON.stringify(payload));
|
||||
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
|
||||
}
|
||||
|
||||
function failFinalParse(
|
||||
parsed: ReturnType<typeof safeParseServerReportDocument>,
|
||||
): GeneratePersonalReportResult {
|
||||
if (!parsed.ok) {
|
||||
return failSchema(classifyFinalParseInnerReason(parsed.errors), parsed.errors);
|
||||
}
|
||||
return failSchema("final_parse_rejected");
|
||||
}
|
||||
|
||||
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
||||
if (isPersonalReportGenerationAbort(error, signal)) throw error;
|
||||
}
|
||||
@@ -3473,7 +3501,7 @@ async function generateSectionedPersonalReport(
|
||||
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
|
||||
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
|
||||
const parsed = safeParseServerReportDocument(guarded.document);
|
||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failFinalParse(parsed);
|
||||
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
@@ -3575,7 +3603,7 @@ export async function generatePersonalReport(
|
||||
}
|
||||
const parsed = safeParseServerReportDocument(guarded.document);
|
||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
|
||||
return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
||||
return failFinalParse(parsed);
|
||||
}
|
||||
return {
|
||||
status: "ready",
|
||||
|
||||
@@ -1294,6 +1294,7 @@ test("list timestamps accept Date objects and ISO strings from self-hosted postg
|
||||
assert.match(coreSource, /export function reportListTimestamp/);
|
||||
assert.match(generationSource, /innerReason/);
|
||||
assert.match(generationSource, /isPersonalReportGenerationAbort/);
|
||||
assert.match(generationSource, /parsePaths/);
|
||||
assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseServerReportDocument,
|
||||
} from "../src/lib/personal-report-contract.server-core.ts";
|
||||
import {
|
||||
CHART_IDS,
|
||||
findBlockedDeterministicClaims,
|
||||
findChartSetViolations,
|
||||
findDanglingEvidenceRefs,
|
||||
@@ -336,6 +337,60 @@ test("v2 guard rejects unsupported dates, medical diagnoses, and deterministic f
|
||||
assert.equal(safeParseReportDocument(financial).ok, false);
|
||||
});
|
||||
|
||||
test("v2 chart cap and enum stay bound to CHART_IDS", () => {
|
||||
const contractSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-contract.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
contractSource,
|
||||
/charts: z\.array\(reportDocumentV2ChartSchema\)\.min\(1\)\.max\(CHART_IDS\.length\)/,
|
||||
);
|
||||
assert.doesNotMatch(contractSource, /charts: z\.array\(reportDocumentV1ChartSchema\)\.min\(1\)\.max\(CHART_IDS/);
|
||||
|
||||
const schema = JSON.parse(readFileSync(
|
||||
new URL("../../contracts/personal-report/report-document.v2.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as {
|
||||
properties: { charts: { maxItems: number } };
|
||||
definitions: { chart: { properties: { id: { enum: string[] } } } };
|
||||
};
|
||||
assert.equal(schema.properties.charts.maxItems, CHART_IDS.length);
|
||||
assert.deepEqual(schema.definitions.chart.properties.id.enum, [...CHART_IDS]);
|
||||
});
|
||||
|
||||
test("a document with every CHART_IDS entry passes the server parse", () => {
|
||||
const document = cloneV2();
|
||||
const template = document.charts[0];
|
||||
const present = new Set(document.charts.map((chart) => chart.id));
|
||||
for (const id of CHART_IDS) {
|
||||
if (present.has(id)) continue;
|
||||
document.charts.push({
|
||||
...structuredClone(template),
|
||||
id,
|
||||
title: `${id} 分盘`,
|
||||
});
|
||||
}
|
||||
assert.equal(document.charts.length, CHART_IDS.length);
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
assert.equal(parsed.ok, true);
|
||||
});
|
||||
|
||||
test("one chart beyond CHART_IDS.length is still rejected", () => {
|
||||
const document = cloneV2();
|
||||
const template = document.charts[0];
|
||||
while (document.charts.length < CHART_IDS.length + 1) {
|
||||
document.charts.push({
|
||||
...structuredClone(template),
|
||||
id: template.id,
|
||||
title: `${template.id} extra ${document.charts.length}`,
|
||||
});
|
||||
}
|
||||
const result = safeParseReportDocument(document);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((error) => error.path === "charts" && error.code === "too_big"));
|
||||
});
|
||||
|
||||
test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () => {
|
||||
for (const poison of ["<div>报告</div>", "body { color: red; }", 'style="color:red"']) {
|
||||
const document = cloneV2();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ReportEvidenceBundleV2,
|
||||
type TechniqueExecutionReceipt,
|
||||
} from "../src/lib/report-evidence-bundle-v2.ts";
|
||||
import { CHART_IDS } from "../src/lib/personal-report-contract.ts";
|
||||
import {
|
||||
classifyFinalParseInnerReason,
|
||||
classifyReportSchemaInnerReason,
|
||||
@@ -320,6 +321,14 @@ const fullThemes: readonly ThemeSpec[] = [
|
||||
{ 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,
|
||||
@@ -358,6 +367,31 @@ test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real
|
||||
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("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => {
|
||||
const bundle = makeBundle({
|
||||
themes: [{
|
||||
@@ -728,6 +762,50 @@ test("deterministic guard rejection and final schema rejection remain distinct t
|
||||
});
|
||||
});
|
||||
|
||||
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[] }>,
|
||||
|
||||
Reference in New Issue
Block a user