fix(report): persist longform appendix on self-hosted upsert (BUG-576)

Engine calls already returned markdown; the worker failed because local postgres upsert required onConflict and the appendix write omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-07 14:19:21 +08:00
co-authored by Cursor
parent 4716fe46b3
commit e87c58d6e4
19 changed files with 382 additions and 70 deletions
+21 -8
View File
@@ -26,7 +26,7 @@ function envCheck(names: string[]): Check {
: { status: "ok" };
}
async function jyotishApiCheck(): Promise<Check> {
async function jyotishApiCheck(): Promise<{ check: Check; gitCommit: string }> {
const started = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
@@ -35,16 +35,28 @@ async function jyotishApiCheck(): Promise<Check> {
cache: "no-store",
signal: controller.signal,
});
const body = response.ok
? await response.json().catch(() => null) as { git_commit?: unknown } | null
: null;
const gitCommit = typeof body?.git_commit === "string" && body.git_commit.trim()
? body.git_commit.trim()
: "unknown";
return {
status: response.ok ? "ok" : "blocked",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
check: {
status: response.ok ? "ok" : "blocked",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
},
gitCommit,
};
} catch (error) {
return {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
check: {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
},
gitCommit: "unknown",
};
} finally {
clearTimeout(timeout);
@@ -148,7 +160,7 @@ export async function GET() {
...databaseChecks,
modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]),
modelCatalog,
jyotishApi,
jyotishApi: jyotishApi.check,
rectificationMigrations: migrations.check,
researchTruthSource: {
status: truthSourceIdentity.status,
@@ -162,6 +174,7 @@ export async function GET() {
timestamp: new Date().toISOString(),
deployment: {
gitCommit,
apiGitCommit: jyotishApi.gitCommit,
},
database: {
latestMigration: migrations.database.latestMigration,
@@ -48,7 +48,7 @@ async function resolvePersistenceForUser() {
persistence: null as PersonalReportService | null,
jobs: null,
listSections: undefined,
loadLongformMarkdown: undefined,
loadLongformAppendix: undefined,
};
}
const persistence = createSupabasePersonalReportService(supabase);
@@ -57,11 +57,11 @@ async function resolvePersistenceForUser() {
userId: user.id,
persistence,
jobs: createSupabasePersonalReportJobService(supabase),
listSections: async (ownerId: string, requestId: string) => {
listSections: async (ownerId: string, requestId: string) => {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
},
loadLongformMarkdown: async (input: Readonly<{ userId: string; reportId: string }>) => {
loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => {
const appendixRead = await supabase
.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
@@ -70,14 +70,19 @@ async function resolvePersistenceForUser() {
.maybeSingle();
if (appendixRead.error) return null;
const row = parseLongformAppendixRow(appendixRead.data);
return row?.status === "ready" && row.markdown ? row.markdown : null;
if (!row) return null;
return {
status: row.status,
lastErrorCode: row.lastErrorCode,
markdown: row.markdown,
};
},
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs, listSections, loadLongformMarkdown } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -103,7 +108,7 @@ export async function GET(request: Request, context: RouteContext) {
// a substitute.
jobs: jobs ?? undefined,
listSections,
loadLongformMarkdown,
loadLongformAppendix,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
@@ -32,7 +32,7 @@ export type ReportLoadState =
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string }
@@ -47,6 +47,7 @@ export interface ReportEnvelopeView {
status: string;
failureCode: string | null;
failureSummary?: string | null;
appendixLastErrorCode?: string | null;
createdAt: string;
completedAt: string | null;
progressPercent?: number;
@@ -108,10 +109,15 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const summary = typeof view.failureSummary === "string" && view.failureSummary.length > 0
? view.failureSummary
: null;
const appendixLastErrorCode = typeof view.appendixLastErrorCode === "string"
&& view.appendixLastErrorCode.length > 0
? view.appendixLastErrorCode
: null;
return {
phase: "failed",
failureCode: code,
failureCode: appendixLastErrorCode ?? code,
...(summary ? { failureSummary: summary } : {}),
...(appendixLastErrorCode ? { appendixLastErrorCode } : {}),
};
}
default:
@@ -39,6 +39,29 @@ function identifier(value: string): string {
return `"${normalized}"`;
}
export function upsertConflictColumns(options?: { onConflict?: string }): string[] {
return (options?.onConflict ?? "")
.split(",")
.map((column) => column.trim())
.filter(Boolean);
}
async function primaryKeyColumns(client: PoolClient, table: string): Promise<string[]> {
identifier(table);
const result = await client.query<{ column_name: string }>(
`
select a.attname as column_name
from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
where i.indrelid = format('%I.%I', 'public', $1::text)::regclass
and i.indisprimary
order by array_position(i.indkey, a.attnum)
`,
[table],
);
return result.rows.map((row) => row.column_name);
}
function queryError(error: unknown): QueryError {
const value = error as PostgresError;
return {
@@ -197,11 +220,9 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
upsert(
value: Record<string, unknown> | readonly Record<string, unknown>[],
options: { onConflict: string },
options?: { onConflict?: string },
) {
const conflict = options.onConflict
.split(",")
.map((column) => column.trim());
const conflict = upsertConflictColumns(options);
conflict.forEach(identifier);
this.mutation = { kind: "upsert", rows: records(value), conflict };
return this;
@@ -402,13 +423,15 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
);
sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`;
if (this.mutation.kind === "upsert") {
const updates = columns.filter(
(column) =>
!this.mutation ||
this.mutation.kind !== "upsert" ||
!this.mutation.conflict.includes(column),
);
sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${
let conflict = this.mutation.conflict;
if (conflict.length === 0) {
conflict = await primaryKeyColumns(client, this.table);
}
if (conflict.length === 0) {
throw new Error("upsert requires a conflict target");
}
const updates = columns.filter((column) => !conflict.includes(column));
sql += ` on conflict (${conflict.map(identifier).join(", ")}) do ${
updates.length === 0
? "nothing"
: `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`
@@ -13,6 +13,7 @@ export type PersonalReportFailureSummary = Readonly<{
summary: string;
innerReason: string | null;
lastErrorCodes: readonly string[];
appendixLastErrorCode: string | null;
}>;
const SECTION_ERROR_LABELS: Readonly<Record<string, string>> = {
@@ -23,6 +24,15 @@ const SECTION_ERROR_LABELS: Readonly<Record<string, string>> = {
section_output_invalid: "输出未通过校验",
};
const APPENDIX_ERROR_LABELS: Readonly<Record<string, string>> = {
upstream_unavailable: "计算引擎暂时不可用,请稍后重试",
upstream_busy: "计算引擎繁忙,请稍后重试",
empty_markdown: "计算引擎返回了空文,请稍后重试",
generation_failed: "报告生成超时或中断,请稍后重试",
appendix_persist_failed: "报告正文未能保存,请稍后重试",
longform_appendix_persist_failed: "报告正文未能保存,请稍后重试",
};
function uniqueLabels(codes: readonly string[]): string[] {
const labels: string[] = [];
for (const code of codes) {
@@ -36,6 +46,7 @@ export function summarizePersonalReportFailure(input: Readonly<{
themeCount?: number;
sections: readonly PersonalReportSectionFailureRow[];
failureCode?: string | null;
appendixLastErrorCode?: string | null;
}>): PersonalReportFailureSummary {
const sections = input.sections;
const blocked = sections.filter((row) => row.status === "blocked");
@@ -44,6 +55,7 @@ export function summarizePersonalReportFailure(input: Readonly<{
const lastErrorCodes = blocked
.map((row) => row.lastErrorCode)
.filter((code): code is string => typeof code === "string" && code.length > 0);
const appendixLastErrorCode = textCode(input.appendixLastErrorCode);
const total = input.themeCount && input.themeCount > 0 ? input.themeCount : sections.length;
const labels = uniqueLabels(lastErrorCodes);
const labelText = labels.length > 0
@@ -61,6 +73,8 @@ export function summarizePersonalReportFailure(input: Readonly<{
summary = `${ready.length} 个主题已写成,整份报告未完成装配`;
} else if (input.failureCode === "report_schema_invalid") {
summary = "报告未通过结构校验";
} else if (appendixLastErrorCode && APPENDIX_ERROR_LABELS[appendixLastErrorCode]) {
summary = APPENDIX_ERROR_LABELS[appendixLastErrorCode];
} else {
summary = "本次生成没有产出可用报告";
}
@@ -72,5 +86,11 @@ export function summarizePersonalReportFailure(input: Readonly<{
innerReason = "section_generation_incomplete";
}
return { summary, innerReason, lastErrorCodes };
return { summary, innerReason, lastErrorCodes, appendixLastErrorCode };
}
function textCode(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
@@ -36,7 +36,10 @@ export type AppendixClient = {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
};
upsert(row: JsonRecord): PromiseLike<{ error: { message?: string } | null }>;
upsert(
row: JsonRecord,
options?: { onConflict?: string },
): PromiseLike<{ error: { message?: string } | null }>;
};
};
@@ -99,20 +102,25 @@ export async function persistLongformAppendix(input: Readonly<{
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: input.reportId,
user_id: input.userId,
request_id: input.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
});
if (result.error) {
throw new LongformGenerateError("calculation_unavailable", true, "longform_appendix_persist_failed");
try {
const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: input.reportId,
user_id: input.userId,
request_id: input.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
}, { onConflict: "report_id" });
if (result.error) {
throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed");
}
} catch (error) {
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed");
}
}
@@ -126,25 +134,39 @@ async function fetchLongformMarkdown(input: Readonly<{
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
: timeout;
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
cache: "no-store",
signal,
});
if (!upstream.ok) {
throw new LongformGenerateError(
"calculation_unavailable",
true,
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
const started = Date.now();
let httpStatus: number | null = null;
try {
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
cache: "no-store",
signal,
});
httpStatus = upstream.status;
if (!upstream.ok) {
throw new LongformGenerateError(
"calculation_unavailable",
true,
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
} catch (error) {
throw error;
} finally {
const durationMs = Date.now() - started;
if (httpStatus !== null) {
console.info(`[personal-report] engine http_status=${httpStatus} duration_ms=${durationMs}`);
} else {
console.info(`[personal-report] engine http_status=error duration_ms=${durationMs}`);
}
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
}
export async function generatePersonalReportLongform(
+23 -6
View File
@@ -153,6 +153,7 @@ export function reportView(
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
};
}
@@ -655,6 +656,14 @@ export type ReportReadCoreDeps = Readonly<{
userId: string;
reportId: string;
}>) => Promise<string | null>;
loadLongformAppendix?: (input: Readonly<{
userId: string;
reportId: string;
}>) => Promise<{
status: string;
lastErrorCode: string | null;
markdown: string | null;
} | null>;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
@@ -676,17 +685,25 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
};
}
const job = deps.jobs ? await deps.jobs.getOwnedByRequestId(deps.userId, row.requestId) : null;
const failure = row.status === "failed" && deps.listSections
const appendix = deps.loadLongformAppendix
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
: null;
const failure = row.status === "failed"
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: await deps.listSections(deps.userId, row.requestId),
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
failureCode: row.failureCode,
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
})
: null;
if (row.status === "ready") {
const markdown = deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined;
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
: null;
const markdown = markdownFromAppendix
?? (deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined);
if (typeof markdown === "string" && markdown.trim()) {
const validated = deps.validateReadyDocument(row.reportDocument);
return {
@@ -704,7 +721,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
// loader is wired and the appendix is missing, the client shows the
// legacy placeholder instead of the five-chapter document.
const validated = deps.validateReadyDocument(row.reportDocument);
if (deps.loadLongformMarkdown) {
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
return {
status: 200,
body: { report: reportView(row, job), longformMarkdown: null },