diff --git a/PROGRESS-report-writer-failure-20260902.md b/PROGRESS-report-writer-failure-20260902.md new file mode 100644 index 00000000..e81d007d --- /dev/null +++ b/PROGRESS-report-writer-failure-20260902.md @@ -0,0 +1,122 @@ +# PROGRESS · 个人报告写作阶段 report_schema_invalid(2026-09-02) + +任务书:`TASK-report-section-writer-failure-20260902.md`(`838d9e76`) +分支:`codex/report-writer-failure-20260902` +基线:`origin/staging` @ `838d9e76` + +开工前置:已读 `docs/research/pre_work_error_ledger.md`、`frontend/AGENTS.md`;`docs/BUG_HISTORY.md` 检索 BUG-352 / 451 / 486–489 / 495。按任务书从 `origin/staging` 建独立 worktree,不碰原仓脏树。 + +隐私:下文只有错误码、token 计数、`finishReason`、时间戳与 request UUID(任务书已公开)。无 prompt、bundle、模型原文、姓名、出生资料或账号标识。 + +--- + +## 任务 0 · Staging 实证(门控) + +取证主机:staging `118.26.111.127`,路径 `/opt/jyotisha-staging`。Postgres 容器 `jyotisha-staging-postgres-1`。 + +### 日志窗口丢失 + +取证时公开 staging health 仍指向 `35688015`(`838d9e76` 的父提交)。Web 容器在 `2026-09-02T14:00:21Z`(22:00 +08)被 recreate,与任务书提交 `838d9e76`(`14:00:43Z`)几乎同时。事故窗口(13:19–13:21 UTC)的 `docker logs` 已不在。`[personal-report-agent]` 与 `[personal-report] generation_failed` 无法回收。`usage_ledger` 对该 requestId 为 0 行。 + +因此 **不能** 用本事故的 `finishReason` / `outputTokens` 分布来确认「主嫌 · 截断」。截断判定只能写 `blocked`。 + +### 事故行(requestId `77b79067-bd70-465e-ac23-adc4500d4738`) + +| 面 | 结果 | +|---|---| +| `personal_reports` | `failed` / `report_schema_invalid` / created 13:19:23 UTC / updated 13:21:38 UTC | +| `personal_report_jobs` | `failed` / `last_error_code=report_schema_invalid` / phase `failed` / **progress 43%** / attempt **1 of 3** / finished 13:21:38 UTC | +| `theme-career` | **blocked**,attempts **2/2**,`last_error_code=section_evidence_insufficient`,updated 13:21:38.398 UTC | +| `theme-marriage` | pending,0 attempts | +| `theme-timing` | pending,0 attempts | +| `theme-wealth` | pending,0 attempts | + +43% = `30 + floor((1/4)*55)`:career 被 block 后第一次 `onProgress`。Job 在 career block 后约 39ms 结束,其余三章从未 `start`。 + +历史 `report_schema_invalid`(08-30 起,与任务书 5 个小时一致):跨这些小时的 section 行里,本事故是唯一一条 blocked + `section_evidence_insufficient`。其余为 pending 或 ready。 + +### 定案 + +1. **主嫌(CJK `/2` 截断)未证实。** 落库码不是 `section_output_truncated`。截断在 writer 路径通常会变成 `PersonalReportAgentOutputError("report_schema_invalid")` → `section_output_invalid`。本事故没有 telemetry,不得写成 `finishReason=length`。 +2. **次嫌(evidenceRefs 回显)是 career 章的最佳解释。** 旧 `classifySectionErrorCode` 把任何含 `"evidence"` 的消息打成 `section_evidence_insufficient`。`report_writer_evidence_refs_mismatch` 含子串 `evidence`,会被误分类。career 2/2 次、约 29s,符合 assert 失败 + 一次 repair,而不是四章长输出截断。 +3. **第三层:首章 blocked 后没有继续写其余章。** 产品测试已要求其他章仍应交付。Job 以不可重试的 `report_schema_invalid` 失败(attempt 1/3),说明 worker 收到了 `{status:"failed"}` 或 `invalid_request`/`storage_invalid`,而不是 retryable 抛错。无日志无法闭合「是 filter / onProgress / 其它未捕获抛错」。本轮仍把每章失败隔离开,避免一章 throw 中止整份。 + +`all_sections_blocked` 原先裸 `return`、不走 `failSchema`,因此即使日志还在,也可能看不到 `generation_failed`。本轮改为走 `failSchema`。 + +任务 1 仍按任务书授权落地中文口径公式,校准用的是 **08-30 任务 0** 的整份调用观测(`outputTokens=3069`,`finishReason=stop`),**不是** 本事故。research / deep 的 3072 cap 保持不变;若以后实测需要更高,另开任务。 + +--- + +## 任务 1 · CJK 输出预算 + +禁止 `targetCharacters.max / 2`。 + +口径:`1` 中文字符 ≈ `1.3` token(`CJK_OUTPUT_TOKENS_PER_CHAR`)。 + +章节结构余量 `568`(narrative 之外的 title / actions / caveats / refs / JSON 外壳): + +| 部分 | 估算 | +|---|---| +| title ≈ 80 字 | `ceil(80 × 1.3) = 104` | +| 3 条 actions ≈ 120 字 | `156` | +| caveats ≈ 80 字 | `104` | +| evidenceRefs JSON | `80` | +| JSON 键与括号 | `124` | +| 合计 | `568` | + +摘要结构余量 `580` **已经包含** headline / priorities / JSON,不再把 headline 算第二次: + +`ceil(2000 × 1.3) + 580 = 2600 + 580 = 3180` + +章节:`min(3072, max(1024, ceil(maxChars × 1.3) + 568))` + +| 深度 | max 字 | 未封顶 | 落地预算 | +|---|---|---|---| +| concise | 500 | 1218 | **1218** | +| standard | 1200 | 2128 | **2128** | +| deep | 2000 | 3168 | **3072**(cap,缺口 96 token) | +| research | 2800 | 4208 | **3072**(cap;约合 narrative 2504 字,低于 2800 上限) | +| summary | 2000 字段 | 3180 | **3180**(cap 4096) | + +08-30 整份 writer 一次调用 `outputTokens=3069` 且 `stop`,说明 3072 能装下一份大 JSON。本事故未证实截断,因此不把 cap 自行抬高。length 修复重试:同一 `outputBudget`(不得低于首次),提示词要求压到 `targetCharacters.min` 并闭合 JSON。 + +--- + +## 任务 2 · evidenceRefs 回显 + +- `sectionPrompt` 增加:`evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删`。 +- `assertWriterOutput` 仍是 id/theme 全等 + refs **集合相等**,语义未放宽。 +- Repair 只拼类别词:`identity mismatch` / `refs mismatch` / `schema invalid` / `output truncated`。refs 类再次重申逐字复制。不把 ref id、正文或 bundle 拼进 repair。 +- `classifySectionErrorCode` 先匹配 identity / refs,再匹配泛化 `"evidence"`,避免再把 refs mismatch 写成 `section_evidence_insufficient`。 + +--- + +## 任务 3 · 失败可观测 + +不改表。应用层聚合已有 `personal_report_sections` 行。 + +- 详情 API:`failureSummary`、`innerReason`(`all_sections_blocked` 或 `section_generation_incomplete`)、`sectionErrorCodes`。 +- 列表 API:失败行多查 `request_id, status, last_error_code`(无正文),写入 `failureSummary`。 +- 报告中心:`failureSummary ?? failureCode`。详情页摘要为主文案,等宽错误码为辅。 + +本事故若用新摘要,应接近:「4 个主题中 1 个写作失败:证据引用未对齐,3 个未开始」。旧码仍会显示「证据不足」,因为历史行的 `last_error_code` 不会回写。 + +--- + +## 任务 4 · 真实验证 + +**未做。** 等任务 1–3 进入 staging 且 `GET https://staging.jyotisha.chat/api/health` 的 `.deployment.gitCommit` 对齐后再跑:standard `personal_full` 四章正文、telemetry 无 `length`、≥3 处 writer→bundle 回溯、每章 token/墙钟对照 `PROGRESS-report-skill-parity-20260901.md` 的 2 倍线。不提升 main。 + +--- + +## 质量门 + +| 门 | 结果 | +|---|---| +| `frontend/node_modules/.bin/tsc --noEmit` | 通过(exit 0,无输出) | +| `eslint`(改动的 TS/TSX 文件) | exit 0,无 error 输出 | +| 聚焦 `tsx --test`:plan / writer-failure / generation-v2 / api / view / agent-telemetry | 104 pass / 0 fail | +| 复跑 api + writer-failure | 53 pass / 0 fail | +| `personal-report-generation.test.ts` + `personal-report-worker.test.ts` | 51 pass / 0 fail | + +未跑全量 `tests/*.test.ts`,不声称全套绿。未改 `.gitea/workflows/**`,未提升 main。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ee272ca8..76112af5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7633,3 +7633,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-496 | 个人报告写作失败被压成 report_schema_invalid,首章失败后其余主题未开始 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`generateSectionedPersonalReport`、`createPersonalReportAgent`、`classifySectionErrorCode`、`GET /api/reports`、`GET /api/reports/[reportId]`、报告中心失败卡片 +- 用户现象:blocked-repairs 部署后,真实 `personal_full` 仍显示生成失败,可见码只有 `report_schema_invalid`。四主题里只有事业章留下失败记录,婚恋/财富/应期未开始。 +- 触发条件:章节 writer 输出未通过 `assertWriterOutput`(常见为 `evidenceRefs` 与 plan 集合不等),或输出在 token 预算内被截断;随后整份报告失败。 +- 根因:三层叠加。旧错误分类把任何含 `evidence` 的消息打成 `section_evidence_insufficient`,`report_writer_evidence_refs_mismatch` 被误标;章节输出预算用英文「字符 ÷ 2」,standard 上限 1200 字中文只分到 1024 token;一章 blocked 或未捕获抛错可以中止整份,job 停在约 43%。失败详情只有聚合码,页面无法区分截断、引用不齐或未开始。staging 事故窗口的 writer telemetry 在容器 recreate 后丢失,本事故不能写成 `finishReason=length`。 +- 修复:中文口径重算每章/摘要 `maxOutputTokens`,去掉 ÷2;length 修复要求压到字数下限且预算不低于首次。Prompt 要求逐字复制 `plan.evidenceRefs`;repair 只加失败类别。refs/identity 先于泛化 `evidence` 分类。单章失败后继续其余主题;`all_sections_blocked` 走统一失败日志。详情与列表聚合已有 section 行,展示可读摘要与错误码,不改表、不放宽 writer schema。 +- 验证:预算表锁定 concise 1218 / standard 2128 / deep 3072 / research 3072 / summary 3180。refs mismatch 分类为 `section_refs_mismatch` 且其余主题仍交付。repair 提示含类别词、不含内容。失败详情从 section 行汇总。`tsc --noEmit`、改动文件 ESLint、个人报告聚焦测试。 +- 防复发:输出预算不得再用字符 ÷ 2。`assertWriterOutput` 保持 id/theme 全等与 refs 集合相等。section 错误码不得把 refs mismatch 归进 evidence insufficient。一章失败不得中止其余 write 主题。用户可见失败必须有错误码级摘要,不得只展示 `report_schema_invalid`。日志与 PROGRESS 不得写入 prompt、bundle、模型原文或用户资料。 +- 相关记录:BUG-352、BUG-451、BUG-486、BUG-489 +- 复发自:BUG-451(分章后仍把写作失败压成整份 schema 码,截断预算与失败分类未按中文口径收口) +- 修复版本:待发布 + diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index be2d5f42..3a2e2d15 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -11,6 +11,7 @@ import { type PersonalReportService, } from "@/lib/personal-report-service"; import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service"; +import { createPersonalReportSectionService } from "@/lib/personal-report-section-service"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -38,15 +39,24 @@ async function resolvePersistenceForUser() { const supabase = await createServerSupabaseClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { - return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null }; + return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null, listSections: undefined }; } const persistence = createSupabasePersonalReportService(supabase); - return { userId: user.id, persistence, jobs: createSupabasePersonalReportJobService(supabase) }; + const sections = createPersonalReportSectionService(supabase as never); + return { + userId: user.id, + persistence, + jobs: createSupabasePersonalReportJobService(supabase), + listSections: async (ownerId: string, requestId: string) => { + const rows = await sections.list(ownerId, requestId); + return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode })); + }, + }; } export async function GET(request: Request, context: RouteContext) { try { - const { userId, persistence, jobs } = await resolvePersistenceForUser(); + const { userId, persistence, jobs, listSections } = await resolvePersistenceForUser(); const { reportId } = await context.params; if (!uuidPattern.test(reportId)) { return NextResponse.json( @@ -71,6 +81,7 @@ export async function GET(request: Request, context: RouteContext) { // before it is returned to the browser. Client-side validation is never // a substitute. jobs: jobs ?? undefined, + listSections, validateReadyDocument: (document) => { const parsed = safeParseServerReportDocument(document); return parsed.ok diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index a9f15885..6ce35f69 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -6,6 +6,7 @@ import { resolveSkillSnapshot, } from "@/lib/personal-report-generation"; import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { summarizePersonalReportFailure } from "@/lib/personal-report-failure-summary"; import { isProductEnabled } from "@/lib/product-access"; import { isPersonalReportFeatureEnabled, @@ -54,7 +55,10 @@ function toNextResponse(response: { status: number; body: Record | null, +) { const row = value && typeof value === "object" ? value as Record : {}; return { id: typeof row.id === "string" ? row.id : "", @@ -71,6 +75,7 @@ function listReportView(value: unknown) { completedAt: row.completed_at == null || row.completed_at === "" ? null : reportListTimestamp(row.completed_at) || null, + ...(failure?.summary ? { failureSummary: failure.summary } : {}), }; } @@ -89,9 +94,39 @@ export async function GET() { .order("created_at", { ascending: false }) .limit(20); if (error) throw error; - return NextResponse.json({ - reports: Array.isArray(data) ? data.map(listReportView).filter((report) => report.id) : [], - }); + const reports = Array.isArray(data) ? data.map((row) => listReportView(row)).filter((report) => report.id) : []; + const failed = reports.filter((report) => report.status === "failed" && report.requestId); + if (failed.length > 0) { + const { data: sections, error: sectionError } = await supabase + .from("personal_report_sections") + .select("request_id, status, last_error_code") + .eq("user_id", user.id) + .in("request_id", failed.map((report) => report.requestId)); + if (sectionError) throw sectionError; + const grouped = new Map>(); + for (const row of Array.isArray(sections) ? sections : []) { + const requestId = typeof row.request_id === "string" ? row.request_id : ""; + if (!requestId) continue; + const list = grouped.get(requestId) ?? []; + list.push({ + status: typeof row.status === "string" ? row.status : "pending", + lastErrorCode: typeof row.last_error_code === "string" ? row.last_error_code : null, + }); + grouped.set(requestId, list); + } + for (const [index, report] of reports.entries()) { + if (report.status !== "failed") continue; + const source = Array.isArray(data) ? data.find((row) => ( + row && typeof row === "object" && (row as { id?: unknown }).id === report.id + )) : null; + reports[index] = listReportView(source, summarizePersonalReportFailure({ + themeCount: report.themes.length, + sections: grouped.get(report.requestId) ?? [], + failureCode: report.failureCode, + })); + } + } + return NextResponse.json({ reports }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json( diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 84d2534f..70107c2e 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -19,6 +19,7 @@ type ReportListItem = Readonly<{ themes: readonly string[]; status: "generating" | "ready" | "failed"; failureCode: string | null; + failureSummary?: string | null; createdAt: string; completedAt: string | null; }>; @@ -57,6 +58,7 @@ function readReports(value: unknown): ReportListItem[] { : [], status: row.status as ReportListItem["status"], failureCode: typeof row.failureCode === "string" ? row.failureCode : null, + failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null, createdAt: typeof row.createdAt === "string" ? row.createdAt : "", completedAt: typeof row.completedAt === "string" ? row.completedAt : null, }]; @@ -207,7 +209,7 @@ export function PersonalReportCenter() { 查看进度 ) : ( - {report.failureCode ?? "生成失败"} + {report.failureSummary ?? report.failureCode ?? "生成失败"} )} ); diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 138559ab..82bac4e5 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -35,7 +35,7 @@ export type ReportLoadState = | { phase: "not-found" } | { phase: "generating"; progressPercent?: number; progressPhase?: string } | { phase: "timed-out" } - | { phase: "failed"; failureCode: string | null } + | { phase: "failed"; failureCode: string | null; failureSummary?: string | null } | { phase: "invalid"; message: string } | { phase: "network-error" } | { phase: "ready"; document: ReportDocument }; @@ -48,6 +48,7 @@ export interface ReportEnvelopeView { presentationMode: string; status: string; failureCode: string | null; + failureSummary?: string | null; createdAt: string; completedAt: string | null; progressPercent?: number; @@ -104,7 +105,14 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor const code = typeof view.failureCode === "string" && view.failureCode.length > 0 ? view.failureCode : null; - return { phase: "failed", failureCode: code }; + const summary = typeof view.failureSummary === "string" && view.failureSummary.length > 0 + ? view.failureSummary + : null; + return { + phase: "failed", + failureCode: code, + ...(summary ? { failureSummary: summary } : {}), + }; } default: return { phase: "invalid", message: "报告状态无法识别。" }; @@ -293,12 +301,12 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {