فهرست منبع

fix(chat): 修「尾部重复两份」+ 概述与 notices 同句时去重

用户报告:界面上「需求拆解失败,本次仅按原问题检索。」出现 3 次、参考资料出现 2 次,
且正文先渲染完、又从中间某处重渲一遍。

取证:从 DMS 1889 按问题搜到那一轮,落盘看 c_answer —— 存储内容是干净的(ref_links ×1、
概述在正文前、notices 带 - 前缀),所以重复出在上屏链路。

根因(用合成载荷 1:1 复现后才改):契约里 answer 事件排在 answer_end 之后,
所以 summary 到达时概述还没来,flushTailEarly() 的「概述要保留就不抢跑」守卫看不到它
→ 尾部提前上屏;等 answer 到达,done 时发现概述要保留 → 整块重排;而重排时
replacedTrailing 只挂在 needsReorder(二维码插队)上 → 已上屏的旧尾部没被抹掉 → 尾部两份。

改动:
1. replacedTrailing: this.earlyTailText —— 只要提前发过尾部就一定抹掉,不挂在 needsReorder 上。
2. 新增纯函数 isOverviewAlreadyShown():后端的 answer.text 有时就是 summary.notices 里的一句
   (这一轮两边都是「需求拆解失败…」)。两者都展示 → 同一句两次,还白搭一次整块重排。
   判据只有「与某条 notice 同句」或「正文里已含这句」,都是已展示、不丢内容。
   去掉后这一轮不再触发重排,用户看到的「从中间开始重渲」一并消失。

验证:verify-answer-stream 165/165(新增【23】复现该轮事件序、【24】概述确实不同时仍保留、
【25】去重判据);两条修复都实测「阉掉即失败」;回归全绿;build 与 validate-harness 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 روز پیش
والد
کامیت
76206d56f9
4فایلهای تغییر یافته به همراه150 افزوده شده و 3 حذف شده
  1. 33 0
      harness/progress.md
  2. 80 0
      harness/tools/verify-answer-stream.mjs
  3. 23 0
      src/components/answer-stream.ts
  4. 14 3
      src/components/api-chat-coordinator.ts

+ 33 - 0
harness/progress.md

@@ -3358,3 +3358,36 @@ DMS 主机(`121.43.55.7:10081`)是纯 API、根路径 404,没有登录页
 
 > 顺带发现(未处理):`showMockStreamTestButton` 这个 prop 一路从 PC/Mobile 传到本组件,
 > 但模板里**从未使用** —— 是历史上「单个 mock 流按钮」留下的死链路,可择机清理。
+
+### 续 13:修「尾部重复」(用户报:`需求拆解失败…` 出现 3 次、`参考资料` 出现 2 次)
+
+**先取证,不猜**:从 DMS 1889 按问题搜到那一轮(`我是上海信男教育科技有限公司,有什么鬼泣政策`),
+把 `c_answer` 落盘看清结构 —— **存储的内容是干净的**(`<ref_links>` ×1、概述在正文前、
+notices 带 `- ` 前缀),所以重复发生在**上屏那条链路**,不在落库。
+
+**根因(用合成载荷在 `verify-answer-stream` 里 1:1 复现,确认后再改)**:
+契约里 **`answer` 事件排在 `answer_end` 之后**(实测就是 answer_end → summary → answer → result)。
+于是这一轮发生了:
+1. `summary` 到达时**概述还没来**,`flushTailEarly()` 的「概述要保留就不抢跑」这道守卫看不到它
+   → 尾部(notices + 参考资料)提前上屏了;
+2. `answer` 随后到达,且它不是正文的前缀(内容是「需求拆解失败,本次仅按原问题检索。」)
+   → done 时 `leadingText` 非空 → **整块重排**;
+3. 重排时 `replacedTrailing` 只挂在 `needsReorder`(二维码要插队)上,这里是 false
+   → **没抹掉已上屏的旧尾部** → 正文后面出现了两份「notices + 参考资料」。
+
+**改了两处**:
+1. `replacedTrailing: this.earlyTailText` —— **只要提前发过尾部就一定要抹**,不能挂在 `needsReorder` 上。
+   (漏掉这条就复现出用户看到的 2 份参考资料 / 3 次那句话)
+2. 新增纯函数 `isOverviewAlreadyShown(overview, body, notices)`:后端的 `answer.text` 有时**就是**
+   `summary.notices` 里的一句(这一轮两边都是「需求拆解失败,本次仅按原问题检索。」)。
+   两者都展示 → 同一句出现两次,**而且白搭一次整块重排(正文重渲)**。
+   判据只有两条(都已展示、不丢内容):与某条 notice 归一化后相同 / 正文里已含这句。
+   去掉它之后这一轮**不再触发重排**,用户看到的「从中间某处开始重渲」也一并消失。
+
+**运行过的验证**:
+- `verify-answer-stream.mjs` **165/165**。新增【23】(复现用户那一轮的事件序,断言参考资料×1、
+  那句话×1、零重排、顺序不变)、【24】(概述确实是另一段内容时仍保留)、【25】(去重判据 5 例)
+- **两条修复都实测过「阉掉即失败」**:阉掉 `replacedTrailing` → 【24】参考资料 ×2 失败;
+  阉掉去重 → 【23】那句话 ×2 且 compose 触发 失败
+- 回归全绿(适配层 27/27、多实例 11/11、判空 30/30、聊天任务 33/33、政策卡列表 24/24、DMS 载荷 12/12);
+  `npm run build` 与 `validate-harness` 通过

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

