Преглед на файлове

feat(policy): 详情面板显示具体申报事项(同政策多事项可区分)

承接上一提交的 bug 修复:内容不再张冠李戴之后,详情面板的标题对同一政策的
所有申报事项仍然是同一个政策名,用户分不出自己看的是哪一条。按用户拍板,
让 declaration_item 显示具体申报事项。

- api-chat-coordinator.ts 的 toPolicyTableItem:declaration_item 改用后端
  每条唯一的 item.title(同政策多事项时形如「…的通知 - 创业开办费补贴」),
  不再与卡片标题取同一个值;卡片标题 title 仍取 card.name.text(政策名)
- ⚠️ 连带影响已处理:panelData.title(卡片区标题)原先优先取
  originalData.declaration_item,改后会连带把卡片标题也变成「政策名 - 申报事项」。
  已改成优先取 item.title(政策名)——用户只批准改详情标题,卡片区保持原样
- 有意未改:buildRecordPolicyList 的兜底列表显示完整申报事项(列表本就叫
  「政策事项列表」,且只在库未加载时走这条路)

新增 verify-policy-card-fields.mjs(8 项断言):同政策两条的 declaration_item
必须不同、卡片标题仍是政策名、无后缀时退化为政策名不出现空值、与
resolvePolicyDetailIndex 配合能直接命中下标 1。

验证:npm run build 通过;全部验证脚本绿(卡字段 8/8、详情反查 10/10、
政策库 21/21、判空口径 30/30、DMS 36/36);validate-harness 通过。
⚠️ 浏览器未人工点过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao преди 3 дни
родител
ревизия
36f28205e3
променени са 6 файла, в които са добавени 138 реда и са изтрити 8 реда
  1. 6 0
      README.md
  2. 2 2
      harness/feature_list.json
  3. 18 0
      harness/progress.md
  4. 99 0
      harness/tools/verify-policy-card-fields.mjs
  5. 4 4
      src/components/Chat/PolicyMatch.vue
  6. 9 2
      src/components/api-chat-coordinator.ts

+ 6 - 0
README.md

@@ -134,3 +134,9 @@
     —— 宁可走兜底,也不张冠李戴
   - 新增回归脚本 `harness/tools/verify-policy-detail-index.mjs`(10 项断言,
     含「点第 2 张 → 下标 1」这条针对本 bug 的断言)
+  - 详情面板的标题改为显示**具体申报事项**(如「…的通知 - 创业开办费补贴」):
+    适配层的 `declaration_item` 改用后端每条唯一的 `data.title`,不再与卡片标题同值;
+    **卡片区标题仍是政策名,观感不变**(含一处连带的 `panelData.title` 取值顺序修正——
+    否则改详情标题会连带改掉卡片标题)
+  - 新增 `harness/tools/verify-policy-card-fields.mjs`(8 项断言:同政策两条必须可区分、
+    卡片标题不变、无后缀时退化为政策名)

+ 2 - 2
harness/feature_list.json

@@ -25,8 +25,8 @@
         "旧逻辑对照实证:同政策两卡片的场景下,点第 1 张与点第 2 张**都返回下标 0** —— 复现了用户看到的现象",
         "真实载荷探针 probe-policy-cards.mjs:确认后端 item 的 id/policy_id 恒为 undefined、card.name.text 两条相同"
       ],
