|
@@ -27,6 +27,7 @@ import {
|
|
|
normalizeChatMarkdown,
|
|
normalizeChatMarkdown,
|
|
|
parseAnswerStreamEvent,
|
|
parseAnswerStreamEvent,
|
|
|
resolveStreamedBodyAfterEnd,
|
|
resolveStreamedBodyAfterEnd,
|
|
|
|
|
+ isOverviewAlreadyShown,
|
|
|
shouldKeepAnswerOverview,
|
|
shouldKeepAnswerOverview,
|
|
|
} from './_coordinator.mjs';
|
|
} from './_coordinator.mjs';
|
|
|
|
|
|
|
@@ -1050,5 +1051,84 @@ console.log('\n【22】非流式轮(旧后端)不受影响:尾部仍在 do
|
|
|
})(), messageText(seen).slice(0, 120));
|
|
})(), 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} 项 =====`);
|
|
console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
|
|
|
process.exit(fail ? 1 : 0);
|
|
process.exit(fail ? 1 : 0);
|