/** * 验证「惠企政策」判定与置顶逻辑。 * * 验证什么: * 1. 惠企政策判定——优先用接口字段 is_policy_library,缺失时回退到标题匹配, * 标题匹配要能容忍《》全半角等差异 * 2. 置顶——本轮匹配到的惠企政策排在最前,其余保持库内原顺序 * * 怎么跑(在项目根目录): * npm run build:tools # 或手工:npx esbuild <入口> --bundle ... * node harness/tools/verify-policy-library.mjs * * 说明:本脚本直接引用 src 里的同一份实现(policy-library-utils.ts), * 不是复制一份逻辑——复制会漂移,测的就不是真代码了。 */ import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; import { normPolicyName, buildLibraryNameSet, isPolicyLibraryItem, collectMatchedLibraryNames, pinMatchedFirst, buildPolicyDetailUrl, POLICY_DETAIL_URL_BASE, } from './_policy-library-utils.mjs'; const require = createRequire(import.meta.url); // 2026-09-24 用户把本地副本换成了 v2(**文件名与字段名都变了**,见下方第 0 节) const library = require('../../public/merchant-agent/policies_public.v2.json'); /** 政策名:v2 用中文键「政策名称」,老数据用 `name` —— 两版都认 */ const policyNameOf = (p) => String(p?.['政策名称'] || p?.name || ''); let pass = 0; let fail = 0; const check = (name, cond, extra = '') => { if (cond) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; console.log('惠企政策库:' + library.length + ' 条'); const nameSet = buildLibraryNameSet(library); console.log('去重后政策名:' + nameSet.size + ' 个\n'); console.log('=== 0. 本地库数据形态(2026-09-24 换成 v2,字段名也从英文键变成中文键)==='); { const first = library[0] || {}; check('顶层是数组', Array.isArray(library)); check('有内容(>0 条)', library.length > 0, String(library.length)); check( '**取得到政策名**(v2 用「政策名称」;前端 normalizePolicy 也认这个键)', library.filter((p) => policyNameOf(p).trim()).length === library.length, `${library.filter((p) => policyNameOf(p).trim()).length}/${library.length}` ); check( '**取得到部门**(v2 用「政策所属部门」;前端 source 认这个键 → 部门筛选才有东西可筛)', library.every((p) => String(p?.['政策所属部门'] ?? '').trim() !== '') ); check( '**取得到申报事项**(v2 用「部门上报事项」/「大模型拆解申报事项」)', library.every((p) => String(p?.['部门上报事项'] ?? p?.['大模型拆解申报事项'] ?? '').trim() !== '') ); check('有政策原文地址(详情链接的来源)', library.every((p) => String(p?.['政策原文地址'] ?? '').trim() !== '')); // ⚠️ v2 **没有** `data.市级政策id` —— 所以 buildPolicyDetailUrl() 对库条目返回空串, // 详情链接改由 `政策原文地址`(前端 apply_link 的别名链里有它)提供。 const idCount = library.filter((p) => String(p?.data?.['市级政策id'] ?? '').trim()).length; check( 'v2 不带 市级政策id(详情链接走「政策原文地址」,不是 id 拼的地址)', idCount === 0, `实际 ${idCount} 条有 id —— 若数据又变了,请回看 PolicyMatch 的 apply_link 别名链` ); console.log(' (字段样本:' + Object.keys(first).slice(0, 8).join(' / ') + ' …)'); } console.log('=== 1. 名称归一化 ==='); check( '《》差异可忽略', normPolicyName('《上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施》') === normPolicyName('上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施') ); check('括号差异可忽略', normPolicyName('政策(试行)') === normPolicyName('政策(试行)')); check('空白差异可忽略', normPolicyName(' 政策 A ') === normPolicyName('政策A')); console.log('\n=== 2. 惠企政策判定 ==='); const sampleName = policyNameOf(library[0]).replace(/[《》]/g, ''); check( '标题能在库里找到 → 是惠企政策', isPolicyLibraryItem({ title: sampleName }, nameSet) ); check( '标题带《》也能找到 → 是惠企政策', isPolicyLibraryItem({ title: '《' + sampleName + '》' }, nameSet) ); check( '接口字段 is_policy_library=true → 是惠企政策', isPolicyLibraryItem({ title: '库里没有的名字', originalData: { is_policy_library: true } }, nameSet) ); check( '库中没有且无字段 → 不是惠企政策', !isPolicyLibraryItem({ title: '这个名字肯定不在惠企政策库里' }, nameSet) ); check('空标题 → 不是惠企政策', !isPolicyLibraryItem({ title: '' }, nameSet)); console.log('\n=== 3. 收集本轮匹配到的惠企政策 ==='); const cards = [ { title: sampleName }, // 命中 { title: '不在库里的政策' }, { title: '库里没有但后端标记了', originalData: { is_policy_library: true } }, ]; const matched = collectMatchedLibraryNames(cards, nameSet); check('收集到 2 条命中', matched.size === 2, '实际 ' + matched.size); console.log('\n=== 4. 置顶排序 ==='); const items = [ { title: 'A-未命中' }, { title: sampleName }, // 命中,应置顶 { title: 'B-未命中' }, { title: '库里没有但后端标记了', originalData: { is_policy_library: true } }, // 命中,应置顶 { title: 'C-未命中' }, ]; const ordered = pinMatchedFirst(items, matched, (i) => i.title); check('命中项排在最前', ordered.slice(0, 2).every((i) => matched.has(normPolicyName(i.title))), JSON.stringify(ordered.map((i) => i.title))); check('未命中项保持原顺序', JSON.stringify(ordered.slice(2).map((i) => i.title)) === JSON.stringify(['A-未命中', 'B-未命中', 'C-未命中']), JSON.stringify(ordered.slice(2).map((i) => i.title))); check('总数不变', ordered.length === items.length); check('无命中时顺序不变', JSON.stringify(pinMatchedFirst(items, new Set(), (i) => i.title).map((i) => i.title)) === JSON.stringify(items.map((i) => i.title))); console.log('\n=== 5. 真实场景抽样(库里前 3 条政策名)==='); library.slice(0, 3).forEach((p) => { const t = policyNameOf(p).replace(/[《》]/g, ''); console.log(' ' + (isPolicyLibraryItem({ title: t }, nameSet) ? '✓' : '✗') + ' ' + t.slice(0, 40)); }); console.log('\n=== 6. 政策详情页地址(由 市级政策id 拼)==='); { const withId = { data: { '市级政策id': '676eb30861aee302a8607104' } }; check( '有 id → 拼出正确地址', buildPolicyDetailUrl(withId) === POLICY_DETAIL_URL_BASE + '?id=676eb30861aee302a8607104', buildPolicyDetailUrl(withId) ); check('无 id → 空串(不猜)', buildPolicyDetailUrl({ data: {} }) === ''); check('data 缺失 → 空串', buildPolicyDetailUrl({}) === ''); check('null → 空串', buildPolicyDetailUrl(null) === ''); check('id 为空白 → 空串', buildPolicyDetailUrl({ data: { '市级政策id': ' ' } }) === ''); check('id 前后空格 → 去掉', buildPolicyDetailUrl({ data: { '市级政策id': ' abc ' } }) === POLICY_DETAIL_URL_BASE + '?id=abc'); check('特殊字符 → 编码', buildPolicyDetailUrl({ data: { '市级政策id': 'a&b=c' } }) === POLICY_DETAIL_URL_BASE + '?id=a%26b%3Dc'); // 真实库覆盖率 // ⚠️ 老数据(v1)是 261/262 有 `data.市级政策id`,所以详情地址由它拼; // **2026-09-24 换成的 v2 没有这个字段** → 这里覆盖率是 0,属预期: // v2 的详情链接改由「政策原文地址」提供(PolicyMatch 的 apply_link 别名链里排在 // buildPolicyDetailUrl 之前,所以列表里照样点得开)。 const covered = library.filter((p) => buildPolicyDetailUrl(p)).length; console.log(' 真实库覆盖率: ' + covered + ' / ' + library.length + '(v2 无 市级政策id,详情链接走「政策原文地址」)'); check('v2 覆盖率 = 0(有 id 才拼地址;数据若又变了请一并更新这里)', covered === 0, '实际 ' + covered); const withLink = library.filter((p) => String(p?.['政策原文地址'] ?? '').trim()).length; check('但每条都有「政策原文地址」(列表里的链接来源)', withLink === library.length, `${withLink}/${library.length}`); } console.log('\n=== 7. 部门筛选项:只列数据里实际存在的部门(用户 2026-09-24 要求)==='); { const source = readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8'); const departments = [...new Set( library.map((p) => String(p?.['政策所属部门'] ?? '').trim()).filter(Boolean) )].sort((a, b) => a.localeCompare(b, 'zh-CN')); console.log(' 库里的部门(' + departments.length + ' 个):' + departments.join('、')); check('库里的部门取值都非空', departments.length > 0 && library.every((p) => String(p?.['政策所属部门'] ?? '').trim())); // 关键:不再有硬编码部门列表 —— 固定列表会「有库里没有的部门(选了筛空)+ 缺库里新增的部门」 check('**前端不再有硬编码部门列表**(DEFAULT_DEPTS 必须消失)', !/DEFAULT_DEPTS/.test(source)); check('部门选项由数据派生(读 政策所属部门)', /政策所属部门/.test(source) && /collectDeptOptions/.test(source)); check('打开部门菜单/面板时会现算一次(库是异步加载的,不能用 computed 缓存)', /if \(willOpen\) refreshDeptOptions\(\)/.test(source) && (source.match(/refreshDeptOptions\(\)/g) || []).length >= 3); check('选中的部门若不在新清单里 → 退回「全部部门」(否则会筛出空列表还没提示)', /if \(!next\.includes\(currentDeptFilter\.value\)\)/.test(source)); check('部门匹配仍是字面相等(清单与数据取值同一口径)', /!== dept/.test(source)); // ② 2026-09-28 用户报「部门筛选出现了没有内容的项」: // 我曾把**卡片**里出现过的部门也并进下拉,而卡片的 source 可能是**多部门串** // (实测库里就有 `"上海市商务委员会、…、上海市科学技术委员会\n市商务委等"` 这种值)→ 变成假选项。 check('**下拉只来自政策库**(不再并入卡片里的 source)', !/fromCards/.test(source)); check('挡掉多部门串/换行这类脏值(looksLikeSingleDept 守卫)', /const looksLikeSingleDept = \(value: string\) =>/.test(source) && /looksLikeSingleDept\(value\)/.test(source)); // 数据层面:库里若真出现这种脏值,先报出来(别只在组件里悄悄丢掉) const dirty = departments.filter((d) => /[,,、\s]/.test(d) || d.length > 12); check('库里的部门取值都是单一部门名(没有多部门串/换行)', dirty.length === 0, dirty.join(' | ')); } console.log('\n=== 8. 政策库的来源:DMS 远端优先 + 本地兜底(用户 2026-09-24 要求)==='); { const html = readFileSync('index.html', 'utf8'); const env = readFileSync('.env.development', 'utf8'); check('远端用 VITE_POLICY_REMOTE_URL(不再走厂商 VITE_DOWNLOAD_URL)', /var remoteURL = "%VITE_POLICY_REMOTE_URL%";/.test(html) && !/var downloadURL = "%VITE_DOWNLOAD_URL%"/.test(html)); check('远端地址指向 DMS 文件服务(/dms/static/…)', /VITE_POLICY_REMOTE_URL="\/dms\/static\/file\/[^"]+\.json"/.test(env), (env.match(/VITE_POLICY_REMOTE_URL=.*/) || ['(缺)'])[0]); check('本地兜底仍是随包发布的那份', /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html)); check('**顺序固定:先 dms 再 local**(loadPolicies 按数组顺序降级)', /\{ url: remoteURL, label: "dms" \},\s*\{ url: localFallbackURL, label: "local" \},/.test(html)); check('「本地优先」开关已删除(VITE_POLICY_LOCAL_FIRST 不再出现在配置里)', !/VITE_POLICY_LOCAL_FIRST\s*=/.test(env) && !/localFirst/.test(html)); } // ⚠️ 先剥注释再查:源码里留着「[已移除] xxx」「[已删] xxx」这类**说明**(约定要保留), // 不剥的话断言会被自己的注释绊倒(第一次跑就是这么红的) const stripComments = (src) => String(src) .replace(//g, ' ') .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(/(^|[^:])\/\/[^\n]*/g, '$1 '); /** `.env` 用的是 `#` 注释(我们会在里面留「[已删] xxx」这类说明),断言前要一并剥掉 */ const stripEnv = (src) => stripComments(src).replace(/(^|\s)#[^\n]*/g, '$1'); console.log('\n=== 9. 2026-09-28 删掉的两处:不许悄悄回来 ==='); { const match = stripComments(readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8')); const utils = stripComments(readFileSync('src/components/Chat/policy-match-utils.ts', 'utf8')); const html = stripComments(readFileSync('index.html', 'utf8')); const dev = stripEnv(readFileSync('.env.development', 'utf8')); const prod = stripEnv(readFileSync('.env.production', 'utf8')); // ① 政策列表「第三条腿」:前端自己拼 {VITE_MODEL_SERVER}/v1/policies check('PolicyMatch 不再出现 VITE_MODEL_SERVER / v1/policies 调用', !/VITE_MODEL_SERVER/.test(match) && !/buildPolicyListUrl\(/.test(match) && !/buildPolicyListSearchParams\(/.test(match)); check('policy-match-utils 不再导出那四个 helper', !/buildPolicyListSearchParams|buildPolicyListUrl|getLocalPolicyListResponse|extractPolicyListResponse/.test(utils)); check('两份 env 都没有 VITE_MODEL_SERVER', !/VITE_MODEL_SERVER/.test(dev) && !/VITE_MODEL_SERVER/.test(prod)); // ② index.html 里"写了没人读"的死枝 check('index.html 不再有 globalThis.modelServer / isKnowledgeApi / cardURL / cardInfo', !/globalThis\.modelServer/.test(html) && !/globalThis\.isKnowledgeApi/.test(html) && !/\bcardURL\b/.test(html) && !/globalThis\.cardInfo/.test(html)); check('两份 env 都没有 VITE_USE_KNOWLEDGE_API', !/VITE_USE_KNOWLEDGE_API/.test(dev) && !/VITE_USE_KNOWLEDGE_API/.test(prod)); check('VITE_API 仍然保留(企业登录接口在用)', /VITE_API=/.test(dev) && /VITE_API=/.test(prod)); } console.log('\n=== 10. 2026-09-28 再删的两条:百度统计 + 死配置 VITE_DOWNLOAD_URL ==='); { const html = stripComments(readFileSync('index.html', 'utf8')); const dev = stripEnv(readFileSync('.env.development', 'utf8')); const prod = stripEnv(readFileSync('.env.production', 'utf8')); // 百度统计:数据进的是厂商账号(dm 指向 demo.heijingai.com),本项目从未用过它的数据 check('index.html 不再加载百度统计(hm.baidu / _hmt)', !/hm\.baidu/.test(html) && !/_hmt/.test(html)); // 死配置:全仓库没有读者,生产侧 nginx 也就不必再转发 /api/download/ check('两份 env 都没有 VITE_DOWNLOAD_URL', !/VITE_DOWNLOAD_URL\s*=/.test(dev) && !/VITE_DOWNLOAD_URL\s*=/.test(prod)); check('index.html 里没有 /api/download/ 依赖', !/api\/download\//.test(html)); // 防误伤:政策库的"远端 + 本地兜底"两条腿必须都还在 check('政策库远端配置仍在(VITE_POLICY_REMOTE_URL 两份 env 都有)', /VITE_POLICY_REMOTE_URL=/.test(dev) && /VITE_POLICY_REMOTE_URL=/.test(prod)); check('政策库本地兜底仍在(index.html 里的 localFallbackURL)', /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html)); } console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项'); process.exit(fail ? 1 : 0);