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.
Files changed (117) hide show
  1. package/README.md +31 -7
  2. package/cordis.patch.yml +11 -5
  3. package/lib/client.js +5557 -3056
  4. package/lib/doc-page.js +7740 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +36 -9
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/diagnosis/cards.d.ts +8 -7
  9. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  10. package/lib/types/diagnosis/cards.js +119 -49
  11. package/lib/types/diagnosis/cards.js.map +1 -1
  12. package/lib/types/diagnosis/run.d.ts +51 -0
  13. package/lib/types/diagnosis/run.d.ts.map +1 -0
  14. package/lib/types/diagnosis/run.js +239 -0
  15. package/lib/types/diagnosis/run.js.map +1 -0
  16. package/lib/types/diagnosis/schema.d.ts +25 -2
  17. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  18. package/lib/types/diagnosis/schema.js +64 -25
  19. package/lib/types/diagnosis/schema.js.map +1 -1
  20. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  21. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  22. package/lib/types/diagnosis/seed-source.js +18 -1
  23. package/lib/types/diagnosis/seed-source.js.map +1 -1
  24. package/lib/types/diagnosis/seed.d.ts +32 -42
  25. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  26. package/lib/types/diagnosis/seed.js +187 -51
  27. package/lib/types/diagnosis/seed.js.map +1 -1
  28. package/lib/types/diagnosis/store.d.ts +114 -26
  29. package/lib/types/diagnosis/store.d.ts.map +1 -1
  30. package/lib/types/diagnosis/store.js +336 -167
  31. package/lib/types/diagnosis/store.js.map +1 -1
  32. package/lib/types/diagnosis/types.d.ts +168 -16
  33. package/lib/types/diagnosis/types.d.ts.map +1 -1
  34. package/lib/types/diagnosis/types.js +1 -1
  35. package/lib/types/diagnosis/types.js.map +1 -1
  36. package/lib/types/document-page.d.ts +39 -0
  37. package/lib/types/document-page.d.ts.map +1 -0
  38. package/lib/types/document-page.js +43 -0
  39. package/lib/types/document-page.js.map +1 -0
  40. package/lib/types/fs-atomic.d.ts +12 -0
  41. package/lib/types/fs-atomic.d.ts.map +1 -0
  42. package/lib/types/fs-atomic.js +42 -0
  43. package/lib/types/fs-atomic.js.map +1 -0
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +214 -24
  46. package/lib/types/index.js.map +1 -1
  47. package/lib/types/knowledge-bases.d.ts +10 -2
  48. package/lib/types/knowledge-bases.d.ts.map +1 -1
  49. package/lib/types/knowledge-bases.js +20 -18
  50. package/lib/types/knowledge-bases.js.map +1 -1
  51. package/lib/types/proposals.d.ts +166 -0
  52. package/lib/types/proposals.d.ts.map +1 -0
  53. package/lib/types/proposals.js +238 -0
  54. package/lib/types/proposals.js.map +1 -0
  55. package/lib/types/runtime.d.ts +199 -7
  56. package/lib/types/runtime.d.ts.map +1 -1
  57. package/lib/types/runtime.js +728 -145
  58. package/lib/types/runtime.js.map +1 -1
  59. package/lib/types/settings-schema.d.ts +2 -2
  60. package/lib/types/types.d.ts +61 -10
  61. package/lib/types/types.d.ts.map +1 -1
  62. package/lib/types/types.js +56 -8
  63. package/lib/types/types.js.map +1 -1
  64. package/lib/types/wiki-types.d.ts +8 -1
  65. package/lib/types/wiki-types.d.ts.map +1 -1
  66. package/lib/types/wiki-types.js +16 -25
  67. package/lib/types/wiki-types.js.map +1 -1
  68. package/package.json +4 -3
  69. package/preset/tita-data-agent/agent.cordis.yml +11 -5
  70. package/src/client/DirectoryPicker.tsx +12 -12
  71. package/src/client/InfoHint.tsx +21 -8
  72. package/src/client/IntentTab.tsx +20 -26
  73. package/src/client/KnowledgeBaseTab.tsx +190 -80
  74. package/src/client/KnowledgePanel.tsx +42 -9
  75. package/src/client/KnowledgeStepRail.tsx +3 -0
  76. package/src/client/NodeSettings.tsx +20 -22
  77. package/src/client/PreviewSections.tsx +87 -33
  78. package/src/client/ProposalReview.tsx +313 -0
  79. package/src/client/TypeDefinitionDrawer.tsx +72 -38
  80. package/src/client/WikiTypeEditor.tsx +193 -77
  81. package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
  82. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  83. package/src/client/arch/model.ts +164 -26
  84. package/src/client/architecture.css +29 -9
  85. package/src/client/architecture.tsx +54 -29
  86. package/src/client/attribution/AttributionGraph.tsx +481 -34
  87. package/src/client/attribution/AttributionTab.tsx +507 -106
  88. package/src/client/attribution/CaseView.tsx +9 -14
  89. package/src/client/attribution/GraphStage.tsx +25 -18
  90. package/src/client/attribution/RunView.tsx +27 -9
  91. package/src/client/attribution/StageChrome.tsx +296 -140
  92. package/src/client/attribution/graph-options.ts +611 -47
  93. package/src/client/attribution/model.ts +559 -30
  94. package/src/client/convert/ConvertPanel.tsx +6 -1
  95. package/src/client/doc-page.css +134 -0
  96. package/src/client/doc-page.tsx +263 -0
  97. package/src/client/panel-state.ts +105 -0
  98. package/src/client/request.ts +36 -0
  99. package/src/client/styles.css +317 -43
  100. package/src/client/useFitScale.ts +15 -2
  101. package/src/client/wiki-type-groups.ts +86 -0
  102. package/src/convert/host.ts +41 -7
  103. package/src/diagnosis/cards.ts +120 -50
  104. package/src/diagnosis/run.ts +245 -0
  105. package/src/diagnosis/schema.ts +67 -26
  106. package/src/diagnosis/seed-source.ts +28 -3
  107. package/src/diagnosis/seed.ts +187 -85
  108. package/src/diagnosis/store.ts +358 -174
  109. package/src/diagnosis/types.ts +181 -16
  110. package/src/document-page.ts +51 -0
  111. package/src/fs-atomic.ts +42 -0
  112. package/src/index.ts +209 -22
  113. package/src/knowledge-bases.ts +20 -18
  114. package/src/proposals.ts +337 -0
  115. package/src/runtime.ts +736 -135
  116. package/src/types.ts +113 -19
  117. 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
