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