Explorar o código

fix(chat): 修「回答内容重复」并按 卡片 → 正文 → 参考资料 排版

用户症状:同一段回答内容在界面上出现两次;并要求版式为「先卡片,再正文,最后参考资料」。

定位(用用户提供的真实 SSE 抓包回放整条链路,不靠猜):
- 这份载荷里 answer_end.text(1610 字)比 concat(deltas)(1309 字)多 301 字(尾部是追问建议)。
  这推翻了此前「concat(deltas) === answer_end.text」的结论 —— 那是三份样本归纳出来的,
  第四份就不成立,不能当成后端保证。
- 后果:应用侧记录的「流式正文」停在 1309 字,重排时上屏内容不再以它为后缀 →
  落兜底分支(matched=false)→ 基线取整段上屏内容、正文再插一份 →
  正文上屏两份、卡片被挤到两份正文中间。这同时解释了上一轮未定论的
  「内容突然消失变成另一段」(整块重写让渲染层走全量重置)。

修法(渲染层零改动):
- answer-stream.ts 新增 resolveStreamedBodyAfterEnd():answer_end 之后把应用侧记录的流式正文
  同步成最终全文(三种收尾的结果都以最终文本结尾,为空才保留原值)。hook 的 end 监听里调一次,
  重排即回到正常路径。
- 把重排抽成纯函数 composeAroundStreamBody()(原来写在 hook 里、没法单测)并加固兜底:
  后缀匹配失败时不再重复插入正文,改为以正文最后一次出现为锚点切开重排;确实找不到才补插。

验证:
- 真抓包回放:修前 4 项失败(正文出现 2 次、卡片在正文之后、matched=false)→ 修后 13/13,
  上屏结构 卡片@5 → 正文@4687 → 参考资料@6299
- verify-answer-stream 103/103(新增【3c】纯函数断言 +【12】协调器假流+应用层组装,
  用合成载荷钉住这条回归;已实测把修复阉掉时【12】会失败)
- 回归全绿(适配层直通 27/27、多实例 11/11、判空口径 30/30、聊天任务 33/33);
  npm run build 与 validate-harness 通过
- 新增 verify-answer-stream-payload.mjs + 合成夹具 fixtures/answer-stream-tail.payload.md
  (真抓包 600KB 不入库,按用户要求已删除)

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao hai 8 horas
pai
achega
ee475a69c3

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 2 - 2
harness/feature_list.json


+ 40 - 0
harness/progress.md

@@ -3082,3 +3082,43 @@ abort 对不上时不动);`npm run build` 通过。
 **⚠️ 仍未定论**:真实触发原因需要用户复现时看控制台日志(哪几条 `[answer-stream]` / `[record]`
 出现、顺序与长度)。**待用户提供**后即可定位并改正(若确为 `answer_abort(regenerating)`,
 那是契约规定的"撤草稿等重生成",届时需要与用户确认观感怎么处理)。
