fix(web): diagnose personal-report schema failures and ISO list timestamps
Keep report_schema_invalid for the user, but record the inner check, retry plan bind once, and format self-hosted timestamptz so the report list no longer shows 时间未知. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5323,3 +5323,19 @@
|
||||
- 复发自:BUG-348(收集后再区分;区分问句被方法轮询和出牌 defer 排到采用之后)
|
||||
- 修复版本:9873ba42
|
||||
|
||||
## BUG-352 | 个人报告失败被压成 report_schema_invalid,列表把 Date 显示成时间未知
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-22
|
||||
- 最近更新:2026-08-22
|
||||
- 影响面:个人报告中心 `/reports`、`GET /api/reports`、`generatePersonalReport`、自托管 `timestamptz` 投影、报告 writer 绑定
|
||||
- 用户现象:生成完整报告后记录为「未完成」,右侧只显示 `report_schema_invalid`;卡片时间显示「时间未知」。出生时钟在创建时是可用的,失败发生在后台生成之后。
|
||||
- 触发条件:自托管 PostgreSQL 上创建个人完整报告;writer 输出与 section plan 不完全一致,或生成中被中止;列表读取 `created_at`。
|
||||
- 根因:`generatePersonalReport` 把 writer JSON、plan 绑定、组装、终态 parse 和 abort 全部吞成同一个 `report_schema_invalid`,且不记录内层 token。evidenceRefs 还要求顺序全等。自托管 `queryValue()` 只把 `date` 收成字符串,`timestamptz` 仍是 `Date`,列表投影只接受 string,UI 便显示「时间未知」。
|
||||
- 修复:schema 失败返回 allowlist `innerReason` 并打无 PII 日志;abort 向上抛出,不再伪装成 schema 失败。plan 绑定失败走现有那一次 repair;evidenceRefs 改为集合相等。`queryValue` 把 `timestamptz`/`timestamp` 收成 ISO,列表投影同时接受 `Date`。
|
||||
- 验证:`frontend/tests/personal-report-generation-v2.test.ts`、`frontend/tests/personal-report-generation.test.ts`、`frontend/tests/personal-report-api.test.ts`、`frontend/tests/local-postgres-query-value.test.ts`、`frontend/tests/personal-report-worker.test.ts`。
|
||||
- 防复发:用户可见码可以仍是 `report_schema_invalid`,但生成结果和日志必须带 allowlist 内层 token。lease abort 不得再断言成 schema 失败。列表时间必须能从 `Date` 或 ISO 字符串格式化。不得把模型原文、出生资料或路径写进失败日志。
|
||||
- 相关记录:BUG-154、BUG-188、BUG-217
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/personal-report-entitlement";
|
||||
import {
|
||||
resolveReportCreate,
|
||||
reportListTimestamp,
|
||||
type ReportCreateCoreDeps,
|
||||
} from "@/lib/personal-report-route-core";
|
||||
import {
|
||||
@@ -64,8 +65,10 @@ function listReportView(value: unknown) {
|
||||
: [],
|
||||
status: typeof row.status === "string" ? row.status : "failed",
|
||||
failureCode: typeof row.failure_code === "string" ? row.failure_code : null,
|
||||
createdAt: typeof row.created_at === "string" ? row.created_at : "",
|
||||
completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
|
||||
createdAt: reportListTimestamp(row.created_at),
|
||||
completedAt: row.completed_at == null || row.completed_at === ""
|
||||
? null
|
||||
: reportListTimestamp(row.completed_at) || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -136,14 +136,25 @@ function databaseValue(type: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function queryValue(type: string | undefined, value: unknown): unknown {
|
||||
if (type !== "date" || !(value instanceof Date)) return value;
|
||||
// pg parses DATE at local midnight; UTC formatting can shift the calendar day.
|
||||
return [
|
||||
String(value.getFullYear()).padStart(4, "0"),
|
||||
String(value.getMonth() + 1).padStart(2, "0"),
|
||||
String(value.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
export function queryValue(type: string | undefined, value: unknown): unknown {
|
||||
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return value;
|
||||
if (type === "date") {
|
||||
// pg parses DATE at local midnight; UTC formatting can shift the calendar day.
|
||||
return [
|
||||
String(value.getFullYear()).padStart(4, "0"),
|
||||
String(value.getMonth() + 1).padStart(2, "0"),
|
||||
String(value.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
}
|
||||
if (
|
||||
type === "timestamptz"
|
||||
|| type === "timestamp"
|
||||
|| type === "timestamp with time zone"
|
||||
|| type === "timestamp without time zone"
|
||||
) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
|
||||
@@ -10,13 +10,14 @@ import type {
|
||||
ReportDocumentV1,
|
||||
ReportDocumentV2,
|
||||
} from "./personal-report-contract.ts";
|
||||
import type {
|
||||
EvidenceRefStatus,
|
||||
PersonalReportAgentOutput,
|
||||
ReportAgentPort,
|
||||
ReportEvidenceBundleV2,
|
||||
ReportEvidencePacket,
|
||||
ReportPlanetFact,
|
||||
import {
|
||||
PersonalReportAgentOutputError,
|
||||
type EvidenceRefStatus,
|
||||
type PersonalReportAgentOutput,
|
||||
type ReportAgentPort,
|
||||
type ReportEvidenceBundleV2,
|
||||
type ReportEvidencePacket,
|
||||
type ReportPlanetFact,
|
||||
} from "@/mastra/personal-report";
|
||||
import type {
|
||||
EvidenceConflict,
|
||||
@@ -1433,8 +1434,11 @@ function uniqueInOrder(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function equalStringArrays(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
function equalStringSets(left: readonly string[], right: readonly string[]): boolean {
|
||||
const uniqueLeft = [...new Set(left)].sort();
|
||||
const uniqueRight = [...new Set(right)].sort();
|
||||
return uniqueLeft.length === uniqueRight.length
|
||||
&& uniqueLeft.every((value, index) => value === uniqueRight[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1463,7 +1467,7 @@ export function validatePersonalReportAgentOutputAgainstPlan(
|
||||
const card = claimCards.get(section.theme);
|
||||
if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`);
|
||||
if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`);
|
||||
if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) {
|
||||
if (!equalStringSets(section.evidenceRefs, sectionPlan.evidenceRefs)) {
|
||||
throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`);
|
||||
}
|
||||
if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) {
|
||||
@@ -2094,11 +2098,44 @@ export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readon
|
||||
depth: ReportDepth;
|
||||
}>;
|
||||
|
||||
export type ReportSchemaInnerReason = string;
|
||||
|
||||
export type GeneratePersonalReportResult = Readonly<
|
||||
| { status: "ready"; document: ReportDocumentV2; evidenceHash: string }
|
||||
| { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" }
|
||||
| { status: "failed"; failureCode: "report_schema_invalid"; innerReason: ReportSchemaInnerReason }
|
||||
| { status: "failed"; failureCode: "report_guard_rejected" }
|
||||
>;
|
||||
|
||||
const SAFE_INNER_REASON = /^report_(?:writer|plan)_[a-z0-9_.:-]{0,80}$/;
|
||||
|
||||
export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInnerReason {
|
||||
if (error instanceof PersonalReportAgentOutputError) return "agent_output_invalid";
|
||||
if (error instanceof ReportEvidenceInsufficientError) return "assemble_invalid";
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (SAFE_INNER_REASON.test(message)) return message;
|
||||
const token = message.split(":")[0] ?? "";
|
||||
if (SAFE_INNER_REASON.test(token)) return token;
|
||||
return "schema_invalid_unclassified";
|
||||
}
|
||||
|
||||
export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean {
|
||||
if (signal?.aborted) return true;
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult {
|
||||
console.info("[personal-report]", JSON.stringify({
|
||||
event: "generation_failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason,
|
||||
}));
|
||||
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
|
||||
}
|
||||
|
||||
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
||||
if (isPersonalReportGenerationAbort(error, signal)) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the dedicated report agent exactly once (plus its single internal
|
||||
* repair retry), assembles the candidate document, applies the deterministic
|
||||
@@ -2111,19 +2148,58 @@ export async function generatePersonalReport(
|
||||
deps: GeneratePersonalReportDeps,
|
||||
): Promise<GeneratePersonalReportResult> {
|
||||
let bundle: ReportEvidenceBundleV2;
|
||||
let plan: PersonalReportSectionPlan;
|
||||
let agentOutput: PersonalReportAgentOutput;
|
||||
try {
|
||||
bundle = validateReportEvidenceBundleV2(deps.bundle);
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
return failSchema("bundle_invalid");
|
||||
}
|
||||
|
||||
let plan: PersonalReportSectionPlan;
|
||||
try {
|
||||
plan = validatePersonalReportSectionPlan(
|
||||
buildPersonalReportSectionPlan(bundle, deps.depth),
|
||||
bundle,
|
||||
);
|
||||
agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal });
|
||||
validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle);
|
||||
} catch {
|
||||
return { status: "failed", failureCode: "report_schema_invalid" };
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
return failSchema("plan_invalid");
|
||||
}
|
||||
|
||||
const bindWriter = (output: PersonalReportAgentOutput) => (
|
||||
validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle)
|
||||
);
|
||||
|
||||
let agentOutput: PersonalReportAgentOutput;
|
||||
try {
|
||||
agentOutput = await deps.agent.generate(bundle, plan, {
|
||||
signal: deps.signal,
|
||||
assertWriterOutput: bindWriter,
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
return failSchema(
|
||||
error instanceof PersonalReportAgentOutputError
|
||||
? "agent_output_invalid"
|
||||
: classifyReportSchemaInnerReason(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
agentOutput = bindWriter(agentOutput);
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
try {
|
||||
agentOutput = bindWriter(await deps.agent.generate(bundle, plan, {
|
||||
signal: deps.signal,
|
||||
assertWriterOutput: bindWriter,
|
||||
}));
|
||||
} catch (repairError) {
|
||||
rethrowIfAborted(repairError, deps.signal);
|
||||
return failSchema(classifyReportSchemaInnerReason(repairError));
|
||||
}
|
||||
}
|
||||
|
||||
const packet = buildLegacyPacketFromBundle(bundle);
|
||||
let candidate: ReportDocumentV2;
|
||||
try {
|
||||
@@ -2135,8 +2211,13 @@ export async function generatePersonalReport(
|
||||
plan,
|
||||
agentOutput,
|
||||
});
|
||||
} catch {
|
||||
return { status: "failed", failureCode: "report_schema_invalid" };
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
return failSchema(
|
||||
error instanceof ReportEvidenceInsufficientError
|
||||
? "assemble_invalid"
|
||||
: classifyReportSchemaInnerReason(error),
|
||||
);
|
||||
}
|
||||
const guarded = applyReportGuard(candidate, packet);
|
||||
if (!guarded.ok) {
|
||||
@@ -2144,7 +2225,7 @@ export async function generatePersonalReport(
|
||||
}
|
||||
const parsed = safeParseServerReportDocument(guarded.document);
|
||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
|
||||
return { status: "failed", failureCode: "report_schema_invalid" };
|
||||
return failSchema("final_parse_rejected");
|
||||
}
|
||||
return {
|
||||
status: "ready",
|
||||
|
||||
@@ -123,6 +123,14 @@ function parseBirthDate(value: unknown): { year: number; month: number; day: num
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
export function reportListTimestamp(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return Number.isFinite(Date.parse(value)) ? value : "";
|
||||
}
|
||||
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
||||
return "";
|
||||
}
|
||||
|
||||
export function reportView(row: PersonalReportRecord) {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -325,7 +325,10 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
await heartbeatChain;
|
||||
if (heartbeatError !== null) throw heartbeatError;
|
||||
if (generated.status === "failed") {
|
||||
throw new PersonalReportWorkerError(generated.failureCode, false);
|
||||
const innerReason = generated.failureCode === "report_schema_invalid"
|
||||
? generated.innerReason
|
||||
: generated.failureCode;
|
||||
throw new PersonalReportWorkerError(generated.failureCode, false, innerReason);
|
||||
}
|
||||
|
||||
await deps.jobs.updateProgress({
|
||||
|
||||
@@ -181,6 +181,8 @@ ${JSON.stringify({ bundle, plan })}`;
|
||||
|
||||
export type ReportAgentGenerateOptions = Readonly<{
|
||||
signal?: AbortSignal;
|
||||
/** Server-owned plan binding. Failure here consumes the single repair retry. */
|
||||
assertWriterOutput?: (output: PersonalReportAgentOutput) => void;
|
||||
}>;
|
||||
|
||||
export type ReportAgentPort = Readonly<{
|
||||
@@ -192,7 +194,12 @@ export type ReportAgentPort = Readonly<{
|
||||
): Promise<PersonalReportAgentOutput>;
|
||||
}>;
|
||||
|
||||
const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
|
||||
const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
|
||||
|
||||
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
|
||||
if (signal?.aborted) return true;
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort {
|
||||
const agent = new Agent({
|
||||
@@ -208,45 +215,56 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
|
||||
const startedAt = Date.now();
|
||||
const signal = options?.signal;
|
||||
const prompt = buildReportPrompt(bundle, plan);
|
||||
const structuredOutput = {
|
||||
schema: personalReportAgentOutputSchema,
|
||||
jsonPromptInjection: "inline" as const,
|
||||
};
|
||||
let repairAttempted = false;
|
||||
|
||||
const runOnce = (content: string) => agent.generate(
|
||||
[{ role: "user", content }],
|
||||
{ abortSignal: signal, structuredOutput },
|
||||
);
|
||||
|
||||
const accept = (result: { object?: unknown; usage?: unknown }):
|
||||
| { ok: true; data: PersonalReportAgentOutput }
|
||||
| { ok: false; cause: "schema" | "bind"; error?: unknown } => {
|
||||
const parsed = personalReportAgentOutputSchema.safeParse(result.object);
|
||||
if (!parsed.success) return { ok: false, cause: "schema" };
|
||||
try {
|
||||
options?.assertWriterOutput?.(parsed.data);
|
||||
return { ok: true, data: parsed.data };
|
||||
} catch (error) {
|
||||
if (isAbortError(error, signal)) throw error;
|
||||
return { ok: false, cause: "bind", error };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const first = await agent.generate(
|
||||
[{ role: "user", content: prompt }],
|
||||
{
|
||||
abortSignal: signal,
|
||||
structuredOutput: {
|
||||
schema: personalReportAgentOutputSchema,
|
||||
jsonPromptInjection: "inline",
|
||||
},
|
||||
},
|
||||
);
|
||||
const firstParsed = personalReportAgentOutputSchema.safeParse(first.object);
|
||||
if (firstParsed.success) {
|
||||
const first = await runOnce(prompt);
|
||||
const firstAccepted = accept(first);
|
||||
if (firstAccepted.ok) {
|
||||
logTelemetry(model.id, startedAt, false, "resolved", first.usage);
|
||||
return firstParsed.data;
|
||||
return firstAccepted.data;
|
||||
}
|
||||
|
||||
// Exactly one repair retry is allowed. A second failure is terminal.
|
||||
repairAttempted = true;
|
||||
const repaired = await agent.generate(
|
||||
[{ role: "user", content: `${prompt}${REPAIR_PROMPT_SUFFIX}` }],
|
||||
{
|
||||
abortSignal: signal,
|
||||
structuredOutput: {
|
||||
schema: personalReportAgentOutputSchema,
|
||||
jsonPromptInjection: "inline",
|
||||
},
|
||||
},
|
||||
);
|
||||
const repairedParsed = personalReportAgentOutputSchema.safeParse(repaired.object);
|
||||
if (repairedParsed.success) {
|
||||
const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`);
|
||||
const repairedAccepted = accept(repaired);
|
||||
if (repairedAccepted.ok) {
|
||||
logTelemetry(model.id, startedAt, true, "resolved", repaired.usage);
|
||||
return repairedParsed.data;
|
||||
return repairedAccepted.data;
|
||||
}
|
||||
logTelemetry(model.id, startedAt, true, "failed", repaired.usage);
|
||||
if (repairedAccepted.cause === "bind" && repairedAccepted.error) {
|
||||
throw repairedAccepted.error;
|
||||
}
|
||||
throw new PersonalReportAgentOutputError();
|
||||
} catch (error) {
|
||||
if (error instanceof PersonalReportAgentOutputError) throw error;
|
||||
if (isAbortError(error, signal)) throw error;
|
||||
if (error instanceof Error && error.message.startsWith("report_writer_")) throw error;
|
||||
logTelemetry(model.id, startedAt, repairAttempted, "failed", null);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
.single();
|
||||
assert.equal(profile.error, null);
|
||||
const { created_at: createdAt, ...profileData } = profile.data as Record<string, unknown>;
|
||||
assert.ok(createdAt instanceof Date);
|
||||
assert.equal(typeof createdAt, "string");
|
||||
assert.match(String(createdAt), /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.deepEqual(profileData, {
|
||||
id: userId,
|
||||
email: "local-user@example.com",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { queryValue } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
test("queryValue keeps calendar dates in local civil form", () => {
|
||||
assert.equal(queryValue("date", new Date(2026, 7, 22)), "2026-08-22");
|
||||
});
|
||||
|
||||
test("queryValue projects timestamptz and timestamp Date values as ISO strings", () => {
|
||||
const instant = new Date("2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("timestamptz", instant), "2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("timestamp", instant), "2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("timestamp with time zone", instant), "2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("timestamp without time zone", instant), "2026-08-22T12:34:56.000Z");
|
||||
});
|
||||
|
||||
test("queryValue leaves non-date values unchanged", () => {
|
||||
assert.equal(queryValue("timestamptz", "2026-08-22T12:34:56.000Z"), "2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("text", "hello"), "hello");
|
||||
const leftover = new Date("2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("unknown", leftover), leftover);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../src/lib/personal-report-plan.ts";
|
||||
import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts";
|
||||
import {
|
||||
reportListTimestamp,
|
||||
resolveReportCreate,
|
||||
resolveReportDelete,
|
||||
resolveReportRead,
|
||||
@@ -1075,6 +1076,22 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi
|
||||
);
|
||||
assert.doesNotMatch(listColumns, /report_document|calculation_hash|evidence_hash/);
|
||||
assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/);
|
||||
assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/);
|
||||
assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/);
|
||||
});
|
||||
|
||||
test("list timestamps accept Date objects and ISO strings from self-hosted postgres", () => {
|
||||
assert.equal(
|
||||
reportListTimestamp(new Date("2026-08-22T12:34:00.000Z")),
|
||||
"2026-08-22T12:34:00.000Z",
|
||||
);
|
||||
assert.equal(reportListTimestamp("2026-08-22T12:34:00.000Z"), "2026-08-22T12:34:00.000Z");
|
||||
assert.equal(reportListTimestamp(""), "");
|
||||
assert.equal(reportListTimestamp(null), "");
|
||||
assert.match(coreSource, /export function reportListTimestamp/);
|
||||
assert.match(generationSource, /innerReason/);
|
||||
assert.match(generationSource, /isPersonalReportGenerationAbort/);
|
||||
assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/);
|
||||
});
|
||||
|
||||
test("stable error codes live in the dependency-free codes module", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type TechniqueExecutionReceipt,
|
||||
} from "../src/lib/report-evidence-bundle-v2.ts";
|
||||
import {
|
||||
classifyReportSchemaInnerReason,
|
||||
generatePersonalReport,
|
||||
type GeneratePersonalReportResult,
|
||||
} from "../src/lib/personal-report-generation.ts";
|
||||
@@ -242,8 +243,14 @@ function readyV2(result: GeneratePersonalReportResult): ReportDocumentV2 {
|
||||
return result.document as ReportDocumentV2;
|
||||
}
|
||||
|
||||
function expectSchemaRejected(result: GeneratePersonalReportResult): void {
|
||||
assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid" });
|
||||
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[] = [
|
||||
@@ -330,7 +337,10 @@ test("generatePersonalReport passes the worker lease signal to the writer and se
|
||||
assert.equal(observedSignal, controller.signal);
|
||||
|
||||
controller.abort(new Error("lease_lost"));
|
||||
assert.deepEqual(await pending, { status: "failed", failureCode: "report_schema_invalid" });
|
||||
await assert.rejects(pending, (error: unknown) => {
|
||||
assert.equal(error instanceof Error && error.message === "lease_lost", true);
|
||||
return true;
|
||||
});
|
||||
assert.equal(observedSignal?.aborted, true);
|
||||
});
|
||||
|
||||
@@ -344,7 +354,9 @@ test("production writer and worker keep the same signal on initial and repair mo
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(agentSource.match(/abortSignal: signal/g)?.length, 2);
|
||||
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]*\}\)/);
|
||||
});
|
||||
@@ -433,7 +445,7 @@ test("writer extra, duplicate and blocked themes are rejected", async (t) => {
|
||||
const result = await run(bundle, fakeWriter((inputBundle, plan) => (
|
||||
entry.mutate(writerOutputFor(inputBundle, plan))
|
||||
)));
|
||||
expectSchemaRejected(result);
|
||||
expectSchemaRejected(result, "report_writer_theme_count_mismatch");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -481,6 +493,75 @@ test("writer section id, evidence refs and claim-status upgrades are rejected",
|
||||
}
|
||||
});
|
||||
|
||||
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"] }],
|
||||
@@ -526,6 +607,6 @@ test("deterministic guard rejection and final schema rejection remain distinct t
|
||||
}],
|
||||
};
|
||||
}));
|
||||
expectSchemaRejected(result);
|
||||
expectSchemaRejected(result, "final_parse_rejected");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -777,7 +777,7 @@ test("generatePersonalReport fails with report_guard_rejected on guard rejection
|
||||
assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" });
|
||||
});
|
||||
|
||||
test("generatePersonalReport fails with report_schema_invalid when the final parse rejects", async () => {
|
||||
test("generatePersonalReport fails with report_schema_invalid when the writer emits an unplanned theme", async () => {
|
||||
const bundle = bundleV2Fixture();
|
||||
const blockedRef = bundle.evidenceRefs.find((ref) => ref.status === "blocked");
|
||||
assert.ok(blockedRef);
|
||||
@@ -801,11 +801,10 @@ test("generatePersonalReport fails with report_schema_invalid when the final par
|
||||
}), { 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");
|
||||
if (result.status === "failed" && result.failureCode === "report_schema_invalid") {
|
||||
assert.equal(result.innerReason, "report_writer_unplanned_theme:timing");
|
||||
}
|
||||
});
|
||||
|
||||
test("skill snapshot is the real packaged report manifest sha256, never the literal unknown", async () => {
|
||||
|
||||
@@ -476,7 +476,11 @@ test("a worker that loses its lease cannot atomically commit either terminal sta
|
||||
test("a worker that loses its lease cannot atomically fail either terminal state", async () => {
|
||||
const harness = createHarness({
|
||||
loseLeaseBeforeCompletion: true,
|
||||
generate: async () => ({ status: "failed", failureCode: "report_schema_invalid" }),
|
||||
generate: async () => ({
|
||||
status: "failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason: "agent_output_invalid",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
@@ -490,7 +494,11 @@ test("a worker that loses its lease cannot atomically fail either terminal state
|
||||
|
||||
test("invalid writer output atomically fails the report and lease-bound job", async () => {
|
||||
const harness = createHarness({
|
||||
generate: async () => ({ status: "failed", failureCode: "report_schema_invalid" }),
|
||||
generate: async () => ({
|
||||
status: "failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
innerReason: "agent_output_invalid",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await harness.worker.tick();
|
||||
|
||||
Reference in New Issue
Block a user