- // 05_diagnosis 是派生层(智能归因图谱):内容可由 02_wiki + 03_compiled 重建,
29
- // 和 03_compiled 同性质,所以给独立编号段而不是塞进源头目录。
32
+ // **知识树只有三段**(v1.1 起):`01_source_truth` / `02_wiki` / `03_compiled`。
30
33
  //
31
- // `00_schema` 在 0030 被撤掉了:它从建出来那天起就是一个**空目录**。分类注册表
32
- // 与 Wiki 类型注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是
33
- // 配置,跟着根路径走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。
34
- // 「磁盘上有一个文件夹」本身是一种承诺,承诺了却空着就是误导。
35
- const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled', '05_diagnosis'] as const
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
- cardsCreated: number
211
- cardsMerged: number
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.dropLegacySchemaSegment()
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
- private async dropLegacySchemaSegment(): Promise<void> {
989
- const path = this.safePath('00_schema')
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
- cardsCreated?: number; cardsMerged?: number
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
- /** Parse one on-disk SourceRaw file into the detail projection. */
1487
- private async readDocumentFile(path: string): Promise<KnowledgeDocumentDetail | undefined> {
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: parsed.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
- async readDocument(docId: string): Promise<KnowledgeDocumentDetail | undefined> {
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
- return path ? this.readDocumentFile(path) : undefined
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, cardsCreated: 0, cardsMerged: 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
- let cards = await this.loadCards()
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
- cards = await this.extractFromSource(source, cards, enabledTypes, outcome)
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
- private async extractFromSource(source: SourceRaw, cards: KnowledgeCard[], enabledTypes: WikiTypeDef[], outcome: ExtractionOutcome): Promise<KnowledgeCard[]> {
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
- // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
2121
- const aliases = [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))]
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
- ...(aliases.length > 0 ? { aliases } : {}),
2660
+ // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
2661
+ ...(aliasesOf(candidate).length > 0 ? { aliases: aliasesOf(candidate) } : {}),
2131
2662
  }
2132
- const written = await this.writeCard({
2133
- entityType: candidate.type,
2134
- entityId: candidateEntityId(candidate.name),
2135
- title: candidate.name,
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
- outcome.cardsCreated += 1
2147
- next.push({
2148
- entityId: written.data.entityId,
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 = next.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType)
2688
+ const card = cards.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType)
2163
2689
  if (!card) continue
2164
- // 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空写也会
2165
- // 刷新 updated_at,并让「并入了多少张卡」这个计数变成谎话。
2690
+ // 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空提一条提议
2691
+ // 也会让「并入了多少张卡」这个计数变成谎话。
2166
2692
  if (card.sourceRefs.includes(merge.sourceId)) continue
2167
- const id = `${merge.entityType}:${merge.entityId}`
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
- cardsCreated: extraction.cardsCreated,
2268
- cardsMerged: extraction.cardsMerged,
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
- const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0
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
- private graphBoostRanking(query: string, eligible: WikiNote[], includeProblems: boolean): RouteRanking[] {
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
- if (this.units.length === 0) await this.rebuild('all', eventLineage)
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
  }