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>
246 lines
8.1 KiB
TypeScript
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");
|
|
}
|
|
}
|