dsh-data-domain-knowledge 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/cordis.patch.yml +410 -0
  4. package/eval/README.md +76 -0
  5. package/eval/evaluate-retrieval.mjs +121 -0
  6. package/eval/queries.example.jsonl +2 -0
  7. package/lib/client.js +111415 -0
  8. package/lib/types/config-schema.d.ts +35 -0
  9. package/lib/types/config-schema.d.ts.map +1 -0
  10. package/lib/types/config-schema.js +34 -0
  11. package/lib/types/config-schema.js.map +1 -0
  12. package/lib/types/convert/core-loader.d.ts +32 -0
  13. package/lib/types/convert/core-loader.d.ts.map +1 -0
  14. package/lib/types/convert/core-loader.js +60 -0
  15. package/lib/types/convert/core-loader.js.map +1 -0
  16. package/lib/types/convert/host.d.ts +88 -0
  17. package/lib/types/convert/host.d.ts.map +1 -0
  18. package/lib/types/convert/host.js +479 -0
  19. package/lib/types/convert/host.js.map +1 -0
  20. package/lib/types/convert/ingest.d.ts +125 -0
  21. package/lib/types/convert/ingest.d.ts.map +1 -0
  22. package/lib/types/convert/ingest.js +228 -0
  23. package/lib/types/convert/ingest.js.map +1 -0
  24. package/lib/types/convert/settings-schema.d.ts +18 -0
  25. package/lib/types/convert/settings-schema.d.ts.map +1 -0
  26. package/lib/types/convert/settings-schema.js +26 -0
  27. package/lib/types/convert/settings-schema.js.map +1 -0
  28. package/lib/types/convert/types.d.ts +46 -0
  29. package/lib/types/convert/types.d.ts.map +1 -0
  30. package/lib/types/convert/types.js +41 -0
  31. package/lib/types/convert/types.js.map +1 -0
  32. package/lib/types/diagnosis/cards.d.ts +71 -0
  33. package/lib/types/diagnosis/cards.d.ts.map +1 -0
  34. package/lib/types/diagnosis/cards.js +482 -0
  35. package/lib/types/diagnosis/cards.js.map +1 -0
  36. package/lib/types/diagnosis/problem-classes.d.ts +51 -0
  37. package/lib/types/diagnosis/problem-classes.d.ts.map +1 -0
  38. package/lib/types/diagnosis/problem-classes.js +66 -0
  39. package/lib/types/diagnosis/problem-classes.js.map +1 -0
  40. package/lib/types/diagnosis/schema.d.ts +65 -0
  41. package/lib/types/diagnosis/schema.d.ts.map +1 -0
  42. package/lib/types/diagnosis/schema.js +208 -0
  43. package/lib/types/diagnosis/schema.js.map +1 -0
  44. package/lib/types/diagnosis/seed-source.d.ts +20 -0
  45. package/lib/types/diagnosis/seed-source.d.ts.map +1 -0
  46. package/lib/types/diagnosis/seed-source.js +96 -0
  47. package/lib/types/diagnosis/seed-source.js.map +1 -0
  48. package/lib/types/diagnosis/seed.d.ts +50 -0
  49. package/lib/types/diagnosis/seed.d.ts.map +1 -0
  50. package/lib/types/diagnosis/seed.js +242 -0
  51. package/lib/types/diagnosis/seed.js.map +1 -0
  52. package/lib/types/diagnosis/store.d.ts +81 -0
  53. package/lib/types/diagnosis/store.d.ts.map +1 -0
  54. package/lib/types/diagnosis/store.js +409 -0
  55. package/lib/types/diagnosis/store.js.map +1 -0
  56. package/lib/types/diagnosis/types.d.ts +124 -0
  57. package/lib/types/diagnosis/types.d.ts.map +1 -0
  58. package/lib/types/diagnosis/types.js +13 -0
  59. package/lib/types/diagnosis/types.js.map +1 -0
  60. package/lib/types/directory-picker.d.ts +94 -0
  61. package/lib/types/directory-picker.d.ts.map +1 -0
  62. package/lib/types/directory-picker.js +110 -0
  63. package/lib/types/directory-picker.js.map +1 -0
  64. package/lib/types/extract-model.d.ts +76 -0
  65. package/lib/types/extract-model.d.ts.map +1 -0
  66. package/lib/types/extract-model.js +193 -0
  67. package/lib/types/extract-model.js.map +1 -0
  68. package/lib/types/extract.d.ts +121 -0
  69. package/lib/types/extract.d.ts.map +1 -0
  70. package/lib/types/extract.js +549 -0
  71. package/lib/types/extract.js.map +1 -0
  72. package/lib/types/fts.d.ts +96 -0
  73. package/lib/types/fts.d.ts.map +1 -0
  74. package/lib/types/fts.js +184 -0
  75. package/lib/types/fts.js.map +1 -0
  76. package/lib/types/index.d.ts +39 -0
  77. package/lib/types/index.d.ts.map +1 -0
  78. package/lib/types/index.js +1112 -0
  79. package/lib/types/index.js.map +1 -0
  80. package/lib/types/intent/engine.d.ts +108 -0
  81. package/lib/types/intent/engine.d.ts.map +1 -0
  82. package/lib/types/intent/engine.js +501 -0
  83. package/lib/types/intent/engine.js.map +1 -0
  84. package/lib/types/intent/host.d.ts +88 -0
  85. package/lib/types/intent/host.d.ts.map +1 -0
  86. package/lib/types/intent/host.js +389 -0
  87. package/lib/types/intent/host.js.map +1 -0
  88. package/lib/types/intent/preset-tools.d.ts +37 -0
  89. package/lib/types/intent/preset-tools.d.ts.map +1 -0
  90. package/lib/types/intent/preset-tools.js +45 -0
  91. package/lib/types/intent/preset-tools.js.map +1 -0
  92. package/lib/types/intent/settings-schema.d.ts +59 -0
  93. package/lib/types/intent/settings-schema.d.ts.map +1 -0
  94. package/lib/types/intent/settings-schema.js +57 -0
  95. package/lib/types/intent/settings-schema.js.map +1 -0
  96. package/lib/types/intent/store.d.ts +75 -0
  97. package/lib/types/intent/store.d.ts.map +1 -0
  98. package/lib/types/intent/store.js +139 -0
  99. package/lib/types/intent/store.js.map +1 -0
  100. package/lib/types/intent/tools.d.ts +63 -0
  101. package/lib/types/intent/tools.d.ts.map +1 -0
  102. package/lib/types/intent/tools.js +108 -0
  103. package/lib/types/intent/tools.js.map +1 -0
  104. package/lib/types/intent/types.d.ts +189 -0
  105. package/lib/types/intent/types.d.ts.map +1 -0
  106. package/lib/types/intent/types.js +62 -0
  107. package/lib/types/intent/types.js.map +1 -0
  108. package/lib/types/knowledge-bases.d.ts +266 -0
  109. package/lib/types/knowledge-bases.d.ts.map +1 -0
  110. package/lib/types/knowledge-bases.js +1107 -0
  111. package/lib/types/knowledge-bases.js.map +1 -0
  112. package/lib/types/layout.d.ts +262 -0
  113. package/lib/types/layout.d.ts.map +1 -0
  114. package/lib/types/layout.js +312 -0
  115. package/lib/types/layout.js.map +1 -0
  116. package/lib/types/migrate.d.ts +110 -0
  117. package/lib/types/migrate.d.ts.map +1 -0
  118. package/lib/types/migrate.js +280 -0
  119. package/lib/types/migrate.js.map +1 -0
  120. package/lib/types/recall-rank.d.ts +158 -0
  121. package/lib/types/recall-rank.d.ts.map +1 -0
  122. package/lib/types/recall-rank.js +261 -0
  123. package/lib/types/recall-rank.js.map +1 -0
  124. package/lib/types/runtime.d.ts +568 -0
  125. package/lib/types/runtime.d.ts.map +1 -0
  126. package/lib/types/runtime.js +3045 -0
  127. package/lib/types/runtime.js.map +1 -0
  128. package/lib/types/settings-schema.d.ts +117 -0
  129. package/lib/types/settings-schema.d.ts.map +1 -0
  130. package/lib/types/settings-schema.js +92 -0
  131. package/lib/types/settings-schema.js.map +1 -0
  132. package/lib/types/settings-volatile.d.ts +102 -0
  133. package/lib/types/settings-volatile.d.ts.map +1 -0
  134. package/lib/types/settings-volatile.js +114 -0
  135. package/lib/types/settings-volatile.js.map +1 -0
  136. package/lib/types/types.d.ts +799 -0
  137. package/lib/types/types.d.ts.map +1 -0
  138. package/lib/types/types.js +387 -0
  139. package/lib/types/types.js.map +1 -0
  140. package/lib/types/wiki-types.d.ts +68 -0
  141. package/lib/types/wiki-types.d.ts.map +1 -0
  142. package/lib/types/wiki-types.js +322 -0
  143. package/lib/types/wiki-types.js.map +1 -0
  144. package/package.json +101 -0
  145. package/preset/tita-data-agent/agent.cordis.yml +376 -0
  146. package/preset/tita-data-agent/preset.yml +3 -0
  147. package/python/requirements.txt +8 -0
  148. package/scripts/doctor.mjs +70 -0
  149. package/scripts/e2e-pipeline.mjs +355 -0
  150. package/scripts/install-agent-preset.mjs +92 -0
  151. package/scripts/setup-embedding.sh +34 -0
  152. package/scripts/sync-agent-preset.mjs +188 -0
  153. package/src/client/AgentPresetCard.tsx +247 -0
  154. package/src/client/DirectoryPicker.tsx +357 -0
  155. package/src/client/InfoHint.tsx +65 -0
  156. package/src/client/IntentLogicTab.tsx +185 -0
  157. package/src/client/IntentTab.tsx +715 -0
  158. package/src/client/KnowledgeBaseTab.tsx +1454 -0
  159. package/src/client/KnowledgeGraphView.tsx +306 -0
  160. package/src/client/KnowledgePanel.tsx +381 -0
  161. package/src/client/KnowledgeStepRail.tsx +60 -0
  162. package/src/client/NodeSettings.tsx +392 -0
  163. package/src/client/PreviewSections.tsx +116 -0
  164. package/src/client/RecallPanel.tsx +399 -0
  165. package/src/client/TypeDefinitionDrawer.tsx +215 -0
  166. package/src/client/WikiFlowCard.tsx +226 -0
  167. package/src/client/WikiTypeEditor.tsx +332 -0
  168. package/src/client/agent-preset.ts +121 -0
  169. package/src/client/arch/AgentArchitectureGraph.tsx +219 -0
  170. package/src/client/arch/NodeLogicPanel.tsx +133 -0
  171. package/src/client/arch/ProblemDetailPanel.tsx +88 -0
  172. package/src/client/arch/model.ts +691 -0
  173. package/src/client/architecture.css +191 -0
  174. package/src/client/architecture.tsx +530 -0
  175. package/src/client/attribution/AttributionGraph.tsx +104 -0
  176. package/src/client/attribution/AttributionTab.tsx +332 -0
  177. package/src/client/attribution/CaseView.tsx +102 -0
  178. package/src/client/attribution/GraphStage.tsx +87 -0
  179. package/src/client/attribution/RunView.tsx +184 -0
  180. package/src/client/attribution/StageChrome.tsx +403 -0
  181. package/src/client/attribution/ViewSwitch.tsx +107 -0
  182. package/src/client/attribution/graph-options.ts +381 -0
  183. package/src/client/attribution/model.ts +462 -0
  184. package/src/client/config-scope.ts +119 -0
  185. package/src/client/convert/ConvertPanel.tsx +594 -0
  186. package/src/client/convert/styles.css +138 -0
  187. package/src/client/convert/styles.ts +49 -0
  188. package/src/client/format.ts +39 -0
  189. package/src/client/graph/layout.ts +181 -0
  190. package/src/client/graph.css +113 -0
  191. package/src/client/index.ts +98 -0
  192. package/src/client/problem-domains.ts +114 -0
  193. package/src/client/styles.css +940 -0
  194. package/src/client/styles.ts +11 -0
  195. package/src/client/useFitScale.ts +68 -0
  196. package/src/config-schema.ts +45 -0
  197. package/src/convert/core-loader.ts +68 -0
  198. package/src/convert/host.ts +579 -0
  199. package/src/convert/ingest.ts +367 -0
  200. package/src/convert/settings-schema.ts +27 -0
  201. package/src/convert/types.ts +65 -0
  202. package/src/diagnosis/cards.ts +498 -0
  203. package/src/diagnosis/problem-classes.ts +117 -0
  204. package/src/diagnosis/schema.ts +260 -0
  205. package/src/diagnosis/seed-source.ts +107 -0
  206. package/src/diagnosis/seed.ts +291 -0
  207. package/src/diagnosis/store.ts +432 -0
  208. package/src/diagnosis/types.ts +152 -0
  209. package/src/directory-picker.ts +164 -0
  210. package/src/extract-model.ts +227 -0
  211. package/src/extract.ts +632 -0
  212. package/src/fts.ts +203 -0
  213. package/src/index.ts +1091 -0
  214. package/src/intent/README.md +63 -0
  215. package/src/intent/engine.ts +527 -0
  216. package/src/intent/host.ts +462 -0
  217. package/src/intent/preset-tools.ts +48 -0
  218. package/src/intent/settings-schema.ts +63 -0
  219. package/src/intent/store.ts +143 -0
  220. package/src/intent/tools.ts +139 -0
  221. package/src/intent/types.ts +245 -0
  222. package/src/knowledge-bases.ts +1160 -0
  223. package/src/layout.ts +350 -0
  224. package/src/migrate.ts +262 -0
  225. package/src/python/embedding_worker.py +471 -0
  226. package/src/recall-rank.ts +299 -0
  227. package/src/runtime.ts +3069 -0
  228. package/src/settings-schema.ts +94 -0
  229. package/src/settings-volatile.ts +122 -0
  230. package/src/types.ts +1140 -0
  231. package/src/wiki-types.ts +357 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * 召回链路的**纯函数**部分(0031):融合、boost、去冗余、证据抽取。
