/** * 验证适配层产出的政策卡片字段:**同政策的多个申报事项必须能区分开**。 * * 背景(2026-09-17 用户报的 bug): * 适配层原先把卡片的 `title` 与 `declaration_item` 都取自 `card.name.text`(只有政策名), * 而后端不下发 `id`/`policy_id`(实测恒为 undefined)——同政策的多个申报事项 * 在字段上完全无法区分:详情面板标题一样、「查看详情」还会跳到同一条。 * * 验证的不变量: * ① `declaration_item` 取后端每条唯一的 `data.title`(含申报事项后缀),同政策两条必须不同 * ② 卡片标题 `title` 仍是政策名(`card.name.text`)—— 卡片区的观感不变 * ③ 后端没给后缀时(单事项政策),`declaration_item` 退化为政策名,不出现空值 * * 数据来源:`probe-policy-cards.mjs` 抓到的真实载荷形状(这里用合成数据,保证可重复; * 真实载荷每次问法不同、LLM 输出会变,不适合做断言基准)。 * * 怎么跑(在项目根目录): * npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \ * --outfile=harness/tools/_coordinator.mjs --alias:@=./src --define:import.meta.env='{}' * node harness/tools/verify-policy-card-fields.mjs */ import { buildPolicyTableContent } from './_coordinator.mjs'; 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); } }; const POLICY_NAME = '关于进一步完善本区创业扶持政策有关事项的通知'; /** * 按真实 `item` 事件的形状造数据(字段名与 probe 抓到的载荷一致)。 * `cardName` 与 `title` 分开传:有申报事项后缀时两者不同,单事项时两者相同。 */ const makeItem = (title, sourceId, supportText, cardName = POLICY_NAME) => ({ source_id: sourceId, title, knowledge_type: 'policy', is_policy_library: true, card: { kind: 'policy', name: { text: cardName, citations: [] }, department: { text: '区人社局' }, support_objects: { text: '对象' }, support_methods_standards: { text: supportText }, application_conditions: { text: '条件' }, match_reason: { text: '原因' }, }, priority: { rank: 1 }, }); const items = [ makeItem(`${POLICY_NAME} - 创业开办费补贴`, 'k_ff8fe476c2708c93b0d7803e', '一次性创业开办费补贴2万元,最高2万元补贴。'), makeItem(`${POLICY_NAME} - 青浦区级创业孵化基地的补贴和奖励`, 'k_6dc752bd7d30b42d430ca7fc', '分别给予40万/30万/20万运作经费补贴,最高40万元补贴'), ]; const content = buildPolicyTableContent(items, []); const table = JSON.parse(content.replace(/^$/, '')); const [a, b] = table.data; console.log('场景:同一政策下的两个申报事项\n'); console.log('【1】详情面板的申报事项必须能区分'); check('两条的 declaration_item 不同', a.declaration_item !== b.declaration_item, `${a.declaration_item} / ${b.declaration_item}`); check('第 1 条含「创业开办费补贴」', a.declaration_item === `${POLICY_NAME} - 创业开办费补贴`, a.declaration_item); check('第 2 条含「创业孵化基地」', b.declaration_item === `${POLICY_NAME} - 青浦区级创业孵化基地的补贴和奖励`, b.declaration_item); console.log('\n【2】卡片标题保持政策名不变(卡片区观感不动)'); check('两条的 title 都是政策名', a.title === POLICY_NAME && b.title === POLICY_NAME, `${a.title}`); check('title 与 declaration_item 不再相同', a.title !== a.declaration_item); console.log('\n【3】没有后缀时退化为政策名,不出现空值'); // 真实形态:单事项政策的 title 与 card.name.text 相同(见 probe 抓到的 item[1]) const SOLO_NAME = '青浦区关于促进技能人才发展的实施办法'; const single = buildPolicyTableContent([makeItem(SOLO_NAME, 'k_x', '补贴', SOLO_NAME)], []); const singleRow = JSON.parse(single.replace(/^$/, '')).data[0]; check('declaration_item 非空', !!singleRow.declaration_item, JSON.stringify(singleRow.declaration_item)); check('无后缀时等于政策名(与 title 一致)', singleRow.declaration_item === SOLO_NAME && singleRow.title === SOLO_NAME, `${singleRow.declaration_item} / ${singleRow.title}`); console.log('\n【4】详情下标反查:字段可区分后能直接命中(与 resolvePolicyDetailIndex 配合)'); // 模拟 policies 数组(normalizePolicy 之后),declaration_item 不同 → 唯一命中 const policies = table.data.map((d) => ({ declaration_item: d.declaration_item, originalData: d })); const { resolvePolicyDetailIndex } = await import('./_policy-match.mjs'); check( '用「同政策第 2 条」的字段反查 → 命中下标 1', resolvePolicyDetailIndex(policies, { originalData: { declaration_item: b.declaration_item } }) === 1, String(resolvePolicyDetailIndex(policies, { originalData: { declaration_item: b.declaration_item } })) ); console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);