verify-answer-stream-payload.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. /**
  2. * 用**真实 SSE 载荷**回放 F073 正文流式的整条链路(协调器 + 应用侧组装),
  3. * 断言「正文只出现一次」且版式为「卡片 → 正文 → 参考资料」。
  4. *
  5. * 为什么要有这个脚本:`verify-answer-stream.mjs` 用的是**理想化**的窄用例(delta 拼接 == 最终全文),
  6. * 而真后端经常在 `answer_end` 里给出**比增量拼接更长**的文本(2026-09-20 那份载荷:1309 → 1610 字,
  7. * 末尾补了追问建议)。这条路径上「应用侧记录的流式正文」会与上屏内容脱节 → 重排时按兜底分支处理
  8. * → **正文上屏两份、卡片被挤到两份正文中间**。用户从截图报告,用这个脚本复现并定位。
  9. *
  10. * 那条路径的回归现已用**合成载荷**钉在 `verify-answer-stream.mjs` 的【12】(不依赖外部文件);
  11. * 本脚本的价值是**回放真抓包**——真载荷里 delta 片数、heartbeat 夹带、卡片/参考资料事件序
  12. * 都是合成的近似,出问题时先跑它,能直接看出是哪一段与预期不符。
  13. *
  14. * 怎么跑(先按 `_entry-coordinator.ts` 头部注释重新打包 `_coordinator.mjs`):
  15. * node harness/tools/verify-answer-stream-payload.mjs # 用下面的合成夹具
  16. * node harness/tools/verify-answer-stream-payload.mjs <原始SSE文件> # 回放真抓包
  17. * 载荷怎么来:浏览器 Network 面板把 `/api/chat` 的响应另存为文件(保持 `event: / data: ` 原样)。
  18. * ⚠️ 真抓包不入库(单份 600KB 量级):想回放真载荷就显式传路径。
  19. */
  20. import { readFileSync, existsSync } from 'node:fs';
  21. import { fileURLToPath } from 'node:url';
  22. import { dirname, join } from 'node:path';
  23. const here = dirname(fileURLToPath(import.meta.url));
  24. /** 默认夹具:与真抓包**同形态**(answer_end 比增量拼接多一截尾巴)的合成载荷,小到可以入库 */
  25. const DEFAULT_FIXTURE = join(here, 'fixtures', 'answer-stream-tail.payload.md');
  26. const payloadPath = process.argv[2] || DEFAULT_FIXTURE;
  27. const {
  28. ApiChatCoordinator,
  29. applyAnswerStreamEnd,
  30. composeAroundStreamBody,
  31. resolveStreamedBodyAfterEnd,
  32. normalizeChatMarkdown,
  33. } = await import('./_coordinator.mjs');
  34. let failed = 0;
  35. const check = (label, ok, detail) => {
  36. console.log(`${ok ? ' ✅' : ' ❌'} ${label}`);
  37. if (!ok) {
  38. failed++;
  39. if (detail !== undefined) {
  40. const text = typeof detail === 'string' ? detail : JSON.stringify(detail);
  41. console.log(` 实际:${String(text).slice(0, 240)}`);
  42. }
  43. }
  44. };
  45. if (!existsSync(payloadPath)) {
  46. console.error(`载荷文件不存在:${payloadPath}`);
  47. process.exit(2);
  48. }
  49. const raw = readFileSync(payloadPath, 'utf8');
  50. /* ------------------------------------------------------------------ *
  51. * ① 载荷统计:先把「增量拼接 vs answer_end 全文」这条关键差异摆出来
  52. * ------------------------------------------------------------------ */
  53. const parseEvents = (text) =>
  54. text
  55. .split(/\r?\n\r?\n+/)
  56. .map((block) => block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/))
  57. .filter(Boolean)
  58. .map((m) => {
  59. let data = {};
  60. try {
  61. data = JSON.parse(m[2]).data || {};
  62. } catch {
  63. /* 坏帧忽略 */
  64. }
  65. return { name: m[1], data };
  66. });
  67. const events = parseEvents(raw);
  68. const counts = {};
  69. for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1;
  70. const joined = events
  71. .filter((e) => e.name === 'answer_delta')
  72. .map((e) => e.data.text || '')
  73. .join('');
  74. const endEvent = events.find((e) => e.name === 'answer_end');
  75. const endText = endEvent?.data.text || '';
  76. const normalizedFinal = normalizeChatMarkdown(endText);
  77. console.log(`载荷:${payloadPath}`);
  78. console.log('事件计数:', JSON.stringify(counts));
  79. console.log(
  80. `增量拼接 ${joined.length} 字 / answer_end ${endText.length} 字 / 规整后 ${normalizedFinal.length} 字` +
  81. `(末尾额外 ${endText.length - joined.length} 字)`
  82. );
  83. /* ------------------------------------------------------------------ *
  84. * ② 回放:真实协调器 + 应用侧监听器(与 useBusinessAssistantChat 同逻辑)
  85. * ------------------------------------------------------------------ */
  86. globalThis.fetch = async () =>
  87. new Response(
  88. new ReadableStream({
  89. start(controller) {
  90. controller.enqueue(new TextEncoder().encode(raw));
  91. controller.close();
  92. },
  93. }),
  94. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  95. );
  96. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'replay' });
  97. /** 应用侧的 per-task 流状态(对应 useBusinessAssistantChat 里的 `stream`) */
  98. const stream = { active: false, streamId: '', body: '' };
  99. /** 应用侧的消息内容(对应 ai.content) */
  100. let content = '';
  101. const channelOrder = [];
  102. coordinator.addEventListener('answerStreamStart', (p) => {
  103. stream.streamId = String(p?.streamId || '');
  104. stream.body = '';
  105. stream.active = true;
  106. channelOrder.push('streamStart');
  107. });
  108. // 增量走 message 通道上屏
  109. coordinator.addEventListener('message', (msg) => {
  110. if (stream.active) stream.body += msg;
  111. content += msg;
  112. channelOrder.push(`message:${msg.slice(0, 16)}`);
  113. });
  114. coordinator.addEventListener('answerStreamEnd', (p) => {
  115. if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
  116. const before = content;
  117. const finalText = String(p?.text ?? '');
  118. const next = applyAnswerStreamEnd(before, stream.body, finalText);
  119. if (next !== before) content = next;
  120. stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
  121. stream.active = false;
  122. channelOrder.push('streamEnd');
  123. });
  124. coordinator.addEventListener('answerStreamCompose', (p) => {
  125. const before = content;
  126. const body = stream.body;
  127. const { next, matched } = composeAroundStreamBody(
  128. before,
  129. body,
  130. String(p?.leading || ''),
  131. String(p?.trailing || '')
  132. );
  133. content = next;
  134. channelOrder.push(`compose(matched=${matched})`);
  135. });
  136. /** 载荷里有没有 `done`(没有 = 抓包不完整,协调器走断流兼容路径,不发 totalResponse) */
  137. const hasDone = events.some((e) => e.name === 'done');
  138. const errors = [];
  139. const total = await new Promise((resolve) => {
  140. coordinator.addEventListener('totalResponse', (p) => resolve(p));
  141. coordinator.addEventListener('error', (p) => errors.push(p));
  142. void coordinator.generateAnswer('回放');
  143. setTimeout(() => resolve(null), hasDone ? 15000 : 500);
  144. });
  145. await new Promise((r) => setTimeout(r, 200));
  146. if (errors.length) {
  147. console.log(`协调器 error:${errors.map((e) => e.code).join(',')}(载荷无 done 时属预期)`);
  148. }
  149. /* ------------------------------------------------------------------ *
  150. * ③ 断言
  151. * ------------------------------------------------------------------ */
  152. console.log('\n【1】正文只出现一次');
  153. // 用正文里一段**有辨识度**的中段做计数(取规整后全文的 1/3 处 40 字)
  154. const probeStart = Math.floor(normalizedFinal.length / 3);
  155. const probe = normalizedFinal.slice(probeStart, probeStart + 40).trim();
  156. const bodyCount = probe ? content.split(probe).length - 1 : 0;
  157. check(`正文中段片段在最终内容里出现 1 次(片段 ${JSON.stringify(probe.slice(0, 20))})`, bodyCount === 1, `出现 ${bodyCount} 次`);
  158. const headCount = content.split(normalizedFinal.slice(0, 20)).length - 1;
  159. check(`正文开头 20 字出现 1 次`, headCount === 1, `出现 ${headCount} 次`);
  160. check(
  161. `正文全文出现 1 次`,
  162. normalizedFinal ? content.split(normalizedFinal).length - 1 === 1 : true,
  163. `出现 ${normalizedFinal ? content.split(normalizedFinal).length - 1 : 0} 次`
  164. );
  165. console.log('\n【2】版式顺序:卡片 → 正文 → 参考资料');
  166. const atCard = content.indexOf('POLICY_TABLE');
  167. const atBody = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1;
  168. const atRefs = content.indexOf('<ref_links>');
  169. check('含政策卡片标记', atCard >= 0);
  170. check('含参考资料标记', atRefs >= 0);
  171. check(
  172. '卡片在正文之前',
  173. atCard >= 0 && atBody >= 0 && atCard < atBody,
  174. `卡片@${atCard} 正文@${atBody}`
  175. );
  176. check('正文在参考资料之前', atBody >= 0 && atRefs >= 0 && atBody < atRefs, `正文@${atBody} 参考资料@${atRefs}`);
  177. // 人眼可读的上屏结构:按出现位置列出各内容块,便于和截图对照
  178. {
  179. const marks = [
  180. ['思考中卡片 <scope>', '<scope'],
  181. ['政策卡片 POLICY_TABLE', 'POLICY_TABLE'],
  182. ['补问卡片 <question-cards>', '<question-cards>'],
  183. ['企业微信二维码 <cipa', '<cipa'],
  184. ['参考资料 <ref_links>', '<ref_links>'],
  185. ]
  186. .map(([label, needle]) => ({ label, at: content.indexOf(needle) }))
  187. .filter((m) => m.at >= 0)
  188. .sort((a, b) => a.at - b.at);
  189. const bodyAt = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1;
  190. const rows = [...marks, ...(bodyAt >= 0 ? [{ label: `正文(${normalizedFinal.length} 字)`, at: bodyAt }] : [])].sort(
  191. (a, b) => a.at - b.at
  192. );
  193. console.log(' 上屏结构(按位置):');
  194. for (const r of rows) console.log(` @${String(r.at).padStart(6)} ${r.label}`);
  195. console.log(` 上屏内容总长:${content.length} 字`);
  196. }
  197. console.log('\n【3】收尾链路');
  198. check('收到 answerStreamCompose', channelOrder.some((x) => x.startsWith('compose')), channelOrder.at(-1));
  199. check(
  200. '重排时后缀匹配成立(不落兜底分支)',
  201. channelOrder.some((x) => x === 'compose(matched=true)'),
  202. channelOrder.filter((x) => x.startsWith('compose')).join(',')
  203. );
  204. if (hasDone) {
  205. check('totalResponse 里正文出现 1 次', !!total && !!normalizedFinal && String(total.answer || '').split(normalizedFinal).length - 1 === 1, total ? `出现 ${String(total.answer || '').split(normalizedFinal).length - 1} 次` : '未收到 totalResponse');
  206. } else {
  207. console.log(' ⏭️ 载荷没有 done 事件(走断流兼容路径,不发 totalResponse)——跳过该断言');
  208. }
  209. console.log('\n【4】兜底路径回归:流式正文没同步到上屏长度(= 本轮修的 bug)也不能重复');
  210. {
  211. // 直接调纯函数,喂「上屏内容 = 完整正文,记录的正文 = 短一截的前缀」这种错位状态
  212. const staleBody = joined ? normalizeChatMarkdown(joined) : '';
  213. const { next, matched } = composeAroundStreamBody(
  214. normalizedFinal,
  215. staleBody,
  216. '<POLICY_TABLE>卡片</POLICY_TABLE>',
  217. '<ref_links>参考</ref_links>'
  218. );
  219. const dup = staleBody ? next.split(staleBody).length - 1 : 0;
  220. const head = normalizedFinal.slice(0, 20);
  221. check('后缀不匹配时落兜底分支(matched=false)', matched === false, `matched=${matched}`);
  222. check('兜底分支里正文仍只出现 1 次(不重复插入)', dup === 1, `出现 ${dup} 次`);
  223. check('兜底分支里正文没被丢掉', next.includes(head));
  224. check(
  225. '兜底分支里卡片仍在正文之前',
  226. next.indexOf('POLICY_TABLE') < next.indexOf(head),
  227. `卡片@${next.indexOf('POLICY_TABLE')} 正文@${next.indexOf(head)}`
  228. );
  229. check(
  230. '兜底分支里参考资料仍在正文之后',
  231. next.indexOf('<ref_links>') > next.indexOf(head),
  232. `正文@${next.indexOf(head)} 参考@${next.indexOf('<ref_links>')}`
  233. );
  234. }
  235. console.log(`\n${failed === 0 ? '✅ 全部通过' : `❌ ${failed} 项未通过`}`);
  236. process.exit(failed === 0 ? 0 : 1);