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:
@@ -9,6 +9,7 @@ services:
|
||||
- ${APP_ENV_FILE:-../.env.production}
|
||||
environment:
|
||||
PORT: 5200
|
||||
GITHUB_SHA: ${GITHUB_SHA}
|
||||
JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api
|
||||
expose:
|
||||
- "5200"
|
||||
|
||||
@@ -8918,4 +8918,20 @@
|
||||
- 复发自:无
|
||||
- 修复版本:`1ded6bb0`
|
||||
|
||||
## BUG-576 | MD-only 长报告引擎 200 后附录 upsert 未带冲突键,staging 三次重试仍 failed
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-07
|
||||
- 最近更新:2026-09-07
|
||||
- 影响面:`generatePersonalReportLongform`、`personal_report_longform_appendices`、自托管 `createLocalPostgresDataClient` upsert、报告详情失败态
|
||||
- 用户现象:personal_full MD-only 首次真实运行在引擎调用阶段失败(`calculation_unavailable`,`progressPercent=30`)。直连引擎同形虚构盘 HTTP 200。
|
||||
- 触发条件:staging 自托管 web worker 调用 `/api/professional_report_reference` 成功后写入 longform 附录。
|
||||
- 根因:本地 postgres 客户端的 `upsert` 必填 `onConflict`;附录写入只调用 `.upsert(row)`。请求在写库前抛错,附录表 0 行。Job 三次尝试均打到引擎(三次 HTTP 200,约 26–32s,低于 180s 超时)。不是 api 容器滞后,也不是引擎超时。web 容器 docker logs 几乎只有启动行,附录错误码此前也不在详情 API 中。
|
||||
- 修复:附录 upsert 显式 `onConflict: "report_id"`;本地客户端缺省冲突键回落到主键;详情失败态透出 `appendixLastErrorCode` 与可读原因;引擎调用记录 `http_status`/`duration_ms`;api 容器与 `/api/health` 暴露构建 SHA。
|
||||
- 验证:定向 frontend 单测、附录 `test:db` upsert 无 `onConflict`、health/compose 源合同。未改 `.gitea/workflows/**`,不提升 main。
|
||||
- 防复发:自托管 upsert 必须能在缺 `onConflict` 时按主键执行;附录写入必须带 PK 冲突键;失败详情必须带附录错误码。
|
||||
- 相关记录:BUG-574
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# PROGRESS · MD-only 首跑失败取证与修复(2026-09-07)
|
||||
|
||||
工作树:`.worktrees/report-longform-e2e-fail-20260907`
|
||||
分支:`codex/report-longform-e2e-fail-20260907`
|
||||
任务书:`TASK-report-longform-e2e-fail-20260907.md`
|
||||
基线:任务书 `58081a2d`;开工后 `origin/staging` 已含 `ed0cd087`(BUG-575 校时 UI)。事故当时健康检查 SHA 为已部署的 `7cf7705a`。
|
||||
|
||||
## 任务 0 定案
|
||||
|
||||
**定案 C:附录 persist 在自托管 upsert 上失败(不是 A,也不是 B)。**
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| 附录行 | `request_id=d17c27ef-…` 0 行;全表 `personal_report_longform_appendices` 也是 0 行 |
|
||||
| Job | `failed / calculation_unavailable`,`attempt_count=3/3`,`progressPercent=30`,`05:14:15Z`–`05:15:48Z` |
|
||||
| API 访问日志 | 三次 `POST /api/professional_report_reference` 皆 **200**:`05:14:42`、`05:15:14`、`05:15:48`(与 job 三次尝试对齐) |
|
||||
| 容器版本 | web/api 同批 recreate `05:02Z`;web `GITHUB_SHA=7cf7705a`;api 镜像含 `format=markdown` / `packs`;含 gaps2 `e4d16b75` 祖先 |
|
||||
| 任务 0.4 | 从 web 容器对 `api:5200` 虚构盘 POST:`http_status=200 duration_ms=26148 bytes=263471 format=markdown markdown_len=250452` |
|
||||
| web 日志 | docker `LogPath` 空,近乎只有 Next 启动行;`[personal-report]` 不可见 |
|
||||
|
||||
排除:
|
||||
|
||||
- **A api 容器滞后**:端点存在且返回 markdown;web/api 同批部署。
|
||||
- **B 180s 超时**:单次 26–32s,三次合计约 93s,均低于 `AbortSignal.timeout(180s)`。
|
||||
|
||||
根因:`persistLongformAppendix` 调用 `.upsert(row)`,自托管 `LocalPostgresQueryBuilder.upsert` 读取 `options.onConflict` 时同步抛错,引擎 200 的 markdown 从未入表。
|
||||
|
||||
未读取或写入真实用户正文/出生资料。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 0 取证定案 | 完成 | 定案 C |
|
||||
| 1 按定案修复 | 完成 | PK upsert + 本地客户端缺省主键;未调大 180s |
|
||||
| 2 观测补口 | 完成 | 详情透出 `appendixLastErrorCode`;引擎 `http_status`/`duration_ms`;health `apiGitCommit` |
|
||||
| 3 收官 smoke | 未部署 | 代码修复待 push/deploy 后按 `docs/operations/personal-report-staging.md` 重跑 |
|
||||
|
||||
## 实测耗时
|
||||
|
||||
| 路径 | HTTP | duration_ms | 备注 |
|
||||
|---|---|---|---|
|
||||
| 事故三次引擎调用 | 200 | ~32s / 次 | 与 job 三次尝试对齐 |
|
||||
| 任务 0.4 虚构盘 | 200 | 26148 | markdown 250452 字符 |
|
||||
|
||||
未启用 `defer_optional_external_evidence`:真实耗时已在预算内。
|
||||
|
||||
## 质量门
|
||||
|
||||
定向 frontend / Python 源合同与附录 db 测(见提交说明)。未改 `.gitea/workflows/**`,不提升 main。
|
||||
|
||||
## BUG
|
||||
|
||||
`docs/BUG_HISTORY.md` **BUG-576**(远端 staging 已占用 BUG-575 给校时 UI)。
|
||||
@@ -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 },
|
||||
|
||||
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
import {
|
||||
closeLocalPostgresDataPool,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
@@ -37,7 +41,7 @@ function serviceSql(sql: string): string {
|
||||
return `set role service_role;\n${sql}`;
|
||||
}
|
||||
|
||||
test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, () => {
|
||||
test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
|
||||
@@ -144,6 +148,32 @@ test("longform appendices are owner-read, service-written, and never change repo
|
||||
`),
|
||||
"unavailable:upstream_unavailable",
|
||||
);
|
||||
|
||||
const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
|
||||
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
|
||||
try {
|
||||
const persisted = await admin.from("personal_report_longform_appendices").upsert({
|
||||
report_id: REPORT_ID,
|
||||
user_id: USER_A,
|
||||
request_id: REQUEST_ID,
|
||||
status: "unavailable",
|
||||
markdown: null,
|
||||
content_sha256: null,
|
||||
attempt_count: 2,
|
||||
last_error_code: "appendix_persist_failed",
|
||||
});
|
||||
assert.equal(persisted.error, null, persisted.error?.message);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select last_error_code
|
||||
from public.personal_report_longform_appendices
|
||||
where report_id = '${REPORT_ID}'
|
||||
`),
|
||||
"appendix_persist_failed",
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPool(serviceUrl);
|
||||
}
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ test("health endpoint exposes deployment identity for production verification",
|
||||
assert.match(source, /GITHUB_SHA/);
|
||||
assert.match(source, /VERCEL_GIT_COMMIT_SHA/);
|
||||
assert.match(source, /gitCommit/);
|
||||
assert.match(source, /apiGitCommit/);
|
||||
assert.match(source, /latestMigration/);
|
||||
assert.match(source, /rectificationContractVersion/);
|
||||
assert.match(source, /rectificationMigrations/);
|
||||
@@ -409,10 +410,12 @@ test("production traffic waits for a healthy web container and retries short rep
|
||||
const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8");
|
||||
const caddyfile = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8");
|
||||
const web = serviceBlock(compose, "web");
|
||||
const api = serviceBlock(compose, "api");
|
||||
const caddy = serviceBlock(compose, "caddy");
|
||||
const healthcheck = webHealthcheckBlock(web);
|
||||
|
||||
assert.match(web, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
||||
assert.match(api, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
||||
assert.match(web, /healthcheck:\n\s+test: \["CMD", "node", "-e", "fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health'\)\.then\(r=>\{if\(!r\.ok\)process\.exit\(1\)\}\)"\]/);
|
||||
assert.match(healthcheck, /^ interval: 30s$/m);
|
||||
assert.match(healthcheck, /^ timeout: 5s$/m);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { queryValue } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { queryValue, upsertConflictColumns } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
test("queryValue keeps calendar dates in local civil form", () => {
|
||||
assert.equal(queryValue("date", new Date(2026, 7, 22)), "2026-08-22");
|
||||
@@ -21,3 +21,10 @@ test("queryValue leaves non-date values unchanged", () => {
|
||||
const leftover = new Date("2026-08-22T12:34:56.000Z");
|
||||
assert.equal(queryValue("unknown", leftover), leftover);
|
||||
});
|
||||
|
||||
test("upsert without onConflict does not throw before execute and defaults to an empty conflict list", () => {
|
||||
assert.deepEqual(upsertConflictColumns(), []);
|
||||
assert.deepEqual(upsertConflictColumns({}), []);
|
||||
assert.deepEqual(upsertConflictColumns({ onConflict: "report_id" }), ["report_id"]);
|
||||
assert.deepEqual(upsertConflictColumns({ onConflict: "user_id, session_id" }), ["user_id", "session_id"]);
|
||||
});
|
||||
|
||||
@@ -1089,6 +1089,39 @@ test("core read: failed reports include a readable summary from existing section
|
||||
assert.deepEqual(report.sectionErrorCodes, ["section_refs_mismatch"]);
|
||||
});
|
||||
|
||||
test("core read: failed longform reports expose appendix last_error_code without bodies", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
persistence.rows.set(REPORT_ID, {
|
||||
...seedRecord(),
|
||||
status: "failed",
|
||||
failureCode: "calculation_unavailable",
|
||||
requestedThemes: ["career", "marriage", "wealth", "health", "spirituality"],
|
||||
});
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
listSections: async () => [],
|
||||
loadLongformAppendix: async () => ({
|
||||
status: "unavailable",
|
||||
lastErrorCode: "appendix_persist_failed",
|
||||
markdown: null,
|
||||
}),
|
||||
validateReadyDocument: () => ({ ok: false }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const report = response.body.report as {
|
||||
failureSummary?: string;
|
||||
appendixLastErrorCode?: string;
|
||||
};
|
||||
assert.equal(report.appendixLastErrorCode, "appendix_persist_failed");
|
||||
assert.equal(report.failureSummary, "报告正文未能保存,请稍后重试");
|
||||
assert.equal("markdown" in response.body, false);
|
||||
});
|
||||
|
||||
test("core read: rejects a polluted stored ready document via canonical re-validation", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
persistence.rows.set(REPORT_ID, {
|
||||
@@ -1164,6 +1197,27 @@ test("core read: markdown loader returns longform and hides the five-chapter bod
|
||||
assert.equal("reportDocument" in missing.body, false);
|
||||
});
|
||||
|
||||
test("core read: appendix loader supplies ready markdown without the markdown-only fallback", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
persistence.rows.set(REPORT_ID, seedRecord());
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
loadLongformAppendix: async () => ({
|
||||
status: "ready",
|
||||
lastErrorCode: null,
|
||||
markdown: "# 附录长报告\n\n### 摘要\n正文",
|
||||
}),
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.longformMarkdown, "# 附录长报告\n\n### 摘要\n正文");
|
||||
});
|
||||
|
||||
test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
await createReadyRow(persistence);
|
||||
@@ -1264,7 +1318,7 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han
|
||||
assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/);
|
||||
assert.match(itemRoute, /resolveReportRead/);
|
||||
assert.match(itemRoute, /resolveReportDelete/);
|
||||
assert.match(itemRoute, /loadLongformMarkdown/);
|
||||
assert.match(itemRoute, /loadLongformAppendix/);
|
||||
assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
nextLongformAppendixState,
|
||||
parseLongformAppendixRow,
|
||||
} from "../src/lib/personal-report-longform-appendix.ts";
|
||||
import { summarizePersonalReportFailure } from "../src/lib/personal-report-failure-summary.ts";
|
||||
|
||||
test("hashLongformMarkdown is stable sha256", () => {
|
||||
assert.equal(hashLongformMarkdown("# a"), hashLongformMarkdown("# a"));
|
||||
@@ -55,3 +56,15 @@ test("nextLongformAppendixState marks unavailable after two failures and never s
|
||||
assert.equal(ready.contentSha256, hashLongformMarkdown("# Full"));
|
||||
assert.equal(ready.lastErrorCode, null);
|
||||
});
|
||||
|
||||
test("failed longform appendix codes become readable summaries without storing bodies", () => {
|
||||
const summary = summarizePersonalReportFailure({
|
||||
themeCount: 5,
|
||||
sections: [],
|
||||
failureCode: "calculation_unavailable",
|
||||
appendixLastErrorCode: "empty_markdown",
|
||||
});
|
||||
assert.equal(summary.appendixLastErrorCode, "empty_markdown");
|
||||
assert.equal(summary.summary, "计算引擎返回了空文,请稍后重试");
|
||||
assert.deepEqual(summary.lastErrorCodes, []);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,17 @@ test("writer pipeline stays in the tree but is feature-off", () => {
|
||||
assert.match(PERSONAL_REPORT_GENERATING_COPY, /10–30 秒/);
|
||||
});
|
||||
|
||||
test("longform generate persists through PK upsert and logs engine timing without bodies", () => {
|
||||
const generateSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(generateSource, /onConflict:\s*"report_id"/);
|
||||
assert.match(generateSource, /\[personal-report\] engine http_status=/);
|
||||
assert.match(generateSource, /duration_ms=/);
|
||||
assert.doesNotMatch(generateSource, /console\.(?:info|error|log)\([^\n]*markdown/);
|
||||
});
|
||||
|
||||
test("outline lifts navigation and summary to the first screen", () => {
|
||||
const outline = buildLongformOutline(SAMPLE_MARKDOWN);
|
||||
assert.ok(outline.headings.some((heading) => heading.title === "成品阅读导航"));
|
||||
|
||||
@@ -347,6 +347,21 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
|
||||
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
classifyReportEnvelope(200, {
|
||||
report: {
|
||||
...view("failed", "calculation_unavailable"),
|
||||
failureSummary: "报告正文未能保存,请稍后重试",
|
||||
appendixLastErrorCode: "appendix_persist_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
phase: "failed",
|
||||
failureCode: "appendix_persist_failed",
|
||||
failureSummary: "报告正文未能保存,请稍后重试",
|
||||
appendixLastErrorCode: "appendix_persist_failed",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
|
||||
|
||||
// Server-side error envelopes carry a stable code at the top level.
|
||||
|
||||
@@ -3400,6 +3400,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
self._json({
|
||||
'status': 'ok',
|
||||
'version': '6.9.14',
|
||||
'git_commit': os.environ.get('GITHUB_SHA') or 'unknown',
|
||||
'swisseph_available': swisseph_available,
|
||||
'swisseph_version': swisseph_version,
|
||||
'ayanamsa_default': DEFAULT_AYANAMSA_NAME,
|
||||
|
||||
@@ -217,6 +217,7 @@ def test_health_endpoint_exposes_runtime_accuracy_metadata() -> None:
|
||||
assert payload['ayanamsa_default'] == 'raman'
|
||||
assert 'swisseph_available' in payload
|
||||
assert 'swisseph_version' in payload
|
||||
assert 'git_commit' in payload
|
||||
assert payload['vedastro']['configured'] is False
|
||||
assert payload['vedastro']['network_enabled'] is False
|
||||
assert payload['vedastro']['has_api_key'] is False
|
||||
|
||||
Reference in New Issue
Block a user