dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (180) hide show
  1. package/README.md +147 -19
  2. package/cordis.patch.yml +25 -10
  3. package/lib/client.js +6400 -3326
  4. package/lib/doc-page.js +7761 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +63 -11
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/convert/ingest.d.ts +7 -4
  9. package/lib/types/convert/ingest.d.ts.map +1 -1
  10. package/lib/types/convert/ingest.js +52 -14
  11. package/lib/types/convert/ingest.js.map +1 -1
  12. package/lib/types/convert/types.d.ts +2 -2
  13. package/lib/types/convert/types.d.ts.map +1 -1
  14. package/lib/types/convert/types.js +2 -2
  15. package/lib/types/convert/types.js.map +1 -1
  16. package/lib/types/diagnosis/cards.d.ts +8 -7
  17. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  18. package/lib/types/diagnosis/cards.js +119 -49
  19. package/lib/types/diagnosis/cards.js.map +1 -1
  20. package/lib/types/diagnosis/run.d.ts +57 -0
  21. package/lib/types/diagnosis/run.d.ts.map +1 -0
  22. package/lib/types/diagnosis/run.js +245 -0
  23. package/lib/types/diagnosis/run.js.map +1 -0
  24. package/lib/types/diagnosis/schema.d.ts +25 -2
  25. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  26. package/lib/types/diagnosis/schema.js +64 -25
  27. package/lib/types/diagnosis/schema.js.map +1 -1
  28. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  29. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  30. package/lib/types/diagnosis/seed-source.js +19 -2
  31. package/lib/types/diagnosis/seed-source.js.map +1 -1
  32. package/lib/types/diagnosis/seed.d.ts +32 -42
  33. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  34. package/lib/types/diagnosis/seed.js +187 -51
  35. package/lib/types/diagnosis/seed.js.map +1 -1
  36. package/lib/types/diagnosis/store.d.ts +95 -25
  37. package/lib/types/diagnosis/store.d.ts.map +1 -1
  38. package/lib/types/diagnosis/store.js +297 -166
  39. package/lib/types/diagnosis/store.js.map +1 -1
  40. package/lib/types/diagnosis/types.d.ts +168 -16
  41. package/lib/types/diagnosis/types.d.ts.map +1 -1
  42. package/lib/types/diagnosis/types.js +1 -1
  43. package/lib/types/diagnosis/types.js.map +1 -1
  44. package/lib/types/document-page.d.ts +39 -0
  45. package/lib/types/document-page.d.ts.map +1 -0
  46. package/lib/types/document-page.js +43 -0
  47. package/lib/types/document-page.js.map +1 -0
  48. package/lib/types/extract-model.d.ts +67 -27
  49. package/lib/types/extract-model.d.ts.map +1 -1
  50. package/lib/types/extract-model.js +225 -97
  51. package/lib/types/extract-model.js.map +1 -1
  52. package/lib/types/extract.d.ts +16 -0
  53. package/lib/types/extract.d.ts.map +1 -1
  54. package/lib/types/extract.js +19 -2
  55. package/lib/types/extract.js.map +1 -1
  56. package/lib/types/fs-atomic.d.ts +11 -0
  57. package/lib/types/fs-atomic.d.ts.map +1 -0
  58. package/lib/types/fs-atomic.js +41 -0
  59. package/lib/types/fs-atomic.js.map +1 -0
  60. package/lib/types/index.d.ts.map +1 -1
  61. package/lib/types/index.js +466 -63
  62. package/lib/types/index.js.map +1 -1
  63. package/lib/types/intent/types.js +1 -1
  64. package/lib/types/intent/types.js.map +1 -1
  65. package/lib/types/knowledge-bases.d.ts +23 -12
  66. package/lib/types/knowledge-bases.d.ts.map +1 -1
  67. package/lib/types/knowledge-bases.js +37 -44
  68. package/lib/types/knowledge-bases.js.map +1 -1
  69. package/lib/types/layout.d.ts +3 -3
  70. package/lib/types/layout.d.ts.map +1 -1
  71. package/lib/types/layout.js +4 -4
  72. package/lib/types/layout.js.map +1 -1
  73. package/lib/types/migrate.d.ts +1 -1
  74. package/lib/types/migrate.js +2 -2
  75. package/lib/types/migrate.js.map +1 -1
  76. package/lib/types/model-text.d.ts +99 -0
  77. package/lib/types/model-text.d.ts.map +1 -0
  78. package/lib/types/model-text.js +103 -0
  79. package/lib/types/model-text.js.map +1 -0
  80. package/lib/types/present.d.ts +239 -0
  81. package/lib/types/present.d.ts.map +1 -0
  82. package/lib/types/present.js +255 -0
  83. package/lib/types/present.js.map +1 -0
  84. package/lib/types/recall-rank.d.ts +37 -0
  85. package/lib/types/recall-rank.d.ts.map +1 -1
  86. package/lib/types/recall-rank.js +41 -1
  87. package/lib/types/recall-rank.js.map +1 -1
  88. package/lib/types/runtime.d.ts +564 -31
  89. package/lib/types/runtime.d.ts.map +1 -1
  90. package/lib/types/runtime.js +2645 -292
  91. package/lib/types/runtime.js.map +1 -1
  92. package/lib/types/settings-schema.d.ts +4 -2
  93. package/lib/types/settings-schema.d.ts.map +1 -1
  94. package/lib/types/settings-schema.js +2 -0
  95. package/lib/types/settings-schema.js.map +1 -1
  96. package/lib/types/source-images.d.ts +118 -0
  97. package/lib/types/source-images.d.ts.map +1 -0
  98. package/lib/types/source-images.js +288 -0
  99. package/lib/types/source-images.js.map +1 -0
  100. package/lib/types/types.d.ts +356 -28
  101. package/lib/types/types.d.ts.map +1 -1
  102. package/lib/types/types.js +112 -18
  103. package/lib/types/types.js.map +1 -1
  104. package/lib/types/wiki-finalize.d.ts +140 -0
  105. package/lib/types/wiki-finalize.d.ts.map +1 -0
  106. package/lib/types/wiki-finalize.js +269 -0
  107. package/lib/types/wiki-finalize.js.map +1 -0
  108. package/lib/types/wiki-types.d.ts +10 -2
  109. package/lib/types/wiki-types.d.ts.map +1 -1
  110. package/lib/types/wiki-types.js +27 -30
  111. package/lib/types/wiki-types.js.map +1 -1
  112. package/package.json +4 -3
  113. package/preset/tita-data-agent/agent.cordis.yml +25 -10
  114. package/src/client/DirectoryPicker.tsx +12 -12
  115. package/src/client/DrawerSection.tsx +18 -0
  116. package/src/client/InfoHint.tsx +21 -8
  117. package/src/client/IntentTab.tsx +20 -26
  118. package/src/client/KnowledgeBaseTab.tsx +458 -158
  119. package/src/client/KnowledgePanel.tsx +44 -10
  120. package/src/client/KnowledgeStepRail.tsx +3 -0
  121. package/src/client/KnowledgeToolRow.tsx +331 -0
  122. package/src/client/NodeSettings.tsx +20 -22
  123. package/src/client/PreviewSections.tsx +178 -43
  124. package/src/client/RecallPanel.tsx +418 -127
  125. package/src/client/TypeDefinitionDrawer.tsx +154 -54
  126. package/src/client/WikiCardReview.tsx +266 -0
  127. package/src/client/WikiFlowCard.tsx +57 -95
  128. package/src/client/WikiTypeEditor.tsx +197 -95
  129. package/src/client/arch/AgentArchitectureGraph.tsx +30 -14
  130. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  131. package/src/client/arch/model.ts +164 -26
  132. package/src/client/architecture.css +25 -10
  133. package/src/client/architecture.tsx +66 -41
  134. package/src/client/asset-images.ts +125 -0
  135. package/src/client/attribution/AttributionGraph.tsx +481 -34
  136. package/src/client/attribution/AttributionTab.tsx +501 -106
  137. package/src/client/attribution/CaseView.tsx +9 -14
  138. package/src/client/attribution/GraphStage.tsx +25 -18
  139. package/src/client/attribution/RunView.tsx +27 -9
  140. package/src/client/attribution/StageChrome.tsx +280 -140
  141. package/src/client/attribution/graph-options.ts +611 -47
  142. package/src/client/attribution/model.ts +559 -30
  143. package/src/client/convert/ConvertPanel.tsx +36 -4
  144. package/src/client/doc-page.css +134 -0
  145. package/src/client/doc-page.tsx +242 -0
  146. package/src/client/index.ts +18 -0
  147. package/src/client/panel-state.ts +105 -0
  148. package/src/client/problem-domains.ts +1 -1
  149. package/src/client/request.ts +36 -0
  150. package/src/client/styles.css +397 -54
  151. package/src/client/useFitScale.ts +15 -2
  152. package/src/client/wiki-type-groups.ts +86 -0
  153. package/src/convert/host.ts +68 -9
  154. package/src/convert/ingest.ts +61 -18
  155. package/src/convert/types.ts +2 -2
  156. package/src/diagnosis/cards.ts +120 -50
  157. package/src/diagnosis/run.ts +251 -0
  158. package/src/diagnosis/schema.ts +67 -26
  159. package/src/diagnosis/seed-source.ts +29 -4
  160. package/src/diagnosis/seed.ts +187 -85
  161. package/src/diagnosis/store.ts +310 -173
  162. package/src/diagnosis/types.ts +181 -16
  163. package/src/document-page.ts +51 -0
  164. package/src/extract-model.ts +254 -99
  165. package/src/extract.ts +35 -3
  166. package/src/fs-atomic.ts +41 -0
  167. package/src/index.ts +499 -62
  168. package/src/intent/types.ts +1 -1
  169. package/src/knowledge-bases.ts +50 -49
  170. package/src/layout.ts +4 -4
  171. package/src/migrate.ts +2 -2
  172. package/src/model-text.ts +126 -0
  173. package/src/present.ts +367 -0
  174. package/src/recall-rank.ts +57 -2
  175. package/src/runtime.ts +2802 -293
  176. package/src/settings-schema.ts +2 -0
  177. package/src/source-images.ts +326 -0
  178. package/src/types.ts +446 -45
  179. package/src/wiki-finalize.ts +310 -0
  180. package/src/wiki-types.ts +28 -32
