verify-policy-library.mjs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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 { readFileSync } from 'node:fs';
  18. import {
  19. normPolicyName,
  20. buildLibraryNameSet,
  21. isPolicyLibraryItem,
  22. collectMatchedLibraryNames,
  23. pinMatchedFirst,
  24. buildPolicyDetailUrl,
  25. POLICY_DETAIL_URL_BASE,
  26. } from './_policy-library-utils.mjs';
  27. const require = createRequire(import.meta.url);
  28. // 2026-09-24 用户把本地副本换成了 v2(**文件名与字段名都变了**,见下方第 0 节)
  29. const library = require('../../public/merchant-agent/policies_public.v2.json');
  30. /** 政策名:v2 用中文键「政策名称」,老数据用 `name` —— 两版都认 */
  31. const policyNameOf = (p) => String(p?.['政策名称'] || p?.name || '');
  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);
  41. }
  42. };
  43. console.log('惠企政策库:' + library.length + ' 条');
  44. const nameSet = buildLibraryNameSet(library);
  45. console.log('去重后政策名:' + nameSet.size + ' 个\n');
  46. console.log('=== 0. 本地库数据形态(2026-09-24 换成 v2,字段名也从英文键变成中文键)===');
  47. {
  48. const first = library[0] || {};
  49. check('顶层是数组', Array.isArray(library));
  50. check('有内容(>0 条)', library.length > 0, String(library.length));
  51. check(
  52. '**取得到政策名**(v2 用「政策名称」;前端 normalizePolicy 也认这个键)',
  53. library.filter((p) => policyNameOf(p).trim()).length === library.length,
  54. `${library.filter((p) => policyNameOf(p).trim()).length}/${library.length}`
  55. );
  56. check(
  57. '**取得到部门**(v2 用「政策所属部门」;前端 source 认这个键 → 部门筛选才有东西可筛)',
  58. library.every((p) => String(p?.['政策所属部门'] ?? '').trim() !== '')
  59. );
  60. check(
  61. '**取得到申报事项**(v2 用「部门上报事项」/「大模型拆解申报事项」)',
  62. library.every((p) => String(p?.['部门上报事项'] ?? p?.['大模型拆解申报事项'] ?? '').trim() !== '')
  63. );
  64. check('有政策原文地址(详情链接的来源)', library.every((p) => String(p?.['政策原文地址'] ?? '').trim() !== ''));
  65. // ⚠️ v2 **没有** `data.市级政策id` —— 所以 buildPolicyDetailUrl() 对库条目返回空串,
  66. // 详情链接改由 `政策原文地址`(前端 apply_link 的别名链里有它)提供。
  67. const idCount = library.filter((p) => String(p?.data?.['市级政策id'] ?? '').trim()).length;
  68. check(
  69. 'v2 不带 市级政策id(详情链接走「政策原文地址」,不是 id 拼的地址)',
  70. idCount === 0,
  71. `实际 ${idCount} 条有 id —— 若数据又变了,请回看 PolicyMatch 的 apply_link 别名链`
  72. );
  73. console.log(' (字段样本:' + Object.keys(first).slice(0, 8).join(' / ') + ' …)');
  74. }
  75. console.log('=== 1. 名称归一化 ===');
  76. check(
  77. '《》差异可忽略',
  78. normPolicyName('《上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施》') ===
  79. normPolicyName('上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施')
  80. );
  81. check('括号差异可忽略', normPolicyName('政策(试行)') === normPolicyName('政策(试行)'));
  82. check('空白差异可忽略', normPolicyName(' 政策 A ') === normPolicyName('政策A'));
  83. console.log('\n=== 2. 惠企政策判定 ===');
  84. const sampleName = policyNameOf(library[0]).replace(/[《》]/g, '');
  85. check(
  86. '标题能在库里找到 → 是惠企政策',
  87. isPolicyLibraryItem({ title: sampleName }, nameSet)
  88. );
  89. check(
  90. '标题带《》也能找到 → 是惠企政策',
  91. isPolicyLibraryItem({ title: '《' + sampleName + '》' }, nameSet)
  92. );
  93. check(
  94. '接口字段 is_policy_library=true → 是惠企政策',
  95. isPolicyLibraryItem({ title: '库里没有的名字', originalData: { is_policy_library: true } }, nameSet)
  96. );
  97. check(
  98. '库中没有且无字段 → 不是惠企政策',
  99. !isPolicyLibraryItem({ title: '这个名字肯定不在惠企政策库里' }, nameSet)
  100. );
  101. check('空标题 → 不是惠企政策', !isPolicyLibraryItem({ title: '' }, nameSet));
  102. console.log('\n=== 3. 收集本轮匹配到的惠企政策 ===');
  103. const cards = [
  104. { title: sampleName }, // 命中
  105. { title: '不在库里的政策' },
  106. { title: '库里没有但后端标记了', originalData: { is_policy_library: true } },
  107. ];
  108. const matched = collectMatchedLibraryNames(cards, nameSet);
  109. check('收集到 2 条命中', matched.size === 2, '实际 ' + matched.size);
  110. console.log('\n=== 4. 置顶排序 ===');
  111. const items = [
  112. { title: 'A-未命中' },
  113. { title: sampleName }, // 命中,应置顶
  114. { title: 'B-未命中' },
  115. { title: '库里没有但后端标记了', originalData: { is_policy_library: true } }, // 命中,应置顶
  116. { title: 'C-未命中' },
  117. ];
  118. const ordered = pinMatchedFirst(items, matched, (i) => i.title);
  119. check('命中项排在最前', ordered.slice(0, 2).every((i) => matched.has(normPolicyName(i.title))), JSON.stringify(ordered.map((i) => i.title)));
  120. check('未命中项保持原顺序', JSON.stringify(ordered.slice(2).map((i) => i.title)) === JSON.stringify(['A-未命中', 'B-未命中', 'C-未命中']), JSON.stringify(ordered.slice(2).map((i) => i.title)));
  121. check('总数不变', ordered.length === items.length);
  122. check('无命中时顺序不变', JSON.stringify(pinMatchedFirst(items, new Set(), (i) => i.title).map((i) => i.title)) === JSON.stringify(items.map((i) => i.title)));
  123. console.log('\n=== 5. 真实场景抽样(库里前 3 条政策名)===');
  124. library.slice(0, 3).forEach((p) => {
  125. const t = policyNameOf(p).replace(/[《》]/g, '');
  126. console.log(' ' + (isPolicyLibraryItem({ title: t }, nameSet) ? '✓' : '✗') + ' ' + t.slice(0, 40));
  127. });
  128. console.log('\n=== 6. 政策详情页地址(由 市级政策id 拼)===');
  129. {
  130. const withId = { data: { '市级政策id': '676eb30861aee302a8607104' } };
  131. check(
  132. '有 id → 拼出正确地址',
  133. buildPolicyDetailUrl(withId) === POLICY_DETAIL_URL_BASE + '?id=676eb30861aee302a8607104',
  134. buildPolicyDetailUrl(withId)
  135. );
  136. check('无 id → 空串(不猜)', buildPolicyDetailUrl({ data: {} }) === '');
  137. check('data 缺失 → 空串', buildPolicyDetailUrl({}) === '');
  138. check('null → 空串', buildPolicyDetailUrl(null) === '');
  139. check('id 为空白 → 空串', buildPolicyDetailUrl({ data: { '市级政策id': ' ' } }) === '');
  140. check('id 前后空格 → 去掉', buildPolicyDetailUrl({ data: { '市级政策id': ' abc ' } }) === POLICY_DETAIL_URL_BASE + '?id=abc');
  141. check('特殊字符 → 编码', buildPolicyDetailUrl({ data: { '市级政策id': 'a&b=c' } }) === POLICY_DETAIL_URL_BASE + '?id=a%26b%3Dc');
  142. // 真实库覆盖率
  143. // ⚠️ 老数据(v1)是 261/262 有 `data.市级政策id`,所以详情地址由它拼;
  144. // **2026-09-24 换成的 v2 没有这个字段** → 这里覆盖率是 0,属预期:
  145. // v2 的详情链接改由「政策原文地址」提供(PolicyMatch 的 apply_link 别名链里排在
  146. // buildPolicyDetailUrl 之前,所以列表里照样点得开)。
  147. const covered = library.filter((p) => buildPolicyDetailUrl(p)).length;
  148. console.log(' 真实库覆盖率: ' + covered + ' / ' + library.length + '(v2 无 市级政策id,详情链接走「政策原文地址」)');
  149. check('v2 覆盖率 = 0(有 id 才拼地址;数据若又变了请一并更新这里)', covered === 0, '实际 ' + covered);
  150. const withLink = library.filter((p) => String(p?.['政策原文地址'] ?? '').trim()).length;
  151. check('但每条都有「政策原文地址」(列表里的链接来源)', withLink === library.length, `${withLink}/${library.length}`);
  152. }
  153. console.log('\n=== 7. 部门筛选项:只列数据里实际存在的部门(用户 2026-09-24 要求)===');
  154. {
  155. const source = readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8');
  156. const departments = [...new Set(
  157. library.map((p) => String(p?.['政策所属部门'] ?? '').trim()).filter(Boolean)
  158. )].sort((a, b) => a.localeCompare(b, 'zh-CN'));
  159. console.log(' 库里的部门(' + departments.length + ' 个):' + departments.join('、'));
  160. check('库里的部门取值都非空', departments.length > 0 && library.every((p) => String(p?.['政策所属部门'] ?? '').trim()));
  161. // 关键:不再有硬编码部门列表 —— 固定列表会「有库里没有的部门(选了筛空)+ 缺库里新增的部门」
  162. check('**前端不再有硬编码部门列表**(DEFAULT_DEPTS 必须消失)', !/DEFAULT_DEPTS/.test(source));
  163. check('部门选项由数据派生(读 政策所属部门)', /政策所属部门/.test(source) && /collectDeptOptions/.test(source));
  164. check('打开部门菜单/面板时会现算一次(库是异步加载的,不能用 computed 缓存)',
  165. /if \(willOpen\) refreshDeptOptions\(\)/.test(source) &&
  166. (source.match(/refreshDeptOptions\(\)/g) || []).length >= 3);
  167. check('选中的部门若不在新清单里 → 退回「全部部门」(否则会筛出空列表还没提示)',
  168. /if \(!next\.includes\(currentDeptFilter\.value\)\)/.test(source));
  169. check('部门匹配仍是字面相等(清单与数据取值同一口径)', /!== dept/.test(source));
  170. }
  171. console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
  172. process.exit(fail ? 1 : 0);