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,164 @@
1
+ /**
2
+ * 目录选择:把 DSH 宿主的 `ctx.directoryPicker` 接到 TitaAgent 控制台上(0029)。
3
+ *
4
+ * ## 为什么不让用户手输路径
5
+ *
6
+ * 路径是**磁盘事实**,不是用户脑子里的字符串。让人手打 `/Users/xxx/Desktop/…`
7
+ * 的结果是:拼错一个字符、`~` 没展开、复制粘贴带上了尾随空格 —— 而这一处的错误
8
+ * 代价特别大,它决定了整个 Agent 的东西存在哪。所以设置里那个「根路径」是一个
9
+ * **选择**动作,不是一个输入框。
10
+ *
11
+ * ## 为什么要适配两种交互
12
+ *
13
+ * 宿主把「怎么选目录」抽象成一个 capability,形状有两种:
14
+ *
15
+ * - `native`:在**宿主那台机器**上弹系统文件夹对话框,返回一个绝对路径。
16
+ * 浏览器和宿主在同一台机器上时这是最好的形态 —— 用户看到的是他熟悉的访达。
17
+ * - `browse`:宿主只提供「列一层 / 建子目录」两个原语,由调用方自己在页面里画
18
+ * 浏览界面。浏览器在远端(或宿主没有显示器)时这是唯一能用的形态。
19
+ *
20
+ * 两者**不能只挑一个**:本机部署走 native,远程访问走 browse,而这是启动时根据
21
+ * 绑定地址 / SSH / 显示器判定的(`dsh-host-directory-picker-auto`)。所以这里
22
+ * 如实把 kind 转给前端,由前端决定弹系统框还是画浏览框 —— 前端的默认写法是
23
+ * 「拿到 kind 再分叉」,而不是假设自己是哪一种。
24
+ *
25
+ * ## 为什么类型是结构化的
26
+ *
27
+ * `@deepseek-ai/dsh-host-directory-picker` 是 DSH 安装里的包,不是 npm 上可依赖的
28
+ * 版本,插件不能 import 它(会把 DSH 的内部路径焊进依赖图)。所以这里只声明我们
29
+ * 真正用到的那几个成员 —— 服务是按名字从 ctx 里取的,取不到就是没有,退化成
30
+ * 「宿主没提供目录选择器」这一条如实回答,而不是崩掉。
31
+ *
32
+ * @module
33
+ */
34
+
35
+ /** 一层目录里的一行:一个子目录,或一个面包屑祖先。 */
36
+ export interface DirectoryEntry {
37
+ /** 显示用的名字(根面包屑带的是完整路径)。 */
38
+ name: string
39
+ /** 宿主机上的绝对路径 —— 调用方永远不自己拼路径段。 */
40
+ path: string
41
+ /** 宿主平台约定的隐藏项(POSIX 上是点开头)。 */
42
+ hidden: boolean
43
+ }
44
+
45
+ /** 一层目录 + 它的祖先链。 */
46
+ export interface DirectoryListing {
47
+ path: string
48
+ /** 宿主账号的 home,面包屑的「Home」锚点。 */
49
+ home: string
50
+ crumbs: DirectoryEntry[]
51
+ entries: DirectoryEntry[]
52
+ /** 后端把 entries 截断在它的完整结果上限上了。 */
53
+ truncated: boolean
54
+ }
55
+
56
+ /** 宿主的目录选择能力。两个原语都可能是 `undefined` —— 取决于 kind。 */
57
+ export interface DirectoryPickerCapability {
58
+ kind: string
59
+ pick?(signal: AbortSignal): Promise<string | null>
60
+ list?(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
61
+ createDirectory?(path: string, name: string): Promise<string>
62
+ }
63
+
64
+ /** `ctx.directoryPicker` 的结构化视图。 */
65
+ export interface DirectoryPickerLike {
66
+ capability(): DirectoryPickerCapability
67
+ }
68
+
69
+ /** 目录选择失败的业务码。前端据此给出「怎么办」而不是一句「失败了」。 */
70
+ export type PickerErrorCode =
71
+ | 'PICKER_UNAVAILABLE'
72
+ | 'PICK_CANCELLED'
73
+ | 'DIRECTORY_UNREADABLE'
74
+ | 'DIRECTORY_EXISTS'
75
+ | 'DIRECTORY_CREATE_FAILED'
76
+
77
+ /** 带业务码的目录选择失败。 */
78
+ export class PickerError extends Error {
79
+ readonly code: PickerErrorCode
80
+ constructor(code: PickerErrorCode, message: string) {
81
+ super(message)
82
+ this.code = code
83
+ this.name = 'PickerError'
84
+ }
85
+ }
86
+
87
+ /** 从 ctx 里按名字取目录选择服务。取不到返回 `undefined`(宿主没挂这个能力)。 */
88
+ export function pickerOf(ctx: { get(name: string): unknown }): DirectoryPickerLike | undefined {
89
+ const service = ctx.get('directoryPicker') as DirectoryPickerLike | undefined
90
+ return service && typeof service.capability === 'function' ? service : undefined
91
+ }
92
+
93
+ /** 宿主当前提供的是哪种交互。取不到服务时返回 `null`。 */
94
+ export function pickerKind(picker: DirectoryPickerLike | undefined): string | null {
95
+ if (!picker) return null
96
+ try {
97
+ return picker.capability()?.kind ?? null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 开一次选择。
105
+ *
106
+ * native 会**阻塞到用户给出答案**(关掉对话框也算答案:`path` 为 `null`),所以
107
+ * 调用方的 HTTP 请求会一直挂着 —— 这正是系统对话框的语义,不该用超时把它打断。
108
+ * browse 立刻返回,让前端去画浏览界面。
109
+ */
110
+ export async function pickDirectory(
111
+ picker: DirectoryPickerLike | undefined,
112
+ signal: AbortSignal,
113
+ ): Promise<{ kind: 'native'; path: string | null } | { kind: 'browse' }> {
114
+ const capability = picker?.capability()
115
+ if (!capability) {
116
+ throw new PickerError('PICKER_UNAVAILABLE', '宿主没有提供目录选择器,无法打开文件夹对话框。')
117
+ }
118
+ if (capability.kind === 'native') {
119
+ if (typeof capability.pick !== 'function') {
120
+ throw new PickerError('PICKER_UNAVAILABLE', '宿主的目录选择器声明为 native,但没有提供 pick。')
121
+ }
122
+ return { kind: 'native', path: await capability.pick(signal) }
123
+ }
124
+ return { kind: 'browse' }
125
+ }
126
+
127
+ /** 列一层目录(browse 形态)。 */
128
+ export async function listDirectory(
129
+ picker: DirectoryPickerLike | undefined,
130
+ path: string | undefined,
131
+ signal: AbortSignal,
132
+ ): Promise<DirectoryListing> {
133
+ const capability = picker?.capability()
134
+ if (!capability || capability.kind !== 'browse' || typeof capability.list !== 'function') {
135
+ throw new PickerError('PICKER_UNAVAILABLE', '宿主没有提供可浏览的目录列表。')
136
+ }
137
+ try {
138
+ return await capability.list(path, signal)
139
+ } catch (cause) {
140
+ throw new PickerError('DIRECTORY_UNREADABLE', cause instanceof Error ? cause.message : String(cause))
141
+ }
142
+ }
143
+
144
+ /** 在某个目录下建一个子目录(browse 形态)。 */
145
+ export async function createDirectory(
146
+ picker: DirectoryPickerLike | undefined,
147
+ path: string,
148
+ name: string,
149
+ ): Promise<string> {
150
+ const capability = picker?.capability()
151
+ if (!capability || capability.kind !== 'browse' || typeof capability.createDirectory !== 'function') {
152
+ throw new PickerError('PICKER_UNAVAILABLE', '宿主没有提供新建目录的能力。')
153
+ }
154
+ const trimmed = name.trim()
155
+ if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed === '.' || trimmed === '..') {
156
+ throw new PickerError('DIRECTORY_CREATE_FAILED', '目录名不能为空、不能包含路径分隔符,也不能是 . 或 ..')
157
+ }
158
+ try {
159
+ return await capability.createDirectory(path, trimmed)
160
+ } catch (cause) {
161
+ const message = cause instanceof Error ? cause.message : String(cause)
162
+ throw new PickerError(/exist/i.test(message) ? 'DIRECTORY_EXISTS' : 'DIRECTORY_CREATE_FAILED', message)
163
+ }
164
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * 模型驱动的候选抽取与**实体归并**(#3b)。
3
+ *
4
+ * ## 为什么需要这一步,而不是继续改规则
5
+ *
6
+ * Pass 0 的规则抽取用**小节标题**当实体身份(`candidateEntityId(candidate.name)`)。
7
+ * 于是同一件东西只要换个标题写法,就是两个实体。这不是理论问题,是实测出来的:
8
+ *
9
+ * - 年报里的小节叫「毛利率变动及其驱动因素」;
10
+ * - 口径手册里的小节叫「毛利率(口径补充)」;
11
+ * - 结果:`新建 2 / 并入 0`,两张互不相干的卡片,
12
+ * 而「实体已存在就追加正文」那条分支**永远不会触发**。
13
+ *
14
+ * 想去掉括号后缀、去掉「及其驱动因素」这类尾巴,是**语义判断**:`毛利率` 与
15
+ * `毛利率变动及其驱动因素` 是同一个东西,而 `应收账款周转天数` 与 `库存周转天数`
16
+ * 不是 —— 两者字面都带「周转天数」。阈值化的字符串规则在第二步上必然出错,
17
+ * 所以这件事必须交给模型。
18
+ *
19
+ * ## 这一层只问一个问题
20
+ *
21
+ * 它**不**让模型自由编造卡片,也不让它决定正文:正文永远是来源里的原文片段,
22
+ * 模型只回答两件它擅长、而规则做不好的事 ——
23
+ *
24
+ * 1. 「这份来源里有哪些**实体**」(规则只会按小节切,切出来的是标题不是实体);
25
+ * 2. 「这个实体是不是已经在下表里了」(给出已有卡片的 `existing_id`)。
26
+ *
27
+ * 第 2 问是归并的关键:**判断权在模型,决定权在这里**。模型说「是同一个」,
28
+ * 我们就走追加;说「不是」,就新建。模型给出的 `existing_id` 必须能在已有卡片里
29
+ * 找到,找不到就退回新建 —— 不能让模型凭一个不存在的 id 把内容写丢。
30
+ *
31
+ * ## 为什么要注入 `call`
32
+ *
33
+ * 模型调用是这一层唯一的非纯部分。把它作为参数注入,这个模块就能在没有模型的环境里
34
+ * 被完整测试(见 `tests/extract-model.test.mjs`)——否则「归并对不对」这件事只能靠
35
+ * 真跑一次模型来验,而那是不可重复的。
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { candidateEntityId, type ExtractionCandidate } from './extract.js'
41
+ import type { KnowledgeCard, SchemaType, SourceRaw, WikiTypeDef } from './types.js'
42
+
43
+ /** 一次纯文本模型调用:给出 system 与 prompt,拿回模型的原始文本。 */
44
+ export type ModelCall = (request: { system: string; prompt: string }) => Promise<string>
45
+
46
+ /** 归并条目:与规则抽取的 `NormalizedCandidates['merges']` 保持同一形状。 */
47
+ export interface ModelMerge {
48
+ key: string
49
+ entityId: string
50
+ entityType: SchemaType
51
+ sourceId: string
52
+ body: string
53
+ }
54
+
55
+ export interface ModelExtractionResult {
56
+ /** 判定为「wiki 里还没有」的实体 → 新建卡片。 */
57
+ fresh: ExtractionCandidate[]
58
+ /** 判定为「wiki 里已经有了」的实体 → 正文追加进那张卡。 */
59
+ merges: ModelMerge[]
60
+ warnings: string[]
61
+ }
62
+
63
+ export interface ModelExtractionInput {
64
+ source: SourceRaw
65
+ /** 已启用的 wiki 类型:模型只能从这些 id 里选。 */
66
+ wikiTypes: WikiTypeDef[]
67
+ /** 当前库里的卡片:既是「已存在」的候选集,也是给模型的上下文。 */
68
+ existing: KnowledgeCard[]
69
+ call: ModelCall
70
+ }
71
+
72
+ /** 单个实体最多给模型的正文长度:够判断语义,又不至于把上下文撑爆。 */
73
+ const BODY_LIMIT = 1200
74
+
75
+ /**
76
+ * 系统提示。**刻意写死在这里**而不是做成设置项:它不是用户的偏好,是这个模块与模型
77
+ * 之间的接口契约(输出必须是一段可解析的 JSON)。允许用户改它,等于允许用户把这个
78
+ * 解析器改坏。
79
+ */
80
+ const SYSTEM = [
81
+ 'You extract wiki entities from a business document and decide which of them already exist.',
82
+ 'Answer with a single JSON object and nothing else. No prose, no code fence.',
83
+ 'Shape: {"entities":[{"name":string,"type":string,"summary":string,"body":string,"existing_id":string|null}]}',
84
+ 'Rules:',
85
+ '- "name" is the entity name as a business person would say it (e.g. 毛利率). Not a section heading.',
86
+ '- "type" MUST be one of the allowed type ids given in the prompt.',
87
+ '- "body" must be verbatim text from the document that backs this entity. Never invent facts.',
88
+ '- "existing_id" is the entityId of the existing card this entity is the SAME thing as, or null.',
89
+ ' Same thing means the same business concept; a narrower or different concept is NOT the same.',
90
+ '- Prefer few, real entities over many headings.',
91
+ ].join('\n')
92
+
93
+ /** 从一串可能带 ``` 围栏的文本里取出 JSON 对象并解析。 */
94
+ function parseJsonObject(raw: string): unknown {
95
+ const trimmed = raw.trim()
96
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
97
+ const body = (fenced ? fenced[1] : trimmed).trim()
98
+ try {
99
+ return JSON.parse(body)
100
+ } catch {
101
+ // 模型有时会在 JSON 前后带一句解释:退一步,取第一个 `{` 到最后一个 `}`。
102
+ const start = body.indexOf('{')
103
+ const end = body.lastIndexOf('}')
104
+ if (start < 0 || end <= start) return undefined
105
+ try {
106
+ return JSON.parse(body.slice(start, end + 1))
107
+ } catch {
108
+ return undefined
109
+ }
110
+ }
111
+ }
112
+
113
+ /** 把模型的回答收敛成「合法实体数组」;任何不合法的一条都丢掉并记一条 warning。 */
114
+ function readEntities(raw: unknown, allowedTypes: Set<string>, warnings: string[]): Array<Record<string, unknown>> {
115
+ if (raw === null || typeof raw !== 'object') {
116
+ warnings.push('模型输出不是 JSON 对象,本次抽取按空处理')
117
+ return []
118
+ }
119
+ const list = (raw as { entities?: unknown }).entities
120
+ if (!Array.isArray(list)) {
121
+ warnings.push('模型输出里没有 entities 数组,本次抽取按空处理')
122
+ return []
123
+ }
124
+ const kept: Array<Record<string, unknown>> = []
125
+ for (const item of list) {
126
+ if (item === null || typeof item !== 'object') continue
127
+ const record = item as Record<string, unknown>
128
+ const name = typeof record.name === 'string' ? record.name.trim() : ''
129
+ const type = typeof record.type === 'string' ? record.type.trim() : ''
130
+ const body = typeof record.body === 'string' ? record.body.trim() : ''
131
+ if (!name) continue
132
+ if (!allowedTypes.has(type)) {
133
+ warnings.push(`模型给出的类型「${type}」不在已启用类型里,候选「${name}」被丢掉`)
134
+ continue
135
+ }
136
+ if (!body) {
137
+ warnings.push(`候选「${name}」没有正文,被丢掉(卡片不能没有正文)`)
138
+ continue
139
+ }
140
+ kept.push({ ...record, name, type, body })
141
+ }
142
+ return kept
143
+ }
144
+
145
+ /**
146
+ * 用模型抽取实体,并把它们判归到「新建」或「追加到已有卡片」两条路上。
147
+ *
148
+ * 失败一律降级而不抛出:模型不可用时返回空结果 + 一条 warning,让上层继续跑规则抽取。
149
+ * 「因为模型挂了所以整次编译失败」是不能接受的 —— 那条链路本来就不依赖模型。
150
+ */
151
+ export async function extractWithModel(input: ModelExtractionInput): Promise<ModelExtractionResult> {
152
+ const { source, wikiTypes, existing, call } = input
153
+ const warnings: string[] = []
154
+ const enabled = wikiTypes.filter(def => def.enabled)
155
+ const allowedTypes = new Set(enabled.map(def => String(def.id)))
156
+
157
+ const catalog = existing
158
+ .slice(0, 200)
159
+ .map(card => `- ${card.entityId} :: ${card.title}(${card.entityType})`)
160
+ .join('\n')
161
+ const types = enabled
162
+ .map(def => `- ${def.id} :: ${def.label}${def.description ? ` — ${def.description}` : ''}`)
163
+ .join('\n')
164
+
165
+ const prompt = [
166
+ '# Allowed wiki type ids',
167
+ types || '(没有任何已启用类型)',
168
+ '',
169
+ '# Existing wiki cards (entityId :: title)',
170
+ catalog || '(这个库还是空的)',
171
+ '',
172
+ '# Document',
173
+ `title: ${source.title}`,
174
+ `source_id: ${source.sourceId}`,
175
+ '',
176
+ source.content.slice(0, BODY_LIMIT * 8),
177
+ ].join('\n')
178
+
179
+ let raw: string
180
+ try {
181
+ raw = await call({ system: SYSTEM, prompt })
182
+ } catch (cause) {
183
+ warnings.push(`模型抽取不可用:${cause instanceof Error ? cause.message : String(cause)}`)
184
+ return { fresh: [], merges: [], warnings }
185
+ }
186
+
187
+ const byId = new Map(existing.map(card => [`${card.entityType}:${card.entityId}`, card]))
188
+ const byEntityId = new Map(existing.map(card => [card.entityId, card]))
189
+ const fresh: ExtractionCandidate[] = []
190
+ const merges: ModelMerge[] = []
191
+ const claimed = new Set<string>()
192
+
193
+ for (const entity of readEntities(parseJsonObject(raw), allowedTypes, warnings)) {
194
+ const name = String(entity.name)
195
+ const type = String(entity.type) as SchemaType
196
+ const body = String(entity.body).slice(0, BODY_LIMIT)
197
+ const wantedId = typeof entity.existing_id === 'string' ? entity.existing_id.trim() : ''
198
+ // 模型给的 id 必须真的存在:它指着不存在的卡片时退回新建,绝不能让正文丢在空中。
199
+ const target = (wantedId ? byEntityId.get(wantedId) : undefined) ?? byId.get(`${type}:${name}`)
200
+
201
+ if (target) {
202
+ const key = `${target.entityType}:${target.entityId}`
203
+ // 同一张卡在一次抽取里被多个候选指向时只并一次,正文在这里先合起来。
204
+ const already = merges.find(entry => entry.key === key)
205
+ if (already) already.body = `${already.body}\n\n${body}`
206
+ else merges.push({ key, entityId: target.entityId, entityType: target.entityType, sourceId: source.sourceId, body })
207
+ claimed.add(key)
208
+ continue
209
+ }
210
+
211
+ const entityId = candidateEntityId(name)
212
+ const key = `${type}:${entityId}`
213
+ if (claimed.has(key)) continue
214
+ claimed.add(key)
215
+ fresh.push({
216
+ kind: 'entity',
217
+ name,
218
+ type,
219
+ body,
220
+ aliases: [],
221
+ evidence: { sourceId: source.sourceId },
222
+ confidence: 0.7,
223
+ })
224
+ }
225
+
226
+ return { fresh, merges, warnings }
227
+ }