verify-policy-card-list.mjs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /**
  2. * 验证「更多」→「政策列表」侧边栏的纯逻辑:跨消息聚合、去重、排序、关键词过滤。
  3. *
  4. * 为什么要单独测:卡片区每个 PolicyMatch 实例只持有**自己那条消息**的卡片,
  5. * 侧边栏要的是**整个会话**的聚合,这段逻辑(解析历史消息 → 去重 → 排序 → 过滤)
  6. * 容易在边界上出错(坏 JSON、重复卡片、精简格式占位符、同分排序抖动)。
  7. *
  8. * 怎么跑(在项目根目录):
  9. * npx esbuild harness/tools/_entry-policy-cards.ts --bundle --format=esm \
  10. * --outfile=harness/tools/_policy-cards.mjs
  11. * node harness/tools/verify-policy-card-list.mjs
  12. */
  13. import {
  14. collectPolicyCardsFromMessages,
  15. buildPolicyCardDedupKey,
  16. dedupePolicyItems,
  17. sortPolicyCardsByMatchScore,
  18. matchesPolicyCardKeyword,
  19. } from './_policy-cards.mjs';
  20. let pass = 0;
  21. let fail = 0;
  22. const check = (name, cond, extra = '') => {
  23. if (cond) {
  24. pass++;
  25. console.log(' ok ' + name);
  26. } else {
  27. fail++;
  28. console.log(' FAIL ' + name + ' ' + extra);
  29. }
  30. };
  31. /** 造一条带 POLICY_TABLE 块的 AI 消息(格式与适配层 flushContent 产出一致) */
  32. const aiMessage = (cards, extraText = '正文') =>
  33. `${extraText}\n<!-- POLICY_TABLE ${JSON.stringify({ data: cards })} POLICY_TABLE -->\n`;
  34. console.log('【1】跨消息聚合(collectPolicyCardsFromMessages)');
  35. const cardA = { declaration_item: '政策甲 - 事项一', title: '政策甲', match_score: 90 };
  36. const cardB = { declaration_item: '政策乙 - 事项一', title: '政策乙', match_score: 45 };
  37. const cardC = { declaration_item: '政策丙 - 事项一', title: '政策丙', match_score: 60 };
  38. const messages = [
  39. { role: 'user', content: '我有什么政策' }, // 用户消息不该被解析
  40. { role: 'ai', content: aiMessage([cardA, cardB]) },
  41. { role: 'ai', content: '这条没有卡片' },
  42. { role: 'ai', content: aiMessage([cardC]) },
  43. ];
  44. const collected = collectPolicyCardsFromMessages(messages);
  45. check('收集到 3 张卡片', collected.length === 3, `实际 ${collected.length}`);
  46. check('保持消息顺序(甲→乙→丙)', collected.map((c) => c.title).join(',') === '政策甲,政策乙,政策丙', collected.map((c) => c.title).join(','));
  47. check('用户消息被跳过', !collected.some((c) => c.title === '我有什么政策'));
  48. check('空输入不抛错 → []', Array.isArray(collectPolicyCardsFromMessages([])) && collectPolicyCardsFromMessages([]).length === 0);
  49. check('null 输入不抛错 → []', collectPolicyCardsFromMessages(null).length === 0);
  50. check('坏 JSON 不抛错且不贡献卡片', collectPolicyCardsFromMessages([{ role: 'ai', content: '<!-- POLICY_TABLE {坏JSON POLICY_TABLE -->' }]).length === 0);
  51. check('无 POLICY_TABLE 块 → []', collectPolicyCardsFromMessages([{ role: 'ai', content: '纯文本回答' }]).length === 0);
  52. console.log('\n【2】去重键(buildPolicyCardDedupKey)');
  53. check(
  54. '同 declaration_item → 同键',
  55. buildPolicyCardDedupKey(cardA) === buildPolicyCardDedupKey({ ...cardA, match_score: 10 }),
  56. buildPolicyCardDedupKey(cardA)
  57. );
  58. check('不同 declaration_item → 不同键', buildPolicyCardDedupKey(cardA) !== buildPolicyCardDedupKey(cardB));
  59. // 精简格式:declaration_item 是占位符,不能把不同卡片并成一条
  60. const minimalA = { declaration_item: '查看政策详情', title: '甲', match_reason: '理由A' };
  61. const minimalB = { declaration_item: '查看政策详情', title: '乙', match_reason: '理由B' };
  62. check('占位符「查看政策详情」不按它去重(不同卡片不同键)', buildPolicyCardDedupKey(minimalA) !== buildPolicyCardDedupKey(minimalB), buildPolicyCardDedupKey(minimalA));
  63. check('占位符场景仍可用同内容命中同键', buildPolicyCardDedupKey(minimalA) === buildPolicyCardDedupKey({ ...minimalA }));
  64. console.log('\n【3】去重(dedupePolicyItems,首见优先)');
  65. const withDup = [cardA, cardB, { ...cardA, match_score: 999 }, cardC, { ...cardB }];
  66. const deduped = dedupePolicyItems(withDup, buildPolicyCardDedupKey);
  67. check('去重后 3 条', deduped.length === 3, `实际 ${deduped.length}`);
  68. check('保留的是**首见**那条(match_score=90 而非 999)', deduped[0].match_score === 90, String(deduped[0].match_score));
  69. check('保持首见顺序', deduped.map((c) => c.title).join(',') === '政策甲,政策乙,政策丙');
  70. check('空输入安全', dedupePolicyItems(null, buildPolicyCardDedupKey).length === 0);
  71. console.log('\n【4】排序(sortPolicyCardsByMatchScore)');
  72. const sorted = sortPolicyCardsByMatchScore([cardB, cardA, cardC]); // 45, 90, 60
  73. check('按 match_score 降序', sorted.map((c) => c.match_score).join(',') === '90,60,45', sorted.map((c) => c.match_score).join(','));
  74. const tieA = { declaration_item: 'a', match_score: 90, tag2: 'first' };
  75. const tieB = { declaration_item: 'b', match_score: 90, tag2: 'second' };
  76. const tieSorted = sortPolicyCardsByMatchScore([tieA, tieB]);
  77. check('同分保持原相对顺序(稳定排序)', tieSorted[0].tag2 === 'first' && tieSorted[1].tag2 === 'second', tieSorted.map((c) => c.tag2).join(','));
  78. check('缺 match_score 视为 0 排最后', sortPolicyCardsByMatchScore([{ declaration_item: 'x' }, cardB])[1].declaration_item === 'x');
  79. check('空输入安全', sortPolicyCardsByMatchScore([]).length === 0);
  80. console.log('\n【5】关键词过滤(matchesPolicyCardKeyword)');
  81. const searchable = {
  82. title: '关于创业扶持的通知',
  83. desc: '面向本区创业者',
  84. declaration_item: '创业开办费补贴',
  85. match_reason: '用户询问开办补贴',
  86. source: '区人社局',
  87. enterprise_benefit: '最高2万元补贴',
  88. };
  89. check('空关键词恒真', matchesPolicyCardKeyword(searchable, '') === true && matchesPolicyCardKeyword(searchable, ' ') === true);
  90. check('命中 title', matchesPolicyCardKeyword(searchable, '创业扶持') === true);
  91. check('命中 desc', matchesPolicyCardKeyword(searchable, '本区创业者') === true);
  92. check('命中 declaration_item', matchesPolicyCardKeyword(searchable, '开办费') === true);
  93. check('命中 match_reason', matchesPolicyCardKeyword(searchable, '询问') === true);
  94. check('命中 source(部门)', matchesPolicyCardKeyword(searchable, '区人社局') === true);
  95. check('命中 enterprise_benefit', matchesPolicyCardKeyword(searchable, '2万元') === true);
  96. check('大小写不敏感', matchesPolicyCardKeyword({ title: 'ABC Policy' }, 'abc policy') === true);
  97. check('不命中返回 false', matchesPolicyCardKeyword(searchable, '不存在的词') === false);
  98. check('item 为 null 不抛错', matchesPolicyCardKeyword(null, 'x') === false);
  99. console.log('\n【6】端到端组合(聚合 → 去重 → 排序 → 过滤)');
  100. const e2e = sortPolicyCardsByMatchScore(
  101. dedupePolicyItems(collectPolicyCardsFromMessages(messages), buildPolicyCardDedupKey)
  102. );
  103. check('组合后 3 条且按分数降序', e2e.length === 3 && e2e[0].match_score === 90, e2e.map((c) => c.match_score).join(','));
  104. const e2eFiltered = e2e.filter((c) => matchesPolicyCardKeyword(c, '政策乙'));
  105. check('组合后可按关键词筛出 1 条', e2eFiltered.length === 1 && e2eFiltered[0].title === '政策乙', String(e2eFiltered.length));
  106. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  107. process.exit(fail ? 1 : 0);