@@ -9,9 +9,9 @@
9
9
  * `chunk_index` / `score` 一起给出 —— 只报「命中 N 条」看不出召回质量。
10
10
  */
11
11
 
12
- import { useEffect, useState, type ReactNode } from 'react'
13
- import { Check, CircleHelp, Play, Search } from '@tita-data-agent/ui/icons'
14
- import { Badge, Button, Card, Input, SelectMenu, cn } from '@tita-data-agent/ui/components'
12
+ import { Fragment, useEffect, useState, type ReactNode } from 'react'
13
+ import { ArrowUpDown, ChevronRight, CircleHelp, GitBranch, Play, Search } from '@tita-data-agent/ui/icons'
14
+ import { Button, Card, Input, SelectMenu, cn } from '@tita-data-agent/ui/components'
15
15
  import { styles } from './styles.ts'
16
16
 
17
17
  /** 一条命中的 wikiNote。`match` 是**真正贡献了这次命中**的路线。 */
@@ -24,6 +24,8 @@ export interface RecallHitView {
24
24
  score: number
25
25
  status?: string
26
26
  entityType?: string
27
+ /** 这条 note 属于哪张卡片(点开预览用)。 */
28
+ entityId?: string
27
29
  rerankScore?: number
28
30
  }
29
31
 
@@ -36,8 +38,47 @@ export interface ContextItemView {
36
38
  rerankScore?: number
37
39
  wikiTitle?: string
38
40
  wikiId?: string
41
+ /** 这张卡片是命中的还是 1 跳扩展来的(0050)。 */
42
+ reason?: 'hit' | 'expansion'
43
+ /** 扩展条目的来路:「哪张卡(哪条边)」。 */
44
+ via?: string
39
45
  }
40
46
 
