# 任务书 · 个人报告改为分章节生成(2026-08-30) 基线:`origin/staging` @ `a96df754`。 ## 为什么要做 现状:`createPersonalReportAgent` 用**一次**模型调用产出整份叙述(`executiveSummary` + 最多 12 个 `thematicNarrative`),结构化输出 + Zod 校验,失败只允许**一次**修复重试。 两个问题: 1. **没有设输出上限。** 仓库里有现成的 `agentGenerationSettings`(答复预算 16,384 token),`agent-run.ts` 和 `consultation-tools.ts` 都在用,**只有 `src/mastra/personal-report.ts` 没用** —— 它走模型提供方的默认值。而 schema 允许的最坏输出约 224,000 字符(12 章 × 每章上限)。 2. **失败是全有或全无。** 任何一章写坏或被截断 → JSON 不完整 → schema 不过 → 重试一次 → 整份报告 `failed`。用户看到的是"生成失败",不是"少了两个主题"。 **产品已确认:一份缺若干主题、缺失部分明确标注的报告,算合格交付。** 这是本轮降级方案成立的前提。 ## 这不是"上下文丢失"问题 `createPersonalReportAgent` 是结构性无状态的 —— 注释原文:*"chat history and skill text are structurally excluded by the agent definition (no skills, no tools, no memory)"*。prompt 是 `(bundle, plan)` 的纯函数,**现在就没有跨调用的对话上下文可丢**。拆开后每次仍拿到完整证据。 真正需要跨章节的只有三件,均在本任务书内解决:摘要要概括全部主题(最后生成)、章节间不重复(传已写标题列表)、断言口径一致(服务端 `claimCards` + `evidenceRefs` 绑定,本就每次相同)。 --- ## 硬红线 1. **任务 0 是门控。** 观测必须先上线并收到真实数据,才允许调整输出上限或收紧 schema。**不得凭任务书里的估算直接改参数** —— 那个 224,000 是 schema 上限推算,不是实测。 2. **章节生成先串行,不得并发。** 章节之间无依赖,理论上可并发,但会放大限流风险。先串行拿数据。 3. **`report_document` 的语义不得改变**:它只存最终成品。中间结果一律不写进去,避免半成品被当成完整报告读出。 4. **schema 只可收紧,不可放宽。** 不得为了让截断的输出通过而放松校验。 5. **任务生命周期在 Postgres 函数里**(`claim_personal_report_job` / `heartbeat_personal_report_job` / `complete_personal_report_job` / `fail_personal_report_job`,见 `supabase/migrations/20260814040000_personal_report_jobs_v2.sql`)。章节级状态**必须沿用同一模式加 SQL 函数**,不得把状态机搬进应用层。 6. **不得修改既有测试断言** —— 除非该断言锁的正是本轮要改的缺陷本身;那种情况必须在断言上方注明原值与原因,并在 PROGRESS 单列。 7. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。 8. **数据库测试必须真跑。** 本轮动了表结构,`npm run test:db` 需要 Docker。**没有 Docker 就不要推** —— 把环境缺口写进 `BLOCKED.md` 并停下,不允许"本地跑不了所以跳过"。 9. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 让步顺序:数据不损坏 > 功能与测试不回归 > 可验证的改进 > 成本 > 代码整洁。 ## 开工前置 ```bash git fetch origin --prune git worktree add -b codex/report-sectioned-20260830 \ ../.worktrees/report-sectioned-20260830 origin/staging ``` 基线必须是 `origin/staging`。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。改前在 `docs/BUG_HISTORY.md` 检索同类记录。 **先读这几个文件再动手**,本任务书的判断都基于它们: - `src/mastra/personal-report.ts` —— agent、prompt、schema、重试 - `src/lib/personal-report-plan.ts` —— `ReportSectionPlanEntry`,注意它**已经有** `disposition`、`evidenceRefs`、`targetCharacters` - `src/lib/personal-report-contract.ts` —— `claimStatus` 含 `blocked` - `supabase/migrations/20260814040000_personal_report_jobs_v2.sql` —— 任务表与四个 SQL 函数 - `src/components/personal-report/personal-report-page.tsx` —— 9 个状态与轮询 --- ## 任务 0(P0,门控)· 先把截断变成可观测的 ### 事实 `src/mastra/personal-report.ts` 的 `logTelemetry` 只记 `resolved` / `failed`,不记 `finishReason` 也不记 token 用量。**现在无法从数据上区分"模型不听话"和"输出被截断"**,也就无法证明后续改动有效。 ### 做法 - `logTelemetry` 增加 `finishReason`(`length` 即截断)与 `usage.outputTokens` / `usage.inputTokens` - 两次尝试(首次与修复重试)各记一条,能区分是哪一次 - 遵守 `AGENTS.md` 的隐私红线:**只记指标名、阶段、数值**,不得记 prompt、bundle、模型原文、用户资料、案例 ID ### 验收 - 人为构造一次会被截断的生成(例如临时把输出上限调到极小),确认日志出现 `finishReason: "length"` - 上线后至少收集一段真实数据,在 PROGRESS 里给出:失败总数、其中 `length` 占比、`outputTokens` 的 p50/p95 **这一条必须先单独上线并收到数据**,任务 2 的输出上限取值要用这批数据决定,不许拍脑袋。 --- ## 任务 1(P0)· 章节中间结果落库 ### 做法 新建 `public.personal_report_sections`,**不要**往 `report_document` 里增量写。 字段至少包含:`user_id`、`request_id`、`section_id`、`payload jsonb`(单个 thematicNarrative 条目)、`status`(`pending` / `ready` / `blocked`)、`attempt_count`、`max_attempts`、`last_error_code`、`created_at`、`updated_at`。主键或唯一约束用 `(user_id, request_id, section_id)`。 选这个方案而不是复用 `report_document` 的三个理由,实现时请守住: 1. `report_document` 保持"最终成品"语义 2. **每个章节有自己的重试预算** —— 一个难写的主题不得烧光整个任务的 `max_attempts` 3. 续做只需查"哪些 section 还没有 payload" ### 迁移要求 照 `20260814040000_personal_report_jobs_v2.sql` 的既有写法: - 整个迁移包在 `begin; ... commit;` 里 - 所有 DDL 用 `if not exists`,可重复执行 - **必须 `enable row level security`**,并给 `authenticated` 只读自己的行的 policy - 显式 `grant`:`authenticated` 只 `select`;`service_role` 才有写权限 - 章节状态流转写成 SQL 函数(与 `claim/heartbeat/complete/fail_personal_report_job` 同一模式),`security definer` 并显式 `grant execute` 给 `service_role` - 文件名沿用 `YYYYMMDDHHMMSS_描述.sql` 格式,时间戳要晚于 `20260830010000` ### 验收 ```bash npm run db:migrate:check # 幂等性 npm run test:db # 需要 Docker ``` 新增数据库测试,至少覆盖:`authenticated` 读不到别人的章节;`authenticated` 写不进去;`service_role` 能写;同一 `(user_id, request_id, section_id)` 重复插入不产生第二行。 ### 止损 如果执行环境没有 Docker,**停在这里**,把缺口写进 `BLOCKED.md`,不要带着未验证的迁移继续做任务 2。 --- ## 任务 2(P0)· 把一次生成切成 plan → 章节 → 摘要 → 装配 ### 阶段模型 沿用**已存在但基本没用起来**的 `personal_report_jobs.progress_phase`: ``` plan → section: × N → summary → assemble → ready ↑确定性 ↑每章节一次调用 ↑最后一次 ↑确定性 ``` ### 每个章节一次调用 输入: - **按该章节 `evidenceRefs` 过滤后的 bundle 子集** —— `ReportSectionPlanEntry.evidenceRefs` 已经有这个字段。**这条是成本关键**:不过滤就是把完整证据包重发 N 次。过滤后既省钱也让模型更聚焦。 - plan 只保留这一个 section - **已完成章节的标题列表**(只要标题,不要正文)——用于避免重复 - 显式输出上限,用 `agentGenerationSettings`;具体数值由任务 0 的数据决定,并参考该 section 的 `targetCharacters.max` 输出 schema 收成**单个 thematicNarrative 条目**,不再是 12 条数组。 ### 摘要最后生成 输入是各已完成章节的**标题 + `claimStatus`**,不是正文全文。这样既概括到全部主题,又不会把 prompt 撑大。 ### 装配 确定性合并成 `report_document`,此时才写 `report_document` 并把 `personal_reports.status` 置 `ready`。 ### 续做 worker 拿到租约后,按 `progress_phase` 与 `personal_report_sections` 的已有行判断从哪继续,**已完成章节不得重算**。 ### 验收 - 单元测试:给定 N 个 write 章节,验证发生 N+1 次调用,且第 k 次的 bundle 只含该章节 `evidenceRefs` 引用到的证据 - 续做测试:模拟在第 3 章后中断,重新领取任务,验证前 3 章不重算、最终文档完整 - 端到端至少跑一份真实报告,PROGRESS 给出:章节数、每章 `outputTokens`、总墙钟时间,与改前单次调用对照 ### 止损 - 如果过滤 bundle 导致某类章节缺证据写不出来,**不要退回发全量**:先确认是 plan 的 `evidenceRefs` 不全还是过滤逻辑错了。真是 plan 不全就修 plan,并在 PROGRESS 说明。 - 如果总墙钟时间超过现有 8 分钟轮询预算(`POLL_BUDGET_MS`),**不要顺手调大预算**,先把数据摆出来再决定。 --- ## 任务 3(P1)· 章节写不出时降级为 blocked,报告照常交付 ### 前提 **产品已确认:缺若干主题、缺失部分明确标注的报告算合格交付。** `blocked` 已经是一等 `claimStatus`(`personal-report-contract.ts`),文档视图已有「阻塞」标签与渲染路径 —— 复用它,不要新造状态。 ### 做法 - 单个章节耗尽自己的 `attempt_count` 后,标记该行 `status = 'blocked'`,记 `last_error_code`,**继续下一章** - 装配时把 blocked 章节渲染成明确的"本节未能生成",并说明原因类别(截断 / 校验失败 / 证据不足),**不得静默省略** - 只有当**全部** write 章节都 blocked、或摘要本身失败时,整份报告才 `failed` ### 明确不要做 - 不得因为有 blocked 章节就退积分或改计费 —— 产品已定这算合格交付 - 不得把 blocked 章节伪装成正常内容 - 不得为了凑数让模型"随便写点什么" ### 验收 - 强制某一章连续失败,验证:该章标 blocked、其余章正常、报告 `ready`、页面上该节有明确标注 - 强制全部章节失败,验证报告 `failed` 且失败原因可读 --- ## 任务 4(P2)· 等待页显示真进度 ### 事实 `personal_report_jobs.progress_percent`(0–100)和 `progress_phase` 都已存在,目前基本没被用起来。等待页只有一个转圈加"已等待 N 分",8 分钟里没有任何进展信号。 ### 做法 - 每完成一个章节更新 `progress_percent`(已完成 / 总 write 章节数)与 `progress_phase` - 等待页显示"正在生成第 3/7 个主题"一类的文案 - 保留既有的 `timed-out` / 「继续等待」/ 「返回报告中心」路径,**不要动那 9 个状态的结构** ### 验收 生成一份多主题报告,录一段等待过程,说明进度是如何推进的。 --- ## 不在本轮范围 - **章节并发生成**:红线 2 已禁止。等串行数据出来再单独立项。 - **提示缓存**:先做 `evidenceRefs` 过滤,量完成本再谈。 - **收紧 `thematicNarrative` 的 `max(12)`**:等任务 0 的数据。 - **换模型或改模型选择逻辑**:不在本轮。 - 前端 UI 的其它优化:见 `TASK-frontend-interaction-20260830.md`,两份任务书互不冲突。 ## 收尾 - PROGRESS 文件名写 `PROGRESS-report-sectioned-20260830.md`。**不要写成 `PROGRESS.md`** —— 根目录已有受版本控制的 `progress.md`,大小写不敏感文件系统上会互相覆盖。 - **任务 0 单独一次推送并收数据**;任务 1–4 可合成第二次推送。staging push 触发全量构建+部署,没有路径过滤。 - 数据库迁移走既有流程(`.gitea/workflows/migrate-staging-database.yml`),**不要手工连库改表**。 - 推送后核对 `https://staging.jyotisha.chat/api/health` 的 `.deployment.gitCommit`。流水线是 `validate` → `publish` → 才派发 `deploy`,**全链路约 20 分钟**,不要十几分钟就断定失败。 - 不自行提升 main。 ## 交付物清单 1. `logTelemetry` 记 `finishReason` 与 token 用量,且不含任何隐私字段 2. 任务 0 上线后的真实数据:失败总数、`length` 占比、`outputTokens` p50/p95 3. `personal_report_sections` 迁移(事务内、幂等、RLS、显式 grant、SQL 状态函数) 4. 数据库测试:越权读、越权写、service_role 可写、重复插入幂等 5. 分章节生成实现:N+1 次调用、按 `evidenceRefs` 过滤 bundle、摘要最后生成 6. 续做测试:中断后不重算已完成章节 7. blocked 降级:单章失败报告仍 `ready` 且有明确标注;全部失败才 `failed` 8. `progress_percent` / `progress_phase` 真实推进 + 等待页文案 9. 改前改后对照:章节数、每章 `outputTokens`、总墙钟时间 10. `docs/BUG_HISTORY.md` 条目(编号先对远端确认) 11. `BLOCKED.md`:任何触发止损的项,尤其是 Docker 缺口 12. `PROGRESS-report-sectioned-20260830.md` 13. `tsc --noEmit` / `eslint`(0 error)/ `tsx --test`(失败清单与基线逐条比对)/ `npm run test:db` / `next build` 的实际输出