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/src/runtime.ts
CHANGED
|
@@ -17,22 +17,39 @@ import {
|
|
|
17
17
|
type WikiSchemaMode, type WikiTypeCatalog, type WikiTypeDef,
|
|
18
18
|
} from './types.js'
|
|
19
19
|
import { defaultWikiTypes, validateCardAgainstType, WIKI_TYPE_RULES } from './wiki-types.js'
|
|
20
|
+
import { LEGACY_ENTITY_TYPES } from './diagnosis/schema.js'
|
|
21
|
+
import { CardProposalStore } from './proposals.js'
|
|
22
|
+
import { DiagnosisStore } from './diagnosis/store.js'
|
|
20
23
|
import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandidates, RELATES_TO, type NormalizedCandidates } from './extract.js'
|
|
21
24
|
import { extractWithModel, type ModelCall } from './extract-model.js'
|
|
22
25
|
import { FTS_FILE, FtsIndex, loadSqlite, segment } from './fts.js'
|
|
23
26
|
import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, rrfFuse, selectContext, tokenSet, type RouteRanking } from './recall-rank.js'
|
|
24
27
|
import { legacyKnowledgeRoot } from './layout.js'
|
|
28
|
+
import { unlinkIfExists, writeBufferAtomic, writeTextAtomic } from './fs-atomic.js'
|
|
25
29
|
import { envelopeFailure, envelopeSuccess } from 'dsh-data-contracts/envelope'
|
|
26
30
|
import type { LineageContext, LineageRef } from 'dsh-data-contracts/refs'
|
|
27
31
|
|
|
28
|
-
//
|
|
29
|
-
// 和 03_compiled 同性质,所以给独立编号段而不是塞进源头目录。
|
|
32
|
+
// **知识树只有三段**(v1.1 起):`01_source_truth` / `02_wiki` / `03_compiled`。
|
|
30
33
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
34
|
+
// 撤掉的两段,理由都写在这里,避免下一个人重新加回来:
|
|
35
|
+
//
|
|
36
|
+
// - `00_schema`(0030 撤):它从建出来那天起就是一个**空目录**。分类注册表与 Wiki 类型
|
|
37
|
+
// 注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是**配置**,跟着根路径
|
|
38
|
+
// 走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。「磁盘上有一个文件夹」
|
|
39
|
+
// 本身是一种承诺,承诺了却空着就是误导。**一句话:schema 在设置里编辑,不在磁盘上。**
|
|
40
|
+
// - `05_diagnosis`(v1.1 撤):它同时装着两类东西 —— 「一次排查的产物」(源头)与
|
|
41
|
+
// 「五类模板卡」(配置)—— 一个编号段混装两种性质,`rebuild --only` 的语义立刻含糊。
|
|
42
|
+
// 现在:一次排查的产物是一条 `agent_diagnosis_case` 的 SourceRaw(落 `01_source_truth/`),
|
|
43
|
+
// 模板卡由类型定义派生(不落盘)。旧目录里的 `*.jsonl` 降为**只读的迁移输入与历史快照**。
|
|
44
|
+
const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled'] as const
|
|
45
|
+
/**
|
|
46
|
+
* 卡片正文里那个机器可读块的围栏标记。
|
|
47
|
+
*
|
|
48
|
+
* 与 `diagnosis/cards.ts` 的 `BLOCK_FENCE` 是同一个字符串,但这里不能 import 它 ——
|
|
49
|
+
* 迁移要在**不解析卡片**的前提下改块里的一个字段名(旧块的 `failure_mode`),
|
|
50
|
+
* 而 `cards.ts` 的 `parseCard` 已经把旧字段名归一化了,读回来的对象里根本没有它。
|
|
51
|
+
*/
|
|
52
|
+
const DIAGNOSIS_BLOCK_FENCE = 'json diagnosis'
|
|
36
53
|
/**
|
|
37
54
|
* SourceRaw 段。段内**按来源分类再分一层**:`<段>/<source_type>/<docSlug>.md`。
|
|
38
55
|
*
|
|
@@ -68,6 +85,16 @@ const VECTOR_QUANTIZATION = 'binary+hamming+int8' as const
|
|
|
68
85
|
const ENCODER_FINGERPRINT = 'wemm-official-processor-v1'
|
|
69
86
|
/** Stage 2 output consumed by stage 3 recall. */
|
|
70
87
|
const WIKI_NOTES = 'wiki_notes.json'
|
|
88
|
+
/**
|
|
89
|
+
* 归因图谱的编译产物(0021 §14.3)。
|
|
90
|
+
*
|
|
91
|
+
* 在这之前 `cardsToGraph()` 是**读的时候现算**的,归因图不落 `03_compiled` —— 于是
|
|
92
|
+
* `build-manifest.json` 不知道它存在(回答不了「这张图是哪个版本编出来的」),
|
|
93
|
+
* 而图召回也读不到它。现在它和 `knowledge_graph.json` 一起由同一次 `rebuild` 产出,
|
|
94
|
+
* **但保留两种粒度**:卡片级回答「知识长什么样」,字段级回答「这条路怎么走」。
|
|
95
|
+
* 合成一张会让图谱 Tab 看到一张卡片散成七个点(§14.4 的三条理由)。
|
|
96
|
+
*/
|
|
97
|
+
const DIAGNOSIS_GRAPH = 'diagnosis_graph.json'
|
|
71
98
|
/** Pre-0014 artifact name; kept readable so an upgrade does not force a rebuild. */
|
|
72
99
|
const LEGACY_UNITS = 'knowledge_units.json'
|
|
73
100
|
/**
|
|
@@ -114,6 +141,20 @@ const NOTE_SPLIT_MIN_CHARS = 800
|
|
|
114
141
|
const RELATION_QUERY_WORDS: Record<string, string> = {
|
|
115
142
|
supported_by: 'supported by source 来源 出处 依据 支撑',
|
|
116
143
|
[RELATES_TO]: 'relates to related 相关 关联 关系 有关',
|
|
144
|
+
// 归因图谱的十种边(0021 §4.2)。它们住在 `diagnosis_graph.json` 里,
|
|
145
|
+
// 但**按关系词命中**这条读法是同一套:中文提问不会写 `may_be_caused_by`。
|
|
146
|
+
// 少了这十行,图召回对 `refutes` / `verified_by` 这些语义完全不可见 ——
|
|
147
|
+
// 而「上次这条假设是被什么否定的」正是最该被召回到的东西。
|
|
148
|
+
may_be_caused_by: 'may be caused by 可能是原因 可能原因 候选原因 怀疑',
|
|
149
|
+
verified_by: 'verified by 由…验证 怎么验证 用哪个检查 检查点',
|
|
150
|
+
categorized_as: 'categorized as 归类为 属于哪一类 失败原因 切面',
|
|
151
|
+
queries: 'queries 查询依据 要去查 查哪些表 影响面',
|
|
152
|
+
produces: 'produces 产出 产出了什么 跑出了什么',
|
|
153
|
+
supports: 'supports 支持 印证 算数',
|
|
154
|
+
refutes: 'refutes 否定 排除 不成立 反例',
|
|
155
|
+
confirmed_as: 'confirmed as 确认为根因 确认根因 定位到',
|
|
156
|
+
instance_of: 'instance of 实例属于 哪次排查 案例',
|
|
157
|
+
indexed_from: 'indexed from 反查索引 落在哪个对象上 相关资产',
|
|
117
158
|
}
|
|
118
159
|
/**
|
|
119
160
|
* 融合与阈值的常数(0031)。
|
|
@@ -207,8 +248,16 @@ interface ExtractionOutcome {
|
|
|
207
248
|
extractionHashes: Record<string, string>
|
|
208
249
|
candidatesExtracted: number
|
|
209
250
|
candidatesRejected: number
|
|
210
|
-
|
|
211
|
-
|
|
251
|
+
/**
|
|
252
|
+
* 这一轮**新提**的卡片提议条数。
|
|
253
|
+
*
|
|
254
|
+
* 名字从 `cardsCreated` 改过来(v1.5):Pass 0 不再直接落卡了 —— 它只提议,
|
|
255
|
+
* 由人在「知识库 → Wiki」的待办区确认之后才走 `writeCard`(0021 §6.1)。
|
|
256
|
+
* 计数不改名的话,报告会持续说「新建了 N 张卡片」,而磁盘上一张都没有。
|
|
257
|
+
*/
|
|
258
|
+
proposalsCreated: number
|
|
259
|
+
/** 这一轮**更新**已有卡片内容的提议条数(原来叫 `cardsMerged`)。 */
|
|
260
|
+
proposalsMerged: number
|
|
212
261
|
sourcesExtracted: number
|
|
213
262
|
sourcesSkipped: number
|
|
214
263
|
relations: Array<{ fromKey: string; toKey: string; relation: string; sourceId: string }>
|
|
@@ -252,6 +301,69 @@ function contentHashOf(content: string): string { return hash(content) }
|
|
|
252
301
|
* 已经在正文里出现过的那一段不再追加:同一份来源重建、或同一段被两个候选命中时,
|
|
253
302
|
* 卡片不该越编译越长。判重按整段文本,所以它是幂等的。
|
|
254
303
|
*/
|
|
304
|
+
/**
|
|
305
|
+
* Pass 0 收集到的一条待落提议。
|
|
306
|
+
*
|
|
307
|
+
* 它存在的原因是**整轮结束才 upsert**:一份来源里同一个实体可能被抽到两次,
|
|
308
|
+
* 两份来源也可能抽到同一个实体 —— 逐次写会得到 N 条互相打架的提议,
|
|
309
|
+
* 而人 review 的时候只想知道「这张卡片该不该建、正文是什么」。
|
|
310
|
+
*/
|
|
311
|
+
interface PendingProposal {
|
|
312
|
+
action: 'create' | 'update'
|
|
313
|
+
entityType: SchemaType
|
|
314
|
+
entityId: string
|
|
315
|
+
title: string
|
|
316
|
+
content: string
|
|
317
|
+
sourceRefs: string[]
|
|
318
|
+
metadata?: Record<string, unknown>
|
|
319
|
+
/** `update` 时目标卡片当时的正文指纹 —— 提议之后卡片又被人改过时能发现。 */
|
|
320
|
+
baseHash?: string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 候选的别名集合:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。 */
|
|
324
|
+
function aliasesOf(candidate: { name: string; aliases?: string[] }): string[] {
|
|
325
|
+
return [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 把一次「并入已有卡片」累积进 `pending`。
|
|
330
|
+
*
|
|
331
|
+
* 已有卡片只读,所以累积的是**提议的正文**:`appendExtracted` 从卡片当时的正文出发,
|
|
332
|
+
* 把这一轮所有命中的正文依次追加进去。同一条提议被追加两次正文时不会重复
|
|
333
|
+
* (`appendExtracted` 自带 `includes` 判重)。
|
|
334
|
+
*/
|
|
335
|
+
function mergeProposal(
|
|
336
|
+
pending: Map<string, PendingProposal>,
|
|
337
|
+
card: KnowledgeCard,
|
|
338
|
+
sourceId: string,
|
|
339
|
+
bodies: string[],
|
|
340
|
+
keys: string[],
|
|
341
|
+
): void {
|
|
342
|
+
const key = `${card.entityType}:${card.entityId}`
|
|
343
|
+
const previous = pending.get(key)
|
|
344
|
+
const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : []
|
|
345
|
+
// 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
|
|
346
|
+
const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20)
|
|
347
|
+
if (previous) {
|
|
348
|
+
previous.content = appendExtracted(previous.content, bodies)
|
|
349
|
+
if (!previous.sourceRefs.includes(sourceId)) previous.sourceRefs.push(sourceId)
|
|
350
|
+
previous.metadata = { ...previous.metadata, extraction_merges }
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
pending.set(key, {
|
|
354
|
+
action: 'update',
|
|
355
|
+
entityType: card.entityType,
|
|
356
|
+
entityId: card.entityId,
|
|
357
|
+
title: card.title,
|
|
358
|
+
// **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
|
|
359
|
+
// 于是知识是累积的,来源改写了也不会停在旧版本上。
|
|
360
|
+
content: appendExtracted(card.content, bodies),
|
|
361
|
+
sourceRefs: [...card.sourceRefs, sourceId],
|
|
362
|
+
metadata: { ...card.metadata, extraction_merges },
|
|
363
|
+
baseHash: contentHashOf(card.content),
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
255
367
|
function appendExtracted(existing: string, additions: string[]): string {
|
|
256
368
|
let content = existing
|
|
257
369
|
for (const addition of additions) {
|
|
@@ -304,10 +416,22 @@ function emptyDocumentDetail(docId: string): KnowledgeDocumentDetail {
|
|
|
304
416
|
return {
|
|
305
417
|
docId, title: docId, sourceType: '', sourceTypeKnown: false, sourceFormat: '', parser: '', externalId: '', contentHash: '',
|
|
306
418
|
bytes: 0, assetCount: 0, createdAt: '', updatedAt: '', versionCount: 0,
|
|
307
|
-
relativePath: '', ext: '', content: '', frontmatter: {}, assets: [],
|
|
419
|
+
relativePath: '', ext: '', content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [],
|
|
308
420
|
}
|
|
309
421
|
}
|
|
310
422
|
|
|
423
|
+
/**
|
|
424
|
+
* 抽屉里的原文预览**只发**正文的前这么多字符。
|
|
425
|
+
*
|
|
426
|
+
* 依据是实跑反馈:一份几十万字的转换文档,抽屉只渲染前 4000 字符(更长的在
|
|
427
|
+
* 380px 宽的抽屉里没有阅读意义),而接口原来把**整份正文**发过去 —— 传输、JSON 解析、
|
|
428
|
+
* 塞进 React state 全按全文的量级付费,于是「打开弹层要等半天」。上面那段封顶之后,
|
|
429
|
+
* 弹层的耗时与文档大小**脱钩**;要读全文走独立整页(分页取,见 `documentContentPage`)。
|
|
430
|
+
*
|
|
431
|
+
* 4000 是用户明确要的阈值,所以它是一个具名常量而不是散在各处的字面量。
|
|
432
|
+
*/
|
|
433
|
+
export const SOURCE_PREVIEW_CHARS = 4000
|
|
434
|
+
|
|
311
435
|
function generatedId(prefix: string): string { return `${prefix}_${hash(`${Date.now()}:${Math.random()}`)}` }
|
|
312
436
|
|
|
313
437
|
function parseLineage(value: unknown): LineageContext | undefined {
|
|
@@ -362,42 +486,24 @@ export function parseMarkdown(source: string): { frontmatter: Record<string, unk
|
|
|
362
486
|
return { frontmatter, content: source.slice(end + 4).trim() }
|
|
363
487
|
}
|
|
364
488
|
|
|
489
|
+
/**
|
|
490
|
+
* 把旧卡片结构化块里的 `failure_mode` 改成 `failure_reason`(0040 的第二次改名)。
|
|
491
|
+
*
|
|
492
|
+
* 只动那个围栏块里的键名,不动正文 —— 正文里出现「failure_mode」这个词完全可能是
|
|
493
|
+
* 有意写的(比如一张讲 FMEA 的卡片)。做字符串级替换而不是解析再序列化,是因为
|
|
494
|
+
* 解析会**丢掉格式**(缩进、键序),而迁移的第一条纪律是不制造无谓的 diff。
|
|
495
|
+
*/
|
|
496
|
+
function rewriteLegacyBlockFields(content: string): string {
|
|
497
|
+
const pattern = new RegExp('(```' + DIAGNOSIS_BLOCK_FENCE + '\\s*\\n)([\\s\\S]*?)(```)')
|
|
498
|
+
return content.replace(pattern, (_all, head: string, body: string, tail: string) =>
|
|
499
|
+
head + body.replace(/"failure_mode"(\s*:)/g, '"failure_reason"$1') + tail)
|
|
500
|
+
}
|
|
501
|
+
|
|
365
502
|
function markdown(frontmatter: Record<string, unknown>, content: string): string {
|
|
366
503
|
const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
|
|
367
504
|
return `---\n${lines.join('\n')}\n---\n\n${content.trim()}\n`
|
|
368
505
|
}
|
|
369
506
|
|
|
370
|
-
async function writeTextAtomic(path: string, content: string): Promise<void> {
|
|
371
|
-
// 原子写需要一个与目标同目录的临时文件,所以目标目录必须先存在。0030 起
|
|
372
|
-
// SourceRaw 落在 `<段>/<source_type>/` 下,而 `ensureLayout` 只建段目录 —— 少了
|
|
373
|
-
// 这一行,第一次写某一种分类就会 ENOENT(实测)。
|
|
374
|
-
await mkdir(dirname(path), { recursive: true })
|
|
375
|
-
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
|
|
376
|
-
try {
|
|
377
|
-
await writeFile(temporary, content, 'utf8')
|
|
378
|
-
await rename(temporary, path)
|
|
379
|
-
} finally {
|
|
380
|
-
await unlinkIfExists(temporary)
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function writeBufferAtomic(path: string, content: Buffer): Promise<void> {
|
|
385
|
-
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
|
|
386
|
-
try {
|
|
387
|
-
await writeFile(temporary, content)
|
|
388
|
-
await rename(temporary, path)
|
|
389
|
-
} finally {
|
|
390
|
-
await unlinkIfExists(temporary)
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
async function unlinkIfExists(path: string): Promise<void> {
|
|
395
|
-
try {
|
|
396
|
-
const { unlink } = await import('node:fs/promises')
|
|
397
|
-
await unlink(path)
|
|
398
|
-
} catch { /* rename already consumed the temp file */ }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
507
|
async function filesUnder(root: string): Promise<string[]> {
|
|
402
508
|
try {
|
|
403
509
|
const entries = await readdir(root, { withFileTypes: true })
|
|
@@ -722,6 +828,12 @@ export class KnowledgeRuntime {
|
|
|
722
828
|
private sourceIndex: Map<string, string> | undefined
|
|
723
829
|
/** 段内布局整理是否已经跑过(每个 runtime 一次)。 */
|
|
724
830
|
private segmentsOrganized = false
|
|
831
|
+
/**
|
|
832
|
+
* 一次性迁移里**没有静默处理掉**的事(改名冲突、迁移后比对不一致)。
|
|
833
|
+
*
|
|
834
|
+
* 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
|
|
835
|
+
*/
|
|
836
|
+
private migrationProblems: string[] = []
|
|
725
837
|
private vectorIndex: LoadedVectorIndex | null = null
|
|
726
838
|
private vectorError: string | undefined
|
|
727
839
|
private embeddingWorker: JsonLineWorker | null = null
|
|
@@ -828,18 +940,188 @@ export class KnowledgeRuntime {
|
|
|
828
940
|
|
|
829
941
|
/**
|
|
830
942
|
* Agent 与设置面板共用的类型目录:注册表 + 生效方式 + 内置只读判定规则 +
|
|
831
|
-
* 现有卡片的三层校验结果(`report` 模式下这是"待补"
|
|
943
|
+
* 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)+ **待审提议的条数**。
|
|
944
|
+
*
|
|
945
|
+
* 提议计数挂在这里而不是另开一路,是因为 review 就发生在这一屏(「知识库 → Wiki」的
|
|
946
|
+
* 待办区)—— 与 Pass 0 的候选草案、lint 待补清单是同一处,不新造第二套 review 界面。
|
|
832
947
|
*/
|
|
833
948
|
async wikiTypeCatalog(): Promise<Envelope<WikiTypeCatalog>> {
|
|
834
949
|
const schema = this.wikiSchema()
|
|
835
950
|
const issues = await this.lintCards()
|
|
951
|
+
const all = await this.cardProposals().list()
|
|
836
952
|
return success({
|
|
837
953
|
types: schema.types,
|
|
838
954
|
mode: schema.mode,
|
|
839
955
|
rules: WIKI_TYPE_RULES,
|
|
840
956
|
issues: issues.slice(0, 50),
|
|
841
957
|
issueCount: issues.length,
|
|
958
|
+
proposals: {
|
|
959
|
+
pending: all.filter(item => item.status === 'pending').length,
|
|
960
|
+
rejected: all.filter(item => item.status === 'rejected').length,
|
|
961
|
+
confirmed: all.filter(item => item.status === 'confirmed' && !item.writtenAt).length,
|
|
962
|
+
},
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* 智能归因图谱的存储句柄。
|
|
968
|
+
*
|
|
969
|
+
* 它由 runtime 自己构造(而不是由 index.ts 注入),因为 `rebuild` 要产出
|
|
970
|
+
* `03_compiled/diagnosis_graph.json` —— 那次产出必须和 `knowledge_graph.json`
|
|
971
|
+
* 在同一次卡片遍历的语义下发生,否则两份产物会来自两个不同版本的卡片。
|
|
972
|
+
*/
|
|
973
|
+
private diagnosis?: DiagnosisStore
|
|
974
|
+
|
|
975
|
+
diagnosisStore(): DiagnosisStore {
|
|
976
|
+
if (!this.diagnosis) this.diagnosis = new DiagnosisStore(this, this.cardProposals())
|
|
977
|
+
return this.diagnosis
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* 卡片提议的存储(0021 §6.1 的那道门)。
|
|
982
|
+
*
|
|
983
|
+
* 它按**记忆段**(`<agentRoot>/memory/<库 id>`)落,不在知识树里 —— 理由写在
|
|
984
|
+
* `proposals.ts` 顶上:提议是「关于 Agent 动作的待办留痕」,删掉只丢待办、
|
|
985
|
+
* 重跑一次提议就能回来,所以它既不属于「源头」(删了就丢数据的那一段),
|
|
986
|
+
* 更不能落 `02_wiki/`(那正是这道门要拦的事)。
|
|
987
|
+
*/
|
|
988
|
+
private proposalStore?: CardProposalStore
|
|
989
|
+
|
|
990
|
+
cardProposals(): CardProposalStore {
|
|
991
|
+
if (!this.proposalStore) {
|
|
992
|
+
this.proposalStore = new CardProposalStore({
|
|
993
|
+
memoryRoot: this.memoryDir,
|
|
994
|
+
schema: () => ({ types: this.wikiSchema().types }),
|
|
995
|
+
})
|
|
996
|
+
}
|
|
997
|
+
return this.proposalStore
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/* ────────────────────────────────────── 卡片提议:提议 → 确认 ── */
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 提一批卡片提议。**它不写任何卡片**(0021 §6.1)。
|
|
1004
|
+
*
|
|
1005
|
+
* 这是 Agent 唯一能碰类型层的入口:把草案挂到触发它的那条 SourceRaw 上,
|
|
1006
|
+
* 由人在面板上确认之后才落卡。未确认的提议不落 `02_wiki/`、不进编译、
|
|
1007
|
+
* 不参与召回、不在图上出现。
|
|
1008
|
+
*/
|
|
1009
|
+
async proposeCards(input: {
|
|
1010
|
+
sourceId: string
|
|
1011
|
+
proposals: Array<{
|
|
1012
|
+
action?: 'create' | 'update' | 'merge'
|
|
1013
|
+
entityType: string
|
|
1014
|
+
entityId: string
|
|
1015
|
+
title: string
|
|
1016
|
+
summary?: string
|
|
1017
|
+
content: string
|
|
1018
|
+
sourceRefs?: string[]
|
|
1019
|
+
metadata?: Record<string, unknown>
|
|
1020
|
+
}>
|
|
1021
|
+
}): Promise<Envelope<{ proposed: number; ids: string[]; rejected: string[] }>> {
|
|
1022
|
+
const empty = { proposed: 0, ids: [] as string[], rejected: [] as string[] }
|
|
1023
|
+
if (!input.sourceId?.trim()) return failure(empty, 'SOURCE_REF_REQUIRED', '提议必须挂在一条触发它的 SourceRaw 上', false, '先调 add_source 落一条来源,再用它的 source_id 提提议。')
|
|
1024
|
+
const sources = new Set((await this.loadSources()).map(source => source.sourceId))
|
|
1025
|
+
if (!sources.has(input.sourceId)) {
|
|
1026
|
+
return failure(empty, 'SOURCE_REF_NOT_FOUND', `source_refs 引用不存在: ${input.sourceId}`, false, '先调用 add_source 写入对应的 SourceTruth')
|
|
1027
|
+
}
|
|
1028
|
+
if (!Array.isArray(input.proposals) || input.proposals.length === 0) {
|
|
1029
|
+
return failure(empty, 'PROPOSAL_EMPTY', '至少要提一条提议', true)
|
|
1030
|
+
}
|
|
1031
|
+
const schema = this.wikiSchema()
|
|
1032
|
+
const rejected: string[] = []
|
|
1033
|
+
const accepted = input.proposals.filter(item => {
|
|
1034
|
+
const type = schema.types.find(def => def.id === item.entityType)
|
|
1035
|
+
if (!type) { rejected.push(`${item.entityId}:未知卡片类型 ${item.entityType}`); return false }
|
|
1036
|
+
if (!type.enabled) { rejected.push(`${item.entityId}:类型 ${item.entityType} 已停用`); return false }
|
|
1037
|
+
if (!SAFE_ID.test(item.entityId)) { rejected.push(`${item.entityId}:entity_id 形状不合法`); return false }
|
|
1038
|
+
if (!item.title?.trim() || !item.content?.trim()) { rejected.push(`${item.entityId}:title 与 content 都不能为空`); return false }
|
|
1039
|
+
return true
|
|
1040
|
+
})
|
|
1041
|
+
const touched = await this.cardProposals().upsert(accepted.map(item => ({
|
|
1042
|
+
sourceRef: input.sourceId,
|
|
1043
|
+
trigger: 'agent' as const,
|
|
1044
|
+
action: item.action ?? 'create',
|
|
1045
|
+
entityType: item.entityType,
|
|
1046
|
+
entityId: item.entityId,
|
|
1047
|
+
title: item.title.trim(),
|
|
1048
|
+
...(item.summary ? { summary: item.summary } : {}),
|
|
1049
|
+
content: item.content,
|
|
1050
|
+
sourceRefs: item.sourceRefs ?? [input.sourceId],
|
|
1051
|
+
...(item.metadata ? { metadata: item.metadata } : {}),
|
|
1052
|
+
})))
|
|
1053
|
+
return success(
|
|
1054
|
+
{ proposed: touched.length, ids: touched.map(item => item.id), rejected },
|
|
1055
|
+
rejected.length > 0 ? [`${rejected.length} 条提议被拒:${rejected.join(';')}`] : [],
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* 把一条**已确认**的提议落成卡片。
|
|
1061
|
+
*
|
|
1062
|
+
* 三道检查,缺一条这道门就有缝:
|
|
1063
|
+
* 1. 提议存在,且状态是 `confirmed`(人点过「通过」);
|
|
1064
|
+
* 2. **还没落过卡**(`writtenAt` 为空)—— 确认凭证是**一次性**的;
|
|
1065
|
+
* 3. 再跑一遍三层校验(0021 §6.5):提议阶段那一遍是 Agent 自己补的,
|
|
1066
|
+
* 这一遍的修改可能来自人,两道都跑才拦得住「改完又破了约束」。
|
|
1067
|
+
*/
|
|
1068
|
+
async applyProposal(proposalId: string, lineage?: LineageContext): Promise<Envelope<{ entityId: string; path: string; proposalId: string }>> {
|
|
1069
|
+
const store = this.cardProposals()
|
|
1070
|
+
const empty = { entityId: '', path: '', proposalId }
|
|
1071
|
+
const proposal = await store.get(proposalId)
|
|
1072
|
+
if (!proposal) return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在: ${proposalId}`, true)
|
|
1073
|
+
if (proposal.status === 'pending') {
|
|
1074
|
+
return failure(empty, 'CARD_PROPOSAL_NOT_CONFIRMED', `提议尚未经人确认: ${proposalId}`, false, '未确认的提议不落卡片 —— 这正是这道门的意义。请在「知识库 → Wiki」的待办区确认它。')
|
|
1075
|
+
}
|
|
1076
|
+
if (proposal.status === 'rejected') {
|
|
1077
|
+
return failure(empty, 'CARD_PROPOSAL_REJECTED', `提议已被拒绝: ${proposalId}`, false, proposal.reason ? `拒绝原因:${proposal.reason}` : '被拒绝的提议不会再落卡;内容变了才会重新提。')
|
|
1078
|
+
}
|
|
1079
|
+
if (proposal.writtenAt) {
|
|
1080
|
+
return failure(empty, 'CARD_PROPOSAL_ALREADY_WRITTEN', `提议已经落过卡: ${proposalId}`, false, '确认凭证是一次性的;要改卡片请直接改卡片,或重新提一条提议。')
|
|
1081
|
+
}
|
|
1082
|
+
const written = await this.writeCard({
|
|
1083
|
+
entityType: proposal.entityType as SchemaType,
|
|
1084
|
+
entityId: proposal.entityId,
|
|
1085
|
+
title: proposal.title,
|
|
1086
|
+
content: proposal.content,
|
|
1087
|
+
sourceRefs: proposal.sourceRefs,
|
|
1088
|
+
...(proposal.summary ? { summary: proposal.summary } : {}),
|
|
1089
|
+
...(proposal.metadata ? { metadata: proposal.metadata } : {}),
|
|
1090
|
+
...(lineage ? { lineage } : {}),
|
|
842
1091
|
})
|
|
1092
|
+
if (written.ok === false) return failure(empty, written.error.code, written.error.message, written.error.retryable, written.error.suggestion)
|
|
1093
|
+
await store.markWritten(proposalId)
|
|
1094
|
+
return success({ entityId: proposal.entityId, path: written.data.path, proposalId })
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 人的 review 动作(面板调用,**不是工具**)。
|
|
1099
|
+
*
|
|
1100
|
+
* Agent 拿不到这个入口 —— 它只能 `proposeCards` 与 `applyProposal`,而后者要求
|
|
1101
|
+
* 一条已经被人确认过的提议。确认权收在人手里,这是 §6.1 那条硬规则的结构保证。
|
|
1102
|
+
*/
|
|
1103
|
+
async reviewProposal(input: {
|
|
1104
|
+
proposalId: string
|
|
1105
|
+
action: 'confirm-and-write' | 'confirm' | 'reject'
|
|
1106
|
+
reviewer?: string
|
|
1107
|
+
reason?: string
|
|
1108
|
+
patch?: { title?: string; summary?: string; content?: string }
|
|
1109
|
+
lineage?: LineageContext
|
|
1110
|
+
}): Promise<Envelope<{ proposalId: string; status: string; written: boolean }>> {
|
|
1111
|
+
const store = this.cardProposals()
|
|
1112
|
+
const reviewer = input.reviewer?.trim() || 'user:panel'
|
|
1113
|
+
const empty = { proposalId: input.proposalId, status: '', written: false }
|
|
1114
|
+
if (input.action === 'reject') {
|
|
1115
|
+
const rejected = await store.reject(input.proposalId, input.reason ?? '', reviewer)
|
|
1116
|
+
if (!rejected) return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在或已不在待审状态: ${input.proposalId}`, true)
|
|
1117
|
+
return success({ proposalId: rejected.id, status: rejected.status, written: false })
|
|
1118
|
+
}
|
|
1119
|
+
const confirmed = await store.confirm(input.proposalId, reviewer, input.patch)
|
|
1120
|
+
if (!confirmed) return failure(empty, 'CARD_PROPOSAL_NOT_FOUND', `提议不存在或已不在待审状态: ${input.proposalId}`, true)
|
|
1121
|
+
if (input.action === 'confirm') return success({ proposalId: confirmed.id, status: confirmed.status, written: false })
|
|
1122
|
+
const applied = await this.applyProposal(confirmed.id, input.lineage)
|
|
1123
|
+
if (applied.ok === false) return failure(empty, applied.error.code, applied.error.message, applied.error.retryable, applied.error.suggestion)
|
|
1124
|
+
return success({ proposalId: confirmed.id, status: 'confirmed', written: true })
|
|
843
1125
|
}
|
|
844
1126
|
|
|
845
1127
|
/** 对已落库的卡片跑三层校验。不写盘,只报告。 */
|
|
@@ -944,7 +1226,9 @@ export class KnowledgeRuntime {
|
|
|
944
1226
|
if (!this.segmentsOrganized) {
|
|
945
1227
|
this.segmentsOrganized = true
|
|
946
1228
|
await this.migrateSourceTruthLayout()
|
|
947
|
-
await this.
|
|
1229
|
+
await this.dropRetiredSegments()
|
|
1230
|
+
await this.migrateLegacySourceTypeRename()
|
|
1231
|
+
await this.migrateLegacyDiagnosisCards()
|
|
948
1232
|
}
|
|
949
1233
|
}
|
|
950
1234
|
|
|
@@ -985,8 +1269,35 @@ export class KnowledgeRuntime {
|
|
|
985
1269
|
* 有人放了文件,那就不属于「没用的空文件夹」了 —— 那种情况下留着并让人自己判断,
|
|
986
1270
|
* 比替他们删掉安全。
|
|
987
1271
|
*/
|
|
988
|
-
|
|
989
|
-
|
|
1272
|
+
/**
|
|
1273
|
+
* 清掉**只装了旧版本自己生成的东西**的退役段。
|
|
1274
|
+
*
|
|
1275
|
+
* `00_schema` 从来没有一行代码写过东西,所以「空」就是它的全部历史形态 —— 空就删。
|
|
1276
|
+
*
|
|
1277
|
+
* `05_diagnosis` 不一样:旧版本在里面铺过 `templates/*.md` 与一份 README,那两样是
|
|
1278
|
+
* 可以重建的(模板由类型定义派生、README 是说明),删掉不丢知识。但同一个目录里
|
|
1279
|
+
* 可能躺着**真正的历史快照**(`*.jsonl` / `cases.json`),那是只读的迁移输入,
|
|
1280
|
+
* 一个字节都不能动。所以判据是**文件清单**而不是「空不空」:只认得旧版本自己写的
|
|
1281
|
+
* 那些名字时,才删;出现任何别的东西就整个留着。
|
|
1282
|
+
*/
|
|
1283
|
+
private async dropRetiredSegments(): Promise<void> {
|
|
1284
|
+
await this.dropSegmentIfEmpty('00_schema')
|
|
1285
|
+
const legacy = this.safePath('05_diagnosis')
|
|
1286
|
+
try {
|
|
1287
|
+
const entries = await readdir(legacy, { withFileTypes: true })
|
|
1288
|
+
const owned = entries.every(entry =>
|
|
1289
|
+
entry.isDirectory()
|
|
1290
|
+
? entry.name === 'templates'
|
|
1291
|
+
: entry.name === 'README.md' || entry.name.endsWith('.jsonl') || entry.name === 'cases.json')
|
|
1292
|
+
if (!owned) return
|
|
1293
|
+
// 有 jsonl / cases.json 就是历史快照,留着(`DiagnosisStore` 还要读它做迁移)。
|
|
1294
|
+
if (entries.some(entry => entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'cases.json'))) return
|
|
1295
|
+
await rm(legacy, { recursive: true, force: true })
|
|
1296
|
+
} catch { /* 不存在或删不掉都不是错误 */ }
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
private async dropSegmentIfEmpty(name: string): Promise<void> {
|
|
1300
|
+
const path = this.safePath(name)
|
|
990
1301
|
try {
|
|
991
1302
|
const entries = await readdir(path)
|
|
992
1303
|
if (entries.length > 0) return
|
|
@@ -997,6 +1308,137 @@ export class KnowledgeRuntime {
|
|
|
997
1308
|
} catch { /* 不存在、非空、或删不掉都不是错误 */ }
|
|
998
1309
|
}
|
|
999
1310
|
|
|
1311
|
+
/**
|
|
1312
|
+
* 0040 §3.2 的一次性迁移:`01_source_truth/ai_chat_message/` → `agent_chat_message/`。
|
|
1313
|
+
*
|
|
1314
|
+
* 改名不能只改注册表:`01_source_truth/` 是**按分类分目录**的,存量来源的
|
|
1315
|
+
* frontmatter 里写着 `source_type: ai_chat_message`。只改注册表会有两个后果 ——
|
|
1316
|
+
* 旧目录里那些来源变成一个「注册表里没有的分类」(界面上显示成未知分类),
|
|
1317
|
+
* 而旧分类因为还有来源在,永远删不掉。
|
|
1318
|
+
*
|
|
1319
|
+
* 三条纪律:
|
|
1320
|
+
* 1. **逐份搬,不整体 rename 目录。** 目标里可能已经有同名文件(用户在新版本里
|
|
1321
|
+
* 重新录过同一份来源),整体搬会覆盖它。
|
|
1322
|
+
* 2. **同名时比对正文**:一致就删掉旧的那份(同一份东西,只是目录名变了);
|
|
1323
|
+
* 不一致就**两边都留**,并把冲突报给界面 —— 静默覆盖等于丢数据。
|
|
1324
|
+
* 3. **迁移无损才认**:写完读回来比对 frontmatter 与正文,不一致就放弃这一份。
|
|
1325
|
+
*/
|
|
1326
|
+
private async migrateLegacySourceTypeRename(): Promise<void> {
|
|
1327
|
+
const from = this.safePath(SOURCE_TRUTH, 'ai_chat_message')
|
|
1328
|
+
const to = this.safePath(SOURCE_TRUTH, 'agent_chat_message')
|
|
1329
|
+
let names: string[]
|
|
1330
|
+
try { names = await readdir(from) } catch { return }
|
|
1331
|
+
const conflicts: string[] = []
|
|
1332
|
+
for (const name of names) {
|
|
1333
|
+
if (extname(name).toLowerCase() !== '.md') continue
|
|
1334
|
+
const source = join(from, name)
|
|
1335
|
+
let raw: string
|
|
1336
|
+
try { raw = await readFile(source, 'utf8') } catch { continue }
|
|
1337
|
+
const parsed = parseMarkdown(raw)
|
|
1338
|
+
const sourceId = String(parsed.frontmatter.source_id ?? basename(name, '.md'))
|
|
1339
|
+
const target = join(to, name)
|
|
1340
|
+
let existing: string | undefined
|
|
1341
|
+
try { existing = await readFile(target, 'utf8') } catch { /* 目标里还没有 */ }
|
|
1342
|
+
if (existing !== undefined) {
|
|
1343
|
+
const previous = parseMarkdown(existing)
|
|
1344
|
+
if (previous.content.trim() !== parsed.content.trim()) conflicts.push(sourceId)
|
|
1345
|
+
await unlinkIfExists(source)
|
|
1346
|
+
continue
|
|
1347
|
+
}
|
|
1348
|
+
await writeTextAtomic(target, markdown({ ...parsed.frontmatter, source_type: 'agent_chat_message' }, parsed.content))
|
|
1349
|
+
const roundTrip = parseMarkdown(await readFile(target, 'utf8'))
|
|
1350
|
+
if (roundTrip.content.trim() !== parsed.content.trim() || roundTrip.frontmatter.source_type !== 'agent_chat_message') {
|
|
1351
|
+
// 读回来对不上:把目标撤掉,旧的那份留着 —— 宁可迁移没生效,也不要留半份。
|
|
1352
|
+
await unlinkIfExists(target)
|
|
1353
|
+
conflicts.push(`${sourceId}(迁移后比对不一致,已放弃)`)
|
|
1354
|
+
continue
|
|
1355
|
+
}
|
|
1356
|
+
await unlinkIfExists(source)
|
|
1357
|
+
}
|
|
1358
|
+
try { await rmdir(from) } catch { /* 还有别的东西在里面 */ }
|
|
1359
|
+
if (conflicts.length > 0) {
|
|
1360
|
+
this.migrationProblems.push(`来源分类改名迁移有 ${conflicts.length} 处冲突,旧文件保留在 01_source_truth/ai_chat_message/:${conflicts.join('、')}`)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* 0040 §4 的一次性迁移:两次改名(`check` → `check_point`、`failure-mode` →
|
|
1366
|
+
* `failure_reason`)的存量卡片。
|
|
1367
|
+
*
|
|
1368
|
+
* 两件事一起做,因为它们是同一次改名的两个面:
|
|
1369
|
+
* 1. **卡片类型**:`02_wiki/diagnosis/check/` → `check-point/`,
|
|
1370
|
+
* frontmatter 的 `entity_type` 一起改;
|
|
1371
|
+
* 2. **结构化块里的字段名**:`failure_mode` → `failure_reason`。这一条与卡片类型
|
|
1372
|
+
* 无关 —— `diagnosis/hypothesis` 卡片的类型没变,但它块里那个字段变了。
|
|
1373
|
+
* 只按目录过滤的话,假设卡片上的旧字段名会永远留在磁盘上,而读回来是「少一个
|
|
1374
|
+
* 必填字段」,正文里却明明写着它。
|
|
1375
|
+
*
|
|
1376
|
+
* 读取端本来也认别名(`cards.parseCard`),但**只在内存里认是不够的**:`writeCard`
|
|
1377
|
+
* 按 `entity_type` 决定写哪个目录,改一张旧卡片就会在 `check-point/` 下多写一份,
|
|
1378
|
+
* 而旧目录里那份还在 —— 同一张卡片变成两张,读图时看谁先被遍历到。
|
|
1379
|
+
*
|
|
1380
|
+
* 用 `writeCard` 而不是自己拼 frontmatter:它是唯一知道卡片信封长什么样、并且会
|
|
1381
|
+
* 顺手做版本快照与三层校验的地方。
|
|
1382
|
+
*/
|
|
1383
|
+
private async migrateLegacyDiagnosisCards(): Promise<void> {
|
|
1384
|
+
const root = this.safePath(WIKI, 'diagnosis')
|
|
1385
|
+
let kinds: string[]
|
|
1386
|
+
try { kinds = await readdir(root) } catch { return }
|
|
1387
|
+
/**
|
|
1388
|
+
* 目录名是 `entity_type` 的**命名空间之后那一段**(`02_wiki/diagnosis/check/` 对应
|
|
1389
|
+
* `diagnosis/check`)。用后缀匹配而不是拿 `entity_type` 直接比目录名 —— 直接比的话
|
|
1390
|
+
* 一个都匹配不上,而失败方式是静默的:迁移什么都没做,卡片仍按旧类型读。
|
|
1391
|
+
*/
|
|
1392
|
+
const legacyByDir = new Map<string, string>()
|
|
1393
|
+
for (const [legacy, next] of Object.entries(LEGACY_ENTITY_TYPES)) {
|
|
1394
|
+
legacyByDir.set(legacy.slice(legacy.indexOf('/') + 1), next)
|
|
1395
|
+
}
|
|
1396
|
+
const problems: string[] = []
|
|
1397
|
+
for (const dir of kinds) {
|
|
1398
|
+
const legacyDir = join(root, dir)
|
|
1399
|
+
const renamedType = legacyByDir.get(dir)
|
|
1400
|
+
// 类型没改名、也不在旧目录里的子树也要扫一遍:块字段名的改名与类型无关。
|
|
1401
|
+
for (const file of await filesUnder(legacyDir)) {
|
|
1402
|
+
let raw: string
|
|
1403
|
+
try { raw = await readFile(file, 'utf8') } catch { continue }
|
|
1404
|
+
const parsed = parseMarkdown(raw)
|
|
1405
|
+
const entityId = String(parsed.frontmatter.entity_id ?? basename(file, '.md'))
|
|
1406
|
+
const currentType = String(parsed.frontmatter.entity_type ?? '')
|
|
1407
|
+
const nextType = renamedType ?? LEGACY_ENTITY_TYPES[currentType] ?? currentType
|
|
1408
|
+
if (!nextType) continue
|
|
1409
|
+
const body = rewriteLegacyBlockFields(parsed.content)
|
|
1410
|
+
const typeChanged = nextType !== currentType
|
|
1411
|
+
const bodyChanged = body !== parsed.content
|
|
1412
|
+
// 两样都没变就什么都不做 —— 每次启动都重写一遍所有卡片会把版本历史灌满。
|
|
1413
|
+
if (!typeChanged && !bodyChanged) continue
|
|
1414
|
+
const written = await this.writeCard({
|
|
1415
|
+
entityType: nextType as SchemaType,
|
|
1416
|
+
entityId,
|
|
1417
|
+
title: String(parsed.frontmatter.title ?? entityId),
|
|
1418
|
+
content: body,
|
|
1419
|
+
sourceRefs: Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : [],
|
|
1420
|
+
status: (STATUS.includes(String(parsed.frontmatter.status) as KnowledgeStatus) ? String(parsed.frontmatter.status) : 'unreviewed') as KnowledgeStatus,
|
|
1421
|
+
...(typeof parsed.frontmatter.summary === 'string' && parsed.frontmatter.summary ? { summary: parsed.frontmatter.summary } : {}),
|
|
1422
|
+
...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
|
|
1423
|
+
metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata as Record<string, unknown> : {},
|
|
1424
|
+
})
|
|
1425
|
+
if (written.ok === false) {
|
|
1426
|
+
problems.push(`卡片 ${entityId} 从 ${currentType || dir} 迁到 ${nextType} 失败(${written.error.code}),旧文件保留`)
|
|
1427
|
+
continue
|
|
1428
|
+
}
|
|
1429
|
+
// 读回来确认它真的落在新目录、而且认得出新类型,再删旧的。
|
|
1430
|
+
const readBack = await this.readCard(entityId, nextType as SchemaType)
|
|
1431
|
+
if (!readBack) {
|
|
1432
|
+
problems.push(`卡片 ${entityId} 迁移后读不回来,旧文件保留`)
|
|
1433
|
+
continue
|
|
1434
|
+
}
|
|
1435
|
+
// 正文没变、类型也没变时,`writeCard` 写的是同一个路径 —— 这时**不要**删它。
|
|
1436
|
+
if (written.data.path !== file) await unlinkIfExists(file)
|
|
1437
|
+
}
|
|
1438
|
+
try { await rmdir(legacyDir) } catch { /* 还有别的东西在里面 */ }
|
|
1439
|
+
}
|
|
1440
|
+
if (problems.length > 0) this.migrationProblems.push(...problems)
|
|
1441
|
+
}
|
|
1000
1442
|
private async loadVectorIndex(): Promise<LoadedVectorIndex | null> {
|
|
1001
1443
|
if (this.vectorIndex) return this.vectorIndex
|
|
1002
1444
|
try {
|
|
@@ -1228,6 +1670,10 @@ export class KnowledgeRuntime {
|
|
|
1228
1670
|
const graph = JSON.parse(await readFile(this.safePath('03_compiled', 'knowledge_graph.json'), 'utf8'))
|
|
1229
1671
|
if (graph && Array.isArray(graph.nodes) && Array.isArray(graph.edges)) this.graph = graph as typeof this.graph
|
|
1230
1672
|
} catch { /* the first build has not happened yet */ }
|
|
1673
|
+
try {
|
|
1674
|
+
const diagnosis = JSON.parse(await readFile(this.safePath('03_compiled', DIAGNOSIS_GRAPH), 'utf8'))
|
|
1675
|
+
if (diagnosis && Array.isArray(diagnosis.nodes) && Array.isArray(diagnosis.edges)) this.diagnosisGraph = diagnosis as typeof this.diagnosisGraph
|
|
1676
|
+
} catch { /* 归因图还没编过(这个库没做过排查),或者旧版本没有这个产物 */ }
|
|
1231
1677
|
}
|
|
1232
1678
|
|
|
1233
1679
|
private safePath(...parts: string[]): string {
|
|
@@ -1270,9 +1716,10 @@ export class KnowledgeRuntime {
|
|
|
1270
1716
|
only?: string; incremental?: boolean; skipped?: boolean
|
|
1271
1717
|
cardsReused?: number; cardsRecompiled?: number; cardsAdded?: number; cardsRemoved?: number
|
|
1272
1718
|
notesReused?: number; notesRecompiled?: number
|
|
1273
|
-
// Pass 0
|
|
1719
|
+
// Pass 0 的计数:面板据此显示「这一轮抽了几个候选、提了几条提议」。
|
|
1720
|
+
// v1.5 把 `cardsCreated` / `cardsMerged` 改成提议计数 —— Pass 0 不再落卡。
|
|
1274
1721
|
candidatesExtracted?: number; candidatesRejected?: number
|
|
1275
|
-
|
|
1722
|
+
proposalsCreated?: number; proposalsMerged?: number
|
|
1276
1723
|
relationsExtracted?: number; relationsDropped?: number
|
|
1277
1724
|
sourcesExtracted?: number; sourcesSkipped?: number
|
|
1278
1725
|
}
|
|
@@ -1303,6 +1750,12 @@ export class KnowledgeRuntime {
|
|
|
1303
1750
|
notes: this.units.length,
|
|
1304
1751
|
graphNodes: this.graph.nodes.length,
|
|
1305
1752
|
graphEdges: this.graph.edges.length,
|
|
1753
|
+
/**
|
|
1754
|
+
* 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
|
|
1755
|
+
*
|
|
1756
|
+
* 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
|
|
1757
|
+
*/
|
|
1758
|
+
migrationProblems: this.migrationProblems,
|
|
1306
1759
|
buildVersion: Number(buildManifest.version ?? 0),
|
|
1307
1760
|
builtAt: buildManifest.builtAt,
|
|
1308
1761
|
trackedCards: Object.keys(buildManifest.cardHashes ?? {}).length,
|
|
@@ -1483,11 +1936,19 @@ export class KnowledgeRuntime {
|
|
|
1483
1936
|
return assets.sort((left, right) => left.rel.localeCompare(right.rel))
|
|
1484
1937
|
}
|
|
1485
1938
|
|
|
1486
|
-
/**
|
|
1487
|
-
|
|
1939
|
+
/**
|
|
1940
|
+
* Parse one on-disk SourceRaw file into the detail projection.
|
|
1941
|
+
*
|
|
1942
|
+
* `previewChars` 给了就把正文截到那么长,并把**总长度**如实放进 `contentChars`
|
|
1943
|
+
* (见 `SOURCE_PREVIEW_CHARS` 的理由)。不给就是不截断 —— 那是「我确实要全文」的
|
|
1944
|
+
* 调用方,目前只有 `readDocumentBody()` 之外的内部路径会这么用。
|
|
1945
|
+
*/
|
|
1946
|
+
private async readDocumentFile(path: string, previewChars?: number): Promise<KnowledgeDocumentDetail | undefined> {
|
|
1488
1947
|
let raw: string
|
|
1489
1948
|
try { raw = await readFile(path, 'utf8') } catch { return undefined }
|
|
1490
1949
|
const parsed = parseMarkdown(raw)
|
|
1950
|
+
const contentChars = parsed.content.length
|
|
1951
|
+
const content = previewChars !== undefined && previewChars < contentChars ? parsed.content.slice(0, previewChars) : parsed.content
|
|
1491
1952
|
const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'))
|
|
1492
1953
|
const sourceType = String(parsed.frontmatter.source_type ?? 'document')
|
|
1493
1954
|
// 未知分类照样投影出来(0013);前端按 sourceTypeKnown 渲染「未知分类」徽标。
|
|
@@ -1523,7 +1984,9 @@ export class KnowledgeRuntime {
|
|
|
1523
1984
|
versionCount: (await this.versionIds(sourceId)).length + 1,
|
|
1524
1985
|
relativePath: relative(this.rootDir, path).split(sep).join('/'),
|
|
1525
1986
|
ext,
|
|
1526
|
-
content
|
|
1987
|
+
content,
|
|
1988
|
+
contentChars,
|
|
1989
|
+
contentTruncated: content.length < contentChars,
|
|
1527
1990
|
frontmatter: parsed.frontmatter,
|
|
1528
1991
|
assets,
|
|
1529
1992
|
...(lineage ? { lineage: lineage as Record<string, string> } : {}),
|
|
@@ -1548,9 +2011,27 @@ export class KnowledgeRuntime {
|
|
|
1548
2011
|
}
|
|
1549
2012
|
|
|
1550
2013
|
/** 文档详情:正文、frontmatter、资产与 lineage。 */
|
|
1551
|
-
|
|
2014
|
+
/**
|
|
2015
|
+
* 一份文档的详情投影,**正文默认按 `SOURCE_PREVIEW_CHARS` 封顶**。
|
|
2016
|
+
*
|
|
2017
|
+
* 这是明细接口(抽屉里的预览)读的那一份,所以默认就是「够预览」而不是「全文」:
|
|
2018
|
+
* 全文由 `readDocumentBody()` 提供服务,而它只服务独立整页的分页读取。
|
|
2019
|
+
*/
|
|
2020
|
+
async readDocument(docId: string, previewChars: number = SOURCE_PREVIEW_CHARS): Promise<KnowledgeDocumentDetail | undefined> {
|
|
2021
|
+
const path = await this.sourcePathOf(docId)
|
|
2022
|
+
return path ? this.readDocumentFile(path, previewChars) : undefined
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* 一份文档的**完整正文**(不含 frontmatter),给独立整页分页取用。
|
|
2027
|
+
*
|
|
2028
|
+
* 每次调用都重读一遍盘:独立整页是「用户滚到哪儿取哪一页」,一次请求一段文本,
|
|
2029
|
+
* 谁都不该拿到整份文档 —— 这正是抽屉那一版的问题。缓存留到真的量出来是瓶颈再加。
|
|
2030
|
+
*/
|
|
2031
|
+
async readDocumentBody(docId: string): Promise<string | undefined> {
|
|
1552
2032
|
const path = await this.sourcePathOf(docId)
|
|
1553
|
-
|
|
2033
|
+
if (path === undefined) return undefined
|
|
2034
|
+
try { return parseMarkdown(await readFile(path, 'utf8')).content } catch { return undefined }
|
|
1554
2035
|
}
|
|
1555
2036
|
|
|
1556
2037
|
/**
|
|
@@ -1605,7 +2086,7 @@ export class KnowledgeRuntime {
|
|
|
1605
2086
|
async revertDocument(docId: string, versionId: string): Promise<Envelope<KnowledgeDocumentDetail>> {
|
|
1606
2087
|
const path = await this.sourcePathOf(docId)
|
|
1607
2088
|
if (path === undefined) return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true)
|
|
1608
|
-
const head = await this.readDocumentFile(path)
|
|
2089
|
+
const head = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS)
|
|
1609
2090
|
if (!head) return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true)
|
|
1610
2091
|
const target = typeof versionId === 'string' ? versionId.trim() : ''
|
|
1611
2092
|
if (!target) return failure(head, 'VERSION_ID_REQUIRED', 'versionId 不能为空', false, '用 list_versions / GET /knowledge/documents/:docId/versions 先取版本列表。')
|
|
@@ -1623,7 +2104,7 @@ export class KnowledgeRuntime {
|
|
|
1623
2104
|
lineage: parseLineage(parsed.frontmatter.lineage),
|
|
1624
2105
|
})
|
|
1625
2106
|
if (!restored.ok) return failure(head, restored.error?.code ?? 'REVERT_FAILED', restored.error?.message ?? '回滚写入失败', restored.error?.retryable ?? false, restored.error?.suggestion)
|
|
1626
|
-
const document = await this.readDocumentFile(path)
|
|
2107
|
+
const document = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS)
|
|
1627
2108
|
if (!document) return failure(head, 'DOCUMENT_NOT_FOUND', `回滚后文档不可读: ${docId}`, true)
|
|
1628
2109
|
await this.audit('source_truth.revert', { docId, versionId: target })
|
|
1629
2110
|
return success(document)
|
|
@@ -1982,7 +2463,7 @@ export class KnowledgeRuntime {
|
|
|
1982
2463
|
private emptyExtractionOutcome(extractionHashes: Record<string, string>): ExtractionOutcome {
|
|
1983
2464
|
return {
|
|
1984
2465
|
extractionHashes,
|
|
1985
|
-
candidatesExtracted: 0, candidatesRejected: 0,
|
|
2466
|
+
candidatesExtracted: 0, candidatesRejected: 0, proposalsCreated: 0, proposalsMerged: 0,
|
|
1986
2467
|
sourcesExtracted: 0, sourcesSkipped: 0, relations: [], relationsBySource: {}, warnings: [],
|
|
1987
2468
|
}
|
|
1988
2469
|
}
|
|
@@ -2009,7 +2490,13 @@ export class KnowledgeRuntime {
|
|
|
2009
2490
|
const persisted = await this.readExtractedRelations()
|
|
2010
2491
|
// 计数器与产物不能由 readdir 的顺序决定,所以先按 sourceId 排序。
|
|
2011
2492
|
const ordered = [...sources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))
|
|
2012
|
-
|
|
2493
|
+
/**
|
|
2494
|
+
* 库里的卡片是**只读的参照物**:这一轮不再往里加东西,抽到的一切都进 `pending`。
|
|
2495
|
+
* 整轮结束才一次性 upsert —— 同一张卡片被多份来源命中时只有一条提议,
|
|
2496
|
+
* 正文累积(而不是产生 N 条互相打架的提议)。
|
|
2497
|
+
*/
|
|
2498
|
+
const cards = await this.loadCards()
|
|
2499
|
+
const pending = new Map<string, PendingProposal>()
|
|
2013
2500
|
for (const source of ordered) {
|
|
2014
2501
|
const fingerprint = this.extractionFingerprint(source, registryHash)
|
|
2015
2502
|
outcome.extractionHashes[source.sourceId] = fingerprint
|
|
@@ -2024,11 +2511,34 @@ export class KnowledgeRuntime {
|
|
|
2024
2511
|
}
|
|
2025
2512
|
outcome.sourcesExtracted += 1
|
|
2026
2513
|
try {
|
|
2027
|
-
|
|
2514
|
+
await this.proposeFromSource(source, cards, enabledTypes, outcome, pending)
|
|
2028
2515
|
} catch (cause) {
|
|
2029
2516
|
outcome.warnings.push(`来源 ${source.sourceId} 的候选抽取失败,已跳过该来源:${cause instanceof Error ? cause.message : String(cause)}`)
|
|
2030
2517
|
}
|
|
2031
2518
|
}
|
|
2519
|
+
/**
|
|
2520
|
+
* 落**提议**,不是卡片。
|
|
2521
|
+
*
|
|
2522
|
+
* `upsert` 会按内容指纹判重:人已经拒绝过、而且内容没变的那一条不会再冒出来;
|
|
2523
|
+
* 人已经确认过、而且内容没变的那一条也不会再问一遍。计数取自它真正写下的那些,
|
|
2524
|
+
* 而不是 `pending` 的大小 —— 报告里的数字必须是「这次真的发生了什么」。
|
|
2525
|
+
*/
|
|
2526
|
+
if (pending.size > 0) {
|
|
2527
|
+
const touched = await this.cardProposals().upsert([...pending.values()].map(item => ({
|
|
2528
|
+
sourceRef: item.sourceRefs[0] ?? `kb:${item.entityType}`,
|
|
2529
|
+
trigger: 'extraction' as const,
|
|
2530
|
+
action: item.action,
|
|
2531
|
+
entityType: item.entityType,
|
|
2532
|
+
entityId: item.entityId,
|
|
2533
|
+
title: item.title,
|
|
2534
|
+
content: item.content,
|
|
2535
|
+
sourceRefs: item.sourceRefs,
|
|
2536
|
+
...(item.metadata ? { metadata: item.metadata } : {}),
|
|
2537
|
+
...(item.baseHash ? { baseHash: item.baseHash } : {}),
|
|
2538
|
+
})))
|
|
2539
|
+
outcome.proposalsCreated = touched.filter(item => item.action === 'create').length
|
|
2540
|
+
outcome.proposalsMerged = touched.filter(item => item.action === 'update').length
|
|
2541
|
+
}
|
|
2032
2542
|
// 只留这一轮真实存在的来源:删掉的文档不该在图上留下幽灵关系。
|
|
2033
2543
|
const alive = new Set(ordered.map(source => source.sourceId))
|
|
2034
2544
|
const next: Record<string, ExtractionOutcome['relations']> = {}
|
|
@@ -2070,8 +2580,22 @@ export class KnowledgeRuntime {
|
|
|
2070
2580
|
} catch { /* 写不进去就当这一轮没有产物,下一次重建会重新抽 */ }
|
|
2071
2581
|
}
|
|
2072
2582
|
|
|
2073
|
-
/**
|
|
2074
|
-
|
|
2583
|
+
/**
|
|
2584
|
+
* 一份来源的抽取结果:**产出提议**(新建 / 更新),并收集关系候选。
|
|
2585
|
+
*
|
|
2586
|
+
* 它**不再写卡片**。这是 v1.5 那道门最容易被绕开的地方:Pass 0 是一次自动流程,
|
|
2587
|
+
* 它写进 `02_wiki/` 的东西会立刻参与编译、进图、被召回 —— 而类型层是收敛的那一层,
|
|
2588
|
+
* 它错一条,所有现象的排序一起错。所以这里只往 `pending` 里放提议,
|
|
2589
|
+
* 由 `runExtractionStage` 在整轮结束时一次性 upsert(同一张卡片被多份来源命中时,
|
|
2590
|
+
* 只有一条提议,正文累积)。
|
|
2591
|
+
*/
|
|
2592
|
+
private async proposeFromSource(
|
|
2593
|
+
source: SourceRaw,
|
|
2594
|
+
cards: KnowledgeCard[],
|
|
2595
|
+
enabledTypes: WikiTypeDef[],
|
|
2596
|
+
outcome: ExtractionOutcome,
|
|
2597
|
+
pending: Map<string, PendingProposal>,
|
|
2598
|
+
): Promise<void> {
|
|
2075
2599
|
/**
|
|
2076
2600
|
* #3b:**模型先抽实体,规则补关系。**
|
|
2077
2601
|
*
|
|
@@ -2095,30 +2619,36 @@ export class KnowledgeRuntime {
|
|
|
2095
2619
|
normalized = { fresh: modelled.fresh, merges: modelled.merges, relations: ruleNormalized.relations }
|
|
2096
2620
|
}
|
|
2097
2621
|
}
|
|
2098
|
-
const candidates = normalized.fresh
|
|
2099
|
-
if (candidates.length === 0 && normalized.merges.length === 0) return cards
|
|
2100
|
-
const next = [...cards]
|
|
2101
2622
|
|
|
2102
|
-
// 先建后并:同一轮里新建的卡片也可能被别名指回,先建才能让后面的合并找得到对象。
|
|
2103
2623
|
for (const candidate of normalized.fresh) {
|
|
2104
2624
|
/**
|
|
2105
|
-
*
|
|
2625
|
+
* 候选**必须带正文**才提提议。
|
|
2106
2626
|
*
|
|
2107
2627
|
* 0029 实跑发现:这里原来在候选没有正文时拿 `type.template` 顶上,于是
|
|
2108
2628
|
* `[[链接]]` 指到、但还没人写过的实体(那时链接候选确实没有正文)会落成一张
|
|
2109
2629
|
* **正文是模板**的卡片,而模板里的「(必填:写什么)」会被编进 wikiNotes、
|
|
2110
2630
|
* 被召回、被塞进模型的上下文包 —— 管线写出了一张它自己的校验器判为不合格的
|
|
2111
2631
|
* 卡片。链接候选现在会带上它所在小节的正文(`extract.ts`),所以这条回退基本
|
|
2112
|
-
*
|
|
2632
|
+
* 不会被走到;真走到了就**不提**,并如实计数:没有正文 = 没有可说的。
|
|
2113
2633
|
*/
|
|
2114
2634
|
const body = candidate.body?.trim() ?? ''
|
|
2115
2635
|
if (!body) {
|
|
2116
2636
|
outcome.candidatesRejected += 1
|
|
2117
|
-
outcome.warnings.push(`候选「${candidate.name}
|
|
2637
|
+
outcome.warnings.push(`候选「${candidate.name}」没有任何正文(只在别处被提到,所在小节也没取到),不提提议`)
|
|
2638
|
+
continue
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* 类型必须来自注册表。抽到一个没登记的类型时**不提这条提议** —— 提议的正文与
|
|
2642
|
+
* 小节骨架都按类型的 schema 校验,而未知类型没有 schema,人 review 时只能拒掉它。
|
|
2643
|
+
* 与其往待办队列里塞一条注定被拒的东西,不如当场记一笔并说清原因。
|
|
2644
|
+
*/
|
|
2645
|
+
if (!enabledTypes.some(def => def.id === candidate.type)) {
|
|
2646
|
+
outcome.candidatesRejected += 1
|
|
2647
|
+
outcome.warnings.push(`候选「${candidate.name}」未提议(SCHEMA_TYPE_INVALID):类型 ${candidate.type} 未登记或已停用`)
|
|
2118
2648
|
continue
|
|
2119
2649
|
}
|
|
2120
|
-
|
|
2121
|
-
const
|
|
2650
|
+
const entityId = candidateEntityId(candidate.name)
|
|
2651
|
+
const key = `${candidate.type}:${entityId}`
|
|
2122
2652
|
const metadata: Record<string, unknown> = {
|
|
2123
2653
|
extraction: 'pass0',
|
|
2124
2654
|
candidate_kind: candidate.kind,
|
|
@@ -2127,72 +2657,40 @@ export class KnowledgeRuntime {
|
|
|
2127
2657
|
sourceId: candidate.evidence.sourceId,
|
|
2128
2658
|
...(candidate.evidence.heading ? { heading: candidate.evidence.heading } : {}),
|
|
2129
2659
|
},
|
|
2130
|
-
|
|
2660
|
+
// 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
|
|
2661
|
+
...(aliasesOf(candidate).length > 0 ? { aliases: aliasesOf(candidate) } : {}),
|
|
2131
2662
|
}
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
content: body,
|
|
2137
|
-
sourceRefs: [candidate.evidence.sourceId],
|
|
2138
|
-
status: 'unreviewed',
|
|
2139
|
-
metadata,
|
|
2140
|
-
})
|
|
2141
|
-
if (!written.ok) {
|
|
2142
|
-
outcome.candidatesRejected += 1
|
|
2143
|
-
outcome.warnings.push(`候选「${candidate.name}」未写入(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`)
|
|
2663
|
+
// 库里已经有这张卡片 → 不是新建,而是「把这次抽到的正文并进去」。
|
|
2664
|
+
const existing = cards.find(card => card.entityId === entityId && card.entityType === candidate.type)
|
|
2665
|
+
if (existing) {
|
|
2666
|
+
mergeProposal(pending, existing, candidate.evidence.sourceId, [body], [candidate.name])
|
|
2144
2667
|
continue
|
|
2145
2668
|
}
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2669
|
+
const previous = pending.get(key)
|
|
2670
|
+
if (previous) {
|
|
2671
|
+
previous.content = appendExtracted(previous.content, [body])
|
|
2672
|
+
if (!previous.sourceRefs.includes(candidate.evidence.sourceId)) previous.sourceRefs.push(candidate.evidence.sourceId)
|
|
2673
|
+
continue
|
|
2674
|
+
}
|
|
2675
|
+
pending.set(key, {
|
|
2676
|
+
action: 'create',
|
|
2149
2677
|
entityType: candidate.type,
|
|
2678
|
+
entityId,
|
|
2150
2679
|
title: candidate.name,
|
|
2151
|
-
status: 'unreviewed',
|
|
2152
2680
|
content: body,
|
|
2153
2681
|
sourceRefs: [candidate.evidence.sourceId],
|
|
2154
2682
|
metadata,
|
|
2155
|
-
updatedAt: '',
|
|
2156
2683
|
})
|
|
2157
2684
|
}
|
|
2158
2685
|
|
|
2159
|
-
//
|
|
2160
|
-
const targets = new Map<string, { card: KnowledgeCard; keys: string[]; sourceId: string; bodies: string[] }>()
|
|
2686
|
+
// 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并成一条提议。
|
|
2161
2687
|
for (const merge of normalized.merges) {
|
|
2162
|
-
const card =
|
|
2688
|
+
const card = cards.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType)
|
|
2163
2689
|
if (!card) continue
|
|
2164
|
-
// 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 ——
|
|
2165
|
-
//
|
|
2690
|
+
// 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空提一条提议
|
|
2691
|
+
// 也会让「并入了多少张卡」这个计数变成谎话。
|
|
2166
2692
|
if (card.sourceRefs.includes(merge.sourceId)) continue
|
|
2167
|
-
|
|
2168
|
-
const entry = targets.get(id)
|
|
2169
|
-
if (entry) {
|
|
2170
|
-
if (!entry.keys.includes(merge.key)) entry.keys.push(merge.key)
|
|
2171
|
-
entry.bodies.push(merge.body)
|
|
2172
|
-
} else targets.set(id, { card, keys: [merge.key], sourceId: merge.sourceId, bodies: [merge.body] })
|
|
2173
|
-
}
|
|
2174
|
-
for (const { card, keys, sourceId, bodies } of targets.values()) {
|
|
2175
|
-
const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : []
|
|
2176
|
-
// 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
|
|
2177
|
-
const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20)
|
|
2178
|
-
// **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
|
|
2179
|
-
// 于是知识是累积的,来源改写了也不会停在旧版本上。
|
|
2180
|
-
const content = appendExtracted(card.content, bodies)
|
|
2181
|
-
const written = await this.writeCard({
|
|
2182
|
-
...card,
|
|
2183
|
-
content,
|
|
2184
|
-
sourceRefs: [...card.sourceRefs, sourceId],
|
|
2185
|
-
metadata: { ...card.metadata, extraction_merges },
|
|
2186
|
-
})
|
|
2187
|
-
if (!written.ok) {
|
|
2188
|
-
outcome.candidatesRejected += 1
|
|
2189
|
-
outcome.warnings.push(`候选并入卡片「${card.entityId}」失败(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`)
|
|
2190
|
-
continue
|
|
2191
|
-
}
|
|
2192
|
-
outcome.cardsMerged += 1
|
|
2193
|
-
// 让后面的来源看到已经追加过的正文与 source_refs,避免重复写入。
|
|
2194
|
-
const index = next.findIndex(item => item.entityId === card.entityId && item.entityType === card.entityType)
|
|
2195
|
-
if (index >= 0) next[index] = { ...card, content, sourceRefs: [...card.sourceRefs, sourceId], metadata: { ...card.metadata, extraction_merges } }
|
|
2693
|
+
mergeProposal(pending, card, merge.sourceId, [merge.body], [merge.key])
|
|
2196
2694
|
}
|
|
2197
2695
|
|
|
2198
2696
|
outcome.relations.push(...normalized.relations)
|
|
@@ -2201,7 +2699,6 @@ export class KnowledgeRuntime {
|
|
|
2201
2699
|
...(outcome.relationsBySource[source.sourceId] ?? []),
|
|
2202
2700
|
...normalized.relations,
|
|
2203
2701
|
]
|
|
2204
|
-
return next
|
|
2205
2702
|
}
|
|
2206
2703
|
|
|
2207
2704
|
/**
|
|
@@ -2240,7 +2737,7 @@ export class KnowledgeRuntime {
|
|
|
2240
2737
|
const sourceTypes = new Map(sources.map(source => [source.sourceId, source.sourceType]))
|
|
2241
2738
|
|
|
2242
2739
|
// ------------------------------------------------ previous build state
|
|
2243
|
-
let previousManifest: { version?: number; builtAt?: string; cardHashes?: Record<string, string>; extractionHashes?: Record<string, string>; embedding?: unknown } = {}
|
|
2740
|
+
let previousManifest: { version?: number; builtAt?: string; cardHashes?: Record<string, string>; extractionHashes?: Record<string, string>; embedding?: unknown; diagnosisHash?: string } = {}
|
|
2244
2741
|
try {
|
|
2245
2742
|
previousManifest = JSON.parse(await readFile(this.safePath('03_compiled', 'build-manifest.json'), 'utf8')) as typeof previousManifest
|
|
2246
2743
|
} catch { /* first build */ }
|
|
@@ -2260,12 +2757,11 @@ export class KnowledgeRuntime {
|
|
|
2260
2757
|
: this.emptyExtractionOutcome(previousExtractionHashes)
|
|
2261
2758
|
// 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
|
|
2262
2759
|
// 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
|
|
2263
|
-
if (extraction.cardsCreated > 0 || extraction.cardsMerged > 0) this.cards = await this.loadCards()
|
|
2264
2760
|
const extractionCounts = {
|
|
2265
2761
|
candidatesExtracted: extraction.candidatesExtracted,
|
|
2266
2762
|
candidatesRejected: extraction.candidatesRejected,
|
|
2267
|
-
|
|
2268
|
-
|
|
2763
|
+
proposalsCreated: extraction.proposalsCreated,
|
|
2764
|
+
proposalsMerged: extraction.proposalsMerged,
|
|
2269
2765
|
sourcesExtracted: extraction.sourcesExtracted,
|
|
2270
2766
|
sourcesSkipped: extraction.sourcesSkipped,
|
|
2271
2767
|
}
|
|
@@ -2335,7 +2831,19 @@ export class KnowledgeRuntime {
|
|
|
2335
2831
|
// bumping the version there would invalidate the vector cache and every
|
|
2336
2832
|
// qrels pointing at it for nothing. `all` never takes this path (see
|
|
2337
2833
|
// `incremental` above).
|
|
2338
|
-
|
|
2834
|
+
/**
|
|
2835
|
+
* 归因图(字段级)也是这次编译的产物之一,所以它变了也算「语料变了」。
|
|
2836
|
+
*
|
|
2837
|
+
* 少了这一条会踩一个很隐蔽的坑:**记一次排查不改任何卡片**(它写的是一条
|
|
2838
|
+
* `agent_diagnosis_case` 的 SourceRaw),于是增量编译判定「没变化」直接跳过 ——
|
|
2839
|
+
* 而 `03_compiled/diagnosis_graph.json` 停在上一轮,图召回因此看不到这次排查
|
|
2840
|
+
* 新增的 `produces` / `supports` / `refutes` 边。它读的是**编译产物**,
|
|
2841
|
+
* 不是 `02_wiki` 的实时投影(§14.3)。
|
|
2842
|
+
*/
|
|
2843
|
+
const diagnosis = await this.diagnosisStore().readGraph()
|
|
2844
|
+
const diagnosisHash = contentHashOf(JSON.stringify({ nodes: diagnosis.nodes, edges: diagnosis.edges }))
|
|
2845
|
+
const diagnosisChanged = diagnosisHash !== previousManifest.diagnosisHash
|
|
2846
|
+
const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged
|
|
2339
2847
|
if (!corpusChanged) {
|
|
2340
2848
|
const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`
|
|
2341
2849
|
const report: RebuildReport = {
|
|
@@ -2356,7 +2864,7 @@ export class KnowledgeRuntime {
|
|
|
2356
2864
|
// 用户刚点了编译却看到上次的统计,会以为按钮没生效。version 不变,所以
|
|
2357
2865
|
// 这个写入既不改索引也不让向量缓存失效。extractionHashes 必须跟着更新:
|
|
2358
2866
|
// 来源变了但没抽出卡片时,不落新指纹会让下一次又把它重抽一遍。
|
|
2359
|
-
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))
|
|
2867
|
+
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))
|
|
2360
2868
|
return success(report, ['知识库没有变化:已复用全部 wikiNotes,未生成新的索引版本。', ...extraction.warnings])
|
|
2361
2869
|
}
|
|
2362
2870
|
|
|
@@ -2381,6 +2889,14 @@ export class KnowledgeRuntime {
|
|
|
2381
2889
|
// Compatibility mirror for tooling that predates wiki_notes.json.
|
|
2382
2890
|
await writeTextAtomic(this.safePath('03_compiled', LEGACY_UNITS), JSON.stringify(this.units, null, 2))
|
|
2383
2891
|
await writeTextAtomic(this.safePath('03_compiled', 'knowledge_graph.json'), JSON.stringify(this.graph, null, 2))
|
|
2892
|
+
/**
|
|
2893
|
+
* 字段级的归因图(0021 §14.3)。**同一次编译产出两份投影,各自由它的消费者读**:
|
|
2894
|
+
* `knowledge_graph.json` 是卡片级(图谱 Tab、图召回),这里是字段级
|
|
2895
|
+
* (智能归因三视图)。不合成一张 —— 消费者不同、`supported_by` 只对卡片成立、
|
|
2896
|
+
* 而召回权重的基准不该被顺手改掉(§14.4)。
|
|
2897
|
+
*/
|
|
2898
|
+
this.diagnosisGraph = { nodes: diagnosis.nodes as unknown as Array<Record<string, unknown>>, edges: diagnosis.edges as unknown as Array<Record<string, unknown>> }
|
|
2899
|
+
await writeTextAtomic(this.safePath('03_compiled', DIAGNOSIS_GRAPH), JSON.stringify({ version, builtAt, nodes: diagnosis.nodes, edges: diagnosis.edges }, null, 2))
|
|
2384
2900
|
// 关键词索引跟着 notes 一起落盘(0031):它的输入就是 notes,两者必须同时有效,
|
|
2385
2901
|
// 否则会出现「卡片改了、关键词路还按旧词表召回」。它不依赖编码器,所以 wiki-only
|
|
2386
2902
|
// 重建也要重建它 —— 而向量是 stage 3,仍然只在 `embeddings`/`all` 时重算。
|
|
@@ -2390,7 +2906,7 @@ export class KnowledgeRuntime {
|
|
|
2390
2906
|
// re-encode of the whole corpus.
|
|
2391
2907
|
const embeddingResult = only === 'all' ? await this.buildVectorIndex(this.units, version) : { status: 'skipped' as const, count: 0, reused: 0 }
|
|
2392
2908
|
const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`
|
|
2393
|
-
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))
|
|
2909
|
+
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))
|
|
2394
2910
|
const report: RebuildReport = {
|
|
2395
2911
|
runId: compileRunId, compileRunId, version, only,
|
|
2396
2912
|
cards: this.cards.length, units: this.units.length, notes: this.units.length,
|
|
@@ -2598,7 +3114,60 @@ export class KnowledgeRuntime {
|
|
|
2598
3114
|
* 匹配逻辑与 0030 相同(词面匹配节点 title / id 与关系词,命中节点带出 1 跳邻居并衰减),
|
|
2599
3115
|
* 但返回值只用于给**已经在候选池里**的条目加权 —— 它自己不能制造候选。
|
|
2600
3116
|
*/
|
|
2601
|
-
|
|
3117
|
+
/**
|
|
3118
|
+
* 字段级的归因图(`03_compiled/diagnosis_graph.json`)。
|
|
3119
|
+
*
|
|
3120
|
+
* 它只用于**给已经在候选池里的条目加权**(和卡片级图一样,不能自己制造候选)。
|
|
3121
|
+
* 用途是把「假设 → 检查点」「证据 → 假设」这两类卡片级图看不见的语义带进召回。
|
|
3122
|
+
*/
|
|
3123
|
+
private diagnosisGraph: { nodes: Array<Record<string, unknown>>; edges: Array<Record<string, unknown>> } = { nodes: [], edges: [] }
|
|
3124
|
+
|
|
3125
|
+
/**
|
|
3126
|
+
* 归因图上按词面命中的节点 → 分数,含 1 跳衰减。
|
|
3127
|
+
*
|
|
3128
|
+
* 与卡片级那一段是**同一条算法、两份数据**:卡片级图里只有 `supported_by` /
|
|
3129
|
+
* `relates_to`,而字段级图里是十种语义边。分开算再按 `entityId` 合流,
|
|
3130
|
+
* 是因为两张图的节点含义不同(一个是卡片,一个是字段级节点),合成一张会让
|
|
3131
|
+
* 图谱 Tab 看到一张卡片散成七个点。
|
|
3132
|
+
*/
|
|
3133
|
+
private diagnosisHits(queryTokens: string[]): Map<string, number> {
|
|
3134
|
+
const seed = new Map<string, number>()
|
|
3135
|
+
if (this.diagnosisGraph.nodes.length === 0) return seed
|
|
3136
|
+
for (const node of this.diagnosisGraph.nodes) {
|
|
3137
|
+
const title = String(node.title ?? '').toLocaleLowerCase()
|
|
3138
|
+
const id = String(node.id ?? '').toLocaleLowerCase()
|
|
3139
|
+
let score = 0
|
|
3140
|
+
for (const token of queryTokens) {
|
|
3141
|
+
if (title.includes(token)) score += 3
|
|
3142
|
+
else if (id.includes(token)) score += 2
|
|
3143
|
+
}
|
|
3144
|
+
if (score > 0) seed.set(String(node.id), score)
|
|
3145
|
+
}
|
|
3146
|
+
// 关系词命中:两端各得 1 分。中文提问不会写 `refutes`,所以走 RELATION_QUERY_WORDS。
|
|
3147
|
+
for (const edge of this.diagnosisGraph.edges) {
|
|
3148
|
+
const type = String(edge.kind ?? '')
|
|
3149
|
+
const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase()
|
|
3150
|
+
let score = 0
|
|
3151
|
+
for (const token of queryTokens) if (relation.includes(token)) score += 1
|
|
3152
|
+
if (score > 0) {
|
|
3153
|
+
seed.set(String(edge.source), Math.max(seed.get(String(edge.source)) ?? 0, score))
|
|
3154
|
+
seed.set(String(edge.target), Math.max(seed.get(String(edge.target)) ?? 0, score))
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
if (seed.size === 0) return seed
|
|
3158
|
+
const expanded = new Map<string, number>(seed)
|
|
3159
|
+
for (const [id, score] of seed) {
|
|
3160
|
+
for (const edge of this.diagnosisGraph.edges) {
|
|
3161
|
+
const from = String(edge.source)
|
|
3162
|
+
const to = String(edge.target)
|
|
3163
|
+
if (from === id) expanded.set(to, Math.max(expanded.get(to) ?? 0, score * 0.5))
|
|
3164
|
+
else if (to === id) expanded.set(from, Math.max(expanded.get(from) ?? 0, score * 0.5))
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
return expanded
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
private async graphBoostRanking(query: string, eligible: WikiNote[], includeProblems: boolean): Promise<RouteRanking[]> {
|
|
2602
3171
|
if (this.graph.nodes.length === 0) return []
|
|
2603
3172
|
const queryTokens = segment(query)
|
|
2604
3173
|
if (queryTokens.length === 0) return []
|
|
@@ -2606,6 +3175,9 @@ export class KnowledgeRuntime {
|
|
|
2606
3175
|
const boost = (key: string, score: number): void => { byNodeKey.set(key, Math.max(byNodeKey.get(key) ?? 0, score)) }
|
|
2607
3176
|
const visible = (node: Record<string, unknown>): boolean => includeProblems || node.status !== 'problem'
|
|
2608
3177
|
const nodeKey = (node: Record<string, unknown>): string => `${String(node.type)}:${String(node.id)}`
|
|
3178
|
+
/** 卡片级节点 id → 它的 `entity_type`。归因图的节点 id 与卡片 id 相等(§14.1)。 */
|
|
3179
|
+
const typeByEntityId = new Map<string, string>()
|
|
3180
|
+
for (const node of this.graph.nodes) typeByEntityId.set(String(node.id), String(node.type))
|
|
2609
3181
|
|
|
2610
3182
|
for (const node of this.graph.nodes) {
|
|
2611
3183
|
if (!visible(node)) continue
|
|
@@ -2625,6 +3197,18 @@ export class KnowledgeRuntime {
|
|
|
2625
3197
|
for (const token of queryTokens) if (relation.includes(token)) score += 1
|
|
2626
3198
|
if (score > 0) { boost(String(edge.from), score); boost(String(edge.to), score) }
|
|
2627
3199
|
}
|
|
3200
|
+
/**
|
|
3201
|
+
* **字段级的归因图**(0021 §14.4 的第二条最小路径)。
|
|
3202
|
+
*
|
|
3203
|
+
* 卡片级的图只认 `supported_by` 与 `relates_to` 两种关系词,所以「假设 → 检查点」
|
|
3204
|
+
* 与「证据 → 假设」这两类语义对召回完全不可见。这里按 `entityId` 把两张图接起来:
|
|
3205
|
+
* 归因图上命中的节点,映射回同 id 的卡片级节点加权 —— **不需要合图**,
|
|
3206
|
+
* 因为两张图的节点 id 本来就相等(§14.1)。
|
|
3207
|
+
*/
|
|
3208
|
+
for (const [id, score] of this.diagnosisHits(queryTokens)) {
|
|
3209
|
+
const type = typeByEntityId.get(id)
|
|
3210
|
+
if (type) boost(`${type}:${id}`, score)
|
|
3211
|
+
}
|
|
2628
3212
|
if (byNodeKey.size === 0) return []
|
|
2629
3213
|
const expanded = new Map<string, number>(byNodeKey)
|
|
2630
3214
|
for (const [key, score] of byNodeKey) {
|
|
@@ -2755,7 +3339,7 @@ export class KnowledgeRuntime {
|
|
|
2755
3339
|
const vectorOutcome = useVector
|
|
2756
3340
|
? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold)
|
|
2757
3341
|
: { ranking: [] as RouteRanking[], diagnostic: { route: 'vector' as const, status: 'skipped' as const, reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } }
|
|
2758
|
-
const graphRanking = useGraphBoost ? this.graphBoostRanking(query, eligible, includeProblems) : []
|
|
3342
|
+
const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems) : []
|
|
2759
3343
|
|
|
2760
3344
|
if (ftsOutcome.diagnostic.status === 'degraded' && ftsOutcome.diagnostic.reason) warnings.push(ftsOutcome.diagnostic.reason)
|
|
2761
3345
|
if (vectorOutcome.diagnostic.status === 'degraded' && vectorOutcome.diagnostic.reason) warnings.push(vectorOutcome.diagnostic.reason)
|
|
@@ -2984,8 +3568,25 @@ export class KnowledgeRuntime {
|
|
|
2984
3568
|
|
|
2985
3569
|
async graphQuery(action: 'entity' | 'neighbors' | 'path' | 'list' | 'types', entityId?: string, entityType?: string, depth = 1, options: { fromId?: string; toId?: string; status?: string; includeProblem?: boolean; limit?: number } = {}, lineage?: LineageContext): Promise<Envelope<Record<string, unknown>>> {
|
|
2986
3570
|
const eventLineage = withRun(lineage, 'graph-query')
|
|
2987
|
-
|
|
3571
|
+
/**
|
|
3572
|
+
* **读盘,不编译。**
|
|
3573
|
+
*
|
|
3574
|
+
* `units` 为空只说明这个 runtime 还没读过 `03_compiled/`,**不等于**盘上没有编译
|
|
3575
|
+
* 产物:runtime 是按库缓存的,设置一变就被丢掉(`KnowledgeBases.invalidate()`),
|
|
3576
|
+
* 下一次读图时内存里又是空的。原来的写法把这两种情况当成同一件事,在这里直接
|
|
3577
|
+
* `rebuild('all')` —— 于是一次**读**变成一次全量编译:面板刷新时那个
|
|
3578
|
+
* `GET /knowledge/graph` 在几百篇文档的库上要等 3~5 秒,而这段时间整个知识库面板
|
|
3579
|
+
* 连同「类型定义」都是不可操作的。
|
|
3580
|
+
*
|
|
3581
|
+
* 编译是**写**动作,归「编译」按钮与 `POST /knowledge/rebuild`。读路径只如实报告
|
|
3582
|
+
* 「盘上还没有编译产物」(`needsBuild`),要不要编由界面和 Agent 自己决定 ——
|
|
3583
|
+
* 与 `recall` 同一条规矩:先读盘,读不到再说。
|
|
3584
|
+
*/
|
|
3585
|
+
if (this.units.length === 0 && this.graph.nodes.length === 0) await this.loadCompiledState()
|
|
3586
|
+
const needsBuild = this.units.length === 0 && this.graph.nodes.length === 0
|
|
2988
3587
|
const attachLineage = (data: Record<string, unknown>): Record<string, unknown> => eventLineage ? { ...data, lineage: eventLineage } : data
|
|
3588
|
+
/** 空图 + 一句为什么空:调用方(尤其是 Agent)不能把「还没编译」读成「这个库没有知识」。 */
|
|
3589
|
+
const attachBuild = (data: Record<string, unknown>): Record<string, unknown> => needsBuild ? { ...data, needsBuild: true } : data
|
|
2989
3590
|
if (depth < 1 || depth > 3) return failure({}, 'DEPTH_INVALID', 'depth 必须在 1 到 3 之间', false)
|
|
2990
3591
|
const limit = options.limit ?? 50
|
|
2991
3592
|
if (!Number.isInteger(limit) || limit < 1 || limit > 50) return failure({}, 'LIMIT_INVALID', 'limit 必须在 1 到 50 之间', false)
|
|
@@ -2998,7 +3599,7 @@ export class KnowledgeRuntime {
|
|
|
2998
3599
|
.filter(node => !options.status || node.status === options.status)
|
|
2999
3600
|
const nodes = allNodes.filter(node => !entityType || node.type === entityType)
|
|
3000
3601
|
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)))
|
|
3001
|
-
if (action === 'list') return success(attachLineage({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) }))
|
|
3602
|
+
if (action === 'list') return success(attachLineage(attachBuild({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) })))
|
|
3002
3603
|
|
|
3003
3604
|
const nodeKey = (rawId: string | undefined, rawType?: string): string | undefined => {
|
|
3004
3605
|
if (!rawId) return undefined
|
|
@@ -3024,20 +3625,20 @@ export class KnowledgeRuntime {
|
|
|
3024
3625
|
})
|
|
3025
3626
|
for (const next of nextKeys) if (!visited.has(next)) { visited.add(next); queue.push({ key: next, path: [...current.path, next] }) }
|
|
3026
3627
|
}
|
|
3027
|
-
if (!found) return success(attachLineage({ path: [], nodes: [], edges: [] }))
|
|
3628
|
+
if (!found) return success(attachLineage(attachBuild({ path: [], nodes: [], edges: [] })))
|
|
3028
3629
|
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))) }))
|
|
3029
3630
|
}
|
|
3030
3631
|
|
|
3031
3632
|
const id = nodeKey(entityId, entityType)
|
|
3032
3633
|
if (!id) return failure({}, 'ENTITY_REQUIRED', '该 graph action 需要 entity_id', true)
|
|
3033
|
-
if (action === 'entity') return success(attachLineage({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) }))
|
|
3634
|
+
if (action === 'entity') return success(attachLineage(attachBuild({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) })))
|
|
3034
3635
|
if (action === 'neighbors') {
|
|
3035
3636
|
const ids = new Set([id])
|
|
3036
3637
|
for (let index = 0; index < depth; index++) for (const edge of edges) {
|
|
3037
3638
|
if (ids.has(String(edge.from))) ids.add(String(edge.to))
|
|
3038
3639
|
if (ids.has(String(edge.to))) ids.add(String(edge.from))
|
|
3039
3640
|
}
|
|
3040
|
-
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) }))
|
|
3641
|
+
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) })))
|
|
3041
3642
|
}
|
|
3042
3643
|
return failure({}, 'ACTION_INVALID', `不支持的 graph action: ${action}`, false)
|
|
3043
3644
|
}
|