47
+ /** 账本里的一行(0050):这一路对这个候选算出的分数与名次。 */
48
+ export interface LedgerRowView {
49
+ unitId: string
50
+ title: string
51
+ entityType: string
52
+ entityId: string
53
+ score: number
54
+ rank: number
55
+ kept: boolean
56
+ }
57
+
58
+ /** 去重后的命中卡片(0050)。 */
59
+ export interface WikiCardView {
60
+ entityId: string
61
+ entityType: string
62
+ title: string
63
+ summary?: string
64
+ /** 它有几条 note 进了上下文(命中的小节数)。 */
65
+ noteCount: number
66
+ }
67
+
68
+ /** 1 跳扩展进来的卡片(0050)。 */
69
+ export interface ExpansionNodeView {
70
+ entityType: string
71
+ entityId: string
72
+ title: string
73
+ /** 来路:`来源卡片的 key|关系`。 */
74
+ via: string[]
75
+ /** 真的进了上下文吗(被预算挡住的也在列表里,只是标出来)。 */
76
+ included: boolean
77
+ }
78
+
79
+ /** 召回链路的三个步骤(0050 改版:召回 → 重排 → 扩展)。 */
80
+ export type RecallStep = 'recall' | 'rerank' | 'expand'
81
+
41
82
  export interface RecallResultView {
42
83
  count: number
43
84
  backend: string
@@ -65,6 +106,31 @@ export interface RecallResultView {
65
106
  encoder?: { ok: boolean; reason?: string }
66
107
  hits: RecallHitView[]
67
108
  context: ContextItemView[]
109
+ /** 面板账本(0050):每一路的全部候选 + 重排的全部候选(含被丢弃的)。 */
110
+ ledger?: {
111
+ fts: LedgerRowView[]
112
+ vector: LedgerRowView[]
113
+ rerank: Array<LedgerRowView & { fused: number; logit?: number; rerankScore?: number; dropped: boolean }>
114
+ }
115
+ /** 去重后的命中卡片(0050)。 */
116
+ wiki: WikiCardView[]
117
+ /** 1 跳扩展(0050)。 */
118
+ expansion?: { nodes: ExpansionNodeView[]; edgeCount: number; included: string[] }
119
+ /**
120
+ * 各阶段耗时(毫秒,0050 第二版)。
121
+ *
122
+ * 它进界面的理由:这几个数量级差得很远(查询编码 270ms vs 首次加载 8s),
123
+ * 只给「这次测试很慢」的感受,人没法判断该不该管它。
124
+ */
125
+ timings?: {
126
+ ftsMs: number; vectorMs: number; embedMs?: number; scanMs?: number
127
+ graphMs: number; fuseMs: number; rerankMs?: number; assembleMs: number; totalMs: number
128
+ }
129
+ /** 命中几张卡 + 1 跳扩展几张(0050)。 */
130
+ hitCards?: number
131
+ expandedCards?: number
132
+ omittedCards?: number
133
+ overflowPath?: string
68
134
  kbName?: string
69
135
  indexVersion?: number
70
136
  }
@@ -85,6 +151,95 @@ async function requestJson<T>(url: string): Promise<Envelope<T>> {
85
151
  return payload
86
152
  }
87
153
 
154
+ /**
155
+ * 账本列表(0050):**每一路的全部候选**,没过阈值的也在(虚线 + 淡色 + 一句原因)。
156
+ *
157
+ * 它与 `HitList` 的分工:`HitList` 是「这次召回最后剩了什么」(融合 + 重排之后),
158
+ * 这里是「这一路各自看到了什么、丢了什么」。面板要回答「为什么没召回」,靠的是后者。
159
+ */
160
+ export function LedgerList({ rows, scoreLabel, droppedLabel, emptyLabel }: {
161
+ rows: Array<LedgerRowView & { fused?: number; logit?: number; rerankScore?: number; dropped?: boolean }>
162
+ scoreLabel: (row: LedgerRowView & { fused?: number; logit?: number; rerankScore?: number }) => string
163
+ droppedLabel?: string
164
+ emptyLabel?: string
165
+ }): ReactNode {
166
+ if (rows.length === 0) return <div className={styles.emptyState}><CircleHelp size={16} />{emptyLabel ?? '这一路没有候选'}</div>
167
+ return <ul className={styles.hitList}>
168
+ {rows.map(row => {
169
+ const dropped = row.kept === false || row.dropped === true
170
+ return <li key={row.unitId} className={cn(styles.hitRow, dropped && styles.hitRowDropped)}>
171
+ <div className={styles.hitHead}>
172
+ <span className={styles.hitRank}>{row.rank}</span>
173
+ <span className={styles.hitTitle} title={row.unitId}>{row.title}</span>
174
+ {dropped && <span className={styles.hitDropped}>{droppedLabel ?? '已丢弃'}</span>}
175
+ <span className={styles.hitScore}>{scoreLabel(row)}</span>
176
+ </div>
177
+ <div className={styles.hitFoot}>
178
+ <span className={styles.kbCode}>{row.entityType}/{row.entityId}</span>
179
+ </div>
180
+ </li>
181
+ })}
182
+ </ul>
183
+ }
184
+
185
+ /** 命中卡片(去重后)与 1 跳扩展两块(0050 的「扩展」tab)。 */
186
+ export function ExpansionView({ wiki, expansion, context, onOpenWiki }: {
187
+ wiki: WikiCardView[]
188
+ expansion?: RecallResultView['expansion']
189
+ context: ContextItemView[]
190
+ onOpenWiki?: OpenWikiCard
191
+ }): ReactNode {
192
+ const expandedInContext = context.filter(item => item.reason === 'expansion')
193
+ /** 卡片标题:宿主给了抽屉就是按钮,没给就是纯文本 —— 面板不自己造一个抽屉出来。 */
194
+ const cardTitle = (card: { entityType: string; entityId: string; title: string }): ReactNode =>
195
+ onOpenWiki
196
+ ? <button type="button" className={styles.cardLink} onClick={() => onOpenWiki(card)}>{card.title}</button>
197
+ : <span className={styles.hitTitle}>{card.title}</span>
198
+ return <>
199
+ <div className={styles.kbSubHead}><h4>命中的 wiki 卡片(按卡片去重后)</h4><span>{wiki.length} 张</span></div>
200
+ <p className={styles.kbHint}>召回与重排的对象是 wikiNotes(片段),这里是把它们折回卡片的结果:同一张卡片的多个小节只算一张,等级取它最高那条 note。</p>
201
+ {wiki.length === 0
202
+ ? <div className={styles.emptyState}><CircleHelp size={16} />这次没有命中的卡片</div>
203
+ : <ul className={styles.hitList}>
204
+ {wiki.map(card => <li key={`${card.entityType}:${card.entityId}`} className={styles.hitRow}>
205
+ <div className={styles.hitHead}>
206
+ {cardTitle(card)}
207
+ <span className={styles.hitScore}>{card.noteCount} 条片段</span>
208
+ </div>
209
+ {card.summary && <p className={styles.hitSnippet}>{card.summary}</p>}
210
+ <div className={styles.hitFoot}><span className={styles.kbCode}>{card.entityType}/{card.entityId}</span></div>
211
+ </li>)}
212
+ </ul>}
213
+
214
+ <div className={styles.kbSubHead}>
215
+ <h4>1 跳知识图谱扩展</h4>
216
+ <span>{expansion?.nodes.length ?? 0} 张卡片 · {expansion?.edgeCount ?? 0} 条边</span>
217
+ </div>
218
+ <p className={styles.kbHint}>从上面那些卡片出发,沿图上的关系边与「共同来源」各走一跳。<b>它在去重之后发生</b> —— 所以这里列的都是邻居,不是召回来的。</p>
219
+ {(expansion?.nodes.length ?? 0) === 0
220
+ ? <div className={styles.emptyState}><CircleHelp size={16} />这些卡片在图上没有邻居</div>
221
+ : <ul className={styles.hitList}>
222
+ {expansion?.nodes.map(node => <li key={`${node.entityType}:${node.entityId}`} className={cn(styles.hitRow, !node.included && styles.hitRowDropped)}>
223
+ <div className={styles.hitHead}>
224
+ {cardTitle({ entityType: node.entityType, entityId: node.entityId, title: node.title })}
225
+ {!node.included && <span className={styles.hitDropped}>超预算</span>}
226
+ <span className={styles.hitScore}>{node.via.length} 条来路</span>
227
+ </div>
228
+ <div className={styles.hitFoot}>
229
+ <span className={styles.kbCode}>{node.entityType}/{node.entityId}</span>
230
+ <span>来自 {node.via.map(via => via.split('|')[0]).join('、')}</span>
231
+ </div>
232
+ </li>)}
233
+ </ul>}
234
+ {(expansion?.nodes.length ?? 0) > 0 && <p className={styles.kbHint}>
235
+ 其中 {expandedInContext.length} 张进了上下文
236
+ {expandedInContext.length < (expansion?.nodes.length ?? 0)
237
+ ? `,其余 ${(expansion?.nodes.length ?? 0) - expandedInContext.length} 张被上下文预算挡在外面(完整内容在溢出文件里)`
238
+ : ''}。
239
+ </p>}
240
+ </>
241
+ }
242
+
88
243
  /**
89
244
  * 召回是**两路**(关键词 BM25 + 向量)加一个图谱 boost(0031)。
90
245
  *
@@ -96,17 +251,23 @@ export const RECALL_ROUTES: Array<{ id: string; label: string; hint: string }> =
96
251
  { id: 'vector', label: '向量', hint: 'WeMM 向量近邻(binary 预筛 + int8 精算余弦)' },
97
252
  ]
98
253
 
254
+ /**
255
+ * 路线选项**只有两路**(0050 改版)。
256
+ *
257
+ * 图谱 boost 不是一条路(它不能制造候选,只给已在池里的条目加权),所以它不该出现在
258
+ * 「选哪一路」的下拉里 —— 上一版那两条「融合 + 图谱 boost」「同融合」正是这个错:
259
+ * 把加权当成了路线。融合是两路的**组合方式**,写在选项文字里,不是第三个选项。
260
+ */
99
261
  export const RECALL_MODES: Array<{ id: string; label: string }> = [
100
- { id: 'hybrid', label: '融合 + 图谱 boost(默认)' },
262
+ { id: 'hybrid', label: '关键词 + 向量(融合,默认)' },
101
263
  { id: 'fts', label: '仅关键词' },
102
264
  { id: 'vector', label: '仅向量' },
103
- { id: 'graph', label: '同融合(图谱已不是独立一路)' },
104
265
  ]
105
266
 
106
267
  const HIT_ROUTES: Record<string, string> = {
107
268
  fts: styles.hitRouteFts, vector: styles.hitRouteVector, graph: styles.hitRouteGraph, hybrid: styles.hitRouteHybrid,
108
269
  }
109
- const HIT_ROUTE_LABEL: Record<string, string> = { fts: '关键词', vector: '向量', graph: '知识图谱', hybrid: '三路融合' }
270
+ const HIT_ROUTE_LABEL: Record<string, string> = { fts: '关键词', vector: '向量', hybrid: '关键词+向量', graph: '图谱加权' }
110
271
 
111
272
  /** 去掉 markdown 脚手架,让命中读起来是一段正文而不是源码。 */
112
273
  export function snippetOf(content: string, limit = 200): string {
@@ -119,86 +280,38 @@ export function snippetOf(content: string, limit = 200): string {
119
280
  return plain.length > limit ? `${plain.slice(0, limit)}…` : plain
120
281
  }
121
282
 
122
- /**
123
- * 路线状态行。
124
- *
125
- * 0031 起它读的是 `diagnostics.routes` 而不是「`routes` 里有没有这个 id」:**降级也要看得见**。
126
- * 原来的写法只能表达「跑没跑」,于是向量路坏掉时芯片显示「(未跑)」而 `routes` 里却写着
127
- * `vector` —— 同一屏上两处互相矛盾,而且都没说为什么。
128
- */
129
- export function RecallRoutes({ routes, backend, routesDetail, graphBoost }: {
130
- routes: string[]
131
- backend?: string
132
- routesDetail?: RecallResultView['routesDetail']
133
- graphBoost?: RecallResultView['graphBoost']
134
- }): ReactNode {
135
- const active = new Set(routes.map(route => route.toLocaleLowerCase()))
136
- type RouteDetail = NonNullable<RecallResultView['routesDetail']>[number]
137
- const detailOf = (id: string): RouteDetail | undefined => routesDetail?.find(item => item.route === id)
138
- return <div className={styles.routeRow} aria-label="召回路线">
139
- {RECALL_ROUTES.map(route => {
140
- const detail = detailOf(route.id)
141
- const ran = detail ? detail.status === 'ran' : (active.has(route.id) || (route.id === 'fts' && active.has('keyword')))
142
- const degraded = detail?.status === 'degraded'
143
- const title = degraded && detail?.reason ? `${route.hint} —— ${detail.reason}` : route.hint
144
- const suffix = degraded ? '(降级)' : ran ? (detail?.kept !== undefined ? ` ${detail.kept}` : '') : '(未跑)'
145
- return <span key={route.id} className={cn(styles.routeChip, ran && styles.routeChipOn, degraded && styles.routeChipOff)} title={title}>
146
- {route.label}{suffix}
147
- </span>
148
- })}
149
- {/* 图谱是 boost:显示它加权了几条,而不是「跑没跑」。 */}
150
- <span className={styles.routeChip} title="图谱只给已在候选池里的条目加权,不能自己制造候选">
151
- 图谱 boost {graphBoost && graphBoost.applied > 0 ? `+${graphBoost.applied}` : '未生效'}
152
- </span>
153
- <span className={styles.routeBackend}>{backend ? backend.toUpperCase() : 'FTS'}</span>
154
- </div>
155
- }
156
-
157
- function HitList({ hits }: { hits: RecallHitView[] }): ReactNode {
283
+ function HitList({ hits, onOpenWiki }: { hits: RecallHitView[]; onOpenWiki?: OpenWikiCard }): ReactNode {
284
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({})
158
285
  if (hits.length === 0) return null
159
286
  return <ul className={styles.hitList}>
160
- {hits.map(hit => <li key={hit.unitId} className={styles.hitRow}>
161
- <div className={styles.hitHead}>
162
- <span className={cn(styles.hitRoute, HIT_ROUTES[hit.match] ?? styles.hitRouteFts)}>{HIT_ROUTE_LABEL[hit.match] ?? hit.match}</span>
163
- <span className={styles.hitTitle} title={hit.unitId}>{hit.title}{hit.heading ? ` · ${hit.heading}` : ''}</span>
164
- <span className={styles.hitScore}>{hit.score.toFixed(3)}</span>
165
- </div>
166
- <p className={styles.hitSnippet}>{hit.snippet || '(这条 wikiNote 没有正文)'}</p>
167
- <div className={styles.hitFoot}>
168
- <span className={styles.kbCode}>{hit.unitId}</span>
169
- {hit.status && <span>状态 {hit.status}</span>}
170
- {hit.entityType && <span>{hit.entityType}</span>}
171
- {hit.rerankScore !== undefined && <span>rerank {hit.rerankScore.toFixed(3)}</span>}
172
- </div>
173
- </li>)}
174
- </ul>
175
- }
176
-
177
- /**
178
- * 进入 Context Pack 的 topN。重排这一栏必须显示得分的**来源**:
179
- * rerank 真跑过就给 rerank 分数,降级时按融合分数排就只给融合分数,不混着显示。
180
- */
181
- function ContextList({ items, provider }: { items: ContextItemView[]; provider?: string }): ReactNode {
182
- if (items.length === 0) return null
183
- const reranked = provider !== 'none' && items.some(item => item.rerankScore !== undefined)
184
- return <ol className={styles.contextList}>
185
- {items.map((item, index) => <li key={item.unitId} className={styles.contextRow}>
186
- <span className={styles.contextRank}>{index + 1}</span>
187
- <div className={styles.contextCopy}>
188
- <div className={styles.contextHead}>
189
- <span className={styles.hitTitle} title={item.unitId}>{item.title}</span>
190
- <span className={styles.hitScore}>{reranked && item.rerankScore !== undefined ? `rerank ${item.rerankScore.toFixed(3)}` : `融合 ${item.score.toFixed(3)}`}</span>
287
+ {hits.map(hit => {
288
+ const open = expanded[hit.unitId] === true
289
+ return <li key={hit.unitId} className={styles.hitRow}>
290
+ <div className={styles.hitHead}>
291
+ <span className={cn(styles.hitRoute, HIT_ROUTES[hit.match] ?? styles.hitRouteFts)}>{HIT_ROUTE_LABEL[hit.match] ?? hit.match}</span>
292
+ <span className={styles.hitTitle} title={hit.unitId}>{hit.title}{hit.heading ? ` · ${hit.heading}` : ''}</span>
293
+ <span className={styles.hitScore}>{hit.score.toFixed(3)}</span>
191
294
  </div>
192
- <p className={styles.hitSnippet}>{item.snippet || '(这条 wikiNote 没有正文)'}</p>
295
+ {/* 正文默认只占两行:一条笔记的片段常有几百字,全摊开一屏只能看两条。
296
+ 要读全文就点「展开正文」—— 数据没被截掉,只是不再默认占满屏幕。 */}
297
+ <p className={cn(styles.hitSnippet, !open && styles.hitSnippetClamp)}>{hit.snippet || '(这条 wikiNote 没有正文)'}</p>
193
298
  <div className={styles.hitFoot}>
194
- <span className={styles.kbCode}>{item.unitId}</span>
195
- {item.wikiTitle && <span>父级 wiki:{item.wikiTitle}</span>}
299
+ <span className={styles.kbCode}>{hit.unitId}</span>
300
+ {hit.status && <span>状态 {hit.status}</span>}
301
+ {hit.rerankScore !== undefined && <span>rerank {hit.rerankScore.toFixed(3)}</span>}
302
+ {onOpenWiki && hit.entityType && hit.entityId && <button type="button" className={styles.hitToggle}
303
+ onClick={() => onOpenWiki({ entityType: hit.entityType!, entityId: hit.entityId!, title: hit.title })}>打开卡片</button>}
304
+ {hit.snippet.length > 80 && <button type="button" className={styles.hitToggle}
305
+ onClick={() => setExpanded(current => ({ ...current, [hit.unitId]: !open }))}>{open ? '收起' : '展开正文'}</button>}
196
306
  </div>
197
- </div>
198
- </li>)}
199
- </ol>
307
+ </li>
308
+ })}
309
+ </ul>
200
310
  }
201
311
 
312
+ /** 打开某张 wiki 卡片的预览抽屉(由宿主提供;召回的扩展结果要能点进去看)。 */
313
+ export type OpenWikiCard = (card: { entityType: string; entityId: string; title: string }) => void
314
+
202
315
  export interface RecallPanelProps {
203
316
  /** 作用范围:当前知识库;空表示默认库。 */
204
317
  kbId?: string
@@ -207,7 +320,7 @@ export interface RecallPanelProps {
207
320
  /** `recall` 看命中切片,`rerank` 看进入上下文的 topN,`runner` 是评测里的紧凑版。 */
208
321
  /**
209
322
  * `recall` = 知识库 → **召回重排**那一栏(0033 起召回与重排合成一栏,一次运行
210
- * 从上到下给出三路命中 → 融合排序 → 精排丢了多少 → 进上下文的 topN)。
323
+ * 从上到下给出两路命中 → 融合排序 → 精排丢了多少 → 进上下文的 topN)。
211
324
  * `runner` = 评测里的紧凑版单问题检查。
212
325
  */
213
326
  variant?: 'recall' | 'runner'
@@ -220,9 +333,11 @@ export interface RecallPanelProps {
220
333
  * 参数住在设置抽屉里,这里只是那扇离现场更近的门。
221
334
  */
222
335
  settingsChip?: ReactNode
336
+ /** 点某张卡片时打开它的预览抽屉。不给就是纯文本(面板不自己造一个抽屉出来)。 */
337
+ onOpenWiki?: OpenWikiCard
223
338
  }
224
339
 
225
- export function RecallPanel({ kbId, kbName, visible, variant = 'recall', defaults, resetKey, settingsChip }: RecallPanelProps): ReactNode {
340
+ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', defaults, resetKey, settingsChip, onOpenWiki }: RecallPanelProps): ReactNode {
226
341
  const [query, setQuery] = useState('销售额')
227
342
  const [mode, setMode] = useState(defaults?.mode ?? 'hybrid')
228
343
  const [topK, setTopK] = useState(defaults?.topK ?? 10)
@@ -230,6 +345,10 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
230
345
  const [busy, setBusy] = useState(false)
231
346
  const [error, setError] = useState<string | null>(null)
232
347
  const [result, setResult] = useState<RecallResultView | null>(null)
348
+ /** 三个 tab(0050):召回 / 重排 / 扩展 —— 一条链路的三段,各自回答一个问题。 */
349
+ const [tab, setTab] = useState<RecallStep>('recall')
350
+ /** 召回 tab 里正在看哪一路(`all` = 看融合后的命中)。 */
351
+ const [routeView, setRouteView] = useState<'all' | 'fts' | 'vector'>('all')
233
352
 
234
353
  // 知识库换了或设置存了:面板上的默认值跟着走,否则显示的还是上一个库的参数。
235
354
  // 用 effect 而不是渲染期 setState:这条同步是「外部输入变了」而不是渲染推导。
@@ -252,9 +371,16 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
252
371
  const boundedTopN = Math.min(topN, topK)
253
372
  const params = new URLSearchParams({ query: trimmed, top_k: String(topK), top_n: String(boundedTopN), mode })
254
373
  if (kbId) params.set('kb', kbId)
374
+ // `detail=true`:面板账本(每一路的候选、重排的全部候选)。工具调用不带它。
255
375
  const payload = await requestJson<{
256
376
  hits?: Array<Partial<RecallHitView> & { content?: string }>
257
- context?: Array<{ note?: Partial<RecallHitView> & { content?: string }; wiki?: { entityId?: string; title?: string } | null }>
377
+ context?: Array<{
378
+ note?: Partial<RecallHitView> & { content?: string }
379
+ wiki?: { entityId?: string; entityType?: string; title?: string; summary?: string } | null
380
+ reason?: 'hit' | 'expansion'
381
+ via?: { title?: string; relation?: string }
382
+ }>
383
+ budget?: { hitCards?: number; expandedCards?: number; omittedCards?: number; overflowPath?: string }
258
384
  backend?: string
259
385
  routes?: string[]
260
386
  recallTopK?: number
@@ -269,6 +395,14 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
269
395
  encoder?: { ok: boolean; reason?: string }
270
396
  }
271
397
  indexVersion?: number
398
+ ledger?: {
399
+ fts?: LedgerRowView[]
400
+ vector?: LedgerRowView[]
401
+ rerank?: Array<LedgerRowView & { fused?: number; logit?: number; rerankScore?: number; dropped?: boolean }>
402
+ }
403
+ wiki?: Array<{ entityId?: string; entityType?: string; title?: string; summary?: string }>
404
+ expansion?: { nodes?: Array<{ entityType?: string; entityId?: string; title?: string; via?: string[] }>; edges?: unknown[]; included?: string[] }
405
+ timings?: RecallResultView['timings']
272
406
  }>(`/knowledge/search?${params.toString()}`)
273
407
  const data = payload.data ?? {}
274
408
  const hits: RecallHitView[] = (data.hits ?? []).map(hit => ({
@@ -280,22 +414,39 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
280
414
  score: Number(hit.score ?? 0),
281
415
  ...(hit.status ? { status: hit.status } : {}),
282
416
  ...(hit.entityType ? { entityType: hit.entityType } : {}),
417
+ ...(hit.entityId ? { entityId: hit.entityId } : {}),
283
418
  ...(hit.rerankScore === undefined ? {} : { rerankScore: hit.rerankScore }),
284
419
  }))
285
- const context: ContextItemView[] = (data.context ?? []).map(item => ({
286
- unitId: item.note?.unitId ?? item.note?.title ?? 'unknown',
287
- title: item.note?.title ?? item.note?.unitId ?? '未命名切片',
288
- snippet: snippetOf(item.note?.content ?? ''),
289
- score: Number(item.note?.score ?? 0),
290
- ...(item.note?.rerankScore === undefined ? {} : { rerankScore: item.note.rerankScore }),
291
- ...(item.wiki?.title ? { wikiTitle: item.wiki.title } : {}),
292
- ...(item.wiki?.entityId ? { wikiId: item.wiki.entityId } : {}),
293
- }))
420
+ /**
421
+ * 0050 起上下文按**卡片**去重:一条 = 一张卡片 + 它命中的那些 note。
422
+ * 1 跳扩展来的卡片没有 note,所以这里用卡片自己兜底,并带上 `reason` ——
423
+ * 界面必须能分清「召回来的」与「邻居」(前者是证据,后者是延伸)。
424
+ */
425
+ const context: ContextItemView[] = (data.context ?? []).map(item => {
426
+ const note = item.note
427
+ const card = item.wiki
428
+ const expansion = item.reason === 'expansion'
429
+ return {
430
+ unitId: note?.unitId ?? (card?.entityType && card?.entityId ? `${card.entityType}:${card.entityId}` : 'unknown'),
431
+ title: note?.title ?? card?.title ?? '未命名',
432
+ snippet: snippetOf(note?.content ?? card?.summary ?? ''),
433
+ score: Number(note?.score ?? 0),
434
+ reason: expansion ? 'expansion' : 'hit',
435
+ ...(note?.rerankScore === undefined ? {} : { rerankScore: note.rerankScore }),
436
+ ...(card?.title ? { wikiTitle: card.title } : {}),
437
+ ...(card?.entityId ? { wikiId: card.entityId } : {}),
438
+ ...(expansion && item.via ? { via: `${item.via.title}(${item.via.relation})` } : {}),
439
+ }
440
+ })
294
441
  setResult({
295
442
  count: hits.length,
296
443
  backend: data.backend ?? 'fts',
297
444
  routes: data.routes,
298
445
  contextCount: context.length,
446
+ ...(data.budget?.hitCards === undefined ? {} : { hitCards: data.budget.hitCards }),
447
+ ...(data.budget?.expandedCards === undefined ? {} : { expandedCards: data.budget.expandedCards }),
448
+ ...(data.budget?.omittedCards === undefined ? {} : { omittedCards: data.budget.omittedCards }),
449
+ ...(data.budget?.overflowPath === undefined ? {} : { overflowPath: data.budget.overflowPath }),
299
450
  recallTopK: topK,
300
451
  candidatePool: data.candidatePool,
301
452
  contextTopN: Math.min(topN, topK),
@@ -313,6 +464,35 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
313
464
  ...(data.diagnostics?.encoder === undefined ? {} : { encoder: data.diagnostics.encoder }),
314
465
  hits,
315
466
  context,
467
+ // 账本(0050):每一路的全部候选 + 重排的全部候选。缺字段时给空数组而不是
468
+ // undefined —— 界面按「这一路没有候选」渲染,与「这次没取到账本」不是一回事,
469
+ // 而后者只可能出现在老服务端上。
470
+ ledger: {
471
+ fts: data.ledger?.fts ?? [],
472
+ vector: data.ledger?.vector ?? [],
473
+ rerank: (data.ledger?.rerank ?? []).map(row => ({ ...row, fused: Number(row.fused ?? row.score ?? 0), dropped: row.dropped === true })),
474
+ },
475
+ wiki: (data.wiki ?? []).map(card => ({
476
+ entityId: card.entityId ?? '',
477
+ entityType: card.entityType ?? '',
478
+ title: card.title ?? card.entityId ?? '未命名卡片',
479
+ ...(card.summary ? { summary: card.summary } : {}),
480
+ noteCount: context.filter(item => item.wikiId === card.entityId && item.reason === 'hit').length,
481
+ })),
482
+ ...(data.expansion === undefined ? {} : {
483
+ expansion: {
484
+ nodes: (data.expansion?.nodes ?? []).map(node => ({
485
+ entityType: node.entityType ?? '',
486
+ entityId: node.entityId ?? '',
487
+ title: node.title ?? node.entityId ?? '未命名卡片',
488
+ via: node.via ?? [],
489
+ included: (data.expansion?.included ?? []).includes(`${node.entityType}:${node.entityId}`),
490
+ })),
491
+ edgeCount: (data.expansion?.edges ?? []).length,
492
+ included: data.expansion?.included ?? [],
493
+ },
494
+ }),
495
+ ...(data.timings === undefined ? {} : { timings: data.timings }),
316
496
  ...(kbName ? { kbName } : {}),
317
497
  ...(data.indexVersion === undefined ? {} : { indexVersion: data.indexVersion }),
318
498
  })
@@ -323,22 +503,66 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
323
503
  }
324
504
  }
325
505
 
326
- const title = variant === 'runner' ? '单问题召回检查' : '召回重排 · 三路召回 + 精排'
327
- const description = variant === 'runner'
328
- ? '换路线、调 topK,然后看真正命中的切片与得分 —— 不是只看一个命中总数。'
329
- : '跑一次真实召回,从上到下看完整条链:三路各自命中了什么 → 融合后的排序 → cross-encoder 丢了多少 → 最终进入模型上下文的 topN 是哪几条。'
506
+ const title = variant === 'runner' ? '单问题召回检查' : '召回 · 精排'
507
+ // 这一栏没有「一句话说明」:那条步骤轨就是说明。每一步带自己的计数,
508
+ // 「两路各自命中了什么 → 融合排序 → 精排丢了多少 → 进上下文的是哪几条」
509
+ // 这句话由轨上的真实数字说,比一句固定文案准,也不会随实现变化而过期。
510
+
511
+ /**
512
+ * 三步的事实行(0050 改版):每一步说清「这一段现在是多少」。
513
+ *
514
+ * 没跑过时写「未运行」,不编一个 0 —— 与知识库那条步骤轨同一条纪律:没有数据的那一步
515
+ * 宁可只写动作。三个步骤**一直在**(它是这条链路的结构,不是运行结果的一部分)。
516
+ */
517
+ const routesDetail = result?.routesDetail ?? []
518
+ const keptOf = (route: string): number | undefined => routesDetail.find(item => item.route === route)?.kept
519
+ /**
520
+ * 耗时怎么读:小于 1 秒给毫秒(向量扫描只有几毫秒,显示成「0.0s」等于没显示),
521
+ * 超过 1 秒给秒(首次加载模型是 8 秒,写 8300ms 反而难读)。
522
+ */
523
+ const ms = (value: number | undefined): string => {
524
+ if (value === undefined || !Number.isFinite(value)) return ''
525
+ return value < 1000 ? `${Math.round(value)}ms` : `${(value / 1000).toFixed(1)}s`
526
+ }
527
+ const timings = result?.timings
528
+ const steps: Array<{ id: RecallStep; icon: typeof Search; label: string; verb: string; value: string; note: string; cost: string; costTitle: string }> = [
529
+ {
530
+ id: 'recall', icon: Search, label: '召回', verb: '找',
531
+ value: result ? `关键词 ${keptOf('fts') ?? 0} · 向量 ${keptOf('vector') ?? 0}` : '未运行',
532
+ note: '两路各自过阈值的条数',
533
+ cost: timings ? ms(timings.ftsMs + timings.vectorMs) : '',
534
+ costTitle: timings
535
+ ? `关键词 ${ms(timings.ftsMs)}${timings.embedMs === undefined ? '' : ` · 查询编码 ${ms(timings.embedMs)}${timings.scanMs === undefined ? '' : ` · 索引扫描 ${ms(timings.scanMs)}`}`}`
536
+ : '',
537
+ },
538
+ {
539
+ id: 'rerank', icon: ArrowUpDown, label: '重排', verb: '排',
540
+ value: result ? `保留 ${result.count} · 丢 ${result.rerankDropped ?? 0}` : '未运行',
541
+ note: result?.rerankThreshold === undefined
542
+ ? 'cross-encoder 精排候选池'
543
+ : `阈值 ${result.rerankThreshold} · 最高 ${(result.rerankTopLogit ?? 0).toFixed(2)}`,
544
+ cost: timings?.rerankMs === undefined ? '' : ms(timings.rerankMs),
545
+ costTitle: result?.rerank !== 'ready' ? '这次没跑重排' : 'cross-encoder 打分(首次含加载模型)',
546
+ },
547
+ {
548
+ id: 'expand', icon: GitBranch, label: '扩展', verb: '展',
549
+ value: result ? `${result.hitCards ?? 0} 张 + ${result.expandedCards ?? 0} 张` : '未运行',
550
+ note: '命中的卡片 + 1 跳邻居',
551
+ cost: timings ? ms(timings.assembleMs) : '',
552
+ costTitle: timings ? `组装:按卡片去重 + 1 跳扩展 + 上下文预算` : '',
553
+ },
554
+ ]
555
+ const finalWiki = (result?.hitCards ?? 0) + (result?.expandedCards ?? 0)
330
556
 
331
557
  return <Card className={styles.workspaceCard}>
332
558
  <div className={styles.cardHeader}>
333
559
  <div><h3><Search size={14} />{title}</h3></div>
334
- <div className={styles.cardHeaderActions}>
335
- {settingsChip}
336
- <Badge tone={result?.backend === 'hybrid' ? 'success' : 'neutral'}>{result?.backend === 'hybrid' ? '三路融合' : 'FTS fallback'}</Badge>
337
- </div>
560
+ <div className={styles.cardHeaderActions}>{settingsChip}</div>
338
561
  </div>
339
562
  <div className={styles.recallTop}>
340
- <p className={styles.cardDescription}>{description}</p>
341
563
  <span className={styles.recallScope}>作用范围 <b>{result?.kbName ?? kbName ?? '默认知识库'}</b></span>
564
+ {/* 总耗时放在作用范围这一行:它是这一屏的抬头,不属于任何一步。 */}
565
+ {result?.timings && <span className={styles.recallTiming} title="从查询进来到上下文组装完成">本次 {ms(result.timings.totalMs)}</span>}
342
566
  </div>
343
567
  <div className={styles.inputRow}>
344
568
  <Input value={query} placeholder="例如:销售额如何计算"
@@ -348,17 +572,13 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
348
572
  {variant === 'runner' && <Play size={13} />}{busy ? '测试中…' : variant === 'runner' ? '运行检查' : '测试'}
349
573
  </Button>
350
574
  </div>
575
+ {/* 旋钮集中在**一处**(0050 第二版):路线、召回 topK、上下文 topN 是同一次运行的参数,
576
+ 分散到各步骤里意味着「想改 topN 得先切到第三步」—— 那是把布局当成了状态。 */}
351
577
  <div className={styles.recallControls}>
352
578
  <div className={styles.recallControl}>
353
579
  <span>路线</span>
354
- <SelectMenu
355
- className="w-auto"
356
- value={mode}
357
- disabled={busy}
358
- onValueChange={setMode}
359
- aria-label="召回路线"
360
- options={RECALL_MODES.map(item => ({ value: item.id, label: item.label }))}
361
- />
580
+ <SelectMenu className="w-auto" value={mode} disabled={busy} onValueChange={setMode} aria-label="召回路线"
581
+ options={RECALL_MODES.map(item => ({ value: item.id, label: item.label }))} />
362
582
  </div>
363
583
  <label>召回 topK
364
584
  <Input type="number" min={1} max={50} value={topK} disabled={busy}
@@ -370,30 +590,101 @@ export function RecallPanel({ kbId, kbName, visible, variant = 'recall', default
370
590
  </label>
371
591
  </div>
372
592
  {error && <p className={cn(styles.kbNotice, styles.kbNoticeError)} role="alert">{error}</p>}
373
- <RecallRoutes routes={result?.routes ?? []} backend={result?.backend} routesDetail={result?.routesDetail} graphBoost={result?.graphBoost} />
374
593
  {/* 弃答不是失败,是一个结论:知识库里没有能回答这个问题的东西。如实说出来,
375
594
  而不是把 5 条噪声塞给模型让它编。 */}
376
595
  {result?.insufficientEvidence && <p className={cn(styles.kbNotice, styles.kbNoticeError)} role="status">
377
596
  <CircleHelp size={13} /><span>知识库里没有能回答这个问题的内容{result.diagnosticsReason ? `:${result.diagnosticsReason}` : ''}(判据:{result.abstention === 'rerank-threshold' ? '重排阈值' : result.abstention === 'vector-threshold' ? '向量余弦阈值' : '关键词相对阈值,判据较弱'})</span>
378
597
  </p>}
379
- {result && <Badge className={styles.resultBadge} tone={result.insufficientEvidence ? 'warning' : 'success'}>
380
- <Check size={13} />命中 {result.count} 条 wikiNotes · 上下文 {result.contextCount} 条{result.candidatePool ? ` · 候选池 ${result.candidatePool}` : ''}{result.rerank ? ` · rerank ${result.rerank}${result.rerankThreshold === undefined ? '' : `(阈值 ${result.rerankThreshold}${result.rerankDropped ? `,丢 ${result.rerankDropped}` : ''}${result.rerankTopLogit === undefined ? '' : `,最高 ${result.rerankTopLogit.toFixed(2)}`})`}` : ''}
381
- </Badge>}
382
- {result && (result.hits.length > 0
383
- ? <HitList hits={result.hits} />
384
- : <div className={styles.emptyState}><CircleHelp size={16} />这个路线组合没有命中任何 wikiNote</div>)}
385
- {result && result.context.length > 0 && <>
386
- <div className={styles.kbSubHead}><h4>进入上下文包的 topN</h4><span>{result.context.length} 条</span></div>
387
- <ContextList items={result.context} provider={result.rerankProvider} />
598
+
599
+ {/* 一条**步骤轨**,不是三个并列的 tab:召回 → 重排 → 扩展是有方向的(找 → 排 → 展),
600
+ 方向本身就是信息。每一步带上它自己的计数,所以「这次到底发生了什么」不用再写一句
601
+ 技术总结 —— 那句话说不了三步各自的账。 */}
602
+ <nav className={cn(styles.kbSteps, styles.recallSteps)} aria-label="召回链路">
603
+ {steps.map((step, index) => {
604
+ const Icon = step.icon
605
+ const on = tab === step.id
606
+ return <Fragment key={step.id}>
607
+ {index > 0 && <span className={styles.kbStepArrow} aria-hidden="true"><ChevronRight size={13} /></span>}
608
+ <button type="button" className={cn(styles.kbStep, on && styles.kbStepOn)}
609
+ aria-current={on ? 'step' : undefined}
610
+ title={`${step.label}:${step.value} · ${step.note}`}
611
+ onClick={() => setTab(step.id)}>
612
+ <span className={styles.kbStepTop}>
613
+ <Icon size={13} aria-hidden="true" /><b>{step.label}</b><em className={styles.kbStepVerb}>{step.verb}</em>
614
+ {step.cost && <em className={styles.kbStepCost} title={step.costTitle}>{step.cost}</em>}
615
+ </span>
616
+ <span className={styles.kbStepFacts}>
617
+ <span className={styles.kbStepValue}>{step.value}</span>
618
+ <span className={styles.kbStepNote}>{step.note}</span>
619
+ </span>
620
+ </button>
621
+ </Fragment>
622
+ })}
623
+ </nav>
624
+
625
+ {tab === 'recall' && <>
626
+ {/* 路由标签:点一下就只看这一路的候选(该路自己的分数与名次)。 */}
627
+ <div className={styles.recallRoutes}>
628
+ <button type="button" className={cn(styles.routeChip, routeView === 'all' && styles.routeChipOn)} onClick={() => setRouteView('all')}>
629
+ 融合后命中 {result?.hits.length ?? 0}
630
+ </button>
631
+ {RECALL_ROUTES.map(route => {
632
+ const detail = routesDetail.find(item => item.route === route.id)
633
+ const rows = route.id === 'fts' ? result?.ledger?.fts ?? [] : result?.ledger?.vector ?? []
634
+ const kept = detail?.kept ?? rows.filter(row => row.kept).length
635
+ const total = detail?.candidates ?? rows.length
636
+ // 降级与「这一路没有候选」是两件事:前者要能一眼看出来(0031 的诚实边界)。
637
+ const degraded = detail?.status === 'degraded'
638
+ return <button key={route.id} type="button"
639
+ title={degraded && detail?.reason ? `${route.hint} —— ${detail.reason}` : route.hint}
640
+ className={cn(styles.routeChip, routeView === route.id && styles.routeChipOn, degraded && styles.routeChipOff)}
641
+ onClick={() => setRouteView(route.id as 'fts' | 'vector')}>
642
+ {route.label} {degraded ? '降级' : `${kept}/${total}`}
643
+ </button>
644
+ })}
645
+ </div>
646
+ {!result
647
+ ? <div className={styles.emptyState}><CircleHelp size={16} />先在上面输入一个查询,点「测试」跑一次召回</div>
648
+ : routeView === 'all'
649
+ ? (result.hits.length > 0
650
+ ? <HitList hits={result.hits} />
651
+ : <div className={styles.emptyState}><CircleHelp size={16} />这个路线组合没有命中任何 wikiNote</div>)
652
+ : <LedgerList
653
+ rows={routeView === 'fts' ? result.ledger?.fts ?? [] : result.ledger?.vector ?? []}
654
+ scoreLabel={row => row.score.toFixed(3)}
655
+ droppedLabel="低于阈值"
656
+ />}
388
657
  </>}
389
- <div className={styles.infoList}>
390
- <div><span>召回 topK</span><strong>{result?.recallTopK ?? topK}</strong></div>
391
- <div><span>上下文 topN</span><strong>{result?.contextTopN ?? Math.min(topN, topK)}</strong></div>
392
- <div><span>默认召回路线</span><strong>{mode}</strong></div>
393
- {/* 词表是重排能不能用的**可见证据**:只有 5 个特殊 token 时模型照样出分,
394
- 所以「真的加载了 250002 条词表」这件事得看得见(0031)。 */}
395
- {result?.rerankVocabSize !== undefined && <div><span>重排词表</span><strong>{result.rerankVocabSize}</strong></div>}
396
- <div><span>索引版本</span><strong>{result?.indexVersion ? `v${result.indexVersion}` : '—'}</strong></div>
397
- </div>
658
+
659
+ {tab === 'rerank' && (result === null
660
+ ? <div className={styles.emptyState}><CircleHelp size={16} />先跑一次召回,这里显示候选池里每一条的重排分</div>
661
+ : result.rerank !== 'ready'
662
+ ? <>
663
+ <p className={styles.kbHint}>这次没有跑重排({result.rerank ?? 'skipped'}),下面是融合顺序 —— 分数是 RRF 融合分,不是模型分。</p>
664
+ <LedgerList rows={result.ledger?.rerank ?? []} emptyLabel="这次没有参与重排的候选" scoreLabel={row => `融合 ${(row.fused ?? row.score).toFixed(4)}`} />
665
+ </>
666
+ : <>
667
+ <p className={styles.kbHint}>
668
+ cross-encoder 对候选池的 <b>每一条</b> 打分(原始 logit,阈值 {result.rerankThreshold ?? 0.2}):低于阈值的被丢弃并置灰 —— 这样「为什么没召回」看得见。
669
+ </p>
670
+ <LedgerList
671
+ rows={result.ledger?.rerank ?? []}
672
+ emptyLabel="这次没有参与重排的候选"
673
+ scoreLabel={row => row.logit === undefined ? `融合 ${(row.fused ?? row.score).toFixed(4)}` : `logit ${row.logit.toFixed(2)} · ${(row.rerankScore ?? 0).toFixed(3)}`}
674
+ droppedLabel={`低于阈值 ${result.rerankThreshold ?? 0.2}`}
675
+ />
676
+ </>)}
677
+
678
+ {tab === 'expand' && (result === null
679
+ ? <div className={styles.emptyState}><CircleHelp size={16} />先跑一次召回,这里显示最终进上下文的卡片与它们的 1 跳邻居</div>
680
+ : <>
681
+ <ExpansionView wiki={result.wiki} expansion={result.expansion} context={result.context} {...(onOpenWiki ? { onOpenWiki } : {})} />
682
+ <p className={styles.kbHint}>
683
+ 最终 <b>{finalWiki} 个 wiki</b> 进上下文
684
+ {result.omittedCards ? `(另有 ${result.omittedCards} 张因为超出上下文预算没进去,完整内容在临时文件里)` : ''}
685
+ 。
686
+ </p>
687
+ </>)}
688
+
398
689
  </Card>
399
690
  }