| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- /**
- * 用**真实 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);
|