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
package/src/extract.ts ADDED
@@ -0,0 +1,632 @@
1
+ import type { KnowledgeCard, SchemaType, SourceRaw, WikiTypeDef } from './types.js'
2
+
3
+ /**
4
+ * Pass 0 · 候选抽取 + 归一(与库内判重)。
5
+ *
6
+ * 这一层是「SourceRaw → Wiki」的第一个编译阶段,也是唯一一个**不调用模型**的阶段:
7
+ * 它只做「文档里明明白白写着什么」这件事。三个设计判断:
8
+ *
9
+ * 1. **纯函数。** 不碰磁盘、不碰模型、不碰网络,所以它可以被直接单测,
10
+ * 也可以在重建之外被复用(Agent 想先看看会抽出什么)。真正落盘的是 `rebuild()`。
11
+ * 2. **只认有据可查的候选。** 每个候选都必须带着 `evidence`(来源 id + 小节 / 原文片段);
12
+ * 抽不出证据的位置宁可不出候选,也不编一个名字出来。
13
+ * 3. **related ≠ same。** `relation` 候选永远只变成图的边,绝不参与卡片合并 ——
14
+ * 「A 和 B 有关系」和「A 就是 B」是两件事,混起来会把两张卡片糊成一张,
15
+ * 而且糊掉之后没有任何痕迹能还原。
16
+ */
17
+
18
+ export interface CandidateEvidence {
19
+ sourceId: string
20
+ /** 候选来自哪个 `##`/`###` 小节(声明式候选可自带)。 */
21
+ heading?: string
22
+ /** 支撑这个候选的原文片段;只作证据展示,不参与判重。 */
23
+ quote?: string
24
+ }
25
+
26
+ export interface ExtractionCandidate {
27
+ kind: 'entity' | 'concept' | 'relation'
28
+ /** Display name, e.g. 「销售口径」. */
29
+ name: string
30
+ /** The wiki card type this candidate should become. Must be an enabled WikiTypeDef id. */
31
+ type: SchemaType
32
+ evidence: CandidateEvidence
33
+ /** Other spellings that mean the same thing (from `[[Name|alias]]` and frontmatter). */
34
+ aliases?: string[]
35
+ /** Markdown body that backs this candidate (used as the card body). */
36
+ body?: string
37
+ /** For kind === 'relation': the other end. */
38
+ target?: { name: string; type?: SchemaType }
39
+ /** For kind === 'relation': the relation id, e.g. 'relates_to'. */
40
+ relation?: string
41
+ confidence: number
42
+ }
43
+
44
+ /** 抽取关系候选时使用的默认关系 id;图里也只认这个 id(见 `rebuild()` 的边装配)。 */
45
+ export const RELATES_TO = 'relates_to'
46
+
47
+ /**
48
+ * 每份来源最多抽多少张卡片候选。
49
+ *
50
+ * 这是**有意为之的上限**,不是省略号:一份长文档的小节可以有几十个,放开就会把
51
+ * 一个知识库灌成「一篇文档 = 几十张未审核卡片」,人再也不会去看它们。宁可少抽,
52
+ * 剩下的由 Agent 用声明式候选主动补。
53
+ */
54
+ export const MAX_CANDIDATES_PER_SOURCE = 12
55
+
56
+ /** `##`/`###` 小节正文短于此长度就不成候选:那更像标签而不是一个可定义的对象。 */
57
+ const SECTION_MIN_CHARS = 120
58
+
59
+ /** 这些小节名是结构性噪音,抽成卡片只会污染 wiki。 */
60
+ const GENERIC_HEADINGS = new Set([
61
+ '参考', '参考资料', '参考文献', '参考链接', '附录', '目录', '变更记录', '更新记录',
62
+ '版本历史', '修订记录', '相关文档', '相关链接', '致谢', '脚注', '备注', '索引', '术语表',
63
+ ])
64
+
65
+ /** 兜底类型:所有已知类型都不命中时落它(见 `pickType`)。 */
66
+ const FALLBACK_TYPE: SchemaType = 'concept'
67
+
68
+ /** 与 `runtime.ts` 的 `SAFE_ID` 同形:卡片 id 只允许 ASCII 安全字符。 */
69
+ const SAFE_ENTITY_ID = /^[a-zA-Z0-9._:-]{1,160}$/
70
+
71
+ /**
72
+ * 名字归一:小写 + 把非字母数字的连续段折成 `-`。
73
+ *
74
+ * **必须与 `candidateKey` / `buildAliasIndex` 用同一套归一**,否则「GMV 口径」和
75
+ * 「gmv-口径」在索引里是两个键,判重就会漏。这里刻意用小写化而不是运行时 slug 的
76
+ * 原样保留,因为判重是「同一个指称」的问题,大小写不构成区别。
77
+ */
78
+ export function normalizeCandidateName(value: string): string {
79
+ return value.trim().toLowerCase().replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 120)
80
+ }
81
+
82
+ export function candidateKey(candidate: { type: string; name: string }): string {
83
+ return `${candidate.type}:${normalizeCandidateName(candidate.name)}`
84
+ }
85
+
86
+ /**
87
+ * 候选落到磁盘时用的 `entity_id`。
88
+ *
89
+ * 卡片 id 必须过 `SAFE_ID`(ASCII),而候选名常常是中文,所以中文名不能直接当 id:
90
+ * 这里退化成 `candidate-<fnv1a>` 的稳定哈希。名字本身仍然完整地写进卡片 title,
91
+ * 并作为 alias 进索引,所以下一次判重照样能按名字找到它 —— 判重认的是名字,不是 id。
92
+ */
93
+ export function candidateEntityId(name: string): string {
94
+ const normalized = normalizeCandidateName(name)
95
+ if (SAFE_ENTITY_ID.test(normalized)) return normalized
96
+ return `candidate-${fnv1a(normalized)}`
97
+ }
98
+
99
+ /** FNV-1a(32 位):只用来生成稳定短 id,不用于任何安全目的。 */
100
+ function fnv1a(value: string): string {
101
+ let hash = 0x811c9dc5
102
+ for (let index = 0; index < value.length; index += 1) {
103
+ hash ^= value.charCodeAt(index)
104
+ hash = Math.imul(hash, 0x01000193)
105
+ }
106
+ return (hash >>> 0).toString(36)
107
+ }
108
+
109
+ function stringList(value: unknown): string[] {
110
+ if (typeof value === 'string') return value.trim() ? [value.trim()] : []
111
+ if (!Array.isArray(value)) return []
112
+ return value.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
113
+ }
114
+
115
+ function plainRecord(value: unknown): Record<string, unknown> {
116
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
117
+ }
118
+
119
+ // ------------------------------------------------------------ 类型判定
120
+
121
+ /**
122
+ * 用类型注册表的 `examples` / `description` 做**关键词重合**判型。
123
+ *
124
+ * 刻意不用模型:这一步只要「像指标就落指标,什么都不像就落概念」,判错了人还能改
125
+ * (卡片是 unreviewed)。要的是**确定性** —— 同一份文档两次抽取必须得到同一个类型,
126
+ * 否则增量指纹会永远在变。
127
+ */
128
+ function typeCorpus(def: WikiTypeDef): string {
129
+ return [def.id, def.label, def.description, ...def.examples].join('\n').toLowerCase()
130
+ }
131
+
132
+ /**
133
+ * 名称的匹配片段。
134
+ *
135
+ * 中文没有词边界,只按空格切词的话「销售口径」永远匹配不上任何例子,所以额外补
136
+ * 2-gram(`销售` / `售口` / `口径`)。片段只用于打分,不用于生成任何 id。
137
+ */
138
+ function nameFragments(name: string): string[] {
139
+ const lower = name.trim().toLowerCase()
140
+ const fragments = new Set<string>()
141
+ if (lower.length >= 2) fragments.add(lower)
142
+ for (const token of lower.split(/[^\p{L}\p{N}]+/u)) if (token.length >= 2) fragments.add(token)
143
+ const compact = [...lower.replace(/[^\p{L}\p{N}]+/gu, '')]
144
+ for (let index = 0; index + 1 < compact.length; index += 1) fragments.add(`${compact[index]}${compact[index + 1]}`)
145
+ return [...fragments]
146
+ }
147
+
148
+ function scoreType(def: WikiTypeDef, name: string): number {
149
+ const corpus = typeCorpus(def)
150
+ const full = name.trim().toLowerCase()
151
+ let score = full.length >= 2 && corpus.includes(full) ? 3 : 0
152
+ for (const fragment of nameFragments(name)) if (corpus.includes(fragment)) score += 1
153
+ return score
154
+ }
155
+
156
+ /** 在**启用的**类型里挑一个;全不命中时落 `concept`(不在注册表里就落第一个启用的)。 */
157
+ function pickType(name: string, enabled: WikiTypeDef[]): SchemaType {
158
+ let best: WikiTypeDef | undefined
159
+ let bestScore = 0
160
+ for (const def of enabled) {
161
+ const score = scoreType(def, name)
162
+ // 只有严格更高才替换:同分保持注册表顺序,抽取结果因此是确定的。
163
+ if (score > bestScore) { best = def; bestScore = score }
164
+ }
165
+ if (best) return best.id as SchemaType
166
+ if (enabled.some(def => def.id === FALLBACK_TYPE)) return FALLBACK_TYPE
167
+ return (enabled[0]?.id ?? FALLBACK_TYPE) as SchemaType
168
+ }
169
+
170
+ // ------------------------------------------------------------ 小节切分
171
+
172
+ interface Section { heading: string; content: string; markdown: string }
173
+
174
+ /** 按 `##`/`###` 切小节;`#` 与 `####` 不参与(前者是文档标题,后者太细)。 */
175
+ function sectionsOf(content: string): Section[] {
176
+ const sections: Section[] = []
177
+ let current: { heading: string; lines: string[] } | undefined
178
+ const flush = (): void => {
179
+ if (!current) return
180
+ const markdown = current.lines.join('\n').trim()
181
+ const body = current.lines.slice(1).join('\n').trim()
182
+ sections.push({ heading: current.heading, content: body, markdown })
183
+ current = undefined
184
+ }
185
+ for (const line of content.split(/\r?\n/)) {
186
+ const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line)
187
+ if (match) {
188
+ flush()
189
+ if (match[1] === '##' || match[1] === '###') current = { heading: match[2]!.replace(/[::]\s*$/, '').trim(), lines: [line] }
190
+ continue
191
+ }
192
+ if (current) current.lines.push(line)
193
+ }
194
+ flush()
195
+ return sections
196
+ }
197
+
198
+ function isGenericHeading(heading: string): boolean {
199
+ const normalized = heading.replace(/[\s::。.、]+$/g, '').trim()
200
+ if (GENERIC_HEADINGS.has(normalized)) return true
201
+ // 纯序号 / 纯数字小节(`1.`、`(二)`、`3`)不构成一个可定义的对象。
202
+ return /^[((]?[0-9一二三四五六七八九十]+[)).、]?$/.test(normalized)
203
+ }
204
+
205
+ function sectionForName(sections: Section[], name: string): Section | undefined {
206
+ const normalized = normalizeCandidateName(name)
207
+ if (!normalized) return undefined
208
+ return sections.find(section => {
209
+ const heading = normalizeCandidateName(section.heading)
210
+ return heading === normalized || (heading.length >= 2 && normalized.includes(heading)) || (normalized.length >= 2 && heading.includes(normalized))
211
+ })
212
+ }
213
+
214
+ // ------------------------------------------------------------ 声明式候选
215
+
216
+ /**
217
+ * 文档frontmatter / metadata 里可以直接声明候选,这是 Agent 的**逃生通道**:
218
+ * 自动抽取判断不了的时候,由调用方明确写出「这里有一个 X」。
219
+ *
220
+ * `SourceRaw` 只带 `metadata:` 这一块(`loadSources` 只投影它),所以声明写在
221
+ * `metadata` 里。接受四种形态,按出现顺序合并:
222
+ *
223
+ * ```yaml
224
+ * metadata:
225
+ * candidates: # ① 分桶
226
+ * entities: [{ name: 订单表, type: table }]
227
+ * concepts: [{ name: 销售口径, aliases: [GMV 口径], body: "…" }]
228
+ * relations: [{ from: 销售口径, to: GMV, relation: relates_to }]
229
+ * candidates: # ② 扁平数组(每条自带 kind)
230
+ * - { kind: entity, name: 订单表, type: table }
231
+ * - { kind: concept, name: 销售口径 }
232
+ * - { kind: relation, name: 销售口径, target: { name: GMV } }
233
+ * - 纯字符串 # 等价于 { kind: concept, name: 字符串 }
234
+ * entities: [ … ] # ③ 直接挂 entities / concepts / relations
235
+ * concepts: [ … ]
236
+ * relations: [ … ]
237
+ * ```
238
+ *
239
+ * 单条候选可带:`name`(或 `title`;关系也可用 `from`)、`type` / `entity_type`、
240
+ * `aliases`、`body`、`heading`、`confidence`。关系还可带 `to` / `target`、
241
+ * `relation`(默认 `relates_to`)、`to_type`。
242
+ */
243
+ interface DeclaredEntry { kind: 'entity' | 'concept' | 'relation'; entry: unknown }
244
+
245
+ function declaredEntries(metadata: Record<string, unknown>): DeclaredEntry[] {
246
+ const result: DeclaredEntry[] = []
247
+ const pushList = (kind: DeclaredEntry['kind'], value: unknown): void => {
248
+ if (!Array.isArray(value)) return
249
+ for (const entry of value) result.push({ kind, entry })
250
+ }
251
+ const bag = metadata.candidates
252
+ if (Array.isArray(bag)) {
253
+ for (const entry of bag) {
254
+ const record = typeof entry === 'string' ? undefined : plainRecord(entry)
255
+ const declared = record && typeof record.kind === 'string' ? record.kind.trim().toLowerCase() : ''
256
+ const kind: DeclaredEntry['kind'] = declared === 'relation' ? 'relation' : declared === 'entity' ? 'entity' : 'concept'
257
+ result.push({ kind, entry })
258
+ }
259
+ } else if (bag !== null && typeof bag === 'object') {
260
+ const bucket = plainRecord(bag)
261
+ pushList('entity', bucket.entities)
262
+ pushList('concept', bucket.concepts)
263
+ pushList('relation', bucket.relations)
264
+ }
265
+ pushList('entity', metadata.entities)
266
+ pushList('concept', metadata.concepts)
267
+ pushList('relation', metadata.relations)
268
+ return result
269
+ }
270
+
271
+ function entryName(entry: unknown): string {
272
+ if (typeof entry === 'string') return entry.trim()
273
+ const record = plainRecord(entry)
274
+ for (const key of ['name', 'title', 'from'] as const) {
275
+ const value = record[key]
276
+ if (typeof value === 'string' && value.trim()) return value.trim()
277
+ }
278
+ return ''
279
+ }
280
+
281
+ /** 显式声明的类型 id:原样带出去,交给 `writeCard` 的类型闸门裁决(不在这里改判)。 */
282
+ function entryTypeId(entry: unknown): string | undefined {
283
+ const record = plainRecord(entry)
284
+ for (const key of ['type', 'entity_type'] as const) {
285
+ const value = record[key]
286
+ if (typeof value === 'string' && value.trim()) return value.trim()
287
+ }
288
+ return undefined
289
+ }
290
+
291
+ function entryTargetName(entry: unknown): string {
292
+ const record = plainRecord(entry)
293
+ const target = record.target
294
+ if (typeof record.to === 'string' && record.to.trim()) return record.to.trim()
295
+ if (typeof target === 'string' && target.trim()) return target.trim()
296
+ const nested = plainRecord(target)
297
+ return typeof nested.name === 'string' ? nested.name.trim() : ''
298
+ }
299
+
300
+ function entryTargetTypeId(entry: unknown): string | undefined {
301
+ const record = plainRecord(entry)
302
+ const direct = record.to_type
303
+ if (typeof direct === 'string' && direct.trim()) return direct.trim()
304
+ const nested = plainRecord(record.target)
305
+ return typeof nested.type === 'string' && nested.type.trim() ? nested.type.trim() : undefined
306
+ }
307
+
308
+ function entryRelation(entry: unknown): string {
309
+ const value = plainRecord(entry).relation
310
+ return typeof value === 'string' && value.trim() ? value.trim() : RELATES_TO
311
+ }
312
+
313
+ function entryConfidence(entry: unknown, fallback: number): number {
314
+ const value = Number(plainRecord(entry).confidence)
315
+ return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback
316
+ }
317
+
318
+ function entryBody(entry: unknown): string | undefined {
319
+ const value = plainRecord(entry).body
320
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
321
+ }
322
+
323
+ function entryHeading(entry: unknown): string | undefined {
324
+ const value = plainRecord(entry).heading
325
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
326
+ }
327
+
328
+ function entryQuote(entry: unknown): string {
329
+ const text = typeof entry === 'string' ? entry : JSON.stringify(entry)
330
+ return text.replace(/\s+/g, ' ').trim().slice(0, 240)
331
+ }
332
+
333
+ // ------------------------------------------------------------ 抽取入口
334
+
335
+ /** 文档自己的主候选:声明式优先,其次第一个像样的小节(`[[链接]]` 关系的起点)。 */
336
+ function kindFor(type: SchemaType): 'entity' | 'concept' {
337
+ return type === FALLBACK_TYPE ? 'concept' : 'entity'
338
+ }
339
+
340
+ export function extractCandidates(source: SourceRaw, wikiTypes: WikiTypeDef[]): ExtractionCandidate[] {
341
+ const metadata = plainRecord(source.metadata)
342
+ const enabled = wikiTypes.filter(def => def.enabled)
343
+ const sections = sectionsOf(source.content)
344
+ const evidence = (heading?: string, quote?: string): CandidateEvidence => ({
345
+ sourceId: source.sourceId,
346
+ ...(heading ? { heading } : {}),
347
+ ...(quote ? { quote } : {}),
348
+ })
349
+ // 显式声明的类型原样采信:抽取器不替 Agent 改判类型。写不进去(id 未登记 / 已停用)
350
+ // 是 `writeCard` 的闸门该说的话,那时它会被计入 candidatesRejected 而不是被静默换型。
351
+ const declaredType = (raw: string | undefined, name: string): SchemaType => (raw ?? pickType(name, enabled)) as SchemaType
352
+
353
+ // ---- ① / ② 声明式候选(最高置信)
354
+ const declaredEntities: ExtractionCandidate[] = []
355
+ const declaredRelations: ExtractionCandidate[] = []
356
+ for (const { kind, entry } of declaredEntries(metadata)) {
357
+ const name = entryName(entry)
358
+ if (!name) continue
359
+ const type = declaredType(entryTypeId(entry), name)
360
+ if (kind === 'relation') {
361
+ const to = entryTargetName(entry)
362
+ if (!to) continue
363
+ declaredRelations.push({
364
+ kind: 'relation', name, type,
365
+ evidence: evidence(entryHeading(entry), entryQuote(entry)),
366
+ target: { name: to, ...(entryTargetTypeId(entry) ? { type: declaredType(entryTargetTypeId(entry), to) } : {}) },
367
+ relation: entryRelation(entry),
368
+ confidence: entryConfidence(entry, 0.95),
369
+ })
370
+ continue
371
+ }
372
+ const aliases = stringList(plainRecord(entry).aliases)
373
+ const heading = entryHeading(entry) ?? sectionForName(sections, name)?.heading
374
+ const body = entryBody(entry) ?? sectionForName(sections, name)?.markdown
375
+ declaredEntities.push({
376
+ kind: kind === 'entity' ? 'entity' : kindFor(type), name, type,
377
+ evidence: evidence(heading, entryQuote(entry)),
378
+ ...(aliases.length > 0 ? { aliases } : {}),
379
+ ...(body ? { body } : {}),
380
+ confidence: entryConfidence(entry, 0.95),
381
+ })
382
+ }
383
+
384
+ // ---- ④ 小节候选(先算:`[[链接]]` 的关系起点可能就是第一个小节)
385
+ const headingCandidates: ExtractionCandidate[] = []
386
+ for (const section of sections) {
387
+ if (isGenericHeading(section.heading)) continue
388
+ if (section.content.length < SECTION_MIN_CHARS) continue
389
+ const type = pickType(section.heading, enabled)
390
+ headingCandidates.push({
391
+ kind: kindFor(type), name: section.heading, type,
392
+ evidence: evidence(section.heading, section.content.slice(0, 240)),
393
+ body: section.markdown,
394
+ confidence: 0.5,
395
+ })
396
+ }
397
+
398
+ // ---- ③ wikilink 候选 + 从主候选指向它的 relates_to
399
+ const linkCandidates: ExtractionCandidate[] = []
400
+ const linkSeen = new Set<string>()
401
+ /**
402
+ * 每个 `[[链接]]` 落在哪个小节里。
403
+ *
404
+ * 0029 实跑发现的召回污染就是这里造成的:链接候选原来只带 `evidence(undefined, …)`,
405
+ * 于是它**没有正文**,落卡时只能拿 wiki 类型的模板顶上 —— 模型召回到的是
406
+ * 「(必填:写什么)」。而链接所在的那一小节就是关于它的现成说明,本来就在手边。
407
+ */
408
+ const sectionByHeading = new Map(sections.map(section => [section.heading, section]))
409
+ const linkSection = new Map<string, Section>()
410
+ {
411
+ let current: Section | undefined
412
+ for (const line of source.content.split(/\r?\n/)) {
413
+ const heading = /^#{2,6}\s+(.+?)\s*$/.exec(line)
414
+ if (heading) { current = sectionByHeading.get(heading[1]!.trim()); continue }
415
+ if (!current) continue
416
+ for (const match of line.matchAll(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g)) {
417
+ const name = (match[1] ?? '').trim()
418
+ if (name && !linkSection.has(name)) linkSection.set(name, current)
419
+ }
420
+ }
421
+ }
422
+ for (const match of source.content.matchAll(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g)) {
423
+ const name = (match[1] ?? '').trim()
424
+ if (!name) continue
425
+ const alias = (match[2] ?? '').trim()
426
+ const type = pickType(name, enabled)
427
+ const key = candidateKey({ type, name })
428
+ if (linkSeen.has(key)) continue
429
+ linkSeen.add(key)
430
+ const section = linkSection.get(name)
431
+ linkCandidates.push({
432
+ kind: kindFor(type), name, type,
433
+ evidence: evidence(section?.heading, match[0].slice(0, 240)),
434
+ ...(alias ? { aliases: [alias] } : {}),
435
+ ...(section ? { body: section.markdown } : {}),
436
+ confidence: 0.6,
437
+ })
438
+ }
439
+ const primary = declaredEntities[0] ?? headingCandidates[0]
440
+ const linkRelations: ExtractionCandidate[] = []
441
+ if (primary) {
442
+ for (const link of linkCandidates) {
443
+ // 主候选指向自己不算关系。
444
+ if (candidateKey(primary) === candidateKey(link)) continue
445
+ linkRelations.push({
446
+ kind: 'relation', name: primary.name, type: primary.type,
447
+ evidence: link.evidence,
448
+ target: { name: link.name, type: link.type },
449
+ relation: RELATES_TO,
450
+ confidence: 0.55,
451
+ })
452
+ }
453
+ }
454
+
455
+ // ---- ⑤ 什么都没抽到时,文档标题本身就是一个候选(有标题就有据)
456
+ const titleCandidates: ExtractionCandidate[] = []
457
+ const title = (source.title ?? '').trim()
458
+ if (declaredEntities.length + linkCandidates.length + headingCandidates.length === 0 && title) {
459
+ const type = pickType(title, enabled)
460
+ titleCandidates.push({
461
+ kind: kindFor(type), name: title, type,
462
+ // 整篇正文就是「这篇文档在讲什么」的正文 —— 没有小节可切时,它就是唯一的内容。
463
+ evidence: evidence(undefined, title),
464
+ ...(source.content.trim() ? { body: source.content.trim() } : {}),
465
+ confidence: 0.35,
466
+ })
467
+ }
468
+
469
+ // 顺序 = 优先级(声明式 → 链接 → 小节 → 标题),同类内保持文档出现顺序;
470
+ // 上限只砍尾部的低置信候选,不会把声明式候选挤掉。
471
+ const entities: ExtractionCandidate[] = []
472
+ const entitySeen = new Set<string>()
473
+ for (const candidate of [...declaredEntities, ...linkCandidates, ...headingCandidates, ...titleCandidates]) {
474
+ if (!candidate.name.trim()) continue
475
+ const key = candidateKey(candidate)
476
+ if (entitySeen.has(key)) continue
477
+ entitySeen.add(key)
478
+ entities.push(candidate)
479
+ if (entities.length >= MAX_CANDIDATES_PER_SOURCE) break
480
+ }
481
+ const relations: ExtractionCandidate[] = []
482
+ const relationSeen = new Set<string>()
483
+ for (const candidate of [...declaredRelations, ...linkRelations]) {
484
+ const to = candidate.target?.name?.trim()
485
+ if (!candidate.name.trim() || !to) continue
486
+ const key = `${normalizeCandidateName(candidate.name)}->${normalizeCandidateName(to)}:${candidate.relation ?? RELATES_TO}`
487
+ if (relationSeen.has(key)) continue
488
+ relationSeen.add(key)
489
+ relations.push(candidate)
490
+ if (relations.length >= MAX_CANDIDATES_PER_SOURCE) break
491
+ }
492
+ return [...entities, ...relations]
493
+ }
494
+
495
+ // ------------------------------------------------------------ 归一 · 与库内判重
496
+
497
+ /**
498
+ * 现有卡片的判重索引。
499
+ *
500
+ * 三处都登记:`entityType + entityId`(技术 id)、`entityType + title`(人写的中文名)、
501
+ * `metadata.aliases` 里的每个写法。**别名是关键字**:Agent 写卡片时把「GMV 口径」登记
502
+ * 成 `gmv-kpi` 的别名,抽取器下一次就认得这两者是同一个指称,不会再造第二张卡。
503
+ */
504
+ export function buildAliasIndex(cards: KnowledgeCard[]): Map<string, KnowledgeCard> {
505
+ const index = new Map<string, KnowledgeCard>()
506
+ const put = (type: string, name: unknown, card: KnowledgeCard): void => {
507
+ if (typeof name !== 'string' || !name.trim()) return
508
+ const key = candidateKey({ type, name })
509
+ // 先到先得:同一 slug 撞车时保持确定性,不让文件系统顺序决定谁赢。
510
+ if (!index.has(key)) index.set(key, card)
511
+ }
512
+ for (const card of cards) {
513
+ put(card.entityType, card.entityId, card)
514
+ put(card.entityType, card.title, card)
515
+ for (const alias of stringList(plainRecord(card.metadata).aliases)) put(card.entityType, alias, card)
516
+ }
517
+ return index
518
+ }
519
+
520
+ export interface NormalizedCandidates {
521
+ fresh: ExtractionCandidate[]
522
+ /**
523
+ * Candidates whose referent already exists: merge into that card.
524
+ *
525
+ * `body` 是**这次新抽到的正文**,必须一起带出去。只带 `sourceId` 的话,调用方
526
+ * 除了「记一笔来源」什么也做不了 —— 新写进来源里的内容会**静默消失**,卡片永远
527
+ * 停在第一次抽取时的样子(落点是 `runtime.ts` 的 `appendExtracted`)。
528
+ */
529
+ merges: Array<{ key: string; entityId: string; entityType: SchemaType; sourceId: string; body: string }>
530
+ /**
531
+ * Relation candidates are never merged — they become graph edges.
532
+ *
533
+ * `fromKey` / `toKey` 是**图上的节点键**(`<entityType>:<entityId>`),不是候选键
534
+ * (`<type>:<归一化名字>`)。两者在名字是 ASCII 时长得一样,在中文名上完全不同 ——
535
+ * 中文名的卡片 id 会退化成 `candidate-<hash>`。这里必须先解析成真正的节点键,
536
+ * 否则装配图时按候选键去找节点,**所有中文名之间的关系都会被当成「端点不存在」丢掉**。
537
+ */
538
+ relations: Array<{ fromKey: string; toKey: string; relation: string; sourceId: string }>
539
+ }
540
+
541
+ /**
542
+ * 归一:把候选分成「新建」「并入已有卡片」「关系」三类。
543
+ *
544
+ * 关系两端也走同一套判重,所以边只可能连到**真实存在的 wiki 节点**上;解析不出来的
545
+ * 端点会带着一个稳定 key 出去,由 `rebuild()` 在装配图时丢弃并计数,而不是在这里
546
+ * 凭空造一个节点。
547
+ */
548
+ export function normalizeCandidates(candidates: ExtractionCandidate[], index: Map<string, KnowledgeCard>): NormalizedCandidates {
549
+ const resolved = new Map(index)
550
+ const byName = new Map<string, string[]>()
551
+ const remember = (key: string): void => {
552
+ const separator = key.indexOf(':')
553
+ const name = separator >= 0 ? key.slice(separator + 1) : key
554
+ const bucket = byName.get(name)
555
+ if (bucket) { if (!bucket.includes(key)) bucket.push(key) }
556
+ else byName.set(name, [key])
557
+ }
558
+ for (const key of resolved.keys()) remember(key)
559
+
560
+ const lookup = (name: string, type?: string): string | undefined => {
561
+ const normalized = normalizeCandidateName(name)
562
+ if (!normalized) return undefined
563
+ if (type) {
564
+ const exact = `${type}:${normalized}`
565
+ if (resolved.has(exact)) return exact
566
+ }
567
+ // 没给类型(或给的类型对不上)时按名字找:判重认的是指称,不是类型。
568
+ return byName.get(normalized)?.[0]
569
+ }
570
+
571
+ /** 候选键 → 图上的节点键。找不到对应卡片时返回 undefined,由调用方决定怎么兜底。 */
572
+ const nodeKey = (name: string, type?: string): string | undefined => {
573
+ const key = lookup(name, type)
574
+ if (!key) return undefined
575
+ const card = resolved.get(key)
576
+ return card ? `${card.entityType}:${card.entityId}` : key
577
+ }
578
+
579
+ const fresh: ExtractionCandidate[] = []
580
+ const merges: NormalizedCandidates['merges'] = []
581
+ const relations: NormalizedCandidates['relations'] = []
582
+ /** 本次已判为「新建」的 key(含别名):同一份文档里的重复写法不再各建一张。 */
583
+ const freshKeys = new Set<string>()
584
+
585
+ const register = (key: string, card: KnowledgeCard): void => {
586
+ if (!resolved.has(key)) resolved.set(key, card)
587
+ remember(key)
588
+ }
589
+
590
+ for (const candidate of candidates) {
591
+ if (candidate.kind === 'relation') {
592
+ const targetName = candidate.target?.name ?? ''
593
+ if (!candidate.name.trim() || !targetName.trim()) continue
594
+ const targetType = candidate.target?.type
595
+ relations.push({
596
+ // 解析不出来时退回候选键:它一定不在 nodeMap 里,装配阶段会把它计入
597
+ // relationsDropped —— 这比在这里凭空造一个节点诚实。
598
+ fromKey: nodeKey(candidate.name, candidate.type) ?? candidateKey({ type: candidate.type, name: candidate.name }),
599
+ toKey: nodeKey(targetName, targetType) ?? candidateKey({ type: targetType ?? candidate.type, name: targetName }),
600
+ relation: candidate.relation ?? RELATES_TO,
601
+ sourceId: candidate.evidence.sourceId,
602
+ })
603
+ continue
604
+ }
605
+ const key = candidateKey(candidate)
606
+ if (freshKeys.has(key)) continue
607
+ const existing = resolved.get(key)
608
+ if (existing) {
609
+ merges.push({ key, entityId: existing.entityId, entityType: existing.entityType, sourceId: candidate.evidence.sourceId, body: candidate.body ?? '' })
610
+ continue
611
+ }
612
+ fresh.push(candidate)
613
+ const projected: KnowledgeCard = {
614
+ entityId: candidateEntityId(candidate.name),
615
+ entityType: candidate.type,
616
+ title: candidate.name,
617
+ status: 'unreviewed',
618
+ content: candidate.body ?? '',
619
+ sourceRefs: [candidate.evidence.sourceId],
620
+ metadata: {},
621
+ updatedAt: '',
622
+ }
623
+ freshKeys.add(key)
624
+ register(key, projected)
625
+ for (const alias of candidate.aliases ?? []) {
626
+ const aliasKey = candidateKey({ type: candidate.type, name: alias })
627
+ freshKeys.add(aliasKey)
628
+ register(aliasKey, projected)
629
+ }
630
+ }
631
+ return { fresh, merges, relations }
632
+ }