@@ -27,6 +27,7 @@ import {
   normalizeChatMarkdown,
   parseAnswerStreamEvent,
   resolveStreamedBodyAfterEnd,
+  isOverviewAlreadyShown,
   shouldKeepAnswerOverview,
 } from './_coordinator.mjs';
 
@@ -1050,5 +1051,84 @@ console.log('\n【22】非流式轮(旧后端)不受影响:尾部仍在 do
   })(), messageText(seen).slice(0, 120));
 }
 
+console.log('\n【23】尾部提前上屏后,「answer 概述要保留」触发的重排不能复制一份尾部');
+{
+  // 用户 2026-09-21 报告的真实形态(18482a 那轮):界面上「需求拆解失败…」出现 3 次、
+  // 参考资料出现 2 次。根因是下面这个**事件顺序**:
+  //   契约里 `answer` 排在 `answer_end` 之后(实测 summary 紧跟 answer_end、answer 随后),
+  //   所以 `flushTailEarly()` 在 summary 那一刻**还不知道概述要保留** → 抢跑发了尾部;
+  //   等 `answer` 到达,`flushContent` 发现概述要保留 → 整块重排,而重排时
+  //   `replacedTrailing` 只在 `needsReorder` 时才传 → 已上屏的旧尾部没被抹掉 → **尾部两份**。
+  const RICH_HEAD = '您问的政策我们这样理解:可以按下面的渠道办理。\n\n### 一、惠企政策查询\n\n**办理渠道**\n\n- 随申兑平台';
+  const OVERVIEW = '需求拆解失败,本次仅按原问题检索。';
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...[...RICH_HEAD].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...RICH_HEAD].length, text: RICH_HEAD, status: 'completed' }],
+    // summary 紧跟 answer_end:此刻 answer 还没到 → flushTailEarly 抢跑发出尾部
+    ['summary', { text: RICH_HEAD, source_ids: ['s1'], notices: [OVERVIEW] }],
+    // answer 随后才到,且它不是正文的前缀 → 概述要保留 → done 时要整块重排
+    ['answer', { text: OVERVIEW }],
+    ['result', { response: RICH_HEAD, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  const content = seen.content;
+  check('**参考资料只出现一次**(重排不复制旧尾部)', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
+  check(
+    '**那句话只出现 1 次**(后端把它同时放进了 answer.text 与 notices,前端去重)',
+    countOf(content, OVERVIEW) === 1,
+    countOf(content, OVERVIEW)
+  );
+  check('去重后不再需要整块重排(正文不重渲)', seen.compose.length === 0, seen.compose.length);
+  check('正文只出现一次', countOf(content, '办理渠道') === 1, countOf('办理渠道') >= 0 ? countOf(content, '办理渠道') : 0);
+  check('卡片只出现一次', countCards(content) === 1, countCards(content));
+  check(
+    '卡片 → 正文 → notices → 参考资料 顺序不变',
+    (() => {
+      const t = content;
+      return (
+        t.indexOf('POLICY_TABLE') < t.indexOf('办理渠道') &&
+        t.indexOf('办理渠道') < t.indexOf(OVERVIEW) &&
+        t.indexOf(OVERVIEW) < t.indexOf('<ref_links>')
+      );
+    })(),
+    content.slice(0, 80)
+  );
+}
+
+console.log('\n【24】概述确实是**另一段内容**(不在 notices/正文里)→ 仍保留在正文之上');
+{
+  // 去重不能误伤:真的多出来的一段话必须照旧展示(宁可多一段,不能丢字)
+  const BODY = '这是正文,讲的是办理渠道与材料。';
+  const OTHER = '另外提醒:本答复仅供参照,具体以窗口为准。';
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...[...BODY].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...BODY].length, text: BODY, status: 'completed' }],
+    ['summary', { text: BODY, source_ids: ['s1'], notices: ['提示:材料需加盖公章'] }],
+    ['answer', { text: OTHER }],
+    ['result', { response: BODY, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  const content = seen.content;
+  check('概述保留了下来', content.includes(OTHER), content.slice(0, 80));
+  check('概述排在正文之前', content.indexOf(OTHER) < content.indexOf(BODY));
+  check('正文与参考资料各只出现一次', countOf(content, BODY) === 1 && countOf(content, '<ref_links>') === 1, `${countOf(content, BODY)}/${countOf(content, '<ref_links>')}`);
+  check('notices 也在', content.includes('材料需加盖公章'));
+}
+
+console.log('\n【25】纯函数:isOverviewAlreadyShown 的判据');
+{
+  check('与 notice 同句(空白差异不计)→ 已展示', isOverviewAlreadyShown('需求拆解失败,本次仅按原问题检索。', '正文', ['需求拆解失败,本次仅按原问题检索。']) === true);
+  check('正文里已含这句 → 已展示', isOverviewAlreadyShown('我们青浦目前没有专项扶持', '先说明:我们青浦目前没有专项扶持,但有几项可以关注。', []) === true);
+  check('是另一段内容 → 未展示', isOverviewAlreadyShown('另外提醒:以窗口为准。', '这是正文。', ['提示:材料需盖章']) === false);
+  check('空概述 → 视为已展示(不展示)', isOverviewAlreadyShown('', '正文', []) === true);
+  check('notices 为空数组不报错', isOverviewAlreadyShown('甲', '乙', []) === false);
+}
+
 console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

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

@@ -250,6 +250,29 @@ export const composeStreamedContent = (
     .join('\n\n');
 };
 
+/**
+ * 这段「开场概述」**是不是已经会在别处展示**?(是的话就不必再摆一遍)
+ *
+ * 为什么需要:实测(2026-09-21)后端的 `answer.text` 有时**就是** `summary.notices` 里的
+ * 一句 —— 「需求拆解失败,本次仅按原问题检索。」两边都有。都展示的话同一句话会出现两次;
+ * 更要紧的是它会走「保留概述」分支 → 整块重排 → 正文重渲一遍。
+ *
+ * 判据只有两条,都是「这句已经在屏幕上」的意思,**不会丢内容**:
+ * ① 与某条 notice 归一化后完全相同
+ * ② 正文里已经含这句(说明它本来就是正文的一段)
+ */
+export const isOverviewAlreadyShown = (
+  overview: string,
+  body: string,
+  notices: readonly string[] = []
+): boolean => {
+  const text = String(overview || '').replace(/\s+/g, '');
+  if (!text) return true;
+  const bodyText = String(body || '').replace(/\s+/g, '');
+  if (bodyText && bodyText.includes(text)) return true;
+  return notices.some((notice) => String(notice || '').replace(/\s+/g, '') === text);
+};
+
 /**
  * `answer_end` 之后,把应用侧记录的「流式正文」同步成**上屏正文**。
  *

+ 14 - 3
src/components/api-chat-coordinator.ts

@@ -39,6 +39,7 @@ import {
   createIncrementalMarkdownNormalizer,
   normalizeChatMarkdown,
   parseAnswerStreamEvent,
+  isOverviewAlreadyShown,
   shouldKeepAnswerOverview,
   type AnswerAbortReason,
   type AnswerEndStatus,
@@ -1139,7 +1140,13 @@ export class ApiChatCoordinator {
       if (this.answerText) {
         leading.push(normalizeChatMarkdown(this.answerText));
       }
-    } else if (this.answerText && shouldKeepAnswerOverview(this.answerText, streamBody)) {
+    } else if (
+      this.answerText &&
+      shouldKeepAnswerOverview(this.answerText, streamBody) &&
+      // 后端偶尔把同一句话同时放进 `answer.text` 与 `summary.notices`(实测 2026-09-21)。
+      // 已经在别处展示的话就别再摆一遍:既是同一句出现两次,又会白搭一次整块重排。
+      !isOverviewAlreadyShown(this.answerText, streamBody, this.pendingSummary?.notices ?? [])
+    ) {
       // 概况不是流式正文的前缀(= 是另一段内容,不是压缩版)→ 保留在正文之上,不能丢
       console.warn('[answer-stream] answer 概述不属于流式正文,已保留展示');
       leading.push(normalizeChatMarkdown(this.answerText));
@@ -1193,8 +1200,12 @@ export class ApiChatCoordinator {
           trailing: allTailText,
           // 卡片集合变了:旧卡片块已经在屏幕上,应用层重排时要先把它抹掉(否则新旧两份并存)
           replacedLeading: policyTable && !cardsAlreadyShown ? this.preStreamLeading : '',
-          // 尾部已提前上屏过:同样要先抹掉,否则新旧两份尾部并存
-          replacedTrailing: needsReorder ? this.earlyTailText : '',
+          // 只要**提前上屏过**尾部就一定要抹掉 —— 不能只挂在 `needsReorder` 上。
+          // 踩过的坑(2026-09-21 用户报「参考资料出现 2 次、概述那句出现 3 次」):
+          // `answer` 事件按契约排在 `answer_end` **之后**,所以 `flushTailEarly()` 在 summary
+          // 那一刻还不知道「概述要保留」,会先把尾部发出去;等 answer 到达,done 时发现概述
+          // 要保留 → 走整块重排 —— 此时若没抹掉旧尾部,重排出来的内容里就有**两份尾部**。
+          replacedTrailing: this.earlyTailText,
         });
       } else if (pendingTail.length) {
         // 卡片已在正文之前上屏:尾部**纯追加**即可 —— 应用侧不重置、不闪