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/types.ts ADDED
@@ -0,0 +1,1140 @@
1
+ import { DIAGNOSIS_ENTITY_TYPES } from './diagnosis/schema.js'
2
+
3
+ export type KnowledgeStatus = 'unreviewed' | 'draft' | 'reviewed' | 'active' | 'deprecated' | 'problem'
4
+ /**
5
+ * 内置来源业务分类的 id(0013 注册表)。
6
+ *
7
+ * 注册表允许用户追加自定义分类,所以**运行时一律按 `string` 处理**
8
+ * (见 `SourceType`);这个字面量联合只用来标记哪些 id 是内置的。
9
+ */
10
+ export type BuiltinSourceType = 'chat_message' | 'ai_chat_message' | 'document' | 'book' | 'article' | 'prd' | 'question'
11
+ /** 来源业务分类 id。自定义分类是开放集合,所以不是字面量联合。 */
12
+ export type SourceType = string
13
+ /**
14
+ * 知识卡片的 entity_type 白名单。
15
+ *
16
+ * **智能归因的五类也在这里**,这不是放宽而是纠错:排查思路本来就应该是一张 wiki 卡片
17
+ * (0021 §6.4 定了五类实体与三段式卡片),但白名单里没有它们的时候,`writeCard` 会直接
18
+ * 返回 `SCHEMA_TYPE_INVALID`,`readCards` 也会跳过 —— 于是排查思路**永远进不了 wikiNotes,
19
+ * 也就永远召不回来**。列表本身在 `diagnosis/schema.ts` 里定义一次,这里只做联合。
20
+ */
21
+ export type SchemaType =
22
+ | 'concept' | 'metric' | 'table' | 'event' | 'playbook' | 'case'
23
+ | 'diagnosis/symptom' | 'diagnosis/hypothesis' | 'diagnosis/check'
24
+ | 'diagnosis/failure-mode' | 'diagnosis/case'
25
+ export type EmbeddingProvider = 'wemm-python' | 'ollama' | 'none'
26
+ export type EmbeddingDevice = 'auto' | 'mps' | 'cpu'
27
+ export type RerankProvider = 'bert-python' | 'none'
28
+ /** 真正跑起来的召回路线。`graph` 是 0021 加入的知识图谱路。 */
29
+ export type RecallRoute = 'fts' | 'vector' | 'graph'
30
+ /**
31
+ * 召回路线选择(0031 起语义有变)。
32
+ *
33
+ * `graph` 与 `hybrid` **同义**:知识图谱从「第三路」降成了 boost,它不能凭自己制造
34
+ * 候选,只给已经在候选池里的条目加权(见 `recallBoost`/`graphBoost`)。所以「只靠图谱」
35
+ * 的召回在设计上不成立,保留这个取值只为兼容已存的设置与调用方。
36
+ */
37
+ export type RecallMode = 'fts' | 'vector' | 'graph' | 'hybrid'
38
+ import { existsSync } from 'node:fs'
39
+ import { homedir } from 'node:os'
40
+ import { join, resolve } from 'node:path'
41
+ import type { DataError, Envelope as WireEnvelope } from 'dsh-data-contracts/envelope'
42
+ import type { LineageContext, LineageRef } from 'dsh-data-contracts/refs'
43
+
44
+ export interface KnowledgeError extends DataError {
45
+ code: string
46
+ message: string
47
+ retryable: boolean
48
+ suggestion?: string
49
+ }
50
+
51
+ /**
52
+ * This plugin's envelope: the shared five-key wire contract
53
+ * (`spec/contracts/envelope.json`) with the error narrowed to `KnowledgeError`,
54
+ * so `retryable` / `suggestion` stay visible to callers and the panel.
55
+ *
56
+ * The shape itself lives in `dsh-data-contracts` — the status rule
57
+ * "warnings make it partial" is defined once there, not re-spelled here.
58
+ */
59
+ export type Envelope<T> = WireEnvelope<T, KnowledgeError>
60
+
61
+ export interface KnowledgeConfig {
62
+ /**
63
+ * **Tita Agent 根路径** —— 用户唯一要设的那个路径(0029)。
64
+ *
65
+ * 根路径下固定四段:`domain_knowledge/`(知识树,按知识库 id 分叉)、
66
+ * `output/`(交付物,二级按日期)、`tmp/`(运行期临时文件,二级按日期)、
67
+ * `resource/`(模型、Python 环境、注册表)。
68
+ * 四段为什么这么分,见 `layout.ts` 顶部的四条原理。
69
+ */
70
+ agentRoot: string
71
+ /**
72
+ * @deprecated 0029:这是**旧布局**的知识树根(`01_source_truth` 直接躺在它
73
+ * 下面)。它不再可设置,也不再决定任何路径 —— 只作为一次性的迁移来源存在:
74
+ * 值非空且新位置还是空的时,把旧树搬进 `<agentRoot>/domain_knowledge/default`。
75
+ * 搬完(或本来就没有)它就不再有意义。
76
+ */
77
+ rootDir: string
78
+ schemaTypes: SchemaType[]
79
+ /**
80
+ * @deprecated 0013:来源分类改为**按知识库持久化的注册表**
81
+ * (`KnowledgeBase.sourceTypes` / `00_schema/source-types.json` 语义)。
82
+ * 这个字段只是历史 settings JSON 的兼容壳,既不参与 `addSource` 校验,
83
+ * 也不再暴露在设置面板里;判定与校验一律走注册表。
84
+ */
85
+ sourceAppendTypes: SourceType[]
86
+ allowSourceTruth: boolean
87
+ /**
88
+ * Pass 0(候选抽取 + 归一)的总开关。
89
+ *
90
+ * 默认**开**:它是「SourceRaw → Wiki」这条链上唯一不依赖模型的一步,关掉之后
91
+ * 知识库就只能靠 Agent 逐张 `write_knowledge_card` 长大。关掉的场景是真实存在的
92
+ * (批量导入时不想让每份文档都自动长出一张未审核卡片),所以它必须可关,
93
+ * 但关掉不等于删除:计数器会如实回 0,而不是假装抽过了。
94
+ */
95
+ extractCandidates: boolean
96
+ /**
97
+ * #3b:用模型做**实体抽取与归并**(`extract-model.ts`)。
98
+ *
99
+ * 开着时,`rebuild` 把「这份来源里有哪些实体、其中哪些 wiki 里已经有了」交给模型;
100
+ * 关系候选仍由规则抽取补上,模型不可用则整轮退回规则。所以它是**增强**而不是依赖 ——
101
+ * 关掉它链路照跑,只是同一实体横跨两份文档时可能落成两张卡(实测过)。
102
+ *
103
+ * 触发时机跟着来源走:只有**变化的来源**才进这一轮(增量复用没变的不重跑),
104
+ * 所以成本与「改了多少来源」成正比,而不是与库的大小成正比。
105
+ */
106
+ extractWithModel: boolean
107
+ /** Python 解释器。空串 = 自动(`<agentRoot>/resource/python/bin/python`,退回旧家当目录,再退回 `python3`)。 */
108
+ python: string
109
+ embeddingProvider: EmbeddingProvider
110
+ embeddingBaseUrl: string
111
+ /** 嵌入模型:一个本地目录或一个 HF 模型名。空串 = 自动(根路径下的本地模型,退回旧家当目录,再退回 HF 名)。 */
112
+ embeddingModel: string
113
+ embeddingDimension: number
114
+ embeddingBatchSize: number
115
+ embeddingTimeoutMs: number
116
+ embeddingDevice: EmbeddingDevice
117
+ /** Stage 3 recall: how many wikiNotes the multi-route recall may return. */
118
+ recallTopK: number
119
+ /**
120
+ * Stage 3 的**候选池**深度:融合之前每一路各自超采多少条(0031)。
121
+ *
122
+ * 它和 `recallTopK` 是两件事,0014 早就点明过:前者是「先粗召回多少」,
123
+ * 后者是「最终给模型多少」。两者共用一个数时,重排只能给已经截断的列表换顺序,
124
+ * 永远救不回被融合排到窗口外的那一条。默认 50(与 WeKnora 的 `EmbeddingTopK` 一致)。
125
+ */
126
+ recallCandidatePool: number
127
+ /** Stage 3 recall: default route when a caller does not pass `mode`. */
128
+ recallMode: RecallMode
129
+ /**
130
+ * 关键词路的最低分(归一化 BM25,0..1)。低于它不进候选池。
131
+ *
132
+ * 注意它是**相对**阈值(按本次查询的最高分归一),所以它挡得住「弱命中」,
133
+ * 挡不住「整批都弱」;真正的绝对闸门是 `rerankThreshold`。
134
+ */
135
+ keywordThreshold: number
136
+ /** 向量路的最低相似度(原始余弦,−1..1)。这是**绝对**阈值,可直接用于弃答。 */
137
+ vectorThreshold: number
138
+ /** Stage 4 rerank: how many reranked wikiNotes enter the model context. */
139
+ contextTopN: number
140
+ /**
141
+ * Stage 4 重排后的最低分(0..1,sigmoid 之后的 cross-encoder 分)。
142
+ *
143
+ * 它是整条链路里唯一**跨查询可比**的相关性判据,所以「弃答」主要靠它。
144
+ */
145
+ rerankThreshold: number
146
+ /** Stage 4 rerank: BERT cross-encoder provider, or `none` to skip reranking. */
147
+ rerankProvider: RerankProvider
148
+ /** 重排模型:同上。空串 = 自动。 */
149
+ rerankModel: string
150
+ rerankTimeoutMs: number
151
+ }
152
+
153
+ /**
154
+ * 旧的「家当」目录(0029 之前模型与虚拟环境住在这里)。
155
+ *
156
+ * 现在它们是**兜底**:路径解析优先看 `<agentRoot>/resource/`,找不到再回这里,
157
+ * 最后才退回裸的模型名(从 HuggingFace 拉)。这样搬完家当、或还没搬完家当,
158
+ * 行为都对;而 `TITA_DATA_AGENT_HOME` 仍然是覆盖它的入口。
159
+ */
160
+ export const legacyResourceHome = (): string => resolve(process.env.TITA_DATA_AGENT_HOME || join(homedir(), '.tita-data-agent'))
161
+ /** 本地模型目录名。与 `layout.ts` 的 `resourceModels()` 拼在一起就是主位置。 */
162
+ export const LOCAL_EMBEDDING_MODEL_DIR = 'WeMM-Embedding-2B-Quantized'
163
+ export const LOCAL_RERANK_MODEL_DIR = 'bge-reranker-base'
164
+ /** 裸模型名:本地没有时从 HuggingFace 拉。 */
165
+ export const FALLBACK_EMBEDDING_MODEL = 'ewin-reg/WeMM-Embedding-2B-Quantized'
166
+ export const FALLBACK_RERANK_MODEL = 'BAAI/bge-reranker-base'
167
+
168
+ /**
169
+ * 一个**知识库运行时**需要的那部分配置。
170
+ *
171
+ * 去掉的是两个**路径**字段:运行时是「一个知识库」的视角,它的根由构造参数单独
172
+ * 传入(`rootDir`),而 `agentRoot` 是整个部署的根路径 —— 运行时既不该拥有它,
173
+ * 也不该从它推自己的位置。两个字段留在 `KnowledgeConfig` 里,是因为设置面板要
174
+ * 编辑 `agentRoot`、迁移要读旧的 `rootDir`。
175
+ */
176
+ export type RuntimeKnowledgeConfig = Omit<KnowledgeConfig, 'rootDir' | 'agentRoot'>
177
+
178
+ /**
179
+ * 除两个路径字段以外的全部默认值。
180
+ *
181
+ * `agentRoot` / `rootDir` 由 `index.ts` 用 `layout.ts` 算出来再合进来 —— 它们是
182
+ * 环境与磁盘事实(桌面在哪、旧树在不在),不是这张常量表能自己回答的。
183
+ */
184
+ export const DEFAULT_KNOWLEDGE_CONFIG: RuntimeKnowledgeConfig = {
185
+ // 智能归因的五类在这里一并默认开启:没有它们,排查思路写不进 wiki、召不回来(见 SchemaType)。
186
+ schemaTypes: ['concept', 'metric', 'table', 'event', 'playbook', 'case', ...DIAGNOSIS_ENTITY_TYPES],
187
+ sourceAppendTypes: ['chat_message', 'ai_chat_message', 'document'],
188
+ allowSourceTruth: true,
189
+ // 0029 撤掉了 `maxSourceBytes`:单条来源**没有**字符数上限。见
190
+ // `runtime.ts` 的 `addSource` —— 真正的边界是转换内核的 `maxInputBytes`
191
+ // 与嵌入分批,不是存储层的一个配额。
192
+ // 只有显式写 `false` / `0` 才关;空值、其他任何写法都按默认「开」处理,
193
+ // 免得一个打错的 env 值把整条 Pass 0 悄悄关掉。
194
+ extractCandidates: !['false', '0'].includes((process.env.DSH_KNOWLEDGE_EXTRACT_CANDIDATES ?? '').trim().toLowerCase()),
195
+ // #3b:模型抽取默认**关**。它会真花钱、也依赖模型可用,所以由用户显式打开;
196
+ // 关着时链路与以前完全一致(纯规则 Pass 0)。
197
+ extractWithModel: ['true', '1'].includes((process.env.DSH_KNOWLEDGE_EXTRACT_WITH_MODEL ?? '').trim().toLowerCase()),
198
+ // 0029:三个「家当」路径的默认值是**空串 = 自动**。它们跟着 `<agentRoot>/resource/`
199
+ // 走,而 agentRoot 由设置决定、模块加载时还不知道 —— 所以在运行时解析
200
+ // (`runtime.ts` 的 `resolveResources`)。环境变量仍然优先。
201
+ python: process.env.DSH_PYTHON || '',
202
+ embeddingProvider: 'wemm-python',
203
+ embeddingBaseUrl: process.env.DSH_EMBEDDING_BASE_URL || 'http://127.0.0.1:11434',
204
+ embeddingModel: process.env.DSH_EMBEDDING_MODEL || process.env.DSH_EMBEDDING_MODEL_PATH || '',
205
+ embeddingDimension: Number(process.env.DSH_EMBEDDING_DIM || 1024),
206
+ embeddingBatchSize: 8,
207
+ embeddingTimeoutMs: 120000,
208
+ embeddingDevice: (process.env.DSH_EMBEDDING_DEVICE as 'auto' | 'mps' | 'cpu' | undefined) || 'auto',
209
+ recallTopK: Number(process.env.DSH_KNOWLEDGE_RECALL_TOP_K || 10),
210
+ recallCandidatePool: Number(process.env.DSH_KNOWLEDGE_CANDIDATE_POOL || 50),
211
+ recallMode: (process.env.DSH_KNOWLEDGE_RECALL_MODE as RecallMode | undefined) || 'hybrid',
212
+ // 阈值初值:关键词与重排沿用 WeKnora 的出厂值(0.3 / 0.2),**向量阈值必须改** ——
213
+ // 见下。
214
+ keywordThreshold: Number(process.env.DSH_KNOWLEDGE_KEYWORD_THRESHOLD || 0.3),
215
+ /**
216
+ * 向量阈值:**0.5,不是 WeKnora 的 0.15**。
217
+ *
218
+ * WeKnora 的 0.15 是给它自己那个 embedding 模型的;照搬到 WeMM 上,阈值就形同虚设 ——
219
+ * 实测(真实知识库,44 条 note,默认 WeMM):
220
+ * - 无关查询(`完全无关的词汇zzz` / `zzzzz`)的最高余弦:**0.42 / 0.32**
221
+ * - 相关查询(`怎么排查指标异常` / `DAU 突增的原因` / `日报 GMV 为 0` / `口径变更怎么处理` /
222
+ * `订单数下降`)的最高余弦:**0.60 / 0.70 / 0.83 / 0.67 / 0.74**
223
+ *
224
+ * 两簇之间有一条 0.42–0.59 的空带,0.5 落在带内。用 0.15 时无关查询会召回 43/44 条、
225
+ * `insufficientEvidence` 永远是 false —— 「弃答」就是一句空话(实测过)。
226
+ *
227
+ * 这个值**必须按模型与语料校准**:`diagnostics.routes[vector].topScore` 就是每次召回
228
+ * 的真实最高余弦,用它看自己的分布,而不是照抄别人的常数。
229
+ */
230
+ vectorThreshold: Number(process.env.DSH_KNOWLEDGE_VECTOR_THRESHOLD || 0.5),
231
+ contextTopN: Number(process.env.DSH_KNOWLEDGE_CONTEXT_TOP_N || 5),
232
+ rerankThreshold: Number(process.env.DSH_KNOWLEDGE_RERANK_THRESHOLD || 0.2),
233
+ rerankProvider: (process.env.DSH_RERANK_PROVIDER as RerankProvider | undefined) || 'bert-python',
234
+ rerankModel: process.env.DSH_RERANK_MODEL || '',
235
+ rerankTimeoutMs: Number(process.env.DSH_RERANK_TIMEOUT_MS || 60000),
236
+ }
237
+
238
+ /** 一条原始可追溯文档(SourceRaw)。磁盘布局沿用 `01_source_truth`。 */
239
+ export interface SourceRaw {
240
+ sourceId: string
241
+ sourceType: SourceType
242
+ title: string
243
+ content: string
244
+ externalId?: string
245
+ metadata: Record<string, unknown>
246
+ lineage?: LineageContext
247
+ createdAt: string
248
+ updatedAt: string
249
+ }
250
+
251
+ /**
252
+ * `query_source_truth` 读回来的一条来源:`SourceRaw` + **它在磁盘上的位置**。
253
+ *
254
+ * ## 为什么必须带 `path`
255
+ *
256
+ * 读取有配额(一次最多 `max_chars`,上限 40000 字符),而且配额是**跨多条来源共享**
257
+ * 的 —— 一份长文档很容易只回来一个开头。被截断时 `content` 是**不完整**的,而「不完整」
258
+ * 如果只能靠调用方拿长度自己猜,就会出最坏的那种错:以为读到了全文,其实只有前 4 万字符。
259
+ *
260
+ * 所以每条都给出绝对路径与全长:Agent 看到 `returnedChars < totalChars` 就知道该去读
261
+ * 文件原文(它本来就有读文件的能力),而不是基于半份文档下结论。这个路径是知识库里的
262
+ * **真实文件**,读它与读这里的内容是同一份数据。
263
+ */
264
+ export interface SourceRawRead extends SourceRaw {
265
+ /** 这份来源在磁盘上的绝对路径。 */
266
+ path: string
267
+ /** 本次实际返回的字符数。 */
268
+ returnedChars: number
269
+ /** 这份来源正文的完整字符数。`returnedChars < totalChars` 即被截断。 */
270
+ totalChars: number
271
+ }
272
+
273
+ export interface KnowledgeCard {
274
+ entityId: string
275
+ entityType: SchemaType
276
+ title: string
277
+ status: KnowledgeStatus
278
+ summary?: string
279
+ content: string
280
+ sourceRefs: string[]
281
+ provenance?: LineageRef[]
282
+ metadata: Record<string, unknown>
283
+ updatedAt: string
284
+ /** Resolved local markdown image references used by the multimodal embedder. */
285
+ imagePaths?: string[]
286
+ }
287
+
288
+ /**
289
+ * A compiled recall unit. Stage 2 turns every wiki card into one or more
290
+ * wikiNotes; stage 3 recalls wikiNotes (never the wiki directly) and resolves
291
+ * each hit back to its parent wiki.
292
+ */
293
+ export interface WikiNote {
294
+ /**
295
+ * Stable recall id. The first note of a wiki reuses `<entityType>:<entityId>`
296
+ * so historical qrels keep working; later notes append `#<n>`.
297
+ */
298
+ unitId: string
299
+ /** Alias of `unitId`, in wikiNotes vocabulary. */
300
+ noteId: string
301
+ entityId: string
302
+ /** The wiki card this note was compiled from (alias of `entityId`). */
303
+ wikiId: string
304
+ entityType: SchemaType
305
+ title: string
306
+ /** Section heading when a long wiki card was split into several notes. */
307
+ heading?: string
308
+ content: string
309
+ sourceRefs: string[]
310
+ sourceTypes: SourceType[]
311
+ provenance?: LineageRef[]
312
+ status: KnowledgeStatus
313
+ /** Local markdown image references are embedded with the text when possible. */
314
+ imagePaths?: string[]
315
+ }
316
+
317
+ /** Backwards-compatible alias: compiled retrieval units are now wikiNotes. */
318
+ export type KnowledgeUnit = WikiNote
319
+
320
+ /**
321
+ * One historical revision of a wiki card.
322
+ *
323
+ * Mirrors `DocumentVersion` on purpose: SourceRaw and wiki are the two content
324
+ * layers in this plugin, and a user who can roll a document back must be able to
325
+ * roll the card compiled from it back too. Snapshots live under
326
+ * `04_memory/card-versions/<entityType>/<entityId>/`, hold the *original card
327
+ * markdown bytes*, and are append-only — reverting snapshots the pre-revert head
328
+ * as well, so a rollback is itself reversible.
329
+ */
330
+ export interface WikiCardVersion {
331
+ versionId: string
332
+ createdAt: string
333
+ contentHash: string
334
+ bytes: number
335
+ title: string
336
+ status: string
337
+ label: string
338
+ isCurrent: boolean
339
+ }
340
+
341
+ export interface SearchHit extends WikiNote {
342
+ score: number
343
+ match: 'fts' | 'vector' | 'graph' | 'hybrid'
344
+ /**
345
+ * 这个命中在每一路里的**名次**(1 起,0031)。
346
+ *
347
+ * 融合改用 RRF 之后,名次是唯一进入融合的量 —— 所以它必须留痕:界面上的
348
+ * 「三路状态」、`match` 的推导、以及「为什么这条排第一」都读它。
349
+ * 缺失 = 那一路没有召回它。
350
+ */
351
+ ranks?: Partial<Record<RecallRoute, number>>
352
+ /** 该命中引用的 SourceRaw 证据片段(0031)。可追溯从卡片级下沉到段落级。 */
353
+ evidence?: RecallEvidence[]
354
+ }
355
+
356
+ /**
357
+ * 一条命中的**证据片段**:它引用的 SourceRaw 里,与查询最相关的那一段原文。
358
+ *
359
+ * 为什么要有它:`context` 原来只带 wiki 卡片,模型要核对原文还得自己再调一次工具。
360
+ * 把证据放进上下文,「每一个数字都能指回它的口径定义与原始文档」才在**上下文层面**
361
+ * 成立,而不是靠模型自觉。
362
+ */
363
+ export interface RecallEvidence {
364
+ sourceId: string
365
+ title?: string
366
+ /** 覆盖查询词最多的那一段原文(已按上限截断)。 */
367
+ excerpt: string
368
+ /** 该段在原文正文里的字符偏移,便于回跳。 */
369
+ offset: number
370
+ /** 这一段覆盖了查询词的比例(0..1)。 */
371
+ coverage: number
372
+ }
373
+
374
+ export interface RecallHit extends SearchHit {
375
+ /** Cross-encoder score after stage 4 rerank; absent when rerank is skipped. */
376
+ rerankScore?: number
377
+ }
378
+
379
+ export interface RerankResult {
380
+ status: 'ready' | 'unavailable' | 'skipped'
381
+ provider: RerankProvider
382
+ model?: string
383
+ scored?: number
384
+ error?: string
385
+ /** 这一轮生效的最低分(0031)。**原始 logit 刻度**,低于它的一律不进上下文。 */
386
+ threshold?: number
387
+ /** 被阈值丢掉的条数 —— 「弃答」能不能发生,看的就是它。 */
388
+ dropped?: number
389
+ /**
390
+ * 候选池里最高的 logit(0031)。
391
+ *
392
+ * 与 `dropped` 一起回答「这一批到底有多相关」:`dropped: 0` 有两种可能 ——
393
+ * 全都相关(topLogit 高),或者阈值根本没在工作(topLogit 贴近阈值)。
394
+ * 只报 `dropped` 会把这两种情况混成一个。
395
+ */
396
+ topLogit?: number
397
+ /**
398
+ * cross-encoder 分词器的词表大小(0031)。
399
+ *
400
+ * 存在的理由是**回归可见性**:词表退化成 5 个特殊 token 时,模型照样返回分数、
401
+ * 照样 `status: ready`,只是所有输入都变成 `<unk>`(实测三条无关文本 0.998–0.9994)。
402
+ * 现在 worker 会在加载时直接报 `RERANK_TOKENIZER_DEGENERATE`,这个字段则让
403
+ * 「真的加载了 250002 条词表」在面板上看得见。
404
+ */
405
+ vocabSize?: number
406
+ }
407
+
408
+ /**
409
+ * 一路在**这一轮**里的真实状态(0031)。
410
+ *
411
+ * 「降级不谎报」原来只体现在 `routes` 数组里,但 `routes` 只回答「跑没跑」,
412
+ * 回答不了「为什么没结果」。诊断把阈值、候选数、最高分一起给出,于是弃答
413
+ * (`insufficientEvidence`)不再是黑箱。
414
+ */
415
+ export interface RecallRouteDiagnostic {
416
+ route: RecallRoute
417
+ status: 'ran' | 'degraded' | 'skipped'
418
+ /** 过滤前该路超采到的候选数。 */
419
+ candidates?: number
420
+ /** 过阈值之后剩下的条数。 */
421
+ kept?: number
422
+ /** 该路的最高分(解释「为什么弃答」时最有用的一格)。 */
423
+ topScore?: number
424
+ threshold?: number
425
+ /** 降级 / 跳过的原因,直接给用户看。 */
426
+ reason?: string
427
+ }
428
+
429
+ export interface RecallDiagnostics {
430
+ routes: RecallRouteDiagnostic[]
431
+ fusion: { method: 'rrf'; k: number; weights: Partial<Record<RecallRoute, number>> }
432
+ /** 图谱 boost 命中了多少条、权重多少。 */
433
+ graphBoost: { applied: number; weight: number }
434
+ /**
435
+ * 弃答判据的强度,如实标注:
436
+ * - `rerank-threshold`:重排跑通且**真的丢了东西** —— 跨查询可比的绝对判据;
437
+ * - `vector-threshold`:向量余弦阈值丢的(原始余弦也是绝对量);
438
+ * - `relative-only`:只剩「按本次最高分归一」的相对阈值 —— 挡得住弱命中,挡不住
439
+ * 「整批都弱」;
440
+ * - `not-needed`:有结果且不需要判弃答。
441
+ */
442
+ abstention: 'rerank-threshold' | 'vector-threshold' | 'relative-only' | 'not-needed'
443
+ /** 向量索引与编码器的健康检查结论(0031 的 P0 门)。 */
444
+ encoder?: { ok: boolean; reason?: string }
445
+ /** 弃答时的一句话原因。 */
446
+ reason?: string
447
+ }
448
+
449
+ export interface ContextGraph {
450
+ nodes: Array<Record<string, unknown>>
451
+ edges: Array<Record<string, unknown>>
452
+ }
453
+
454
+ export interface ContextItem {
455
+ note: RecallHit
456
+ wiki: KnowledgeCard | null
457
+ /** 1-hop subgraph around the hit's wiki card. */
458
+ graph: ContextGraph
459
+ /**
460
+ * 这条命中引用的 SourceRaw 证据片段(0031)。
461
+ *
462
+ * 与 `note.evidence` 同源,放在这里是为了让「上下文包」自包含:读到 `context`
463
+ * 就拿到了「片段(为什么召回)→ 卡片(结论)→ 原文(证据)」三层。
464
+ */
465
+ evidence?: RecallEvidence[]
466
+ }
467
+
468
+ export interface RecallResult {
469
+ /** Stage 3: 融合(+ 重排)之后的 wikiNotes,最多 `recallTopK`。 */
470
+ hits: RecallHit[]
471
+ /** Stage 4: the `contextTopN` notes expanded to their wiki + 1-hop graph. */
472
+ context: ContextItem[]
473
+ /** Distinct wiki cards hit by the recalled notes. */
474
+ wiki: KnowledgeCard[]
475
+ /** Union of the 1-hop subgraphs of the recalled wiki cards. */
476
+ graph: ContextGraph
477
+ backend: string
478
+ routes: RecallRoute[]
479
+ /** Route actually used (caller override or the configured default). */
480
+ recallMode: RecallMode
481
+ rerank: RerankResult
482
+ recallTopK: number
483
+ /** 融合之前每一路的超采深度(0031)。 */
484
+ candidatePool: number
485
+ contextTopN: number
486
+ /**
487
+ * 一路都没过阈值 = 这个知识库里没有能回答这个问题的东西(0031)。
488
+ *
489
+ * 它是产品诚实性的一部分:与其凑满 5 条噪声让模型编,不如明确说「没有」。
490
+ * 消费方(Agent 工具 / 面板)看到 true 就该如实转述,而不是把 `hits: []`
491
+ * 当成一次失败。
492
+ */
493
+ insufficientEvidence: boolean
494
+ /** 每一路在这一轮里发生了什么(阈值、候选数、最高分、降级原因)。 */
495
+ diagnostics: RecallDiagnostics
496
+ recallId: string
497
+ indexVersion?: number
498
+ compileRunId?: string
499
+ lineage?: LineageContext
500
+ }
501
+
502
+ // ---------------------------------------------------------------- 多知识库(0021)
503
+
504
+ /** 一个知识库 = 独立配置 + 独立内容根目录。 */
505
+ export interface KnowledgeBase {
506
+ id: string
507
+ name: string
508
+ description: string
509
+ rootDir: string
510
+ isDefault: boolean
511
+ /**
512
+ * 来源业务分类注册表(0013),与 `wikiTypes` 平级、同样按库持久化。
513
+ * `fileCount` / `sourceCount` 这类计算字段由 refresh 实时算出。
514
+ */
515
+ sourceTypes: SourceTypeDef[]
516
+ /**
517
+ * Wiki 卡片类型注册表(0024):决定"SourceRaw → Wiki"这一段能生成哪些类型的卡片、
518
+ * 每类卡片必须有哪些小节与字段。与 `sourceTypes` 同处持久化,理由见 0013 §7.1。
519
+ */
520
+ wikiTypes: WikiTypeDef[]
521
+ /**
522
+ * 卡片 schema 的生效方式:`report` 只把问题写进卡片并回报,`strict` 直接拒绝不合规的写入。
523
+ * 默认 `report`,避免存量卡片在升级后被挡在门外。
524
+ */
525
+ wikiSchemaMode: WikiSchemaMode
526
+ /**
527
+ * 计算字段:当前 `wikiTypes` 是否已偏离出厂定义。界面据此决定「恢复默认」是否可点,
528
+ * 也让人一眼看出这套 schema 是本库自己调过的。
529
+ */
530
+ wikiTypesCustomized: boolean
531
+ createdAt: string
532
+ updatedAt: string
533
+ sourceCount: number
534
+ cardCount: number
535
+ noteCount: number
536
+ buildVersion: number
537
+ }
538
+
539
+ /** 文档列表项(SourceRaw 的对外投影,不含正文)。 */
540
+ export interface KnowledgeDocument {
541
+ /** = SourceRaw.sourceId */
542
+ docId: string
543
+ title: string
544
+ sourceType: string
545
+ /**
546
+ * `sourceType` 是否仍在当前知识库的注册表里。
547
+ *
548
+ * 0013 之前 `loadSources()` 会把未知分类**静默丢弃**,文档凭空消失;
549
+ * 现在一律保留并把这个标记交给前端渲染成「未知分类」徽标。
550
+ */
551
+ sourceTypeKnown: boolean
552
+ sourceFormat: string
553
+ parser: string
554
+ externalId: string
555
+ contentHash: string
556
+ bytes: number
557
+ assetCount: number
558
+ createdAt: string
559
+ updatedAt: string
560
+ versionCount: number
561
+ /** 相对知识库根目录,如 `01_source_truth/<slug>.md`。 */
562
+ relativePath: string
563
+ ext: string
564
+ }
565
+
566
+ /** 详情页要展示的资产引用;`url` 直接指向 `/knowledge/assets/<kbId>/<rel>`。 */
567
+ export interface KnowledgeAssetRef {
568
+ rel: string
569
+ bytes: number
570
+ url: string
571
+ }
572
+
573
+ export interface KnowledgeDocumentDetail extends KnowledgeDocument {
574
+ content: string
575
+ frontmatter: Record<string, unknown>
576
+ assets: KnowledgeAssetRef[]
577
+ lineage?: Record<string, string>
578
+ }
579
+
580
+ /** 文档版本:`isCurrent` 的那条是当前 head,其余是历史快照。 */
581
+ export interface DocumentVersion {
582
+ versionId: string
583
+ createdAt: string
584
+ contentHash: string
585
+ bytes: number
586
+ sourceFormat: string
587
+ label: string
588
+ isCurrent: boolean
589
+ }
590
+
591
+ /** 默认「文件类型 → 解析引擎」规则的内容,不含 `fileCount`。 */
592
+ /**
593
+ * 从 SourceRaw 的 `metadata` 推导文档扩展名。
594
+ *
595
+ * **这个推导是有损的。** SourceRaw 只保存正文与 metadata,并不保存原始文件名;
596
+ * 因此只有上游(`src/convert/ingest.ts`)显式写了 `metadata.source_path` 时才能拿到
597
+ * 真实扩展名。回退链是启发式的:
598
+ *
599
+ * 1. `metadata.source_path` 的 basename 扩展名(最可信);
600
+ * 2. `metadata.source_format === 'markdown'` → `.md`;
601
+ * 3. `metadata.source_format === 'office'` → `.docx`(office 是容器格式的粗粒度标记,
602
+ * 真实的 .doc/.xls/.ppt 会被误报为 `.docx`);
603
+ * 4. 其余 `source_format` → `.` + source_format(当它本身就是一个扩展名时才对);
604
+ * 5. 都没有 → `.md`(知识库里最常见的形态)。
605
+ *
606
+ * 因此 `fileCount` 与按扩展名过滤都只是近似值;要精确统计需要在入库时写 `source_path`。
607
+ */
608
+ export function deriveSourceExt(metadata: Record<string, unknown> | undefined): string {
609
+ const sourcePath = metadata && typeof metadata.source_path === 'string' ? metadata.source_path.trim() : ''
610
+ if (sourcePath) {
611
+ const base = sourcePath.split(/[\\/]/).pop() ?? ''
612
+ const dot = base.lastIndexOf('.')
613
+ if (dot > 0 && dot < base.length - 1) return base.slice(dot).toLowerCase()
614
+ }
615
+ const format = metadata && typeof metadata.source_format === 'string' ? metadata.source_format.trim().toLowerCase() : ''
616
+ if (format === 'markdown') return '.md'
617
+ if (format === 'office') return '.docx'
618
+ if (format) return `.${format}`
619
+ return '.md'
620
+ }
621
+
622
+ export interface RebuildReport {
623
+ runId: string
624
+ version: number
625
+ only: string
626
+ cards: number
627
+ units: number
628
+ /** Compiled wikiNotes count (stage 2 output). */
629
+ notes: number
630
+ graphNodes: number
631
+ graphEdges: number
632
+ embedding: 'ready' | 'unavailable' | 'skipped'
633
+ embeddingItems?: number
634
+ embeddingReused?: number
635
+ embeddingDimension?: number
636
+ vectorQuantization?: 'binary+hamming+int8'
637
+ embeddingError?: string
638
+ /**
639
+ * 关键词索引(FTS5)的构建结论(0031)。
640
+ *
641
+ * 与 `embedding` 并列而不是塞进它:两者可以一个成一个败(编码器坏了但关键词索引照样
642
+ * 能建),而「哪一路现在是活的」必须一眼看得出。
643
+ */
644
+ fts?: 'ready' | 'unavailable'
645
+ lineage?: LineageContext
646
+ compileRunId?: string
647
+ startedAt: string
648
+ finishedAt: string
649
+ // ------------------------------------------------ 增量编译(0025 §增量更新)
650
+ /**
651
+ * Stage-2 incrementality. `compileNotes` is deterministic, so recompiling is
652
+ * cheap — what is *not* cheap is invalidating a stable corpus: a rebuild that
653
+ * renumbers notes forces a full re-embedding and breaks existing qrels.
654
+ * These counters make the reuse visible instead of implicit.
655
+ */
656
+ cardsReused?: number
657
+ cardsRecompiled?: number
658
+ cardsAdded?: number
659
+ cardsRemoved?: number
660
+ notesReused?: number
661
+ notesRecompiled?: number
662
+ /** Nothing changed: knowledge artifacts were left byte-identical. */
663
+ incremental?: boolean
664
+ /** The run was a no-op (a `wiki`-only rebuild over an unchanged corpus). */
665
+ skipped?: boolean
666
+ // ------------------------------------------------ Pass 0 · 候选抽取 + 归一
667
+ /**
668
+ * 「SourceRaw → Wiki」第一段的诚实计数。
669
+ *
670
+ * 这八个数**每一个都来自真实发生过的事件**,没有一个是估算出来的:抽取了几个
671
+ * 候选、其中几个被类型闸门拒了、建了几张卡、并进几张卡、几条关系真的连上了、
672
+ * 几条因为端点不存在被丢掉、几份来源这次被重抽、几份因为指纹没变被跳过。
673
+ * `extractCandidates` 关掉时它们全部回 0,而不是从响应里消失 —— 「跳过」和
674
+ * 「没有这个能力」是两件事,用缺字段表达会让人分不清。
675
+ */
676
+ candidatesExtracted: number
677
+ candidatesRejected: number
678
+ cardsCreated: number
679
+ cardsMerged: number
680
+ relationsExtracted: number
681
+ relationsDropped: number
682
+ sourcesExtracted: number
683
+ sourcesSkipped: number
684
+ }
685
+
686
+ // ------------------------------------------------- 来源业务分类注册表(0013)
687
+
688
+ /**
689
+ * 一条来源业务分类。
690
+ *
691
+ * 设计原则:`source_type` 描述**业务场景**,不描述载体格式;载体走
692
+ * `source_format` / `parser`(见 `deriveSourceExt`)。三个「判定档位」是刻意
693
+ * 分开的,UI 与 Agent 都按这个契约消费:
694
+ *
695
+ * - 必填且不可动(`fallback`):兜底分类必须存在且始终启用;
696
+ * - 内置可调(`builtin && !fallback`):可改文案、可停用,但不可删除、不可改 id;
697
+ * - 自定义(`!builtin`):可增可删,但新建时必须提交完整判定文案。
698
+ */
699
+ export interface SourceTypeDef {
700
+ /** 稳定 id,`^[a-z][a-z0-9_]{1,40}$`,创建后不可修改。 */
701
+ id: string
702
+ label: string
703
+ /** 一句话定义,同时是 Agent 的判定依据。 */
704
+ description: string
705
+ /** 典型信号,至少 2 条;与 `description` 一起拼进判定提示词。 */
706
+ signals: string[]
707
+ /** 明确排除项,至少 1 条,每条必须指向一个已存在的分类。 */
708
+ excludes: string[]
709
+ /** 停用后不再出现在选择器与判定提示词里,历史引用仍能解析。 */
710
+ enabled: boolean
711
+ /** 内置分类只允许停用,不允许删除。 */
712
+ builtin: boolean
713
+ /** 兜底分类(`document`):必须存在且不可停用,判定链最后一步落在它上。 */
714
+ fallback: boolean
715
+ /** 计算字段:该库中引用该分类的来源数,由 `refresh` 实时算出,不参与持久化。 */
716
+ sourceCount: number
717
+ }
718
+
719
+ /** 分类 id 的形状约束;与 0013 规范第 1 节一致。 */
720
+ export const SOURCE_TYPE_ID_PATTERN = /^[a-z][a-z0-9_]{1,40}$/
721
+ /** 兜底分类 id;判定链全不命中时落它,因此它不可停用、不可删除。 */
722
+ export const FALLBACK_SOURCE_TYPE = 'document'
723
+ /** 单库自定义分类数量上限,防止注册表无界膨胀。 */
724
+ export const MAX_SOURCE_TYPES = 50
725
+
726
+ /**
727
+ * 内置分类的出厂默认值(0013 规范第 7 节的 `source-types.json` 内容)。
728
+ *
729
+ * 这些值**可以改**(label / description / signals / excludes / enabled),
730
+ * 但条目本身不可删除——`normalizeSourceTypes()` 会把缺失的内置条目补回来。
731
+ */
732
+ export const BUILTIN_SOURCE_TYPES: ReadonlyArray<Omit<SourceTypeDef, 'sourceCount'>> = [
733
+ {
734
+ id: 'chat_message',
735
+ label: '聊天日志',
736
+ description: '人与人之间在日常沟通中产生的消息记录,以对话往返为主要形态。',
737
+ signals: ['有发言人与时间戳的短消息流', '多话题跳转、口语化、缩写多', '常见 @提及、表情、附件'],
738
+ excludes: ['与 AI 的对话归 ai_chat_message', '整理后的会议纪要归 document', '已成问题—根因—方案的记录归 question'],
739
+ enabled: true,
740
+ builtin: true,
741
+ fallback: false,
742
+ },
743
+ {
744
+ id: 'ai_chat_message',
745
+ label: 'AI 对话日志',
746
+ description: '用户与 AI 助手之间的完整对话记录,包含双方往返消息。',
747
+ signals: ['user / assistant 角色交替', '提示词与回答成对出现', '代码块密集、常与会话 id 或模型名同现'],
748
+ excludes: ['人在群里转贴 AI 回答归 chat_message', '由 AI 对话整理出的方法论文档归 document 或 article'],
749
+ enabled: true,
750
+ builtin: true,
751
+ fallback: false,
752
+ },
753
+ {
754
+ id: 'document',
755
+ label: '企业内部文档',
756
+ description: '组织内部产生、用于内部协作与沉淀的文档;也是全不命中时的兜底分类。',
757
+ signals: ['内部作者、团队或编号、密级', '提到内部系统与组织名', '结构完整但非出版形态'],
758
+ excludes: ['面向功能与验收的需求文档归 prd', '以排查具体问题为中心归 question', '组织外部产出归 article', '正式出版物归 book'],
759
+ enabled: true,
760
+ builtin: true,
761
+ fallback: true,
762
+ },
763
+ {
764
+ id: 'book',
765
+ label: '书籍',
766
+ description: '正式出版或成体系的整书内容。',
767
+ signals: ['ISBN、出版社、版次或丛书名', '目录、章节、页码、前言、参考文献齐备', '按章拆分为多份录入'],
768
+ excludes: ['单篇论文、博客、报告归 article', '内部系统性手册归 document'],
769
+ enabled: true,
770
+ builtin: true,
771
+ fallback: false,
772
+ },
773
+ {
774
+ id: 'article',
775
+ label: '业界文章',
776
+ description: '来自组织外部或对外发布的单篇文献。',
777
+ signals: ['外链 URL、外部作者或媒体署名、发布时间', '面向公开读者', '带原文链接或转载字样'],
778
+ excludes: ['正式出版物整书归 book', '内部未对外发布的资料归 document', '排查本组织具体问题归 question'],
779
+ enabled: true,
780
+ builtin: true,
781
+ fallback: false,
782
+ },
783
+ {
784
+ id: 'prd',
785
+ label: '产品需求文档',
786
+ description: '描述要做什么产品功能、为谁做、为什么做、怎么算做完的需求文档。',
787
+ signals: ['需求背景、目标用户、用户故事', '功能列表、验收标准、上线计划、优先级', '版本号与评审状态'],
788
+ excludes: ['技术实现方案归 document', '缺陷与线上问题排查归 question', '产品使用说明归 document 或 article'],
789
+ enabled: true,
790
+ builtin: true,
791
+ fallback: false,
792
+ },
793
+ {
794
+ id: 'question',
795
+ label: '问题排查文档',
796
+ description: '以某个具体问题或故障为中心的记录:现象、影响、排查过程、根因、解决方案与后续动作。',
797
+ signals: ['错误码、堆栈或日志片段', '复现步骤、临时规避、根因、修复版本', '时间线、一问一答或一问题多方案'],
798
+ excludes: ['一般性经验总结归 document', '产品需求归 prd', '外部作者的公开分析归 article'],
799
+ enabled: true,
800
+ builtin: true,
801
+ fallback: false,
802
+ },
803
+ /**
804
+ * 以下四类是**数据域**的分类(0030)。
805
+ *
806
+ * 前七类描述的是「文档长什么样」,而数据 Agent 的日常是另一套东西:表结构、
807
+ * 指标口径、跑批脚本、一次取数的问答。它们混进 `document` 的后果最实际 ——
808
+ * 「销售额怎么算」要靠 `metric` 的公式,而不是一篇内部文档的正文;没有这一层,
809
+ * 召回就只能靠词面。
810
+ *
811
+ * 四类的边界是**它们各自回答的问题**:
812
+ * - `table`:这份数据存在哪、长什么样(字段 / 类型 / 分区 / 更新频率)。
813
+ * - `metric`:这个数字怎么算(口径、公式、维度、粒度)。
814
+ * - `data_dev_script`:这张表/这个指标是怎么被生产出来的(SQL / 调度 / 依赖)。
815
+ * - `query_case`:有人问过什么问题、当时怎么取、结果是多少(可复用的问答)。
816
+ *
817
+ * `table` 与 `data_dev_script` 最容易混:判据是**主语**——在建表和描述字段的是
818
+ * `table`,在加工数据、产出某张表的是脚本。`metric` 与 `query_case` 同理:定义
819
+ * 口径的是前者,记录“这一问是怎么答的”是后者。
820
+ */
821
+ {
822
+ id: 'table',
823
+ label: '表',
824
+ description: '一张表或一个数据集的元数据:字段清单、类型、分区、主键、更新频率与负责人。',
825
+ signals: ['CREATE TABLE / DDL,或字段名与类型成列成行', '分区字段、主键、存储格式、生命周期', '常带库名.表名与上下游说明'],
826
+ excludes: ['加工或产出这张表的 SQL / 调度归 data_dev_script', '指标口径与计算公式归 metric', '针对这张表的一次取数问答归 query_case'],
827
+ enabled: true,
828
+ builtin: true,
829
+ fallback: false,
830
+ },
831
+ {
832
+ id: 'metric',
833
+ label: '指标',
834
+ description: '一个可计算、可归属的量化口径:它是什么、怎么算、按什么维度和粒度统计、谁负责。',
835
+ signals: ['指标名 + 口径定义 + 计算公式', '统计周期、维度、粒度、负责人、目标值', '常与同比 / 环比、分层口径、口径变更历史同现'],
836
+ excludes: ['底层表的字段清单归 table', '指标异常的线上排查过程归 question', '一次具体的取数问答归 query_case'],
837
+ enabled: true,
838
+ builtin: true,
839
+ fallback: false,
840
+ },
841
+ {
842
+ id: 'data_dev_script',
843
+ label: '数据开发脚本',
844
+ description: '生产数据用的加工逻辑:SQL、PySpark、Shell 或调度定义,说明它读什么、写什么、什么时候跑。',
845
+ signals: ['SELECT … FROM / INSERT OVERWRITE / DAG 定义等可执行代码块', '调度周期、依赖、参数模板、临时表', '注明产出表名与跑批窗口'],
846
+ excludes: ['在建表并描述字段的是 table', '定义指标口径的是 metric', '只讲怎么取一次数的是 query_case'],
847
+ enabled: true,
848
+ builtin: true,
849
+ fallback: false,
850
+ },
851
+ {
852
+ id: 'query_case',
853
+ label: '数据查询案例',
854
+ description: '一次取数问答的完整记录:被问的是什么、口径怎么理解、查了哪张表、用了什么 SQL、结果对不对。',
855
+ signals: ['「问题 → 口径 → SQL → 结果」的成对结构', '带样例输出、校验数字或与预期不符的说明', '常被当作同类问题的复用模板'],
856
+ excludes: ['指标本身的定义与公式归 metric', '线上指标异常的排查记录归 question', '底层表结构归 table'],
857
+ enabled: true,
858
+ builtin: true,
859
+ fallback: false,
860
+ },
861
+ ]
862
+
863
+ const BUILTIN_SOURCE_TYPE_IDS: ReadonlySet<string> = new Set(BUILTIN_SOURCE_TYPES.map(def => def.id))
864
+
865
+ export function isBuiltinSourceType(id: string): boolean {
866
+ return BUILTIN_SOURCE_TYPE_IDS.has(id)
867
+ }
868
+
869
+ /** 出厂默认注册表(全部启用,`sourceCount` 待算)。 */
870
+ export function defaultSourceTypes(): SourceTypeDef[] {
871
+ return BUILTIN_SOURCE_TYPES.map(def => ({
872
+ ...def,
873
+ signals: [...def.signals],
874
+ excludes: [...def.excludes],
875
+ sourceCount: 0,
876
+ }))
877
+ }
878
+
879
+ /**
880
+ * 内置只读判定提示词。
881
+ *
882
+ * **它不允许被用户修改**——注册表里没有任何字段能覆盖它;但它必须**被暴露
883
+ * 出来**:`list_source_types` 工具、`GET /knowledge/source-types` 与设置面板的
884
+ * 「判定规则(内置,只读)」都原样返回它,让用户看得见 Agent 到底按什么判。
885
+ * 用户能写的只有每个分类自己的 `description` / `signals` / `excludes`。
886
+ */
887
+ export interface SourceClassifierStep {
888
+ /** 该步落到哪个分类;`user` 表示"用户显式指定"这个非分类入口。 */
889
+ type: string
890
+ rule: string
891
+ }
892
+
893
+ export interface SourceClassifierPrompt {
894
+ readonly: true
895
+ version: number
896
+ title: string
897
+ /** 从最特征化走到最泛化;全不命中落 `fallback.type`。 */
898
+ priority: SourceClassifierStep[]
899
+ confidenceRule: string
900
+ fallback: { type: string; note: string }
901
+ escalation: { code: string; note: string }
902
+ /** 自定义分类由用户撰写的判定文案如何参与判定。 */
903
+ extensionRule: string
904
+ }
905
+
906
+ /**
907
+ * 深冻结:把「内置、不允许修改」从口头约定变成运行时可验证的事实。
908
+ *
909
+ * 冻结失败不会让进程崩溃——ESM 是严格模式,任何写入尝试会抛 `TypeError`,
910
+ * 于是"有人偷偷改提示词"会在第一次写入时就炸掉,而不是静默漂移。
911
+ */
912
+ function deepFreeze<T>(value: T): T {
913
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
914
+ Object.freeze(value)
915
+ for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested)
916
+ }
917
+ return value
918
+ }
919
+
920
+ export const SOURCE_CLASSIFIER_PROMPT: SourceClassifierPrompt = deepFreeze({
921
+ readonly: true,
922
+ version: 2,
923
+ title: '来源业务分类判定规则',
924
+ priority: [
925
+ { type: 'user', rule: '用户显式指定分类 → 直接用,记 source_type_source: user。' },
926
+ { type: 'ai_chat_message', rule: '内容是人与 AI 的对话记录(含 AI 回复)→ ai_chat_message。' },
927
+ { type: 'chat_message', rule: '内容是人与人之间的消息流(IM / 会议聊天 / 客服会话)→ chat_message。' },
928
+ // 数据域四类排在通用文档之前:它们的形状(DDL、公式、SQL、问答)比
929
+ // 「像不像一篇内部文档」更特征化,落到 document 之后再想捞回来就晚了。
930
+ // 四类之间的次序按「定义的确定性」排:口径与表结构有明确文本特征,脚本次之,
931
+ // 取数问答最接近泛化的对话记录。
932
+ { type: 'metric', rule: '内容在定义一个可计算的量化口径(指标名 + 公式 + 维度 / 粒度 / 负责人)→ metric。' },
933
+ { type: 'table', rule: '内容在描述一张表或数据集本身(字段清单、类型、分区、主键、更新频率)→ table。' },
934
+ { type: 'data_dev_script', rule: '内容是生产数据的加工逻辑(SQL / PySpark / Shell / 调度定义,读什么写什么)→ data_dev_script。' },
935
+ { type: 'question', rule: '内容以某个具体问题或故障为中心(现象 → 排查 → 根因 → 措施)→ question。' },
936
+ { type: 'query_case', rule: '内容是一次取数问答的完整记录(问题 → 口径 → SQL → 结果)→ query_case。' },
937
+ { type: 'prd', rule: '内容面向「要做什么功能、给谁用、怎么算做完」→ prd。' },
938
+ { type: 'book', rule: '内容是正式出版物或成体系的整书 → book。' },
939
+ { type: 'article', rule: '内容是组织外部产出或对外发布的单篇文献 → article。' },
940
+ { type: 'document', rule: '以上都不是的企业内部协作文档 → document。' },
941
+ ],
942
+ confidenceRule: '每一步都要求高置信才落定;同时命中两类且无法用优先级化解时不得自行决断,也不允许静默落 document。',
943
+ fallback: { type: FALLBACK_SOURCE_TYPE, note: '全不命中时的唯一兜底分类;该分类必须存在且不可停用。' },
944
+ escalation: {
945
+ code: 'SOURCE_TYPE_REQUIRED',
946
+ note: '返回 SOURCE_TYPE_REQUIRED 并附 2–3 个候选(含理由)交给用户在录入向导里选;用户也判不了时要求其显式确认一个暂定分类,并标记 needs_review。',
947
+ },
948
+ extensionRule: '自定义分类的 description / signals / excludes 由用户撰写并拼进本提示词参与判定:description 必须写清「是什么」,signals 至少 2 条,excludes 至少 1 条且必须指向一个已存在的分类。',
949
+ })
950
+
951
+ /** 判定提示词 + 每个分类当前是否启用;停用的分类标注出来而不是从提示词里删掉。 */
952
+ export interface SourceClassifierPromptView extends SourceClassifierPrompt {
953
+ steps: ReadonlyArray<Readonly<SourceClassifierStep & { label: string; enabled: boolean; exists: boolean }>>
954
+ }
955
+
956
+ /**
957
+ * 展开成"每步带 label / enabled / exists"的视图。
958
+ *
959
+ * `priority` 直接复用被冻结的原数组(不复制),所以视图也无法被就地改写;
960
+ * 每次调用只新建 `steps`,并把它一并冻结。注册表怎么改,提示词骨架都不变。
961
+ */
962
+ export function classifierPromptView(defs: SourceTypeDef[]): SourceClassifierPromptView {
963
+ const byId = new Map(defs.map(def => [def.id, def]))
964
+ const steps = SOURCE_CLASSIFIER_PROMPT.priority.map(step => {
965
+ const def = byId.get(step.type)
966
+ return Object.freeze({ ...step, label: def?.label ?? step.type, enabled: def?.enabled ?? false, exists: def !== undefined })
967
+ })
968
+ return Object.freeze({ ...SOURCE_CLASSIFIER_PROMPT, steps: Object.freeze(steps) })
969
+ }
970
+
971
+ /** 一个知识库的完整分类目录:注册表 + 内置只读提示词。 */
972
+ export interface SourceTypeCatalog {
973
+ types: SourceTypeDef[]
974
+ classifierPrompt: SourceClassifierPromptView
975
+ fallbackTypeId: string
976
+ builtinIds: string[]
977
+ }
978
+
979
+ export function buildSourceTypeCatalog(defs: SourceTypeDef[]): SourceTypeCatalog {
980
+ return {
981
+ types: defs,
982
+ classifierPrompt: classifierPromptView(defs),
983
+ fallbackTypeId: FALLBACK_SOURCE_TYPE,
984
+ builtinIds: BUILTIN_SOURCE_TYPES.map(def => def.id),
985
+ }
986
+ }
987
+
988
+ /** 新建 / 编辑分类时提交的草稿。 */
989
+ export interface SourceTypeDraft {
990
+ id?: string
991
+ label?: string
992
+ description?: string
993
+ signals?: unknown
994
+ excludes?: unknown
995
+ enabled?: boolean
996
+ }
997
+
998
+ export interface SourceTypeDraftProblem {
999
+ ok: false
1000
+ code: string
1001
+ message: string
1002
+ suggestion?: string
1003
+ /** 出错的字段,供表单高亮。 */
1004
+ field: 'id' | 'label' | 'description' | 'signals' | 'excludes' | 'enabled'
1005
+ }
1006
+
1007
+ const EXTENSION_HINT = /\.(pdf|docx?|xlsx?|pptx?|md|markdown|txt|csv|json|tsv|html?|png|jpe?g|gif|webp|zip)\b/i
1008
+
1009
+ /**
1010
+ * 校验一份自定义分类草稿。
1011
+ *
1012
+ * 这是在守「用户追加新类型时,必须用文字标明怎么判断」这条要求:光给个名字
1013
+ * 是不允许入库的——入库的 `description` 会直接变成 Agent 的判定依据。
1014
+ */
1015
+ export function validateSourceTypeDraft(draft: SourceTypeDraft, knownIds: ReadonlySet<string>): { ok: true } | SourceTypeDraftProblem {
1016
+ const id = typeof draft.id === 'string' ? draft.id.trim() : ''
1017
+ if (!SOURCE_TYPE_ID_PATTERN.test(id)) {
1018
+ return { ok: false, code: 'SOURCE_TYPE_ID_INVALID', field: 'id', message: '分类 id 必须是小写字母开头的 2–41 位小写字母 / 数字 / 下划线', suggestion: '例如 test_unit、design_review;id 创建后不可修改。' }
1019
+ }
1020
+ if (isBuiltinSourceType(id)) {
1021
+ return { ok: false, code: 'SOURCE_TYPE_BUILTIN_LOCKED', field: 'id', message: `内置分类 id 不可占用或修改: ${id}`, suggestion: '内置分类只能改文案或停用;新场景请另起 id。' }
1022
+ }
1023
+ return validateSourceTypeText(draft, knownIds)
1024
+ }
1025
+
1026
+ /**
1027
+ * 只校验判定文案(label / description / signals / excludes),不碰 id。
1028
+ *
1029
+ * 内置分类改文案走这里:id 锁定,但「必须写得能判」的标准一样严。
1030
+ */
1031
+ export function validateSourceTypeText(draft: SourceTypeDraft, knownIds: ReadonlySet<string>): { ok: true } | SourceTypeDraftProblem {
1032
+ const label = typeof draft.label === 'string' ? draft.label.trim() : ''
1033
+ if (!label) return { ok: false, code: 'SOURCE_TYPE_LABEL_REQUIRED', field: 'label', message: 'label 不能为空' }
1034
+ const description = typeof draft.description === 'string' ? draft.description.trim() : ''
1035
+ if (description.length < 8) {
1036
+ return { ok: false, code: 'SOURCE_TYPE_DESCRIPTION_REQUIRED', field: 'description', message: 'description 必填,且要写清这个分类「是什么」', suggestion: '一句话定义,至少 8 个字;它是 Agent 判定该分类的主要依据。' }
1037
+ }
1038
+ if (EXTENSION_HINT.test(description)) {
1039
+ return { ok: false, code: 'SOURCE_TYPE_DESCRIPTION_BY_FORMAT', field: 'description', message: 'description 描述的是业务场景,不能写成载体 / 文件格式', suggestion: '例如不要写「包含 .xlsx 和 .csv 文件」,要写「用于跟踪测试用例执行结果的记录」。载体请走 source_format。' }
1040
+ }
1041
+ const signals = Array.isArray(draft.signals) ? draft.signals.map(item => String(item).trim()).filter(Boolean) : []
1042
+ if (signals.length < 2) {
1043
+ return { ok: false, code: 'SOURCE_TYPE_SIGNALS_REQUIRED', field: 'signals', message: 'signals 至少 2 条', suggestion: '写清「看到什么就说明是这一类」,每条一个特征。' }
1044
+ }
1045
+ const excludes = Array.isArray(draft.excludes) ? draft.excludes.map(item => String(item).trim()).filter(Boolean) : []
1046
+ if (excludes.length < 1) {
1047
+ return { ok: false, code: 'SOURCE_TYPE_EXCLUDES_REQUIRED', field: 'excludes', message: 'excludes 至少 1 条', suggestion: '写清「看起来像但其实不是」的情况,并指明它该归到哪个分类。' }
1048
+ }
1049
+ // 每条排除项都必须指向一个已存在的分类,否则边界是悬空的。
1050
+ const known = [...new Set([...knownIds, draft.id ?? ''])].filter(Boolean)
1051
+ const dangling = excludes.filter(text => !known.some(knownId => text.includes(knownId)))
1052
+ if (dangling.length > 0) {
1053
+ return {
1054
+ ok: false, code: 'SOURCE_TYPE_EXCLUDE_DANGLING', field: 'excludes',
1055
+ message: `排除项没有指向任何已存在的分类: ${dangling[0]}`,
1056
+ suggestion: `每条排除项都要写出「归到哪个分类」,用 id 或标签都行。可用分类: ${known.join(' / ')}`,
1057
+ }
1058
+ }
1059
+ return { ok: true }
1060
+ }
1061
+
1062
+ // ------------------------------------------------- Wiki 卡片类型注册表(0024)
1063
+
1064
+ /**
1065
+ * Wiki 卡片类型的一条正文小节要求。
1066
+ *
1067
+ * 「正文小节」是给人读的部分:0021 的判断是"序和枚举一旦写进散文,归并算法就再也读不到
1068
+ * 它们了",所以小节名要固定,模板卡照着它生成。
1069
+ */
1070
+ export interface WikiSectionSpec {
1071
+ /** 小节名,同时是模板卡里的 Markdown 标题。 */
1072
+ label: string
1073
+ required: boolean
1074
+ }
1075
+
1076
+ /**
1077
+ * 一条 front matter / metadata 字段要求(机器读的部分)。
1078
+ *
1079
+ * `name` 对应 `write_knowledge_card` 的顶层字段(`owner` / `summary`)或 `metadata` 里的键。
1080
+ */
1081
+ export interface WikiFieldSpec {
1082
+ name: string
1083
+ required: boolean
1084
+ note: string
1085
+ }
1086
+
1087
+ /**
1088
+ * 一个 wiki 卡片类型(0024)。
1089
+ *
1090
+ * 它是"SourceRaw → Wiki"这一段的**约束**:哪些类型能生成、每类必须有哪些小节与字段、
1091
+ * 期望来自哪些来源分类、模板长什么样。判定与生成仍交给模型,这里给的是**判据与骨架**。
1092
+ */
1093
+ export interface WikiTypeDef {
1094
+ /** 稳定 id,`^[a-z][a-z0-9_/-]{1,60}$`(允许 `diagnosis/symptom` 这类命名空间)。 */
1095
+ id: string
1096
+ label: string
1097
+ description: string
1098
+ /** 借 0021 的分层:类型层可复用上千次(假设/口径),实例层只发生过一次(案例)。 */
1099
+ layer: 'type' | 'instance'
1100
+ sections: WikiSectionSpec[]
1101
+ fields: WikiFieldSpec[]
1102
+ /** 期望的来源业务分类 id(0013 的 `source_type`);空数组表示不限制。 */
1103
+ sourceTypes: string[]
1104
+ /** few-shot:用户会怎么提到这种卡片。随工具描述下发。 */
1105
+ examples: string[]
1106
+ /** 模板卡(Markdown 骨架),新建卡片时照抄。 */
1107
+ template: string
1108
+ enabled: boolean
1109
+ /** 内置类型只允许停用,不允许删除。 */
1110
+ builtin: boolean
1111
+ /** 计算字段:该库中该类型的卡片数,由 refresh 实时算出。 */
1112
+ cardCount: number
1113
+ }
1114
+
1115
+ /** 卡片 schema 的生效方式。 */
1116
+ export type WikiSchemaMode = 'strict' | 'report'
1117
+
1118
+ /** 卡片校验问题的一层。混成同一个「校验失败」,人就学不会怎么写卡片(0021 §1)。 */
1119
+ export type CardIssueLevel = 'format' | 'reference' | 'semantic'
1120
+
1121
+ export interface CardSchemaIssue {
1122
+ level: CardIssueLevel
1123
+ /** 出问题的小节名或字段名。 */
1124
+ field?: string
1125
+ message: string
1126
+ }
1127
+
1128
+ /** 卡片类型目录:注册表 + 生效方式 + 内置只读判定规则 + 当前卡片的问题清单。 */
1129
+ export interface WikiTypeCatalog {
1130
+ types: WikiTypeDef[]
1131
+ mode: WikiSchemaMode
1132
+ /** 内置只读的判定规则:模型该按什么顺序挑类型。 */
1133
+ rules: string
1134
+ /** 已落库卡片的三层校验结果(前 N 条)。 */
1135
+ issues: Array<CardSchemaIssue & { entityId: string; entityType: string }>
1136
+ issueCount: number
1137
+ }
1138
+
1139
+ /** 卡片类型 id 的形状;允许 `diagnosis/symptom` 这类带命名空间的 id。 */
1140
+ export const WIKI_TYPE_ID_PATTERN = /^[a-z][a-z0-9_/-]{1,60}$/