verify-policy-library.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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. // ② 2026-09-28 用户报「部门筛选出现了没有内容的项」:
  171. // 我曾把**卡片**里出现过的部门也并进下拉,而卡片的 source 可能是**多部门串**
  172. // (实测库里就有 `"上海市商务委员会、…、上海市科学技术委员会\n市商务委等"` 这种值)→ 变成假选项。
  173. check('**下拉只来自政策库**(不再并入卡片里的 source)',
  174. !/fromCards/.test(source));
  175. check('挡掉多部门串/换行这类脏值(looksLikeSingleDept 守卫)',
  176. /const looksLikeSingleDept = \(value: string\) =>/.test(source) &&
  177. /looksLikeSingleDept\(value\)/.test(source));
  178. // 数据层面:库里若真出现这种脏值,先报出来(别只在组件里悄悄丢掉)
  179. const dirty = departments.filter((d) => /[,,、\s]/.test(d) || d.length > 12);
  180. check('库里的部门取值都是单一部门名(没有多部门串/换行)', dirty.length === 0, dirty.join(' | '));
  181. }
  182. console.log('\n=== 8. 政策库的来源:DMS 远端优先 + 本地兜底(用户 2026-09-24 要求)===');
  183. {
  184. const html = readFileSync('index.html', 'utf8');
  185. const env = readFileSync('.env.development', 'utf8');
  186. check('远端用 VITE_POLICY_REMOTE_URL(不再走厂商 VITE_DOWNLOAD_URL)',
  187. /var remoteURL = "%VITE_POLICY_REMOTE_URL%";/.test(html) && !/var downloadURL = "%VITE_DOWNLOAD_URL%"/.test(html));
  188. check('远端地址指向 DMS 文件服务(/dms/static/…)',
  189. /VITE_POLICY_REMOTE_URL="\/dms\/static\/file\/[^"]+\.json"/.test(env),
  190. (env.match(/VITE_POLICY_REMOTE_URL=.*/) || ['(缺)'])[0]);
  191. check('本地兜底仍是随包发布的那份', /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
  192. check('**顺序固定:先 dms 再 local**(loadPolicies 按数组顺序降级)',
  193. /\{ url: remoteURL, label: "dms" \},\s*\{ url: localFallbackURL, label: "local" \},/.test(html));
  194. check('「本地优先」开关已删除(VITE_POLICY_LOCAL_FIRST 不再出现在配置里)',
  195. !/VITE_POLICY_LOCAL_FIRST\s*=/.test(env) && !/localFirst/.test(html));
  196. }
  197. // ⚠️ 先剥注释再查:源码里留着「[已移除] xxx」「[已删] xxx」这类**说明**(约定要保留),
  198. // 不剥的话断言会被自己的注释绊倒(第一次跑就是这么红的)
  199. const stripComments = (src) =>
  200. String(src)
  201. .replace(/<!--[\s\S]*?-->/g, ' ')
  202. .replace(/\/\*[\s\S]*?\*\//g, ' ')
  203. .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
  204. /** `.env` 用的是 `#` 注释(我们会在里面留「[已删] xxx」这类说明),断言前要一并剥掉 */
  205. const stripEnv = (src) => stripComments(src).replace(/(^|\s)#[^\n]*/g, '$1');
  206. console.log('\n=== 9. 2026-09-28 删掉的两处:不许悄悄回来 ===');
  207. {
  208. const match = stripComments(readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8'));
  209. const utils = stripComments(readFileSync('src/components/Chat/policy-match-utils.ts', 'utf8'));
  210. const html = stripComments(readFileSync('index.html', 'utf8'));
  211. const dev = stripEnv(readFileSync('.env.development', 'utf8'));
  212. const prod = stripEnv(readFileSync('.env.production', 'utf8'));
  213. // ① 政策列表「第三条腿」:前端自己拼 {VITE_MODEL_SERVER}/v1/policies
  214. check('PolicyMatch 不再出现 VITE_MODEL_SERVER / v1/policies 调用',
  215. !/VITE_MODEL_SERVER/.test(match) && !/buildPolicyListUrl\(/.test(match) && !/buildPolicyListSearchParams\(/.test(match));
  216. check('policy-match-utils 不再导出那四个 helper',
  217. !/buildPolicyListSearchParams|buildPolicyListUrl|getLocalPolicyListResponse|extractPolicyListResponse/.test(utils));
  218. check('两份 env 都没有 VITE_MODEL_SERVER',
  219. !/VITE_MODEL_SERVER/.test(dev) && !/VITE_MODEL_SERVER/.test(prod));
  220. // ② index.html 里"写了没人读"的死枝
  221. check('index.html 不再有 globalThis.modelServer / isKnowledgeApi / cardURL / cardInfo',
  222. !/globalThis\.modelServer/.test(html) && !/globalThis\.isKnowledgeApi/.test(html) &&
  223. !/\bcardURL\b/.test(html) && !/globalThis\.cardInfo/.test(html));
  224. check('两份 env 都没有 VITE_USE_KNOWLEDGE_API',
  225. !/VITE_USE_KNOWLEDGE_API/.test(dev) && !/VITE_USE_KNOWLEDGE_API/.test(prod));
  226. check('VITE_API 仍然保留(企业登录接口在用)',
  227. /VITE_API=/.test(dev) && /VITE_API=/.test(prod));
  228. }
  229. console.log('\n=== 10. 2026-09-28 再删的两条:百度统计 + 死配置 VITE_DOWNLOAD_URL ===');
  230. {
  231. const html = stripComments(readFileSync('index.html', 'utf8'));
  232. const dev = stripEnv(readFileSync('.env.development', 'utf8'));
  233. const prod = stripEnv(readFileSync('.env.production', 'utf8'));
  234. // 百度统计:数据进的是厂商账号(dm 指向 demo.heijingai.com),本项目从未用过它的数据
  235. check('index.html 不再加载百度统计(hm.baidu / _hmt)',
  236. !/hm\.baidu/.test(html) && !/_hmt/.test(html));
  237. // 死配置:全仓库没有读者,生产侧 nginx 也就不必再转发 /api/download/
  238. check('两份 env 都没有 VITE_DOWNLOAD_URL',
  239. !/VITE_DOWNLOAD_URL\s*=/.test(dev) && !/VITE_DOWNLOAD_URL\s*=/.test(prod));
  240. check('index.html 里没有 /api/download/ 依赖',
  241. !/api\/download\//.test(html));
  242. // 防误伤:政策库的"远端 + 本地兜底"两条腿必须都还在
  243. check('政策库远端配置仍在(VITE_POLICY_REMOTE_URL 两份 env 都有)',
  244. /VITE_POLICY_REMOTE_URL=/.test(dev) && /VITE_POLICY_REMOTE_URL=/.test(prod));
  245. check('政策库本地兜底仍在(index.html 里的 localFallbackURL)',
  246. /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
  247. }
  248. console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
  249. process.exit(fail ? 1 : 0);