Преглед изворни кода

change(policy): 「更多」侧边栏只列本条回答的政策,收回会话级聚合

用户要求:侧边栏只展示「回答中存在的政策」,不要整个会话的。
即把 2026-09-17 做的「会话级跨消息聚合」收回成「本条回答的卡片」。
(方向与当时的决定相反,但那是用户当时的明确选择,本次是其调整,不是回归。)

- PolicyMatch.rebuildCardList():数据源改回本组件自己的卡片
  (props.policyData / props.policyContent,与 syncPolicies 同源),
  不再 inject('collectSessionPolicyCards') 往上层取聚合
- 连带清理(已成死代码):
  · useBusinessAssistantChat.ts 删掉 provide('collectSessionPolicyCards') 与 import
  · policy-match-utils.ts 删掉 collectPolicyCardsFromMessages() 及其
    parsePolicyTableContent import(该函数是它唯一使用者)
  · _entry-policy-cards.ts 去掉该导出
  · verify-policy-card-list.mjs 删掉第 1 节(聚合断言)、第 6 节改用直接传卡片,
    并把头部两段自相矛盾的说明改掉(有一段还写着「侧边栏要的是整个会话的聚合」)
- 保留:去重 / 排序 / 关键词过滤三个纯函数(同一回答内的重复条目仍会去重)

验证:verify-policy-card-list.mjs 24/24 通过(原 31 项减去 7 项聚合断言);
npm run build 通过;聚合相关代码在源码与入口零命中;validate-harness 通过。
⚠️ 浏览器未实测。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 2 дана
родитељ
комит
93a4fd4ab1

+ 3 - 2
README.md

@@ -142,8 +142,9 @@
     卡片标题不变、无后缀时退化为政策名)
 - 卡片区「更多 >>」改为打开新的**「政策列表」侧边栏**:
   - 标题「政策列表」,保留搜索框与热门搜索,**移除部门/分类筛选**
-  - 内容为**本会话内所有消息出现过的政策卡片**(跨消息聚合、去重、按匹配度排序)——
-    此前会话级聚合并不存在(每条消息的卡片各自渲染),本轮新增
+  - 内容为**本条回答里出现的政策卡片**(去重、按匹配度排序)
+    —— 注:2026-09-18 按需求调整,此前一度做成「本会话内所有消息出现过的卡片」
+    (跨消息聚合),那套聚合与它的采集函数已随本次调整删除
   - 详情面板的「返回政策事项列表」**仍打开原来的惠企政策库列表**(两套列表并存,
     按要求只换「更多」入口)
   - 面板新增第三种模式 `cardList`,与旧列表**共用同一套模板与样式类,未新增 CSS**;

Разлика између датотеке није приказан због своје велике величине
+ 5 - 5
harness/feature_list.json


+ 38 - 0
harness/progress.md

@@ -2121,3 +2121,41 @@
 - **下一步最佳动作**:把这份数据给后端;**最有效的一步是让后端改成流式**(边生成边推
   `answer`/`summary`),用户等待感会立刻不同;其次是查 `recommend` 阶段为何要 26 秒
 
