Browse Source

fix(policy): 同一政策的多个申报事项,点「查看详情」不再都跳到第一张

用户报:两张卡片政策名相同、申报事项不同(最高2万 / 最高40万),
点「查看详情」内容却一样。

根因(读原始 SSE 载荷 + 数据实证):
- 后端 item 事件的 id / policy_id 恒为 undefined,每条唯一的信息在
  data.title(「政策名 - 申报事项」)与 data.source_id
- 适配层 toPolicyTableItem 把卡片 title 取自 card.name.text(只有政策名),
  并把 declaration_item 也设成同一个值 → 每条唯一的 data.title 被丢掉
- 于是同一政策下多个申报事项的 declaration_item 完全相同,而
  openDetailByItem 的兜底匹配正是拿它比 → findIndex 永远命中第 0 张

实证:用旧逻辑跑同政策两卡片的场景,点第 1 张与点第 2 张都返回下标 0,
与用户看到的现象吻合。

修法:反查逻辑抽成纯函数 resolvePolicyDetailIndex()(policy-match-utils.ts),
匹配顺序改为「对象同一性优先 → 字段比对仅在唯一命中时采信 → 否则 -1 交给
兜底」。多命中时宁可走「直接展示被点条目」的兜底,也不张冠李戴。
未改任何 UI 文案与样式。

新增:
- harness/tools/verify-policy-detail-index.mjs(10 项断言,含「点第 2 张 →
  下标 1」这条针对本 bug 的断言)
- harness/tools/probe-policy-cards.mjs(打 item 原始载荷,供后续排查)

验证:npm run build 通过;回归脚本 10/10 通过;旧逻辑对照复现了 bug。
⚠️ 浏览器未人工点过。

遗留(需用户拍板,本次未动):适配层丢掉了 data.title 里的申报事项后缀,
详情面板标题对同一政策的所有申报事项都一样——内容修好了,但用户从标题上
仍分不出自己看的是哪个申报事项。改它要动显示文案,属 UI 变更。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 ngày trước cách đây
mục cha
commit
820cdcf150

+ 10 - 0
README.md

@@ -124,3 +124,13 @@
     产物重新生成的命令写在 `harness/tools/README.md`
   - 入库前做了凭据扫描(无真实 token),并**模拟 fresh clone**(删光产物 →
     按文档重新生成 → 四个验证脚本全绿)确认别人拿到仓库能跑通
+- 修复「同一政策的多个申报事项,点『查看详情』内容一样」:
+  - 根因:同一条政策下的多个申报事项会各出一张卡片,但适配层把卡片的 `title` 与
+    `declaration_item` 都取自 `card.name.text`(**只有政策名**),而后端不下发
+    `id`/`policy_id`(实测恒为 undefined)——这些卡片在字段上完全无法区分,
+    原来的反查只要任一条件命中就返回,于是点第二张也跳到第一张
+  - 修法:反查逻辑抽成纯函数 `resolvePolicyDetailIndex()`,**对象同一性优先**,
+    退化的字段比对**只在唯一命中时采信**,多命中时交给「直接展示被点条目」的兜底
+    —— 宁可走兜底,也不张冠李戴
+  - 新增回归脚本 `harness/tools/verify-policy-detail-index.mjs`(10 项断言,
+    含「点第 2 张 → 下标 1」这条针对本 bug 的断言)

+ 16 - 0
harness/feature_list.json

