docs: BUG-1051 record, progress, real-device checklist, changelog and board row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 22:20:56 +08:00
co-authored by Claude Opus 5.5
parent 1530a0dd63
commit 1dd51f2d42
5 changed files with 168 additions and 0 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-26 — 普通对话:回答写到一半被掐断时不再算完成、不再扣点(待验收)
- 以前排盘计算和写回答共用 110 秒,计算慢时回答会停在半句上,却显示完成并扣点(BUG-1051,复发自 BUG-305)。现在写回答有自己的 70 秒,从开始写时才计时;最长总等待约 3 分钟。
- 回答没有正常写完(被超时掐断、被内容过滤、模型中途停下等)时,保留已写出的内容,但停在最后一个完整句子上,输入框上方提示「回答未完成,已保留现有内容;本次不会扣点。」,不扣点、不按完成记账。篇幅超限仍会先自动接着写一次。
- 线上诊断日志补记写回答这一段怎么结束、是否被中止、回答有多少字(只有数字和类别,不含正文)。
- Skill 版本不 bump。不改数据库。
## 2026-09-26 — 生时校正开场改大白话、题目带例子;步骤名改大白话且不重复(待验收)
- 开场正文改成两句大白话:「我们来把你的出生时间缩小到更准的范围,现在先在 HH:MM–HH:MM 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」不再出现大运、盘面、代表分钟、精确到秒这些没解释过的词,也不再在开场说「最后给区间和代表分钟,不给精确到秒」(结果卡上「这只是代表性候选,不是已确认的唯一出生分钟。」不变)。
+23
View File
@@ -14104,3 +14104,26 @@
- 相关记录:BUG-1047、BUG-1049、BUG-725。
- 复发自:无。
- 修复版本:分支 `codex/rectification-opening-plain-20260926`(本地提交,未推)。
## BUG-1051 | 普通咨询回答写到一半被掐断,仍按完成扣点、不提示未完成
- 状态:resolved(本地修复,真实 Mastra 流回归测试通过;未推送、未部署,真机清单 `docs/testing/consult-answer-truncation-20260926.md` 待走)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`POST /api/consult` 的三条 agentic 路径(本命、申报时段、无出生分钟);`frontend/src/lib/stream-agent-response.ts` 的 `consumeAttempt` / `continueCurrentAnswer` / `finishPass4` / 结算段;`frontend/src/mastra/consultation-tools.ts` 时间常数;`frontend/src/lib/agent-observability.ts`;`frontend/src/app/api/consult/route.ts` 的 `composeAnswer` / `continueAfterLength` / `retryForAnswer`。
- 用户现象:staging `e53052a2`(2026-09-26 21:24 CST)一次普通咨询,回答停在一个二级标题之后的半句上;活动区写「已完成 7 步」,没有出现「回答未完成,已保留现有内容;本次不会扣点。」,会话按完成保存并扣点。
- 触发条件:本命路径。工具循环(思考、读 Skill、约 31 秒/领域的计算,共 7 步)先用掉 110 秒闸门的大部分,写回答(compose)只剩几秒。
- 根因:三层。
1. 共用闸刀:`route.ts` 只建一个 `AbortSignal.timeout(AGENT_TIMEOUT_MS)`(110 秒),工具循环、`composeAnswer`、续写和回答重试全都展开同一个 `streamOptions`。BUG-944 的防复发写的是「每个 stream 自己的预算」,但那一轮只取消了分段写作,一次成文的 compose 仍然共用这把闸刀。
2. Mastra 1.50.1 超时不抛错:signal 触发时先入队 `{ type: "abort" }`,再发 `finish`(reason `tripwire`),然后正常关流(`@mastra/core/dist/chunk-OE4IEL7C.js` 约 27360 / 27452 行)。`stream-agent-response.ts` 的 `mapChunk` 忽略 `abort`,abort 运行步只在 `catch` 里记,`continueCurrentAnswer` 只认 `length`,于是流程走到 `onComplete`(扣点并按完成落库)和 `run.completed`;`finishPass4` 还把 Pass 4 缓冲里的半句当最后一句发了出去。
3. 同一缺口也覆盖其他非 `stop` 的结束:`content-filter`、`tool-calls`(compose 是 `toolChoice: "none"`、1 步)、`other`、`error`,以及供应商没发 finish(实测 Mastra 仍补一个 reason 为空的 `finish`,归一为 `unknown`)。只要有可见正文,都会被当成完成。
- 为什么 BUG-305 的测试没拦住:BUG-305 的超时回归(`consultation-agentic-runtime.test.ts`「a timeout after partial visible text…」)手工 `throw new DOMException("…", "TimeoutError")`。这不是 Mastra 的真实流形状(违反 AGENTS §7.4):真实超时根本不抛错,永远进不了 `catch`,所以测试一直绿,线上照样扣点。BUG-944 的防复发只写在文字里,没有测试锁住「compose 不与工具循环共用 signal」。BUG-612 在 staging 日志里已经见过同一形状(`modelFinishReason=tripwire`、墙钟约 110 秒),当时按「模型改领域、分段再调工具」处理,没有追到 Mastra 的 abort 不抛错。
- 修复(产品 2026-09-26 决策 D1–D3):
- D1 写回答阶段自有时钟:新增 `CONSULTATION_COMPOSE_TIMEOUT_MS = 70_000` 与 `createConsultationAnswerClock()`(第一次调用时才开始计时,同一轮后续的续写、Pass 4 重写、回答重试共用这一个 signal)。`composeAnswer`、三条路径的 `continueAfterLength` 与 `retryForAnswer` 都改用它;工具循环和工具本身仍用 110 秒的 `agentAbortSignal`。最坏总等待 110 + 70 = 180 秒;路由 `maxDuration` 120 → 240(自托管 `node server.js` 不执行该值,只作上限说明)。70 秒的依据见 PROGRESS。
- D2 结算只认 `stop`:每次 attempt 记录自己的 finish reason 与是否收到 Mastra `abort` 块;写回答的最后一次 attempt 不是 `stop`(abort/tripwire、content-filter、tool-calls、other/unknown/error、没有 finish)而正文非空时,不调用 `onComplete`、不发 `run.completed`,改为 `run.failed` / `answer_truncated`,保留已流出的正文,账务走 `cancel`,客户端沿用既有提示。Mastra `abort` 块记 `kind: "abort"` 运行步(工具循环 `tool-abort`、写回答 `compose-abort`),另记 `answer-truncated` 校验步,回执不再显示全部成功。`length` 仍先续写一次;续写本身也被掐或仍停在 `length` 时同样按截断处理。被掐的流不再把 Pass 4 缓冲里的半句冲出去。
- D3 观测:`[agent-observability]` 新增 `composeFinishReason`(封闭枚举 + `missing`)、`composeAborted`(布尔)、`answerVisibleChars`(计数),只有枚举和数字,不含正文。公开回执仍不带 `modelFinishReason`(BUG-305 规则)。
- 验证:新增 `frontend/tests/consult-answer-truncation-20260926.test.ts`(15 条,全部用真实 Mastra `Agent` + 假模型产生的流):共享闸刀在正文中途触发 → `answer_truncated`、`onComplete` 未调用、有 `compose-abort` 步、半句不外发;工具循环的 signal 已过期时 compose 用自有时钟照常完成(对照组:沿用过期 signal 则不完成);答案时钟首用才起算、全阶段共用;`content-filter` / `tool-calls` / `other` / `unknown` / `error`、供应商无 finish、流无 finish 块各一条 → 截断;`length` 续写后 `stop` → 完成并扣点;续写被掐 → 截断;正常 `stop` → 完成并扣点一次;观测字段通过严格 schema 且不含正文;路由源码合同(compose、3 处续写、3 处回答重试都用答案时钟,110 + 70 ≤ 180 且小于 `maxDuration`)。修复前 11 / 15 条失败。BUG-305 旧用例保留并加三栏说明、补 abort 步断言;11 条手造「无 finish」的既有 fixture 补上 Mastra 真实流必有的 `finish(stop)`(断言未改)。数字(全量、Python 门、构建、gzip)见 `docs/tasks/PROGRESS-consult-answer-truncation-20260926.md`。
- 未做:活动区标题「已完成 N 步」按时间线行数计,截断回复上仍会这样写(真正的信号是输入框上方的未完成提示);改它属于 UI 改动,未在本单范围。真实供应商下 compose 的实际耗时与 `composeFinishReason` 分布需部署后用新埋点复核。
- 防复发:结算只认 `finish = stop`,任何新的写回答流都要经过同一判定。流的超时 / 中止回归一律用真实 Mastra `Agent` + 假模型产生的流(abort 块 + `finish(tripwire)`),不得手工 `throw`。每个新加的模型流要有自己的时间预算,并用源码合同锁住它不与工具循环共用 signal。
- 相关记录:BUG-305(原记录)、BUG-944(「每个 stream 自己的预算」未落到 compose)、BUG-612(同一 tripwire / 110 秒形状)、BUG-280(回答重试没有独立时间预算,本单一并挂到答案时钟上)。
- 复发自:BUG-305(半截回答被当成功并扣点;原防线只拦「抛出的超时」与 `length`,没拦 Mastra 不抛错的 abort)。
- 修复版本:分支 `codex/consult-answer-truncation-20260926`(本地提交,未推送)。
@@ -0,0 +1,92 @@
# PROGRESS · 普通咨询回答被掐断仍扣点(BUG-1051)· 2026-09-26
- 执行方式:直接执行(产品负责人授权子代理执行;Claude 事后独立验收)
- 基线:`origin/staging` `e53052a2` 开工;提交前变基到 `16f4600d`(BUG-1049/1050 校正开场单,只与本单的文档文件重叠,已并存)
- 分支:`codex/consult-answer-truncation-20260926`(本地提交,未推送)
- BUG 编号:1051(1049/1050 由并行的校正开场单占用)
- 复发自:BUG-305;相关 BUG-944 / BUG-612 / BUG-280
## 结论
| 项 | 结果 |
| --- | --- |
| 根因 | 确认,三层:① 工具循环与写回答共用一个 110 秒 `AbortSignal`;② Mastra 1.50.1 超时不抛错,发 `abort` 块 + `finish(tripwire)` 后正常关流,结算段只认抛错和 `length`;③ `content-filter` / `tool-calls` / `other` / `unknown` / `error` 等非 `stop` 结束也被当完成 |
| D1 写回答自有时钟 | 完成:`CONSULTATION_COMPOSE_TIMEOUT_MS = 70_000`,首用才起算,compose / 续写 / Pass 4 重写 / 回答重试共用 |
| D2 非 `stop` 不结算 | 完成:`run.failed` / `answer_truncated`,不调 `onComplete`、账务走 `cancel`,记 `compose-abort` / `tool-abort` 与 `answer-truncated` 步;被掐时不冲出 Pass 4 半句;`length` 续写不变 |
| D3 观测 | 完成:`composeFinishReason`、`composeAborted`、`answerVisibleChars`,只有枚举 / 布尔 / 计数;公开回执不变 |
| 超时层逐层核对 | 无阻塞项,见下表;未改 `deploy/` 与 `.gitea/workflows` |
## 为什么是 70 秒
| 依据 | 数字 |
| --- | --- |
| staging 同供应商默认模型的实测吞吐(`PROGRESS-report-writer-failure-20260902.md` Run A telemetry,端到端含首字延迟) | 560 tok / 5.7 s、1240 / 13.6 s、816 / 8.1 s、759 / 7.4 s → 约 90–100 tok/s |
| 70 秒可写 | 按实测 ≈ 6,300–7,000 可见 token;按一半吞吐仍 ≈ 3,000 token |
| BUG-305 保留的可见预算 | 8,192 token(现 compose 上限 16,384,thinking 关闭) |
| 一次四标题回答的常见长度 | 约 1,500–2,500 token,70 秒有 3–4 倍余量 |
| 最坏总等待 | 工具循环 110 s + 写回答 70 s = 180 s(产品接受「约 3 分钟」) |
写不完 70 秒的回答不会再被当成完成:按截断提示、不扣点。实际 compose 耗时分布需部署后看 `composeFinishReason` / `run.total` 复核;若 `tripwire` 频繁出现,再按数据调整这个值。
## 会不会有别的层先掐断 3 分钟的流
| 层 | 核对结果 | 处理 |
| --- | --- | --- |
| 路由 `maxDuration` | Next 16 文档写明它只给部署平台读(`node_modules/next/dist/docs/.../route-segment-config/maxDuration.md`);生产是自托管 `node server.js`(`deploy/railway-web.Dockerfile`),不执行 | 120 → 240,与校正路由同值,作上限说明;源码合同锁 110 + 70 < 240 |
| Node HTTP 服务器 | `server.timeout` 默认 0;`requestTimeout`(300 s)只管接收请求体;Next 只设 `keepAliveTimeout`(连接空闲,不影响进行中的响应) | 无需改 |
| Caddy(`deploy/Caddyfile.*`) | 只有 `reverse_proxy web:3000`,未配任何 timeout;Caddy 默认无响应读 / 写超时。先例:校正路由 `maxDuration = 240`、整轮预算 210 s 经同一条链路运行 | 无需改,未动 `deploy/` |
| CDN | AGENTS §1:公网边缘只有 Caddy,无 CDN | — |
| 客户端 | `use-consultation-run.ts` 的 `AbortController` 只在用户取消时触发,无超时;恢复轮询 `consultation-recovery-poll.ts` 每 1.75 s 一次、无上限;`/api/consult/status` 无时间阈值 | 无需改 |
| 账务预扣 | `cancel_consultation_credit` / 完成 RPC 没有分钟级过期;过期清理是 1 天 | 无需改 |
| NDJSON 心跳 | 写回答阶段一直有 `answer.delta`;长时间无字节只可能在工具计算阶段,本单未改变该阶段时长 | 无需改 |
| 断线 | 三条路径都是 `continueAfterDisconnect: true`,客户端断开后服务端继续写完并结算 | 不变 |
## 各种结束方式的行为
| 写回答的最后一个流 | 有正文 | 无正文 |
| --- | --- | --- |
| `stop` | `run.completed`,扣点 | 既有 `answer-retry` → 仍空则 `empty_answer` |
| `length` | 续写一次;续写 `stop` → 完成;续写仍 `length`、被掐或其他 → `answer_truncated` | 同左 |
| Mastra `abort` + `finish(tripwire)` | `answer_truncated`,记 `compose-abort`,半句不外发 | `empty_answer` 路径(不扣点) |
| `content-filter` / `tool-calls` / `other` / `unknown` / `error` | `answer_truncated` | `empty_answer` 路径 |
| 供应商没发 finish(Mastra 补 reason 为空 → `unknown`) | `answer_truncated` | `empty_answer` 路径 |
| 流里完全没有 finish 块(未在 Mastra 观测到,防传输提前关闭) | `answer_truncated`(`composeFinishReason = missing`) | `empty_answer` 路径 |
工具循环(本命路径里被「drain」、不进正文的那一段)自己的结束原因不参与结算判定;它被掐时记 `tool-abort`,写回答照常在自己的时钟上进行。
## 改动文件
| 文件 | 改动 |
| --- | --- |
| `frontend/src/mastra/consultation-tools.ts` | `CONSULTATION_COMPOSE_TIMEOUT_MS`、`createConsultationAnswerClock()`;运行态加三项观测字段并进 `consultationModelStepTelemetry` |
| `frontend/src/app/api/consult/route.ts` | `answerPhaseSignal`;compose、3 处续写、3 处回答重试改用;`maxDuration` 240 |
| `frontend/src/lib/stream-agent-response.ts` | 每次 attempt 的结束记录、`abort` 块留痕、结算判定、被掐不冲半句、观测写回 |
| `frontend/src/lib/agent-observability.ts` | 严格 schema 加三项字段 |
| `frontend/tests/consult-answer-truncation-20260926.test.ts` | 新增 15 条,真实 Mastra `Agent` + 假模型 |
| `frontend/tests/consultation-agentic-runtime.test.ts` | BUG-305 旧用例加三栏说明 + abort 步断言;11 条 fixture 补 `finish(stop)` |
## 既有测试改动(三栏)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 「a timeout after partial visible text…」(BUG-305) | 手工 `throw DOMException("TimeoutError")` 代表超时半截,是唯一超时回归 | 保留,只代表「真的抛出」的 catch 分支,补断言 abort 运行步;真实超时形状由新文件覆盖 | 手造形状不是 Mastra 行为,旧测试一直绿而线上照样扣点(§7.4) |
| 11 条既有 fixture(contract/degraded/window precompute/answer-retry/reasoning 等) | 生成器只 yield `text-delta` 就结束 | 末尾补 `yield STOP_FINISH`(Mastra 正常结束必有的 `finish(stop)`) | 无 finish 的流现在按截断处理;断言一条未改 |
## 验证(Node 22.14)
| 项 | 结果 |
| --- | --- |
| 新回归文件 | 15 / 15 通过,连跑 3 次稳定;修复前代码上 11 / 15 失败 |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(128 warning;本单涉及文件里的 2 条 warning 均为基线已有) |
| `npm test` 全量 | 变基前(`e53052a2` 上)4060 条,pass 4008 / fail 24 / skip 28;变基后(`16f4600d` 上)4069 条,pass 4017 / fail 24 / skip 28。对照基线 `cs-test.log` 4045 条、fail 25 / skip 28:失败名单 0 新增;基线的「Gitea quality gate validates before publishing an immutable ACR manifest」通过(基线日志早于 `e53052a2` 恢复 upload-artifact action);其余 24 条逐条一致(均为 Docker / DB)。测试名 0 消失;新增 24 条 = 本单 15 + 校正开场单 9 |
| Python 门禁集 | 948 passed / 1 skipped(与基线一致) |
| `npm run build -- --webpack` | `/` 仍 `○ Static`;rootMainFiles gzip 130,933 B(基线 130,933,0.000%) |
| stray `frontend/frontend/` | 已删除 |
## 让步与未做
- 活动区标题「已完成 N 步」按时间线行数计,截断回复上仍这样写;未完成的信号是输入框上方的提示。改标题属于 UI 改动(要同步 DESIGN.md),不在本单范围,建议另开小单。
- 真实供应商下的 compose 耗时分布、`tripwire` 占比需部署后看 `[agent-observability]` 复核(环境缺口:本地无模型凭据)。
- 真机走查见 `docs/testing/consult-answer-truncation-20260926.md`(环境缺口:无登录态)。
- 无出生分钟路径里,若模型正文被 Pass 4 全部拦下、服务端补拒答句,而写正文的流恰好被掐,本单会按截断处理(显示拒答句 + 未完成提示、不扣点)。概率很低,保持诚实优先。
+1
View File
@@ -126,6 +126,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| — (产品 09-26 口头拍板 D1–D3,直接执行) | `PROGRESS-consult-answer-truncation-20260926.md` | **普通咨询回答写到一半被掐断仍扣点(BUG-1051,复发自 BUG-305)**:工具循环与写回答共用 110 秒 signal;Mastra 1.50 超时不抛错(`abort` 块 + `finish(tripwire)` 后正常关流),结算只认抛错与 `length`。D1 写回答自有 70 秒时钟(首用起算,续写 / 回答重试共用,最坏 180 秒,`maxDuration` 240);D2 写回答的最后一个流不是 `stop` 且有正文 → `answer_truncated`、不扣点、记 abort 步、不冲半句,`length` 续写不变;D3 观测加 `composeFinishReason` / `composeAborted` / `answerVisibleChars` | 待验收 | `codex/consult-answer-truncation-20260926`(本地,未推送);新回归 15 条用真实 Mastra `Agent`(修复前 11 条红);全量失败名单 0 新增;Python 948/1;`/` ○、gzip 0%;真机清单 `docs/testing/consult-answer-truncation-20260926.md` |
| `TASK-scroll-anchor-hook-fixes-20260926.md` | `PROGRESS-scroll-anchor-hook-fixes-20260926.md` | **滚动锚两处老问题**:直接打开已有会话时监听未挂上(BUG-1043)、校正长回答钉顶后因 96px 阈值被拉到底(BUG-1044)。排在 BUG-1042 合入后。挂载改由容器元素本身驱动(每次提交比对元素 / active / resetKey);钉顶只由用户滚动手势解除 | 已验收(Claude 09-26 直接执行:子代理复现两处根因并修复;Claude 独立复验 tsc/lint 0、全量 3970 条失败名单与基线逐条一致、四路由 ○、gzip 不变;iOS 惯性滚动留真机清单) | `da2613ff`(随 `f1d16405` 部署,health 一致) |
| `TASK-latest-turn-actions-gap-20260926.md` | — | **最后一轮正文与点赞 / 踩之间空大半屏**:BUG-930 钉顶留白(`min-height: 视口 − 本轮开头`)加在 `.message-assistant` 上,把兄弟节点 `.message-actions` 推到留白之后;改为加在整轮外层,按钮紧贴正文、空白落在后面;不动滚动 hook | 已验收(Claude 09-26 直接执行:子代理实现,Claude 独立复验 tsc/lint 0、全量 3961 条失败名单与基线逐条一致、四路由 ○、gzip 不变;CDP 实测间距 450–600px → 23px,钉顶仍在) | `080ea5ca`(已部署 `509987b9`,health 一致) |
| `TASK-starter-home-polish-20260926.md` | `PROGRESS-starter-home-polish-20260926.md` | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后。分支误判实为入口摘要解析读错键名(BUG-1041) | 已验收(Claude 09-26 直接执行:子代理实现并查出 BUG-1041 入口摘要驼峰/下划线字段不一致;Claude 改恢复提示文案为「可以在历史对话里接着做」;独立复验 tsc/lint 0、全量 3960 条失败名单与基线逐条一致、四路由 ○、gzip 不变) | `f04da103`(已部署 `62d4c9c4`,health 一致) |
@@ -0,0 +1,45 @@
# 真机清单 · 普通咨询回答被掐断(BUG-1051)· 2026-09-26
部署含 `codex/consult-answer-truncation-20260926` 的 staging 后照做。先确认 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 是这次部署的提交。
## 这次改了什么(给自己看的一句话)
以前「写回答」和「排盘计算」共用 110 秒,计算慢时回答写到一半就被掐断,却照样算完成、照样扣点。现在写回答自己有 70 秒;万一还是被掐,会明确提示没写完、不扣点。最长等待可能到 3 分钟左右。
## 1. 正常回答照常完成、照常扣点
1. 记下右上角点数。
2. 用已校验星盘的账号,普通对话问一个要算两三个方面的问题,例如「我明年的事业和财运怎么样」。
3. 等它写完,不要中途离开页面。
预期:
- 回答完整结束(最后一段是完整的句子,四个标题都在)。
- 输入框上方**没有**「回答未完成」的提示。
- 点数少 1。
- 从发送到写完,最慢也不超过约 3 分钟;中间活动区一直有进度,不会卡死没反应。
## 2. 后台核对(管理员账号)
1. 打开 `https://admin.staging.jyotisha.chat/admin/consultations`,找到刚才那一条(按时间)。
- 预期:状态 `completed`。
2. 打开 `/admin/usage`,找到同一时间的那一行,看「耗时」。
- 预期:有这一行,耗时不超过约 `180000 ms`(3 分钟)。以前常见的是 110000 ms 左右就结束。
## 3. 如果遇到「没写完」的回答,它应该长这样
这一条很难故意制造(要计算特别慢才会触发),平时用的时候留意即可。遇到时对照:
- 正文停在最后一个**完整的句子**上,不会停在半个词、半句话上。
- 输入框上方出现一行:「回答未完成,已保留现有内容;本次不会扣点。」
- 点数**不变**。
- 刷新页面后,那段没写完的回答还在。
- 后台 `/admin/consultations` 该条状态是 `cancelled`;`/admin/usage` 里**没有**这一条(没写完的不记用量)。
已知小问题(本次没改):没写完的回答,活动区标题仍可能写「已完成 N 步」。以输入框上方的提示为准。
## 4. 遇到异常时记录什么
- 发生时间(精确到分钟)、问的是哪类问题(事业 / 财富 / 婚恋 / 家庭 / 今日)。
- 截图:正文结尾、输入框上方的提示、点数。
- 不要截或发送出生资料和完整对话内容;开发方会按时间在日志里查 `composeFinishReason`、`composeAborted` 和耗时。