+
+### 续 5:定位并修掉「同一段内容出现两次」(用户提供真抓包,续 4 的待定论项结案)
+
+**用户报告**:回答内容出现重复,并要求版式为 **卡片 → 正文 → 参考资料**。
+**用户提供**:`/api/chat` 的原始 SSE 抓包(802 个事件,含 771 片 delta)。
+
+**定位(用抓包回放整条链路,不靠猜)**:
+- 这份载荷里 **`answer_end.text`(1610 字)比 `concat(deltas)`(1309 字)多 301 字**——
+  尾部是追问建议。**这推翻了续 4 里「三份载荷实测 concat(deltas) === answer_end.text」的结论**:
+  那个等式**不是**后端保证的,第四份载荷就不成立(教训:拿样本归纳出的等式要写明样本量)
+- 应用侧 `stream.body` 停在 1309 字 → 到 `answerStreamCompose` 重排时,上屏内容
+  (`… + 1610 字全文`)**不再以它为后缀** → 落兜底分支(`matched=false`)→
+  基线取整段上屏内容、正文再插一份 → **正文上屏两份,卡片被挤到两份正文中间**
+- 复现证据(`verify-answer-stream-payload.mjs` 加断言前先跑):
+  `正文中段片段出现 2 次`、`卡片@1617 正文@0`、`compose(matched=false)`
+- 一并解释了两个现象:①用户看到的「重复」;②续 4 里未能定论的
+  「内容突然消失变成另一段」——整块重写让渲染层 `[record]` 走全量重置路径
+
+**修法(纯逻辑层 + 应用层各一处,渲染层零改动)**:
+1. **`answer-stream.ts` 新增 `resolveStreamedBodyAfterEnd()`**:`answer_end` 之后把
+   「应用侧记录的流式正文」同步成**最终全文**。三种收尾(尾缀追加/整块重建/对不上兜底)
+   的结果都以 `finalText` 结尾,所以无条件取它;为空才保留原值(防清空丢正文)。
+   hook 的 `answerStreamEnd` 监听里调一次 → 重排回到**正常路径**
+2. **`composeAroundStreamBody()` 抽成纯函数并加固兜底**(原来这段逻辑写在 hook 里、没法单测):
+   后缀匹配失败时**不再把正文再插一份**——正文多半已在上屏内容里(`answer_end` 追加尾巴后就长这样),
+   改为以它的**最后一次出现**为锚点切开重排(锚点之前当基线、锚点之后夹在正文与参考资料之间);
+   确实找不到才补插(不丢字优先)。调用方按 `matched` 告警
+
+**验证**(证据见 `feature_list.json` 的 `answer-streaming`):
+- 真抓包回放:修前 4 项失败(正文出现 2 次、卡片在正文之后、`matched=false`)→
+  修后 **13/13 通过**,上屏结构 `@5 政策卡片 → @4687 正文(1610字) → @6299 参考资料`
+- `verify-answer-stream.mjs`:**103/103 通过**(新增【3c】纯函数断言 + **【12】协调器假流 + 应用层组装**,
+  用**合成载荷**钉住这条回归;已实测把修复「阉掉」时【12】会失败,是真守卫)
+- 新增 `harness/tools/verify-answer-stream-payload.mjs` + 合成夹具
+  `harness/tools/fixtures/answer-stream-tail.payload.md`:回放真抓包用(真抓包 600KB 不入库,
+  用户已确认删除;合成夹具同形态、小到可入库,让回归随时可回放)
+- `npm run build` 通过;`node harness/tools/validate-harness.mjs` 通过(exit 0)
+
+**⚠️ 待浏览器确认**:修后一轮完整问答应为「卡片 → 正文 → 参考资料」,正文只出现一次。
+若仍看到重复,请把控制台的 `[answer-stream] compose` 一行发来(`matched` 是否为 true、三个长度)。

+ 9 - 0
harness/tools/README.md

@@ -73,6 +73,15 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | `_company-classify.mjs` | `verify-company-classify-e2e.mjs` | ✅ 真实分类接口 + 真实 DMS(要一份**真实形态**的 company_info JSON,见脚本头注释) |
 | `_coordinator.mjs` | `verify-company-info-passthrough.mjs` | ❌ 打桩 fetch 喂假 SSE 流 |
 | `_coordinator.mjs` | `verify-answer-stream.mjs` | ❌ 纯逻辑(增量规整器/状态机)+ 假 SSE 喂真实协调器(F073 正文流式) |
+| `_coordinator.mjs` | `verify-answer-stream-payload.mjs` | ❌ 回放**真实 SSE 抓包**(协调器 + 应用侧组装);默认用 `fixtures/answer-stream-tail.payload.md` |
+
+### 载荷夹具 `fixtures/`
+
+`fixtures/answer-stream-tail.payload.md` 是一份**合成**的原始 SSE 载荷,形态取自真抓包:
+`answer_end` 给出的文本**比增量拼接多一截尾巴**(真载荷 1309 → 1610 字,尾部是追问建议)。
+这个形态会让应用侧记录的「流式正文」不再是上屏内容的后缀 —— 2026-09-20 用户报的
+「同一段内容出现两次」就是它。夹具入库是为了让这条回归**随时可回放**;
+真抓包(600KB 量级)不入库,需要时把浏览器存的响应文件作为参数传进去即可。
 
 文档生成与审计(不是断言脚本,但也打真实 DMS 读模型定义):
 

+ 225 - 0
harness/tools/fixtures/answer-stream-tail.payload.md