-      "evidence": "回归脚本 10/10 通过;旧逻辑对照复现了 bug;构建通过。⚠️ 浏览器未人工点过(见 notes)",
-      "notes": "根因链:①后端 item 事件的 id/policy_id 恒为 undefined,唯一标识在 data.title(「政策名 - 申报事项」)与 data.source_id;②适配层 toPolicyTableItem 把 title 取自 card.name.text(只有政策名),并把 declaration_item 也设成同一个值,**丢掉了每条唯一的 data.title**;③于是同政策的多个申报事项 declaration_item 完全相同,而 openDetailByItem 的兜底匹配正是拿它比 → findIndex 永远命中第 0 张。\n\n修法:抽纯函数 resolvePolicyDetailIndex()(policy-match-utils.ts),匹配顺序改为「对象同一性优先 → 字段比对仅在唯一命中时采信 → 否则 -1 交给兜底」。未改任何 UI 文案与样式。\n\n⚠️ **未修的更深问题**:适配层丢掉了 data.title 里的申报事项后缀,因此详情面板标题(declaration_item)对同一政策的所有申报事项都一样——内容修好了,但用户从标题上仍分不出看的是哪个申报事项。要改需动显示文案,属 UI 变更,等用户拍板。"
+      "evidence": "回归脚本 10/10 + 卡字段脚本 8/8 通过;旧逻辑对照复现了 bug;构建通过。⚠️ 浏览器未人工点过(见 notes)",
+      "notes": "根因链:①后端 item 事件的 id/policy_id 恒为 undefined,唯一标识在 data.title(「政策名 - 申报事项」)与 data.source_id;②适配层 toPolicyTableItem 把 title 取自 card.name.text(只有政策名),并把 declaration_item 也设成同一个值,**丢掉了每条唯一的 data.title**;③于是同政策的多个申报事项 declaration_item 完全相同,而 openDetailByItem 的兜底匹配正是拿它比 → findIndex 永远命中第 0 张。\n\n修法(两步)\n1. 反查纯函数 resolvePolicyDetailIndex()(policy-match-utils.ts),匹配顺序改为「对象同一性优先 → 字段比对仅在唯一命中时采信 → 否则 -1 交给兜底」。\n2. 按用户拍板,适配层的 declaration_item 改用后端每条唯一的 item.title(「政策名 - 申报事项」),详情面板标题从此能区分具体申报事项;卡片标题 title 仍取 card.name.text(政策名)不变。\n\n⚠️ **连带影响已处理**:panelData.title(**卡片区**标题)原先优先取 originalData.declaration_item,改后会连带把卡片标题也变成「政策名 - 申报事项」——用户只批准改详情标题,故已改成优先取 item.title(政策名),卡片区保持原样。\n\n有意未改:buildRecordPolicyList 的兜底列表会显示完整申报事项(列表本就叫「政策事项列表」,且只在库未加载时走)。\n\n两个脚本:verify-policy-detail-index.mjs(10 项,反查逻辑)、verify-policy-card-fields.mjs(8 项,适配层字段)。"
     },
     {
       "id": "remove-dev-auto-login",

+ 18 - 0
harness/progress.md

@@ -73,6 +73,24 @@
   - 遗留:DMS 相关(Session 022/023)的浏览器端到端仍未人工验证
 - **下一步最佳动作**:请用户点一次确认;并就「详情面板是否显示具体申报事项」给答复
 
+### 续:详情面板显示具体申报事项(用户拍板后追加)
+
+用户选择「详情标题显示具体申报事项」。改动:
+- `api-chat-coordinator.ts` 的 `toPolicyTableItem`:`declaration_item` 改用后端每条唯一的
+  `item.title`(同政策多事项时形如「…的通知 - 创业开办费补贴」),**不再与卡片标题取同一个值**。
+  卡片标题 `title` 仍旧取 `card.name.text`(政策名)—— 卡片区观感不变
+- ⚠️ **连带影响已处理**:`panelData.title`(卡片区标题)原先优先取
+  `originalData.declaration_item`,改后会连带把卡片标题变成「政策名 - 申报事项」。
+  已改成优先取 `item.title`(政策名),**卡片区保持原样**——用户只批准改详情标题
+- 未处理(有意):`buildRecordPolicyList` 兜底列表会显示完整申报事项——
+  列表本就叫「政策事项列表」,且只在库未加载时走这条路,更正确
+
+**新增验证** `harness/tools/verify-policy-card-fields.mjs` —— **8/8 通过**:
+①同政策两条的 declaration_item 必须不同 ②卡片标题仍是政策名(观感不变)
+③无后缀时退化为政策名不出现空值 ④与 `resolvePolicyDetailIndex` 配合能直接命中下标 1
+(跑这段测试时先写错过夹具:把「无后缀」场景的 `item.title` 与 `card.name.text` 传成了不同值,
+那其实是「有后缀」形态;已修正夹具,**不是代码问题**)
+
 ## Session 024
 
 - **日期**:2026-09-17

+ 99 - 0
harness/tools/verify-policy-card-fields.mjs

@@ -0,0 +1,99 @@
+/**
+ * 验证适配层产出的政策卡片字段:**同政策的多个申报事项必须能区分开**。
+ *
+ * 背景(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(/^<!-- POLICY_TABLE /, '').replace(/ POLICY_TABLE -->$/, ''));
+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(/^<!-- POLICY_TABLE /, '').replace(/ POLICY_TABLE -->$/, '')).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);

+ 4 - 4
src/components/Chat/PolicyMatch.vue

@@ -1052,10 +1052,10 @@ const panelData = computed(() => {
     .sort((a, b) => b.match_score - a.match_score)
     .slice(0, 6)
     .map((item) => ({
-      title:
-        item.originalData?.declaration_item ||
-        item.declaration_item ||
-        item.title,
+      // 卡片区标题:用「政策名」(normalizePolicy 的 title)。
+      // ⚠️ 这里不能再优先取 declaration_item —— 自 2026-09-17 起它表示**具体申报事项**
+      // (「政策名 - 申报事项」),取值会连带改掉卡片区的观感。卡片区保持原样。
+      title: item.title || item.originalData?.declaration_item || item.declaration_item,
       desc: item.desc || item.match_reason || "",
       match_score: item.match_score,
       match_degree: item.match_degree,

+ 9 - 2
src/components/api-chat-coordinator.ts

@@ -322,6 +322,13 @@ const toPolicyTableItem = (item: ChatItemPayload, sourceUrl: string) => {
   const nameText = resolveFieldText(card?.name);
   const title = nameText || item.title || '';
 
+  // 详情面板的「申报事项」:用后端每条唯一的 `data.title`。
+  // 同一政策下有多个申报事项时,后端会下发「政策名 - 申报事项」的形式
+  // (如「…的通知 - 创业开办费补贴」),而 `card.name.text` 只有政策名。
+  // 之前这里跟 title 取同一个值,导致同政策的多个申报事项在详情面板上
+  // **标题完全一样、也认不出自己看的是哪一条**(2026-09-17 修)。
+  const declarationItem = item.title || title;
+
   // 卡片区按 match_score 倒序取前 6 条。新接口给出的是 priority.rank
   // (1 为第一位),这里换算成递减分值,使卡片顺序与列表顺序、
   // 与接口给定的展示优先级完全一致。
@@ -333,8 +340,8 @@ const toPolicyTableItem = (item: ChatItemPayload, sourceUrl: string) => {
     match_score: matchScore,
     // 来源标识:来自惠企政策库的条目,卡片右上角会打标
     is_policy_library: item.is_policy_library === true,
-    // 详情面板大标题
-    declaration_item: title,
+    // 详情面板大标题:具体到申报事项(见上 declarationItem 的说明)
+    declaration_item: declarationItem,
     // 卡片描述
     desc: item.match_reason || card?.match_reason?.text || '',
     // 详情面板“政策所属部门”