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
parent 4716fe46b3
commit e87c58d6e4
19 changed files with 382 additions and 70 deletions
+1
View File
@@ -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"
+16
View File
@@ -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 容器滞后**:端点存在且返回 markdownweb/api 同批部署。
- **B 180s 超时**:单次 2632s,三次合计约 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)。
+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 },
@@ -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();
}
+3
View File
@@ -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"]);
});
+55 -1
View File
@@ -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, /1030 秒/);
});
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.
+1
View File
@@ -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,
+1
View File
@@ -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