Files
Jyotisha/docs/tasks/TASK-consult-three-channels-20260918.md
T

106 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 咨询运行时重做:进度 / 思考 / 正文三通道在生成时分离(2026-09-18)
> 落地分支:codex/consult-three-channels-20260918,基线 origin/staging @ 742ffbc6。
> 设计稿把三则产品 bug 写成 BUG-939/940/941,与 staging 上门禁合同编号冲突,实现改号 **BUG-942(删词)/ BUG-943(正文记账)/ BUG-944(110s 闸刀)**。
分支:`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` 结束。