3
+ *
4
+ * 与 `graph/layout.ts`、`problem-domains.ts` 同一个理由:把不依赖 runtime、不碰磁盘的
5
+ * 那部分单独放,于是它们可以在 `node:test` 里被直接断言。这里的每个函数都对应 0031
6
+ * 的一个设计决定,注释写的是**为什么**,不是做了什么。
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { RecallEvidence, RecallRoute } from './types.js'
12
+ import { segment } from './fts.js'
13
+
14
+ /** 一路的排序结果(已过阈值、已按分数降序)。 */
15
+ export interface RouteRanking {
16
+ unitId: string
17
+ /** 该路自己的分数,只用于阈值与展示 —— 融合不读它。 */
18
+ score: number
19
+ }
20
+
21
+ export interface FusedHit {
22
+ unitId: string
23
+ score: number
24
+ /** 每一路的名次(1 起)。缺失 = 那一路没有召回它。 */
25
+ ranks: Partial<Record<RecallRoute, number>>
26
+ }
27
+
28
+ export interface RrfOptions {
29
+ /** RRF 的平滑常数;越大越平(削弱第一名)。WeKnora 默认 60。 */
30
+ k: number
31
+ weights: Partial<Record<RecallRoute, number>>
32
+ }
33
+
34
+ /**
35
+ * 名次级融合(Reciprocal Rank Fusion)。
36
+ *
37
+ * **为什么不用分数加权和**:三路的分数是三个不同的量纲 —— 关键词是归一化 BM25、
38
+ * 向量是余弦、图谱是命中分/最大命中分。把它们乘权重相加,等于假设「0.5 的关键词分」
39
+ * 与「0.5 的余弦」一样好,而这个假设没有任何依据。0030 的实际后果就是:向量路输出
40
+ * 常数时,hybrid 的 55% 权重全是噪声,排序退化成 `unitId` 字典序(实测 4 条命中
41
+ * 分数完全相同:`0.2753 = 0.5 × 0.55`)。
42
+ *
43
+ * RRF 只吃**名次**,所以量纲问题从根上消失,而权重变成「我更信哪一路的排序」——
44
+ * 这是一个可以校准、也可以被解释的量。
45
+ */
46
+ export function rrfFuse(rankings: Partial<Record<RecallRoute, RouteRanking[]>>, options: RrfOptions): FusedHit[] {
47
+ const { k, weights } = options
48
+ const ranks = new Map<string, Partial<Record<RecallRoute, number>>>()
49
+ const scores = new Map<string, number>()
50
+ const order: RecallRoute[] = ['fts', 'vector', 'graph']
51
+ for (const route of order) {
52
+ const list = rankings[route]
53
+ if (!list || list.length === 0) continue
54
+ const weight = weights[route] ?? 0
55
+ if (weight <= 0) continue
56
+ // 同一路里同一 unitId 只认第一次出现(更好的名次)——重复条目不该把分数抬两次。
57
+ const seen = new Set<string>()
58
+ let rank = 0
59
+ for (const item of list) {
60
+ if (seen.has(item.unitId)) continue
61
+ seen.add(item.unitId)
62
+ rank += 1
63
+ const entry = ranks.get(item.unitId) ?? {}
64
+ entry[route] = rank
65
+ ranks.set(item.unitId, entry)
66
+ scores.set(item.unitId, (scores.get(item.unitId) ?? 0) + weight / (k + rank))
67
+ }
68
+ }
69
+ return [...scores.entries()]
70
+ .map(([unitId, score]) => ({ unitId, score, ranks: ranks.get(unitId) ?? {} }))
71
+ .sort((left, right) => right.score - left.score || left.unitId.localeCompare(right.unitId))
72
+ }
73
+
74
+ /**
75
+ * 图谱作为**boost**,不是第三路。
76
+ *
77
+ * 0009/0014 都把知识图谱定位为**构建产物**(wiki→图谱),没有把它当作召回路线;
78
+ * 而 0030 把它做成第三路时,匹配逻辑与关键词路同源(词面匹配 title / id / 关系词),
79
+ * 等于把关键词路的弱点复制一遍,还多贡献了一份不可比的分数。
80
+ *
81
+ * 现在的语义是:图谱命中的实体及其 1 跳邻居,**只给已经在候选池里的条目**加一个
82
+ * RRF 项。它因此不能凭自己制造候选(那是「路」的权力),只能重排(那是「boost」的
83
+ * 职责)—— 于是它既保留了「命中实体 → 带出邻居」的价值,又不会污染融合。
84
+ */
85
+ export function graphBoost(fused: FusedHit[], graphRanking: RouteRanking[], options: RrfOptions): { hits: FusedHit[]; applied: number } {
86
+ const weight = options.weights.graph ?? 0
87
+ if (weight <= 0 || graphRanking.length === 0 || fused.length === 0) return { hits: fused, applied: 0 }
88
+ const rankOf = new Map<string, number>()
89
+ let rank = 0
90
+ for (const item of graphRanking) {
91
+ if (rankOf.has(item.unitId)) continue
92
+ rank += 1
93
+ rankOf.set(item.unitId, rank)
94
+ }
95
+ let applied = 0
96
+ const boosted = fused.map(hit => {
97
+ const rank = rankOf.get(hit.unitId)
98
+ if (rank === undefined) return hit
99
+ applied += 1
100
+ return {
101
+ ...hit,
102
+ score: hit.score + weight / (options.k + rank),
103
+ ranks: { ...hit.ranks, graph: rank },
104
+ }
105
+ })
106
+ return { hits: boosted.sort((left, right) => right.score - left.score || left.unitId.localeCompare(right.unitId)), applied }
107
+ }
108
+
109
+ /**
110
+ * 按阈值裁剪一路的候选,并给出这一路的诊断。
111
+ *
112
+ * 关键词路的分是**相对**归一的(见 `fts.ts` 的量纲说明),所以这里的阈值挡得住
113
+ * 「弱命中」、挡不住「整批都弱」;向量路的分是原始余弦,是绝对的。两者都要如实
114
+ * 写进诊断,不能让人以为「过了阈值」等于「相关」。
115
+ */
116
+ export function applyThreshold(ranking: RouteRanking[], threshold: number): { kept: RouteRanking[]; dropped: number; topScore: number } {
117
+ const topScore = ranking.length > 0 ? Math.max(...ranking.map(item => item.score)) : 0
118
+ const kept = ranking.filter(item => item.score >= threshold)
119
+ return { kept, dropped: ranking.length - kept.length, topScore }
120
+ }
121
+
122
+ /**
123
+ * 从原文里挑出与查询最相关的若干段,作为**证据片段**。
124
+ *
125
+ * 为什么要有这一步:`context` 原来只带 wiki 卡片,模型要核对原文还得再调一次工具。
126
+ * 把证据放进上下文,「每一个数字都能指回它的口径定义与原始文档」才在上下文层面
127
+ * 成立,而不是靠模型自觉去查。
128
+ *
129
+ * 段落按空行切(Markdown 的自然段),逐段算**查询词覆盖率**,取最高的几段。
130
+ * 覆盖率是绝对的,所以「找不到相关段落」会如实返回空数组,而不是硬塞一段无关文字。
131
+ */
132
+ export function evidenceExcerpts(sourceId: string, title: string | undefined, text: string, queryTerms: string[], options: { maxExcerpts?: number; maxChars?: number; minCoverage?: number } = {}): RecallEvidence[] {
133
+ const maxExcerpts = options.maxExcerpts ?? 2
134
+ const maxChars = options.maxChars ?? 1200
135
+ const minCoverage = options.minCoverage ?? 0.15
136
+ const terms = [...new Set(queryTerms)].filter(term => term.length > 0)
137
+ if (terms.length === 0 || text.trim() === '') return []
138
+
139
+ const blocks: Array<{ text: string; offset: number }> = []
140
+ const pattern = /[^\n]*(?:\n(?!\s*\n)[^\n]*)*/g
141
+ for (const match of text.matchAll(pattern)) {
142
+ const block = match[0]
143
+ if (block.trim().length < 8) continue
144
+ blocks.push({ text: block.trim(), offset: match.index ?? 0 })
145
+ }
146
+ if (blocks.length === 0) return []
147
+
148
+ const scored = blocks.map(block => {
149
+ const haystack = block.text.toLowerCase()
150
+ const hit = terms.filter(term => haystack.includes(term)).length
151
+ return { ...block, coverage: hit / terms.length }
152
+ }).filter(block => block.coverage >= minCoverage)
153
+
154
+ return scored
155
+ .sort((left, right) => right.coverage - left.coverage || left.offset - right.offset)
156
+ .slice(0, maxExcerpts)
157
+ .map(block => ({
158
+ sourceId,
159
+ ...(title ? { title } : {}),
160
+ excerpt: block.text.length > maxChars ? `${block.text.slice(0, maxChars)}…` : block.text,
161
+ offset: block.offset,
162
+ coverage: Number(block.coverage.toFixed(4)),
163
+ }))
164
+ }
165
+
166
+ /**
167
+ * 一条 note 进关键词索引的**完整文本**。
168
+ *
169
+ * 它包含三部分:标题、正文、以及**溯源身份**(它引用的 sourceId 与那些来源的标题)。
170
+ * 溯源身份不是锦上添花:图谱降为 boost 之后,「只提到某份来源、没提到任何卡片正文」
171
+ * 的查询再也没有第二条路可走,所以「这份台账派生出了哪些结论」必须由关键词路回答。
172
+ *
173
+ * 这个函数被**两处**共用 —— 建索引时写进去的文本,与召回时算查询词覆盖率的文本。
174
+ * 两处各写一份的话,覆盖率会对着不同的文本算,好命中会被静默丢掉(实测:查 `ledger`
175
+ * 时索引命中了、覆盖率却是 0)。
176
+ */
177
+ export function noteIndexText(note: { title: string; content: string; sourceRefs: string[] }, titleOf: (sourceId: string) => string | undefined): string {
178
+ const titles = note.sourceRefs.map(ref => titleOf(ref) ?? '').filter(Boolean).join(' ')
179
+ const provenance = [note.sourceRefs.join(' '), titles].filter(Boolean).join(' ')
180
+ return provenance ? `${note.title}\n${note.content}\n${provenance}` : `${note.title}\n${note.content}`
181
+ }
182
+
183
+ /** 一段文本的词集合,用于 MMR 的冗余度(Jaccard)。 */
184
+ export function tokenSet(text: string): Set<string> {
185
+ return new Set(segment(text))
186
+ }
187
+
188
+ function jaccard(left: Set<string>, right: Set<string>): number {
189
+ if (left.size === 0 || right.size === 0) return 0
190
+ let shared = 0
191
+ for (const token of left) if (right.has(token)) shared += 1
192
+ return shared / (left.size + right.size - shared)
193
+ }
194
+
195
+ export interface MmrOptions<T> {
196
+ /** 相关性(通常就是融合分)。 */
197
+ relevance: (item: T) => number
198
+ /** 该条目的词集合。 */
199
+ tokens: (item: T) => Set<string>
200
+ /** 该条目属于哪张 wiki —— 用于「每张卡最多几条」的硬约束。 */
201
+ wikiKey: (item: T) => string
202
+ /** 相关性 vs 多样性的权衡;0.7 = 偏相关性(与 WeKnora 的 grep_chunks 一致)。 */
203
+ lambda?: number
204
+ /** 每张 wiki 最多入选几条。 */
205
+ perWikiCap?: number
206
+ /** 总共要几条。 */
207
+ limit: number
208
+ }
209
+
210
+ /**
211
+ * 选上下文条目:**每张 wiki 封顶 + MMR 去冗余**。
212
+ *
213
+ * 为什么必须有:0030 的图谱路刻意给同一张 wiki 的分节 note 0.8 的权重,于是 5 条上下文
214
+ * 可以全部来自同一张卡 —— 一屏塞满、信息量为 1。这是「召回质量差」里最容易被忽略的一半:
215
+ * 前 5 条不是「5 条相关」,而是「1 条相关的 5 个片段」。
216
+ *
217
+ * 冗余度用**词集合的 Jaccard**,不用向量:它对编码器是否可用不敏感,因此去冗余在
218
+ * 向量路降级时依然生效 —— 依赖一个可能坏掉的组件的功能,等于把它的可靠性绑在那个组件上。
219
+ */
220
+ export function selectContext<T>(items: T[], options: MmrOptions<T>): T[] {
221
+ const lambda = options.lambda ?? 0.7
222
+ const perWikiCap = options.perWikiCap ?? 2
223
+ const pool = [...items].sort((left, right) => options.relevance(right) - options.relevance(left))
224
+ const selected: T[] = []
225
+ const selectedTokens: Array<Set<string>> = []
226
+ const perWiki = new Map<string, number>()
227
+ const remaining = [...pool]
228
+ while (selected.length < options.limit && remaining.length > 0) {
229
+ let bestIndex = -1
230
+ let bestValue = Number.NEGATIVE_INFINITY
231
+ for (let index = 0; index < remaining.length; index += 1) {
232
+ const candidate = remaining[index]!
233
+ const wiki = options.wikiKey(candidate)
234
+ if ((perWiki.get(wiki) ?? 0) >= perWikiCap) continue
235
+ const tokens = options.tokens(candidate)
236
+ let redundancy = 0
237
+ for (const taken of selectedTokens) redundancy = Math.max(redundancy, jaccard(tokens, taken))
238
+ const value = lambda * options.relevance(candidate) - (1 - lambda) * redundancy
239
+ if (value > bestValue) { bestValue = value; bestIndex = index }
240
+ }
241
+ if (bestIndex < 0) break
242
+ const chosen = remaining.splice(bestIndex, 1)[0]!
243
+ selected.push(chosen)
244
+ selectedTokens.push(options.tokens(chosen))
245
+ const wiki = options.wikiKey(chosen)
246
+ perWiki.set(wiki, (perWiki.get(wiki) ?? 0) + 1)
247
+ }
248
+ return selected
249
+ }
250
+
251
+ /** `match` 字段由名次推导:两路及以上命中 = hybrid。 */
252
+ export function matchOf(ranks: Partial<Record<RecallRoute, number>>): 'fts' | 'vector' | 'graph' | 'hybrid' {
253
+ const used = (['fts', 'vector', 'graph'] as RecallRoute[]).filter(route => ranks[route] !== undefined)
254
+ if (used.length >= 2) return 'hybrid'
255
+ return used[0] ?? 'fts'
256
+ }
257
+
258
+ /** 展示用的概率:`rerankScore` 是 logit 的 sigmoid。 */
259
+ export function sigmoidLogit(logit: number): number {
260
+ return 1 / (1 + Math.exp(-logit))
261
+ }
262
+
263
+ export interface RerankOutcome<T> {
264
+ /** 过阈值的条目,按 logit 降序;`rerankScore` 是 logit 的 sigmoid。 */
265
+ kept: Array<T & { rerankScore: number }>
266
+ /** 被阈值丢掉的条数。 */
267
+ dropped: number
268
+ /** 候选池里最高的 logit —— 跨查询可比的「这一批到底有多相关」。 */
269
+ topLogit: number
270
+ }
271
+
272
+ /**
273
+ * Stage 4:**在 logit 刻度上**过阈值,展示值才做一次 sigmoid。
274
+ *
275
+ * 刻度必须只有一个。cross-encoder 的价值就在于此:它的分是**跨查询可比**的,
276
+ * 所以「低于 0.2 就不要」是一个绝对判据,而关键词/向量的阈值做不到这一点。
277
+ * 0031 落地时这里错了两层:
278
+ *
279
+ * 1. worker 用 `CrossEncoder.predict` 的默认行为,拿到的是 **sigmoid 之后**的值
280
+ * ((0,1)),却按 logit 阈值 0.2 去比 —— 于是 `dropped` 恒为 0,绝对闸门从未生效。
281
+ * 2. Host 又 sigmoid 了一次做展示,把无关文档(logit −10.2)显示成 `0.500`
282
+ * —— 一个「看起来刚好在门槛上」的分数。
283
+ *
284
+ * 现在 worker 返回 logit(`predict_raw`),阈值与排序都在 logit 上,展示只 sigmoid 一次。
285
+ * 这个函数是纯的,所以刻度这件事可以在 `node:test` 里被钉住。
286
+ */
287
+ export function applyRerank<T extends { unitId: string }>(scores: number[], items: T[], threshold: number): RerankOutcome<T> {
288
+ if (scores.length !== items.length) throw new Error(`RERANK_COUNT_INVALID: expected ${items.length}, got ${scores.length}`)
289
+ if (scores.some(value => !Number.isFinite(value))) throw new Error('RERANK_SCORE_INVALID: 返回了非有限分数')
290
+ const scored = items.map((item, index) => ({ item, logit: scores[index]! }))
291
+ const kept = scored.filter(entry => entry.logit >= threshold)
292
+ return {
293
+ kept: kept
294
+ .map(entry => ({ ...entry.item, rerankScore: sigmoidLogit(entry.logit) }))
295
+ .sort((left, right) => right.rerankScore - left.rerankScore || left.unitId.localeCompare(right.unitId)),
296
+ dropped: scored.length - kept.length,
297
+ topLogit: scored.length > 0 ? Math.max(...scored.map(entry => entry.logit)) : 0,
298
+ }
299
+ }