Files
Jyotisha/frontend/src/lib/personal-report-longform-generate.ts
T
Jesse_ChenandCursor e87c58d6e4 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>
2026-09-07 14:19:21 +08:00

246 lines
8.1 KiB
TypeScript

import "server-only";
import {
LONGFORM_APPENDIX_TABLE,
nextLongformAppendixState,
parseLongformAppendixRow,
type LongformAppendixRow,
} from "./personal-report-longform-appendix";
import { buildLongformBirthPayload, utcToday } from "./personal-report-longform-birth";
import { buildLongformCoverDocument } from "./personal-report-longform-cover";
import type { GeneratePersonalReportResult } from "./personal-report-generation";
import type { PersonalReportRecord } from "./personal-report-service-core";
import type { ReportDocumentV2 } from "./personal-report-contract";
const APPENDIX_SELECT =
"report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code";
const DEFAULT_API_BASE = "http://127.0.0.1:5200";
const ENGINE_TIMEOUT_MS = 180_000;
export class LongformGenerateError extends Error {
readonly name = "LongformGenerateError";
constructor(
readonly code: "calculation_unavailable" | "report_schema_invalid" | "birth_time_not_usable",
readonly retryable: boolean,
message?: string,
) {
super(message ?? code);
}
}
type JsonRecord = Record<string, unknown>;
export type AppendixClient = {
from(table: string): {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
};
upsert(
row: JsonRecord,
options?: { onConflict?: string },
): PromiseLike<{ error: { message?: string } | null }>;
};
};
type AppendixFilter = {
eq(column: string, value: unknown): AppendixFilter;
maybeSingle(): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
export type LongformGenerateDeps = Readonly<{
report: PersonalReportRecord;
profile: unknown;
candidateRange: Readonly<{ startTime: string; endTime: string }> | null;
admin: AppendixClient;
displayName: string;
birthTimeStatus: ReportDocumentV2["subject"]["birthTimeStatus"];
apiBase?: string;
fetchImpl?: typeof fetch;
now?: () => Date;
signal?: AbortSignal;
}>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function readOwnedLongformAppendix(
client: AppendixClient,
reportId: string,
userId: string,
): Promise<LongformAppendixRow | null> {
const result = await client
.from(LONGFORM_APPENDIX_TABLE)
.select(APPENDIX_SELECT)
.eq("report_id", reportId)
.eq("user_id", userId)
.maybeSingle();
if (result.error) return null;
return parseLongformAppendixRow(result.data);
}
export async function persistLongformAppendix(input: Readonly<{
admin: AppendixClient;
reportId: string;
userId: string;
requestId: string;
current: LongformAppendixRow | null;
successMarkdown?: string | null;
errorCode?: string | null;
}>): Promise<void> {
const next = nextLongformAppendixState({
current: input.current,
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
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");
}
}
async function fetchLongformMarkdown(input: Readonly<{
payload: Record<string, unknown>;
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
}>): Promise<string> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
: timeout;
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}`);
}
}
}
export async function generatePersonalReportLongform(
deps: LongformGenerateDeps,
): Promise<GeneratePersonalReportResult> {
const profile = record(deps.profile);
if (!profile) {
throw new LongformGenerateError("report_schema_invalid", false);
}
const today = utcToday(deps.now?.() ?? new Date());
const range = deps.candidateRange
? { start_time: deps.candidateRange.startTime, end_time: deps.candidateRange.endTime }
: null;
const payload = buildLongformBirthPayload(deps.profile, {
today,
targetYear: Number(today.slice(0, 4)),
candidateRange: range,
birthTimeAccuracy: range && range.start_time !== range.end_time ? "provisional" : "confirmed",
});
if (!payload) {
throw new LongformGenerateError("birth_time_not_usable", false);
}
const current = await readOwnedLongformAppendix(deps.admin, deps.report.id, deps.report.userId);
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
if (!markdown) {
try {
markdown = await fetchLongformMarkdown({
payload,
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
});
} catch (error) {
if (deps.signal?.aborted) throw error;
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
errorCode: error instanceof LongformGenerateError ? error.message.slice(0, 80) : "generation_failed",
}).catch(() => undefined);
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true);
}
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
successMarkdown: markdown,
});
}
try {
const document = buildLongformCoverDocument({
report: deps.report,
subject: {
displayName: deps.displayName,
birthTimeStatus: deps.birthTimeStatus,
birthPlaceLabel: text(profile.birth_place_label) ?? "未知出生地",
},
markdown,
generatedAt: (deps.now?.() ?? new Date()).toISOString(),
});
return {
status: "ready",
document,
evidenceHash: document.provenance.evidenceHash,
};
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "final_parse_rejected");
}
}