verify-company-info-passthrough.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * 验证适配层把 `result` 事件里的 **company_info 原样带出**(不解析、不渲染进正文)。
  3. *
  4. * 为什么必须测:企业信息同步的第一步就是「拿到 company_info」——
  5. * 原先 `case 'result'` 是个**空分支**,整个 data 被丢弃;本次改成暂存并在 done 时
  6. * 随 `totalResponse` 交给应用侧。这条通路没有别的地方能覆盖(DMS 侧验证是从
  7. * 「已经拿到分类结果」开始的),所以用假 SSE 流喂真实协调器来钉住。
  8. *
  9. * 不联网:把 `globalThis.fetch` 打桩成返回一段构造好的 SSE 流。
  10. *
  11. * 怎么跑(先按 _entry-coordinator.ts 的说明重新打包):
  12. * npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
  13. * --outfile=harness/tools/_coordinator.mjs --alias:@=./src --define:import.meta.env='{}'
  14. * node harness/tools/verify-company-info-passthrough.mjs
  15. */
  16. import { ApiChatCoordinator } from './_coordinator.mjs';
  17. let pass = 0;
  18. let fail = 0;
  19. const check = (name, cond, extra = '') => {
  20. if (cond) {
  21. pass++;
  22. console.log(` ok ${name}`);
  23. } else {
  24. fail++;
  25. console.log(` FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
  26. }
  27. };
  28. const COMPANY_INFO = {
  29. company: { KeyNo: 'k1', Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' },
  30. profile: { data: { Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' }, source: { provider: '企查查' } },
  31. honors: { records: [], sources: [], complete: true, error: null },
  32. };
  33. const sse = (events) =>
  34. events
  35. .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
  36. .join('');
  37. /** 把一段 SSE 文本打桩成 fetch 的响应 */
  38. const stubFetchWith = (sseText) => {
  39. globalThis.fetch = async () => {
  40. const stream = new ReadableStream({
  41. start(controller) {
  42. controller.enqueue(new TextEncoder().encode(sseText));
  43. controller.close();
  44. },
  45. });
  46. return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
  47. };
  48. };
  49. /** 把一轮里收集到的 message 事件拼成整条内容 */
  50. const seen_message_text = (seen) => (seen.messages || []).join('\n\n');
  51. const runTurn = async (events) => {
  52. stubFetchWith(sse(events));
  53. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  54. const seen = { totalResponse: null, messages: [] };
  55. coordinator.addEventListener('totalResponse', (payload) => (seen.totalResponse = payload));
  56. coordinator.addEventListener('message', (msg) => seen.messages.push(msg));
  57. await coordinator.generateAnswer('测试问题');
  58. // flushContent 会等两帧再 emit totalResponse,给它一点时间
  59. await new Promise((resolve) => setTimeout(resolve, 60));
  60. return seen;
  61. };
  62. const BASE_EVENTS = [
  63. ['accepted', { message: '收到' }],
  64. ['answer', { text: '这是回答正文。' }],
  65. ];
  66. console.log('【1】result 带 company_info → totalResponse 原样带出');
  67. {
  68. const seen = await runTurn([
  69. ...BASE_EVENTS,
  70. ['result', { response: '这是回答正文。', company_info: COMPANY_INFO, recommendation: null, errors: [] }],
  71. ['done', { status: 'completed' }],
  72. ]);
  73. check('收到了 totalResponse', !!seen.totalResponse, seen.totalResponse);
  74. check('payload 里有 company_info', !!seen.totalResponse?.company_info);
  75. check(
  76. '内容与后端下发**逐字段一致**(含嵌套的 company/profile/honors)',
  77. JSON.stringify(seen.totalResponse?.company_info) === JSON.stringify(COMPANY_INFO),
  78. seen.totalResponse?.company_info
  79. );
  80. check(
  81. '**没有混进渲染内容**(消息里不应出现 credit code)',
  82. !seen.messages.join('').includes('TESTCODE1'),
  83. seen.messages.map((m) => m.slice(0, 60))
  84. );
  85. }
  86. console.log('\n【2】result 不带 company_info → payload 里也不该有这个键');
  87. {
  88. const seen = await runTurn([
  89. ...BASE_EVENTS,
  90. ['result', { response: '这是回答正文。', recommendation: null, errors: [] }],
  91. ['done', { status: 'completed' }],
  92. ]);
  93. check('没有 company_info 键(而不是 null)', seen.totalResponse && !('company_info' in seen.totalResponse), seen.totalResponse);
  94. }
  95. console.log('\n【3】company_info 为 null / 空对象 → 不往下传(省得白跑一次分类)');
  96. {
  97. const seenNull = await runTurn([
  98. ...BASE_EVENTS,
  99. ['result', { response: 'x', company_info: null }],
  100. ['done', { status: 'completed' }],
  101. ]);
  102. check('company_info=null → 不传', seenNull.totalResponse && !('company_info' in seenNull.totalResponse));
  103. const seenEmpty = await runTurn([
  104. ...BASE_EVENTS,
  105. ['result', { response: 'x', company_info: {} }],
  106. ['done', { status: 'completed' }],
  107. ]);
  108. // 空对象会带出去(适配层不做业务判断),但下游的 isCompanyInfoUsable 会静默跳过
  109. check('company_info={} → 带出去交给下游判断', !!seenEmpty.totalResponse?.company_info, seenEmpty.totalResponse?.company_info);
  110. }
  111. console.log('\n【4】跨轮不串:上一轮有 company_info,下一轮没有 → 不该沿用');
  112. {
  113. stubFetchWith(
  114. sse([
  115. ...BASE_EVENTS,
  116. ['result', { response: 'x', company_info: COMPANY_INFO }],
  117. ['done', { status: 'completed' }],
  118. ])
  119. );
  120. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  121. const payloads = [];
  122. coordinator.addEventListener('totalResponse', (payload) => payloads.push(payload));
  123. await coordinator.generateAnswer('第一轮');
  124. await new Promise((resolve) => setTimeout(resolve, 60));
  125. stubFetchWith(
  126. sse([
  127. ...BASE_EVENTS,
  128. ['result', { response: 'x' }], // 这一轮没有 company_info
  129. ['done', { status: 'completed' }],
  130. ])
  131. );
  132. await coordinator.generateAnswer('第二轮');
  133. await new Promise((resolve) => setTimeout(resolve, 60));
  134. check('两轮各收到一次 totalResponse', payloads.length === 2, payloads.length);
  135. check('第一轮带 company_info', !!payloads[0]?.company_info);
  136. check('第二轮**没有**沿用上一轮的 company_info(resetTurnContent 生效)', !('company_info' in (payloads[1] || {})), payloads[1]);
  137. }
  138. console.log('\n【5】result.cipa → 企业微信二维码块(<image-scope>)');
  139. {
  140. const shown = await runTurn([
  141. ...BASE_EVENTS,
  142. ['result', { response: '这是回答正文。', cipa: true }],
  143. ['done', { status: 'completed' }],
  144. ]);
  145. const text = seen_message_text(shown);
  146. check('cipa=true → 追加 <image-scope> 块', /<image-scope>[\s\S]*?<\/image-scope>/.test(text), text.slice(-120));
  147. check('块里有非空的图片地址', /<image-scope>\s*\S[^\n]*\s*<\/image-scope>/.test(text));
  148. check('正文本身没被污染(正文仍在最前面)', text.trimStart().startsWith('这是回答正文。'), text.slice(0, 40));
  149. // 二维码上方的一句引导语(用户 2026-09-20 指定文案),必须在图片**之前**
  150. const TIP = '如果您还有其他疑问,欢迎随时向我咨询,也可以联系CIPA海外服务平台获取支持。';
  151. check('二维码上方有引导语', text.includes(TIP), text.slice(-160));
  152. check('引导语排在图片之前', text.indexOf(TIP) > -1 && text.indexOf(TIP) < text.indexOf('<image-scope>'), {
  153. tip: text.indexOf(TIP),
  154. img: text.indexOf('<image-scope>'),
  155. });
  156. // 位置:正文 → 二维码 → 参考资料(用户 2026-09-20 指定「二维码放在参考资料之前」)
  157. const withSources = await runTurn([
  158. ...BASE_EVENTS,
  159. // 参考资料块要求来源**有链接**(见 buildRefLinksContent 的 filter)
  160. ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
  161. ['result', { response: '这是回答正文。', cipa: true }],
  162. ['done', { status: 'completed' }],
  163. ]);
  164. const ordered = seen_message_text(withSources);
  165. const qrAt = ordered.indexOf('<image-scope>');
  166. const refAt = ordered.indexOf('<ref_links');
  167. const bodyAt = ordered.indexOf('这是回答正文。');
  168. check('这一轮确实有参考资料块(否则位置断言无意义)', refAt > -1, ordered.slice(-160));
  169. check('二维码在**参考资料之前**', qrAt > -1 && refAt > -1 && qrAt < refAt, { qrAt, refAt });
  170. check('二维码在正文之后', bodyAt > -1 && qrAt > bodyAt, { bodyAt, qrAt });
  171. check('参考资料仍排在整条内容的最末尾', ordered.trimEnd().endsWith('</ref_links>'));
  172. const off = await runTurn([
  173. ...BASE_EVENTS,
  174. ['result', { response: '这是回答正文。', cipa: false }],
  175. ['done', { status: 'completed' }],
  176. ]);
  177. check('cipa=false → 不加图片块', !seen_message_text(off).includes('image-scope'));
  178. const missing = await runTurn([
  179. ...BASE_EVENTS,
  180. ['result', { response: '这是回答正文。' }],
  181. ['done', { status: 'completed' }],
  182. ]);
  183. check('result 里没有 cipa 字段 → 不加图片块', !seen_message_text(missing).includes('image-scope'));
  184. const truthyString = await runTurn([
  185. ...BASE_EVENTS,
  186. ['result', { response: 'x', cipa: 'true' }],
  187. ['done', { status: 'completed' }],
  188. ]);
  189. check('非布尔值一律当 false(严格 === true)', !seen_message_text(truthyString).includes('image-scope'));
  190. // ⚠️ 补问轮不展示(用户 2026-09-20 从截图指出):
  191. // 界面那时是「补问卡片」,还没给出正文回答,贴二维码既突兀也没意义
  192. const INTERRUPT = {
  193. kind: 'company_need',
  194. status: 'not_found',
  195. question: '本次未检索到与您提供的名称或代码匹配的企业。',
  196. input_help: "请核对并补充工商注册全名或统一社会信用代码;也可以输入'跳过公司查询'。",
  197. };
  198. const withInterrupt = await runTurn([
  199. ...BASE_EVENTS,
  200. ['interrupt', INTERRUPT],
  201. ['result', { response: 'x', cipa: true }],
  202. ['done', { status: 'needs_input' }],
  203. ]);
  204. const interruptText = seen_message_text(withInterrupt);
  205. check('补问轮:选项卡仍正常出现', interruptText.includes('<question-cards>'), interruptText.slice(0, 80));
  206. check('**补问轮即使 cipa=true 也不展示二维码**', !interruptText.includes('image-scope'), interruptText.slice(-80));
  207. const withCandidates = await runTurn([
  208. ...BASE_EVENTS,
  209. ['interrupt', { kind: 'company_selection', status: 'found', question: '请选择您所指的公司', result: { candidates: [{ Name: '甲公司', CreditCode: 'C1' }] } }],
  210. ['result', { response: 'x', cipa: true }],
  211. ['done', { status: 'needs_input' }],
  212. ]);
  213. check('候选确认轮同样不展示二维码', !seen_message_text(withCandidates).includes('image-scope'));
  214. }
  215. console.log('\n【6】cipa 跨轮不串(resetTurnContent 生效)');
  216. {
  217. stubFetchWith(
  218. sse([
  219. ...BASE_EVENTS,
  220. ['result', { response: '第一轮', cipa: true }],
  221. ['done', { status: 'completed' }],
  222. ])
  223. );
  224. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  225. const rounds = [];
  226. coordinator.addEventListener('message', (msg) => rounds.push(msg));
  227. await coordinator.generateAnswer('第一轮');
  228. await new Promise((resolve) => setTimeout(resolve, 60));
  229. stubFetchWith(
  230. sse([
  231. ...BASE_EVENTS,
  232. ['result', { response: '第二轮' }], // 这一轮 cipa 缺省
  233. ['done', { status: 'completed' }],
  234. ])
  235. );
  236. await coordinator.generateAnswer('第二轮');
  237. await new Promise((resolve) => setTimeout(resolve, 60));
  238. check('第一轮有图片块', /<image-scope>/.test(rounds[0] || ''));
  239. check('第二轮**没有**沿用上一轮的 cipa', !/<image-scope>/.test(rounds[1] || ''), (rounds[1] || '').slice(-80));
  240. }
  241. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  242. process.exit(fail ? 1 : 0);