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:
Jesse_Chen
2026-08-22 20:46:30 +08:00
co-authored by Cursor
parent 4b3d82d987
commit 9762063453
13 changed files with 341 additions and 72 deletions
+5 -2
View File
@@ -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> {
+101 -20
View File
@@ -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({
+45 -27
View File
@@ -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;
}