+## Session 052
+
+- **日期**:2026-09-18
+- **本轮目标**:用户要求「『更多』侧边栏只展示**回答中存在的政策**,不要整个会话的」
+  → 即把 Session 026 做的**会话级跨消息聚合**收回成**本条回答的卡片**
+- **已完成**:
+  - `PolicyMatch.vue` 的 `rebuildCardList()`:数据源改回**本组件自己的卡片**
+    (`props.policyData` / `props.policyContent`,与 `syncPolicies` 同源),
+    不再 `inject('collectSessionPolicyCards')` 往上层取会话级聚合
+  - 连带清理(已成死代码):
+    - `useBusinessAssistantChat.ts`:删掉 `provide('collectSessionPolicyCards', …)` 与对应的 import
+    - `policy-match-utils.ts`:删掉 `collectPolicyCardsFromMessages()` 及其
+      `parsePolicyTableContent` import(该函数是它唯一的使用者)
+    - `harness/tools/_entry-policy-cards.ts`:去掉该导出
+    - `verify-policy-card-list.mjs`:删掉第 1 节(跨消息聚合的断言)、
+      第 6 节改用直接传入卡片数组;**头部两段说明也改了**(有一段还写着
+      「侧边栏要的是整个会话的聚合」,与新行为自相矛盾,留着会误导)
+- **保留**(这三步仍是列表要用的):去重 `dedupePolicyItems`、排序
+  `sortPolicyCardsByMatchScore`、关键词过滤 `matchesPolicyCardKeyword`
+  —— 同一回答里若出现重复条目,仍会去重
+- **运行过的验证**:
+  - `verify-policy-card-list.mjs` **24/24 通过**(原 31 项中去掉 7 项聚合断言)
+  - `npm run build` 通过
+  - 残留检查:`collectSessionPolicyCards` / `collectPolicyCardsFromMessages`
+    在源码与入口里**零命中**(只剩我写的"已移除"说明注释)
+  - `validate-harness` 通过
+- **已记录证据**:本文件 Session 052;`verify-policy-card-list.mjs`
+- **更新过的文件或工件**:`src/components/Chat/PolicyMatch.vue`、
+  `src/components/Chat/policy-match-utils.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `harness/tools/{_entry-policy-cards.ts,verify-policy-card-list.mjs}`、
+  根 `README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **未在浏览器实测**:要确认点「更多」看到的确实**只**是本条回答的政策
+  - ⚠️ 与 Session 026 的记录**方向相反**(那次用户选的是「本会话全部卡片」)——
+    已在本次记录与 feature_list 里写明是**用户 2026-09-18 的调整**,不是回归
+- **下一步最佳动作**:浏览器实测
+

+ 0 - 1
harness/tools/_entry-policy-cards.ts

