Compare commits

...
Author SHA1 Message Date
Claude Code 6732957beb docs(bugs): 补 BUG-939/940/941(脏思维链删词、正文夹带方法学记账、110s 闸门致空回答)
三条均为已定位未修复,修复决策统一收在 TASK-consult-three-channels-20260918。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PD5E87p38yFHcjZ6rnvXcz
2026-09-18 03:45:52 +00:00
Claude Code 7fe8ed003a docs(tasks): 咨询运行时三通道重做设计(进度/思考/正文生成时分离)
把「脏思维链、正文夹带方法学记账、empty_answer」归到同一个根:一个文本
通道当三个用,再用正则事后劈开。改为四 pass + 预算账本,思考通道发完整
条目而非 chunk,正文一次成文,正则只当门不当刀;附删除清单与质量回归口径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PD5E87p38yFHcjZ6rnvXcz
2026-09-18 03:41:15 +00:00
2 changed files with 149 additions and 0 deletions
+48
View File
@@ -12285,3 +12285,51 @@
- 相关记录:BUG-214、BUG-268、BUG-282、BUG-937
- 复发自:无
- 修复版本:`8b11ae7d`
## BUG-939 | 思维链过滤器在句内删词:≥4 字母英文词被挖掉,连词与孤儿标点留给用户
- 状态:open(已定位,未修复;设计见 `docs/tasks/TASK-consult-three-channels-20260918.md`)
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`frontend/src/lib/public-thinking.ts`、`stream-agent-response.ts` 的 `reasoning-delta → thinking.delta` 两处映射、`consultation-run-timeline.ts` 的 think 行。所有展示思考的咨询运行。
- 用户现象:staging run `1b81e263` 的思考区是词沙拉:`"这周哪些事最好先放一放" — are put .`、`"深入看今日" and the a .`、`统一参数 for 's - I can .`。产品负责人的原话是「思维链里的内容也很脏,导致用户在看正文时很迷惑」。
- 触发条件:模型的推理句是中英混排(引用了用户的中文问题 + 英文推理),即绝大多数句子。
- 根因:`sanitizeCompletedSentence()` 在句子通过 CJK 门之后,仍执行 `.replace(/[A-Za-z]{4,}/g, "")`——删掉所有 ≥4 字母的英文词,而 `are`/`put`/`the`/`for`/`I can` 这些三字母以内的连词全部留下;`releaseCompleted()` 又按 `buffer.search(CJK_RE)` 从第一个汉字处截断,留下句首的孤儿引号与破折号。BUG-612/613 的按句缓冲只解决了「在哪切」,没解决「切完还删词」。更上游的问题是:展示给用户的「思考」是从 provider 的 reasoning token 抓取再过滤得到的,而 provider CoT 本来就不是产品文案,任何过滤器都只能在「英文流水账」和「中文碎片」之间二选一。
- 修复:决策已锁(TASK 文档 P1/P2/P3)——provider reasoning 永不外发,只进服务端日志;展示用的思考改为模型有意写给用户的结构化条目(Pass 1 计划 + Pass 2 证据解读),一次发一条完整条目而非 chunk;`public-thinking.ts` 整个删除,`thinking.delta` 退出事件协议。条目过 zod,校验不过**整条丢弃**,不得改写其中的字。
- 验证:待实现。回归口径:思考面板每条必须成句、含 CJK、无孤儿标点;断言事件流里不存在任何 `reasoning-delta` 转发。
- 防复发:正则只能当门(接受/拒绝/重来),不能当刀(就地删改)。展示给用户的文本不得由「抓取模型内部输出 + 过滤」得到。
- 相关记录:BUG-612/613(按句缓冲,本条的上一轮修复)、BUG-277(同类:用缓冲实现「不该展示」)、BUG-940、BUG-941
- 复发自:BUG-612/613
- 修复版本:待实现
## BUG-940 | 本命正文首节是参数罗列、尾节是技法审计表:方法学记账被当成用户读物
- 状态:open(已定位,未修复;设计见 `docs/tasks/TASK-consult-three-channels-20260918.md`)
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`frontend/src/lib/consultation-thinking-plan.ts` 的 `REPORT_HEADING` 与 `consultationComposeHeadingGroups`、分段写作产出的正文。所有本命路线咨询。
- 用户现象:正文读起来像模型的思考过程。第一节标题「统一参数与原始结构」(岁差、上升、宫位罗列),倒数第二节「技法审计表」,用户反馈「感觉正文里也有思考的内容」。
- 触发条件:任何本命路线咨询。日签路线不受影响。
- 根因:`REPORT_HEADING = { foundation: "统一参数与原始结构", audit: "技法审计表", wrap: "现代生活" }` 把方法学记账直接排成用户可读章节;同文件的 `DAILY_HEADING`(今日趋势 / 适合推进 · 需要避开 / 一个行动)已经是干净口径,本命路线没跟上。审计与参数本来就是结构化数据(`workflowReceipt`、`techniqueAuditTable`),让模型把它们复述成散文既占正文篇幅又降低可读性。
- 修复:本命写作计划改成面向问题的章节;`统一参数与原始结构` 与 `技法审计表` 退出正文,改由折叠的证据面板直接渲染结构化回执。正文出现方法学记账一律由合规校验 pass 判不通过。
- 验证:待实现。回归口径:正文中方法学记账段落数 = 0;证据面板能从回执渲染出同等信息。
- 防复发:结构化数据不得让模型复述成散文;正文只承载结论。
- 相关记录:BUG-939、BUG-941
- 复发自:无
- 修复版本:待实现
## BUG-941 | 一个 110s 闸门被计算吃光,分段写作全部静默 abort,回执分不清「没写」与「被掐」
- 状态:open(已定位,未修复;设计见 `docs/tasks/TASK-consult-three-channels-20260918.md`)
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`frontend/src/app/api/consult/route.ts` 的 `agentAbortSignal` 与 `composeSection`、`stream-agent-response.ts` 的 `composeByHeadings` / `section-empty-retry`、`consultation-tools.ts` 的领域时长与写回答预留常数。所有多领域本命咨询。
- 用户现象:staging run `1b81e263`:计算成功(`tool.completed`,61710ms),随后 5 节写作全部空转,回执里 5 条 `section-empty-retry` + 1 条 `answer-retry`,最终 `run.failed / empty_answer`「计算已完成,但这次没有生成回答」。用户等满约 110 秒、不扣点、什么也没拿到,并反馈「生成速度还是很慢」。
- 触发条件:计算阶段耗时接近领域预算上限(本轮 2 领域 ≈31s/个),之后还要走 5 节分段写作。
- 根因:整轮只有一个 `AbortSignal.timeout(AGENT_TIMEOUT_MS = 110_000)`(`route.ts:898`),被首轮、工具、每一节写作、续写、重试共用;预算常数按 21s/领域算(`consultation-tools.ts:93`),实测 ≈31s,`domainFitsRunBudget` 的外推(31+31=62 ≤ 65)刚好放行第二个领域,计算结束时只剩约 40s,而分段写作还需要 ≥5 次完整模型往返。被 abort 的流不抛错、不产生 `error` 块(`stream-agent-response.ts:408` 若有 error 会记 `model-stream-error`,本轮回执一条都没有),因此每节表现为「模型什么都没写」,触发 `section-empty-retry` 与 `answer-retry`,最后归成 `empty_answer`。`CONSULTATION_ANSWER_RESERVE_MS = 45_000` 是给「一次成文」设计的,分段写作落地后没有重算。
- 修复:正文回到一次成文(取消 `composeSection` / `composeByHeadings` / `section-empty-retry`,进度粒度由思考通道提供);引入运行预算账本,每阶段开工前申请时间,不足即降级而非硬跑;每个 stream 用 `min(阶段预算, 剩余总预算)` 的独立 AbortSignal,**任何 abort 追加 `kind:"abort"` 运行步**;领域时长常数按实测重算。建议同批把 run 搬出 HTTP 请求(后台 job + 事件日志 + 断线重放),并把 evidence packet 随 job 落库供追问复用。
- 验证:待实现。回归口径:构造「计算占满预算」的假运行,断言降级路径产出正文且回执含 abort 步;断言任何已产出正文片段的运行不得以 `empty_answer` 结束。
- 防复发:一个 deadline 不得同时约束多个阶段;静默失败必须留痕——回执要能区分「模型没写」与「流被掐」。预算常数必须与它约束的流程同时更新。
- 相关记录:BUG-280、BUG-268(同为超时与空回答)、BUG-939、BUG-940
- 复发自:无
- 修复版本:待实现
@@ -0,0 +1,101 @@
# TASK · 咨询运行时重做:进度 / 思考 / 正文三通道在生成时分离(2026-09-18)
分支:`docs/three-channel-redesign-20260918`。基线 `origin/staging` = `5a1dcbd2`。
目标:**干净的思考内容可展示、进度可见、正文只剩结论,三者互不混流,且回答质量不下降。**
代价不设限(可以多跑模型轮次、可以落库、可以把运行搬出 HTTP 请求),但不得靠事后正则修补。
## 0. 病根一句话
产品把**一个文本通道**当三个用,然后在下游用正则把它们劈开。凡是「先混流、再过滤」的设计,过滤器迟早在错误的时刻删掉正确的东西——这正是现在同时产生「脏思维链」「正文里有思考」「空回答」的同一个根。
实证(`origin/staging` 行号,会漂):
- `frontend/src/lib/public-thinking.ts:27-33`:一句话通过 CJK 门之后,还要 `.replace(/[A-Za-z]{4,}/g, "")` 删掉所有 ≥4 字母的英文词。模型原句 `The user asked "这周哪些事最好先放一放" — are put off.` 先被 `buffer.slice(cjk)` 从第一个汉字截断(留下孤儿引号和破折号),再被删词(`asked`/`user` 没了,`are`/`put`/`the` 三字母以内全留),输出 `"这周哪些事最好先放一放" — are put .`。**不是模型乱,是过滤器把信息量最大的词删了、把连词留下了。**
- `frontend/src/lib/consultation-thinking-plan.ts:12-16`:本命正文章节 = `统一参数与原始结构` + 各领域 + `技法审计表` + `现代生活`。正文第一节就是岁差/上升/宫位罗列,倒数第二节是技法审计表——方法学记账被当成用户读物。同文件 `DAILY_HEADING` 已经是干净口径,本命路线没跟上。
- `frontend/src/app/api/consult/route.ts:898`:整轮只有一个 `AbortSignal.timeout(110_000)`,被首轮、工具、每一节分段写作、续写、重试**共用**。2026-09-18 的 run `1b81e263`:工具 61.7s(2 领域 ≈31s/个,而预算常数 `consultation-tools.ts:93` 写的是 21s),随后 5 节写作全部无 thinking、无 text、无 error chunk(`stream-agent-response.ts:408` 会把 error 记成 `model-stream-error`,回执里一条都没有)——**沉默流 = 被掐断的流**,于是 5 次 `section-empty-retry` + 1 次 `answer-retry` → `empty_answer`,用户等 110 秒拿到一句「本次没有生成回答」。
## 1. 四条设计原则
- **P1 三通道在生成时就分开。** 进度是服务端确定性事件;思考是模型**有意写给用户**的结构化条目;正文只有结论散文。任何通道都不靠下游切分得到。
- **P2 provider 的 reasoning token 永不外发。** `thinking: "enabled"` 保留(它提升质量),但 `reasoning-delta` 只进服务端日志(截断 + 有保留期),不进事件流、不进数据库可见字段、不进 UI。展示给用户的「思考」是产品产物,不是 CoT 抓取。
- **P3 正则只能当门,不能当刀。** 用正则判断「接受 / 拒绝 / 重来」可以;用正则**就地删词、挖空、替换半句**一律删除。这条同时判掉思维链的 `[A-Za-z]{4,}` 删词和正文的 `[具体时间已省略]` 就地替换。
- **P4 时钟是预算,不是闸刀。** 每个阶段开工前向账本要时间;要不到就降级(少领域、少条目、单段成文),不允许静默 abort。任何 abort 必须在回执里留痕。
## 2. 新运行时:四个 pass + 一个预算账本
```
Pass 0 服务端确定性 绑方法、定路由、定主题(入口钉死) 无模型
Pass 1 读题与取证计划 结构化 JSON:reading + steps[] + domains[] 模型
→ 计算开始前就把「思考」面板填满,用户在 60s 计算期有东西看
计算 逐领域执行 确定性进度事件 progress{current,total} 无模型
→ Pass 1 的 step 状态 pending → running → done
Pass 2 证据解读 每个 step 产出 {stepId, finding, refs, confidence} 模型
→ 这就是用户看到的「思考内容」:中文、成句、可校验;同时是写作输入
Pass 3 成文 只写结论散文,输入 = 问题 + 证据契约 + findings 模型
Pass 4 合规校验 检测越权断言;不通过则指名违规句退回 Pass 3 重写 模型/确定性
```
要点:
1. **思考通道不再是碎片流。** `think.step` 一次发一条**完整条目**,不发 chunk。没有 chunk 就没有「按 chunk 过滤」这回事,`public-thinking.ts` 整个消失。要打字机效果,前端对完整条目做动画,不要靠网络分片。
2. **正文一次成文。** 取消 `composeSection` 的逐节往返(`route.ts:1185-1203`):大输出预算 + 只有 `finishReason=length` 才续写。进度粒度由 think 通道提供,不再需要用分段来制造「正在写 X」。
3. **Pass 2 是 grounding 步骤,不是装饰。** 强制模型先把证据显式转成带出处的结论候选,再写散文——这通常**提升**质量(少空话、少幻觉),也让「思考内容」天然可读。
4. **诚实性红线:** Pass 2 是模型为用户重述的推理依据,**不等于** provider 内部 CoT。UI 文案只能叫「推理过程 / 判断依据」,不得宣称是模型的真实思考。
### 事件协议 v2
```jsonc
run.started {runId, requestId}
phase.started {phase, label, progress?: {current, total}} // 确定性,永不含模型文本
phase.completed {phase, durationMs}
think.plan {steps: [{id, title}]} // Pass 1
think.step {id, status: "running"|"done", text?} // Pass 2,一条完整条目
answer.delta {text} // Pass 3,只有散文
run.completed {receipt} | run.failed {code, receipt}
```
- `thinking.delta` 从协议里**删除**。`thinking.section` 由 `think.plan` + `think.step` 取代。
- 每条 `think.step.text` 过 zod(长度、必须成句、必须含 CJK);**校验不过就丢掉这一条并按计划标签兜底,绝不改写这条的字**。这是 P3 的落点。
- 客户端三个区域各认各的事件:进度条认 `phase.*`,思考面板认 `think.*`,正文认 `answer.*`。现有 `ConsultationRunTimeline` 的 method/calculate/think/write 四类行可以直接接 v2,不用重画。
## 3. 删除清单(不是改造,是删掉)
1. `frontend/src/lib/public-thinking.ts` —— 整个文件。
2. `frontend/src/lib/stream-agent-response.ts` 的两处 `reasoning-delta → thinking.delta` 映射(约 277、431 行)。reasoning 改为只写服务端日志。
3. `frontend/src/lib/consultation-agent-events.ts` 的 `thinkingDeltaSchema` 与 `thinkingSectionEventSchema`(v2 用新事件)。
4. `frontend/src/lib/consultation-run-timeline.ts` 里 `thinking.delta` 的兜底分支(约 107 行)。
5. `frontend/src/lib/timing-output-guard.ts` 的**就地替换**:`exactTimingPatterns` → `[具体时间已省略]`、`guaranteeConclusionPatterns` → `[保证性结论已省略]`、`guardGeneralNoBirthTimeOutput` 的整句替换。正则本身**保留为检测器**供 Pass 4 判定,但不得再写回文本。
6. `route.ts` 的 `composeSection` / `stream-agent-response.ts` 的 `composeByHeadings` + `section-empty-retry`(一次成文后它们没有存在理由)。
## 4. 正文长什么样
- 章节改成面向问题的口径(照 `DAILY_HEADING` 的形状重做本命计划),例如:`先回答你的问题` / `盘里支持这个判断的地方` / `时间怎么看` / `这周可以做的一件事`。
- `统一参数与原始结构`、`技法审计表` 退出正文,改由**证据面板**渲染——数据本来就是结构化的(`workflowReceipt`、`techniqueAuditTable`),不需要模型复述成散文。面板默认折叠。
- 正文里出现 `##统一参数`、技法审计行、领域 id、英文术语堆砌,一律算 Pass 4 不通过。
## 5. 时钟与预算(治 `empty_answer`,顺带让「慢」变成可见进度)
- 一个 run 一本**预算账本**:`{总预算, 已用, 每阶段预留}`。每个阶段开工前申请,不足就降级而不是硬跑。
- 每个 stream 自己的 AbortSignal = `min(阶段预算, 剩余总预算)`;**任何 abort 追加一条 `kind:"abort"` 运行步**,回执必须能区分「模型没写」和「流被掐」——现在两者长得一模一样,这是这次查不出来的直接原因。
- 领域时长常数按实测重算(当前实测 ≈31s/领域,常数写的 21s),上限随之下调;`CONSULTATION_ANSWER_RESERVE_MS` 是给「一次成文」设计的,一次成文回归后它才重新成立。
- 彻底版(建议本单一起做):**把 run 从 HTTP 请求里搬出来**——后台 job + 事件日志表 + 客户端按 `runId` 断线重放。函数 120s 上限不再是产品上限;同时 evidence packet 随 job 落库,**追问直接复用**,不必重跑 60s 计算(这也顺手修掉声明窗口路线「existing packet 根本不存在」的那条自相矛盾)。
## 6. 怎么证明「质量没下降」
改前改后各跑同一组 20 条真实问题(覆盖本命 / 声明窗口 / 无分钟 / 今日入口),记录:
| 口径 | 方法 | 要求 |
|---|---|---|
| 结论具体性、证据引用、可读性 | 人工双盲打分 | 不低于基线 |
| 越权断言条数 | Pass 4 检测器统计 | ≤ 基线,且**改写次数为 0**(现在是每次都在改写) |
| 正文里方法学记账段落数 | 关键词扫描 | 0 |
| 思考面板可读性 | 人工 | 每条成句、中文、无孤儿标点 |
| 首字延迟 / 总时长 / 失败率 | 埋点 | 失败率下降;总时长不劣于基线 |
## 7. 硬红线
1. 不得再出现「先混流再过滤」的新代码。新增正则必须能回答「它是门还是刀」,是刀就不许进。
2. `tsc --noEmit` 0 错、`npm run lint` 0 error;测试总数不得低于基线。删掉的 `public-thinking` 相关断言必须被 v2 的「校验不过整条丢弃」断言替代,不能净减。
3. provider reasoning 不得出现在任何对外事件、数据库可见字段或日志的完整原文里(日志只留截断 + 保留期)。
4. 事件协议 v2 与 v1 并行一个发布周期,客户端两者都能渲染;回执 schema 仍是 strict,新增字段走白名单。
5. 任何一次 run 只要已经产出过正文片段,就不得以 `empty_answer` 结束。