verify-answer-stream.mjs 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134
  1. /**
  2. * 验证 F073「正文真实增量流式输出」的两块:
  3. * 第 1 部分 —— **纯逻辑**(不必联网):增量规整器的前缀不变式 + 流状态机
  4. * 第 2 部分 —— **协调器级假流**:假 SSE 流喂真实协调器,断言事件通道与去重
  5. *
  6. * 为什么规整器要这么较真:它手里攥着一个不变式——
  7. * **任一时刻「已发出的增量拼接」=== `normalizeChatMarkdown(已输入的全部文本)`**
  8. * 只要不变式成立,后端 `answer_end` 的「整块替换」在正常路径上就是纯尾缀追加
  9. * (不会触发整段重建 → 不会让「思考中」卡片闪现重播)。破了它,观感立刻劣化。
  10. *
  11. * 怎么跑(先重新打包,必须带 --loader:.png=dataurl):
  12. * npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
  13. * --outfile=harness/tools/_coordinator.mjs --alias:@=./src \
  14. * --define:import.meta.env='{}' --loader:.png=dataurl
  15. * node harness/tools/verify-answer-stream.mjs
  16. */
  17. import {
  18. ApiChatCoordinator,
  19. applyAnswerStreamAbort,
  20. applyAnswerStreamEnd,
  21. applyAnswerStreamEvent,
  22. createAnswerStreamState,
  23. composeAroundStreamBody,
  24. composeStreamedContent,
  25. createIncrementalMarkdownNormalizer,
  26. normalizeChatMarkdown,
  27. parseAnswerStreamEvent,
  28. resolveStreamedBodyAfterEnd,
  29. isOverviewAlreadyShown,
  30. shouldKeepAnswerOverview,
  31. } from './_coordinator.mjs';
  32. let pass = 0;
  33. let fail = 0;
  34. const check = (name, cond, extra = '') => {
  35. if (cond) {
  36. pass++;
  37. console.log(` ok ${name}`);
  38. } else {
  39. fail++;
  40. console.log(` FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
  41. }
  42. };
  43. /* ================================================================== *
  44. * 第 1 部分:纯逻辑
  45. * ================================================================== */
  46. console.log('【1】增量规整器:前缀不变式');
  47. /** 把一串输入分片喂给规整器,返回逐片输出 */
  48. const feed = (chunks) => {
  49. const normalizer = createIncrementalMarkdownNormalizer();
  50. const out = [];
  51. for (const chunk of chunks) out.push(normalizer.push(chunk));
  52. return { out, normalizer };
  53. };
  54. /**
  55. * 不变式(流中):**已发出的文本必须是「规整后全文」的前缀**,
  56. * 且未发出的差额只能是「**给当前行准备的空行分隔符(可能 1~2 个 \n)+ 当前行的尾巴**」——
  57. * 也就是「为了判断该行是不是列表项而短暂扣住的一小段」,行尾一到就释放。
  58. *
  59. * 这个性质正好保证 `answer_end` 的「整块替换」= 纯尾缀追加(不会整段重建)。
  60. */
  61. const isPrefixWithHeldLineTail = (got, want) => {
  62. if (!want.startsWith(got)) return false;
  63. const held = want.slice(got.length).replace(/^\n+/, ''); // 去掉随行一起扣住的前置空行
  64. return !held.includes('\n');
  65. };
  66. /** 不变式:每喂一片就核对一次 */
  67. const checkInvariant = (label, chunks, { finish = false } = {}) => {
  68. const normalizer = createIncrementalMarkdownNormalizer();
  69. let want = '';
  70. let got = '';
  71. let ok = true;
  72. let at = 0;
  73. for (let i = 0; i < chunks.length; i++) {
  74. want += chunks[i];
  75. got += normalizer.push(chunks[i]);
  76. if (!isPrefixWithHeldLineTail(got, normalizeChatMarkdown(want))) {
  77. ok = false;
  78. at = i;
  79. break;
  80. }
  81. }
  82. if (ok && finish) {
  83. got += normalizer.finish();
  84. if (got !== normalizeChatMarkdown(want)) ok = false;
  85. }
  86. check(
  87. `不变式:${label}`,
  88. ok,
  89. ok ? '' : { 第几片: at, 期望: normalizeChatMarkdown(want).slice(-40), 实际: got.slice(-40) }
  90. );
  91. return ok;
  92. };
  93. checkInvariant('整段一次 push', ['第一行\n第二行\n第三行']);
  94. checkInvariant('逐字 push(含换行)', [...'第一行\n第二行\n1. a\n2. b']);
  95. checkInvariant('双换行(空行)', ['段落一\n\n段落二']);
  96. checkInvariant('连续有序列表不插空行', ['1. 甲\n2. 乙\n3. 丙']);
  97. checkInvariant('连续无序列表不插空行', ['- 甲\n- 乙']);
  98. checkInvariant('列表后接说明行', ['- 甲\n- 乙\n以上就是全部']);
  99. checkInvariant('伪列表符(2倍)不误判', ['2倍\n3. 真列表']);
  100. checkInvariant('行首恰为列表前缀(- )', ['前一行\n- ']);
  101. checkInvariant('行首数字未定型(12)', ['前一行\n12']);
  102. checkInvariant('行首数字带点(12.)', ['前一行\n12.']);
  103. checkInvariant('末尾无换行', ['段一\n段二(未完']);
  104. checkInvariant('以换行结尾', ['段一\n']);
  105. checkInvariant('空串与纯空白', ['', ' ', '\n', ' \n']);
  106. checkInvariant('逐字 push 且收尾 finish', [...'甲\n1. 乙\n丙'], { finish: true });
  107. // 逐字 push 与整段 push 的「拼接结果」必须一致(都 finish 掉扣住的尾巴后比)
  108. {
  109. const text = '第一段\n第二段\n1. 甲\n2. 乙\n收尾说明';
  110. const wholeFeed = feed([text]);
  111. wholeFeed.out.push(wholeFeed.normalizer.finish());
  112. const perCharFeed = feed([...text]);
  113. perCharFeed.out.push(perCharFeed.normalizer.finish());
  114. const whole = wholeFeed.out.join('');
  115. const perChar = perCharFeed.out.join('');
  116. check('逐字 push 与整段 push 输出一致', whole === perChar, { whole, perChar });
  117. check('两者的拼接都等于整段规整结果', whole === normalizeChatMarkdown(text));
  118. }
  119. // 种子化伪随机模糊(≥500 轮)
  120. {
  121. let seed = 20260920;
  122. const rand = () => {
  123. seed = (seed * 1103515245 + 12345) % 2147483648;
  124. return seed / 2147483648;
  125. };
  126. const alphabet = ['甲', '乙', '丙', '\n', '\n\n', '- ', '* ', '1', '2', '.', ')', ' ', '。', '12', '-'];
  127. let bad = null;
  128. for (let round = 0; round < 500 && !bad; round++) {
  129. const chunks = [];
  130. const count = 1 + Math.floor(rand() * 20);
  131. for (let i = 0; i < count; i++) chunks.push(alphabet[Math.floor(rand() * alphabet.length)]);
  132. const normalizer = createIncrementalMarkdownNormalizer();
  133. let want = '';
  134. let got = '';
  135. for (const chunk of chunks) {
  136. want += chunk;
  137. got += normalizer.push(chunk);
  138. if (!isPrefixWithHeldLineTail(got, normalizeChatMarkdown(want))) {
  139. bad = { round, chunks, want, got };
  140. break;
  141. }
  142. }
  143. if (!bad) {
  144. got += normalizer.finish();
  145. if (got !== normalizeChatMarkdown(want)) bad = { round, chunks, want, got, finish: true };
  146. }
  147. }
  148. check('种子化模糊 500 轮(含 finish)不变式恒成立', !bad, bad);
  149. }
  150. // 扣住的部分必须「有界」:只是当前行的一小段,不能无限积压
  151. {
  152. const normalizer = createIncrementalMarkdownNormalizer();
  153. let shown = '';
  154. let want = '';
  155. for (const chunk of ['第一段\n', '第二段\n', '1', '.', ' ', '甲', '\n', '2', '.', ' ', '乙']) {
  156. want += chunk;
  157. shown += normalizer.push(chunk);
  158. const held = normalizeChatMarkdown(want).slice(shown.length).replace(/^\n+/, '');
  159. if (held.includes('\n') || held.length > 8) {
  160. check(`扣住有界(本条 ${JSON.stringify(chunk)})`, false, { held, shown });
  161. break;
  162. }
  163. }
  164. check('扣住的只是「可能成为列表项」的短前缀(本例 ≤8 字符且不含换行)', true);
  165. }
  166. console.log('\n【2】流式事件解析(形状不符 → null 走旧路径)');
  167. {
  168. const start = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', sequence: 0 }, 'answer_start');
  169. check('合法的 answer_start', start?.kind === 'start' && start.streamId === 's1' && start.target === 'summary', start);
  170. check('缺 stream_id → null', parseAnswerStreamEvent({ target: 'summary' }, 'answer_start') === null);
  171. check('空 stream_id → null', parseAnswerStreamEvent({ stream_id: ' ', target: 'summary' }, 'answer_delta') === null);
  172. check('target 非法 → null', parseAnswerStreamEvent({ stream_id: 's1', target: 'other' }, 'answer_delta') === null);
  173. check('非对象 → null', parseAnswerStreamEvent(null, 'answer_delta') === null && parseAnswerStreamEvent([1], 'answer_delta') === null);
  174. const delta = parseAnswerStreamEvent({ stream_id: 's1', target: 'answer', sequence: '3', text: 123 }, 'answer_delta');
  175. check('sequence 宽松(字符串也认)', delta?.sequence === 3, delta);
  176. check('text 非字符串按空串', delta?.text === '', delta);
  177. const end = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', text: 'x', status: '怪值' }, 'answer_end');
  178. check('status 非法 → fallback', end?.kind === 'end' && end.status === 'fallback', end);
  179. const endOk = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', text: 'x', status: 'completed' }, 'answer_end');
  180. check('status=completed 保留', endOk?.status === 'completed');
  181. const abort = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', reason: '怪值' }, 'answer_abort');
  182. check('reason 非法 → generation_failed', abort?.kind === 'abort' && abort.reason === 'generation_failed', abort);
  183. const abortOk = parseAnswerStreamEvent({ stream_id: 's1', target: 'summary', reason: 'not_finalized' }, 'answer_abort');
  184. check('reason=not_finalized 保留', abortOk?.reason === 'not_finalized');
  185. check('未知事件名 → null', parseAnswerStreamEvent({ stream_id: 's1', target: 'summary' }, 'answer_what') === null);
  186. }
  187. console.log('\n【3】流状态机');
  188. {
  189. const run = (events) => {
  190. let state = createAnswerStreamState();
  191. const effects = [];
  192. for (const ev of events) {
  193. const r = applyAnswerStreamEvent(state, ev);
  194. state = r.state;
  195. effects.push(r.effect.kind);
  196. }
  197. return { state, effects };
  198. };
  199. const ev = (kind, extra = {}) => ({ kind, streamId: 's1', target: 'summary', sequence: 0, text: '', ...extra });
  200. // 正常流
  201. {
  202. const { state, effects } = run([
  203. ev('start', { sequence: 0 }),
  204. ev('delta', { sequence: 1, text: '你好' }),
  205. ev('delta', { sequence: 2, text: '世界' }),
  206. ev('end', { sequence: 2, text: '你好,世界' }),
  207. ]);
  208. check('正常流:效果序列 start→append→append→replace', JSON.stringify(effects) === JSON.stringify(['start', 'append', 'append', 'replace']), effects);
  209. check('正常流:end 后 text = 最终全文(整块替换,不拼接)', state.text === '你好,世界' && state.completed === true, state);
  210. }
  211. // 序号缺口
  212. {
  213. const { state, effects } = run([
  214. ev('start'),
  215. ev('delta', { sequence: 1, text: 'a' }),
  216. ev('delta', { sequence: 5, text: 'b' }),
  217. ]);
  218. check('序号缺口:仍拼接、hasGap 置位', state.text === 'ab' && state.hasGap === true, state);
  219. check('缺口不阻断渲染(仍是 append)', effects[2] === 'append');
  220. }
  221. // 重复序号
  222. {
  223. const { state } = run([ev('start'), ev('delta', { sequence: 1, text: 'a' }), ev('delta', { sequence: 1, text: 'XX' })]);
  224. check('重复序号忽略(不重复拼接)', state.text === 'a', state);
  225. }
  226. // 过期流
  227. {
  228. const { state, effects } = run([
  229. ev('start'),
  230. ev('delta', { streamId: 'other', sequence: 1, text: 'X' }),
  231. ev('end', { streamId: 'other', text: 'X' }),
  232. ev('abort', { streamId: 'other' }),
  233. ]);
  234. check('过期 streamId 的 delta/end/abort 全部 none', effects.slice(1).every((k) => k === 'none'), effects);
  235. check('过期事件不改状态', state.text === '' && state.completed === false, state);
  236. }
  237. // end 之后的 delta
  238. {
  239. const { state, effects } = run([ev('start'), ev('end', { text: '定稿' }), ev('delta', { sequence: 9, text: '迟到' })]);
  240. check('end 之后的 delta 忽略', effects[2] === 'none' && state.text === '定稿', state);
  241. }
  242. // abort 复位 + 重新 start
  243. {
  244. const { state, effects } = run([
  245. ev('start'),
  246. ev('delta', { sequence: 1, text: '草稿' }),
  247. ev('abort', { reason: 'generation_failed' }),
  248. ev('start', { streamId: 's2' }),
  249. ev('delta', { streamId: 's2', sequence: 1, text: '新正文' }),
  250. ]);
  251. check('abort 复位(discard 效果 + 状态清空后可再 start)', effects.includes('discard') && state.text === '新正文' && state.streamId === 's2', { effects, state });
  252. }
  253. // end 覆盖草稿(fallback 也替换)
  254. {
  255. const { state } = run([
  256. ev('start'),
  257. ev('delta', { sequence: 1, text: '草稿' }),
  258. ev('end', { sequence: 1, text: '最终', status: 'fallback' }),
  259. ]);
  260. check('fallback 的 end 同样整块替换', state.text === '最终' && state.completed === true, state);
  261. }
  262. check('null 事件 → none 且状态不变', (() => {
  263. const s0 = createAnswerStreamState();
  264. const r = applyAnswerStreamEvent(s0, null);
  265. return r.effect.kind === 'none' && r.state === s0;
  266. })());
  267. }
  268. console.log('\n【3b】应用层的内容对齐(end 替换 / abort 撤稿)');
  269. {
  270. const BASE = '<scope title="思考中">\n</scope>\n';
  271. // 新签名:(当前内容, 流式正文, 最终正文) —— 用「流式正文」做后缀匹配,不按基线长度切
  272. check(
  273. '正常路径:最终文本以增量拼接为前缀 → 纯尾缀追加(不加空行、不重建)',
  274. applyAnswerStreamEnd(BASE + '一、说明', '一、说明', '一、说明。二、补充') === BASE + '一、说明。二、补充'
  275. );
  276. check(
  277. '已上屏一长段、最终只多了尾巴 → 不重复整段',
  278. applyAnswerStreamEnd(BASE + '甲', '甲', '甲乙丙') === BASE + '甲乙丙'
  279. );
  280. check(
  281. '罕见分歧(最终文本与增量拼接不一致)→ 整块重建,且基线里的 <scope> 被剥掉(避免卡片闪现)',
  282. applyAnswerStreamEnd(BASE + '草稿', '草稿', '完全不同的最终文本') === '完全不同的最终文本'
  283. );
  284. check(
  285. '重建时**基线(流开始前就有的内容)保留**,最终文本接在其后 —— 只有 scope 标记会被剥掉',
  286. applyAnswerStreamEnd('前言草稿', '草稿', '最终') === '前言最终'
  287. );
  288. check(
  289. '兜底:上屏内容与流式正文对不上(被别的路径改过)→ 不切,直接把最终正文接上(不丢字)',
  290. applyAnswerStreamEnd('被改过的内容', '对不上的正文', '最终') === '被改过的内容最终'
  291. );
  292. check('abort 撤稿:从上屏内容里去掉流式正文', applyAnswerStreamAbort(BASE + '草稿', '草稿') === BASE);
  293. check('abort 兜底:对不上就不动(宁可多留也不切坏)', applyAnswerStreamAbort('别的内容', '对不上') === '别的内容');
  294. check('abort:空正文返回原内容', applyAnswerStreamAbort('原内容', '') === '原内容');
  295. // answer 开场概述该不该显示(实测:后端把正文作为 summary 流式下发,
  296. // 而 answer 常是同一段开头的**压缩版**——显示会重复开头;但万一是另一段内容就不能丢)
  297. const body =
  298. '我们青浦目前能对上的小微企业扶持,主要分两类:一类是面向特定对象的创业扶持(如带动就业补贴、贷款贴息、留创企业开办资助),另一类是市级中小企业公共资助项目;此外还有几款“小微贷”金融产品。\n\n### 具体政策\n- 创业扶持:带动就业补贴';
  299. // 压缩版的形态与实测一致:**截断在词中间**(正文接着 '###',概述结尾是半个词 '部分候')
  300. const abridged = body.slice(0, 70).replace(/\n+/g, ' ') + ' 部分候…';
  301. check('压缩版开场(截断在词中间,公共前缀 ≥90%)→ 不显示(避免开头重复)', shouldKeepAnswerOverview(abridged, body) === false, abridged);
  302. check('真正的另一段内容(不是前缀)→ 必须保留', shouldKeepAnswerOverview('这是另一段完全不同的开场说明。', body) === true);
  303. check('概述为空 → 不显示', shouldKeepAnswerOverview('', body) === false && shouldKeepAnswerOverview(null, body) === false);
  304. check('流式正文为空 → 保留概述(不能丢字)', shouldKeepAnswerOverview('开场', '') === true);
  305. // 流式轮的最终组装:基线 + 卡片 + 正文 + 尾部(卡片在正文**之上**)
  306. const SCOPE = '<scope title="思考中">\n</scope>\n';
  307. check(
  308. '组装顺序:基线 → leading(卡片)→ 正文 → trailing(参考资料)',
  309. composeStreamedContent(SCOPE + '前言', '【卡片】', '正文正文', '【参考资料】') === '前言\n\n【卡片】\n\n正文正文\n\n【参考资料】',
  310. composeStreamedContent(SCOPE + '前言', '【卡片】', '正文正文', '【参考资料】')
  311. );
  312. check(
  313. '组装时剥掉基线里的 <scope>(否则卡片会闪现重播)',
  314. !composeStreamedContent(SCOPE + '前言', '', '正文', '').includes('<scope')
  315. );
  316. check('没有卡片时只剩正文(不留空行)', composeStreamedContent('', '', '正文', '') === '正文');
  317. check('空块被跳过、不产生多余空行', composeStreamedContent('', '', '正文', '') === '正文' && composeStreamedContent('', '', '', '尾') === '尾');
  318. }
  319. console.log('\n【3c】end 之后同步「流式正文」+ 重排(正文上屏两份的 bug,见 verify-answer-stream-payload)');
  320. {
  321. // 真后端形态:answer_end 的文本比增量拼接**长**(实测 1309 → 1610,末尾补了追问建议)。
  322. // 不同步 stream.body 的话,重排时后缀匹配失败 → 卡片排在正文后、正文上屏两份。
  323. const DELTA = '正文前半';
  324. const FINAL = '正文前半;正文后半(answer_end 才到的尾巴)';
  325. check('end 之后流式正文更新为最终全文', resolveStreamedBodyAfterEnd(DELTA, FINAL) === FINAL);
  326. check('最终正文为空 → 保持原值(不能清空而丢正文)', resolveStreamedBodyAfterEnd(DELTA, '') === DELTA);
  327. check('最终正文为空且原值也空 → 空串', resolveStreamedBodyAfterEnd('', '') === '');
  328. check(
  329. '端到端形态:end 同步后重排 → 卡片在正文之前、正文只出现一次',
  330. (() => {
  331. const onScreen = applyAnswerStreamEnd('', DELTA, FINAL); // end 把尾巴接上
  332. const body = resolveStreamedBodyAfterEnd(DELTA, FINAL); // hook 同步流式正文
  333. const { next, matched } = composeAroundStreamBody(onScreen, body, '【卡片】', '【参考】');
  334. return matched === true && next === '【卡片】\n\n' + FINAL + '\n\n【参考】' && next.split(FINAL).length - 1 === 1;
  335. })()
  336. );
  337. // 兜底:流式正文与上屏内容错位(本 bug 的原状)——**绝不能把正文再插一份**
  338. check(
  339. '兜底:正文不在末尾但在上屏内容里 → 以它为锚点重排,不重复',
  340. (() => {
  341. const { next, matched } = composeAroundStreamBody(FINAL, DELTA, '【卡片】', '【参考】');
  342. return matched === false && next.split(DELTA).length - 1 === 1 && next.startsWith('【卡片】') && next.endsWith('【参考】');
  343. })()
  344. );
  345. check(
  346. '兜底:锚点之后的内容(answer_end 的尾巴)留在正文与参考资料之间',
  347. (() => {
  348. const { next } = composeAroundStreamBody('基线' + FINAL, DELTA, '【卡片】', '【参考】');
  349. return next === '基线\n\n【卡片】\n\n' + DELTA + '\n\n' + FINAL.slice(DELTA.length) + '\n\n【参考】';
  350. })()
  351. );
  352. check(
  353. '兜底:正文确实不在上屏内容里 → 补插(不丢字优先)',
  354. composeAroundStreamBody('别的内容', DELTA, '', '').next === '别的内容\n\n' + DELTA
  355. );
  356. // 流式正文为空:没有可对齐的正文,退化成「现有内容 + 卡片 + 参考资料」(与改动前一致)
  357. const emptyBody = composeAroundStreamBody('已有内容', '', '【卡片】', '【参考】');
  358. check(
  359. '流式正文为空 → 退化为「现有内容 → 卡片 → 参考资料」',
  360. emptyBody.matched === true && emptyBody.next === '已有内容\n\n【卡片】\n\n【参考】',
  361. emptyBody
  362. );
  363. }
  364. console.log(`\n===== 纯逻辑部分:通过 ${pass} 项,失败 ${fail} 项 =====`);
  365. /* ================================================================== *
  366. * 第 2 部分:协调器级假流(假 SSE 喂真实协调器)
  367. * ================================================================== */
  368. const sse = (events) =>
  369. events
  370. .map(
  371. ([event, data]) =>
  372. `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`
  373. )
  374. .join('');
  375. const stubFetchWith = (sseText) => {
  376. globalThis.fetch = async () => {
  377. const stream = new ReadableStream({
  378. start(controller) {
  379. controller.enqueue(new TextEncoder().encode(sseText));
  380. controller.close();
  381. },
  382. });
  383. return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
  384. };
  385. };
  386. /** 跑一轮,收集各类事件 */
  387. const runTurn = async (events) => {
  388. stubFetchWith(sse(events));
  389. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  390. const seen = { messages: [], total: null, streamStart: [], streamEnd: [], streamAbort: [], compose: [], errors: [], order: [] };
  391. coordinator.addEventListener('message', (m) => {
  392. seen.messages.push(m);
  393. seen.order.push(`message:${m.slice(0, 24)}`);
  394. });
  395. coordinator.addEventListener('totalResponse', (p) => {
  396. seen.total = p;
  397. seen.order.push('totalResponse'); // done 时才发;用来判断尾部是不是提前上屏了
  398. });
  399. coordinator.addEventListener('answerStreamStart', (p) => {
  400. seen.streamStart.push(p);
  401. seen.order.push('streamStart');
  402. });
  403. coordinator.addEventListener('answerStreamEnd', (p) => {
  404. seen.streamEnd.push(p);
  405. seen.order.push('streamEnd');
  406. });
  407. coordinator.addEventListener('answerStreamAbort', (p) => {
  408. seen.streamAbort.push(p);
  409. seen.order.push('streamAbort');
  410. });
  411. coordinator.addEventListener('answerStreamCompose', (p) => {
  412. seen.compose.push(p);
  413. seen.order.push('streamCompose');
  414. });
  415. coordinator.addEventListener('error', (p) => {
  416. seen.errors.push(p);
  417. seen.order.push('error');
  418. });
  419. await coordinator.generateAnswer('测试问题');
  420. await new Promise((resolve) => setTimeout(resolve, 80)); // flushContent 会等两帧
  421. return seen;
  422. };
  423. // 应用侧就是 `content += msg`(见 appendAiMessageChunk),**没有分隔符** ——
  424. // 用 '\n\n' 拼会把流式增量切成一堆段落,断言就失真了
  425. const messageText = (seen) => seen.messages.join('');
  426. const countOf = (text, needle) => text.split(needle).length - 1;
  427. /** 数**卡片块**个数(`POLICY_TABLE` 在同一个块里出现两次:`<!-- POLICY_TABLE` 与 `POLICY_TABLE -->`) */
  428. const countCards = (text) => countOf(text, '<!-- POLICY_TABLE');
  429. /**
  430. * 跑一轮**并带上应用层的监听器**(与 `useBusinessAssistantChat` 同逻辑),
  431. * 返回「上屏内容」而不是原始事件 —— 用来断言用户真正看到的东西。
  432. */
  433. const runTurnWithApp = async (events) => {
  434. stubFetchWith(sse(events));
  435. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  436. const stream = { active: false, streamId: '', body: '' };
  437. const seen = { content: '', matched: null, compose: [], messages: [] };
  438. coordinator.addEventListener('answerStreamStart', (p) => {
  439. stream.streamId = String(p?.streamId || '');
  440. stream.body = '';
  441. stream.active = true;
  442. });
  443. coordinator.addEventListener('message', (msg) => {
  444. if (stream.active) stream.body += msg; // 正在流式时这条通道上跑的就是正文增量
  445. seen.messages.push(msg);
  446. seen.content += msg;
  447. });
  448. coordinator.addEventListener('answerStreamEnd', (p) => {
  449. if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
  450. const finalText = String(p?.text ?? '');
  451. seen.content = applyAnswerStreamEnd(seen.content, stream.body, finalText);
  452. stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
  453. stream.active = false;
  454. });
  455. coordinator.addEventListener('answerStreamAbort', (p) => {
  456. if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
  457. seen.content = applyAnswerStreamAbort(seen.content, stream.body);
  458. stream.active = false;
  459. });
  460. coordinator.addEventListener('answerStreamCompose', (p) => {
  461. seen.compose.push(p);
  462. const r = composeAroundStreamBody(
  463. seen.content,
  464. stream.body,
  465. String(p?.leading || ''),
  466. String(p?.trailing || ''),
  467. String(p?.replacedLeading || ''),
  468. String(p?.replacedTrailing || '')
  469. );
  470. seen.content = r.next;
  471. seen.matched = r.matched;
  472. });
  473. await coordinator.generateAnswer('回放');
  474. await new Promise((r) => setTimeout(r, 120));
  475. return seen;
  476. };
  477. const DELTA_TEXTS = ['一、', '第一段说明', '。\n\n', '二、', '第二段说明', '。'];
  478. const FINAL_TEXT = '一、第一段说明。\n\n二、第二段说明。';
  479. const MARKER = '第一段说明';
  480. console.log('\n【4】协调器假流:happy path(带卡片轮形态,含流中 heartbeat)');
  481. {
  482. const seen = await runTurn([
  483. ['accepted', { message: '收到' }],
  484. ['progress', { stage: 'retrieve', message: '正在检索……' }],
  485. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0, provisional: true }],
  486. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  487. ['heartbeat', { status: 'processing', message: '仍在处理……', elapsed_seconds: 20 }],
  488. ['answer', { text: FINAL_TEXT }],
  489. ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  490. // 有 item 才有 POLICY_TABLE 卡片块(buildPolicyTableContent 是按 items 建的)
  491. ['item', { source_id: 's1', title: '测试政策', card: { name: { text: '测试政策' } } }],
  492. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, operation: 'replace', status: 'completed', provisional: false }],
  493. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
  494. ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }],
  495. ['done', { status: 'completed' }],
  496. ]);
  497. const text = messageText(seen);
  498. if (process.env.DBG) console.log('[dbg] text=' + JSON.stringify(text));
  499. check('answerStreamStart 发出一次', seen.streamStart.length === 1, seen.streamStart);
  500. check('answerStreamEnd 发出一次', seen.streamEnd.length === 1, seen.streamEnd);
  501. check('无 abort', seen.streamAbort.length === 0, seen.streamAbort);
  502. check('scope 卡片只发了一次(流中 heartbeat 不再发)', countOf(text, '<scope') === 1, countOf(text, '<scope'));
  503. check('answer_end 的文本是规整后的全文', seen.streamEnd[0]?.text === normalizeChatMarkdown(FINAL_TEXT), seen.streamEnd[0]?.text);
  504. check(
  505. '正文在 message 通道**只出现一次**(完整 answer/summary 事件没有渲染成第二份)',
  506. countOf(text, MARKER) === 1,
  507. countOf(text, MARKER)
  508. );
  509. check(
  510. '流式增量拼接 === 规整后全文(前缀不变式在真链路成立)',
  511. text.includes(normalizeChatMarkdown(FINAL_TEXT).split('\n')[0]),
  512. normalizeChatMarkdown(FINAL_TEXT).slice(0, 30)
  513. );
  514. check('totalResponse.answer 含最终全文', String(seen.total?.answer || '').includes('一、第一段说明。'), String(seen.total?.answer || '').slice(0, 40));
  515. check('totalResponse 里含政策卡片与参考资料', String(seen.total?.answer || '').includes('POLICY_TABLE') && String(seen.total?.answer || '').includes('<ref_links>'));
  516. // 卡片顺序:流式轮**卡片在正文之上**(用户 2026-09-20 要求);正文已上屏,故重排通过
  517. // answerStreamCompose 事件交给应用层(message 通道里只有正文)
  518. const compose = seen.compose[0];
  519. check('发出 answerStreamCompose(流式轮的重排信号)', !!compose, seen.compose);
  520. check('compose.leading 是政策卡片', String(compose?.leading || '').includes('POLICY_TABLE'), String(compose?.leading || '').slice(0, 60));
  521. check('compose.trailing 是参考资料', String(compose?.trailing || '').includes('<ref_links>'), String(compose?.trailing || '').slice(0, 60));
  522. check(
  523. '**totalResponse 里卡片排在正文之前**',
  524. (() => {
  525. const ans = String(seen.total?.answer || '');
  526. return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
  527. })(),
  528. String(seen.total?.answer || '').slice(0, 80)
  529. );
  530. check('正文本身没有在 message 通道里被卡片打断(卡片走 compose)', !text.includes('POLICY_TABLE'));
  531. // ⚠️ 契约:compose 让应用层**整块重写**内容(不是往后追加)——
  532. // 所以渲染层的增量解析必须能识别"非纯追加"并重置,否则会把新内容的中段当正文渲染
  533. // (踩过:参考资料 JSON 的中段变成一坨文本显示在界面上)
  534. const composedLeading = String(compose?.leading || '');
  535. check(
  536. '重排后的内容**不是正文的纯追加**(渲染层必须支持"整块重写")',
  537. composedLeading.length > 0 &&
  538. !`${composedLeading}\n\n${normalizeChatMarkdown(FINAL_TEXT)}`.startsWith(normalizeChatMarkdown(FINAL_TEXT)),
  539. composedLeading.slice(0, 40)
  540. );
  541. }
  542. console.log('\n【5】协调器假流:纯正文轮(无卡片、无 answer 事件)不丢正文');
  543. {
  544. const seen = await runTurn([
  545. ['accepted', { message: '收到' }],
  546. ['answer_start', { stream_id: 's2', target: 'summary', sequence: 0 }],
  547. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's2', target: 'summary', sequence: i + 1, text }]),
  548. ['answer_end', { stream_id: 's2', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
  549. ['summary', { text: FINAL_TEXT }],
  550. ['result', { response: FINAL_TEXT }],
  551. ['done', { status: 'completed' }],
  552. ]);
  553. check('totalResponse.answer === 最终全文(没有因为"没有卡片"而丢正文)', String(seen.total?.answer || '').trim() === normalizeChatMarkdown(FINAL_TEXT).trim(), String(seen.total?.answer || ''));
  554. }
  555. console.log('\n【6】协调器假流:answer_end 与增量拼接不同 → 整块替换生效');
  556. {
  557. const seen = await runTurn([
  558. ['answer_start', { stream_id: 's3', target: 'summary', sequence: 0 }],
  559. ['answer_delta', { stream_id: 's3', target: 'summary', sequence: 1, text: '草稿内容' }],
  560. ['answer_end', { stream_id: 's3', target: 'summary', sequence: 1, text: '定稿内容(后端最终校验改过)', status: 'completed' }],
  561. ['summary', { text: '定稿内容(后端最终校验改过)' }],
  562. ['result', { response: '定稿内容(后端最终校验改过)' }],
  563. ['done', { status: 'completed' }],
  564. ]);
  565. check('End.text = 规整后的最终文本(不是增量拼接)', seen.streamEnd[0]?.text === '定稿内容(后端最终校验改过)', seen.streamEnd[0]?.text);
  566. check('totalResponse.answer 用的是最终文本', String(seen.total?.answer || '').includes('定稿内容'), String(seen.total?.answer || ''));
  567. }
  568. console.log('\n【7】协调器假流:后端 error → 撤草稿(abort 先于 error)');
  569. {
  570. const seen = await runTurn([
  571. ['answer_start', { stream_id: 's4', target: 'summary', sequence: 0 }],
  572. ['answer_delta', { stream_id: 's4', target: 'summary', sequence: 1, text: '半截草稿' }],
  573. ['error', { code: 'processing_failed' }],
  574. ]);
  575. const abortAt = seen.order.indexOf('streamAbort');
  576. const errorAt = seen.order.indexOf('error');
  577. check('abort 在 error **之前**发出(否则 hook 已摘任务、abort 会被守卫丢弃)', abortAt > -1 && abortAt < errorAt, seen.order);
  578. check('abort 的 reason = generation_failed', seen.streamAbort[0]?.reason === 'generation_failed', seen.streamAbort[0]);
  579. check('草稿文本未进入 totalResponse', !String(seen.total?.answer || '').includes('半截草稿'), String(seen.total?.answer || ''));
  580. }
  581. console.log('\n【8】协调器假流:断流(EOF 无 done)→ 撤草稿 + 兼容回退');
  582. {
  583. const seen = await runTurn([
  584. ['answer_start', { stream_id: 's5', target: 'summary', sequence: 0 }],
  585. ['answer_delta', { stream_id: 's5', target: 'summary', sequence: 1, text: '半截草稿' }],
  586. ['answer', { text: '完整事件里的正文(断流前的兼容副本)' }],
  587. ]);
  588. check('断流时发出 abort(reason=not_finalized)', seen.streamAbort[0]?.reason === 'not_finalized', seen.streamAbort);
  589. // ⚠️ 分层:协调器**只发信号**,真正把草稿从界面上撤掉的是应用层(hook 调 applyAnswerStreamAbort)。
  590. // 断流路径也不发 totalResponse(既有行为:内容走 message 通道,hook 的 close 监听负责补写 DMS)。
  591. check('兼容路径的完整 answer 事件仍进了 message 通道', messageText(seen).includes('完整事件里的正文'), messageText(seen).slice(0, 80));
  592. check('报的是连接断开', seen.errors.some((e) => e?.code === 'disconnected'), seen.errors);
  593. }
  594. console.log('\n【9】协调器假流:旧后端(无任何流式事件)行为不变');
  595. {
  596. const seen = await runTurn([
  597. ['accepted', { message: '收到' }],
  598. ['progress', { stage: 'retrieve', message: '正在检索……' }],
  599. ['answer', { text: FINAL_TEXT }],
  600. ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  601. ['item', { source_id: 's1', title: '测试政策', card: { name: { text: '测试政策' } } }],
  602. ['summary', { text: FINAL_TEXT }],
  603. ['result', { response: FINAL_TEXT }],
  604. ['done', { status: 'completed' }],
  605. ]);
  606. const text = messageText(seen);
  607. check('没有流事件、没有 abort', seen.streamStart.length === 0 && seen.streamAbort.length === 0);
  608. check('不发 answerStreamCompose(旧路径不重排)', seen.compose.length === 0, seen.compose);
  609. check('scope 卡片正常', countOf(text, '<scope') === 1);
  610. check('正文按原路径整段输出(规整后)', text.includes(normalizeChatMarkdown(FINAL_TEXT)));
  611. const bodyAt = text.indexOf('第一段说明');
  612. const cardAt = text.indexOf('POLICY_TABLE');
  613. const refAt = text.indexOf('<ref_links>');
  614. check(
  615. '**旧路径顺序不变**:正文 → 卡片 → 参考资料',
  616. bodyAt > -1 && cardAt > -1 && refAt > -1 && bodyAt < cardAt && cardAt < refAt,
  617. { bodyAt, cardAt, refAt }
  618. );
  619. }
  620. console.log('\n【10】协调器:流式轮之后跑旧轮,状态不残留');
  621. {
  622. stubFetchWith(
  623. sse([
  624. ['answer_start', { stream_id: 's6', target: 'summary', sequence: 0 }],
  625. ['answer_delta', { stream_id: 's6', target: 'summary', sequence: 1, text: '流式正文' }],
  626. ['answer_end', { stream_id: 's6', target: 'summary', sequence: 1, text: '流式正文', status: 'completed' }],
  627. ['done', { status: 'completed' }],
  628. ])
  629. );
  630. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  631. const rounds = [];
  632. coordinator.addEventListener('message', (m) => rounds.push(m));
  633. await coordinator.generateAnswer('第一轮');
  634. await new Promise((r) => setTimeout(r, 80));
  635. const cutoff = rounds.length; // 第一轮结束时的分界
  636. stubFetchWith(
  637. sse([
  638. ['progress', { stage: 'retrieve', message: '正在检索……' }],
  639. ['answer', { text: '第二轮正文' }],
  640. ['done', { status: 'completed' }],
  641. ])
  642. );
  643. await coordinator.generateAnswer('第二轮');
  644. await new Promise((r) => setTimeout(r, 80));
  645. const first = rounds.slice(0, cutoff).join('');
  646. const second = rounds.slice(cutoff).join('');
  647. check('第一轮是流式正文', first.includes('流式正文'), first.slice(0, 60));
  648. check('第二轮回到旧路径:有 scope、正文是整段输出', countOf(second, '<scope') === 1 && second.includes('第二轮正文'), second.slice(0, 80));
  649. }
  650. console.log('\n【11】两个协调器并行流式:互不串台');
  651. {
  652. stubFetchWith(
  653. sse([
  654. ['answer_start', { stream_id: 'A', target: 'summary', sequence: 0 }],
  655. ['answer_delta', { stream_id: 'A', target: 'summary', sequence: 1, text: '甲会话正文' }],
  656. ['answer_end', { stream_id: 'A', target: 'summary', sequence: 1, text: '甲会话正文', status: 'completed' }],
  657. ['done', { status: 'completed' }],
  658. ])
  659. );
  660. const a = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'A' });
  661. const b = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'B' });
  662. const gotA = [];
  663. const gotB = [];
  664. a.addEventListener('message', (m) => gotA.push(m));
  665. b.addEventListener('message', (m) => gotB.push(m));
  666. b.addEventListener('answerStreamAbort', () => gotB.push('ABORT'));
  667. await a.generateAnswer('问 A');
  668. await new Promise((r) => setTimeout(r, 80));
  669. check('B 未收到任何事件(含 abort)', gotB.length === 0, gotB);
  670. check('A 正常收到正文', gotA.join('').includes('甲会话正文'), gotA);
  671. }
  672. console.log('\n【12】协调器假流 + 应用层组装:answer_end 比增量拼接**多一截尾巴**(真后端形态)');
  673. {
  674. // 2026-09-20 用户报「同一段内容出现两次」的真实形态(载荷见 harness 记录):
  675. // delta 拼 1309 字,answer_end 给 1610 字 —— 末尾多了 301 字追问建议。
  676. // 这会让应用侧记录的「流式正文」不再是上屏内容的后缀:
  677. // - 修前:重排落兜底 → 卡片排到正文**后面**、正文上屏**两份**
  678. // - 修后:end 时同步 stream.body → 正常路径 → 卡片 → 正文 → 参考资料
  679. //
  680. // 这里用**合成载荷**保住这个回归(真实抓包不入库),逻辑与 hook 的四个监听器一致。
  681. const HEAD = '您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。';
  682. const TAIL = '2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?';
  683. const FINAL = `${HEAD}\n\n${TAIL}`;
  684. const CHUNKS = [...HEAD]; // 逐字下发,复现真实节奏
  685. const seen = await runTurnWithApp([
  686. ['accepted', { message: '收到' }],
  687. ['answer_start', { stream_id: 's9', target: 'summary', sequence: 0 }],
  688. ...CHUNKS.map((text, i) => ['answer_delta', { stream_id: 's9', target: 'summary', sequence: i + 1, text }]),
  689. ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p' } }],
  690. ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
  691. ['answer_end', { stream_id: 's9', target: 'summary', sequence: CHUNKS.length, text: FINAL, status: 'completed' }],
  692. ['summary', { text: FINAL, notices: [] }],
  693. ['result', { response: FINAL, recommendation: {}, errors: [] }],
  694. ['done', { status: 'completed' }],
  695. ]);
  696. const content = seen.content;
  697. const matched = seen.matched;
  698. const normFinal = normalizeChatMarkdown(FINAL);
  699. check('重排落**正常路径**(end 时同步了流式正文)', matched === true, `matched=${matched}`);
  700. check('正文只出现一次(不重复)', content.split(normFinal).length - 1 === 1, `出现 ${content.split(normFinal).length - 1} 次`);
  701. check('answer_end 的尾巴没丢', content.includes(normFinal.slice(-20)));
  702. check('卡-文-参考 顺序:卡片在正文之前', content.indexOf('POLICY_TABLE') < content.indexOf(normFinal.slice(0, 20)));
  703. check('卡-文-参考 顺序:参考资料在正文之后', content.indexOf('<ref_links>') > content.indexOf(normFinal.slice(0, 20)));
  704. }
  705. console.log('\n【13】F079 卡片先于正文:source/item 在 answer_start **之前**到(真后端顺序)');
  706. {
  707. // 契约(token-streaming.md「卡片先于正文(F079)」):有入选政策时,完整 source/item
  708. // 发完才开始 answer_start/answer_delta;前端**收到 item 即可展示卡片**,不要等 summary/result。
  709. // 用户 2026-09-21 报告的就是这里没做到:卡片等 done 才发 → 只能整块重写插到正文之上 →
  710. // 界面上「正文先渲一遍,然后从头再渲一遍」,第一次纯属浪费。
  711. const seen = await runTurn([
  712. ['accepted', { message: '收到' }],
  713. ['progress', { stage: 'retrieve', message: '正在查阅……' }],
  714. ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p1' } }],
  715. ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
  716. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0, provisional: true }],
  717. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  718. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, operation: 'replace', status: 'completed' }],
  719. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
  720. ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }],
  721. ['done', { status: 'completed' }],
  722. ]);
  723. const text = messageText(seen);
  724. const atCard = text.indexOf('POLICY_TABLE');
  725. const atBody = text.indexOf(MARKER);
  726. const atRefs = text.indexOf('<ref_links>');
  727. check('卡片在 message 通道里出现', atCard >= 0);
  728. check('**卡片排在正文之前**(开流前就上屏了)', atCard >= 0 && atBody >= 0 && atCard < atBody, `卡片@${atCard} 正文@${atBody}`);
  729. check('正文只出现一次', countOf(text, MARKER) === 1, countOf(text, MARKER));
  730. check('参考资料仍排在正文之后', atRefs > atBody, `正文@${atBody} 参考@${atRefs}`);
  731. check(
  732. '**不发出 answerStreamCompose**(卡片已在正文之前,不需要整块重写 → 界面上不会重渲一遍)',
  733. seen.compose.length === 0,
  734. seen.compose
  735. );
  736. // 尾部必须是**独立的一段**追加(不是混在正文增量里),否则渲染层会把它当正文
  737. const lastChunk = seen.messages[seen.messages.length - 1] || '';
  738. check(
  739. '尾部(参考资料)作为**独立追加**发出,且不夹带正文',
  740. lastChunk.includes('<ref_links>') && !lastChunk.includes(MARKER),
  741. JSON.stringify(lastChunk.slice(0, 60))
  742. );
  743. check('尾部追加发生在正文之后(上屏内容以参考资料结尾)', text.trimEnd().endsWith('</ref_links>'), JSON.stringify(text.slice(-40)));
  744. check(
  745. 'totalResponse 里卡片仍排在正文之前',
  746. (() => {
  747. const ans = String(seen.total?.answer || '');
  748. return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
  749. })(),
  750. String(seen.total?.answer || '').slice(0, 80)
  751. );
  752. check('totalResponse 与上屏内容顺序一致(卡片 → 正文 → 参考资料)', (() => {
  753. const ans = String(seen.total?.answer || '');
  754. return ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER) && ans.indexOf(MARKER) < ans.indexOf('<ref_links>');
  755. })(), String(seen.total?.answer || '').slice(0, 120));
  756. }
  757. console.log('\n【14】F079 的兜底:item 晚于 answer_start(旧后端或后端顺序变化)');
  758. {
  759. const seen = await runTurn([
  760. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  761. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  762. ['item', { source_id: 's1', title: '迟到卡片', card: { name: { text: '迟到卡片' } } }],
  763. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
  764. ['summary', { text: FINAL_TEXT }],
  765. ['result', { response: FINAL_TEXT }],
  766. ['done', { status: 'completed' }],
  767. ]);
  768. const text = messageText(seen);
  769. check('开流时没有卡片 → 仍发出 answerStreamCompose 兜底', seen.compose.length === 1, seen.compose.length);
  770. check('兜底 compose 的 leading 是卡片', String(seen.compose[0]?.leading || '').includes('POLICY_TABLE'), String(seen.compose[0]?.leading || '').slice(0, 40));
  771. check('卡片只出现一次(没有既提前发、又兜底发)', countCards(text) === 0, `message 通道出现 ${countCards(text)} 次(走 compose 时为 0)`);
  772. check('totalResponse 里卡片仍在正文之前', (() => {
  773. const ans = String(seen.total?.answer || '');
  774. return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
  775. })(), String(seen.total?.answer || '').slice(0, 80));
  776. }
  777. console.log('\n【15】应用层:F079 顺序下的最终版式(卡片 → 正文 → 参考资料,且都只出现一次)');
  778. {
  779. const HEAD = '您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。';
  780. const FINAL = `${HEAD}\n\n2. **确认相关时间**:是否已注册成立满1年?`;
  781. const seen = await runTurnWithApp([
  782. ['progress', { stage: 'retrieve', message: '正在查阅……' }],
  783. ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p' } }],
  784. ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
  785. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  786. ...[...HEAD].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  787. ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...HEAD].length, text: FINAL, status: 'completed' }],
  788. ['summary', { text: FINAL, notices: [] }],
  789. ['result', { response: FINAL, recommendation: {}, errors: [] }],
  790. ['done', { status: 'completed' }],
  791. ]);
  792. const content = seen.content;
  793. const normFinal = normalizeChatMarkdown(FINAL);
  794. const head = normFinal.slice(0, 20);
  795. check('卡片只出现一次', countCards(content) === 1, countCards(content));
  796. check('正文只出现一次', countOf(content, normFinal) === 1, countOf(content, normFinal));
  797. check('参考资料只出现一次', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
  798. check('卡片 → 正文 → 参考资料', content.indexOf('POLICY_TABLE') < content.indexOf(head) && content.indexOf(head) < content.indexOf('<ref_links>'), `卡片@${content.indexOf('POLICY_TABLE')} 正文@${content.indexOf(head)} 参考@${content.indexOf('<ref_links>')}`);
  799. check('**没有整块重排**(compose 一次都没发 → 界面上不会重渲一遍)', seen.compose.length === 0, seen.compose.length);
  800. }
  801. console.log('\n【16】应用层:卡片集合在开流后变大(契约外的兜底)→ 不能留下两份卡片');
  802. {
  803. const FINAL = '一、第一段说明。\n\n二、第二段说明。';
  804. const seen = await runTurnWithApp([
  805. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  806. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  807. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  808. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  809. // 开流后才到的第二张卡(违反 F079 顺序;旧后端或后端改动时可能发生)
  810. ['source', { id: 's2', title: '乙政策', type: 'policy', source: { url: 'https://example.com/p2' } }],
  811. ['item', { source_id: 's2', title: '乙政策', card: { name: { text: '乙政策' } } }],
  812. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL, status: 'completed' }],
  813. ['summary', { text: FINAL }],
  814. ['result', { response: FINAL }],
  815. ['done', { status: 'completed' }],
  816. ]);
  817. const content = seen.content;
  818. check('发了 compose 兜底', seen.compose.length === 1, seen.compose.length);
  819. check('compose 带上了「需要抹掉的旧卡片块」', !!String(seen.compose[0]?.replacedLeading || ''), String(seen.compose[0]?.replacedLeading || '').slice(0, 40));
  820. check('**卡片块只保留一份**(旧的被抹掉,不是新旧并存)', countCards(content) === 1, countCards(content));
  821. check('新卡片在内容里(乙政策)', content.includes('乙政策'));
  822. check('正文只出现一次', countOf(content, MARKER) === 1, countOf(content, MARKER));
  823. check('卡片仍在正文之前', content.indexOf('POLICY_TABLE') < content.indexOf(MARKER));
  824. }
  825. console.log('\n【17】应用层:撤稿时**已上屏的卡片保留**(契约要求),且不会被补发第二份');
  826. {
  827. // ⚠️ 断流(EOF 无 done)这条路径**会走 flushContent** —— 那时 `answerStream` 已被
  828. // `discardActiveStream` 重置,`streaming` 变回 false。若「卡片已上屏」的判据里带上
  829. // `streaming`,卡片就会被当成本轮的新内容**再发一份**(界面上两份卡片)。
  830. const seen = await runTurnWithApp([
  831. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  832. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  833. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  834. ['answer_delta', { stream_id: 's1', target: 'summary', sequence: 1, text: '半截草稿' }],
  835. // 没有 done 就到 EOF
  836. ]);
  837. const content = seen.content;
  838. check('卡片保留(开流前已上屏)且只有一份', countCards(content) === 1, countCards(content));
  839. check('草稿已被撤掉', !content.includes('半截草稿'), content.slice(-60));
  840. }
  841. console.log('\n【18】应用层:后端 error(不走 flushContent)撤稿,卡片同样保留');
  842. {
  843. const seen = await runTurnWithApp([
  844. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  845. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  846. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  847. ['answer_delta', { stream_id: 's1', target: 'summary', sequence: 1, text: '半截草稿' }],
  848. ['error', { code: 'processing_failed' }],
  849. ]);
  850. const content = seen.content;
  851. check('卡片保留且只有一份', countCards(content) === 1, countCards(content));
  852. check('草稿已被撤掉', !content.includes('半截草稿'), content.slice(-60));
  853. }
  854. console.log('\n【19】尾部提前上屏:正文定稿即发参考资料,不等 done 的 result');
  855. {
  856. // 实测(probe-answer-tail-timing):done 要等 result(375~390KB),比正文定稿晚 3~10 秒;
  857. // 而参考资料的 source 事件在 answer_start 之前就发完了。这条就是钉住「别白等」。
  858. //
  859. // ⚠️ 必须带**真实延迟**才测得出来:同步喂事件时,`done` 路径里 message 也排在 totalResponse 之前,
  860. // 索引比较会假通过(第一版就是这么写的)。这里在 summary → result 之间插 600ms,
  861. // 断言参考资料在这 600ms 走完**之前**就上屏了。
  862. const DELAY_BEFORE_RESULT = 600;
  863. const t0 = Date.now();
  864. globalThis.fetch = async () => {
  865. const encoder = new TextEncoder();
  866. const events = [
  867. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }, 0],
  868. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }, 0],
  869. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }, 0],
  870. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }, 0]),
  871. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }, 0],
  872. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }, 0],
  873. // ↓ 这段延迟就是实测里 result 让用户白等的时间
  874. ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }, DELAY_BEFORE_RESULT],
  875. ['done', { status: 'completed' }, 0],
  876. ];
  877. return new Response(
  878. new ReadableStream({
  879. async start(controller) {
  880. for (const [event, data, delay] of events) {
  881. if (delay) await new Promise((r) => setTimeout(r, delay));
  882. controller.enqueue(
  883. encoder.encode(
  884. `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`
  885. )
  886. );
  887. }
  888. controller.close();
  889. },
  890. }),
  891. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  892. );
  893. };
  894. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  895. let refsAt = null;
  896. let doneAt = null;
  897. let total = null;
  898. let composeCount = 0;
  899. let allText = '';
  900. coordinator.addEventListener('message', (m) => {
  901. allText += m;
  902. if (refsAt === null && m.includes('<ref_links>')) refsAt = Date.now() - t0;
  903. });
  904. coordinator.addEventListener('answerStreamCompose', () => composeCount++);
  905. coordinator.addEventListener('totalResponse', (p) => {
  906. doneAt = Date.now() - t0;
  907. total = p;
  908. });
  909. await coordinator.generateAnswer('测试问题');
  910. await new Promise((r) => setTimeout(r, DELAY_BEFORE_RESULT + 200));
  911. check('参考资料确实上屏了', refsAt !== null, `messages=${allText.length}`);
  912. check(
  913. `**参考资料在上面的 ${DELAY_BEFORE_RESULT}ms 延迟走完之前就上屏**(没有白等 result)`,
  914. refsAt !== null && doneAt !== null && refsAt < doneAt - DELAY_BEFORE_RESULT / 2,
  915. `参考@${refsAt}ms done@${doneAt}ms(延迟 ${DELAY_BEFORE_RESULT}ms)`
  916. );
  917. check('参考资料只出现一次(done 时不重发)', countOf(allText, '<ref_links>') === 1, countOf(allText, '<ref_links>'));
  918. check('提前上屏不触发整块重排', composeCount === 0, composeCount);
  919. check(
  920. 'totalResponse 顺序不变:卡片 → 正文 → 参考资料',
  921. (() => {
  922. const ans = String(total?.answer || '');
  923. return ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER) && ans.indexOf(MARKER) < ans.indexOf('<ref_links>');
  924. })(),
  925. String(total?.answer || '').slice(0, 100)
  926. );
  927. }
  928. console.log('\n【20】尾部提前上屏后,二维码(等 result)要插到参考资料**之前**');
  929. {
  930. const seen = await runTurnWithApp([
  931. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  932. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  933. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  934. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  935. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
  936. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
  937. // cipa 只有在 result 里才知道 —— 而二维码必须排在参考资料之前
  938. ['result', { response: FINAL_TEXT, recommendation: {}, cipa: true, errors: [] }],
  939. ['done', { status: 'completed' }],
  940. ]);
  941. const content = seen.content;
  942. const atQr = content.indexOf('<image-scope>');
  943. const atRefs = content.indexOf('<ref_links>');
  944. check('发了整块重排(尾部顺序要调)', seen.compose.length === 1, seen.compose.length);
  945. check('compose 带上了要抹掉的旧尾部', !!String(seen.compose[0]?.replacedTrailing || ''), String(seen.compose[0]?.replacedTrailing || '').slice(0, 40));
  946. check('二维码在参考资料**之前**(版式不变)', atQr >= 0 && atRefs > atQr, `二维码@${atQr} 参考@${atRefs}`);
  947. check('二维码只出现一次', countOf(content, '<image-scope>') === 1, countOf(content, '<image-scope>'));
  948. check('参考资料只出现一次(旧的被抹掉)', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
  949. check('正文只出现一次', countOf(content, MARKER) === 1, countOf(content, MARKER));
  950. check('卡片只出现一次', countCards(content) === 1, countCards(content));
  951. }
  952. console.log('\n【21】没有 result.cipa 时不会重排(常见路径)');
  953. {
  954. const seen = await runTurnWithApp([
  955. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  956. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  957. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  958. ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  959. ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
  960. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
  961. ['result', { response: FINAL_TEXT, recommendation: {}, cipa: false, errors: [] }],
  962. ['done', { status: 'completed' }],
  963. ]);
  964. check('没有二维码时零重排', seen.compose.length === 0, seen.compose.length);
  965. check('无二维码', !seen.content.includes('<image-scope>'));
  966. check('参考资料在正文之后', seen.content.indexOf('<ref_links>') > seen.content.indexOf(MARKER));
  967. }
  968. console.log('\n【22】非流式轮(旧后端)不受影响:尾部仍在 done 时一次性发出');
  969. {
  970. const seen = await runTurn([
  971. ['accepted', { message: '收到' }],
  972. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  973. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  974. ['answer', { text: FINAL_TEXT }],
  975. ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
  976. ['result', { response: FINAL_TEXT }],
  977. ['done', { status: 'completed' }],
  978. ]);
  979. check('没有流式事件 → 不提前发', !seen.streamStart.length, seen.streamStart.length);
  980. // 旧路径是「done 时一次把整段发出去」:整轮只有一条 message(不是逐片上屏)
  981. check('整轮只有一条 message(一次性整段输出)', seen.messages.length === 1, seen.messages.length);
  982. check('参考资料只出现一次', countOf(messageText(seen), '<ref_links>') === 1, countOf(messageText(seen), '<ref_links>'));
  983. check('旧路径顺序不变:正文 → 卡片 → 参考资料', (() => {
  984. const t = messageText(seen);
  985. return t.indexOf(MARKER) < t.indexOf('POLICY_TABLE') && t.indexOf('POLICY_TABLE') < t.indexOf('<ref_links>');
  986. })(), messageText(seen).slice(0, 120));
  987. }
  988. console.log('\n【23】尾部提前上屏后,「answer 概述要保留」触发的重排不能复制一份尾部');
  989. {
  990. // 用户 2026-09-21 报告的真实形态(18482a 那轮):界面上「需求拆解失败…」出现 3 次、
  991. // 参考资料出现 2 次。根因是下面这个**事件顺序**:
  992. // 契约里 `answer` 排在 `answer_end` 之后(实测 summary 紧跟 answer_end、answer 随后),
  993. // 所以 `flushTailEarly()` 在 summary 那一刻**还不知道概述要保留** → 抢跑发了尾部;
  994. // 等 `answer` 到达,`flushContent` 发现概述要保留 → 整块重排,而重排时
  995. // `replacedTrailing` 只在 `needsReorder` 时才传 → 已上屏的旧尾部没被抹掉 → **尾部两份**。
  996. const RICH_HEAD = '您问的政策我们这样理解:可以按下面的渠道办理。\n\n### 一、惠企政策查询\n\n**办理渠道**\n\n- 随申兑平台';
  997. const OVERVIEW = '需求拆解失败,本次仅按原问题检索。';
  998. const seen = await runTurnWithApp([
  999. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  1000. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  1001. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  1002. ...[...RICH_HEAD].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  1003. ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...RICH_HEAD].length, text: RICH_HEAD, status: 'completed' }],
  1004. // summary 紧跟 answer_end:此刻 answer 还没到 → flushTailEarly 抢跑发出尾部
  1005. ['summary', { text: RICH_HEAD, source_ids: ['s1'], notices: [OVERVIEW] }],
  1006. // answer 随后才到,且它不是正文的前缀 → 概述要保留 → done 时要整块重排
  1007. ['answer', { text: OVERVIEW }],
  1008. ['result', { response: RICH_HEAD, recommendation: {}, errors: [] }],
  1009. ['done', { status: 'completed' }],
  1010. ]);
  1011. const content = seen.content;
  1012. check('**参考资料只出现一次**(重排不复制旧尾部)', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
  1013. check(
  1014. '**那句话只出现 1 次**(后端把它同时放进了 answer.text 与 notices,前端去重)',
  1015. countOf(content, OVERVIEW) === 1,
  1016. countOf(content, OVERVIEW)
  1017. );
  1018. check('去重后不再需要整块重排(正文不重渲)', seen.compose.length === 0, seen.compose.length);
  1019. check('正文只出现一次', countOf(content, '办理渠道') === 1, countOf('办理渠道') >= 0 ? countOf(content, '办理渠道') : 0);
  1020. check('卡片只出现一次', countCards(content) === 1, countCards(content));
  1021. check(
  1022. '卡片 → 正文 → notices → 参考资料 顺序不变',
  1023. (() => {
  1024. const t = content;
  1025. return (
  1026. t.indexOf('POLICY_TABLE') < t.indexOf('办理渠道') &&
  1027. t.indexOf('办理渠道') < t.indexOf(OVERVIEW) &&
  1028. t.indexOf(OVERVIEW) < t.indexOf('<ref_links>')
  1029. );
  1030. })(),
  1031. content.slice(0, 80)
  1032. );
  1033. }
  1034. console.log('\n【24】概述确实是**另一段内容**(不在 notices/正文里)→ 仍保留在正文之上');
  1035. {
  1036. // 去重不能误伤:真的多出来的一段话必须照旧展示(宁可多一段,不能丢字)
  1037. const BODY = '这是正文,讲的是办理渠道与材料。';
  1038. const OTHER = '另外提醒:本答复仅供参照,具体以窗口为准。';
  1039. const seen = await runTurnWithApp([
  1040. ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  1041. ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
  1042. ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
  1043. ...[...BODY].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
  1044. ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...BODY].length, text: BODY, status: 'completed' }],
  1045. ['summary', { text: BODY, source_ids: ['s1'], notices: ['提示:材料需加盖公章'] }],
  1046. ['answer', { text: OTHER }],
  1047. ['result', { response: BODY, recommendation: {}, errors: [] }],
  1048. ['done', { status: 'completed' }],
  1049. ]);
  1050. const content = seen.content;
  1051. check('概述保留了下来', content.includes(OTHER), content.slice(0, 80));
  1052. check('概述排在正文之前', content.indexOf(OTHER) < content.indexOf(BODY));
  1053. check('正文与参考资料各只出现一次', countOf(content, BODY) === 1 && countOf(content, '<ref_links>') === 1, `${countOf(content, BODY)}/${countOf(content, '<ref_links>')}`);
  1054. check('notices 也在', content.includes('材料需加盖公章'));
  1055. }
  1056. console.log('\n【25】纯函数:isOverviewAlreadyShown 的判据');
  1057. {
  1058. check('与 notice 同句(空白差异不计)→ 已展示', isOverviewAlreadyShown('需求拆解失败,本次仅按原问题检索。', '正文', ['需求拆解失败,本次仅按原问题检索。']) === true);
  1059. check('正文里已含这句 → 已展示', isOverviewAlreadyShown('我们青浦目前没有专项扶持', '先说明:我们青浦目前没有专项扶持,但有几项可以关注。', []) === true);
  1060. check('是另一段内容 → 未展示', isOverviewAlreadyShown('另外提醒:以窗口为准。', '这是正文。', ['提示:材料需盖章']) === false);
  1061. check('空概述 → 视为已展示(不展示)', isOverviewAlreadyShown('', '正文', []) === true);
  1062. check('notices 为空数组不报错', isOverviewAlreadyShown('甲', '乙', []) === false);
  1063. }
  1064. console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
  1065. process.exit(fail ? 1 : 0);