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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user