@@ -1,6 +1,5 @@
 /** esbuild 入口:卡片聚合相关的纯逻辑(「更多」→ 政策列表 侧边栏) */
 export {
-  collectPolicyCardsFromMessages,
   buildPolicyCardDedupKey,
   dedupePolicyItems,
   sortPolicyCardsByMatchScore,

+ 11 - 29
harness/tools/verify-policy-card-list.mjs

@@ -1,9 +1,12 @@
 /**
- * 验证「更多」→「政策列表」侧边栏的纯逻辑:跨消息聚合、去重、排序、关键词过滤。
+ * 验证「更多」→「政策列表」侧边栏的纯逻辑:去重、排序、关键词过滤。
  *
- * 为什么要单独测:卡片区每个 PolicyMatch 实例只持有**自己那条消息**的卡片,
- * 侧边栏要的是**整个会话**的聚合,这段逻辑(解析历史消息 → 去重 → 排序 → 过滤)
- * 容易在边界上出错(坏 JSON、重复卡片、精简格式占位符、同分排序抖动)。
+ * 注:侧边栏列的是**本条回答里的政策**(用户 2026-09-18 调整;此前一度做成
+ *     「整个会话跨消息聚合」,那个采集函数已随之删除)。
+ *
+ * 为什么要单独测:列表要经过「去重 → 排序 → 关键词过滤」三步,
+ * 边界容易出错(精简格式的占位符 declaration_item、同分排序抖动、空关键词、
+ * 空/脏输入)。这些都是纯逻辑,用断言守住比在浏览器里点便宜得多。
  *
  * 怎么跑(在项目根目录):
  *   npx esbuild harness/tools/_entry-policy-cards.ts --bundle --format=esm \
@@ -12,7 +15,6 @@
  */
 
 import {
-  collectPolicyCardsFromMessages,
   buildPolicyCardDedupKey,
   dedupePolicyItems,
   sortPolicyCardsByMatchScore,
@@ -31,32 +33,12 @@ const check = (name, cond, extra = '') => {
   }
 };
 
-/** 造一条带 POLICY_TABLE 块的 AI 消息(格式与适配层 flushContent 产出一致) */
-const aiMessage = (cards, extraText = '正文') =>
-  `${extraText}\n<!-- POLICY_TABLE ${JSON.stringify({ data: cards })} POLICY_TABLE -->\n`;
-
-console.log('【1】跨消息聚合(collectPolicyCardsFromMessages)');
+/** 三条测试用卡片(后续各节共用) */
 const cardA = { declaration_item: '政策甲 - 事项一', title: '政策甲', match_score: 90 };
 const cardB = { declaration_item: '政策乙 - 事项一', title: '政策乙', match_score: 45 };
 const cardC = { declaration_item: '政策丙 - 事项一', title: '政策丙', match_score: 60 };
 
-const messages = [
-  { role: 'user', content: '我有什么政策' }, // 用户消息不该被解析
-  { role: 'ai', content: aiMessage([cardA, cardB]) },
-  { role: 'ai', content: '这条没有卡片' },
-  { role: 'ai', content: aiMessage([cardC]) },
-];
-const collected = collectPolicyCardsFromMessages(messages);
-check('收集到 3 张卡片', collected.length === 3, `实际 ${collected.length}`);
-check('保持消息顺序(甲→乙→丙)', collected.map((c) => c.title).join(',') === '政策甲,政策乙,政策丙', collected.map((c) => c.title).join(','));
-check('用户消息被跳过', !collected.some((c) => c.title === '我有什么政策'));
-
-check('空输入不抛错 → []', Array.isArray(collectPolicyCardsFromMessages([])) && collectPolicyCardsFromMessages([]).length === 0);
-check('null 输入不抛错 → []', collectPolicyCardsFromMessages(null).length === 0);
-check('坏 JSON 不抛错且不贡献卡片', collectPolicyCardsFromMessages([{ role: 'ai', content: '<!-- POLICY_TABLE {坏JSON POLICY_TABLE -->' }]).length === 0);
-check('无 POLICY_TABLE 块 → []', collectPolicyCardsFromMessages([{ role: 'ai', content: '纯文本回答' }]).length === 0);
-
-console.log('\n【2】去重键(buildPolicyCardDedupKey)');
+console.log('【1】去重键(buildPolicyCardDedupKey)');
 check(
   '同 declaration_item → 同键',
   buildPolicyCardDedupKey(cardA) === buildPolicyCardDedupKey({ ...cardA, match_score: 10 }),
@@ -107,9 +89,9 @@ check('大小写不敏感', matchesPolicyCardKeyword({ title: 'ABC Policy' }, 'a
 check('不命中返回 false', matchesPolicyCardKeyword(searchable, '不存在的词') === false);
 check('item 为 null 不抛错', matchesPolicyCardKeyword(null, 'x') === false);
 
-console.log('\n【6】端到端组合(聚合 → 去重 → 排序 → 过滤)');
+console.log('\n【6】端到端组合(去重 → 排序 → 过滤)');
 const e2e = sortPolicyCardsByMatchScore(
-  dedupePolicyItems(collectPolicyCardsFromMessages(messages), buildPolicyCardDedupKey)
+  dedupePolicyItems([cardA, cardB, cardC], buildPolicyCardDedupKey)
 );
 check('组合后 3 条且按分数降序', e2e.length === 3 && e2e[0].match_score === 90, e2e.map((c) => c.match_score).join(','));
 const e2eFiltered = e2e.filter((c) => matchesPolicyCardKeyword(c, '政策乙'));

+ 8 - 12
src/components/Chat/PolicyMatch.vue

@@ -628,22 +628,18 @@ onBeforeUnmount(() => {
   document.removeEventListener("click", closeMenus);
 });
 
-// ─── 「更多」→ 政策列表 侧边栏(本会话卡片聚合)─────────────────────────
+// ─── 「更多」→ 政策列表 侧边栏(**本条回答的卡片**)─────────────────────
 //
-// 数据来自 useBusinessAssistantChat provide 的会话级采集函数
-// (每个 PolicyMatch 实例只持有自己那条消息的卡片,聚合必须往上取)。
-// 采不到 provider 时(组件被独立使用)回退为本组件自己的卡片,行为与 syncPolicies 同源。
-const collectSessionPolicyCards = inject("collectSessionPolicyCards", null) as
-  | (() => Record<string, any>[])
-  | null;
-
+// ⚠️ 2026-09-18 用户调整:**只列这条回答里出现的政策**,不再跨消息聚合整个会话。
+// 因此数据源与本组件的 syncPolicies 同源(props.policyData / props.policyContent),
+// 不再往上层取「会话级聚合」。
 const cardListItems = ref<PolicyItem[]>([]);
 
-/** 重建聚合列表:采集 → 与政策库合并补全 → 规范化 → 去重(首见优先)→ 按匹配分排序 */
+/** 重建列表:取本条回答的卡片 → 与政策库合并补全 → 规范化 → 去重(首见优先)→ 按匹配分排序 */
 const rebuildCardList = () => {
-  const raw =
-    collectSessionPolicyCards?.() ??
-    (props.policyData?.length ? props.policyData : parsePolicyTableContent(props.policyContent));
+  const raw = props.policyData?.length
+    ? props.policyData
+    : parsePolicyTableContent(props.policyContent);
   const normalized = (raw || []).map(mergeWithPublicPolicies).map(normalizePolicy);
   cardListItems.value = sortPolicyCardsByMatchScore(
     dedupePolicyItems(normalized, buildPolicyCardDedupKey)

+ 4 - 24
src/components/Chat/policy-match-utils.ts

@@ -1,5 +1,3 @@
-import { parsePolicyTableContent } from "@/utils/stream-xml-filter";
-
 export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "非免申即享";
 
 export type PolicyListQuery = {
@@ -184,29 +182,11 @@ export const resolvePolicyDetailIndex = <T extends { originalData?: any; declara
   return matched.length === 1 ? matched[0] : -1;
 };
 
-// ─── 会话级卡片聚合(「更多」→ 政策列表 侧边栏)────────────────────────
+// ─── 「更多」→ 政策列表 侧边栏用的纯逻辑 ────────────────────────────────
 //
-// 背景:卡片区每条消息各自渲染一个 PolicyMatch 实例,`policies.value` 只等于
-// **那一条消息**的卡片;会话内不存在聚合。侧边栏要「本会话所有卡片」,
-// 因此采集层从会话消息的 content 里重新解析 POLICY_TABLE 块(历史消息同样适用,
-// 因为 localStoarge 历史与 DMS 回来的 answer 都原样保留了这些标记)。
-
-/**
- * 从一组消息里解析出全部政策卡片 raw 条目(跨消息聚合)。
- * - 只取 AI 消息(政策卡片只会出现在回答里)
- * - **不去重、不排序**,保持消息顺序 —— 去重与排序由调用方按需组合
- * - 消息内容不完整(流式进行中)或没有 POLICY_TABLE 块时返回空,不抛错
- */
-export const collectPolicyCardsFromMessages = (
-  messages: Array<{ role?: string; content?: unknown }> | null | undefined
-): Record<string, any>[] => {
-  const cards: Record<string, any>[] = [];
-  for (const message of Array.isArray(messages) ? messages : []) {
-    if (message?.role !== "ai") continue;
-    cards.push(...parsePolicyTableContent(String(message.content ?? "")));
-  }
-  return cards;
-};
+// 侧边栏列的是**本条回答里出现的政策**(用户 2026-09-18 调整;此前一度做成
+// 「整个会话跨消息聚合」,已撤回)。所以这里只留去重 / 排序 / 关键词过滤这几个
+// 通用零件,数据源由 PolicyMatch 自己提供(props.policyData / policyContent)。
 
 /**
  * 卡片去重键。

+ 3 - 14
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -36,7 +36,6 @@ import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from
 // [已停用] updateSessionInfo 随 1887 会话写入一并停用
 import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
 // [已停用] upsertDmsSession 随 1887 会话写入一并停用
-import { collectPolicyCardsFromMessages } from '../Chat/policy-match-utils';
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -1682,19 +1681,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   provide('submitQuestionAnswers', submitQuestionAnswers);
 
-  /**
-   * 会话级政策卡片聚合:返回**本会话**所有 AI 消息里出现过的政策卡片 raw 条目。
-   *
-   * 用途:卡片区「更多」打开的新侧边栏(政策列表)要展示整个会话的卡片,
-   * 而每个 PolicyMatch 实例只持有自己那条消息的卡片,故统一从这里取。
-   *
-   * 调用时才读 currentSession.value —— 切换会话后天然跟随,无需额外同步。
-   * 这里只负责「读会话 + 解析」,去重/排序/过滤交给 policy-match-utils 的纯函数
-   * (可被 harness 脚本直接测试)。
-   */
-  provide('collectSessionPolicyCards', (): Record<string, any>[] =>
-    collectPolicyCardsFromMessages(currentSession.value?.messages ?? [])
-  );
+  // [已移除] 会话级卡片聚合 provide('collectSessionPolicyCards', …)
+  // 「更多」侧边栏已改为**只列本条回答里的政策**(用户 2026-09-18 调整),
+  // PolicyMatch 直接用本组件自己的卡片,不再需要往上取会话级聚合。
 
   return {
     suggestions,

Неке датотеке нису приказане због велике количине промена