@@ -12,6 +12,22 @@
     }
   },
   "features": [
+    {
+      "id": "policy-detail-index-mismatch",
+      "priority": 4,
+      "area": "policy",
+      "title": "修复:同一政策的多个申报事项点「查看详情」内容一样",
+      "user_visible_behavior": "两张卡片即使是同一政策下的不同申报事项,点「查看详情」也各自打开对应的那条内容,不再都跳到第一张。",
+      "status": "passing",
+      "verification": [
+        "npm run build 通过",
+        "harness/tools/verify-policy-detail-index.mjs —— 10 项断言全通过。关键项:「点第 2 张 → 下标 1(修复前这里会返回 0)」、字段无法区分时返回 -1(不张冠李戴)、唯一命中仍采信、id/policy_id 可用、空值边界",
+        "旧逻辑对照实证:同政策两卡片的场景下,点第 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 变更,等用户拍板。"
+    },
     {
       "id": "remove-dev-auto-login",
       "priority": 6,

+ 45 - 0
harness/progress.md

@@ -28,6 +28,51 @@
 
 ## 会话记录
 
+## Session 025
+
+- **日期**:2026-09-17
+- **本轮目标**:修用户报的 bug ——「两张卡片政策名相同、申报事项不同,点『查看详情』内容一样」
+- **根因**(**读原始 SSE 载荷 + 数据实证**,不是靠猜):
+  1. 后端 `item` 事件里:`id` / `policy_id` **恒为 undefined**;每条唯一的信息在
+     `data.title`(形如「政策名 - 申报事项」,如 `…通知 - 创业开办费补贴`)与 `data.source_id`
+  2. 适配层 `toPolicyTableItem`(`api-chat-coordinator.ts:322-337`)把 `title` 取自
+     `card.name.text`(**只有政策名**),并把 `declaration_item` 也设成同一个值 ——
+     **每条唯一的 `data.title` 被丢掉了**
+  3. 于是同一政策的多个申报事项,其 `declaration_item` **完全相同**,
+     而 `openDetailByItem` 的兜底匹配正是拿它比 → `findIndex` 永远命中第 0 张
+  - **实证**:用旧逻辑跑同政策两卡片的场景 —— 点第 1 张返回 0、**点第 2 张也返回 0** ✓
+    与截图现象吻合
+- **修法**:把反查逻辑抽成纯函数 `resolvePolicyDetailIndex()`
+  (`policy-match-utils.ts`),匹配顺序改为:
+  1. **对象同一性**(卡片区传入的就是 policies 里的那个对象,精确)
+  2. 字段比对(id / policy_id / declaration_item)**只有唯一命中才采信**;
+     多命中说明这组数据本就区分不了 → 返回 -1,交给「直接展示被点条目自己」的兜底
+     —— **宁可走兜底,也不张冠李戴**
+  - `PolicyMatch.vue` 的 `openDetailByItem` 改用它;未改动任何 UI 文案或样式
+- **运行过的验证**:
+  - `npm run build` 通过
+  - **新增 `harness/tools/verify-policy-detail-index.mjs` —— 10/10 通过**,
+    关键断言「点第 2 张 → 下标 1(修复前这里会返回 0)」与「字段无法区分时返回 -1」
+  - 旧逻辑对照实证(见上,点第 2 张返回 0)
+  - 真实载荷探针:`harness/tools/probe-policy-cards.mjs`(打 `item` 原始事件,
+    确认 `id`/`policy_id` 为 undefined、`card.name.text` 两条相同)
+- **已记录证据**:本文件 Session 025;`harness/tools/verify-policy-detail-index.mjs`;
+  `feature_list.json` 的 `policy-detail-index-mismatch`
+- **更新过的文件或工件**:`src/components/Chat/policy-match-utils.ts`、
+  `src/components/Chat/PolicyMatch.vue`、`harness/tools/_entry-policy-match.ts`(新增)、
+  `harness/tools/verify-policy-detail-index.mjs`(新增)、
+  `harness/tools/probe-policy-cards.mjs`(新增)、`harness/tools/_entry-coordinator.ts`(新增)、
+  `harness/feature_list.json`、根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **浏览器未人工点过**:修复的证据是纯函数断言 + 旧逻辑对照 + 构建,
+    仍需用户点一次确认两张卡片内容确实不同
+  - ⚠️ **一个更深的、本次没动的观察**:适配层丢掉了 `data.title` 里的申报事项后缀,
+    所以详情面板的标题(`declaration_item`)对同一政策的所有申报事项**都一样**——
+    内容虽然修好了,但用户从标题上仍分不出自己看的是哪个申报事项。
+    要改就得动显示文案,**属于 UI 变更,需用户拍板**(已在回复里提出)
+  - 遗留:DMS 相关(Session 022/023)的浏览器端到端仍未人工验证
+- **下一步最佳动作**:请用户点一次确认;并就「详情面板是否显示具体申报事项」给答复
+
 ## Session 024
 
 - **日期**:2026-09-17

+ 2 - 0
harness/tools/_entry-coordinator.ts

@@ -0,0 +1,2 @@
+/** esbuild 入口:暴露适配层里可做纯逻辑验证的函数 */
+export { buildPolicyTableContent } from '../../src/components/api-chat-coordinator';

+ 2 - 0
harness/tools/_entry-policy-match.ts

@@ -0,0 +1,2 @@
+/** esbuild 入口:政策卡片的纯逻辑(详情下标反查等) */
+export { resolvePolicyDetailIndex } from '../../src/components/Chat/policy-match-utils';

+ 106 - 0
harness/tools/probe-policy-cards.mjs

@@ -0,0 +1,106 @@
+/**
+ * 探测 /api/chat 下发的政策卡片(`item` 事件)原始载荷。
+ *
+ * 为什么需要:用户报「两张卡片政策名相同、申报事项不同,但点『查看详情』内容一样」。
+ * 前端 openDetailByItem 是按 `originalData.id / policy_id / declaration_item` 反查卡片索引的,
+ * 若后端给同一政策的多个申报事项下发了**相同的 id**,findIndex 就会永远命中第一张。
+ *
+ * 排查协议问题必须看**原始载荷**,不能只看封装后的中间事件(踩过)。
+ *
+ * 用法:
+ *   node harness/tools/probe-policy-cards.mjs "我想在青浦开一家奶茶店,有什么相关扶持政策吗" [chatApiBase]
+ *   默认 chatApiBase = http://192.168.2.23:8000(可用 dev 代理 https://localhost:8083/chat-api)
+ */
+
+const question = process.argv[2] || '我想在青浦开一家奶茶店,有什么相关扶持政策吗';
+const base = (process.argv[3] || 'http://192.168.2.23:8000').replace(/\/+$/, '');
+const url = base.endsWith('/api/chat') ? base : `${base}/api/chat`;
+
+console.log(`POST ${url}\nquestion: ${question}\n`);
+
+const response = await fetch(url, {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+  body: JSON.stringify({ thread_id: `probe_${Date.now()}`, question }),
+});
+
+if (!response.ok) {
+  console.error('HTTP', response.status, await response.text());
+  process.exit(1);
+}
+
+const reader = response.body.getReader();
+const decoder = new TextDecoder();
+let buffer = '';
+const items = [];
+let currentEvent = '';
+
+while (true) {
+  const { done, value } = await reader.read();
+  if (done) break;
+  buffer += decoder.decode(value, { stream: true });
+
+  const lines = buffer.split('\n');
+  buffer = lines.pop() || '';
+  for (const raw of lines) {
+    const line = raw.replace(/\r$/, '');
+    if (line.startsWith('event:')) {
+      currentEvent = line.slice(6).trim();
+    } else if (line.startsWith('data:')) {
+      const payload = line.slice(5).trim();
+      if (currentEvent === 'item') {
+        try {
+          items.push(JSON.parse(payload));
+        } catch {
+          /* 分片 JSON,忽略,靠完整帧 */
+        }
+      }
+      if (currentEvent === 'done' || currentEvent === 'error') {
+        currentEvent = '';
+      }
+    }
+  }
+}
+
+console.log(`收到 ${items.length} 个 item 事件\n`);
+console.log('═'.repeat(70));
+items.forEach((evt, i) => {
+  const d = evt?.data ?? evt;
+  const list = Array.isArray(d) ? d : d?.data || [];
+  const arr = Array.isArray(list) ? list : [list];
+  arr.forEach((it, k) => {
+    console.log(`\n【item ${i}.${k}】`);
+    console.log('  id           :', JSON.stringify(it?.id));
+    console.log('  policy_id    :', JSON.stringify(it?.policy_id));
+    console.log('  name/title   :', JSON.stringify(it?.name ?? it?.title ?? it?.['政策名称']));
+    console.log('  declaration  :', JSON.stringify(it?.declaration_item ?? it?.['申报事项']));
+    console.log('  企业收益      :', JSON.stringify(it?.data?.['企业收益'] ?? it?.['企业收益']));
+    console.log('  顶层字段      :', Object.keys(it || {}).join(','));
+  });
+});
+
+// ── 关键判据:有没有两条 item 共用同一个 id/policy_id ──────────────────
+const ids = [];
+for (const evt of items) {
+  const d = evt?.data ?? evt;
+  const list = Array.isArray(d) ? d : d?.data || [];
+  for (const it of Array.isArray(list) ? list : [list]) {
+    ids.push({ id: it?.id, policy_id: it?.policy_id, decl: it?.declaration_item ?? it?.['申报事项'] });
+  }
+}
+const dupId = new Map();
+for (const x of ids) {
+  const key = x.id ?? x.policy_id;
+  if (key === undefined || key === null) continue;
+  if (!dupId.has(key)) dupId.set(key, []);
+  dupId.get(key).push(x.decl);
+}
+const clashes = [...dupId.entries()].filter(([, v]) => v.length > 1);
+console.log('\n' + '═'.repeat(70));
+console.log('关键判据 —— 是否有不同申报事项共用同一个 id:');
+if (!clashes.length) {
+  console.log('  ✅ 没有:每条 item 的 id 都是独立的(问题可能出在别处)');
+} else {
+  console.log(`  ❌ 有 ${clashes.length} 组共用 id —— 这就是「点进去内容一样」的原因:`);
+  clashes.forEach(([id, decls]) => console.log(`     id=${id} 对应 ${decls.length} 个申报事项:${decls.join(' / ')}`));
+}

+ 98 - 0
harness/tools/verify-policy-detail-index.mjs

@@ -0,0 +1,98 @@
+/**
+ * 验证「点卡片 → 打开哪一条详情」的下标反查逻辑。
+ *
+ * 背景(2026-09-17 用户报的 bug):同一条政策下的多个申报事项会各生成一张卡片,
+ * 但适配层把 `title` 与 `declaration_item` 都取自 `card.name.text`(只有政策名),
+ * 后端又不下发 `id`/`policy_id`(实测恒为 undefined)——这些卡片在字段上**无法区分**。
+ * 原先的 findIndex 只要任一条件命中就返回,于是点第二张卡片也跳到第一张,
+ * 表现为「两张卡片的详情内容一模一样」。
+ *
+ * 验证的不变量:
+ *   ① 卡片区点击(originalData 是 policies 里的同一对象)必须精确命中自己那一条
+ *   ② 字段比对**只有唯一命中**才采信;多个候选时返回 -1(交给兜底,不张冠李戴)
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-policy-match.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_policy-match.mjs
+ *   node harness/tools/verify-policy-detail-index.mjs
+ */
+
+import { resolvePolicyDetailIndex } from './_policy-match.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);
+  }
+};
+
+// ── 复现用户场景:同一政策的两个申报事项 ────────────────────────────────
+// 适配层产出的两个条目:declaration_item 都是政策名,都没有 id
+const POLICY_NAME = '关于进一步完善本区创业扶持政策有关事项的通知';
+const rawA = { declaration_item: POLICY_NAME, support_objects: '本区户籍创业人员' };
+const rawB = { declaration_item: POLICY_NAME, support_objects: '区级考核优秀/良好/合格基地' };
+const policies = [
+  { declaration_item: POLICY_NAME, originalData: rawA, support_objects: rawA.support_objects },
+  { declaration_item: POLICY_NAME, originalData: rawB, support_objects: rawB.support_objects },
+];
+
+console.log('场景:同政策的两个申报事项,declaration_item 与 id 都无法区分\n');
+
+console.log('【1】卡片区点击(对象同一性——精确路径)');
+check('点第 1 张 → 下标 0', resolvePolicyDetailIndex(policies, policies[0]) === 0, String(resolvePolicyDetailIndex(policies, policies[0])));
+check(
+  '点第 2 张 → 下标 1(**修复前这里会返回 0**)',
+  resolvePolicyDetailIndex(policies, policies[1]) === 1,
+  String(resolvePolicyDetailIndex(policies, policies[1]))
+);
+
+console.log('\n【2】字段无法区分时不得猜(唯一性要求)');
+// 一个不在 policies 里的对象,但 declaration_item 与两张卡片都相同
+const foreign = { originalData: { declaration_item: POLICY_NAME }, declaration_item: POLICY_NAME };
+check(
+  '外来条目 + 两个候选 → 返回 -1(不张冠李戴,交给兜底)',
+  resolvePolicyDetailIndex(policies, foreign) === -1,
+  String(resolvePolicyDetailIndex(policies, foreign))
+);
+
+console.log('\n【3】字段唯一命中时仍应采信(退化路径保留)');
+const uniqueRaw = { declaration_item: '某个独占的申报事项' };
+const uniquePolicies = [
+  { declaration_item: POLICY_NAME, originalData: rawA },
+  { declaration_item: '某个独占的申报事项', originalData: uniqueRaw },
+];
+check(
+  '外来条目 + 唯一 declaration_item 命中 → 正确的下标',
+  resolvePolicyDetailIndex(uniquePolicies, { originalData: { declaration_item: '某个独占的申报事项' } }) === 1,
+  String(resolvePolicyDetailIndex(uniquePolicies, { originalData: { declaration_item: '某个独占的申报事项' } }))
+);
+
+const idRaw = { id: 'k_abc123' };
+const idPolicies = [
+  { declaration_item: POLICY_NAME, originalData: rawA },
+  { declaration_item: POLICY_NAME, originalData: idRaw },
+];
+check(
+  '按 id 唯一命中 → 正确的下标',
+  resolvePolicyDetailIndex(idPolicies, { originalData: { id: 'k_abc123' } }) === 1,
+  String(resolvePolicyDetailIndex(idPolicies, { originalData: { id: 'k_abc123' } }))
+);
+check(
+  'policy_id 同样可用',
+  resolvePolicyDetailIndex(idPolicies, { originalData: { policy_id: 'k_abc123' } }) === 1,
+  String(resolvePolicyDetailIndex(idPolicies, { originalData: { policy_id: 'k_abc123' } }))
+);
+
+console.log('\n【4】边界');
+check('policies 为空 → -1', resolvePolicyDetailIndex([], policies[0]) === -1);
+check('policies 为 null → -1', resolvePolicyDetailIndex(null, policies[0]) === -1);
+check('item 为 null → -1', resolvePolicyDetailIndex(policies, null) === -1);
+check('完全对不上 → -1', resolvePolicyDetailIndex(policies, { originalData: { declaration_item: '不存在的事项' } }) === -1);
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 5 - 9
src/components/Chat/PolicyMatch.vue

@@ -469,6 +469,7 @@ import {
   buildPolicyListUrl,
   extractPolicyListResponse,
   getLocalPolicyListResponse,
+  resolvePolicyDetailIndex,
   type PolicyListFilterOption,
 } from "./policy-match-utils";
 import { MODAL_MUTEX_KEY } from "../business-assistant/modal-mutex";
@@ -1175,15 +1176,10 @@ const openDetail = async (index: number) => {
 };
 
 const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_objects?: string }) => {
-  const targetId = item.originalData?.id || item.originalData?.policy_id;
-  const index = policies.value.findIndex((policy) => {
-    const current = policy.originalData;
-    return (
-      current === item.originalData ||
-      (targetId && (current?.id === targetId || current?.policy_id === targetId)) ||
-      policy.declaration_item === item.originalData?.declaration_item
-    );
-  });
+  // 反查下标:先按对象同一性(精确),退化的字段比对只在唯一命中时采信 ——
+  // 同一政策的多个申报事项在字段上无法区分,原先的写法会让第二张卡片也跳到第一张
+  // (表现:两张卡片详情内容一模一样)。见 policy-match-utils.ts 的说明。
+  const index = resolvePolicyDetailIndex(policies.value, item);
 
   if (index >= 0) {
     openDetail(index);

+ 46 - 0
src/components/Chat/policy-match-utils.ts

@@ -135,3 +135,49 @@ export const extractPolicyListResponse = <T>(response: any): PolicyListResponse<
 
   return { items, total };
 };
+
+/**
+ * 由「被点击的条目」反查它在 `policies` 数组里的下标;查不到返回 -1。
+ *
+ * 🐛 为什么需要这个函数(2026-09-17 修的真实 bug):
+ * 同一条政策下的**多个申报事项**会各生成一张卡片,但适配层把卡片的
+ * `title` 与 `declaration_item` 都取自 `card.name.text`(**只有政策名**),
+ * 而后端又没有下发 `id` / `policy_id`(实测恒为 undefined)。
+ * 于是这些卡片在「按字段比对」这条路上**完全无法区分**——
+ * 原先的 findIndex 只要任一条件命中就返回,导致点第二张卡片也跳到第一张,
+ * 表现为「两张卡片的详情内容一模一样」。
+ *
+ * 匹配顺序(从可靠到不可靠):
+ *   1. **对象同一性**:卡片区传入的就是 policies 里的那个对象,这一条是精确的
+ *   2. 字段比对(id / policy_id / declaration_item):**只有唯一命中才采用**。
+ *      多命中说明这组数据本就区分不了(例如同一政策的多个申报事项),
+ *      此时返回 -1 交给调用方用「直接展示被点条目自己」的兜底路径 ——
+ *      宁可走兜底,也不要张冠李戴地展示另一条的内容。
+ */
+export const resolvePolicyDetailIndex = <T extends { originalData?: any; declaration_item?: string }>(
+  policies: T[] | null | undefined,
+  item: { originalData?: any; declaration_item?: string } | null | undefined
+): number => {
+  const list = Array.isArray(policies) ? policies : [];
+  if (!list.length || !item) return -1;
+
+  // 1. 对象同一性
+  const exact = list.findIndex((policy) => policy?.originalData === item.originalData);
+  if (exact >= 0) return exact;
+
+  // 2. 字段比对(仅在唯一命中时采信)
+  const targetId = item.originalData?.id || item.originalData?.policy_id;
+  const targetDeclaration = item.originalData?.declaration_item;
+
+  const matched: number[] = [];
+  list.forEach((policy, index) => {
+    const current = policy?.originalData;
+    const sameId =
+      !!targetId && (current?.id === targetId || current?.policy_id === targetId);
+    const sameDeclaration =
+      !!targetDeclaration && policy?.declaration_item === targetDeclaration;
+    if (sameId || sameDeclaration) matched.push(index);
+  });
+
+  return matched.length === 1 ? matched[0] : -1;
+};