@@ -0,0 +1,225 @@
+event: accepted
+data: {"thread_id":"t","request_id":"r","data":{"message":"收到"}}
+
+event: progress
+data: {"thread_id":"t","request_id":"r","data":{"stage":"retrieve","status":"running","message":"正在查阅……"}}
+
+event: answer_start
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":0}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":1,"text":"您"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"想"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":3,"text":"开"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":4,"text":"奶"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":5,"text":"茶"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":6,"text":"店"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":7,"text":","}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":8,"text":"我"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":9,"text":"们"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":10,"text":"青"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":11,"text":"浦"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":12,"text":"目"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":13,"text":"前"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":14,"text":"没"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":15,"text":"有"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":16,"text":"奶"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":17,"text":"茶"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":18,"text":"店"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":19,"text":"或"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":20,"text":"餐"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":21,"text":"饮"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":22,"text":"行"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":23,"text":"业"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":24,"text":"的"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":25,"text":"专"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":26,"text":"项"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":27,"text":"扶"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":28,"text":"持"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":29,"text":"。"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":30,"text":"\n"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":31,"text":"\n"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":32,"text":"#"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":33,"text":"#"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":34,"text":"#"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":35,"text":" "}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":36,"text":"首"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":37,"text":"次"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":38,"text":"创"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":39,"text":"业"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":40,"text":"一"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":41,"text":"次"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":42,"text":"性"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":43,"text":"补"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":44,"text":"贴"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":45,"text":"\n"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":46,"text":"\n"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":47,"text":"-"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":48,"text":" "}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":49,"text":"补"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":50,"text":"贴"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":51,"text":"标"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":52,"text":"准"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":53,"text":"为"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":54,"text":"*"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":55,"text":"*"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":56,"text":"一"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":57,"text":"次"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":58,"text":"性"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":59,"text":"8"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":60,"text":"0"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":61,"text":"0"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":62,"text":"0"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":63,"text":"元"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":64,"text":"*"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":65,"text":"*"}}
+
+event: answer_delta
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"。"}}
+
+event: source
+data: {"thread_id":"t","request_id":"r","data":{"id":"s1","title":"首次创业一次性补贴","type":"policy","source":{"url":"https://example.com/p"}}}
+
+event: item
+data: {"thread_id":"t","request_id":"r","data":{"source_id":"s1","title":"首次创业一次性补贴","card":{"name":{"text":"首次创业一次性补贴"}}}}
+
+event: answer_end
+data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","operation":"replace","status":"completed"}}
+
+event: summary
+data: {"thread_id":"t","request_id":"r","data":{"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","source_ids":["s1"],"notices":[]}}
+
+event: result
+data: {"thread_id":"t","request_id":"r","data":{"response":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","recommendation":{},"errors":[]}}
+
+event: done
+data: {"thread_id":"t","request_id":"r","data":{"status":"completed"}}
+

+ 259 - 0
harness/tools/verify-answer-stream-payload.mjs

@@ -0,0 +1,259 @@
+/**
+ * 用**真实 SSE 载荷**回放 F073 正文流式的整条链路(协调器 + 应用侧组装),
+ * 断言「正文只出现一次」且版式为「卡片 → 正文 → 参考资料」。
+ *
+ * 为什么要有这个脚本:`verify-answer-stream.mjs` 用的是**理想化**的窄用例(delta 拼接 == 最终全文),
+ * 而真后端经常在 `answer_end` 里给出**比增量拼接更长**的文本(2026-09-20 那份载荷:1309 → 1610 字,
+ * 末尾补了追问建议)。这条路径上「应用侧记录的流式正文」会与上屏内容脱节 → 重排时按兜底分支处理
+ * → **正文上屏两份、卡片被挤到两份正文中间**。用户从截图报告,用这个脚本复现并定位。
+ *
+ * 那条路径的回归现已用**合成载荷**钉在 `verify-answer-stream.mjs` 的【12】(不依赖外部文件);
+ * 本脚本的价值是**回放真抓包**——真载荷里 delta 片数、heartbeat 夹带、卡片/参考资料事件序
+ * 都是合成的近似,出问题时先跑它,能直接看出是哪一段与预期不符。
+ *
+ * 怎么跑(先按 `_entry-coordinator.ts` 头部注释重新打包 `_coordinator.mjs`):
+ *   node harness/tools/verify-answer-stream-payload.mjs                          # 用下面的合成夹具
+ *   node harness/tools/verify-answer-stream-payload.mjs <原始SSE文件>            # 回放真抓包
+ * 载荷怎么来:浏览器 Network 面板把 `/api/chat` 的响应另存为文件(保持 `event: / data: ` 原样)。
+ * ⚠️ 真抓包不入库(单份 600KB 量级):想回放真载荷就显式传路径。
+ */
+
+import { readFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+/** 默认夹具:与真抓包**同形态**(answer_end 比增量拼接多一截尾巴)的合成载荷,小到可以入库 */
+const DEFAULT_FIXTURE = join(here, 'fixtures', 'answer-stream-tail.payload.md');
+const payloadPath = process.argv[2] || DEFAULT_FIXTURE;
+
+const {
+  ApiChatCoordinator,
+  applyAnswerStreamEnd,
+  composeAroundStreamBody,
+  resolveStreamedBodyAfterEnd,
+  normalizeChatMarkdown,
+} = await import('./_coordinator.mjs');
+
+let failed = 0;
+const check = (label, ok, detail) => {
+  console.log(`${ok ? '  ✅' : '  ❌'} ${label}`);
+  if (!ok) {
+    failed++;
+    if (detail !== undefined) {
+      const text = typeof detail === 'string' ? detail : JSON.stringify(detail);
+      console.log(`      实际:${String(text).slice(0, 240)}`);
+    }
+  }
+};
+
+if (!existsSync(payloadPath)) {
+  console.error(`载荷文件不存在:${payloadPath}`);
+  process.exit(2);
+}
+const raw = readFileSync(payloadPath, 'utf8');
+
+/* ------------------------------------------------------------------ *
+ * ① 载荷统计:先把「增量拼接 vs answer_end 全文」这条关键差异摆出来
+ * ------------------------------------------------------------------ */
+const parseEvents = (text) =>
+  text
+    .split(/\r?\n\r?\n+/)
+    .map((block) => block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/))
+    .filter(Boolean)
+    .map((m) => {
+      let data = {};
+      try {
+        data = JSON.parse(m[2]).data || {};
+      } catch {
+        /* 坏帧忽略 */
+      }
+      return { name: m[1], data };
+    });
+
+const events = parseEvents(raw);
+const counts = {};
+for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1;
+const joined = events
+  .filter((e) => e.name === 'answer_delta')
+  .map((e) => e.data.text || '')
+  .join('');
+const endEvent = events.find((e) => e.name === 'answer_end');
+const endText = endEvent?.data.text || '';
+const normalizedFinal = normalizeChatMarkdown(endText);
+
+console.log(`载荷:${payloadPath}`);
+console.log('事件计数:', JSON.stringify(counts));
+console.log(
+  `增量拼接 ${joined.length} 字 / answer_end ${endText.length} 字 / 规整后 ${normalizedFinal.length} 字` +
+    `(末尾额外 ${endText.length - joined.length} 字)`
+);
+
+/* ------------------------------------------------------------------ *
+ * ② 回放:真实协调器 + 应用侧监听器(与 useBusinessAssistantChat 同逻辑)
+ * ------------------------------------------------------------------ */
+globalThis.fetch = async () =>
+  new Response(
+    new ReadableStream({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode(raw));
+        controller.close();
+      },
+    }),
+    { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
+  );
+
+const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'replay' });
+
+/** 应用侧的 per-task 流状态(对应 useBusinessAssistantChat 里的 `stream`) */
+const stream = { active: false, streamId: '', body: '' };
+/** 应用侧的消息内容(对应 ai.content) */
+let content = '';
+const channelOrder = [];
+
+coordinator.addEventListener('answerStreamStart', (p) => {
+  stream.streamId = String(p?.streamId || '');
+  stream.body = '';
+  stream.active = true;
+  channelOrder.push('streamStart');
+});
+
+// 增量走 message 通道上屏
+coordinator.addEventListener('message', (msg) => {
+  if (stream.active) stream.body += msg;
+  content += msg;
+  channelOrder.push(`message:${msg.slice(0, 16)}`);
+});
+
+coordinator.addEventListener('answerStreamEnd', (p) => {
+  if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
+  const before = content;
+  const finalText = String(p?.text ?? '');
+  const next = applyAnswerStreamEnd(before, stream.body, finalText);
+  if (next !== before) content = next;
+  stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
+  stream.active = false;
+  channelOrder.push('streamEnd');
+});
+
+coordinator.addEventListener('answerStreamCompose', (p) => {
+  const before = content;
+  const body = stream.body;
+  const { next, matched } = composeAroundStreamBody(
+    before,
+    body,
+    String(p?.leading || ''),
+    String(p?.trailing || '')
+  );
+  content = next;
+  channelOrder.push(`compose(matched=${matched})`);
+});
+
+/** 载荷里有没有 `done`(没有 = 抓包不完整,协调器走断流兼容路径,不发 totalResponse) */
+const hasDone = events.some((e) => e.name === 'done');
+const errors = [];
+
+const total = await new Promise((resolve) => {
+  coordinator.addEventListener('totalResponse', (p) => resolve(p));
+  coordinator.addEventListener('error', (p) => errors.push(p));
+  void coordinator.generateAnswer('回放');
+  setTimeout(() => resolve(null), hasDone ? 15000 : 500);
+});
+await new Promise((r) => setTimeout(r, 200));
+if (errors.length) {
+  console.log(`协调器 error:${errors.map((e) => e.code).join(',')}(载荷无 done 时属预期)`);
+}
+
+/* ------------------------------------------------------------------ *
+ * ③ 断言
+ * ------------------------------------------------------------------ */
+console.log('\n【1】正文只出现一次');
+// 用正文里一段**有辨识度**的中段做计数(取规整后全文的 1/3 处 40 字)
+const probeStart = Math.floor(normalizedFinal.length / 3);
+const probe = normalizedFinal.slice(probeStart, probeStart + 40).trim();
+const bodyCount = probe ? content.split(probe).length - 1 : 0;
+check(`正文中段片段在最终内容里出现 1 次(片段 ${JSON.stringify(probe.slice(0, 20))})`, bodyCount === 1, `出现 ${bodyCount} 次`);
+
+const headCount = content.split(normalizedFinal.slice(0, 20)).length - 1;
+check(`正文开头 20 字出现 1 次`, headCount === 1, `出现 ${headCount} 次`);
+check(
+  `正文全文出现 1 次`,
+  normalizedFinal ? content.split(normalizedFinal).length - 1 === 1 : true,
+  `出现 ${normalizedFinal ? content.split(normalizedFinal).length - 1 : 0} 次`
+);
+
+console.log('\n【2】版式顺序:卡片 → 正文 → 参考资料');
+const atCard = content.indexOf('POLICY_TABLE');
+const atBody = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1;
+const atRefs = content.indexOf('<ref_links>');
+check('含政策卡片标记', atCard >= 0);
+check('含参考资料标记', atRefs >= 0);
+check(
+  '卡片在正文之前',
+  atCard >= 0 && atBody >= 0 && atCard < atBody,
+  `卡片@${atCard} 正文@${atBody}`
+);
+check('正文在参考资料之前', atBody >= 0 && atRefs >= 0 && atBody < atRefs, `正文@${atBody} 参考资料@${atRefs}`);
+
+// 人眼可读的上屏结构:按出现位置列出各内容块,便于和截图对照
+{
+  const marks = [
+    ['思考中卡片 <scope>', '<scope'],
+    ['政策卡片 POLICY_TABLE', 'POLICY_TABLE'],
+    ['补问卡片 <question-cards>', '<question-cards>'],
+    ['企业微信二维码 <cipa', '<cipa'],
+    ['参考资料 <ref_links>', '<ref_links>'],
+  ]
+    .map(([label, needle]) => ({ label, at: content.indexOf(needle) }))
+    .filter((m) => m.at >= 0)
+    .sort((a, b) => a.at - b.at);
+  const bodyAt = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1;
+  const rows = [...marks, ...(bodyAt >= 0 ? [{ label: `正文(${normalizedFinal.length} 字)`, at: bodyAt }] : [])].sort(
+    (a, b) => a.at - b.at
+  );
+  console.log('  上屏结构(按位置):');
+  for (const r of rows) console.log(`    @${String(r.at).padStart(6)}  ${r.label}`);
+  console.log(`  上屏内容总长:${content.length} 字`);
+}
+
+console.log('\n【3】收尾链路');
+check('收到 answerStreamCompose', channelOrder.some((x) => x.startsWith('compose')), channelOrder.at(-1));
+check(
+  '重排时后缀匹配成立(不落兜底分支)',
+  channelOrder.some((x) => x === 'compose(matched=true)'),
+  channelOrder.filter((x) => x.startsWith('compose')).join(',')
+);
+if (hasDone) {
+  check('totalResponse 里正文出现 1 次', !!total && !!normalizedFinal && String(total.answer || '').split(normalizedFinal).length - 1 === 1, total ? `出现 ${String(total.answer || '').split(normalizedFinal).length - 1} 次` : '未收到 totalResponse');
+} else {
+  console.log('  ⏭️  载荷没有 done 事件(走断流兼容路径,不发 totalResponse)——跳过该断言');
+}
+console.log('\n【4】兜底路径回归:流式正文没同步到上屏长度(= 本轮修的 bug)也不能重复');
+{
+  // 直接调纯函数,喂「上屏内容 = 完整正文,记录的正文 = 短一截的前缀」这种错位状态
+  const staleBody = joined ? normalizeChatMarkdown(joined) : '';
+  const { next, matched } = composeAroundStreamBody(
+    normalizedFinal,
+    staleBody,
+    '<POLICY_TABLE>卡片</POLICY_TABLE>',
+    '<ref_links>参考</ref_links>'
+  );
+  const dup = staleBody ? next.split(staleBody).length - 1 : 0;
+  const head = normalizedFinal.slice(0, 20);
+  check('后缀不匹配时落兜底分支(matched=false)', matched === false, `matched=${matched}`);
+  check('兜底分支里正文仍只出现 1 次(不重复插入)', dup === 1, `出现 ${dup} 次`);
+  check('兜底分支里正文没被丢掉', next.includes(head));
+  check(
+    '兜底分支里卡片仍在正文之前',
+    next.indexOf('POLICY_TABLE') < next.indexOf(head),
+    `卡片@${next.indexOf('POLICY_TABLE')} 正文@${next.indexOf(head)}`
+  );
+  check(
+    '兜底分支里参考资料仍在正文之后',
+    next.indexOf('<ref_links>') > next.indexOf(head),
+    `正文@${next.indexOf(head)} 参考@${next.indexOf('<ref_links>')}`
+  );
+}
+
+console.log(`\n${failed === 0 ? '✅ 全部通过' : `❌ ${failed} 项未通过`}`);
+process.exit(failed === 0 ? 0 : 1);

