verify-policy-library.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * 验证「惠企政策」判定与置顶逻辑。
  3. *
  4. * 验证什么:
  5. * 1. 惠企政策判定——优先用接口字段 is_policy_library,缺失时回退到标题匹配,
  6. * 标题匹配要能容忍《》全半角等差异
  7. * 2. 置顶——本轮匹配到的惠企政策排在最前,其余保持库内原顺序
  8. *
  9. * 怎么跑(在项目根目录):
  10. * npm run build:tools # 或手工:npx esbuild <入口> --bundle ...
  11. * node harness/tools/verify-policy-library.mjs
  12. *
  13. * 说明:本脚本直接引用 src 里的同一份实现(policy-library-utils.ts),
  14. * 不是复制一份逻辑——复制会漂移,测的就不是真代码了。
  15. */
  16. import { createRequire } from 'node:module';
  17. import {
  18. normPolicyName,
  19. buildLibraryNameSet,
  20. isPolicyLibraryItem,
  21. collectMatchedLibraryNames,
  22. pinMatchedFirst,
  23. buildPolicyDetailUrl,
  24. POLICY_DETAIL_URL_BASE,
  25. } from './_policy-library-utils.mjs';
  26. const require = createRequire(import.meta.url);
  27. // 2026-09-24 用户把本地副本换成了 v2(**文件名与字段名都变了**,见下方第 0 节)
  28. const library = require('../../public/merchant-agent/policies_public.v2.json');
  29. /** 政策名:v2 用中文键「政策名称」,老数据用 `name` —— 两版都认 */
  30. const policyNameOf = (p) => String(p?.['政策名称'] || p?.name || '');
  31. let pass = 0;
  32. let fail = 0;
  33. const check = (name, cond, extra = '') => {
  34. if (cond) {
  35. pass++;
  36. console.log(' ok ' + name);
  37. } else {
  38. fail++;
  39. console.log(' FAIL ' + name + ' ' + extra);
  40. }
  41. };
  42. console.log('惠企政策库:' + library.length + ' 条');
  43. const nameSet = buildLibraryNameSet(library);
  44. console.log('去重后政策名:' + nameSet.size + ' 个\n');
  45. console.log('=== 0. 本地库数据形态(2026-09-24 换成 v2,字段名也从英文键变成中文键)===');
  46. {
  47. const first = library[0] || {};
  48. check('顶层是数组', Array.isArray(library));
  49. check('有内容(>0 条)', library.length > 0, String(library.length));
  50. check(
  51. '**取得到政策名**(v2 用「政策名称」;前端 normalizePolicy 也认这个键)',
  52. library.filter((p) => policyNameOf(p).trim()).length === library.length,
  53. `${library.filter((p) => policyNameOf(p).trim()).length}/${library.length}`
  54. );
  55. check(
  56. '**取得到部门**(v2 用「政策所属部门」;前端 source 认这个键 → 部门筛选才有东西可筛)',
  57. library.every((p) => String(p?.['政策所属部门'] ?? '').trim() !== '')
  58. );
  59. check(
  60. '**取得到申报事项**(v2 用「部门上报事项」/「大模型拆解申报事项」)',
  61. library.every((p) => String(p?.['部门上报事项'] ?? p?.['大模型拆解申报事项'] ?? '').trim() !== '')
  62. );
  63. check('有政策原文地址(详情链接的来源)', library.every((p) => String(p?.['政策原文地址'] ?? '').trim() !== ''));
  64. // ⚠️ v2 **没有** `data.市级政策id` —— 所以 buildPolicyDetailUrl() 对库条目返回空串,
  65. // 详情链接改由 `政策原文地址`(前端 apply_link 的别名链里有它)提供。
  66. const idCount = library.filter((p) => String(p?.data?.['市级政策id'] ?? '').trim()).length;
  67. check(
  68. 'v2 不带 市级政策id(详情链接走「政策原文地址」,不是 id 拼的地址)',
  69. idCount === 0,
  70. `实际 ${idCount} 条有 id —— 若数据又变了,请回看 PolicyMatch 的 apply_link 别名链`
  71. );
  72. console.log(' (字段样本:' + Object.keys(first).slice(0, 8).join(' / ') + ' …)');
  73. }
  74. console.log('=== 1. 名称归一化 ===');
  75. check(
  76. '《》差异可忽略',
  77. normPolicyName('《上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施》') ===
  78. normPolicyName('上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施')
  79. );
  80. check('括号差异可忽略', normPolicyName('政策(试行)') === normPolicyName('政策(试行)'));
  81. check('空白差异可忽略', normPolicyName(' 政策 A ') === normPolicyName('政策A'));
  82. console.log('\n=== 2. 惠企政策判定 ===');
  83. const sampleName = policyNameOf(library[0]).replace(/[《》]/g, '');
  84. check(
  85. '标题能在库里找到 → 是惠企政策',
  86. isPolicyLibraryItem({ title: sampleName }, nameSet)
  87. );
  88. check(
  89. '标题带《》也能找到 → 是惠企政策',
  90. isPolicyLibraryItem({ title: '《' + sampleName + '》' }, nameSet)
  91. );
  92. check(
  93. '接口字段 is_policy_library=true → 是惠企政策',
  94. isPolicyLibraryItem({ title: '库里没有的名字', originalData: { is_policy_library: true } }, nameSet)
  95. );
  96. check(
  97. '库中没有且无字段 → 不是惠企政策',
  98. !isPolicyLibraryItem({ title: '这个名字肯定不在惠企政策库里' }, nameSet)
  99. );
  100. check('空标题 → 不是惠企政策', !isPolicyLibraryItem({ title: '' }, nameSet));
  101. console.log('\n=== 3. 收集本轮匹配到的惠企政策 ===');
  102. const cards = [
  103. { title: sampleName }, // 命中
  104. { title: '不在库里的政策' },
  105. { title: '库里没有但后端标记了', originalData: { is_policy_library: true } },
  106. ];
  107. const matched = collectMatchedLibraryNames(cards, nameSet);
  108. check('收集到 2 条命中', matched.size === 2, '实际 ' + matched.size);
  109. console.log('\n=== 4. 置顶排序 ===');
  110. const items = [
  111. { title: 'A-未命中' },
  112. { title: sampleName }, // 命中,应置顶
  113. { title: 'B-未命中' },
  114. { title: '库里没有但后端标记了', originalData: { is_policy_library: true } }, // 命中,应置顶
  115. { title: 'C-未命中' },
  116. ];
  117. const ordered = pinMatchedFirst(items, matched, (i) => i.title);
  118. check('命中项排在最前', ordered.slice(0, 2).every((i) => matched.has(normPolicyName(i.title))), JSON.stringify(ordered.map((i) => i.title)));
  119. check('未命中项保持原顺序', JSON.stringify(ordered.slice(2).map((i) => i.title)) === JSON.stringify(['A-未命中', 'B-未命中', 'C-未命中']), JSON.stringify(ordered.slice(2).map((i) => i.title)));
  120. check('总数不变', ordered.length === items.length);
  121. check('无命中时顺序不变', JSON.stringify(pinMatchedFirst(items, new Set(), (i) => i.title).map((i) => i.title)) === JSON.stringify(items.map((i) => i.title)));
  122. console.log('\n=== 5. 真实场景抽样(库里前 3 条政策名)===');
  123. library.slice(0, 3).forEach((p) => {
  124. const t = policyNameOf(p).replace(/[《》]/g, '');
  125. console.log(' ' + (isPolicyLibraryItem({ title: t }, nameSet) ? '✓' : '✗') + ' ' + t.slice(0, 40));
  126. });
  127. console.log('\n=== 6. 政策详情页地址(由 市级政策id 拼)===');
  128. {
  129. const withId = { data: { '市级政策id': '676eb30861aee302a8607104' } };
  130. check(
  131. '有 id → 拼出正确地址',
  132. buildPolicyDetailUrl(withId) === POLICY_DETAIL_URL_BASE + '?id=676eb30861aee302a8607104',
  133. buildPolicyDetailUrl(withId)
  134. );
  135. check('无 id → 空串(不猜)', buildPolicyDetailUrl({ data: {} }) === '');
  136. check('data 缺失 → 空串', buildPolicyDetailUrl({}) === '');
  137. check('null → 空串', buildPolicyDetailUrl(null) === '');
  138. check('id 为空白 → 空串', buildPolicyDetailUrl({ data: { '市级政策id': ' ' } }) === '');
  139. check('id 前后空格 → 去掉', buildPolicyDetailUrl({ data: { '市级政策id': ' abc ' } }) === POLICY_DETAIL_URL_BASE + '?id=abc');
  140. check('特殊字符 → 编码', buildPolicyDetailUrl({ data: { '市级政策id': 'a&b=c' } }) === POLICY_DETAIL_URL_BASE + '?id=a%26b%3Dc');
  141. // 真实库覆盖率
  142. // ⚠️ 老数据(v1)是 261/262 有 `data.市级政策id`,所以详情地址由它拼;
  143. // **2026-09-24 换成的 v2 没有这个字段** → 这里覆盖率是 0,属预期:
  144. // v2 的详情链接改由「政策原文地址」提供(PolicyMatch 的 apply_link 别名链里排在
  145. // buildPolicyDetailUrl 之前,所以列表里照样点得开)。
  146. const covered = library.filter((p) => buildPolicyDetailUrl(p)).length;
  147. console.log(' 真实库覆盖率: ' + covered + ' / ' + library.length + '(v2 无 市级政策id,详情链接走「政策原文地址」)');
  148. check('v2 覆盖率 = 0(有 id 才拼地址;数据若又变了请一并更新这里)', covered === 0, '实际 ' + covered);
  149. const withLink = library.filter((p) => String(p?.['政策原文地址'] ?? '').trim()).length;
  150. check('但每条都有「政策原文地址」(列表里的链接来源)', withLink === library.length, `${withLink}/${library.length}`);
  151. }
  152. console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
  153. process.exit(fail ? 1 : 0);