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,107 @@
1
+ import type { LucideIcon } from '@tita-data-agent/ui/icons'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '@tita-data-agent/ui/components'
4
+
5
+ export interface ViewSwitchItem<T extends string> {
6
+ id: T
7
+ label: string
8
+ icon: LucideIcon
9
+ /** 一句话说明「这个页面回答什么问题」。换页时跟着换,避免用户靠猜。 */
10
+ hint: string
11
+ /** 页签上的实时计数,例如 `21 节点`。没有就不显示。 */
12
+ meta?: string
13
+ }
14
+
15
+ /**
16
+ * 分段式页面切换(segmented control)。
17
+ *
18
+ * 为什么不用三个独立按钮:**独立按钮无法表达「这是一组互斥的页面」**。三个
19
+ * `variant` 会变的按钮在视觉上只是三个开关,用户要靠颜色去推「它们是单选」;
20
+ * 而分段的轨道 + 会滑动的白色胶囊把「同一组、选一个」直接画了出来 ——
21
+ * 位置本身就是状态,不需要读颜色。
22
+ *
23
+ * 滑块的位移用纯 CSS 算:轨道是 `auto-cols-fr` 的等宽栅格,滑块宽度取
24
+ * `(100% - 内边距) / 页数`,位移取 `index × 100%`(百分比是相对滑块自身宽度)。
25
+ * 这样**不需要测量 DOM**,也就不存在「测量时机不对导致滑块错位」这一类 bug ——
26
+ * 常见实现用 ref 量每个页签的 offsetLeft,窗口一缩放就要重算。
27
+ */
28
+ export function ViewSwitch<T extends string>({ items, value, onChange, ariaLabel, showHint = true, className }: {
29
+ items: ReadonlyArray<ViewSwitchItem<T>>
30
+ value: T
31
+ onChange: (id: T) => void
32
+ ariaLabel: string
33
+ /**
34
+ * 是否自带那句说明。
35
+ *
36
+ * 关掉它,是因为调用方有时要把说明放到**切换器右边**(同一行)而不是下面 ——
37
+ * 页面级的那一条工具行就这么用:左端是切换器,右端是当前页在回答什么问题。
38
+ * 说明文案仍然来自 `items[].hint`,不复制第二份。
39
+ */
40
+ showHint?: boolean
41
+ className?: string
42
+ }): ReactNode {
43
+ const index = Math.max(0, items.findIndex(item => item.id === value))
44
+ const active = items[index] ?? items[0]
45
+
46
+ const onKeyDown = (event: { key: string; preventDefault: () => void; currentTarget: HTMLElement }): void => {
47
+ const step = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1
48
+ : event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1
49
+ : 0
50
+ if (step === 0 || items.length === 0) return
51
+ event.preventDefault()
52
+ const next = (index + step + items.length) % items.length
53
+ const target = items[next]
54
+ if (!target) return
55
+ onChange(target.id)
56
+ // 焦点必须跟着走,否则键盘用户的下一次左右键会从旧位置继续算。
57
+ const nodes = event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')
58
+ nodes[next]?.focus()
59
+ }
60
+
61
+ return <div className={cn('flex min-w-0 flex-col gap-1', className)}>
62
+ <div
63
+ role="tablist"
64
+ aria-label={ariaLabel}
65
+ onKeyDown={onKeyDown}
66
+ // 轨道刻意与面板自己的标签条(`.tita-tabsList`)用同一组颜色与圆角:
67
+ // 同屏出现两个分段控件时,它们要么明显是同一套,要么明显是两套 —— 差一点点最难看。
68
+ className="relative inline-grid grid-flow-col auto-cols-fr self-start rounded-[9px] border border-tita-line bg-tita-surface-muted p-[3px]"
69
+ >
70
+ {/* 滑块:装饰性元素,语义由 `aria-selected` 承载。 */}
71
+ <span
72
+ aria-hidden="true"
73
+ className="pointer-events-none absolute bottom-[3px] left-[3px] top-[3px] rounded-[7px] bg-tita-surface shadow-[0_1px_3px_rgb(15_23_42_/_0.16),inset_0_0_0_1px_var(--tita-primary-shadow)] transition-transform duration-200 ease-[cubic-bezier(0.22,0.61,0.36,1)]"
74
+ style={{ width: `calc((100% - 6px) / ${items.length})`, transform: `translateX(${index * 100}%)` }}
75
+ />
76
+ {items.map((item, itemIndex) => {
77
+ const Icon = item.icon
78
+ const on = itemIndex === index
79
+ return <button
80
+ key={item.id}
81
+ type="button"
82
+ role="tab"
83
+ aria-selected={on}
84
+ tabIndex={on ? 0 : -1}
85
+ data-state={on ? 'active' : 'inactive'}
86
+ onClick={() => onChange(item.id)}
87
+ className={cn(
88
+ // `border-0 bg-transparent` 不是装饰,是**必需的复位**:本仓库刻意不引入
89
+ // Tailwind preflight(它会连带重置宿主样式),所以裸 <button> 会带上浏览器的
90
+ // 2px outset 边框与 ButtonFace 底色 —— 结果一个分段控件看起来像三个凸起的小方块。
91
+ // shadcn 的 Button 与 TabsTrigger 各自复位过,这里同理。
92
+ 'relative z-10 inline-flex w-full min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-[7px] border-0 bg-transparent px-[10px] py-[4px] font-[inherit] font-[650] leading-[inherit] transition-colors duration-150',
93
+ on ? 'text-tita-primary' : 'text-tita-ink-muted hover:text-tita-ink',
94
+ )}
95
+ >
96
+ <Icon size={13} />
97
+ <span className="text-[11.5px] leading-none">{item.label}</span>
98
+ {item.meta && <span className={cn(
99
+ 'rounded-[4px] px-1 font-mono text-[9.5px] leading-[14px]',
100
+ on ? 'bg-tita-primary-soft text-tita-primary' : 'text-tita-ink-faint',
101
+ )} style={on ? undefined : { background: 'color-mix(in srgb, var(--tita-surface) 70%, transparent)' }}>{item.meta}</span>}
102
+ </button>
103
+ })}
104
+ </div>
105
+ {showHint && active && <p className="text-[10px] leading-snug text-tita-ink-muted">{active.hint}</p>}
106
+ </div>
107
+ }
@@ -0,0 +1,381 @@
1
+ import type { GraphData, GraphOptions, LayoutOptions } from '@antv/g6'
2
+ import {
3
+ FAILURE_MODE_STYLE, isZombieEdge, nodeBoxOf, nodeShapeOf, nodeStyleOf, problemBadgeOf,
4
+ visibleEdges, visibleNodes,
5
+ type AttributionEdge, type AttributionGraphData, type AttributionNode, type AttributionEdgeKind,
6
+ } from './model.ts'
7
+
8
+ /**
9
+ * 我的模型 → G6 v5 的数据与配置。
10
+ *
11
+ * 这一层刻意做成**纯函数**:`toG6Data` 把每个节点的尺寸、颜色、徽标、虚线全部算成
12
+ * 显式样式对象,而不是交给 G6 的样式回调去闭包读数据。两个理由:
13
+ *
14
+ * 1. **可测**。node:test 里没有 canvas,跑不起 G6 实例;但只要映射是纯的,
15
+ * 「H6 是不是被标成空区」「僵尸边长什么样」就能在测试里断言。
16
+ * 2. **可推理**。样式的全部来源是数据,画布上看到什么就是模型里写了什么,
17
+ * 不存在「某处配置覆盖了另一处」的排查成本。
18
+ *
19
+ * 依据:0021 §7 的图谱视图规格(大小=复用次数、颜色=失败模式、红虚环=空区、
20
+ * 灰虚线=僵尸边),以及 AGENTS.md 的「尽可能使用已有的高 star 开源组件」——
21
+ * 布局与交互交给 G6,业务语义留在我们这边。
22
+ */
23
+
24
+ /** G6 的节点/边数据里带业务原文,事件回调直接从 `data.node` 取,不做二次查表。 */
25
+ export interface G6AttributionNode {
26
+ id: string
27
+ type: 'circle' | 'rect'
28
+ data: { node: AttributionNode }
29
+ style: Record<string, unknown>
30
+ }
31
+
32
+ export interface G6AttributionEdge {
33
+ id: string
34
+ source: string
35
+ target: string
36
+ data: { edge: AttributionEdge }
37
+ style: Record<string, unknown>
38
+ }
39
+
40
+ export interface G6AttributionData {
41
+ nodes: G6AttributionNode[]
42
+ edges: G6AttributionEdge[]
43
+ }
44
+
45
+ const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'
46
+
47
+ /** 九种边各自的外观。层内边实线、跨层边点线,`refutes` 一律红色虚线(一等公民)。 */
48
+ const EDGE_STYLE: Record<AttributionEdgeKind, { stroke: string; lineWidth: number; lineDash?: number[] }> = {
49
+ may_be_caused_by: { stroke: '#93b4f8', lineWidth: 1.6 },
50
+ verified_by: { stroke: '#6b54cf', lineWidth: 1.3 },
51
+ categorized_as: { stroke: '#c9b8ff', lineWidth: 1.2, lineDash: [5, 4] },
52
+ produces: { stroke: '#b96d14', lineWidth: 1.2, lineDash: [2, 3] },
53
+ supports: { stroke: '#12875f', lineWidth: 1.4 },
54
+ refutes: { stroke: '#b42318', lineWidth: 1.6, lineDash: [5, 4] },
55
+ confirmed_as: { stroke: '#0f766e', lineWidth: 1.6 },
56
+ instance_of: { stroke: '#b96d14', lineWidth: 1.2, lineDash: [2, 3] },
57
+ indexed_from: { stroke: '#9ec2ff', lineWidth: 1.2, lineDash: [2, 3] },
58
+ }
59
+
60
+ /** 边的中文读法。**九种必须全有** —— 图例是通过遍历这个表生成的,漏一种就是漏一段语义。 */
61
+ export const EDGE_KIND_LABEL: Record<AttributionEdgeKind, string> = {
62
+ may_be_caused_by: '可能是原因',
63
+ verified_by: '由…验证',
64
+ categorized_as: '归类为',
65
+ produces: '产出',
66
+ supports: '支持',
67
+ refutes: '否定',
68
+ confirmed_as: '确认为根因',
69
+ instance_of: '实例属于',
70
+ indexed_from: '反查索引',
71
+ }
72
+
73
+ /**
74
+ * 图例数据。
75
+ *
76
+ * 从 `EDGE_STYLE` / `EDGE_KIND_LABEL` **派生**而不是手写第二份清单:图例和画布
77
+ * 用的是同一个颜色来源,二者不可能再对不上。这也是「图例漂移」这类 bug 的根治
78
+ * ——它几乎总是因为样式和说明各写了一遍。
79
+ */
80
+ export const EDGE_LEGEND: Array<{ kind: AttributionEdgeKind; label: string; stroke: string; lineWidth: number; lineDash?: number[] }> =
81
+ (Object.keys(EDGE_STYLE) as AttributionEdgeKind[]).map(kind => ({
82
+ kind,
83
+ label: EDGE_KIND_LABEL[kind],
84
+ ...EDGE_STYLE[kind],
85
+ }))
86
+
87
+ const ZOMBIE_STYLE = { stroke: '#9aa4b2', lineWidth: 1.2, lineDash: [5, 4] }
88
+ const GAP_STROKE = '#b42318'
89
+
90
+ const BADGE_BASE = {
91
+ fill: '#748091',
92
+ fontSize: 9,
93
+ fontFamily: FONT,
94
+ backgroundFill: '#ffffff',
95
+ backgroundStroke: '#e5e9f0',
96
+ backgroundLineWidth: 1,
97
+ backgroundRadius: 6,
98
+ padding: [3, 6, 3, 6],
99
+ }
100
+
101
+ /**
102
+ * 一个节点的完整样式。
103
+ *
104
+ * 假设节点的文字**不放在圆里**:圆的直径由复用次数决定,把中文标题塞进去必然
105
+ * 要么截断要么缩到看不清。所以圆里只留 `H1` 这样的短 id,标题做成圆下方的徽标 ——
106
+ * 圆负责「多大、什么颜色」,徽标负责「叫什么」。
107
+ *
108
+ * 空区用「红色虚环 + 空区徽标」双重表达:只染灰不够 —— 维护者扫一眼时会把颜色
109
+ * 读成失败模式配色,只有虚线会被读成「这里少了东西」。
110
+ */
111
+ export function nodeStyleFor(node: AttributionNode, gapIds: ReadonlySet<string>): Record<string, unknown> {
112
+ const [width, height] = nodeBoxOf(node)
113
+ const shape = nodeShapeOf(node)
114
+ const isGap = gapIds.has(node.id)
115
+ const palette = nodeStyleOf(node)
116
+ const style: Record<string, unknown> = {
117
+ size: shape === 'circle' ? width : [width, height],
118
+ fill: palette.fill,
119
+ stroke: isGap ? GAP_STROKE : palette.stroke,
120
+ lineWidth: isGap ? 2 : 1.5,
121
+ lineDash: isGap ? [4, 3] : undefined,
122
+ zIndex: node.layer === 'instance' ? 0 : 1,
123
+ // 圆里放 id(直径由复用次数决定,中文标题塞不进);胶囊放标题。
124
+ // 案例例外:它的标题来自案例卡片、不含 id,而案例要靠 id 才能在复盘里被指认 ——
125
+ // 所以由画布把 id 拼回去,标题本身保持单一份来源。
126
+ labelText: shape === 'circle' ? node.id : node.kind === 'case' ? `${node.id} ${node.title}` : node.title,
127
+ labelPlacement: 'center',
128
+ labelFill: palette.text,
129
+ labelFontSize: shape === 'circle' ? 13 : 11,
130
+ labelFontFamily: FONT,
131
+ labelFontWeight: shape === 'circle' ? 700 : 500,
132
+ labelWordWrap: true,
133
+ labelWordWrapWidth: shape === 'circle' ? width - 14 : width - 20,
134
+ labelMaxLines: 2,
135
+ labelTextOverflow: 'ellipsis',
136
+ cursor: 'pointer',
137
+ }
138
+ if (shape === 'rect') style.radius = 8
139
+
140
+ const badges: Array<Record<string, unknown>> = []
141
+ const gapBadge = { ...BADGE_BASE, text: '空区 · 无检查', placement: 'top', offsetY: -10, fill: GAP_STROKE, backgroundStroke: '#f0b7ad' }
142
+ if (node.kind === 'symptom') {
143
+ // 问题侧的层次必须能在**画布上**读到,而不是只能靠筛选器猜。
144
+ // 用文字徽标而不是颜色:颜色通道已经被失败模式占满(见 model.ts 的 PROBLEM_CLASS_ORDER)。
145
+ const problem = problemBadgeOf(node)
146
+ if (problem) badges.push({ ...BADGE_BASE, text: problem, placement: 'bottom', offsetY: 12, fill: '#96580f', backgroundStroke: '#f2ddb8' })
147
+ } else if (node.kind === 'hypothesis') {
148
+ badges.push({ ...BADGE_BASE, text: node.title, placement: 'bottom', offsetY: 12, fill: palette.text, backgroundStroke: palette.stroke })
149
+ // 空区徽标必须和「复用 N」**互斥地**占用顶部位置,而不是写成 else-if ——
150
+ // 假设节点会先命中 hypothesis 分支,写成 else-if 的话空区徽标永远不会出现,
151
+ // 而空区恰恰总是假设节点。
152
+ badges.push(isGap ? gapBadge : { ...BADGE_BASE, text: `复用 ${node.reuseCount ?? 0}`, placement: 'top', offsetY: -10 })
153
+ } else if (node.kind === 'check') {
154
+ badges.push({ ...BADGE_BASE, text: `${node.costMinutes ?? '?'} min · ${node.automation === 'auto' ? '自动' : '人工'}`, placement: 'bottom', offsetY: 10, fill: node.automation === 'auto' ? '#12875f' : '#b96d14' })
155
+ } else if (isGap) {
156
+ badges.push(gapBadge)
157
+ }
158
+ if (badges.length > 0) style.badges = badges
159
+ if (node.stale) {
160
+ style.fillOpacity = 0.45
161
+ style.labelOpacity = 0.55
162
+ }
163
+ return style
164
+ }
165
+
166
+ export function edgeStyleFor(edge: AttributionEdge): Record<string, unknown> {
167
+ const zombie = isZombieEdge(edge)
168
+ const base = zombie ? ZOMBIE_STYLE : EDGE_STYLE[edge.kind]
169
+ const style: Record<string, unknown> = {
170
+ ...base,
171
+ endArrow: edge.kind === 'instance_of' || edge.kind === 'indexed_from',
172
+ endArrowType: 'triangle',
173
+ endArrowSize: 6,
174
+ zIndex: 0,
175
+ // 边标签默认会跟着边旋转,斜边上的中文就竖起来了。始终水平可读。
176
+ labelAutoRotate: false,
177
+ labelFontFamily: FONT,
178
+ labelBackground: true,
179
+ labelBackgroundFill: '#f6f8fb',
180
+ labelBackgroundRadius: 4,
181
+ labelPadding: [2, 4, 2, 4],
182
+ }
183
+ if (zombie) {
184
+ style.labelText = `僵尸边 ${edge.staleDays} 天未走`
185
+ style.labelFontSize = 9
186
+ style.labelFill = '#9aa4b2'
187
+ } else if (edge.count !== undefined) {
188
+ style.labelText = `${edge.count} 例`
189
+ style.labelFontSize = 9
190
+ style.labelFill = '#b96d14'
191
+ }
192
+ return style
193
+ }
194
+
195
+ /**
196
+ * 模型 → G6 数据。
197
+ *
198
+ * `includeInstance` 默认 false:实例层随每次排查线性增长,展开它会把类型层挤成
199
+ * 一堆小点 —— 而图谱视图要看的第一件事恰是类型层收不收敛。
200
+ */
201
+ export function toG6Data(
202
+ data: AttributionGraphData,
203
+ options: { includeInstance?: boolean; gapIds: ReadonlySet<string>; positions?: Map<string, [number, number]> } = { gapIds: new Set() },
204
+ ): G6AttributionData {
205
+ const nodes = visibleNodes(data, options.includeInstance ?? false)
206
+ const edges = visibleEdges(data, nodes)
207
+ return {
208
+ nodes: nodes.map(node => {
209
+ const style = nodeStyleFor(node, options.gapIds)
210
+ // 力导向布局的初始位置:给了就落上去再松弛,没给就让 G6 自己随机。
211
+ const seed = options.positions?.get(node.id)
212
+ if (seed) { style.x = seed[0]; style.y = seed[1] }
213
+ return { id: node.id, type: nodeShapeOf(node), data: { node }, style }
214
+ }),
215
+ edges: edges.map(edge => ({
216
+ id: edge.id,
217
+ source: edge.source,
218
+ target: edge.target,
219
+ data: { edge },
220
+ style: edgeStyleFor(edge),
221
+ })),
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 布局种类。
227
+ *
228
+ * `force` 是知识图谱的默认读法:没有「上→下」的方向,关系靠聚簇和距离表达,
229
+ * 节点可以拖,像一张会自己摊开的地图。
230
+ * `layered` 是**语义**读法:现象→假设→检查本来就是有向的 DAG,分层把这个方向
231
+ * 显式画出来 —— 代价是它看起来像流程图而不是知识图谱。
232
+ *
233
+ * 两个都留:默认 force 回答「这张图长什么样」,切到 layered 回答「谁是上游」。
234
+ */
235
+ export type GraphLayout = 'force' | 'layered'
236
+
237
+ /**
238
+ * 画布所处的容器档位。
239
+ *
240
+ * 它不是「主题」,而是**同一张图在两块不同大小的画布上的两套视口参数**:
241
+ * `panel` 是右侧边栏里的那一块(宽 380px 减去内边距,可用高度只有几百像素),
242
+ * `fullscreen` 是铺满视口的那一块。分开的理由是缩放区间与留白都按可用面积定,
243
+ * 用一套参数必然在某一档上过紧或过松。
244
+ */
245
+ export type StageMode = 'panel' | 'fullscreen'
246
+
247
+ /**
248
+ * 缩放区间。
249
+ *
250
+ * G6 的默认值是 `[0.01, 10]` —— 一千倍的区间意味着一次误滚轮就能把图缩成米粒,
251
+ * 或者放大到只剩一个圆。这里按「节点还能不能读」定上下界:面板里最小 0.55
252
+ * (标签仍可辨认),最大 1.8(再大就只剩徽标);全屏可用面积大,两端各放宽一档。
253
+ */
254
+ export const ZOOM_RANGE: Record<StageMode, [number, number]> = {
255
+ panel: [0.55, 1.8],
256
+ fullscreen: [0.4, 2.4],
257
+ }
258
+
259
+ /** 返回副本:调用方(G6 或测试)不应该能改到这份常量。 */
260
+ export function zoomRangeFor(mode: StageMode): [number, number] {
261
+ return [...ZOOM_RANGE[mode]] as [number, number]
262
+ }
263
+
264
+ /**
265
+ * 画布留白 `[上, 右, 下, 左]`。
266
+ *
267
+ * 留白不是审美,是**浮层避让**:图例、分层切换、筛选条都是浮在画布上的,
268
+ * 留白不够时自动适配会把节点摆到浮层底下。上下留白明显大于左右,正是因为
269
+ * 浮层占据的是上下两条。
270
+ */
271
+ export const STAGE_PADDING: Record<StageMode, [number, number, number, number]> = {
272
+ panel: [64, 18, 56, 18],
273
+ fullscreen: [86, 28, 78, 28],
274
+ }
275
+
276
+ export function paddingFor(mode: StageMode): [number, number, number, number] {
277
+ return [...STAGE_PADDING[mode]] as [number, number, number, number]
278
+ }
279
+
280
+ /**
281
+ * 计算初始位置:按 id 稳定排序后落在黄金角螺旋上。
282
+ *
283
+ * 存在的理由是**可复现**。d3-force 的初始位置默认随机,同一张图每次刷新都长成
284
+ * 另一个形状,于是「上次那个空区在右上角」这类空间记忆每次失效。先给一个确定性
285
+ * 的初始布局再让力导向松弛,得到的是「稳定但不僵死」的形状。
286
+ */
287
+ export function seedPositions(data: AttributionGraphData, width: number, height: number): Map<string, [number, number]> {
288
+ const ids = data.nodes.map(node => node.id).sort()
289
+ const golden = Math.PI * (3 - Math.sqrt(5))
290
+ const radius = Math.min(width, height) / 2 - 40
291
+ const cx = width / 2
292
+ const cy = height / 2
293
+ const positions = new Map<string, [number, number]>()
294
+ ids.forEach((id, index) => {
295
+ const r = radius * Math.sqrt((index + 0.5) / ids.length)
296
+ const theta = index * golden
297
+ positions.set(id, [cx + r * Math.cos(theta), cy + r * Math.sin(theta)])
298
+ })
299
+ return positions
300
+ }
301
+
302
+ export function layoutOf(kind: GraphLayout, rankdir: 'LR' | 'TB'): LayoutOptions {
303
+ if (kind === 'layered') {
304
+ return { type: 'antv-dagre', rankdir, nodesep: 46, ranksep: 96, controlPoints: false }
305
+ }
306
+ return {
307
+ type: 'd3-force',
308
+ // 值都偏大:节点带下方徽标,挤在一起时徽标先重叠,比圆重叠更难看。
309
+ link: { distance: 170, strength: 0.5 },
310
+ manyBody: { strength: -520 },
311
+ // 半径按最宽的矩形节点(检查卡 156px)取一半再放余量。取小了的话,圆之间的
312
+ // 排斥算得没错,但 156px 宽的胶囊仍然会横向压到一起 —— 碰撞是按外接圆算的。
313
+ collide: { radius: 86, strength: 1 },
314
+ alphaDecay: 0.022,
315
+ velocityDecay: 0.45,
316
+ }
317
+ }
318
+
319
+ export function behaviorsOf(kind: GraphLayout): string[] {
320
+ // force 布局必须用 drag-element-force:它把拖动写回模拟,松手后图会继续摊开;
321
+ // 用 drag-element 的话拖动只改坐标,力模拟会在下一帧把节点拽回去。
322
+ return kind === 'force'
323
+ ? ['drag-canvas', 'zoom-canvas', 'click-select', 'drag-element-force']
324
+ : ['drag-canvas', 'zoom-canvas', 'click-select', 'drag-element']
325
+ }
326
+
327
+ /**
328
+ * G6 实例配置。
329
+ *
330
+ * `nodesep` 取 46 而不是默认的 10:假设节点下方挂了标题徽标,行距必须容得下它,
331
+ * 否则同一层里相邻两个节点的徽标会叠在一起。
332
+ *
333
+ * **背景是纯白的,而且刻意不加 `grid-line` 插件。** 井字格在截图和演示里能撑场面,
334
+ * 但它在这张图上是纯噪声:节点位置由力导向决定,网格暗示的「坐标」并不存在;
335
+ * 而这张图上真正要读的是「哪块密、哪里空」,底色越干净越好。
336
+ */
337
+ export function buildGraphOptions(
338
+ container: { width: number; height: number },
339
+ layout: GraphLayout = 'force',
340
+ rankdir: 'LR' | 'TB' = 'LR',
341
+ mode: StageMode = 'panel',
342
+ ): GraphOptions {
343
+ return {
344
+ width: container.width,
345
+ height: container.height,
346
+ background: '#ffffff',
347
+ autoFit: { type: 'view', options: { when: 'overflow' } },
348
+ padding: paddingFor(mode),
349
+ zoomRange: zoomRangeFor(mode),
350
+ animation: false,
351
+ layout: layoutOf(layout, rankdir),
352
+ node: {
353
+ state: {
354
+ selected: { lineWidth: 3, stroke: '#2563eb' },
355
+ active: { lineWidth: 2.6, stroke: '#2563eb' },
356
+ },
357
+ },
358
+ edge: {
359
+ // 正交折线是给分层布局用的;力导向里没有「行」的概念,折线反而更乱,
360
+ // 用二次曲线让交叉点少一点。
361
+ type: layout === 'force' ? 'quadratic' : 'polyline',
362
+ style: layout === 'force' ? { curveOffset: 22 } : { radius: 8, router: { type: 'orth' } },
363
+ state: { selected: { lineWidth: 2.4, stroke: '#2563eb' } },
364
+ },
365
+ behaviors: behaviorsOf(layout),
366
+ }
367
+ }
368
+
369
+ /**
370
+ * 收口断言。
371
+ *
372
+ * G6 的 `NodeData.style` 是联合样式类型,而我们的样式是运行时按数据算出来的键值对
373
+ * (尺寸、颜色、徽标数组都是映射结果)。这里做**唯一一次**断言,而不是让 `as` 散落
374
+ * 在组件里 —— 将来 G6 收紧类型时,需要改的就只有这一个函数。
375
+ */
376
+ export function toGraphData(
377
+ data: AttributionGraphData,
378
+ options: { includeInstance?: boolean; gapIds: ReadonlySet<string>; positions?: Map<string, [number, number]> },
379
+ ): GraphData {
380
+ return toG6Data(data, options) as unknown as GraphData
381
+ }