+ 114 - 0
harness/tools/verify-answer-stream.mjs

@@ -21,10 +21,12 @@ import {
   applyAnswerStreamEnd,
   applyAnswerStreamEvent,
   createAnswerStreamState,
+  composeAroundStreamBody,
   composeStreamedContent,
   createIncrementalMarkdownNormalizer,
   normalizeChatMarkdown,
   parseAnswerStreamEvent,
+  resolveStreamedBodyAfterEnd,
   shouldKeepAnswerOverview,
 } from './_coordinator.mjs';
 
@@ -342,6 +344,52 @@ console.log('\n【3b】应用层的内容对齐(end 替换 / abort 撤稿)')
   check('空块被跳过、不产生多余空行', composeStreamedContent('', '', '正文', '') === '正文' && composeStreamedContent('', '', '', '尾') === '尾');
 }
 
+console.log('\n【3c】end 之后同步「流式正文」+ 重排(正文上屏两份的 bug,见 verify-answer-stream-payload)');
+{
+  // 真后端形态:answer_end 的文本比增量拼接**长**(实测 1309 → 1610,末尾补了追问建议)。
+  // 不同步 stream.body 的话,重排时后缀匹配失败 → 卡片排在正文后、正文上屏两份。
+  const DELTA = '正文前半';
+  const FINAL = '正文前半;正文后半(answer_end 才到的尾巴)';
+  check('end 之后流式正文更新为最终全文', resolveStreamedBodyAfterEnd(DELTA, FINAL) === FINAL);
+  check('最终正文为空 → 保持原值(不能清空而丢正文)', resolveStreamedBodyAfterEnd(DELTA, '') === DELTA);
+  check('最终正文为空且原值也空 → 空串', resolveStreamedBodyAfterEnd('', '') === '');
+  check(
+    '端到端形态:end 同步后重排 → 卡片在正文之前、正文只出现一次',
+    (() => {
+      const onScreen = applyAnswerStreamEnd('', DELTA, FINAL); // end 把尾巴接上
+      const body = resolveStreamedBodyAfterEnd(DELTA, FINAL); // hook 同步流式正文
+      const { next, matched } = composeAroundStreamBody(onScreen, body, '【卡片】', '【参考】');
+      return matched === true && next === '【卡片】\n\n' + FINAL + '\n\n【参考】' && next.split(FINAL).length - 1 === 1;
+    })()
+  );
+  // 兜底:流式正文与上屏内容错位(本 bug 的原状)——**绝不能把正文再插一份**
+  check(
+    '兜底:正文不在末尾但在上屏内容里 → 以它为锚点重排,不重复',
+    (() => {
+      const { next, matched } = composeAroundStreamBody(FINAL, DELTA, '【卡片】', '【参考】');
+      return matched === false && next.split(DELTA).length - 1 === 1 && next.startsWith('【卡片】') && next.endsWith('【参考】');
+    })()
+  );
+  check(
+    '兜底:锚点之后的内容(answer_end 的尾巴)留在正文与参考资料之间',
+    (() => {
+      const { next } = composeAroundStreamBody('基线' + FINAL, DELTA, '【卡片】', '【参考】');
+      return next === '基线\n\n【卡片】\n\n' + DELTA + '\n\n' + FINAL.slice(DELTA.length) + '\n\n【参考】';
+    })()
+  );
+  check(
+    '兜底:正文确实不在上屏内容里 → 补插(不丢字优先)',
+    composeAroundStreamBody('别的内容', DELTA, '', '').next === '别的内容\n\n' + DELTA
+  );
+  // 流式正文为空:没有可对齐的正文,退化成「现有内容 + 卡片 + 参考资料」(与改动前一致)
+  const emptyBody = composeAroundStreamBody('已有内容', '', '【卡片】', '【参考】');
+  check(
+    '流式正文为空 → 退化为「现有内容 → 卡片 → 参考资料」',
+    emptyBody.matched === true && emptyBody.next === '已有内容\n\n【卡片】\n\n【参考】',
+    emptyBody
+  );
+}
+
 console.log(`\n===== 纯逻辑部分:通过 ${pass} 项,失败 ${fail} 项 =====`);
 
 /* ================================================================== *
@@ -618,5 +666,71 @@ console.log('\n【11】两个协调器并行流式:互不串台');
   check('A 正常收到正文', gotA.join('').includes('甲会话正文'), gotA);
 }
 
+console.log('\n【12】协调器假流 + 应用层组装:answer_end 比增量拼接**多一截尾巴**(真后端形态)');
+{
+  // 2026-09-20 用户报「同一段内容出现两次」的真实形态(载荷见 harness 记录):
+  //   delta 拼 1309 字,answer_end 给 1610 字 —— 末尾多了 301 字追问建议。
+  // 这会让应用侧记录的「流式正文」不再是上屏内容的后缀:
+  //   - 修前:重排落兜底 → 卡片排到正文**后面**、正文上屏**两份**
+  //   - 修后:end 时同步 stream.body → 正常路径 → 卡片 → 正文 → 参考资料
+  //
+  // 这里用**合成载荷**保住这个回归(真实抓包不入库),逻辑与 hook 的四个监听器一致。
+  const HEAD = '您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。';
+  const TAIL = '2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?';
+  const FINAL = `${HEAD}\n\n${TAIL}`;
+  const CHUNKS = [...HEAD]; // 逐字下发,复现真实节奏
+
+  const sseText = [
+    ['accepted', { message: '收到' }],
+    ['answer_start', { stream_id: 's9', target: 'summary', sequence: 0 }],
+    ...CHUNKS.map((text, i) => ['answer_delta', { stream_id: 's9', target: 'summary', sequence: i + 1, text }]),
+    ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p' } }],
+    ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
+    ['answer_end', { stream_id: 's9', target: 'summary', sequence: CHUNKS.length, text: FINAL, status: 'completed' }],
+    ['summary', { text: FINAL, notices: [] }],
+    ['result', { response: FINAL, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]
+    .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
+    .join('');
+  stubFetchWith(sseText);
+
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  // ---- 以下与 useBusinessAssistantChat 的监听器同逻辑 ----
+  const stream = { active: false, streamId: '', body: '' };
+  let content = '';
+  let matched = null;
+  coordinator.addEventListener('answerStreamStart', (p) => {
+    stream.streamId = String(p?.streamId || '');
+    stream.body = '';
+    stream.active = true;
+  });
+  coordinator.addEventListener('message', (msg) => {
+    if (stream.active) stream.body += msg;
+    content += msg;
+  });
+  coordinator.addEventListener('answerStreamEnd', (p) => {
+    if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
+    const finalText = String(p?.text ?? '');
+    content = applyAnswerStreamEnd(content, stream.body, finalText);
+    stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
+    stream.active = false;
+  });
+  coordinator.addEventListener('answerStreamCompose', (p) => {
+    const r = composeAroundStreamBody(content, stream.body, String(p?.leading || ''), String(p?.trailing || ''));
+    content = r.next;
+    matched = r.matched;
+  });
+  await coordinator.generateAnswer('回放');
+  await new Promise((r) => setTimeout(r, 120));
+
+  const normFinal = normalizeChatMarkdown(FINAL);
+  check('重排落**正常路径**(end 时同步了流式正文)', matched === true, `matched=${matched}`);
+  check('正文只出现一次(不重复)', content.split(normFinal).length - 1 === 1, `出现 ${content.split(normFinal).length - 1} 次`);
+  check('answer_end 的尾巴没丢', content.includes(normFinal.slice(-20)));
+  check('卡-文-参考 顺序:卡片在正文之前', content.indexOf('POLICY_TABLE') < content.indexOf(normFinal.slice(0, 20)));
+  check('卡-文-参考 顺序:参考资料在正文之后', content.indexOf('<ref_links>') > content.indexOf(normFinal.slice(0, 20)));
+}
+
 console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 71 - 0
src/components/answer-stream.ts

@@ -250,6 +250,77 @@ export const composeStreamedContent = (
     .join('\n\n');
 };
 
+/**
+ * `answer_end` 之后,把应用侧记录的「流式正文」同步成**上屏正文**。
+ *
+ * 为什么必须同步:`answer_end` 的最终文本**可能比增量拼接更长**(实测真后端在末尾补了
+ * 追问建议,1309 → 1610 字)。应用侧 `stream.body` 若仍停在增量拼接的长度上,它就不再是
+ * 上屏内容的后缀 → 到 `answerStreamCompose` 重排时后缀匹配失败 → 卡片被插到正文**后面**、
+ * 正文上屏**两份**(用户 2026-09-20 从截图报告,用 `verify-answer-stream-payload.mjs` 复现)。
+ *
+ * 三种收尾(尾缀追加 / 整块重建 / 对不上兜底)的结果都**以 `finalText` 结尾**,
+ * 所以这里取 `finalText`;只有它为空时才保持原值(宁可保留旧值,也不能清空而丢正文)。
+ */
+export const resolveStreamedBodyAfterEnd = (streamedBody: string, finalText: string): string => {
+  const next = String(finalText ?? '');
+  return next ? next : String(streamedBody ?? '');
+};
+
+export interface StreamComposeResult {
+  /** 重排后的完整内容 */
+  next: string;
+  /** 上屏内容末尾是否就是这段流式正文(false = 落了兜底分支) */
+  matched: boolean;
+}
+
+/**
+ * 流式轮重排:把「正文之上(卡片)」与「正文之下(参考资料等)」摆到正文两侧,
+ * 得到用户要求的版式 **卡片 → 正文 → 参考资料**。
+ *
+ * 正常路径是「上屏内容以这段正文结尾」→ 切掉正文、把三块重新拼。
+ *
+ * 兜底路径(正文不在末尾)**绝不把正文再插一份**:它多半已经在上屏内容里了
+ * (`answer_end` 追加尾巴后就长这样),再插就是用户看到的那种「同一段内容出现两次」。
+ * 所以以它的**最后一次出现**为锚点切开:锚点之前当基线、锚点之后(尾部追加的内容)
+ * 夹在正文与参考资料之间。正文确实找不到(极端路径)才补插,宁可多一段也不丢字。
+ */
+export const composeAroundStreamBody = (
+  content: string,
+  streamedBody: string,
+  leading: string,
+  trailing: string
+): StreamComposeResult => {
+  const before = String(content ?? '');
+  const body = String(streamedBody ?? '');
+
+  if (!body) {
+    return { next: composeStreamedContent(before, leading, '', trailing), matched: true };
+  }
+  if (before.endsWith(body)) {
+    return {
+      next: composeStreamedContent(before.slice(0, before.length - body.length), leading, body, trailing),
+      matched: true,
+    };
+  }
+
+  const at = before.lastIndexOf(body);
+  if (at < 0) {
+    // 正文不在上屏内容里:补插(不丢字优先),但这是不该出现的路径,调用方会告警
+    return { next: composeStreamedContent(before, leading, body, trailing), matched: false };
+  }
+  const base = before.slice(0, at);
+  const afterBody = before.slice(at + body.length);
+  return {
+    next: composeStreamedContent(
+      base,
+      leading,
+      body,
+      [afterBody, trailing].filter((part) => String(part || '').trim()).join('\n\n')
+    ),
+    matched: false,
+  };
+};
+
 /**
  * `answer_abort` / 断流:撤掉未定稿的草稿。
  *

+ 19 - 7
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -37,7 +37,12 @@ import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from
 import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
 import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
 import { hasVisibleMessageContent } from '@/utils/interrupted-message';
-import { applyAnswerStreamAbort, applyAnswerStreamEnd, composeStreamedContent } from '../answer-stream';
+import {
+  applyAnswerStreamAbort,
+  applyAnswerStreamEnd,
+  composeAroundStreamBody,
+  resolveStreamedBodyAfterEnd,
+} from '../answer-stream';
 // [已停用] upsertDmsSession 随 1887 会话写入一并停用
 
 interface UseBusinessAssistantChatOptions {
@@ -377,6 +382,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       const finalText = String(payload?.text ?? '');
       const next = applyAnswerStreamEnd(before, stream.body, finalText);
       if (next !== before) ai.content = next;
+      // 上屏正文更新为最终全文(可能比增量拼接长)—— 不同步的话,下面的
+      // answerStreamCompose 就不再是「上屏内容以流式正文结尾」,会退到兜底分支(正文上屏两份)
+      stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
       debugStream('end', {
         流式正文: stream.body.length,
         最终正文: finalText.length,
@@ -394,14 +402,18 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       task.chunkBuffer.flush(); // 残余增量先落,正文才是完整的
       const before = ai.content;
       const body = stream.body;
-      // 基线由「上屏内容减去流式正文」**推导**,不用开流时的快照
-      const base = before.endsWith(body) ? before.slice(0, before.length - body.length) : before;
-      if (!before.endsWith(body)) {
-        console.warn('[answer-stream] 上屏内容与流式正文对不上,重排时按当前内容兜底(不丢字)');
+      // 基线由「上屏内容减去流式正文」**推导**,不用开流时的快照(基线可能已被别的路径改过)
+      const { next, matched } = composeAroundStreamBody(
+        before,
+        body,
+        String(payload?.leading || ''),
+        String(payload?.trailing || '')
+      );
+      if (!matched) {
+        console.warn('[answer-stream] 上屏内容末尾不是流式正文,重排走兜底(不重复、不丢字)');
       }
-      const next = composeStreamedContent(base, String(payload?.leading || ''), body, String(payload?.trailing || ''));
       if (next !== before) ai.content = next;
-      debugStream('compose', { 正文: body.length, 卡片: String(payload?.leading || '').length, 尾部: String(payload?.trailing || '').length });
+      debugStream('compose', { 正文: body.length, 卡片: String(payload?.leading || '').length, 尾部: String(payload?.trailing || '').length, matched });
     });
 
     coordinator?.addEventListener('answerStreamAbort', (payload: any) => {

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio