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.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/cordis.patch.yml +410 -0
- package/eval/README.md +76 -0
- package/eval/evaluate-retrieval.mjs +121 -0
- package/eval/queries.example.jsonl +2 -0
- package/lib/client.js +111415 -0
- package/lib/types/config-schema.d.ts +35 -0
- package/lib/types/config-schema.d.ts.map +1 -0
- package/lib/types/config-schema.js +34 -0
- package/lib/types/config-schema.js.map +1 -0
- package/lib/types/convert/core-loader.d.ts +32 -0
- package/lib/types/convert/core-loader.d.ts.map +1 -0
- package/lib/types/convert/core-loader.js +60 -0
- package/lib/types/convert/core-loader.js.map +1 -0
- package/lib/types/convert/host.d.ts +88 -0
- package/lib/types/convert/host.d.ts.map +1 -0
- package/lib/types/convert/host.js +479 -0
- package/lib/types/convert/host.js.map +1 -0
- package/lib/types/convert/ingest.d.ts +125 -0
- package/lib/types/convert/ingest.d.ts.map +1 -0
- package/lib/types/convert/ingest.js +228 -0
- package/lib/types/convert/ingest.js.map +1 -0
- package/lib/types/convert/settings-schema.d.ts +18 -0
- package/lib/types/convert/settings-schema.d.ts.map +1 -0
- package/lib/types/convert/settings-schema.js +26 -0
- package/lib/types/convert/settings-schema.js.map +1 -0
- package/lib/types/convert/types.d.ts +46 -0
- package/lib/types/convert/types.d.ts.map +1 -0
- package/lib/types/convert/types.js +41 -0
- package/lib/types/convert/types.js.map +1 -0
- package/lib/types/diagnosis/cards.d.ts +71 -0
- package/lib/types/diagnosis/cards.d.ts.map +1 -0
- package/lib/types/diagnosis/cards.js +482 -0
- package/lib/types/diagnosis/cards.js.map +1 -0
- package/lib/types/diagnosis/problem-classes.d.ts +51 -0
- package/lib/types/diagnosis/problem-classes.d.ts.map +1 -0
- package/lib/types/diagnosis/problem-classes.js +66 -0
- package/lib/types/diagnosis/problem-classes.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +65 -0
- package/lib/types/diagnosis/schema.d.ts.map +1 -0
- package/lib/types/diagnosis/schema.js +208 -0
- package/lib/types/diagnosis/schema.js.map +1 -0
- package/lib/types/diagnosis/seed-source.d.ts +20 -0
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -0
- package/lib/types/diagnosis/seed-source.js +96 -0
- package/lib/types/diagnosis/seed-source.js.map +1 -0
- package/lib/types/diagnosis/seed.d.ts +50 -0
- package/lib/types/diagnosis/seed.d.ts.map +1 -0
- package/lib/types/diagnosis/seed.js +242 -0
- package/lib/types/diagnosis/seed.js.map +1 -0
- package/lib/types/diagnosis/store.d.ts +81 -0
- package/lib/types/diagnosis/store.d.ts.map +1 -0
- package/lib/types/diagnosis/store.js +409 -0
- package/lib/types/diagnosis/store.js.map +1 -0
- package/lib/types/diagnosis/types.d.ts +124 -0
- package/lib/types/diagnosis/types.d.ts.map +1 -0
- package/lib/types/diagnosis/types.js +13 -0
- package/lib/types/diagnosis/types.js.map +1 -0
- package/lib/types/directory-picker.d.ts +94 -0
- package/lib/types/directory-picker.d.ts.map +1 -0
- package/lib/types/directory-picker.js +110 -0
- package/lib/types/directory-picker.js.map +1 -0
- package/lib/types/extract-model.d.ts +76 -0
- package/lib/types/extract-model.d.ts.map +1 -0
- package/lib/types/extract-model.js +193 -0
- package/lib/types/extract-model.js.map +1 -0
- package/lib/types/extract.d.ts +121 -0
- package/lib/types/extract.d.ts.map +1 -0
- package/lib/types/extract.js +549 -0
- package/lib/types/extract.js.map +1 -0
- package/lib/types/fts.d.ts +96 -0
- package/lib/types/fts.d.ts.map +1 -0
- package/lib/types/fts.js +184 -0
- package/lib/types/fts.js.map +1 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +1112 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/intent/engine.d.ts +108 -0
- package/lib/types/intent/engine.d.ts.map +1 -0
- package/lib/types/intent/engine.js +501 -0
- package/lib/types/intent/engine.js.map +1 -0
- package/lib/types/intent/host.d.ts +88 -0
- package/lib/types/intent/host.d.ts.map +1 -0
- package/lib/types/intent/host.js +389 -0
- package/lib/types/intent/host.js.map +1 -0
- package/lib/types/intent/preset-tools.d.ts +37 -0
- package/lib/types/intent/preset-tools.d.ts.map +1 -0
- package/lib/types/intent/preset-tools.js +45 -0
- package/lib/types/intent/preset-tools.js.map +1 -0
- package/lib/types/intent/settings-schema.d.ts +59 -0
- package/lib/types/intent/settings-schema.d.ts.map +1 -0
- package/lib/types/intent/settings-schema.js +57 -0
- package/lib/types/intent/settings-schema.js.map +1 -0
- package/lib/types/intent/store.d.ts +75 -0
- package/lib/types/intent/store.d.ts.map +1 -0
- package/lib/types/intent/store.js +139 -0
- package/lib/types/intent/store.js.map +1 -0
- package/lib/types/intent/tools.d.ts +63 -0
- package/lib/types/intent/tools.d.ts.map +1 -0
- package/lib/types/intent/tools.js +108 -0
- package/lib/types/intent/tools.js.map +1 -0
- package/lib/types/intent/types.d.ts +189 -0
- package/lib/types/intent/types.d.ts.map +1 -0
- package/lib/types/intent/types.js +62 -0
- package/lib/types/intent/types.js.map +1 -0
- package/lib/types/knowledge-bases.d.ts +266 -0
- package/lib/types/knowledge-bases.d.ts.map +1 -0
- package/lib/types/knowledge-bases.js +1107 -0
- package/lib/types/knowledge-bases.js.map +1 -0
- package/lib/types/layout.d.ts +262 -0
- package/lib/types/layout.d.ts.map +1 -0
- package/lib/types/layout.js +312 -0
- package/lib/types/layout.js.map +1 -0
- package/lib/types/migrate.d.ts +110 -0
- package/lib/types/migrate.d.ts.map +1 -0
- package/lib/types/migrate.js +280 -0
- package/lib/types/migrate.js.map +1 -0
- package/lib/types/recall-rank.d.ts +158 -0
- package/lib/types/recall-rank.d.ts.map +1 -0
- package/lib/types/recall-rank.js +261 -0
- package/lib/types/recall-rank.js.map +1 -0
- package/lib/types/runtime.d.ts +568 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +3045 -0
- package/lib/types/runtime.js.map +1 -0
- package/lib/types/settings-schema.d.ts +117 -0
- package/lib/types/settings-schema.d.ts.map +1 -0
- package/lib/types/settings-schema.js +92 -0
- package/lib/types/settings-schema.js.map +1 -0
- package/lib/types/settings-volatile.d.ts +102 -0
- package/lib/types/settings-volatile.d.ts.map +1 -0
- package/lib/types/settings-volatile.js +114 -0
- package/lib/types/settings-volatile.js.map +1 -0
- package/lib/types/types.d.ts +799 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +387 -0
- package/lib/types/types.js.map +1 -0
- package/lib/types/wiki-types.d.ts +68 -0
- package/lib/types/wiki-types.d.ts.map +1 -0
- package/lib/types/wiki-types.js +322 -0
- package/lib/types/wiki-types.js.map +1 -0
- package/package.json +101 -0
- package/preset/tita-data-agent/agent.cordis.yml +376 -0
- package/preset/tita-data-agent/preset.yml +3 -0
- package/python/requirements.txt +8 -0
- package/scripts/doctor.mjs +70 -0
- package/scripts/e2e-pipeline.mjs +355 -0
- package/scripts/install-agent-preset.mjs +92 -0
- package/scripts/setup-embedding.sh +34 -0
- package/scripts/sync-agent-preset.mjs +188 -0
- package/src/client/AgentPresetCard.tsx +247 -0
- package/src/client/DirectoryPicker.tsx +357 -0
- package/src/client/InfoHint.tsx +65 -0
- package/src/client/IntentLogicTab.tsx +185 -0
- package/src/client/IntentTab.tsx +715 -0
- package/src/client/KnowledgeBaseTab.tsx +1454 -0
- package/src/client/KnowledgeGraphView.tsx +306 -0
- package/src/client/KnowledgePanel.tsx +381 -0
- package/src/client/KnowledgeStepRail.tsx +60 -0
- package/src/client/NodeSettings.tsx +392 -0
- package/src/client/PreviewSections.tsx +116 -0
- package/src/client/RecallPanel.tsx +399 -0
- package/src/client/TypeDefinitionDrawer.tsx +215 -0
- package/src/client/WikiFlowCard.tsx +226 -0
- package/src/client/WikiTypeEditor.tsx +332 -0
- package/src/client/agent-preset.ts +121 -0
- package/src/client/arch/AgentArchitectureGraph.tsx +219 -0
- package/src/client/arch/NodeLogicPanel.tsx +133 -0
- package/src/client/arch/ProblemDetailPanel.tsx +88 -0
- package/src/client/arch/model.ts +691 -0
- package/src/client/architecture.css +191 -0
- package/src/client/architecture.tsx +530 -0
- package/src/client/attribution/AttributionGraph.tsx +104 -0
- package/src/client/attribution/AttributionTab.tsx +332 -0
- package/src/client/attribution/CaseView.tsx +102 -0
- package/src/client/attribution/GraphStage.tsx +87 -0
- package/src/client/attribution/RunView.tsx +184 -0
- package/src/client/attribution/StageChrome.tsx +403 -0
- package/src/client/attribution/ViewSwitch.tsx +107 -0
- package/src/client/attribution/graph-options.ts +381 -0
- package/src/client/attribution/model.ts +462 -0
- package/src/client/config-scope.ts +119 -0
- package/src/client/convert/ConvertPanel.tsx +594 -0
- package/src/client/convert/styles.css +138 -0
- package/src/client/convert/styles.ts +49 -0
- package/src/client/format.ts +39 -0
- package/src/client/graph/layout.ts +181 -0
- package/src/client/graph.css +113 -0
- package/src/client/index.ts +98 -0
- package/src/client/problem-domains.ts +114 -0
- package/src/client/styles.css +940 -0
- package/src/client/styles.ts +11 -0
- package/src/client/useFitScale.ts +68 -0
- package/src/config-schema.ts +45 -0
- package/src/convert/core-loader.ts +68 -0
- package/src/convert/host.ts +579 -0
- package/src/convert/ingest.ts +367 -0
- package/src/convert/settings-schema.ts +27 -0
- package/src/convert/types.ts +65 -0
- package/src/diagnosis/cards.ts +498 -0
- package/src/diagnosis/problem-classes.ts +117 -0
- package/src/diagnosis/schema.ts +260 -0
- package/src/diagnosis/seed-source.ts +107 -0
- package/src/diagnosis/seed.ts +291 -0
- package/src/diagnosis/store.ts +432 -0
- package/src/diagnosis/types.ts +152 -0
- package/src/directory-picker.ts +164 -0
- package/src/extract-model.ts +227 -0
- package/src/extract.ts +632 -0
- package/src/fts.ts +203 -0
- package/src/index.ts +1091 -0
- package/src/intent/README.md +63 -0
- package/src/intent/engine.ts +527 -0
- package/src/intent/host.ts +462 -0
- package/src/intent/preset-tools.ts +48 -0
- package/src/intent/settings-schema.ts +63 -0
- package/src/intent/store.ts +143 -0
- package/src/intent/tools.ts +139 -0
- package/src/intent/types.ts +245 -0
- package/src/knowledge-bases.ts +1160 -0
- package/src/layout.ts +350 -0
- package/src/migrate.ts +262 -0
- package/src/python/embedding_worker.py +471 -0
- package/src/recall-rank.ts +299 -0
- package/src/runtime.ts +3069 -0
- package/src/settings-schema.ts +94 -0
- package/src/settings-volatile.ts +122 -0
- package/src/types.ts +1140 -0
- package/src/wiki-types.ts +357 -0
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多知识库注册表(0021)。
|
|
3
|
+
*
|
|
4
|
+
* 产品参照 WeKnora:**一个知识库 = 独立配置 + 独立内容**。每个库有自己的
|
|
5
|
+
* `rootDir`(沿用既有的 `01_source_truth` / `02_wiki` / `03_compiled` /
|
|
6
|
+
* `04_memory` 布局)和一套「文件类型 → 解析引擎」规则;库下面挂文档列表、
|
|
7
|
+
* 文档详情与版本历史。
|
|
8
|
+
*
|
|
9
|
+
* 0029:库的 `rootDir` 不再是各写各的自由路径,而是**从 Tita Agent 根路径推出来的
|
|
10
|
+
* 一格**:`<agentRoot>/domain_knowledge/<库 id>`。注册表因此不再需要用户为每个库
|
|
11
|
+
* 挑一个目录 —— 见 `layout.ts` 的三条原理。
|
|
12
|
+
*
|
|
13
|
+
* 注册表本身只持久化**配置**(id / name / description / rootDir / sourceTypes / wikiTypes),
|
|
14
|
+
* 落到 `<dataHome>/knowledge-bases.json`,`dataHome =
|
|
15
|
+
* process.env.TITA_DATA_AGENT_HOME || ~/.tita-data-agent`。
|
|
16
|
+
* `sourceCount` / `cardCount` / `noteCount` / `buildVersion` 和每条分类/类型的
|
|
17
|
+
* 计数都是**计算字段**,缓存在同一个 JSON 里,由 `refresh()` 从真实
|
|
18
|
+
* runtime 重新统计;这样 `list()` / `get()` 保持同步签名(前端首屏直接用),
|
|
19
|
+
* 路由在返回前 `await refresh()` 拿到准确值。
|
|
20
|
+
*
|
|
21
|
+
* 默认库 id 固定为 `default`,其 `rootDir` 始终来自 `defaultRootDir()`
|
|
22
|
+
* (即插件设置里的 `agentRoot` 推出来的那一格),因此旧接口 / 旧工具不传 `kb`
|
|
23
|
+
* 时行为完全不变。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { dirname, join, resolve } from 'node:path'
|
|
30
|
+
import {
|
|
31
|
+
BUILTIN_SOURCE_TYPES, defaultSourceTypes, FALLBACK_SOURCE_TYPE, isBuiltinSourceType,
|
|
32
|
+
MAX_SOURCE_TYPES, SOURCE_TYPE_ID_PATTERN, validateSourceTypeDraft, validateSourceTypeText, WIKI_TYPE_ID_PATTERN,
|
|
33
|
+
type Envelope, type KnowledgeBase, type KnowledgeConfig,
|
|
34
|
+
type SourceTypeDef, type SourceTypeDraft, type WikiSchemaMode, type WikiTypeDef,
|
|
35
|
+
} from './types.js'
|
|
36
|
+
import {
|
|
37
|
+
defaultWikiTypes, isBuiltinWikiType, parseFieldLines, parseSectionLines, templateCardFor,
|
|
38
|
+
validateWikiTypeDraft, validateWikiTypeText, type WikiTypeDraft,
|
|
39
|
+
} from './wiki-types.js'
|
|
40
|
+
import { defaultKnowledgeRoot, failure, KnowledgeRuntime, success } from './runtime.js'
|
|
41
|
+
import { knowledgeBaseRoot, memoryRoot } from './layout.js'
|
|
42
|
+
|
|
43
|
+
/** 持久化形态:配置 + 计算字段缓存分区。 */
|
|
44
|
+
interface KnowledgeBaseRecord {
|
|
45
|
+
id: string
|
|
46
|
+
name: string
|
|
47
|
+
description: string
|
|
48
|
+
rootDir: string
|
|
49
|
+
isDefault: boolean
|
|
50
|
+
sourceTypes: SourceTypeDef[]
|
|
51
|
+
/** Wiki 卡片类型注册表(0024)。 */
|
|
52
|
+
wikiTypes: WikiTypeDef[]
|
|
53
|
+
/** `report` 只标记问题,`strict` 拒绝不合规的写入。 */
|
|
54
|
+
wikiSchemaMode: WikiSchemaMode
|
|
55
|
+
createdAt: string
|
|
56
|
+
updatedAt: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface BaseStats {
|
|
60
|
+
sourceCount: number
|
|
61
|
+
cardCount: number
|
|
62
|
+
noteCount: number
|
|
63
|
+
buildVersion: number
|
|
64
|
+
/** typeId → 该库中引用该来源分类的文档数。 */
|
|
65
|
+
sourceTypes: Record<string, number>
|
|
66
|
+
/** wikiTypeId → 该库中该类型的卡片数。 */
|
|
67
|
+
wikiTypes: Record<string, number>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 默认知识库的 id。
|
|
72
|
+
*
|
|
73
|
+
* 导出成常量而不是只放在类字段上:`index.ts` 里 `runtimeOptions()` 会被
|
|
74
|
+
* **`bases` 构造之前**调用一次(默认库的 runtime 要先于注册表存在,因为注册表要
|
|
75
|
+
* 复用它),那时读 `bases.defaultId` 会撞上 TDZ。id 本身是契约的一部分,本来就
|
|
76
|
+
* 不该只挂在一个实例上。
|
|
77
|
+
*/
|
|
78
|
+
export const DEFAULT_KB_ID = 'default'
|
|
79
|
+
|
|
80
|
+
export interface KnowledgeBasesOptions {
|
|
81
|
+
/** 覆盖 `~/.tita-data-agent`;测试用临时目录,避免写用户主目录。 */
|
|
82
|
+
dataHome?: string
|
|
83
|
+
/** 默认库的根目录来源;插件传入由 `agentRoot` 推出来的那一格。 */
|
|
84
|
+
defaultRootDir?: () => string
|
|
85
|
+
/**
|
|
86
|
+
* Tita Agent 根路径来源(0029)。给了它,**新建**的知识库就落在
|
|
87
|
+
* `<agentRoot>/domain_knowledge/<id>`;不给则退回旧行为
|
|
88
|
+
* (`<dataHome>/knowledge-bases/<id>`),这样只关心注册表逻辑的单元测试
|
|
89
|
+
* 不必先造一个根路径。
|
|
90
|
+
*/
|
|
91
|
+
agentRoot?: () => string
|
|
92
|
+
/**
|
|
93
|
+
* 默认库已由宿主(settings 驱动)持有 runtime 时,复用它而不是另建一个,
|
|
94
|
+
* 否则同一个 rootDir 会被两个 runtime 同时写。
|
|
95
|
+
*/
|
|
96
|
+
defaultRuntime?: () => KnowledgeRuntime
|
|
97
|
+
/** 非默认库的 runtime 配置来源(embedding / rerank / topK 等跟随插件设置)。 */
|
|
98
|
+
config?: () => Partial<Omit<KnowledgeConfig, 'rootDir' | 'agentRoot'>>
|
|
99
|
+
/**
|
|
100
|
+
* 覆盖注册表文件位置(默认 `<dataHome>/knowledge-bases.json`)。
|
|
101
|
+
*
|
|
102
|
+
* 可以是函数:0029 起它跟着 `agentRoot` 走(`<agentRoot>/resource/knowledge-bases.json`),
|
|
103
|
+
* 而 agentRoot 由设置决定、构造时还不知道。
|
|
104
|
+
*/
|
|
105
|
+
registryFile?: string | (() => string)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function record(value: unknown): Record<string, unknown> {
|
|
109
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 只接受"非空字符串",其余一律回退。注册表里有大量 `unknown` 字段要走这里。 */
|
|
113
|
+
function textOr(input: unknown, fallback: string): string {
|
|
114
|
+
return typeof input === 'string' && input.trim() ? input.trim() : fallback
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeExt(value: unknown, fallback: string): string {
|
|
118
|
+
const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
119
|
+
const ext = text || fallback
|
|
120
|
+
return ext.startsWith('.') ? ext : `.${ext}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 空的失败载荷:删除守卫失败时前端仍需一个可渲染的 base 形状。 */
|
|
124
|
+
function emptyBase(id: string): KnowledgeBase {
|
|
125
|
+
return {
|
|
126
|
+
id, name: id, description: '', rootDir: '', isDefault: false, sourceTypes: [],
|
|
127
|
+
wikiTypes: [], wikiSchemaMode: 'report', wikiTypesCustomized: false,
|
|
128
|
+
createdAt: '', updatedAt: '', sourceCount: 0, cardCount: 0, noteCount: 0, buildVersion: 0,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeTextAtomic(path: string, content: string): Promise<void> {
|
|
133
|
+
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
|
|
134
|
+
try {
|
|
135
|
+
await writeFile(temporary, content, 'utf8')
|
|
136
|
+
await rename(temporary, path)
|
|
137
|
+
} finally {
|
|
138
|
+
try { await unlink(temporary) } catch { /* rename already consumed it */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function defaultDataHome(): string {
|
|
143
|
+
return resolve(process.env.TITA_DATA_AGENT_HOME || join(homedir(), '.tita-data-agent'))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export class KnowledgeBases {
|
|
147
|
+
readonly defaultId = DEFAULT_KB_ID
|
|
148
|
+
private readonly dataHome: string
|
|
149
|
+
/** 路径来源可以是函数(跟着 agentRoot 走),所以现算。 */
|
|
150
|
+
private resolveRegistry(): string {
|
|
151
|
+
const configured = this.options.registryFile
|
|
152
|
+
const value = typeof configured === 'function' ? configured() : configured
|
|
153
|
+
return resolve(value || join(this.dataHome, 'knowledge-bases.json'))
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* **实际在用**的注册表文件。
|
|
157
|
+
*
|
|
158
|
+
* 由 `repoint()` 决定,`ensureLoaded()` 与 `persist()` 都读它。之所以不能每次都现算:
|
|
159
|
+
* 家当搬迁失败时(跨卷等)我们要**留在旧位置**,而「留在哪」必须是一个被记住的
|
|
160
|
+
* 决定,不能每次重新推导 —— 否则读在旧位置、写在新的,两边就分叉了。
|
|
161
|
+
*/
|
|
162
|
+
private activeRegistry = ''
|
|
163
|
+
/** 实际在用的注册表文件;`repoint()` 还没跑过时就是配置算出来的那个。 */
|
|
164
|
+
private get registryPath(): string { return this.activeRegistry || this.resolveRegistry() }
|
|
165
|
+
private readonly options: KnowledgeBasesOptions
|
|
166
|
+
private records: KnowledgeBaseRecord[] = []
|
|
167
|
+
private stats = new Map<string, BaseStats>()
|
|
168
|
+
/** runtime 缓存按 rootDir 建键,两个库共用目录时也就共用 runtime。 */
|
|
169
|
+
private readonly runtimes = new Map<string, KnowledgeRuntime>()
|
|
170
|
+
private loaded = false
|
|
171
|
+
|
|
172
|
+
constructor(options: KnowledgeBasesOptions = {}) {
|
|
173
|
+
this.options = options
|
|
174
|
+
this.dataHome = resolve(options.dataHome || defaultDataHome())
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 注册表位置变了就重新读一遍(0029:它跟着 `agentRoot` 走)。
|
|
179
|
+
*
|
|
180
|
+
* 位置没变时是空操作。变了的场景只有一个:用户改了根路径,于是注册表要跟着
|
|
181
|
+
* 搬过去 —— 那时旧位置的记录必须丢掉,否则会出现「两个库列表叠在一起」。
|
|
182
|
+
* 缓存的 runtime 一并关掉:它们的 `rootDir` 是按旧 agentRoot 算的。
|
|
183
|
+
*/
|
|
184
|
+
async repoint(): Promise<void> {
|
|
185
|
+
const next = this.resolveRegistry()
|
|
186
|
+
// 只在**已经决定过**的前提下才早退:`activeRegistry` 还是空的时候,「当前路径」
|
|
187
|
+
// 就是配置算出来的 `next`,拿它做相等判断会让第一次 repoint 直接返回 ——
|
|
188
|
+
// 于是下面的回退分支永远走不到,注册表也就永远切不过去。
|
|
189
|
+
if (this.activeRegistry && this.activeRegistry === next) return
|
|
190
|
+
/**
|
|
191
|
+
* 新位置还没有注册表、旧位置有 → **留在旧位置**。
|
|
192
|
+
*
|
|
193
|
+
* 这正是「家当搬迁失败」的样子(跨卷时我们刻意不自动复制几个 GB)。这时如果
|
|
194
|
+
* 一头扎到新路径,用户会看到一个**空的知识库列表** —— 数据没丢,但界面说丢了,
|
|
195
|
+
* 这是最糟的失败方式。留在旧位置至少是可用的。
|
|
196
|
+
*/
|
|
197
|
+
const legacy = resolve(join(this.dataHome, 'knowledge-bases.json'))
|
|
198
|
+
if (!existsSync(next) && existsSync(legacy)) {
|
|
199
|
+
this.activeRegistry = legacy
|
|
200
|
+
} else {
|
|
201
|
+
this.activeRegistry = next
|
|
202
|
+
}
|
|
203
|
+
for (const runtime of this.runtimes.values()) runtime.close()
|
|
204
|
+
this.runtimes.clear()
|
|
205
|
+
this.records = []
|
|
206
|
+
this.stats.clear()
|
|
207
|
+
this.loaded = false
|
|
208
|
+
this.ensureLoaded()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------- 读路径(同步)
|
|
212
|
+
|
|
213
|
+
list(): KnowledgeBase[] {
|
|
214
|
+
this.ensureLoaded()
|
|
215
|
+
return this.records.map(item => this.view(item))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get(id: string): KnowledgeBase | undefined {
|
|
219
|
+
this.ensureLoaded()
|
|
220
|
+
const found = this.records.find(item => item.id === id)
|
|
221
|
+
return found ? this.view(found) : undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 某个知识库的 runtime(按 rootDir 缓存)。
|
|
226
|
+
*
|
|
227
|
+
* 未知 id 抛 `BASE_NOT_FOUND`(契约签名是同步返回 runtime,没有 Envelope
|
|
228
|
+
* 可承载错误);路由先用 `get()` 做 404 判定。
|
|
229
|
+
*/
|
|
230
|
+
runtimeOf(id: string): KnowledgeRuntime {
|
|
231
|
+
const base = this.requireRecord(id)
|
|
232
|
+
const key = base.rootDir
|
|
233
|
+
const cached = this.runtimes.get(key)
|
|
234
|
+
if (cached) return cached
|
|
235
|
+
const runtime = base.isDefault && this.options.defaultRuntime
|
|
236
|
+
? this.options.defaultRuntime()
|
|
237
|
+
: new KnowledgeRuntime(base.rootDir, this.options.config?.() ?? {}, {
|
|
238
|
+
sourceTypes: () => this.sourceTypesOf(base.id),
|
|
239
|
+
wikiSchema: () => ({ types: this.wikiTypesOf(base.id), mode: base.wikiSchemaMode }),
|
|
240
|
+
// 0032:记忆段跟着**这个库**走,二级用 id。`agentRoot` 没给时(只测注册表
|
|
241
|
+
// 逻辑的单元测试)留空,runtime 会退回知识树里的 `04_memory`。
|
|
242
|
+
...(this.options.agentRoot ? { memoryRoot: memoryRoot(this.options.agentRoot(), base.id) } : {}),
|
|
243
|
+
})
|
|
244
|
+
runtime.baseId = base.id
|
|
245
|
+
this.runtimes.set(key, runtime)
|
|
246
|
+
return runtime
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 设置变化后调用:关掉并丢弃非默认库的 runtime,下次 `runtimeOf` 会用新的
|
|
251
|
+
* embedding / rerank 配置重建。默认库由宿主自己重建,这里不动。
|
|
252
|
+
*/
|
|
253
|
+
invalidate(): void {
|
|
254
|
+
const keep = resolve(this.defaultRootDir())
|
|
255
|
+
for (const [key, runtime] of this.runtimes) {
|
|
256
|
+
if (key === keep) continue
|
|
257
|
+
runtime.close()
|
|
258
|
+
this.runtimes.delete(key)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
closeAll(): void {
|
|
263
|
+
for (const runtime of this.runtimes.values()) runtime.close()
|
|
264
|
+
this.runtimes.clear()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ------------------------------------------------------------- 统计刷新
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 从真实 runtime 重新统计一个(或全部)知识库的 sourceCount / cardCount /
|
|
271
|
+
* noteCount / buildVersion 与每条规则的 fileCount,并落盘缓存。
|
|
272
|
+
* 目录不可读时保留上一次统计,不抛错、不谎报 0。
|
|
273
|
+
*/
|
|
274
|
+
async refresh(id?: string): Promise<void> {
|
|
275
|
+
this.ensureLoaded()
|
|
276
|
+
for (const item of this.records) {
|
|
277
|
+
if (id !== undefined && item.id !== id) continue
|
|
278
|
+
await this.refreshOne(item)
|
|
279
|
+
}
|
|
280
|
+
await this.persist()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async refreshOne(base: KnowledgeBaseRecord): Promise<void> {
|
|
284
|
+
const runtime = this.runtimeOf(base.id)
|
|
285
|
+
try {
|
|
286
|
+
const [summary, documents, cards] = await Promise.all([runtime.stats(), runtime.listDocuments(), runtime.loadCards()])
|
|
287
|
+
// sourceCount 同理:按文档**实际**写在 frontmatter 里的分类计数,而不是按
|
|
288
|
+
// 注册表里还剩下的分类计数。否则手工改坏的分类会被统计成 0,删除守卫就形同虚设。
|
|
289
|
+
const sourceTypes: Record<string, number> = {}
|
|
290
|
+
for (const def of base.sourceTypes) sourceTypes[def.id] = 0
|
|
291
|
+
for (const document of documents) sourceTypes[document.sourceType] = (sourceTypes[document.sourceType] ?? 0) + 1
|
|
292
|
+
// wikiType 的 cardCount 同理:按卡片**实际**写的 entity_type 计数,未知类型也算,
|
|
293
|
+
// 否则"未登记类型"的卡片会在界面上凭空消失。
|
|
294
|
+
const wikiTypes: Record<string, number> = {}
|
|
295
|
+
for (const def of base.wikiTypes) wikiTypes[def.id] = 0
|
|
296
|
+
for (const card of cards) wikiTypes[card.entityType] = (wikiTypes[card.entityType] ?? 0) + 1
|
|
297
|
+
this.stats.set(base.id, {
|
|
298
|
+
sourceCount: Number(summary.sourceCount ?? documents.length),
|
|
299
|
+
cardCount: Number(summary.cards ?? 0),
|
|
300
|
+
noteCount: Number(summary.notes ?? 0),
|
|
301
|
+
buildVersion: Number(summary.buildVersion ?? 0),
|
|
302
|
+
sourceTypes,
|
|
303
|
+
wikiTypes,
|
|
304
|
+
})
|
|
305
|
+
} catch { /* 保留上一次统计 */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ------------------------------------------------------------- 写路径
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 新建知识库。名字为空时抛 `NAME_REQUIRED`(契约签名返回裸
|
|
312
|
+
* `KnowledgeBase`,没有 Envelope 可承载错误,路由捕获后转 400)。
|
|
313
|
+
* `rootDir` 缺省为 `<agentRoot>/domain_knowledge/<id>`(0029);没接根路径时
|
|
314
|
+
* 退回 `<dataHome>/knowledge-bases/<id>`。
|
|
315
|
+
*/
|
|
316
|
+
async create(input: { name: string; description?: string; rootDir?: string }): Promise<KnowledgeBase> {
|
|
317
|
+
this.ensureLoaded()
|
|
318
|
+
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
|
319
|
+
if (!name) throw new Error('NAME_REQUIRED')
|
|
320
|
+
const id = this.nextId(name)
|
|
321
|
+
const agentRoot = this.options.agentRoot?.()
|
|
322
|
+
const rootDir = typeof input.rootDir === 'string' && input.rootDir.trim()
|
|
323
|
+
? resolve(input.rootDir.trim())
|
|
324
|
+
: agentRoot
|
|
325
|
+
? knowledgeBaseRoot(agentRoot, id)
|
|
326
|
+
: join(this.dataHome, 'knowledge-bases', id)
|
|
327
|
+
const now = new Date().toISOString()
|
|
328
|
+
const base: KnowledgeBaseRecord = {
|
|
329
|
+
id,
|
|
330
|
+
name,
|
|
331
|
+
description: typeof input.description === 'string' ? input.description.trim() : '',
|
|
332
|
+
rootDir,
|
|
333
|
+
isDefault: false,
|
|
334
|
+
sourceTypes: defaultSourceTypes(),
|
|
335
|
+
wikiTypes: defaultWikiTypes(),
|
|
336
|
+
wikiSchemaMode: 'report',
|
|
337
|
+
createdAt: now,
|
|
338
|
+
updatedAt: now,
|
|
339
|
+
}
|
|
340
|
+
this.records.push(base)
|
|
341
|
+
await mkdir(rootDir, { recursive: true })
|
|
342
|
+
await this.persist()
|
|
343
|
+
return this.view(base)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 更新名称 / 描述 / 来源类型注册表 / Wiki 类型注册表。
|
|
348
|
+
*
|
|
349
|
+
* `patch.fileTypes` 与 `patch.sourceTypes` 都是**完整的期望列表**:凡是提供了
|
|
350
|
+
* 列表却丢掉了某个「在用」的条目,就返回 `*_IN_USE` 并且**整体不写入**(不做
|
|
351
|
+
* 部分更新),这样前端不会出现「名称改了、规则没删掉」的半成品状态。
|
|
352
|
+
*
|
|
353
|
+
* `sourceTypes` 另有三条注册表专属守卫(0013):内置分类不允许删除(缺失即
|
|
354
|
+
* 恢复,并给出 warning)、兜底分类 `document` 不允许停用、新建自定义分类必须
|
|
355
|
+
* 提交完整判定文案。
|
|
356
|
+
*/
|
|
357
|
+
async update(id: string, patch: { name?: string; description?: string; sourceTypes?: SourceTypeDef[]; wikiTypes?: WikiTypeDef[]; wikiSchemaMode?: WikiSchemaMode; resetWikiTypes?: boolean }): Promise<Envelope<KnowledgeBase>> {
|
|
358
|
+
this.ensureLoaded()
|
|
359
|
+
const base = this.records.find(item => item.id === id)
|
|
360
|
+
if (!base) return failure(emptyBase(id), 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
361
|
+
// 守卫必须基于精确的实时计数,不能用可能过期的缓存。
|
|
362
|
+
await this.refreshOne(base)
|
|
363
|
+
let sourceTypes: SourceTypeDef[] | undefined
|
|
364
|
+
let sourceWarnings: string[] = []
|
|
365
|
+
if (patch.sourceTypes !== undefined) {
|
|
366
|
+
if (!Array.isArray(patch.sourceTypes)) return failure(this.view(base), 'SOURCE_TYPES_INVALID', 'sourceTypes 必须是 SourceTypeDef 数组', false)
|
|
367
|
+
const merged = this.mergeSourceTypes(base, patch.sourceTypes)
|
|
368
|
+
if ('error' in merged) return failure(this.view(base), merged.error.code, merged.error.message, false, merged.error.suggestion)
|
|
369
|
+
sourceTypes = merged.types
|
|
370
|
+
sourceWarnings = merged.warnings
|
|
371
|
+
}
|
|
372
|
+
if (patch.name !== undefined) {
|
|
373
|
+
const name = String(patch.name).trim()
|
|
374
|
+
if (!name) return failure(this.view(base), 'NAME_REQUIRED', 'name 不能为空', false)
|
|
375
|
+
base.name = name
|
|
376
|
+
}
|
|
377
|
+
let wikiTypes: WikiTypeDef[] | undefined
|
|
378
|
+
let wikiWarnings: string[] = []
|
|
379
|
+
if (patch.wikiTypes !== undefined) {
|
|
380
|
+
if (!Array.isArray(patch.wikiTypes)) return failure(this.view(base), 'WIKI_TYPES_INVALID', 'wikiTypes 必须是 WikiTypeDef 数组', false)
|
|
381
|
+
const merged = this.mergeWikiTypes(base, patch.wikiTypes)
|
|
382
|
+
if ('error' in merged) return failure(this.view(base), merged.error.code, merged.error.message, false, merged.error.suggestion)
|
|
383
|
+
wikiTypes = merged.types
|
|
384
|
+
wikiWarnings = merged.warnings
|
|
385
|
+
}
|
|
386
|
+
if (patch.resetWikiTypes === true) base.wikiTypes = defaultWikiTypes()
|
|
387
|
+
if (patch.description !== undefined) base.description = String(patch.description)
|
|
388
|
+
if (sourceTypes !== undefined) base.sourceTypes = sourceTypes
|
|
389
|
+
if (wikiTypes !== undefined) base.wikiTypes = wikiTypes
|
|
390
|
+
if (patch.wikiSchemaMode !== undefined) base.wikiSchemaMode = patch.wikiSchemaMode === 'strict' ? 'strict' : 'report'
|
|
391
|
+
base.updatedAt = new Date().toISOString()
|
|
392
|
+
await this.refreshOne(base)
|
|
393
|
+
await this.persist()
|
|
394
|
+
return success(this.view(base), [...sourceWarnings, ...wikiWarnings])
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ------------------------------------------------- 来源分类注册表(0013)
|
|
398
|
+
|
|
399
|
+
/** 某个知识库当前生效的分类注册表(含 `sourceCount`)。供 runtime provider 使用。 */
|
|
400
|
+
sourceTypesOf(id: string): SourceTypeDef[] {
|
|
401
|
+
this.ensureLoaded()
|
|
402
|
+
const base = this.records.find(item => item.id === id)
|
|
403
|
+
if (!base) return defaultSourceTypes()
|
|
404
|
+
const counts = this.stats.get(base.id)?.sourceTypes ?? {}
|
|
405
|
+
return base.sourceTypes.map(def => ({ ...def, sourceCount: counts[def.id] ?? 0 }))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 新增一个自定义分类。
|
|
410
|
+
*
|
|
411
|
+
* 必须先满足判定文案要求(`validateSourceTypeDraft`)——0013 的「用户追加新
|
|
412
|
+
* 类型时要用文字标明怎么判断」在这里落成硬校验:没有 `description` /
|
|
413
|
+
* 至少 2 条 `signals` / 至少 1 条指向已知分类的 `excludes`,一律不进注册表。
|
|
414
|
+
*/
|
|
415
|
+
async addSourceType(id: string, draft: SourceTypeDraft): Promise<Envelope<SourceTypeDef>> {
|
|
416
|
+
this.ensureLoaded()
|
|
417
|
+
const base = this.records.find(item => item.id === id)
|
|
418
|
+
if (!base) return failure({} as SourceTypeDef, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
419
|
+
const typeId = typeof draft?.id === 'string' ? draft.id.trim() : ''
|
|
420
|
+
if (base.sourceTypes.some(def => def.id === typeId)) {
|
|
421
|
+
return failure({} as SourceTypeDef, 'SOURCE_TYPE_EXISTS', `分类已存在: ${typeId}`, false, '改语义请编辑现有分类,或换一个 id。')
|
|
422
|
+
}
|
|
423
|
+
const customs = base.sourceTypes.filter(def => !def.builtin)
|
|
424
|
+
if (customs.length >= MAX_SOURCE_TYPES - BUILTIN_SOURCE_TYPES.length) {
|
|
425
|
+
return failure({} as SourceTypeDef, 'SOURCE_TYPE_LIMIT', `自定义分类最多 ${MAX_SOURCE_TYPES - BUILTIN_SOURCE_TYPES.length} 个`, false)
|
|
426
|
+
}
|
|
427
|
+
const known = new Set(base.sourceTypes.map(def => def.id))
|
|
428
|
+
const problem = validateSourceTypeDraft(draft, known)
|
|
429
|
+
if (!problem.ok) return failure({} as SourceTypeDef, problem.code, problem.message, false, problem.suggestion)
|
|
430
|
+
const created: SourceTypeDef = {
|
|
431
|
+
id: typeId,
|
|
432
|
+
label: String(draft.label).trim(),
|
|
433
|
+
description: String(draft.description).trim(),
|
|
434
|
+
signals: (draft.signals as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
435
|
+
excludes: (draft.excludes as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
436
|
+
enabled: draft.enabled !== false,
|
|
437
|
+
builtin: false,
|
|
438
|
+
fallback: false,
|
|
439
|
+
sourceCount: 0,
|
|
440
|
+
}
|
|
441
|
+
base.sourceTypes = [...base.sourceTypes, created]
|
|
442
|
+
base.updatedAt = new Date().toISOString()
|
|
443
|
+
await this.persist()
|
|
444
|
+
return success({ ...created, sourceCount: 0 })
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 编辑一个分类的文案 / 启用状态。
|
|
449
|
+
*
|
|
450
|
+
* - 内置分类:可以改 `label` / `description` / `signals` / `excludes` / `enabled`;
|
|
451
|
+
* - 兜底分类 `document`:**不允许停用**(判定链最后一步落在它上);
|
|
452
|
+
* - 自定义分类:同样可以改,`id` 永远锁定。
|
|
453
|
+
*/
|
|
454
|
+
async updateSourceType(id: string, typeId: string, patch: SourceTypeDraft): Promise<Envelope<SourceTypeDef>> {
|
|
455
|
+
this.ensureLoaded()
|
|
456
|
+
const base = this.records.find(item => item.id === id)
|
|
457
|
+
if (!base) return failure({} as SourceTypeDef, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
458
|
+
const existing = base.sourceTypes.find(def => def.id === typeId)
|
|
459
|
+
if (!existing) return failure({} as SourceTypeDef, 'SOURCE_TYPE_NOT_FOUND', `来源分类不存在: ${typeId}`, true)
|
|
460
|
+
if (patch.enabled === false && existing.fallback) {
|
|
461
|
+
return failure({} as SourceTypeDef, 'SOURCE_TYPE_FALLBACK_REQUIRED', `兜底分类 ${existing.label}(${existing.id})不能停用`, false, '判定链全不命中时必须有一个归属;请改用其他分类的启用状态。')
|
|
462
|
+
}
|
|
463
|
+
const merged: SourceTypeDraft = {
|
|
464
|
+
id: existing.id,
|
|
465
|
+
label: patch.label ?? existing.label,
|
|
466
|
+
description: patch.description ?? existing.description,
|
|
467
|
+
signals: patch.signals ?? existing.signals,
|
|
468
|
+
excludes: patch.excludes ?? existing.excludes,
|
|
469
|
+
enabled: patch.enabled === undefined ? existing.enabled : patch.enabled !== false,
|
|
470
|
+
}
|
|
471
|
+
const known = new Set(base.sourceTypes.map(def => def.id))
|
|
472
|
+
const problem = validateSourceTypeText(merged, known)
|
|
473
|
+
if (!problem.ok) return failure({} as SourceTypeDef, problem.code, problem.message, false, problem.suggestion)
|
|
474
|
+
const next: SourceTypeDef = {
|
|
475
|
+
...existing,
|
|
476
|
+
label: String(merged.label).trim(),
|
|
477
|
+
description: String(merged.description).trim(),
|
|
478
|
+
signals: (merged.signals as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
479
|
+
excludes: (merged.excludes as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
480
|
+
enabled: merged.enabled === true,
|
|
481
|
+
}
|
|
482
|
+
base.sourceTypes = base.sourceTypes.map(def => def.id === typeId ? next : def)
|
|
483
|
+
base.updatedAt = new Date().toISOString()
|
|
484
|
+
await this.persist()
|
|
485
|
+
return success(next)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 删除知识库。
|
|
490
|
+
*
|
|
491
|
+
* 只删注册表条目,**不动磁盘内容**(删用户文档是不可逆的,必须先显式清空文档,
|
|
492
|
+
* 这也是 `BASE_IN_USE` 守卫的另一半含义)。默认库拒绝删除。
|
|
493
|
+
*/
|
|
494
|
+
async remove(id: string): Promise<Envelope<{ deleted: true }>> {
|
|
495
|
+
this.ensureLoaded()
|
|
496
|
+
const base = this.records.find(item => item.id === id)
|
|
497
|
+
if (!base) return failure({ deleted: true }, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
498
|
+
if (base.isDefault || base.id === this.defaultId) return failure({ deleted: true }, 'BASE_IS_DEFAULT', '默认知识库不能删除', false)
|
|
499
|
+
let documentCount = 0
|
|
500
|
+
try { documentCount = (await this.runtimeOf(base.id).listDocuments()).length } catch { /* 目录不可读则按空处理 */ }
|
|
501
|
+
if (documentCount > 0) return failure({ deleted: true }, 'BASE_IN_USE', `知识库下还有 ${documentCount} 个文档,先清空再删除`, false, '文档接口 DELETE /knowledge/documents/:docId 可逐个删除。')
|
|
502
|
+
this.records = this.records.filter(item => item.id !== id)
|
|
503
|
+
this.runtimes.get(base.rootDir)?.close()
|
|
504
|
+
this.runtimes.delete(base.rootDir)
|
|
505
|
+
this.stats.delete(id)
|
|
506
|
+
await this.persist()
|
|
507
|
+
return success({ deleted: true })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 删除一个来源分类(0013)。
|
|
512
|
+
*
|
|
513
|
+
* 三层守卫,顺序即优先级:
|
|
514
|
+
* 1. 内置分类**不允许删除**(只能停用);
|
|
515
|
+
* 2. 兜底分类**不允许删除**(它必须永远存在);
|
|
516
|
+
* 3. 该分类下还有来源(`sourceCount > 0`)→ `SOURCE_TYPE_IN_USE`,先迁移再删。
|
|
517
|
+
*/
|
|
518
|
+
async removeSourceType(id: string, typeId: string): Promise<Envelope<{ deleted: true }>> {
|
|
519
|
+
this.ensureLoaded()
|
|
520
|
+
const base = this.records.find(item => item.id === id)
|
|
521
|
+
if (!base) return failure({ deleted: true }, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
522
|
+
const def = base.sourceTypes.find(item => item.id === typeId)
|
|
523
|
+
if (!def) return failure({ deleted: true }, 'SOURCE_TYPE_NOT_FOUND', `来源分类不存在: ${typeId}`, true)
|
|
524
|
+
if (def.builtin) {
|
|
525
|
+
return failure({ deleted: true }, 'SOURCE_TYPE_BUILTIN_LOCKED', `内置分类不可删除: ${def.label}(${def.id})`, false, '内置分类只允许停用;确要换语义请新建一个自定义分类。')
|
|
526
|
+
}
|
|
527
|
+
if (def.fallback) {
|
|
528
|
+
return failure({ deleted: true }, 'SOURCE_TYPE_FALLBACK_REQUIRED', `兜底分类不可删除: ${def.label}(${def.id})`, false)
|
|
529
|
+
}
|
|
530
|
+
await this.refreshOne(base)
|
|
531
|
+
const count = this.stats.get(base.id)?.sourceTypes?.[typeId] ?? 0
|
|
532
|
+
if (count > 0) {
|
|
533
|
+
return failure({ deleted: true }, 'SOURCE_TYPE_IN_USE', `该分类下还有 ${count} 个来源,先迁移再删除`, false, `把 /knowledge/documents?source_type=${typeId} 下的来源改到别的分类,或保留该分类。`)
|
|
534
|
+
}
|
|
535
|
+
base.sourceTypes = base.sourceTypes.filter(item => item.id !== typeId)
|
|
536
|
+
base.updatedAt = new Date().toISOString()
|
|
537
|
+
await this.persist()
|
|
538
|
+
return success({ deleted: true })
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ------------------------------------------------- Wiki 卡片类型注册表(0024)
|
|
542
|
+
|
|
543
|
+
/** 某个知识库当前生效的卡片类型(含 `cardCount`)。供 runtime provider 使用。 */
|
|
544
|
+
wikiTypesOf(id: string): WikiTypeDef[] {
|
|
545
|
+
this.ensureLoaded()
|
|
546
|
+
const base = this.records.find(item => item.id === id)
|
|
547
|
+
if (!base) return defaultWikiTypes()
|
|
548
|
+
const counts = this.stats.get(base.id)?.wikiTypes ?? {}
|
|
549
|
+
return base.wikiTypes.map(def => ({ ...def, cardCount: counts[def.id] ?? 0 }))
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 卡片 schema 生效方式;未知 id 回退出厂值。 */
|
|
553
|
+
wikiSchemaModeOf(id: string): WikiSchemaMode {
|
|
554
|
+
this.ensureLoaded()
|
|
555
|
+
return this.records.find(item => item.id === id)?.wikiSchemaMode ?? 'report'
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 新增一个卡片类型。
|
|
560
|
+
*
|
|
561
|
+
* 与 0013 的 source type 同一条要求:光给名字不让入库——`sections` / `fields` 会直接
|
|
562
|
+
* 变成"卡片该怎么写"的约束,也是模板卡的来源。
|
|
563
|
+
*/
|
|
564
|
+
async addWikiType(id: string, draft: WikiTypeDraft): Promise<Envelope<WikiTypeDef>> {
|
|
565
|
+
this.ensureLoaded()
|
|
566
|
+
const base = this.records.find(item => item.id === id)
|
|
567
|
+
if (!base) return failure({} as WikiTypeDef, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
568
|
+
const typeId = typeof draft?.id === 'string' ? draft.id.trim() : ''
|
|
569
|
+
if (base.wikiTypes.some(def => def.id === typeId)) {
|
|
570
|
+
return failure({} as WikiTypeDef, 'WIKI_TYPE_EXISTS', `卡片类型已存在: ${typeId}`, false, '改语义请编辑现有类型,或换一个 id。')
|
|
571
|
+
}
|
|
572
|
+
const problem = validateWikiTypeDraft(draft, new Set(base.wikiTypes.map(def => def.id)))
|
|
573
|
+
if (!problem.ok) return failure({} as WikiTypeDef, problem.code, problem.message, false, problem.suggestion)
|
|
574
|
+
const created = this.wikiTypeFromDraft(typeId, draft)
|
|
575
|
+
base.wikiTypes = [...base.wikiTypes, created]
|
|
576
|
+
base.updatedAt = new Date().toISOString()
|
|
577
|
+
await this.persist()
|
|
578
|
+
return success({ ...created, cardCount: 0 })
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** 编辑一个卡片类型的文案 / 小节 / 字段 / 启停。内置类型也可以改,但 id 与 `builtin` 锁定。 */
|
|
582
|
+
async updateWikiType(id: string, typeId: string, patch: WikiTypeDraft & { enabled?: boolean }): Promise<Envelope<WikiTypeDef>> {
|
|
583
|
+
this.ensureLoaded()
|
|
584
|
+
const base = this.records.find(item => item.id === id)
|
|
585
|
+
if (!base) return failure({} as WikiTypeDef, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
586
|
+
const existing = base.wikiTypes.find(def => def.id === typeId)
|
|
587
|
+
if (!existing) return failure({} as WikiTypeDef, 'WIKI_TYPE_NOT_FOUND', `卡片类型不存在: ${typeId}`, true)
|
|
588
|
+
const merged: WikiTypeDraft = {
|
|
589
|
+
id: existing.id,
|
|
590
|
+
label: patch.label ?? existing.label,
|
|
591
|
+
description: patch.description ?? existing.description,
|
|
592
|
+
layer: patch.layer ?? existing.layer,
|
|
593
|
+
sections: patch.sections ?? existing.sections,
|
|
594
|
+
fields: patch.fields ?? existing.fields,
|
|
595
|
+
sourceTypes: patch.sourceTypes ?? existing.sourceTypes,
|
|
596
|
+
examples: patch.examples ?? existing.examples,
|
|
597
|
+
}
|
|
598
|
+
const problem = validateWikiTypeText(merged)
|
|
599
|
+
if (!problem.ok) return failure({} as WikiTypeDef, problem.code, problem.message, false, problem.suggestion)
|
|
600
|
+
const sections = parseSectionLines(merged.sections)
|
|
601
|
+
const fields = parseFieldLines(merged.fields)
|
|
602
|
+
const next: WikiTypeDef = {
|
|
603
|
+
...existing,
|
|
604
|
+
label: String(merged.label).trim(),
|
|
605
|
+
description: String(merged.description).trim(),
|
|
606
|
+
layer: merged.layer === 'instance' ? 'instance' : 'type',
|
|
607
|
+
sections,
|
|
608
|
+
fields,
|
|
609
|
+
sourceTypes: this.stringList(merged.sourceTypes),
|
|
610
|
+
examples: this.stringList(merged.examples),
|
|
611
|
+
template: templateCardFor(sections, fields),
|
|
612
|
+
enabled: patch.enabled === undefined ? existing.enabled : patch.enabled !== false,
|
|
613
|
+
}
|
|
614
|
+
base.wikiTypes = base.wikiTypes.map(def => def.id === typeId ? next : def)
|
|
615
|
+
base.updatedAt = new Date().toISOString()
|
|
616
|
+
await this.persist()
|
|
617
|
+
return success(next)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 删除一个卡片类型。
|
|
622
|
+
*
|
|
623
|
+
* 两层守卫:内置类型不可删(只能停用);该类型下还有卡片时不可删(先改卡片类型)。
|
|
624
|
+
*/
|
|
625
|
+
async removeWikiType(id: string, typeId: string): Promise<Envelope<{ deleted: true }>> {
|
|
626
|
+
this.ensureLoaded()
|
|
627
|
+
const base = this.records.find(item => item.id === id)
|
|
628
|
+
if (!base) return failure({ deleted: true }, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
629
|
+
const def = base.wikiTypes.find(item => item.id === typeId)
|
|
630
|
+
if (!def) return failure({ deleted: true }, 'WIKI_TYPE_NOT_FOUND', `卡片类型不存在: ${typeId}`, true)
|
|
631
|
+
if (def.builtin) {
|
|
632
|
+
return failure({ deleted: true }, 'WIKI_TYPE_BUILTIN_LOCKED', `内置类型不可删除: ${def.label}(${def.id})`, false, '内置类型只允许停用;确要换语义请新建一个自定义类型。')
|
|
633
|
+
}
|
|
634
|
+
await this.refreshOne(base)
|
|
635
|
+
const count = this.stats.get(base.id)?.wikiTypes?.[typeId] ?? 0
|
|
636
|
+
if (count > 0) {
|
|
637
|
+
return failure({ deleted: true }, 'WIKI_TYPE_IN_USE', `该类型下还有 ${count} 张卡片,先改类型再删除`, false, `把 entity_type=${typeId} 的卡片改成别的类型,或在 wikiTypes 里保留该类型。`)
|
|
638
|
+
}
|
|
639
|
+
base.wikiTypes = base.wikiTypes.filter(item => item.id !== typeId)
|
|
640
|
+
base.updatedAt = new Date().toISOString()
|
|
641
|
+
await this.persist()
|
|
642
|
+
return success({ deleted: true })
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 切换卡片 schema 的生效方式(`report` 只标记,`strict` 拒绝不合规的写入)。 */
|
|
646
|
+
async setWikiSchemaMode(id: string, mode: WikiSchemaMode): Promise<Envelope<{ mode: WikiSchemaMode }>> {
|
|
647
|
+
this.ensureLoaded()
|
|
648
|
+
const base = this.records.find(item => item.id === id)
|
|
649
|
+
if (!base) return failure({ mode }, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
650
|
+
base.wikiSchemaMode = mode === 'strict' ? 'strict' : 'report'
|
|
651
|
+
base.updatedAt = new Date().toISOString()
|
|
652
|
+
await this.persist()
|
|
653
|
+
return success({ mode: base.wikiSchemaMode })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 把一条 draft 转成完整类型(模板卡由小节派生,不手写第二份)。 */
|
|
657
|
+
private wikiTypeFromDraft(typeId: string, draft: WikiTypeDraft): WikiTypeDef {
|
|
658
|
+
const sections = parseSectionLines(draft.sections)
|
|
659
|
+
const fields = parseFieldLines(draft.fields)
|
|
660
|
+
return {
|
|
661
|
+
id: typeId,
|
|
662
|
+
label: String(draft.label ?? typeId).trim(),
|
|
663
|
+
description: String(draft.description ?? '').trim(),
|
|
664
|
+
layer: draft.layer === 'instance' ? 'instance' : 'type',
|
|
665
|
+
sections,
|
|
666
|
+
fields,
|
|
667
|
+
sourceTypes: this.stringList(draft.sourceTypes),
|
|
668
|
+
examples: this.stringList(draft.examples),
|
|
669
|
+
template: templateCardFor(sections, fields),
|
|
670
|
+
enabled: true,
|
|
671
|
+
builtin: false,
|
|
672
|
+
cardCount: 0,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private stringList(value: unknown): string[] {
|
|
677
|
+
if (!Array.isArray(value)) return []
|
|
678
|
+
return [...new Set(value.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))]
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 把磁盘上的 `wikiTypes` 规整成**必定包含全部内置类型**的合法注册表。
|
|
683
|
+
*
|
|
684
|
+
* 与 `normalizeSourceTypes` 同一套语义:老配置(没有 `wikiTypes` 字段)升级时补齐内置,
|
|
685
|
+
* 缺失的内置类型被恢复(内置只允许停用,不允许删除)。
|
|
686
|
+
*/
|
|
687
|
+
private normalizeWikiTypes(raw: unknown): WikiTypeDef[] {
|
|
688
|
+
const persisted = Array.isArray(raw) ? raw : []
|
|
689
|
+
const byId = new Map<string, Record<string, unknown>>()
|
|
690
|
+
for (const item of persisted) {
|
|
691
|
+
const value = record(item)
|
|
692
|
+
const id = typeof value.id === 'string' ? value.id.trim() : ''
|
|
693
|
+
if (!id || !WIKI_TYPE_ID_PATTERN.test(id) || byId.has(id)) continue
|
|
694
|
+
byId.set(id, value)
|
|
695
|
+
}
|
|
696
|
+
const defaults = defaultWikiTypes()
|
|
697
|
+
const builtins = defaults.map(def => {
|
|
698
|
+
const value = byId.get(def.id)
|
|
699
|
+
if (!value) return def
|
|
700
|
+
byId.delete(def.id)
|
|
701
|
+
return this.wikiTypeFromRecord(def, value)
|
|
702
|
+
})
|
|
703
|
+
const customs: WikiTypeDef[] = []
|
|
704
|
+
for (const [id, value] of byId) {
|
|
705
|
+
if (isBuiltinWikiType(id)) continue
|
|
706
|
+
const base: WikiTypeDef = { id, label: id, description: '', layer: 'type', sections: [], fields: [], sourceTypes: [], examples: [], template: '', enabled: true, builtin: false, cardCount: 0 }
|
|
707
|
+
const draft = this.wikiTypeFromRecord(base, value)
|
|
708
|
+
// 只有能写出骨架的自定义类型才值得留下;否则它既没有模板也校验不了。
|
|
709
|
+
if (validateWikiTypeText(draft).ok) customs.push(draft)
|
|
710
|
+
}
|
|
711
|
+
return [...builtins, ...customs]
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 用一条持久化记录覆盖默认值;`builtin` / `id` 只信服务端。 */
|
|
715
|
+
private wikiTypeFromRecord(base: WikiTypeDef, value: Record<string, unknown>): WikiTypeDef {
|
|
716
|
+
const sections = parseSectionLines(value.sections)
|
|
717
|
+
const fields = parseFieldLines(value.fields)
|
|
718
|
+
return {
|
|
719
|
+
id: base.id,
|
|
720
|
+
label: textOr(value.label, base.label),
|
|
721
|
+
description: textOr(value.description, base.description),
|
|
722
|
+
layer: value.layer === 'instance' ? 'instance' : base.layer,
|
|
723
|
+
sections: sections.length > 0 ? sections : base.sections.map(section => ({ ...section })),
|
|
724
|
+
fields: Array.isArray(value.fields) ? fields : base.fields.map(field => ({ ...field })),
|
|
725
|
+
sourceTypes: Array.isArray(value.sourceTypes) ? this.stringList(value.sourceTypes) : [...base.sourceTypes],
|
|
726
|
+
examples: this.stringList(value.examples).length > 0 ? this.stringList(value.examples) : [...base.examples],
|
|
727
|
+
template: textOr(value.template, base.template),
|
|
728
|
+
enabled: value.enabled !== false,
|
|
729
|
+
builtin: base.builtin,
|
|
730
|
+
cardCount: 0,
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** `wikiTypes` 是否已偏离出厂定义(只看语义字段,不看计算字段)。 */
|
|
735
|
+
private wikiTypesDifferFromDefaults(types: WikiTypeDef[]): boolean {
|
|
736
|
+
const signature = (list: WikiTypeDef[]): string => JSON.stringify(list.map(def => ({
|
|
737
|
+
id: def.id, label: def.label, description: def.description, layer: def.layer, enabled: def.enabled,
|
|
738
|
+
sections: def.sections, fields: def.fields, sourceTypes: def.sourceTypes, examples: def.examples,
|
|
739
|
+
})))
|
|
740
|
+
const defaults = defaultWikiTypes()
|
|
741
|
+
if (types.length !== defaults.length) return true
|
|
742
|
+
return signature(types) !== signature(defaults)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* 把 `wikiTypes` 恢复成出厂定义(模式一并回到 `report`)。
|
|
747
|
+
*
|
|
748
|
+
* 与"整表提交里缺了某个内置类型"不同:那条路径只是**恢复缺失项**,保留了用户改过的内容;
|
|
749
|
+
* 这里是货真价实的重置,所以单独一个开关,语义不含糊。
|
|
750
|
+
*/
|
|
751
|
+
async resetWikiTypes(id: string): Promise<Envelope<KnowledgeBase>> {
|
|
752
|
+
this.ensureLoaded()
|
|
753
|
+
const base = this.records.find(item => item.id === id)
|
|
754
|
+
if (!base) return failure(emptyBase(id), 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true)
|
|
755
|
+
base.wikiTypes = defaultWikiTypes()
|
|
756
|
+
base.wikiSchemaMode = 'report'
|
|
757
|
+
base.updatedAt = new Date().toISOString()
|
|
758
|
+
await this.refreshOne(base)
|
|
759
|
+
await this.persist()
|
|
760
|
+
return success(this.view(base))
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ------------------------------------------------------------- 内部
|
|
764
|
+
|
|
765
|
+
private defaultRootDir(): string {
|
|
766
|
+
return resolve(this.options.defaultRootDir?.() || defaultKnowledgeRoot())
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private requireRecord(id: string): KnowledgeBaseRecord {
|
|
770
|
+
this.ensureLoaded()
|
|
771
|
+
const base = this.records.find(item => item.id === id)
|
|
772
|
+
if (!base) throw new Error(`BASE_NOT_FOUND: ${id}`)
|
|
773
|
+
return base
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private view(base: KnowledgeBaseRecord): KnowledgeBase {
|
|
777
|
+
const stats = this.stats.get(base.id)
|
|
778
|
+
return {
|
|
779
|
+
id: base.id,
|
|
780
|
+
name: base.name,
|
|
781
|
+
description: base.description,
|
|
782
|
+
rootDir: base.isDefault ? this.defaultRootDir() : base.rootDir,
|
|
783
|
+
isDefault: base.isDefault,
|
|
784
|
+
sourceTypes: base.sourceTypes.map(def => ({ ...def, sourceCount: stats?.sourceTypes?.[def.id] ?? 0 })),
|
|
785
|
+
wikiTypes: base.wikiTypes.map(def => ({ ...def, cardCount: stats?.wikiTypes?.[def.id] ?? 0 })),
|
|
786
|
+
wikiSchemaMode: base.wikiSchemaMode,
|
|
787
|
+
wikiTypesCustomized: this.wikiTypesDifferFromDefaults(base.wikiTypes),
|
|
788
|
+
createdAt: base.createdAt,
|
|
789
|
+
updatedAt: base.updatedAt,
|
|
790
|
+
sourceCount: stats?.sourceCount ?? 0,
|
|
791
|
+
cardCount: stats?.cardCount ?? 0,
|
|
792
|
+
noteCount: stats?.noteCount ?? 0,
|
|
793
|
+
buildVersion: stats?.buildVersion ?? 0,
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private seedDefault(): KnowledgeBaseRecord {
|
|
798
|
+
const now = new Date().toISOString()
|
|
799
|
+
return {
|
|
800
|
+
id: this.defaultId,
|
|
801
|
+
name: '默认知识库',
|
|
802
|
+
description: '插件设置 rootDir 指向的知识库,旧接口与旧工具默认作用于它。',
|
|
803
|
+
rootDir: this.defaultRootDir(),
|
|
804
|
+
isDefault: true,
|
|
805
|
+
sourceTypes: defaultSourceTypes(),
|
|
806
|
+
wikiTypes: defaultWikiTypes(),
|
|
807
|
+
wikiSchemaMode: 'report',
|
|
808
|
+
createdAt: now,
|
|
809
|
+
updatedAt: now,
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private ensureLoaded(): void {
|
|
814
|
+
if (this.loaded) return
|
|
815
|
+
this.loaded = true
|
|
816
|
+
try {
|
|
817
|
+
const parsed = JSON.parse(readFileSync(this.registryPath, 'utf8')) as { bases?: unknown; stats?: unknown }
|
|
818
|
+
if (Array.isArray(parsed.bases)) {
|
|
819
|
+
for (const item of parsed.bases) {
|
|
820
|
+
const base = this.normalize(item)
|
|
821
|
+
if (base && !this.records.some(existing => existing.id === base.id)) this.records.push(base)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const cached = record(parsed.stats)
|
|
825
|
+
for (const [id, value] of Object.entries(cached)) {
|
|
826
|
+
const stats = record(value)
|
|
827
|
+
this.stats.set(id, {
|
|
828
|
+
sourceCount: Number(stats.sourceCount ?? 0),
|
|
829
|
+
cardCount: Number(stats.cardCount ?? 0),
|
|
830
|
+
noteCount: Number(stats.noteCount ?? 0),
|
|
831
|
+
buildVersion: Number(stats.buildVersion ?? 0),
|
|
832
|
+
sourceTypes: Object.fromEntries(Object.entries(record(stats.sourceTypes)).map(([key, count]) => [key, Number(count) || 0])),
|
|
833
|
+
wikiTypes: Object.fromEntries(Object.entries(record(stats.wikiTypes)).map(([key, count]) => [key, Number(count) || 0])),
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
} catch { /* 首次运行或文件损坏:从默认库重新开始 */ }
|
|
837
|
+
|
|
838
|
+
// 默认库永远存在,且 rootDir 以插件设置(defaultRootDir)为准。
|
|
839
|
+
const index = this.records.findIndex(item => item.isDefault || item.id === this.defaultId)
|
|
840
|
+
const seed = this.seedDefault()
|
|
841
|
+
if (index < 0) this.records.unshift(seed)
|
|
842
|
+
else {
|
|
843
|
+
const existing = this.records[index]!
|
|
844
|
+
this.records[index] = {
|
|
845
|
+
...existing,
|
|
846
|
+
id: this.defaultId,
|
|
847
|
+
isDefault: true,
|
|
848
|
+
rootDir: seed.rootDir,
|
|
849
|
+
name: existing.name || seed.name,
|
|
850
|
+
sourceTypes: existing.sourceTypes.length > 0 ? existing.sourceTypes : seed.sourceTypes,
|
|
851
|
+
wikiTypes: existing.wikiTypes.length > 0 ? existing.wikiTypes : seed.wikiTypes,
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private normalize(raw: unknown): KnowledgeBaseRecord | undefined {
|
|
857
|
+
const value = record(raw)
|
|
858
|
+
const id = typeof value.id === 'string' ? value.id.trim() : ''
|
|
859
|
+
if (!id) return undefined
|
|
860
|
+
const now = new Date().toISOString()
|
|
861
|
+
const rootDir = typeof value.rootDir === 'string' && value.rootDir.trim()
|
|
862
|
+
? resolve(value.rootDir.trim())
|
|
863
|
+
: join(this.dataHome, 'knowledge-bases', id)
|
|
864
|
+
return {
|
|
865
|
+
id,
|
|
866
|
+
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : id,
|
|
867
|
+
description: typeof value.description === 'string' ? value.description : '',
|
|
868
|
+
rootDir,
|
|
869
|
+
isDefault: value.isDefault === true || id === this.defaultId,
|
|
870
|
+
sourceTypes: this.normalizeSourceTypes(value.sourceTypes),
|
|
871
|
+
wikiTypes: this.normalizeWikiTypes(value.wikiTypes),
|
|
872
|
+
wikiSchemaMode: value.wikiSchemaMode === 'strict' ? 'strict' : 'report',
|
|
873
|
+
createdAt: typeof value.createdAt === 'string' && value.createdAt ? value.createdAt : now,
|
|
874
|
+
updatedAt: typeof value.updatedAt === 'string' && value.updatedAt ? value.updatedAt : now,
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// --------------------------------------------- 来源分类注册表的规整与合并(0013)
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* 把磁盘上的 `sourceTypes` 规整成一个**必定包含全部内置分类**的合法注册表。
|
|
882
|
+
*
|
|
883
|
+
* 两个关键行为:
|
|
884
|
+
* 1. 老注册表(没有 `sourceTypes` 字段)升级时自动补上全部内置分类;
|
|
885
|
+
* 2. 任何缺失的内置分类都会被补回来——内置分类只允许停用,不允许删除。
|
|
886
|
+
*/
|
|
887
|
+
private normalizeSourceTypes(raw: unknown): SourceTypeDef[] {
|
|
888
|
+
const persisted = Array.isArray(raw) ? raw : []
|
|
889
|
+
const byId = new Map<string, Record<string, unknown>>()
|
|
890
|
+
for (const item of persisted) {
|
|
891
|
+
const value = record(item)
|
|
892
|
+
const id = typeof value.id === 'string' ? value.id.trim() : ''
|
|
893
|
+
if (!id || !SOURCE_TYPE_ID_PATTERN.test(id) || byId.has(id)) continue
|
|
894
|
+
byId.set(id, value)
|
|
895
|
+
}
|
|
896
|
+
const defaults = defaultSourceTypes()
|
|
897
|
+
const builtins = defaults.map(def => {
|
|
898
|
+
const value = byId.get(def.id)
|
|
899
|
+
if (!value) return def
|
|
900
|
+
byId.delete(def.id)
|
|
901
|
+
return this.sourceTypeFromRecord(def, value)
|
|
902
|
+
})
|
|
903
|
+
const customs: SourceTypeDef[] = []
|
|
904
|
+
for (const [id, value] of byId) {
|
|
905
|
+
if (isBuiltinSourceType(id)) continue
|
|
906
|
+
customs.push(this.sourceTypeFromRecord({ id, label: id, description: '', signals: [], excludes: [], enabled: true, builtin: false, fallback: false, sourceCount: 0 }, value))
|
|
907
|
+
}
|
|
908
|
+
const kept = customs
|
|
909
|
+
.filter(def => validateSourceTypeDraft(def, new Set([...builtins.map(item => item.id), ...customs.map(item => item.id)])).ok)
|
|
910
|
+
.slice(0, Math.max(0, MAX_SOURCE_TYPES - builtins.length))
|
|
911
|
+
return [...builtins, ...kept]
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** 用一条持久化记录覆盖默认值;`builtin` / `fallback` / `id` 只信服务端。 */
|
|
915
|
+
private sourceTypeFromRecord(base: SourceTypeDef, value: Record<string, unknown>): SourceTypeDef {
|
|
916
|
+
const list = (input: unknown, fallback: string[]): string[] => {
|
|
917
|
+
if (!Array.isArray(input)) return [...fallback]
|
|
918
|
+
const items = input.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
|
919
|
+
return items.length > 0 ? items : [...fallback]
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
id: base.id,
|
|
923
|
+
label: textOr(value.label, base.label),
|
|
924
|
+
description: textOr(value.description, base.description),
|
|
925
|
+
signals: list(value.signals, base.signals),
|
|
926
|
+
excludes: list(value.excludes, base.excludes),
|
|
927
|
+
// 兜底分类永远启用:判定链最后一步必须有归属。
|
|
928
|
+
enabled: base.fallback ? true : value.enabled !== false,
|
|
929
|
+
builtin: base.builtin,
|
|
930
|
+
fallback: base.fallback,
|
|
931
|
+
sourceCount: 0,
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 合并前端提交的**完整**注册表列表。
|
|
937
|
+
*
|
|
938
|
+
* 返回 `error` 表示整体拒绝(不做部分写入);返回 `warnings` 表示有内容被
|
|
939
|
+
* 静默纠正(目前只有"试图删除内置分类"这一种),前端据此提示用户。
|
|
940
|
+
*/
|
|
941
|
+
private mergeSourceTypes(
|
|
942
|
+
base: KnowledgeBaseRecord,
|
|
943
|
+
patch: SourceTypeDef[],
|
|
944
|
+
): { types: SourceTypeDef[]; warnings: string[] } | { error: { code: string; message: string; suggestion?: string } } {
|
|
945
|
+
const existing = base.sourceTypes
|
|
946
|
+
const requested = new Map<string, Record<string, unknown>>()
|
|
947
|
+
for (const item of patch) {
|
|
948
|
+
const value = record(item)
|
|
949
|
+
const id = typeof value.id === 'string' ? value.id.trim() : ''
|
|
950
|
+
if (!id || requested.has(id)) continue
|
|
951
|
+
requested.set(id, value)
|
|
952
|
+
}
|
|
953
|
+
const warnings: string[] = []
|
|
954
|
+
const next: SourceTypeDef[] = []
|
|
955
|
+
|
|
956
|
+
// 1) 内置分类:顺序固定、不可删除(缺失即恢复),可改文案、可停用(兜底除外)。
|
|
957
|
+
for (const seed of defaultSourceTypes()) {
|
|
958
|
+
const previous = existing.find(def => def.id === seed.id) ?? seed
|
|
959
|
+
const value = requested.get(seed.id)
|
|
960
|
+
if (value === undefined) {
|
|
961
|
+
if (existing.some(def => def.id === seed.id)) warnings.push(`内置分类 ${seed.label}(${seed.id})不可删除,已保留。`)
|
|
962
|
+
next.push(previous)
|
|
963
|
+
continue
|
|
964
|
+
}
|
|
965
|
+
requested.delete(seed.id)
|
|
966
|
+
const draft: SourceTypeDraft = {
|
|
967
|
+
id: seed.id,
|
|
968
|
+
label: textOr(value.label, previous.label),
|
|
969
|
+
description: textOr(value.description, previous.description),
|
|
970
|
+
signals: value.signals ?? previous.signals,
|
|
971
|
+
excludes: value.excludes ?? previous.excludes,
|
|
972
|
+
enabled: value.enabled === undefined ? previous.enabled : value.enabled !== false,
|
|
973
|
+
}
|
|
974
|
+
if (seed.fallback && draft.enabled === false) {
|
|
975
|
+
return { error: { code: 'SOURCE_TYPE_FALLBACK_REQUIRED', message: `兜底分类 ${seed.label}(${seed.id})不能停用`, suggestion: '判定链全不命中时必须有一个归属;请改为停用其他分类。' } }
|
|
976
|
+
}
|
|
977
|
+
const problem = validateSourceTypeText(draft, new Set([...existing.map(def => def.id), ...requested.keys()]))
|
|
978
|
+
if (!problem.ok) return { error: { code: problem.code, message: `${seed.label}: ${problem.message}`, suggestion: problem.suggestion } }
|
|
979
|
+
next.push({
|
|
980
|
+
...seed,
|
|
981
|
+
label: String(draft.label).trim(),
|
|
982
|
+
description: String(draft.description).trim(),
|
|
983
|
+
signals: (draft.signals as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
984
|
+
excludes: (draft.excludes as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
985
|
+
enabled: seed.fallback ? true : draft.enabled !== false,
|
|
986
|
+
sourceCount: 0,
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// 2) 自定义分类:可增可删,但新增/编辑都必须带上能判的文案。
|
|
991
|
+
const keptCustoms: SourceTypeDef[] = []
|
|
992
|
+
const known = new Set([...next.map(def => def.id), ...requested.keys()])
|
|
993
|
+
for (const [id, value] of requested) {
|
|
994
|
+
const previous = existing.find(def => def.id === id)
|
|
995
|
+
const draft: SourceTypeDraft = {
|
|
996
|
+
id,
|
|
997
|
+
label: textOr(value.label, previous?.label ?? id),
|
|
998
|
+
description: textOr(value.description, previous?.description ?? ''),
|
|
999
|
+
signals: value.signals ?? previous?.signals,
|
|
1000
|
+
excludes: value.excludes ?? previous?.excludes,
|
|
1001
|
+
enabled: value.enabled === undefined ? previous?.enabled ?? true : value.enabled !== false,
|
|
1002
|
+
}
|
|
1003
|
+
const problem = validateSourceTypeDraft(draft, known)
|
|
1004
|
+
if (!problem.ok) return { error: { code: problem.code, message: `${id}: ${problem.message}`, suggestion: problem.suggestion } }
|
|
1005
|
+
keptCustoms.push({
|
|
1006
|
+
id, label: String(draft.label).trim(), description: String(draft.description).trim(),
|
|
1007
|
+
signals: (draft.signals as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
1008
|
+
excludes: (draft.excludes as unknown[]).map(item => String(item).trim()).filter(Boolean),
|
|
1009
|
+
enabled: draft.enabled !== false, builtin: false, fallback: false, sourceCount: 0,
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
if (keptCustoms.length > Math.max(0, MAX_SOURCE_TYPES - BUILTIN_SOURCE_TYPES.length)) {
|
|
1013
|
+
return { error: { code: 'SOURCE_TYPE_LIMIT', message: `自定义分类最多 ${MAX_SOURCE_TYPES - BUILTIN_SOURCE_TYPES.length} 个` } }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// 3) 被删掉的自定义分类:在用就不许删(整体拒绝)。
|
|
1017
|
+
const counts = this.stats.get(base.id)?.sourceTypes ?? {}
|
|
1018
|
+
const keptIds = new Set([...next.map(def => def.id), ...keptCustoms.map(def => def.id)])
|
|
1019
|
+
for (const def of existing) {
|
|
1020
|
+
if (def.builtin || keptIds.has(def.id)) continue
|
|
1021
|
+
const count = counts[def.id] ?? 0
|
|
1022
|
+
if (count > 0) {
|
|
1023
|
+
return { error: { code: 'SOURCE_TYPE_IN_USE', message: `分类 ${def.label}(${def.id})下还有 ${count} 个来源,先迁移再删除`, suggestion: `把 /knowledge/documents?source_type=${def.id} 下的来源改到别的分类,或保留该分类。` } }
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return { types: [...next, ...keptCustoms], warnings }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* 合并前端提交的**完整**卡片类型列表。
|
|
1032
|
+
*
|
|
1033
|
+
* 与 `mergeSourceTypes` 同一套语义:内置类型不可删除(缺失即恢复 + warning),
|
|
1034
|
+
* 在用类型不可删除(整体拒绝,不做部分写入),自定义类型必须能写出骨架。
|
|
1035
|
+
*/
|
|
1036
|
+
private mergeWikiTypes(
|
|
1037
|
+
base: KnowledgeBaseRecord,
|
|
1038
|
+
patch: WikiTypeDef[],
|
|
1039
|
+
): { types: WikiTypeDef[]; warnings: string[] } | { error: { code: string; message: string; suggestion?: string } } {
|
|
1040
|
+
const existing = base.wikiTypes
|
|
1041
|
+
const requested = new Map<string, Record<string, unknown>>()
|
|
1042
|
+
for (const item of patch) {
|
|
1043
|
+
const value = record(item)
|
|
1044
|
+
const typeId = typeof value.id === 'string' ? value.id.trim() : ''
|
|
1045
|
+
if (!typeId || requested.has(typeId)) continue
|
|
1046
|
+
requested.set(typeId, value)
|
|
1047
|
+
}
|
|
1048
|
+
const warnings: string[] = []
|
|
1049
|
+
const next: WikiTypeDef[] = []
|
|
1050
|
+
|
|
1051
|
+
// 1) 内置类型:顺序固定、不可删除(缺失即恢复),可改文案与小节。
|
|
1052
|
+
for (const seed of defaultWikiTypes()) {
|
|
1053
|
+
const previous = existing.find(def => def.id === seed.id) ?? seed
|
|
1054
|
+
const value = requested.get(seed.id)
|
|
1055
|
+
if (value === undefined) {
|
|
1056
|
+
if (existing.some(def => def.id === seed.id)) warnings.push(`内置类型 ${seed.label}(${seed.id})不可删除,已保留。`)
|
|
1057
|
+
next.push(previous)
|
|
1058
|
+
continue
|
|
1059
|
+
}
|
|
1060
|
+
requested.delete(seed.id)
|
|
1061
|
+
const draft: WikiTypeDraft = {
|
|
1062
|
+
id: seed.id,
|
|
1063
|
+
label: textOr(value.label, previous.label),
|
|
1064
|
+
description: textOr(value.description, previous.description),
|
|
1065
|
+
layer: value.layer === 'instance' ? 'instance' : previous.layer,
|
|
1066
|
+
sections: Array.isArray(value.sections) ? value.sections : previous.sections,
|
|
1067
|
+
fields: Array.isArray(value.fields) ? value.fields : previous.fields,
|
|
1068
|
+
sourceTypes: Array.isArray(value.sourceTypes) ? value.sourceTypes : previous.sourceTypes,
|
|
1069
|
+
examples: Array.isArray(value.examples) ? value.examples : previous.examples,
|
|
1070
|
+
}
|
|
1071
|
+
const problem = validateWikiTypeText(draft)
|
|
1072
|
+
if (!problem.ok) return { error: { code: problem.code, message: `${seed.label}: ${problem.message}`, suggestion: problem.suggestion } }
|
|
1073
|
+
const sections = parseSectionLines(draft.sections)
|
|
1074
|
+
const fields = parseFieldLines(draft.fields)
|
|
1075
|
+
next.push({
|
|
1076
|
+
...seed,
|
|
1077
|
+
label: String(draft.label).trim(),
|
|
1078
|
+
description: String(draft.description).trim(),
|
|
1079
|
+
layer: draft.layer === 'instance' ? 'instance' : 'type',
|
|
1080
|
+
sections,
|
|
1081
|
+
fields,
|
|
1082
|
+
sourceTypes: this.stringList(draft.sourceTypes),
|
|
1083
|
+
examples: this.stringList(draft.examples),
|
|
1084
|
+
template: templateCardFor(sections, fields),
|
|
1085
|
+
enabled: value.enabled === undefined ? previous.enabled : value.enabled !== false,
|
|
1086
|
+
})
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 2) 自定义类型:可增可删,但必须能写出骨架(小节 + 必填小节)。
|
|
1090
|
+
const keptCustoms: WikiTypeDef[] = []
|
|
1091
|
+
for (const [typeId, value] of requested) {
|
|
1092
|
+
if (isBuiltinWikiType(typeId)) continue
|
|
1093
|
+
const previous = existing.find(def => def.id === typeId)
|
|
1094
|
+
const draft: WikiTypeDraft = {
|
|
1095
|
+
id: typeId,
|
|
1096
|
+
label: textOr(value.label, previous?.label ?? typeId),
|
|
1097
|
+
description: textOr(value.description, previous?.description ?? ''),
|
|
1098
|
+
layer: value.layer === 'instance' ? 'instance' : previous?.layer ?? 'type',
|
|
1099
|
+
sections: Array.isArray(value.sections) ? value.sections : previous?.sections ?? [],
|
|
1100
|
+
fields: Array.isArray(value.fields) ? value.fields : previous?.fields ?? [],
|
|
1101
|
+
sourceTypes: Array.isArray(value.sourceTypes) ? value.sourceTypes : previous?.sourceTypes ?? [],
|
|
1102
|
+
examples: Array.isArray(value.examples) ? value.examples : previous?.examples ?? [],
|
|
1103
|
+
}
|
|
1104
|
+
const problem = previous ? validateWikiTypeText(draft) : validateWikiTypeDraft(draft, new Set(existing.map(def => def.id)))
|
|
1105
|
+
if (!problem.ok) return { error: { code: problem.code, message: `${typeId}: ${problem.message}`, suggestion: problem.suggestion } }
|
|
1106
|
+
const sections = parseSectionLines(draft.sections)
|
|
1107
|
+
const fields = parseFieldLines(draft.fields)
|
|
1108
|
+
keptCustoms.push({
|
|
1109
|
+
id: typeId,
|
|
1110
|
+
label: String(draft.label).trim(),
|
|
1111
|
+
description: String(draft.description).trim(),
|
|
1112
|
+
layer: draft.layer === 'instance' ? 'instance' : 'type',
|
|
1113
|
+
sections,
|
|
1114
|
+
fields,
|
|
1115
|
+
sourceTypes: this.stringList(draft.sourceTypes),
|
|
1116
|
+
examples: this.stringList(draft.examples),
|
|
1117
|
+
template: templateCardFor(sections, fields),
|
|
1118
|
+
enabled: value.enabled === undefined ? previous?.enabled ?? true : value.enabled !== false,
|
|
1119
|
+
builtin: false,
|
|
1120
|
+
cardCount: 0,
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// 3) 被删掉的自定义类型:在用就不许删(整体拒绝)。
|
|
1125
|
+
const counts = this.stats.get(base.id)?.wikiTypes ?? {}
|
|
1126
|
+
const keptIds = new Set([...next.map(def => def.id), ...keptCustoms.map(def => def.id)])
|
|
1127
|
+
for (const def of existing) {
|
|
1128
|
+
if (def.builtin || keptIds.has(def.id)) continue
|
|
1129
|
+
const count = counts[def.id] ?? 0
|
|
1130
|
+
if (count > 0) {
|
|
1131
|
+
return { error: { code: 'WIKI_TYPE_IN_USE', message: `类型 ${def.label}(${def.id})下还有 ${count} 张卡片,先改类型再删除`, suggestion: `把 entity_type=${def.id} 的卡片改成别的类型,或保留该类型。` } }
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return { types: [...next, ...keptCustoms], warnings }
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private nextId(name: string): string {
|
|
1139
|
+
const slugged = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48)
|
|
1140
|
+
// 中文等非 ASCII 名字 slug 后会为空,用 base64url 前 10 位兜底,保证 URL 安全。
|
|
1141
|
+
const fingerprint = Buffer.from(name, 'utf8').toString('base64url').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10).toLowerCase()
|
|
1142
|
+
const stem = slugged || `kb-${fingerprint || 'base'}`
|
|
1143
|
+
const base = stem === this.defaultId ? `${stem}-base` : stem
|
|
1144
|
+
let candidate = base
|
|
1145
|
+
let suffix = 2
|
|
1146
|
+
while (this.records.some(item => item.id === candidate)) candidate = `${base}-${suffix++}`
|
|
1147
|
+
return candidate
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private async persist(): Promise<void> {
|
|
1151
|
+
const payload = {
|
|
1152
|
+
version: 1,
|
|
1153
|
+
bases: this.records.map(base => ({ ...base, rootDir: base.isDefault ? this.defaultRootDir() : base.rootDir })),
|
|
1154
|
+
stats: Object.fromEntries(this.stats),
|
|
1155
|
+
}
|
|
1156
|
+
const path = this.registryPath
|
|
1157
|
+
await mkdir(dirname(path), { recursive: true })
|
|
1158
|
+
await writeTextAtomic(path, JSON.stringify(payload, null, 2))
|
|
1159
|
+
}
|
|
1160
|
+
}
|