dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.3
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/README.md +31 -7
- package/cordis.patch.yml +11 -5
- package/lib/client.js +5557 -3056
- package/lib/doc-page.js +7740 -0
- package/lib/types/convert/host.d.ts.map +1 -1
- package/lib/types/convert/host.js +36 -9
- package/lib/types/convert/host.js.map +1 -1
- package/lib/types/diagnosis/cards.d.ts +8 -7
- package/lib/types/diagnosis/cards.d.ts.map +1 -1
- package/lib/types/diagnosis/cards.js +119 -49
- package/lib/types/diagnosis/cards.js.map +1 -1
- package/lib/types/diagnosis/run.d.ts +51 -0
- package/lib/types/diagnosis/run.d.ts.map +1 -0
- package/lib/types/diagnosis/run.js +239 -0
- package/lib/types/diagnosis/run.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +25 -2
- package/lib/types/diagnosis/schema.d.ts.map +1 -1
- package/lib/types/diagnosis/schema.js +64 -25
- package/lib/types/diagnosis/schema.js.map +1 -1
- package/lib/types/diagnosis/seed-source.d.ts +17 -2
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
- package/lib/types/diagnosis/seed-source.js +18 -1
- package/lib/types/diagnosis/seed-source.js.map +1 -1
- package/lib/types/diagnosis/seed.d.ts +32 -42
- package/lib/types/diagnosis/seed.d.ts.map +1 -1
- package/lib/types/diagnosis/seed.js +187 -51
- package/lib/types/diagnosis/seed.js.map +1 -1
- package/lib/types/diagnosis/store.d.ts +114 -26
- package/lib/types/diagnosis/store.d.ts.map +1 -1
- package/lib/types/diagnosis/store.js +336 -167
- package/lib/types/diagnosis/store.js.map +1 -1
- package/lib/types/diagnosis/types.d.ts +168 -16
- package/lib/types/diagnosis/types.d.ts.map +1 -1
- package/lib/types/diagnosis/types.js +1 -1
- package/lib/types/diagnosis/types.js.map +1 -1
- package/lib/types/document-page.d.ts +39 -0
- package/lib/types/document-page.d.ts.map +1 -0
- package/lib/types/document-page.js +43 -0
- package/lib/types/document-page.js.map +1 -0
- package/lib/types/fs-atomic.d.ts +12 -0
- package/lib/types/fs-atomic.d.ts.map +1 -0
- package/lib/types/fs-atomic.js +42 -0
- package/lib/types/fs-atomic.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +214 -24
- package/lib/types/index.js.map +1 -1
- package/lib/types/knowledge-bases.d.ts +10 -2
- package/lib/types/knowledge-bases.d.ts.map +1 -1
- package/lib/types/knowledge-bases.js +20 -18
- package/lib/types/knowledge-bases.js.map +1 -1
- package/lib/types/proposals.d.ts +166 -0
- package/lib/types/proposals.d.ts.map +1 -0
- package/lib/types/proposals.js +238 -0
- package/lib/types/proposals.js.map +1 -0
- package/lib/types/runtime.d.ts +199 -7
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +728 -145
- package/lib/types/runtime.js.map +1 -1
- package/lib/types/settings-schema.d.ts +2 -2
- package/lib/types/types.d.ts +61 -10
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js +56 -8
- package/lib/types/types.js.map +1 -1
- package/lib/types/wiki-types.d.ts +8 -1
- package/lib/types/wiki-types.d.ts.map +1 -1
- package/lib/types/wiki-types.js +16 -25
- package/lib/types/wiki-types.js.map +1 -1
- package/package.json +4 -3
- package/preset/tita-data-agent/agent.cordis.yml +11 -5
- package/src/client/DirectoryPicker.tsx +12 -12
- package/src/client/InfoHint.tsx +21 -8
- package/src/client/IntentTab.tsx +20 -26
- package/src/client/KnowledgeBaseTab.tsx +190 -80
- package/src/client/KnowledgePanel.tsx +42 -9
- package/src/client/KnowledgeStepRail.tsx +3 -0
- package/src/client/NodeSettings.tsx +20 -22
- package/src/client/PreviewSections.tsx +87 -33
- package/src/client/ProposalReview.tsx +313 -0
- package/src/client/TypeDefinitionDrawer.tsx +72 -38
- package/src/client/WikiTypeEditor.tsx +193 -77
- package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
- package/src/client/arch/NodeLogicPanel.tsx +6 -3
- package/src/client/arch/model.ts +164 -26
- package/src/client/architecture.css +29 -9
- package/src/client/architecture.tsx +54 -29
- package/src/client/attribution/AttributionGraph.tsx +481 -34
- package/src/client/attribution/AttributionTab.tsx +507 -106
- package/src/client/attribution/CaseView.tsx +9 -14
- package/src/client/attribution/GraphStage.tsx +25 -18
- package/src/client/attribution/RunView.tsx +27 -9
- package/src/client/attribution/StageChrome.tsx +296 -140
- package/src/client/attribution/graph-options.ts +611 -47
- package/src/client/attribution/model.ts +559 -30
- package/src/client/convert/ConvertPanel.tsx +6 -1
- package/src/client/doc-page.css +134 -0
- package/src/client/doc-page.tsx +263 -0
- package/src/client/panel-state.ts +105 -0
- package/src/client/request.ts +36 -0
- package/src/client/styles.css +317 -43
- package/src/client/useFitScale.ts +15 -2
- package/src/client/wiki-type-groups.ts +86 -0
- package/src/convert/host.ts +41 -7
- package/src/diagnosis/cards.ts +120 -50
- package/src/diagnosis/run.ts +245 -0
- package/src/diagnosis/schema.ts +67 -26
- package/src/diagnosis/seed-source.ts +28 -3
- package/src/diagnosis/seed.ts +187 -85
- package/src/diagnosis/store.ts +358 -174
- package/src/diagnosis/types.ts +181 -16
- package/src/document-page.ts +51 -0
- package/src/fs-atomic.ts +42 -0
- package/src/index.ts +209 -22
- package/src/knowledge-bases.ts +20 -18
- package/src/proposals.ts +337 -0
- package/src/runtime.ts +736 -135
- package/src/types.ts +113 -19
- package/src/wiki-types.ts +17 -27
package/lib/types/runtime.js
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { appendFile, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises';
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { buildSourceTypeCatalog, defaultSourceTypes, DEFAULT_KNOWLEDGE_CONFIG, deriveSourceExt, FALLBACK_EMBEDDING_MODEL, FALLBACK_RERANK_MODEL, legacyResourceHome, SOURCE_TYPE_ID_PATTERN, LOCAL_EMBEDDING_MODEL_DIR, LOCAL_RERANK_MODEL_DIR, } from './types.js';
|
|
8
8
|
import { defaultWikiTypes, validateCardAgainstType, WIKI_TYPE_RULES } from './wiki-types.js';
|
|
9
|
+
import { LEGACY_ENTITY_TYPES } from './diagnosis/schema.js';
|
|
10
|
+
import { CardProposalStore } from './proposals.js';
|
|
11
|
+
import { DiagnosisStore } from './diagnosis/store.js';
|
|
9
12
|
import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandidates, RELATES_TO } from './extract.js';
|
|
10
13
|
import { extractWithModel } from './extract-model.js';
|
|
11
14
|
import { FTS_FILE, FtsIndex, loadSqlite, segment } from './fts.js';
|
|
12
15
|
import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, rrfFuse, selectContext, tokenSet } from './recall-rank.js';
|
|
13
16
|
import { legacyKnowledgeRoot } from './layout.js';
|
|
17
|
+
import { unlinkIfExists, writeBufferAtomic, writeTextAtomic } from './fs-atomic.js';
|
|
14
18
|
import { envelopeFailure, envelopeSuccess } from 'dsh-data-contracts/envelope';
|
|
15
|
-
//
|
|
16
|
-
// 和 03_compiled 同性质,所以给独立编号段而不是塞进源头目录。
|
|
19
|
+
// **知识树只有三段**(v1.1 起):`01_source_truth` / `02_wiki` / `03_compiled`。
|
|
17
20
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// 撤掉的两段,理由都写在这里,避免下一个人重新加回来:
|
|
22
|
+
//
|
|
23
|
+
// - `00_schema`(0030 撤):它从建出来那天起就是一个**空目录**。分类注册表与 Wiki 类型
|
|
24
|
+
// 注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是**配置**,跟着根路径
|
|
25
|
+
// 走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。「磁盘上有一个文件夹」
|
|
26
|
+
// 本身是一种承诺,承诺了却空着就是误导。**一句话:schema 在设置里编辑,不在磁盘上。**
|
|
27
|
+
// - `05_diagnosis`(v1.1 撤):它同时装着两类东西 —— 「一次排查的产物」(源头)与
|
|
28
|
+
// 「五类模板卡」(配置)—— 一个编号段混装两种性质,`rebuild --only` 的语义立刻含糊。
|
|
29
|
+
// 现在:一次排查的产物是一条 `agent_diagnosis_case` 的 SourceRaw(落 `01_source_truth/`),
|
|
30
|
+
// 模板卡由类型定义派生(不落盘)。旧目录里的 `*.jsonl` 降为**只读的迁移输入与历史快照**。
|
|
31
|
+
const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled'];
|
|
32
|
+
/**
|
|
33
|
+
* 卡片正文里那个机器可读块的围栏标记。
|
|
34
|
+
*
|
|
35
|
+
* 与 `diagnosis/cards.ts` 的 `BLOCK_FENCE` 是同一个字符串,但这里不能 import 它 ——
|
|
36
|
+
* 迁移要在**不解析卡片**的前提下改块里的一个字段名(旧块的 `failure_mode`),
|
|
37
|
+
* 而 `cards.ts` 的 `parseCard` 已经把旧字段名归一化了,读回来的对象里根本没有它。
|
|
38
|
+
*/
|
|
39
|
+
const DIAGNOSIS_BLOCK_FENCE = 'json diagnosis';
|
|
23
40
|
/**
|
|
24
41
|
* SourceRaw 段。段内**按来源分类再分一层**:`<段>/<source_type>/<docSlug>.md`。
|
|
25
42
|
*
|
|
@@ -55,6 +72,16 @@ const VECTOR_QUANTIZATION = 'binary+hamming+int8';
|
|
|
55
72
|
const ENCODER_FINGERPRINT = 'wemm-official-processor-v1';
|
|
56
73
|
/** Stage 2 output consumed by stage 3 recall. */
|
|
57
74
|
const WIKI_NOTES = 'wiki_notes.json';
|
|
75
|
+
/**
|
|
76
|
+
* 归因图谱的编译产物(0021 §14.3)。
|
|
77
|
+
*
|
|
78
|
+
* 在这之前 `cardsToGraph()` 是**读的时候现算**的,归因图不落 `03_compiled` —— 于是
|
|
79
|
+
* `build-manifest.json` 不知道它存在(回答不了「这张图是哪个版本编出来的」),
|
|
80
|
+
* 而图召回也读不到它。现在它和 `knowledge_graph.json` 一起由同一次 `rebuild` 产出,
|
|
81
|
+
* **但保留两种粒度**:卡片级回答「知识长什么样」,字段级回答「这条路怎么走」。
|
|
82
|
+
* 合成一张会让图谱 Tab 看到一张卡片散成七个点(§14.4 的三条理由)。
|
|
83
|
+
*/
|
|
84
|
+
const DIAGNOSIS_GRAPH = 'diagnosis_graph.json';
|
|
58
85
|
/** Pre-0014 artifact name; kept readable so an upgrade does not force a rebuild. */
|
|
59
86
|
const LEGACY_UNITS = 'knowledge_units.json';
|
|
60
87
|
/**
|
|
@@ -101,6 +128,20 @@ const NOTE_SPLIT_MIN_CHARS = 800;
|
|
|
101
128
|
const RELATION_QUERY_WORDS = {
|
|
102
129
|
supported_by: 'supported by source 来源 出处 依据 支撑',
|
|
103
130
|
[RELATES_TO]: 'relates to related 相关 关联 关系 有关',
|
|
131
|
+
// 归因图谱的十种边(0021 §4.2)。它们住在 `diagnosis_graph.json` 里,
|
|
132
|
+
// 但**按关系词命中**这条读法是同一套:中文提问不会写 `may_be_caused_by`。
|
|
133
|
+
// 少了这十行,图召回对 `refutes` / `verified_by` 这些语义完全不可见 ——
|
|
134
|
+
// 而「上次这条假设是被什么否定的」正是最该被召回到的东西。
|
|
135
|
+
may_be_caused_by: 'may be caused by 可能是原因 可能原因 候选原因 怀疑',
|
|
136
|
+
verified_by: 'verified by 由…验证 怎么验证 用哪个检查 检查点',
|
|
137
|
+
categorized_as: 'categorized as 归类为 属于哪一类 失败原因 切面',
|
|
138
|
+
queries: 'queries 查询依据 要去查 查哪些表 影响面',
|
|
139
|
+
produces: 'produces 产出 产出了什么 跑出了什么',
|
|
140
|
+
supports: 'supports 支持 印证 算数',
|
|
141
|
+
refutes: 'refutes 否定 排除 不成立 反例',
|
|
142
|
+
confirmed_as: 'confirmed as 确认为根因 确认根因 定位到',
|
|
143
|
+
instance_of: 'instance of 实例属于 哪次排查 案例',
|
|
144
|
+
indexed_from: 'indexed from 反查索引 落在哪个对象上 相关资产',
|
|
104
145
|
};
|
|
105
146
|
/**
|
|
106
147
|
* 融合与阈值的常数(0031)。
|
|
@@ -159,16 +200,43 @@ function slug(value) {
|
|
|
159
200
|
function hash(value) { return createHash('sha256').update(value).digest('hex').slice(0, 24); }
|
|
160
201
|
/** The content hash used for `KnowledgeDocument.contentHash` and version ids. */
|
|
161
202
|
function contentHashOf(content) { return hash(content); }
|
|
203
|
+
/** 候选的别名集合:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。 */
|
|
204
|
+
function aliasesOf(candidate) {
|
|
205
|
+
return [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))];
|
|
206
|
+
}
|
|
162
207
|
/**
|
|
163
|
-
*
|
|
208
|
+
* 把一次「并入已有卡片」累积进 `pending`。
|
|
164
209
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* 已经在正文里出现过的那一段不再追加:同一份来源重建、或同一段被两个候选命中时,
|
|
170
|
-
* 卡片不该越编译越长。判重按整段文本,所以它是幂等的。
|
|
210
|
+
* 已有卡片只读,所以累积的是**提议的正文**:`appendExtracted` 从卡片当时的正文出发,
|
|
211
|
+
* 把这一轮所有命中的正文依次追加进去。同一条提议被追加两次正文时不会重复
|
|
212
|
+
* (`appendExtracted` 自带 `includes` 判重)。
|
|
171
213
|
*/
|
|
214
|
+
function mergeProposal(pending, card, sourceId, bodies, keys) {
|
|
215
|
+
const key = `${card.entityType}:${card.entityId}`;
|
|
216
|
+
const previous = pending.get(key);
|
|
217
|
+
const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : [];
|
|
218
|
+
// 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
|
|
219
|
+
const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20);
|
|
220
|
+
if (previous) {
|
|
221
|
+
previous.content = appendExtracted(previous.content, bodies);
|
|
222
|
+
if (!previous.sourceRefs.includes(sourceId))
|
|
223
|
+
previous.sourceRefs.push(sourceId);
|
|
224
|
+
previous.metadata = { ...previous.metadata, extraction_merges };
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
pending.set(key, {
|
|
228
|
+
action: 'update',
|
|
229
|
+
entityType: card.entityType,
|
|
230
|
+
entityId: card.entityId,
|
|
231
|
+
title: card.title,
|
|
232
|
+
// **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
|
|
233
|
+
// 于是知识是累积的,来源改写了也不会停在旧版本上。
|
|
234
|
+
content: appendExtracted(card.content, bodies),
|
|
235
|
+
sourceRefs: [...card.sourceRefs, sourceId],
|
|
236
|
+
metadata: { ...card.metadata, extraction_merges },
|
|
237
|
+
baseHash: contentHashOf(card.content),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
172
240
|
function appendExtracted(existing, additions) {
|
|
173
241
|
let content = existing;
|
|
174
242
|
for (const addition of additions) {
|
|
@@ -218,9 +286,20 @@ function emptyDocumentDetail(docId) {
|
|
|
218
286
|
return {
|
|
219
287
|
docId, title: docId, sourceType: '', sourceTypeKnown: false, sourceFormat: '', parser: '', externalId: '', contentHash: '',
|
|
220
288
|
bytes: 0, assetCount: 0, createdAt: '', updatedAt: '', versionCount: 0,
|
|
221
|
-
relativePath: '', ext: '', content: '', frontmatter: {}, assets: [],
|
|
289
|
+
relativePath: '', ext: '', content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [],
|
|
222
290
|
};
|
|
223
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* 抽屉里的原文预览**只发**正文的前这么多字符。
|
|
294
|
+
*
|
|
295
|
+
* 依据是实跑反馈:一份几十万字的转换文档,抽屉只渲染前 4000 字符(更长的在
|
|
296
|
+
* 380px 宽的抽屉里没有阅读意义),而接口原来把**整份正文**发过去 —— 传输、JSON 解析、
|
|
297
|
+
* 塞进 React state 全按全文的量级付费,于是「打开弹层要等半天」。上面那段封顶之后,
|
|
298
|
+
* 弹层的耗时与文档大小**脱钩**;要读全文走独立整页(分页取,见 `documentContentPage`)。
|
|
299
|
+
*
|
|
300
|
+
* 4000 是用户明确要的阈值,所以它是一个具名常量而不是散在各处的字面量。
|
|
301
|
+
*/
|
|
302
|
+
export const SOURCE_PREVIEW_CHARS = 4000;
|
|
224
303
|
function generatedId(prefix) { return `${prefix}_${hash(`${Date.now()}:${Math.random()}`)}`; }
|
|
225
304
|
function parseLineage(value) {
|
|
226
305
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -285,41 +364,21 @@ export function parseMarkdown(source) {
|
|
|
285
364
|
}
|
|
286
365
|
return { frontmatter, content: source.slice(end + 4).trim() };
|
|
287
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* 把旧卡片结构化块里的 `failure_mode` 改成 `failure_reason`(0040 的第二次改名)。
|
|
369
|
+
*
|
|
370
|
+
* 只动那个围栏块里的键名,不动正文 —— 正文里出现「failure_mode」这个词完全可能是
|
|
371
|
+
* 有意写的(比如一张讲 FMEA 的卡片)。做字符串级替换而不是解析再序列化,是因为
|
|
372
|
+
* 解析会**丢掉格式**(缩进、键序),而迁移的第一条纪律是不制造无谓的 diff。
|
|
373
|
+
*/
|
|
374
|
+
function rewriteLegacyBlockFields(content) {
|
|
375
|
+
const pattern = new RegExp('(```' + DIAGNOSIS_BLOCK_FENCE + '\\s*\\n)([\\s\\S]*?)(```)');
|
|
376
|
+
return content.replace(pattern, (_all, head, body, tail) => head + body.replace(/"failure_mode"(\s*:)/g, '"failure_reason"$1') + tail);
|
|
377
|
+
}
|
|
288
378
|
function markdown(frontmatter, content) {
|
|
289
379
|
const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`);
|
|
290
380
|
return `---\n${lines.join('\n')}\n---\n\n${content.trim()}\n`;
|
|
291
381
|
}
|
|
292
|
-
async function writeTextAtomic(path, content) {
|
|
293
|
-
// 原子写需要一个与目标同目录的临时文件,所以目标目录必须先存在。0030 起
|
|
294
|
-
// SourceRaw 落在 `<段>/<source_type>/` 下,而 `ensureLayout` 只建段目录 —— 少了
|
|
295
|
-
// 这一行,第一次写某一种分类就会 ENOENT(实测)。
|
|
296
|
-
await mkdir(dirname(path), { recursive: true });
|
|
297
|
-
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
298
|
-
try {
|
|
299
|
-
await writeFile(temporary, content, 'utf8');
|
|
300
|
-
await rename(temporary, path);
|
|
301
|
-
}
|
|
302
|
-
finally {
|
|
303
|
-
await unlinkIfExists(temporary);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
async function writeBufferAtomic(path, content) {
|
|
307
|
-
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
308
|
-
try {
|
|
309
|
-
await writeFile(temporary, content);
|
|
310
|
-
await rename(temporary, path);
|
|
311
|
-
}
|
|
312
|
-
finally {
|
|
313
|
-
await unlinkIfExists(temporary);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
async function unlinkIfExists(path) {
|
|
317
|
-
try {
|
|
318
|
-
const { unlink } = await import('node:fs/promises');
|
|
319
|
-
await unlink(path);
|
|
320
|
-
}
|
|
321
|
-
catch { /* rename already consumed the temp file */ }
|
|
322
|
-
}
|
|
323
382
|
async function filesUnder(root) {
|
|
324
383
|
try {
|
|
325
384
|
const entries = await readdir(root, { withFileTypes: true });
|
|
@@ -615,6 +674,12 @@ export class KnowledgeRuntime {
|
|
|
615
674
|
sourceIndex;
|
|
616
675
|
/** 段内布局整理是否已经跑过(每个 runtime 一次)。 */
|
|
617
676
|
segmentsOrganized = false;
|
|
677
|
+
/**
|
|
678
|
+
* 一次性迁移里**没有静默处理掉**的事(改名冲突、迁移后比对不一致)。
|
|
679
|
+
*
|
|
680
|
+
* 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
|
|
681
|
+
*/
|
|
682
|
+
migrationProblems = [];
|
|
618
683
|
vectorIndex = null;
|
|
619
684
|
vectorError;
|
|
620
685
|
embeddingWorker = null;
|
|
@@ -715,19 +780,179 @@ export class KnowledgeRuntime {
|
|
|
715
780
|
}
|
|
716
781
|
/**
|
|
717
782
|
* Agent 与设置面板共用的类型目录:注册表 + 生效方式 + 内置只读判定规则 +
|
|
718
|
-
* 现有卡片的三层校验结果(`report` 模式下这是"待补"
|
|
783
|
+
* 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)+ **待审提议的条数**。
|
|
784
|
+
*
|
|
785
|
+
* 提议计数挂在这里而不是另开一路,是因为 review 就发生在这一屏(「知识库 → Wiki」的
|
|
786
|
+
* 待办区)—— 与 Pass 0 的候选草案、lint 待补清单是同一处,不新造第二套 review 界面。
|
|
719
787
|
*/
|
|
720
788
|
async wikiTypeCatalog() {
|
|
721
789
|
const schema = this.wikiSchema();
|
|
722
790
|
const issues = await this.lintCards();
|
|
791
|
+
const all = await this.cardProposals().list();
|
|
723
792
|
return success({
|
|
724
793
|
types: schema.types,
|
|
725
794
|
mode: schema.mode,
|
|
726
795
|
rules: WIKI_TYPE_RULES,
|
|
727
796
|
issues: issues.slice(0, 50),
|
|
728
797
|
issueCount: issues.length,
|
|
798
|
+
proposals: {
|
|
799
|
+
pending: all.filter(item => item.status === 'pending').length,
|
|
800
|
+
rejected: all.filter(item => item.status === 'rejected').length,
|
|
801
|
+
confirmed: all.filter(item => item.status === 'confirmed' && !item.writtenAt).length,
|
|
802
|
+
},
|
|
729
803
|
});
|
|
730
804
|
}
|
|
805
|
+
/**
|
|
806
|
+
* 智能归因图谱的存储句柄。
|
|
807
|
+
*
|
|
808
|
+
* 它由 runtime 自己构造(而不是由 index.ts 注入),因为 `rebuild` 要产出
|
|
809
|
+
* `03_compiled/diagnosis_graph.json` —— 那次产出必须和 `knowledge_graph.json`
|
|
810
|
+
* 在同一次卡片遍历的语义下发生,否则两份产物会来自两个不同版本的卡片。
|
|
811
|
+
*/
|
|
812
|
+
diagnosis;
|
|
813
|
+
diagnosisStore() {
|
|
814
|
+
if (!this.diagnosis)
|
|
815
|
+
this.diagnosis = new DiagnosisStore(this, this.cardProposals());
|
|
816
|
+
return this.diagnosis;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* 卡片提议的存储(0021 §6.1 的那道门)。
|
|
820
|
+
*
|
|
821
|
+
* 它按**记忆段**(`<agentRoot>/memory/<库 id>`)落,不在知识树里 —— 理由写在
|
|
822
|
+
* `proposals.ts` 顶上:提议是「关于 Agent 动作的待办留痕」,删掉只丢待办、
|
|
823
|
+
* 重跑一次提议就能回来,所以它既不属于「源头」(删了就丢数据的那一段),
|
|
824
|
+
* 更不能落 `02_wiki/`(那正是这道门要拦的事)。
|
|
825
|
+
*/
|
|
826
|
+
proposalStore;
|
|
827
|
+
cardProposals() {
|
|
828
|
+
if (!this.proposalStore) {
|
|
829
|
+
this.proposalStore = new CardProposalStore({
|
|
830
|
+
memoryRoot: this.memoryDir,
|
|
831
|
+
schema: () => ({ types: this.wikiSchema().types }),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return this.proposalStore;
|
|
835
|
+
}
|
|
836
|
+
/* ────────────────────────────────────── 卡片提议:提议 → 确认 ── */
|
|
837
|
+
/**
|
|
838
|
+
* 提一批卡片提议。**它不写任何卡片**(0021 §6.1)。
|
|
839
|
+
*
|
|
840
|
+
* 这是 Agent 唯一能碰类型层的入口:把草案挂到触发它的那条 SourceRaw 上,
|
|
841
|
+
* 由人在面板上确认之后才落卡。未确认的提议不落 `02_wiki/`、不进编译、
|
|
842
|
+
* 不参与召回、不在图上出现。
|
|
843
|
+
*/
|
|
844
|
+
async proposeCards(input) {
|
|
845
|
+
const empty = { proposed: 0, ids: [], rejected: [] };
|
|
846
|
+
if (!input.sourceId?.trim())
|
|
847
|
+
return failure(empty, 'SOURCE_REF_REQUIRED', '提议必须挂在一条触发它的 SourceRaw 上', false, '先调 add_source 落一条来源,再用它的 source_id 提提议。');
|
|
848
|
+
const sources = new Set((await this.loadSources()).map(source => source.sourceId));
|
|
849
|
+
if (!sources.has(input.sourceId)) {
|
|
850
|
+
return failure(empty, 'SOURCE_REF_NOT_FOUND', `source_refs 引用不存在: ${input.sourceId}`, false, '先调用 add_source 写入对应的 SourceTruth');
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(input.proposals) || input.proposals.length === 0) {
|
|
853
|
+
return failure(empty, 'PROPOSAL_EMPTY', '至少要提一条提议', true);
|
|
854
|
+
}
|
|
855
|
+
const schema = this.wikiSchema();
|
|
856
|
+
const rejected = [];
|
|
857
|
+
const accepted = input.proposals.filter(item => {
|
|
858
|
+
const type = schema.types.find(def => def.id === item.entityType);
|
|
859
|
+
if (!type) {
|
|
860
|
+
rejected.push(`${item.entityId}:未知卡片类型 ${item.entityType}`);
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
if (!type.enabled) {
|
|
864
|
+
rejected.push(`${item.entityId}:类型 ${item.entityType} 已停用`);
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
if (!SAFE_ID.test(item.entityId)) {
|
|
868
|
+
rejected.push(`${item.entityId}:entity_id 形状不合法`);
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
if (!item.title?.trim() || !item.content?.trim()) {
|
|
872
|
+
rejected.push(`${item.entityId}:title 与 content 都不能为空`);
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
return true;
|
|
876
|
+
});
|
|
877
|
+
const touched = await this.cardProposals().upsert(accepted.map(item => ({
|
|
878
|
+
sourceRef: input.sourceId,
|
|
879
|
+
trigger: 'agent',
|
|
880
|
+
action: item.action ?? 'create',
|
|
881
|
+
entityType: item.entityType,
|
|
882
|
+
entityId: item.entityId,
|
|
883
|
+
title: item.title.trim(),
|
|
884
|
+
...(item.summary ? { summary: item.summary } : {}),
|
|
885
|
+
content: item.content,
|
|
886
|
+
sourceRefs: item.sourceRefs ?? [input.sourceId],
|
|
887
|
+
...(item.metadata ? { metadata: item.metadata } : {}),
|
|
888
|
+
})));
|
|
889
|
+
return success({ proposed: touched.length, ids: touched.map(item => item.id), rejected }, rejected.length > 0 ? [`${rejected.length} 条提议被拒:${rejected.join(';')}`] : []);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* 把一条**已确认**的提议落成卡片。
|
|
893
|
+
*
|
|
894
|
+
* 三道检查,缺一条这道门就有缝:
|
|
895
|
+
* 1. 提议存在,且状态是 `confirmed`(人点过「通过」);
|
|
896
|
+
* 2. **还没落过卡**(`writtenAt` 为空)—— 确认凭证是**一次性**的;
|
|
897
|
+
* 3. 再跑一遍三层校验(0021 §6.5):提议阶段那一遍是 Agent 自己补的,
|
|
898
|
+
* 这一遍的修改可能来自人,两道都跑才拦得住「改完又破了约束」。
|
|
899
|
+
*/
|
|
900
|
+
async applyProposal(proposalId, lineage) {
|
|
901
|
+
const store = this.cardProposals();
|
|
902
|
+
const empty = { entityId: '', path: '', proposalId };
|
|
903
|
+
const proposal = await store.get(proposalId);
|
|
904
|
+
if (!proposal)
|
|
905
|
+
return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在: ${proposalId}`, true);
|
|
906
|
+
if (proposal.status === 'pending') {
|
|
907
|
+
return failure(empty, 'CARD_PROPOSAL_NOT_CONFIRMED', `提议尚未经人确认: ${proposalId}`, false, '未确认的提议不落卡片 —— 这正是这道门的意义。请在「知识库 → Wiki」的待办区确认它。');
|
|
908
|
+
}
|
|
909
|
+
if (proposal.status === 'rejected') {
|
|
910
|
+
return failure(empty, 'CARD_PROPOSAL_REJECTED', `提议已被拒绝: ${proposalId}`, false, proposal.reason ? `拒绝原因:${proposal.reason}` : '被拒绝的提议不会再落卡;内容变了才会重新提。');
|
|
911
|
+
}
|
|
912
|
+
if (proposal.writtenAt) {
|
|
913
|
+
return failure(empty, 'CARD_PROPOSAL_ALREADY_WRITTEN', `提议已经落过卡: ${proposalId}`, false, '确认凭证是一次性的;要改卡片请直接改卡片,或重新提一条提议。');
|
|
914
|
+
}
|
|
915
|
+
const written = await this.writeCard({
|
|
916
|
+
entityType: proposal.entityType,
|
|
917
|
+
entityId: proposal.entityId,
|
|
918
|
+
title: proposal.title,
|
|
919
|
+
content: proposal.content,
|
|
920
|
+
sourceRefs: proposal.sourceRefs,
|
|
921
|
+
...(proposal.summary ? { summary: proposal.summary } : {}),
|
|
922
|
+
...(proposal.metadata ? { metadata: proposal.metadata } : {}),
|
|
923
|
+
...(lineage ? { lineage } : {}),
|
|
924
|
+
});
|
|
925
|
+
if (written.ok === false)
|
|
926
|
+
return failure(empty, written.error.code, written.error.message, written.error.retryable, written.error.suggestion);
|
|
927
|
+
await store.markWritten(proposalId);
|
|
928
|
+
return success({ entityId: proposal.entityId, path: written.data.path, proposalId });
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* 人的 review 动作(面板调用,**不是工具**)。
|
|
932
|
+
*
|
|
933
|
+
* Agent 拿不到这个入口 —— 它只能 `proposeCards` 与 `applyProposal`,而后者要求
|
|
934
|
+
* 一条已经被人确认过的提议。确认权收在人手里,这是 §6.1 那条硬规则的结构保证。
|
|
935
|
+
*/
|
|
936
|
+
async reviewProposal(input) {
|
|
937
|
+
const store = this.cardProposals();
|
|
938
|
+
const reviewer = input.reviewer?.trim() || 'user:panel';
|
|
939
|
+
const empty = { proposalId: input.proposalId, status: '', written: false };
|
|
940
|
+
if (input.action === 'reject') {
|
|
941
|
+
const rejected = await store.reject(input.proposalId, input.reason ?? '', reviewer);
|
|
942
|
+
if (!rejected)
|
|
943
|
+
return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在或已不在待审状态: ${input.proposalId}`, true);
|
|
944
|
+
return success({ proposalId: rejected.id, status: rejected.status, written: false });
|
|
945
|
+
}
|
|
946
|
+
const confirmed = await store.confirm(input.proposalId, reviewer, input.patch);
|
|
947
|
+
if (!confirmed)
|
|
948
|
+
return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在或已不在待审状态: ${input.proposalId}`, true);
|
|
949
|
+
if (input.action === 'confirm')
|
|
950
|
+
return success({ proposalId: confirmed.id, status: confirmed.status, written: false });
|
|
951
|
+
const applied = await this.applyProposal(confirmed.id, input.lineage);
|
|
952
|
+
if (applied.ok === false)
|
|
953
|
+
return failure(empty, applied.error.code, applied.error.message, applied.error.retryable, applied.error.suggestion);
|
|
954
|
+
return success({ proposalId: confirmed.id, status: 'confirmed', written: true });
|
|
955
|
+
}
|
|
731
956
|
/** 对已落库的卡片跑三层校验。不写盘,只报告。 */
|
|
732
957
|
async lintCards() {
|
|
733
958
|
const schema = this.wikiSchema();
|
|
@@ -824,7 +1049,9 @@ export class KnowledgeRuntime {
|
|
|
824
1049
|
if (!this.segmentsOrganized) {
|
|
825
1050
|
this.segmentsOrganized = true;
|
|
826
1051
|
await this.migrateSourceTruthLayout();
|
|
827
|
-
await this.
|
|
1052
|
+
await this.dropRetiredSegments();
|
|
1053
|
+
await this.migrateLegacySourceTypeRename();
|
|
1054
|
+
await this.migrateLegacyDiagnosisCards();
|
|
828
1055
|
}
|
|
829
1056
|
}
|
|
830
1057
|
/**
|
|
@@ -874,8 +1101,36 @@ export class KnowledgeRuntime {
|
|
|
874
1101
|
* 有人放了文件,那就不属于「没用的空文件夹」了 —— 那种情况下留着并让人自己判断,
|
|
875
1102
|
* 比替他们删掉安全。
|
|
876
1103
|
*/
|
|
877
|
-
|
|
878
|
-
|
|
1104
|
+
/**
|
|
1105
|
+
* 清掉**只装了旧版本自己生成的东西**的退役段。
|
|
1106
|
+
*
|
|
1107
|
+
* `00_schema` 从来没有一行代码写过东西,所以「空」就是它的全部历史形态 —— 空就删。
|
|
1108
|
+
*
|
|
1109
|
+
* `05_diagnosis` 不一样:旧版本在里面铺过 `templates/*.md` 与一份 README,那两样是
|
|
1110
|
+
* 可以重建的(模板由类型定义派生、README 是说明),删掉不丢知识。但同一个目录里
|
|
1111
|
+
* 可能躺着**真正的历史快照**(`*.jsonl` / `cases.json`),那是只读的迁移输入,
|
|
1112
|
+
* 一个字节都不能动。所以判据是**文件清单**而不是「空不空」:只认得旧版本自己写的
|
|
1113
|
+
* 那些名字时,才删;出现任何别的东西就整个留着。
|
|
1114
|
+
*/
|
|
1115
|
+
async dropRetiredSegments() {
|
|
1116
|
+
await this.dropSegmentIfEmpty('00_schema');
|
|
1117
|
+
const legacy = this.safePath('05_diagnosis');
|
|
1118
|
+
try {
|
|
1119
|
+
const entries = await readdir(legacy, { withFileTypes: true });
|
|
1120
|
+
const owned = entries.every(entry => entry.isDirectory()
|
|
1121
|
+
? entry.name === 'templates'
|
|
1122
|
+
: entry.name === 'README.md' || entry.name.endsWith('.jsonl') || entry.name === 'cases.json');
|
|
1123
|
+
if (!owned)
|
|
1124
|
+
return;
|
|
1125
|
+
// 有 jsonl / cases.json 就是历史快照,留着(`DiagnosisStore` 还要读它做迁移)。
|
|
1126
|
+
if (entries.some(entry => entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'cases.json')))
|
|
1127
|
+
return;
|
|
1128
|
+
await rm(legacy, { recursive: true, force: true });
|
|
1129
|
+
}
|
|
1130
|
+
catch { /* 不存在或删不掉都不是错误 */ }
|
|
1131
|
+
}
|
|
1132
|
+
async dropSegmentIfEmpty(name) {
|
|
1133
|
+
const path = this.safePath(name);
|
|
879
1134
|
try {
|
|
880
1135
|
const entries = await readdir(path);
|
|
881
1136
|
if (entries.length > 0)
|
|
@@ -887,6 +1142,171 @@ export class KnowledgeRuntime {
|
|
|
887
1142
|
}
|
|
888
1143
|
catch { /* 不存在、非空、或删不掉都不是错误 */ }
|
|
889
1144
|
}
|
|
1145
|
+
/**
|
|
1146
|
+
* 0040 §3.2 的一次性迁移:`01_source_truth/ai_chat_message/` → `agent_chat_message/`。
|
|
1147
|
+
*
|
|
1148
|
+
* 改名不能只改注册表:`01_source_truth/` 是**按分类分目录**的,存量来源的
|
|
1149
|
+
* frontmatter 里写着 `source_type: ai_chat_message`。只改注册表会有两个后果 ——
|
|
1150
|
+
* 旧目录里那些来源变成一个「注册表里没有的分类」(界面上显示成未知分类),
|
|
1151
|
+
* 而旧分类因为还有来源在,永远删不掉。
|
|
1152
|
+
*
|
|
1153
|
+
* 三条纪律:
|
|
1154
|
+
* 1. **逐份搬,不整体 rename 目录。** 目标里可能已经有同名文件(用户在新版本里
|
|
1155
|
+
* 重新录过同一份来源),整体搬会覆盖它。
|
|
1156
|
+
* 2. **同名时比对正文**:一致就删掉旧的那份(同一份东西,只是目录名变了);
|
|
1157
|
+
* 不一致就**两边都留**,并把冲突报给界面 —— 静默覆盖等于丢数据。
|
|
1158
|
+
* 3. **迁移无损才认**:写完读回来比对 frontmatter 与正文,不一致就放弃这一份。
|
|
1159
|
+
*/
|
|
1160
|
+
async migrateLegacySourceTypeRename() {
|
|
1161
|
+
const from = this.safePath(SOURCE_TRUTH, 'ai_chat_message');
|
|
1162
|
+
const to = this.safePath(SOURCE_TRUTH, 'agent_chat_message');
|
|
1163
|
+
let names;
|
|
1164
|
+
try {
|
|
1165
|
+
names = await readdir(from);
|
|
1166
|
+
}
|
|
1167
|
+
catch {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const conflicts = [];
|
|
1171
|
+
for (const name of names) {
|
|
1172
|
+
if (extname(name).toLowerCase() !== '.md')
|
|
1173
|
+
continue;
|
|
1174
|
+
const source = join(from, name);
|
|
1175
|
+
let raw;
|
|
1176
|
+
try {
|
|
1177
|
+
raw = await readFile(source, 'utf8');
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
const parsed = parseMarkdown(raw);
|
|
1183
|
+
const sourceId = String(parsed.frontmatter.source_id ?? basename(name, '.md'));
|
|
1184
|
+
const target = join(to, name);
|
|
1185
|
+
let existing;
|
|
1186
|
+
try {
|
|
1187
|
+
existing = await readFile(target, 'utf8');
|
|
1188
|
+
}
|
|
1189
|
+
catch { /* 目标里还没有 */ }
|
|
1190
|
+
if (existing !== undefined) {
|
|
1191
|
+
const previous = parseMarkdown(existing);
|
|
1192
|
+
if (previous.content.trim() !== parsed.content.trim())
|
|
1193
|
+
conflicts.push(sourceId);
|
|
1194
|
+
await unlinkIfExists(source);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
await writeTextAtomic(target, markdown({ ...parsed.frontmatter, source_type: 'agent_chat_message' }, parsed.content));
|
|
1198
|
+
const roundTrip = parseMarkdown(await readFile(target, 'utf8'));
|
|
1199
|
+
if (roundTrip.content.trim() !== parsed.content.trim() || roundTrip.frontmatter.source_type !== 'agent_chat_message') {
|
|
1200
|
+
// 读回来对不上:把目标撤掉,旧的那份留着 —— 宁可迁移没生效,也不要留半份。
|
|
1201
|
+
await unlinkIfExists(target);
|
|
1202
|
+
conflicts.push(`${sourceId}(迁移后比对不一致,已放弃)`);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
await unlinkIfExists(source);
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
await rmdir(from);
|
|
1209
|
+
}
|
|
1210
|
+
catch { /* 还有别的东西在里面 */ }
|
|
1211
|
+
if (conflicts.length > 0) {
|
|
1212
|
+
this.migrationProblems.push(`来源分类改名迁移有 ${conflicts.length} 处冲突,旧文件保留在 01_source_truth/ai_chat_message/:${conflicts.join('、')}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* 0040 §4 的一次性迁移:两次改名(`check` → `check_point`、`failure-mode` →
|
|
1217
|
+
* `failure_reason`)的存量卡片。
|
|
1218
|
+
*
|
|
1219
|
+
* 两件事一起做,因为它们是同一次改名的两个面:
|
|
1220
|
+
* 1. **卡片类型**:`02_wiki/diagnosis/check/` → `check-point/`,
|
|
1221
|
+
* frontmatter 的 `entity_type` 一起改;
|
|
1222
|
+
* 2. **结构化块里的字段名**:`failure_mode` → `failure_reason`。这一条与卡片类型
|
|
1223
|
+
* 无关 —— `diagnosis/hypothesis` 卡片的类型没变,但它块里那个字段变了。
|
|
1224
|
+
* 只按目录过滤的话,假设卡片上的旧字段名会永远留在磁盘上,而读回来是「少一个
|
|
1225
|
+
* 必填字段」,正文里却明明写着它。
|
|
1226
|
+
*
|
|
1227
|
+
* 读取端本来也认别名(`cards.parseCard`),但**只在内存里认是不够的**:`writeCard`
|
|
1228
|
+
* 按 `entity_type` 决定写哪个目录,改一张旧卡片就会在 `check-point/` 下多写一份,
|
|
1229
|
+
* 而旧目录里那份还在 —— 同一张卡片变成两张,读图时看谁先被遍历到。
|
|
1230
|
+
*
|
|
1231
|
+
* 用 `writeCard` 而不是自己拼 frontmatter:它是唯一知道卡片信封长什么样、并且会
|
|
1232
|
+
* 顺手做版本快照与三层校验的地方。
|
|
1233
|
+
*/
|
|
1234
|
+
async migrateLegacyDiagnosisCards() {
|
|
1235
|
+
const root = this.safePath(WIKI, 'diagnosis');
|
|
1236
|
+
let kinds;
|
|
1237
|
+
try {
|
|
1238
|
+
kinds = await readdir(root);
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* 目录名是 `entity_type` 的**命名空间之后那一段**(`02_wiki/diagnosis/check/` 对应
|
|
1245
|
+
* `diagnosis/check`)。用后缀匹配而不是拿 `entity_type` 直接比目录名 —— 直接比的话
|
|
1246
|
+
* 一个都匹配不上,而失败方式是静默的:迁移什么都没做,卡片仍按旧类型读。
|
|
1247
|
+
*/
|
|
1248
|
+
const legacyByDir = new Map();
|
|
1249
|
+
for (const [legacy, next] of Object.entries(LEGACY_ENTITY_TYPES)) {
|
|
1250
|
+
legacyByDir.set(legacy.slice(legacy.indexOf('/') + 1), next);
|
|
1251
|
+
}
|
|
1252
|
+
const problems = [];
|
|
1253
|
+
for (const dir of kinds) {
|
|
1254
|
+
const legacyDir = join(root, dir);
|
|
1255
|
+
const renamedType = legacyByDir.get(dir);
|
|
1256
|
+
// 类型没改名、也不在旧目录里的子树也要扫一遍:块字段名的改名与类型无关。
|
|
1257
|
+
for (const file of await filesUnder(legacyDir)) {
|
|
1258
|
+
let raw;
|
|
1259
|
+
try {
|
|
1260
|
+
raw = await readFile(file, 'utf8');
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
const parsed = parseMarkdown(raw);
|
|
1266
|
+
const entityId = String(parsed.frontmatter.entity_id ?? basename(file, '.md'));
|
|
1267
|
+
const currentType = String(parsed.frontmatter.entity_type ?? '');
|
|
1268
|
+
const nextType = renamedType ?? LEGACY_ENTITY_TYPES[currentType] ?? currentType;
|
|
1269
|
+
if (!nextType)
|
|
1270
|
+
continue;
|
|
1271
|
+
const body = rewriteLegacyBlockFields(parsed.content);
|
|
1272
|
+
const typeChanged = nextType !== currentType;
|
|
1273
|
+
const bodyChanged = body !== parsed.content;
|
|
1274
|
+
// 两样都没变就什么都不做 —— 每次启动都重写一遍所有卡片会把版本历史灌满。
|
|
1275
|
+
if (!typeChanged && !bodyChanged)
|
|
1276
|
+
continue;
|
|
1277
|
+
const written = await this.writeCard({
|
|
1278
|
+
entityType: nextType,
|
|
1279
|
+
entityId,
|
|
1280
|
+
title: String(parsed.frontmatter.title ?? entityId),
|
|
1281
|
+
content: body,
|
|
1282
|
+
sourceRefs: Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : [],
|
|
1283
|
+
status: (STATUS.includes(String(parsed.frontmatter.status)) ? String(parsed.frontmatter.status) : 'unreviewed'),
|
|
1284
|
+
...(typeof parsed.frontmatter.summary === 'string' && parsed.frontmatter.summary ? { summary: parsed.frontmatter.summary } : {}),
|
|
1285
|
+
...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
|
|
1286
|
+
metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata : {},
|
|
1287
|
+
});
|
|
1288
|
+
if (written.ok === false) {
|
|
1289
|
+
problems.push(`卡片 ${entityId} 从 ${currentType || dir} 迁到 ${nextType} 失败(${written.error.code}),旧文件保留`);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
// 读回来确认它真的落在新目录、而且认得出新类型,再删旧的。
|
|
1293
|
+
const readBack = await this.readCard(entityId, nextType);
|
|
1294
|
+
if (!readBack) {
|
|
1295
|
+
problems.push(`卡片 ${entityId} 迁移后读不回来,旧文件保留`);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
// 正文没变、类型也没变时,`writeCard` 写的是同一个路径 —— 这时**不要**删它。
|
|
1299
|
+
if (written.data.path !== file)
|
|
1300
|
+
await unlinkIfExists(file);
|
|
1301
|
+
}
|
|
1302
|
+
try {
|
|
1303
|
+
await rmdir(legacyDir);
|
|
1304
|
+
}
|
|
1305
|
+
catch { /* 还有别的东西在里面 */ }
|
|
1306
|
+
}
|
|
1307
|
+
if (problems.length > 0)
|
|
1308
|
+
this.migrationProblems.push(...problems);
|
|
1309
|
+
}
|
|
890
1310
|
async loadVectorIndex() {
|
|
891
1311
|
if (this.vectorIndex)
|
|
892
1312
|
return this.vectorIndex;
|
|
@@ -1129,6 +1549,12 @@ export class KnowledgeRuntime {
|
|
|
1129
1549
|
this.graph = graph;
|
|
1130
1550
|
}
|
|
1131
1551
|
catch { /* the first build has not happened yet */ }
|
|
1552
|
+
try {
|
|
1553
|
+
const diagnosis = JSON.parse(await readFile(this.safePath('03_compiled', DIAGNOSIS_GRAPH), 'utf8'));
|
|
1554
|
+
if (diagnosis && Array.isArray(diagnosis.nodes) && Array.isArray(diagnosis.edges))
|
|
1555
|
+
this.diagnosisGraph = diagnosis;
|
|
1556
|
+
}
|
|
1557
|
+
catch { /* 归因图还没编过(这个库没做过排查),或者旧版本没有这个产物 */ }
|
|
1132
1558
|
}
|
|
1133
1559
|
safePath(...parts) {
|
|
1134
1560
|
const path = resolve(this.rootDir, ...parts);
|
|
@@ -1189,6 +1615,12 @@ export class KnowledgeRuntime {
|
|
|
1189
1615
|
notes: this.units.length,
|
|
1190
1616
|
graphNodes: this.graph.nodes.length,
|
|
1191
1617
|
graphEdges: this.graph.edges.length,
|
|
1618
|
+
/**
|
|
1619
|
+
* 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
|
|
1620
|
+
*
|
|
1621
|
+
* 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
|
|
1622
|
+
*/
|
|
1623
|
+
migrationProblems: this.migrationProblems,
|
|
1192
1624
|
buildVersion: Number(buildManifest.version ?? 0),
|
|
1193
1625
|
builtAt: buildManifest.builtAt,
|
|
1194
1626
|
trackedCards: Object.keys(buildManifest.cardHashes ?? {}).length,
|
|
@@ -1388,8 +1820,14 @@ export class KnowledgeRuntime {
|
|
|
1388
1820
|
}
|
|
1389
1821
|
return assets.sort((left, right) => left.rel.localeCompare(right.rel));
|
|
1390
1822
|
}
|
|
1391
|
-
/**
|
|
1392
|
-
|
|
1823
|
+
/**
|
|
1824
|
+
* Parse one on-disk SourceRaw file into the detail projection.
|
|
1825
|
+
*
|
|
1826
|
+
* `previewChars` 给了就把正文截到那么长,并把**总长度**如实放进 `contentChars`
|
|
1827
|
+
* (见 `SOURCE_PREVIEW_CHARS` 的理由)。不给就是不截断 —— 那是「我确实要全文」的
|
|
1828
|
+
* 调用方,目前只有 `readDocumentBody()` 之外的内部路径会这么用。
|
|
1829
|
+
*/
|
|
1830
|
+
async readDocumentFile(path, previewChars) {
|
|
1393
1831
|
let raw;
|
|
1394
1832
|
try {
|
|
1395
1833
|
raw = await readFile(path, 'utf8');
|
|
@@ -1398,6 +1836,8 @@ export class KnowledgeRuntime {
|
|
|
1398
1836
|
return undefined;
|
|
1399
1837
|
}
|
|
1400
1838
|
const parsed = parseMarkdown(raw);
|
|
1839
|
+
const contentChars = parsed.content.length;
|
|
1840
|
+
const content = previewChars !== undefined && previewChars < contentChars ? parsed.content.slice(0, previewChars) : parsed.content;
|
|
1401
1841
|
const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'));
|
|
1402
1842
|
const sourceType = String(parsed.frontmatter.source_type ?? 'document');
|
|
1403
1843
|
// 未知分类照样投影出来(0013);前端按 sourceTypeKnown 渲染「未知分类」徽标。
|
|
@@ -1433,7 +1873,9 @@ export class KnowledgeRuntime {
|
|
|
1433
1873
|
versionCount: (await this.versionIds(sourceId)).length + 1,
|
|
1434
1874
|
relativePath: relative(this.rootDir, path).split(sep).join('/'),
|
|
1435
1875
|
ext,
|
|
1436
|
-
content
|
|
1876
|
+
content,
|
|
1877
|
+
contentChars,
|
|
1878
|
+
contentTruncated: content.length < contentChars,
|
|
1437
1879
|
frontmatter: parsed.frontmatter,
|
|
1438
1880
|
assets,
|
|
1439
1881
|
...(lineage ? { lineage: lineage } : {}),
|
|
@@ -1457,9 +1899,32 @@ export class KnowledgeRuntime {
|
|
|
1457
1899
|
return documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.docId.localeCompare(right.docId));
|
|
1458
1900
|
}
|
|
1459
1901
|
/** 文档详情:正文、frontmatter、资产与 lineage。 */
|
|
1460
|
-
|
|
1902
|
+
/**
|
|
1903
|
+
* 一份文档的详情投影,**正文默认按 `SOURCE_PREVIEW_CHARS` 封顶**。
|
|
1904
|
+
*
|
|
1905
|
+
* 这是明细接口(抽屉里的预览)读的那一份,所以默认就是「够预览」而不是「全文」:
|
|
1906
|
+
* 全文由 `readDocumentBody()` 提供服务,而它只服务独立整页的分页读取。
|
|
1907
|
+
*/
|
|
1908
|
+
async readDocument(docId, previewChars = SOURCE_PREVIEW_CHARS) {
|
|
1909
|
+
const path = await this.sourcePathOf(docId);
|
|
1910
|
+
return path ? this.readDocumentFile(path, previewChars) : undefined;
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* 一份文档的**完整正文**(不含 frontmatter),给独立整页分页取用。
|
|
1914
|
+
*
|
|
1915
|
+
* 每次调用都重读一遍盘:独立整页是「用户滚到哪儿取哪一页」,一次请求一段文本,
|
|
1916
|
+
* 谁都不该拿到整份文档 —— 这正是抽屉那一版的问题。缓存留到真的量出来是瓶颈再加。
|
|
1917
|
+
*/
|
|
1918
|
+
async readDocumentBody(docId) {
|
|
1461
1919
|
const path = await this.sourcePathOf(docId);
|
|
1462
|
-
|
|
1920
|
+
if (path === undefined)
|
|
1921
|
+
return undefined;
|
|
1922
|
+
try {
|
|
1923
|
+
return parseMarkdown(await readFile(path, 'utf8')).content;
|
|
1924
|
+
}
|
|
1925
|
+
catch {
|
|
1926
|
+
return undefined;
|
|
1927
|
+
}
|
|
1463
1928
|
}
|
|
1464
1929
|
/**
|
|
1465
1930
|
* 版本历史,新→旧。
|
|
@@ -1520,7 +1985,7 @@ export class KnowledgeRuntime {
|
|
|
1520
1985
|
const path = await this.sourcePathOf(docId);
|
|
1521
1986
|
if (path === undefined)
|
|
1522
1987
|
return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true);
|
|
1523
|
-
const head = await this.readDocumentFile(path);
|
|
1988
|
+
const head = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS);
|
|
1524
1989
|
if (!head)
|
|
1525
1990
|
return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true);
|
|
1526
1991
|
const target = typeof versionId === 'string' ? versionId.trim() : '';
|
|
@@ -1543,7 +2008,7 @@ export class KnowledgeRuntime {
|
|
|
1543
2008
|
});
|
|
1544
2009
|
if (!restored.ok)
|
|
1545
2010
|
return failure(head, restored.error?.code ?? 'REVERT_FAILED', restored.error?.message ?? '回滚写入失败', restored.error?.retryable ?? false, restored.error?.suggestion);
|
|
1546
|
-
const document = await this.readDocumentFile(path);
|
|
2011
|
+
const document = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS);
|
|
1547
2012
|
if (!document)
|
|
1548
2013
|
return failure(head, 'DOCUMENT_NOT_FOUND', `回滚后文档不可读: ${docId}`, true);
|
|
1549
2014
|
await this.audit('source_truth.revert', { docId, versionId: target });
|
|
@@ -1910,7 +2375,7 @@ export class KnowledgeRuntime {
|
|
|
1910
2375
|
emptyExtractionOutcome(extractionHashes) {
|
|
1911
2376
|
return {
|
|
1912
2377
|
extractionHashes,
|
|
1913
|
-
candidatesExtracted: 0, candidatesRejected: 0,
|
|
2378
|
+
candidatesExtracted: 0, candidatesRejected: 0, proposalsCreated: 0, proposalsMerged: 0,
|
|
1914
2379
|
sourcesExtracted: 0, sourcesSkipped: 0, relations: [], relationsBySource: {}, warnings: [],
|
|
1915
2380
|
};
|
|
1916
2381
|
}
|
|
@@ -1936,7 +2401,13 @@ export class KnowledgeRuntime {
|
|
|
1936
2401
|
const persisted = await this.readExtractedRelations();
|
|
1937
2402
|
// 计数器与产物不能由 readdir 的顺序决定,所以先按 sourceId 排序。
|
|
1938
2403
|
const ordered = [...sources].sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
1939
|
-
|
|
2404
|
+
/**
|
|
2405
|
+
* 库里的卡片是**只读的参照物**:这一轮不再往里加东西,抽到的一切都进 `pending`。
|
|
2406
|
+
* 整轮结束才一次性 upsert —— 同一张卡片被多份来源命中时只有一条提议,
|
|
2407
|
+
* 正文累积(而不是产生 N 条互相打架的提议)。
|
|
2408
|
+
*/
|
|
2409
|
+
const cards = await this.loadCards();
|
|
2410
|
+
const pending = new Map();
|
|
1940
2411
|
for (const source of ordered) {
|
|
1941
2412
|
const fingerprint = this.extractionFingerprint(source, registryHash);
|
|
1942
2413
|
outcome.extractionHashes[source.sourceId] = fingerprint;
|
|
@@ -1951,12 +2422,35 @@ export class KnowledgeRuntime {
|
|
|
1951
2422
|
}
|
|
1952
2423
|
outcome.sourcesExtracted += 1;
|
|
1953
2424
|
try {
|
|
1954
|
-
|
|
2425
|
+
await this.proposeFromSource(source, cards, enabledTypes, outcome, pending);
|
|
1955
2426
|
}
|
|
1956
2427
|
catch (cause) {
|
|
1957
2428
|
outcome.warnings.push(`来源 ${source.sourceId} 的候选抽取失败,已跳过该来源:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1958
2429
|
}
|
|
1959
2430
|
}
|
|
2431
|
+
/**
|
|
2432
|
+
* 落**提议**,不是卡片。
|
|
2433
|
+
*
|
|
2434
|
+
* `upsert` 会按内容指纹判重:人已经拒绝过、而且内容没变的那一条不会再冒出来;
|
|
2435
|
+
* 人已经确认过、而且内容没变的那一条也不会再问一遍。计数取自它真正写下的那些,
|
|
2436
|
+
* 而不是 `pending` 的大小 —— 报告里的数字必须是「这次真的发生了什么」。
|
|
2437
|
+
*/
|
|
2438
|
+
if (pending.size > 0) {
|
|
2439
|
+
const touched = await this.cardProposals().upsert([...pending.values()].map(item => ({
|
|
2440
|
+
sourceRef: item.sourceRefs[0] ?? `kb:${item.entityType}`,
|
|
2441
|
+
trigger: 'extraction',
|
|
2442
|
+
action: item.action,
|
|
2443
|
+
entityType: item.entityType,
|
|
2444
|
+
entityId: item.entityId,
|
|
2445
|
+
title: item.title,
|
|
2446
|
+
content: item.content,
|
|
2447
|
+
sourceRefs: item.sourceRefs,
|
|
2448
|
+
...(item.metadata ? { metadata: item.metadata } : {}),
|
|
2449
|
+
...(item.baseHash ? { baseHash: item.baseHash } : {}),
|
|
2450
|
+
})));
|
|
2451
|
+
outcome.proposalsCreated = touched.filter(item => item.action === 'create').length;
|
|
2452
|
+
outcome.proposalsMerged = touched.filter(item => item.action === 'update').length;
|
|
2453
|
+
}
|
|
1960
2454
|
// 只留这一轮真实存在的来源:删掉的文档不该在图上留下幽灵关系。
|
|
1961
2455
|
const alive = new Set(ordered.map(source => source.sourceId));
|
|
1962
2456
|
const next = {};
|
|
@@ -2003,8 +2497,16 @@ export class KnowledgeRuntime {
|
|
|
2003
2497
|
}
|
|
2004
2498
|
catch { /* 写不进去就当这一轮没有产物,下一次重建会重新抽 */ }
|
|
2005
2499
|
}
|
|
2006
|
-
/**
|
|
2007
|
-
|
|
2500
|
+
/**
|
|
2501
|
+
* 一份来源的抽取结果:**产出提议**(新建 / 更新),并收集关系候选。
|
|
2502
|
+
*
|
|
2503
|
+
* 它**不再写卡片**。这是 v1.5 那道门最容易被绕开的地方:Pass 0 是一次自动流程,
|
|
2504
|
+
* 它写进 `02_wiki/` 的东西会立刻参与编译、进图、被召回 —— 而类型层是收敛的那一层,
|
|
2505
|
+
* 它错一条,所有现象的排序一起错。所以这里只往 `pending` 里放提议,
|
|
2506
|
+
* 由 `runExtractionStage` 在整轮结束时一次性 upsert(同一张卡片被多份来源命中时,
|
|
2507
|
+
* 只有一条提议,正文累积)。
|
|
2508
|
+
*/
|
|
2509
|
+
async proposeFromSource(source, cards, enabledTypes, outcome, pending) {
|
|
2008
2510
|
/**
|
|
2009
2511
|
* #3b:**模型先抽实体,规则补关系。**
|
|
2010
2512
|
*
|
|
@@ -2028,30 +2530,35 @@ export class KnowledgeRuntime {
|
|
|
2028
2530
|
normalized = { fresh: modelled.fresh, merges: modelled.merges, relations: ruleNormalized.relations };
|
|
2029
2531
|
}
|
|
2030
2532
|
}
|
|
2031
|
-
const candidates = normalized.fresh;
|
|
2032
|
-
if (candidates.length === 0 && normalized.merges.length === 0)
|
|
2033
|
-
return cards;
|
|
2034
|
-
const next = [...cards];
|
|
2035
|
-
// 先建后并:同一轮里新建的卡片也可能被别名指回,先建才能让后面的合并找得到对象。
|
|
2036
2533
|
for (const candidate of normalized.fresh) {
|
|
2037
2534
|
/**
|
|
2038
|
-
*
|
|
2535
|
+
* 候选**必须带正文**才提提议。
|
|
2039
2536
|
*
|
|
2040
2537
|
* 0029 实跑发现:这里原来在候选没有正文时拿 `type.template` 顶上,于是
|
|
2041
2538
|
* `[[链接]]` 指到、但还没人写过的实体(那时链接候选确实没有正文)会落成一张
|
|
2042
2539
|
* **正文是模板**的卡片,而模板里的「(必填:写什么)」会被编进 wikiNotes、
|
|
2043
2540
|
* 被召回、被塞进模型的上下文包 —— 管线写出了一张它自己的校验器判为不合格的
|
|
2044
2541
|
* 卡片。链接候选现在会带上它所在小节的正文(`extract.ts`),所以这条回退基本
|
|
2045
|
-
*
|
|
2542
|
+
* 不会被走到;真走到了就**不提**,并如实计数:没有正文 = 没有可说的。
|
|
2046
2543
|
*/
|
|
2047
2544
|
const body = candidate.body?.trim() ?? '';
|
|
2048
2545
|
if (!body) {
|
|
2049
2546
|
outcome.candidatesRejected += 1;
|
|
2050
|
-
outcome.warnings.push(`候选「${candidate.name}
|
|
2547
|
+
outcome.warnings.push(`候选「${candidate.name}」没有任何正文(只在别处被提到,所在小节也没取到),不提提议`);
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* 类型必须来自注册表。抽到一个没登记的类型时**不提这条提议** —— 提议的正文与
|
|
2552
|
+
* 小节骨架都按类型的 schema 校验,而未知类型没有 schema,人 review 时只能拒掉它。
|
|
2553
|
+
* 与其往待办队列里塞一条注定被拒的东西,不如当场记一笔并说清原因。
|
|
2554
|
+
*/
|
|
2555
|
+
if (!enabledTypes.some(def => def.id === candidate.type)) {
|
|
2556
|
+
outcome.candidatesRejected += 1;
|
|
2557
|
+
outcome.warnings.push(`候选「${candidate.name}」未提议(SCHEMA_TYPE_INVALID):类型 ${candidate.type} 未登记或已停用`);
|
|
2051
2558
|
continue;
|
|
2052
2559
|
}
|
|
2053
|
-
|
|
2054
|
-
const
|
|
2560
|
+
const entityId = candidateEntityId(candidate.name);
|
|
2561
|
+
const key = `${candidate.type}:${entityId}`;
|
|
2055
2562
|
const metadata = {
|
|
2056
2563
|
extraction: 'pass0',
|
|
2057
2564
|
candidate_kind: candidate.kind,
|
|
@@ -2060,77 +2567,42 @@ export class KnowledgeRuntime {
|
|
|
2060
2567
|
sourceId: candidate.evidence.sourceId,
|
|
2061
2568
|
...(candidate.evidence.heading ? { heading: candidate.evidence.heading } : {}),
|
|
2062
2569
|
},
|
|
2063
|
-
|
|
2570
|
+
// 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
|
|
2571
|
+
...(aliasesOf(candidate).length > 0 ? { aliases: aliasesOf(candidate) } : {}),
|
|
2064
2572
|
};
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
outcome.warnings.push(`候选「${candidate.name}」未写入(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`);
|
|
2573
|
+
// 库里已经有这张卡片 → 不是新建,而是「把这次抽到的正文并进去」。
|
|
2574
|
+
const existing = cards.find(card => card.entityId === entityId && card.entityType === candidate.type);
|
|
2575
|
+
if (existing) {
|
|
2576
|
+
mergeProposal(pending, existing, candidate.evidence.sourceId, [body], [candidate.name]);
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
const previous = pending.get(key);
|
|
2580
|
+
if (previous) {
|
|
2581
|
+
previous.content = appendExtracted(previous.content, [body]);
|
|
2582
|
+
if (!previous.sourceRefs.includes(candidate.evidence.sourceId))
|
|
2583
|
+
previous.sourceRefs.push(candidate.evidence.sourceId);
|
|
2077
2584
|
continue;
|
|
2078
2585
|
}
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
entityId: written.data.entityId,
|
|
2586
|
+
pending.set(key, {
|
|
2587
|
+
action: 'create',
|
|
2082
2588
|
entityType: candidate.type,
|
|
2589
|
+
entityId,
|
|
2083
2590
|
title: candidate.name,
|
|
2084
|
-
status: 'unreviewed',
|
|
2085
2591
|
content: body,
|
|
2086
2592
|
sourceRefs: [candidate.evidence.sourceId],
|
|
2087
2593
|
metadata,
|
|
2088
|
-
updatedAt: '',
|
|
2089
2594
|
});
|
|
2090
2595
|
}
|
|
2091
|
-
//
|
|
2092
|
-
const targets = new Map();
|
|
2596
|
+
// 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并成一条提议。
|
|
2093
2597
|
for (const merge of normalized.merges) {
|
|
2094
|
-
const card =
|
|
2598
|
+
const card = cards.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType);
|
|
2095
2599
|
if (!card)
|
|
2096
2600
|
continue;
|
|
2097
|
-
// 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 ——
|
|
2098
|
-
//
|
|
2601
|
+
// 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空提一条提议
|
|
2602
|
+
// 也会让「并入了多少张卡」这个计数变成谎话。
|
|
2099
2603
|
if (card.sourceRefs.includes(merge.sourceId))
|
|
2100
2604
|
continue;
|
|
2101
|
-
|
|
2102
|
-
const entry = targets.get(id);
|
|
2103
|
-
if (entry) {
|
|
2104
|
-
if (!entry.keys.includes(merge.key))
|
|
2105
|
-
entry.keys.push(merge.key);
|
|
2106
|
-
entry.bodies.push(merge.body);
|
|
2107
|
-
}
|
|
2108
|
-
else
|
|
2109
|
-
targets.set(id, { card, keys: [merge.key], sourceId: merge.sourceId, bodies: [merge.body] });
|
|
2110
|
-
}
|
|
2111
|
-
for (const { card, keys, sourceId, bodies } of targets.values()) {
|
|
2112
|
-
const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : [];
|
|
2113
|
-
// 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
|
|
2114
|
-
const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20);
|
|
2115
|
-
// **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
|
|
2116
|
-
// 于是知识是累积的,来源改写了也不会停在旧版本上。
|
|
2117
|
-
const content = appendExtracted(card.content, bodies);
|
|
2118
|
-
const written = await this.writeCard({
|
|
2119
|
-
...card,
|
|
2120
|
-
content,
|
|
2121
|
-
sourceRefs: [...card.sourceRefs, sourceId],
|
|
2122
|
-
metadata: { ...card.metadata, extraction_merges },
|
|
2123
|
-
});
|
|
2124
|
-
if (!written.ok) {
|
|
2125
|
-
outcome.candidatesRejected += 1;
|
|
2126
|
-
outcome.warnings.push(`候选并入卡片「${card.entityId}」失败(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`);
|
|
2127
|
-
continue;
|
|
2128
|
-
}
|
|
2129
|
-
outcome.cardsMerged += 1;
|
|
2130
|
-
// 让后面的来源看到已经追加过的正文与 source_refs,避免重复写入。
|
|
2131
|
-
const index = next.findIndex(item => item.entityId === card.entityId && item.entityType === card.entityType);
|
|
2132
|
-
if (index >= 0)
|
|
2133
|
-
next[index] = { ...card, content, sourceRefs: [...card.sourceRefs, sourceId], metadata: { ...card.metadata, extraction_merges } };
|
|
2605
|
+
mergeProposal(pending, card, merge.sourceId, [merge.body], [merge.key]);
|
|
2134
2606
|
}
|
|
2135
2607
|
outcome.relations.push(...normalized.relations);
|
|
2136
2608
|
// 按来源存一份:跳过的来源下次重建要靠它把关系读回来(见 EXTRACTED_RELATIONS)。
|
|
@@ -2138,7 +2610,6 @@ export class KnowledgeRuntime {
|
|
|
2138
2610
|
...(outcome.relationsBySource[source.sourceId] ?? []),
|
|
2139
2611
|
...normalized.relations,
|
|
2140
2612
|
];
|
|
2141
|
-
return next;
|
|
2142
2613
|
}
|
|
2143
2614
|
/**
|
|
2144
2615
|
* 把关系候选装配成 wiki↔wiki 的边。
|
|
@@ -2196,13 +2667,11 @@ export class KnowledgeRuntime {
|
|
|
2196
2667
|
: this.emptyExtractionOutcome(previousExtractionHashes);
|
|
2197
2668
|
// 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
|
|
2198
2669
|
// 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
|
|
2199
|
-
if (extraction.cardsCreated > 0 || extraction.cardsMerged > 0)
|
|
2200
|
-
this.cards = await this.loadCards();
|
|
2201
2670
|
const extractionCounts = {
|
|
2202
2671
|
candidatesExtracted: extraction.candidatesExtracted,
|
|
2203
2672
|
candidatesRejected: extraction.candidatesRejected,
|
|
2204
|
-
|
|
2205
|
-
|
|
2673
|
+
proposalsCreated: extraction.proposalsCreated,
|
|
2674
|
+
proposalsMerged: extraction.proposalsMerged,
|
|
2206
2675
|
sourcesExtracted: extraction.sourcesExtracted,
|
|
2207
2676
|
sourcesSkipped: extraction.sourcesSkipped,
|
|
2208
2677
|
};
|
|
@@ -2276,7 +2745,19 @@ export class KnowledgeRuntime {
|
|
|
2276
2745
|
// bumping the version there would invalidate the vector cache and every
|
|
2277
2746
|
// qrels pointing at it for nothing. `all` never takes this path (see
|
|
2278
2747
|
// `incremental` above).
|
|
2279
|
-
|
|
2748
|
+
/**
|
|
2749
|
+
* 归因图(字段级)也是这次编译的产物之一,所以它变了也算「语料变了」。
|
|
2750
|
+
*
|
|
2751
|
+
* 少了这一条会踩一个很隐蔽的坑:**记一次排查不改任何卡片**(它写的是一条
|
|
2752
|
+
* `agent_diagnosis_case` 的 SourceRaw),于是增量编译判定「没变化」直接跳过 ——
|
|
2753
|
+
* 而 `03_compiled/diagnosis_graph.json` 停在上一轮,图召回因此看不到这次排查
|
|
2754
|
+
* 新增的 `produces` / `supports` / `refutes` 边。它读的是**编译产物**,
|
|
2755
|
+
* 不是 `02_wiki` 的实时投影(§14.3)。
|
|
2756
|
+
*/
|
|
2757
|
+
const diagnosis = await this.diagnosisStore().readGraph();
|
|
2758
|
+
const diagnosisHash = contentHashOf(JSON.stringify({ nodes: diagnosis.nodes, edges: diagnosis.edges }));
|
|
2759
|
+
const diagnosisChanged = diagnosisHash !== previousManifest.diagnosisHash;
|
|
2760
|
+
const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged;
|
|
2280
2761
|
if (!corpusChanged) {
|
|
2281
2762
|
const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`;
|
|
2282
2763
|
const report = {
|
|
@@ -2297,7 +2778,7 @@ export class KnowledgeRuntime {
|
|
|
2297
2778
|
// 用户刚点了编译却看到上次的统计,会以为按钮没生效。version 不变,所以
|
|
2298
2779
|
// 这个写入既不改索引也不让向量缓存失效。extractionHashes 必须跟着更新:
|
|
2299
2780
|
// 来源变了但没抽出卡片时,不落新指纹会让下一次又把它重抽一遍。
|
|
2300
|
-
await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version: previousVersion, builtAt: previousManifest.builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: previousManifest.embedding, compileRunId, lastBuild: { only, incremental: true, skipped: true, cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0, notesReused, notesRecompiled: 0, ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
|
|
2781
|
+
await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version: previousVersion, builtAt: previousManifest.builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: previousManifest.embedding, diagnosisHash, compileRunId, lastBuild: { only, incremental: true, skipped: true, cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0, notesReused, notesRecompiled: 0, ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
|
|
2301
2782
|
return success(report, ['知识库没有变化:已复用全部 wikiNotes,未生成新的索引版本。', ...extraction.warnings]);
|
|
2302
2783
|
}
|
|
2303
2784
|
const nodeMap = new Map();
|
|
@@ -2321,6 +2802,14 @@ export class KnowledgeRuntime {
|
|
|
2321
2802
|
// Compatibility mirror for tooling that predates wiki_notes.json.
|
|
2322
2803
|
await writeTextAtomic(this.safePath('03_compiled', LEGACY_UNITS), JSON.stringify(this.units, null, 2));
|
|
2323
2804
|
await writeTextAtomic(this.safePath('03_compiled', 'knowledge_graph.json'), JSON.stringify(this.graph, null, 2));
|
|
2805
|
+
/**
|
|
2806
|
+
* 字段级的归因图(0021 §14.3)。**同一次编译产出两份投影,各自由它的消费者读**:
|
|
2807
|
+
* `knowledge_graph.json` 是卡片级(图谱 Tab、图召回),这里是字段级
|
|
2808
|
+
* (智能归因三视图)。不合成一张 —— 消费者不同、`supported_by` 只对卡片成立、
|
|
2809
|
+
* 而召回权重的基准不该被顺手改掉(§14.4)。
|
|
2810
|
+
*/
|
|
2811
|
+
this.diagnosisGraph = { nodes: diagnosis.nodes, edges: diagnosis.edges };
|
|
2812
|
+
await writeTextAtomic(this.safePath('03_compiled', DIAGNOSIS_GRAPH), JSON.stringify({ version, builtAt, nodes: diagnosis.nodes, edges: diagnosis.edges }, null, 2));
|
|
2324
2813
|
// 关键词索引跟着 notes 一起落盘(0031):它的输入就是 notes,两者必须同时有效,
|
|
2325
2814
|
// 否则会出现「卡片改了、关键词路还按旧词表召回」。它不依赖编码器,所以 wiki-only
|
|
2326
2815
|
// 重建也要重建它 —— 而向量是 stage 3,仍然只在 `embeddings`/`all` 时重算。
|
|
@@ -2330,7 +2819,7 @@ export class KnowledgeRuntime {
|
|
|
2330
2819
|
// re-encode of the whole corpus.
|
|
2331
2820
|
const embeddingResult = only === 'all' ? await this.buildVectorIndex(this.units, version) : { status: 'skipped', count: 0, reused: 0 };
|
|
2332
2821
|
const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`;
|
|
2333
|
-
await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version, builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: embeddingResult, fts: ftsResult, compileRunId, lastBuild: { only, incremental: cardsReused > 0, skipped: false, cardsReused, cardsRecompiled, cardsAdded, cardsRemoved: removedCount, notesReused, notesRecompiled, ...extractionCounts, relationsExtracted, relationsDropped }, ...(lineage ? { lineage } : {}) }, null, 2));
|
|
2822
|
+
await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version, builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: embeddingResult, fts: ftsResult, compileRunId, diagnosisHash, diagnosisGraph: { nodes: diagnosis.nodes.length, edges: diagnosis.edges.length }, lastBuild: { only, incremental: cardsReused > 0, skipped: false, cardsReused, cardsRecompiled, cardsAdded, cardsRemoved: removedCount, notesReused, notesRecompiled, ...extractionCounts, relationsExtracted, relationsDropped }, ...(lineage ? { lineage } : {}) }, null, 2));
|
|
2334
2823
|
const report = {
|
|
2335
2824
|
runId: compileRunId, compileRunId, version, only,
|
|
2336
2825
|
cards: this.cards.length, units: this.units.length, notes: this.units.length,
|
|
@@ -2545,7 +3034,67 @@ export class KnowledgeRuntime {
|
|
|
2545
3034
|
* 匹配逻辑与 0030 相同(词面匹配节点 title / id 与关系词,命中节点带出 1 跳邻居并衰减),
|
|
2546
3035
|
* 但返回值只用于给**已经在候选池里**的条目加权 —— 它自己不能制造候选。
|
|
2547
3036
|
*/
|
|
2548
|
-
|
|
3037
|
+
/**
|
|
3038
|
+
* 字段级的归因图(`03_compiled/diagnosis_graph.json`)。
|
|
3039
|
+
*
|
|
3040
|
+
* 它只用于**给已经在候选池里的条目加权**(和卡片级图一样,不能自己制造候选)。
|
|
3041
|
+
* 用途是把「假设 → 检查点」「证据 → 假设」这两类卡片级图看不见的语义带进召回。
|
|
3042
|
+
*/
|
|
3043
|
+
diagnosisGraph = { nodes: [], edges: [] };
|
|
3044
|
+
/**
|
|
3045
|
+
* 归因图上按词面命中的节点 → 分数,含 1 跳衰减。
|
|
3046
|
+
*
|
|
3047
|
+
* 与卡片级那一段是**同一条算法、两份数据**:卡片级图里只有 `supported_by` /
|
|
3048
|
+
* `relates_to`,而字段级图里是十种语义边。分开算再按 `entityId` 合流,
|
|
3049
|
+
* 是因为两张图的节点含义不同(一个是卡片,一个是字段级节点),合成一张会让
|
|
3050
|
+
* 图谱 Tab 看到一张卡片散成七个点。
|
|
3051
|
+
*/
|
|
3052
|
+
diagnosisHits(queryTokens) {
|
|
3053
|
+
const seed = new Map();
|
|
3054
|
+
if (this.diagnosisGraph.nodes.length === 0)
|
|
3055
|
+
return seed;
|
|
3056
|
+
for (const node of this.diagnosisGraph.nodes) {
|
|
3057
|
+
const title = String(node.title ?? '').toLocaleLowerCase();
|
|
3058
|
+
const id = String(node.id ?? '').toLocaleLowerCase();
|
|
3059
|
+
let score = 0;
|
|
3060
|
+
for (const token of queryTokens) {
|
|
3061
|
+
if (title.includes(token))
|
|
3062
|
+
score += 3;
|
|
3063
|
+
else if (id.includes(token))
|
|
3064
|
+
score += 2;
|
|
3065
|
+
}
|
|
3066
|
+
if (score > 0)
|
|
3067
|
+
seed.set(String(node.id), score);
|
|
3068
|
+
}
|
|
3069
|
+
// 关系词命中:两端各得 1 分。中文提问不会写 `refutes`,所以走 RELATION_QUERY_WORDS。
|
|
3070
|
+
for (const edge of this.diagnosisGraph.edges) {
|
|
3071
|
+
const type = String(edge.kind ?? '');
|
|
3072
|
+
const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase();
|
|
3073
|
+
let score = 0;
|
|
3074
|
+
for (const token of queryTokens)
|
|
3075
|
+
if (relation.includes(token))
|
|
3076
|
+
score += 1;
|
|
3077
|
+
if (score > 0) {
|
|
3078
|
+
seed.set(String(edge.source), Math.max(seed.get(String(edge.source)) ?? 0, score));
|
|
3079
|
+
seed.set(String(edge.target), Math.max(seed.get(String(edge.target)) ?? 0, score));
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
if (seed.size === 0)
|
|
3083
|
+
return seed;
|
|
3084
|
+
const expanded = new Map(seed);
|
|
3085
|
+
for (const [id, score] of seed) {
|
|
3086
|
+
for (const edge of this.diagnosisGraph.edges) {
|
|
3087
|
+
const from = String(edge.source);
|
|
3088
|
+
const to = String(edge.target);
|
|
3089
|
+
if (from === id)
|
|
3090
|
+
expanded.set(to, Math.max(expanded.get(to) ?? 0, score * 0.5));
|
|
3091
|
+
else if (to === id)
|
|
3092
|
+
expanded.set(from, Math.max(expanded.get(from) ?? 0, score * 0.5));
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
return expanded;
|
|
3096
|
+
}
|
|
3097
|
+
async graphBoostRanking(query, eligible, includeProblems) {
|
|
2549
3098
|
if (this.graph.nodes.length === 0)
|
|
2550
3099
|
return [];
|
|
2551
3100
|
const queryTokens = segment(query);
|
|
@@ -2555,6 +3104,10 @@ export class KnowledgeRuntime {
|
|
|
2555
3104
|
const boost = (key, score) => { byNodeKey.set(key, Math.max(byNodeKey.get(key) ?? 0, score)); };
|
|
2556
3105
|
const visible = (node) => includeProblems || node.status !== 'problem';
|
|
2557
3106
|
const nodeKey = (node) => `${String(node.type)}:${String(node.id)}`;
|
|
3107
|
+
/** 卡片级节点 id → 它的 `entity_type`。归因图的节点 id 与卡片 id 相等(§14.1)。 */
|
|
3108
|
+
const typeByEntityId = new Map();
|
|
3109
|
+
for (const node of this.graph.nodes)
|
|
3110
|
+
typeByEntityId.set(String(node.id), String(node.type));
|
|
2558
3111
|
for (const node of this.graph.nodes) {
|
|
2559
3112
|
if (!visible(node))
|
|
2560
3113
|
continue;
|
|
@@ -2582,6 +3135,19 @@ export class KnowledgeRuntime {
|
|
|
2582
3135
|
boost(String(edge.to), score);
|
|
2583
3136
|
}
|
|
2584
3137
|
}
|
|
3138
|
+
/**
|
|
3139
|
+
* **字段级的归因图**(0021 §14.4 的第二条最小路径)。
|
|
3140
|
+
*
|
|
3141
|
+
* 卡片级的图只认 `supported_by` 与 `relates_to` 两种关系词,所以「假设 → 检查点」
|
|
3142
|
+
* 与「证据 → 假设」这两类语义对召回完全不可见。这里按 `entityId` 把两张图接起来:
|
|
3143
|
+
* 归因图上命中的节点,映射回同 id 的卡片级节点加权 —— **不需要合图**,
|
|
3144
|
+
* 因为两张图的节点 id 本来就相等(§14.1)。
|
|
3145
|
+
*/
|
|
3146
|
+
for (const [id, score] of this.diagnosisHits(queryTokens)) {
|
|
3147
|
+
const type = typeByEntityId.get(id);
|
|
3148
|
+
if (type)
|
|
3149
|
+
boost(`${type}:${id}`, score);
|
|
3150
|
+
}
|
|
2585
3151
|
if (byNodeKey.size === 0)
|
|
2586
3152
|
return [];
|
|
2587
3153
|
const expanded = new Map(byNodeKey);
|
|
@@ -2711,7 +3277,7 @@ export class KnowledgeRuntime {
|
|
|
2711
3277
|
const vectorOutcome = useVector
|
|
2712
3278
|
? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold)
|
|
2713
3279
|
: { ranking: [], diagnostic: { route: 'vector', status: 'skipped', reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } };
|
|
2714
|
-
const graphRanking = useGraphBoost ? this.graphBoostRanking(query, eligible, includeProblems) : [];
|
|
3280
|
+
const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems) : [];
|
|
2715
3281
|
if (ftsOutcome.diagnostic.status === 'degraded' && ftsOutcome.diagnostic.reason)
|
|
2716
3282
|
warnings.push(ftsOutcome.diagnostic.reason);
|
|
2717
3283
|
if (vectorOutcome.diagnostic.status === 'degraded' && vectorOutcome.diagnostic.reason)
|
|
@@ -2941,9 +3507,26 @@ export class KnowledgeRuntime {
|
|
|
2941
3507
|
}
|
|
2942
3508
|
async graphQuery(action, entityId, entityType, depth = 1, options = {}, lineage) {
|
|
2943
3509
|
const eventLineage = withRun(lineage, 'graph-query');
|
|
2944
|
-
|
|
2945
|
-
|
|
3510
|
+
/**
|
|
3511
|
+
* **读盘,不编译。**
|
|
3512
|
+
*
|
|
3513
|
+
* `units` 为空只说明这个 runtime 还没读过 `03_compiled/`,**不等于**盘上没有编译
|
|
3514
|
+
* 产物:runtime 是按库缓存的,设置一变就被丢掉(`KnowledgeBases.invalidate()`),
|
|
3515
|
+
* 下一次读图时内存里又是空的。原来的写法把这两种情况当成同一件事,在这里直接
|
|
3516
|
+
* `rebuild('all')` —— 于是一次**读**变成一次全量编译:面板刷新时那个
|
|
3517
|
+
* `GET /knowledge/graph` 在几百篇文档的库上要等 3~5 秒,而这段时间整个知识库面板
|
|
3518
|
+
* 连同「类型定义」都是不可操作的。
|
|
3519
|
+
*
|
|
3520
|
+
* 编译是**写**动作,归「编译」按钮与 `POST /knowledge/rebuild`。读路径只如实报告
|
|
3521
|
+
* 「盘上还没有编译产物」(`needsBuild`),要不要编由界面和 Agent 自己决定 ——
|
|
3522
|
+
* 与 `recall` 同一条规矩:先读盘,读不到再说。
|
|
3523
|
+
*/
|
|
3524
|
+
if (this.units.length === 0 && this.graph.nodes.length === 0)
|
|
3525
|
+
await this.loadCompiledState();
|
|
3526
|
+
const needsBuild = this.units.length === 0 && this.graph.nodes.length === 0;
|
|
2946
3527
|
const attachLineage = (data) => eventLineage ? { ...data, lineage: eventLineage } : data;
|
|
3528
|
+
/** 空图 + 一句为什么空:调用方(尤其是 Agent)不能把「还没编译」读成「这个库没有知识」。 */
|
|
3529
|
+
const attachBuild = (data) => needsBuild ? { ...data, needsBuild: true } : data;
|
|
2947
3530
|
if (depth < 1 || depth > 3)
|
|
2948
3531
|
return failure({}, 'DEPTH_INVALID', 'depth 必须在 1 到 3 之间', false);
|
|
2949
3532
|
const limit = options.limit ?? 50;
|
|
@@ -2959,7 +3542,7 @@ export class KnowledgeRuntime {
|
|
|
2959
3542
|
const nodes = allNodes.filter(node => !entityType || node.type === entityType);
|
|
2960
3543
|
const edges = this.graph.edges.filter(edge => allNodes.some(node => `${node.type}:${node.id}` === String(edge.from)) && allNodes.some(node => `${node.type}:${node.id}` === String(edge.to)));
|
|
2961
3544
|
if (action === 'list')
|
|
2962
|
-
return success(attachLineage({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) }));
|
|
3545
|
+
return success(attachLineage(attachBuild({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) })));
|
|
2963
3546
|
const nodeKey = (rawId, rawType) => {
|
|
2964
3547
|
if (!rawId)
|
|
2965
3548
|
return undefined;
|
|
@@ -2998,14 +3581,14 @@ export class KnowledgeRuntime {
|
|
|
2998
3581
|
}
|
|
2999
3582
|
}
|
|
3000
3583
|
if (!found)
|
|
3001
|
-
return success(attachLineage({ path: [], nodes: [], edges: [] }));
|
|
3584
|
+
return success(attachLineage(attachBuild({ path: [], nodes: [], edges: [] })));
|
|
3002
3585
|
return success(attachLineage({ path: found, nodes: allNodes.filter(node => found.includes(`${node.type}:${node.id}`)), edges: edges.filter(edge => found.includes(String(edge.from)) && found.includes(String(edge.to))) }));
|
|
3003
3586
|
}
|
|
3004
3587
|
const id = nodeKey(entityId, entityType);
|
|
3005
3588
|
if (!id)
|
|
3006
3589
|
return failure({}, 'ENTITY_REQUIRED', '该 graph action 需要 entity_id', true);
|
|
3007
3590
|
if (action === 'entity')
|
|
3008
|
-
return success(attachLineage({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) }));
|
|
3591
|
+
return success(attachLineage(attachBuild({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) })));
|
|
3009
3592
|
if (action === 'neighbors') {
|
|
3010
3593
|
const ids = new Set([id]);
|
|
3011
3594
|
for (let index = 0; index < depth; index++)
|
|
@@ -3015,7 +3598,7 @@ export class KnowledgeRuntime {
|
|
|
3015
3598
|
if (ids.has(String(edge.to)))
|
|
3016
3599
|
ids.add(String(edge.from));
|
|
3017
3600
|
}
|
|
3018
|
-
return success(attachLineage({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) }));
|
|
3601
|
+
return success(attachLineage(attachBuild({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) })));
|
|
3019
3602
|
}
|
|
3020
3603
|
return failure({}, 'ACTION_INVALID', `不支持的 graph action: ${action}`, false);
|
|
3021
3604
|
}
|