policy-match-utils.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "非免申即享";
  2. /**
  3. * 由「被点击的条目」反查它在 `policies` 数组里的下标;查不到返回 -1。
  4. *
  5. * 🐛 为什么需要这个函数(2026-09-17 修的真实 bug):
  6. * 同一条政策下的**多个申报事项**会各生成一张卡片,但适配层把卡片的
  7. * `title` 与 `declaration_item` 都取自 `card.name.text`(**只有政策名**),
  8. * 而后端又没有下发 `id` / `policy_id`(实测恒为 undefined)。
  9. * 于是这些卡片在「按字段比对」这条路上**完全无法区分**——
  10. * 原先的 findIndex 只要任一条件命中就返回,导致点第二张卡片也跳到第一张,
  11. * 表现为「两张卡片的详情内容一模一样」。
  12. *
  13. * 匹配顺序(从可靠到不可靠):
  14. * 1. **对象同一性**:卡片区传入的就是 policies 里的那个对象,这一条是精确的
  15. * 2. 字段比对(id / policy_id / declaration_item):**只有唯一命中才采用**。
  16. * 多命中说明这组数据本就区分不了(例如同一政策的多个申报事项),
  17. * 此时返回 -1 交给调用方用「直接展示被点条目自己」的兜底路径 ——
  18. * 宁可走兜底,也不要张冠李戴地展示另一条的内容。
  19. */
  20. export const resolvePolicyDetailIndex = <T extends { originalData?: any; declaration_item?: string }>(
  21. policies: T[] | null | undefined,
  22. item: { originalData?: any; declaration_item?: string } | null | undefined
  23. ): number => {
  24. const list = Array.isArray(policies) ? policies : [];
  25. if (!list.length || !item) return -1;
  26. // 1. 对象同一性
  27. const exact = list.findIndex((policy) => policy?.originalData === item.originalData);
  28. if (exact >= 0) return exact;
  29. // 2. 字段比对(仅在唯一命中时采信)
  30. const targetId = item.originalData?.id || item.originalData?.policy_id;
  31. const targetDeclaration = item.originalData?.declaration_item;
  32. const matched: number[] = [];
  33. list.forEach((policy, index) => {
  34. const current = policy?.originalData;
  35. const sameId =
  36. !!targetId && (current?.id === targetId || current?.policy_id === targetId);
  37. const sameDeclaration =
  38. !!targetDeclaration && policy?.declaration_item === targetDeclaration;
  39. if (sameId || sameDeclaration) matched.push(index);
  40. });
  41. return matched.length === 1 ? matched[0] : -1;
  42. };
  43. // ─── 「更多」→ 政策列表 侧边栏用的纯逻辑 ────────────────────────────────
  44. //
  45. // 侧边栏列的是**本条回答里出现的政策**(用户 2026-09-18 调整;此前一度做成
  46. // 「整个会话跨消息聚合」,已撤回)。所以这里只留去重 / 排序 / 关键词过滤这几个
  47. // 通用零件,数据源由 PolicyMatch 自己提供(props.policyData / policyContent)。
  48. /**
  49. * 卡片去重键。
  50. *
  51. * 主键用 `declaration_item`:自 2026-09-17 起它是「政策名 - 申报事项」,每条唯一
  52. * (之前它与政策名同值,同政策的多个申报事项无法区分 —— 见 policy-match-utils
  53. * 里 resolvePolicyDetailIndex 的说明)。
  54. *
  55. * 精简格式(minimal)的 `declaration_item` 恒为占位符「查看政策详情」,
  56. * 用它去重会把不同卡片错并成一条,故退化为「标题|匹配度|匹配理由|id」组合键。
  57. */
  58. // 入参刻意用 any:进来的是从消息里解析出的无类型 JSON(也可能是已 normalize 的 PolicyItem),
  59. // 收窄成具体类型反而会让 dedupePolicyItems 的泛型推断跑偏(会把 T 推成 Record<string, any>)
  60. export const buildPolicyCardDedupKey = (item: any): string => {
  61. const declaration = String(item?.declaration_item ?? "").trim();
  62. if (declaration && declaration !== "查看政策详情") {
  63. return `decl:${declaration}`;
  64. }
  65. const id = String(
  66. item?.id ?? item?.policy_id ?? item?.originalData?.id ?? item?.originalData?.policy_id ?? ""
  67. ).trim();
  68. return `fb:${String(item?.title ?? "")}|${String(item?.match_degree ?? "")}|${String(
  69. item?.match_reason ?? ""
  70. ).slice(0, 60)}|${id}`;
  71. };
  72. /** 按 key 去重,**首见优先**(保证聚合结果与会话内出现顺序一致、可预期) */
  73. export const dedupePolicyItems = <T>(items: T[] | null | undefined, keyOf: (item: T) => string): T[] => {
  74. const seen = new Set<string>();
  75. return (Array.isArray(items) ? items : []).filter((item) => {
  76. const key = keyOf(item);
  77. if (seen.has(key)) return false;
  78. seen.add(key);
  79. return true;
  80. });
  81. };
  82. /** 按 match_score 降序;同分保持原有相对顺序(稳定排序,避免顺序抖动) */
  83. export const sortPolicyCardsByMatchScore = <T extends { match_score?: number | string }>(
  84. items: T[] | null | undefined
  85. ): T[] =>
  86. (Array.isArray(items) ? items : [])
  87. .map((item, index) => ({ item, index }))
  88. .sort(
  89. (a, b) =>
  90. (Number(b.item?.match_score) || 0) - (Number(a.item?.match_score) || 0) || a.index - b.index
  91. )
  92. .map(({ item }) => item);
  93. /**
  94. * 卡片关键词匹配。检索面与旧列表 buildRecordPolicyList 保持一致
  95. * (title / desc / declaration_item / match_reason / source / enterprise_benefit)。
  96. * 空关键词恒真;大小写不敏感。
  97. */
  98. export const matchesPolicyCardKeyword = (
  99. item: {
  100. title?: unknown;
  101. desc?: unknown;
  102. declaration_item?: unknown;
  103. match_reason?: unknown;
  104. source?: unknown;
  105. enterprise_benefit?: unknown;
  106. } | null
  107. | undefined,
  108. keyword: string
  109. ): boolean => {
  110. const kw = String(keyword ?? "").trim().toLowerCase();
  111. if (!kw) return true;
  112. const haystack = [
  113. item?.title,
  114. item?.desc,
  115. item?.declaration_item,
  116. item?.match_reason,
  117. item?.source,
  118. item?.enterprise_benefit,
  119. ]
  120. .map((value) => String(value ?? ""))
  121. .join(" ")
  122. .toLowerCase();
  123. return haystack.includes(kw);
  124. };