dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (180) hide show
  1. package/README.md +147 -19
  2. package/cordis.patch.yml +25 -10
  3. package/lib/client.js +6400 -3326
  4. package/lib/doc-page.js +7761 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +63 -11
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/convert/ingest.d.ts +7 -4
  9. package/lib/types/convert/ingest.d.ts.map +1 -1
  10. package/lib/types/convert/ingest.js +52 -14
  11. package/lib/types/convert/ingest.js.map +1 -1
  12. package/lib/types/convert/types.d.ts +2 -2
  13. package/lib/types/convert/types.d.ts.map +1 -1
  14. package/lib/types/convert/types.js +2 -2
  15. package/lib/types/convert/types.js.map +1 -1
  16. package/lib/types/diagnosis/cards.d.ts +8 -7
  17. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  18. package/lib/types/diagnosis/cards.js +119 -49
  19. package/lib/types/diagnosis/cards.js.map +1 -1
  20. package/lib/types/diagnosis/run.d.ts +57 -0
  21. package/lib/types/diagnosis/run.d.ts.map +1 -0
  22. package/lib/types/diagnosis/run.js +245 -0
  23. package/lib/types/diagnosis/run.js.map +1 -0
  24. package/lib/types/diagnosis/schema.d.ts +25 -2
  25. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  26. package/lib/types/diagnosis/schema.js +64 -25
  27. package/lib/types/diagnosis/schema.js.map +1 -1
  28. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  29. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  30. package/lib/types/diagnosis/seed-source.js +19 -2
  31. package/lib/types/diagnosis/seed-source.js.map +1 -1
  32. package/lib/types/diagnosis/seed.d.ts +32 -42
  33. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  34. package/lib/types/diagnosis/seed.js +187 -51
  35. package/lib/types/diagnosis/seed.js.map +1 -1
  36. package/lib/types/diagnosis/store.d.ts +95 -25
  37. package/lib/types/diagnosis/store.d.ts.map +1 -1
  38. package/lib/types/diagnosis/store.js +297 -166
  39. package/lib/types/diagnosis/store.js.map +1 -1
  40. package/lib/types/diagnosis/types.d.ts +168 -16
  41. package/lib/types/diagnosis/types.d.ts.map +1 -1
  42. package/lib/types/diagnosis/types.js +1 -1
  43. package/lib/types/diagnosis/types.js.map +1 -1
  44. package/lib/types/document-page.d.ts +39 -0
  45. package/lib/types/document-page.d.ts.map +1 -0
  46. package/lib/types/document-page.js +43 -0
  47. package/lib/types/document-page.js.map +1 -0
  48. package/lib/types/extract-model.d.ts +67 -27
  49. package/lib/types/extract-model.d.ts.map +1 -1
  50. package/lib/types/extract-model.js +225 -97
  51. package/lib/types/extract-model.js.map +1 -1
  52. package/lib/types/extract.d.ts +16 -0
  53. package/lib/types/extract.d.ts.map +1 -1
  54. package/lib/types/extract.js +19 -2
  55. package/lib/types/extract.js.map +1 -1
  56. package/lib/types/fs-atomic.d.ts +11 -0
  57. package/lib/types/fs-atomic.d.ts.map +1 -0
  58. package/lib/types/fs-atomic.js +41 -0
  59. package/lib/types/fs-atomic.js.map +1 -0
  60. package/lib/types/index.d.ts.map +1 -1
  61. package/lib/types/index.js +466 -63
  62. package/lib/types/index.js.map +1 -1
  63. package/lib/types/intent/types.js +1 -1
  64. package/lib/types/intent/types.js.map +1 -1
  65. package/lib/types/knowledge-bases.d.ts +23 -12
  66. package/lib/types/knowledge-bases.d.ts.map +1 -1
  67. package/lib/types/knowledge-bases.js +37 -44
  68. package/lib/types/knowledge-bases.js.map +1 -1
  69. package/lib/types/layout.d.ts +3 -3
  70. package/lib/types/layout.d.ts.map +1 -1
  71. package/lib/types/layout.js +4 -4
  72. package/lib/types/layout.js.map +1 -1
  73. package/lib/types/migrate.d.ts +1 -1
  74. package/lib/types/migrate.js +2 -2
  75. package/lib/types/migrate.js.map +1 -1
  76. package/lib/types/model-text.d.ts +99 -0
  77. package/lib/types/model-text.d.ts.map +1 -0
  78. package/lib/types/model-text.js +103 -0
  79. package/lib/types/model-text.js.map +1 -0
  80. package/lib/types/present.d.ts +239 -0
  81. package/lib/types/present.d.ts.map +1 -0
  82. package/lib/types/present.js +255 -0
  83. package/lib/types/present.js.map +1 -0
  84. package/lib/types/recall-rank.d.ts +37 -0
  85. package/lib/types/recall-rank.d.ts.map +1 -1
  86. package/lib/types/recall-rank.js +41 -1
  87. package/lib/types/recall-rank.js.map +1 -1
  88. package/lib/types/runtime.d.ts +564 -31
  89. package/lib/types/runtime.d.ts.map +1 -1
  90. package/lib/types/runtime.js +2645 -292
  91. package/lib/types/runtime.js.map +1 -1
  92. package/lib/types/settings-schema.d.ts +4 -2
  93. package/lib/types/settings-schema.d.ts.map +1 -1
  94. package/lib/types/settings-schema.js +2 -0
  95. package/lib/types/settings-schema.js.map +1 -1
  96. package/lib/types/source-images.d.ts +118 -0
  97. package/lib/types/source-images.d.ts.map +1 -0
  98. package/lib/types/source-images.js +288 -0
  99. package/lib/types/source-images.js.map +1 -0
  100. package/lib/types/types.d.ts +356 -28
  101. package/lib/types/types.d.ts.map +1 -1
  102. package/lib/types/types.js +112 -18
  103. package/lib/types/types.js.map +1 -1
  104. package/lib/types/wiki-finalize.d.ts +140 -0
  105. package/lib/types/wiki-finalize.d.ts.map +1 -0
  106. package/lib/types/wiki-finalize.js +269 -0
  107. package/lib/types/wiki-finalize.js.map +1 -0
  108. package/lib/types/wiki-types.d.ts +10 -2
  109. package/lib/types/wiki-types.d.ts.map +1 -1
  110. package/lib/types/wiki-types.js +27 -30
  111. package/lib/types/wiki-types.js.map +1 -1
  112. package/package.json +4 -3
  113. package/preset/tita-data-agent/agent.cordis.yml +25 -10
  114. package/src/client/DirectoryPicker.tsx +12 -12
  115. package/src/client/DrawerSection.tsx +18 -0
  116. package/src/client/InfoHint.tsx +21 -8
  117. package/src/client/IntentTab.tsx +20 -26
  118. package/src/client/KnowledgeBaseTab.tsx +458 -158
  119. package/src/client/KnowledgePanel.tsx +44 -10
  120. package/src/client/KnowledgeStepRail.tsx +3 -0
  121. package/src/client/KnowledgeToolRow.tsx +331 -0
  122. package/src/client/NodeSettings.tsx +20 -22
  123. package/src/client/PreviewSections.tsx +178 -43
  124. package/src/client/RecallPanel.tsx +418 -127
  125. package/src/client/TypeDefinitionDrawer.tsx +154 -54
  126. package/src/client/WikiCardReview.tsx +266 -0
  127. package/src/client/WikiFlowCard.tsx +57 -95
  128. package/src/client/WikiTypeEditor.tsx +197 -95
  129. package/src/client/arch/AgentArchitectureGraph.tsx +30 -14
  130. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  131. package/src/client/arch/model.ts +164 -26
  132. package/src/client/architecture.css +25 -10
  133. package/src/client/architecture.tsx +66 -41
  134. package/src/client/asset-images.ts +125 -0
  135. package/src/client/attribution/AttributionGraph.tsx +481 -34
  136. package/src/client/attribution/AttributionTab.tsx +501 -106
  137. package/src/client/attribution/CaseView.tsx +9 -14
  138. package/src/client/attribution/GraphStage.tsx +25 -18
  139. package/src/client/attribution/RunView.tsx +27 -9
  140. package/src/client/attribution/StageChrome.tsx +280 -140
  141. package/src/client/attribution/graph-options.ts +611 -47
  142. package/src/client/attribution/model.ts +559 -30
  143. package/src/client/convert/ConvertPanel.tsx +36 -4
  144. package/src/client/doc-page.css +134 -0
  145. package/src/client/doc-page.tsx +242 -0
  146. package/src/client/index.ts +18 -0
  147. package/src/client/panel-state.ts +105 -0
  148. package/src/client/problem-domains.ts +1 -1
  149. package/src/client/request.ts +36 -0
  150. package/src/client/styles.css +397 -54
  151. package/src/client/useFitScale.ts +15 -2
  152. package/src/client/wiki-type-groups.ts +86 -0
  153. package/src/convert/host.ts +68 -9
  154. package/src/convert/ingest.ts +61 -18
  155. package/src/convert/types.ts +2 -2
  156. package/src/diagnosis/cards.ts +120 -50
  157. package/src/diagnosis/run.ts +251 -0
  158. package/src/diagnosis/schema.ts +67 -26
  159. package/src/diagnosis/seed-source.ts +29 -4
  160. package/src/diagnosis/seed.ts +187 -85
  161. package/src/diagnosis/store.ts +310 -173
  162. package/src/diagnosis/types.ts +181 -16
  163. package/src/document-page.ts +51 -0
  164. package/src/extract-model.ts +254 -99
  165. package/src/extract.ts +35 -3
  166. package/src/fs-atomic.ts +41 -0
  167. package/src/index.ts +499 -62
  168. package/src/intent/types.ts +1 -1
  169. package/src/knowledge-bases.ts +50 -49
  170. package/src/layout.ts +4 -4
  171. package/src/migrate.ts +2 -2
  172. package/src/model-text.ts +126 -0
  173. package/src/present.ts +367 -0
  174. package/src/recall-rank.ts +57 -2
  175. package/src/runtime.ts +2802 -293
  176. package/src/settings-schema.ts +2 -0
  177. package/src/source-images.ts +326 -0
  178. package/src/types.ts +446 -45
  179. package/src/wiki-finalize.ts +310 -0
  180. package/src/wiki-types.ts +28 -32
@@ -1,25 +1,44 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { appendFile, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { appendFile, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises';
5
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { tmpdir } from 'node:os';
6
7
  import { fileURLToPath } from 'node:url';
7
- import { buildSourceTypeCatalog, defaultSourceTypes, DEFAULT_KNOWLEDGE_CONFIG, deriveSourceExt, FALLBACK_EMBEDDING_MODEL, FALLBACK_RERANK_MODEL, legacyResourceHome, SOURCE_TYPE_ID_PATTERN, LOCAL_EMBEDDING_MODEL_DIR, LOCAL_RERANK_MODEL_DIR, } from './types.js';
8
+ import { buildSourceTypeCatalog, defaultSourceTypes, DEFAULT_KNOWLEDGE_CONFIG, deriveSourceExt, FALLBACK_EMBEDDING_MODEL, FALLBACK_RERANK_MODEL, legacyResourceHome, SOURCE_TYPE_ID_PATTERN, LOCAL_EMBEDDING_MODEL_DIR, LOCAL_RERANK_MODEL_DIR, ANOMALY_SCORE, CARD_REVIEW_BASE_SCORE, REVIEWED_SCORE_FLOOR, } from './types.js';
8
9
  import { defaultWikiTypes, validateCardAgainstType, WIKI_TYPE_RULES } from './wiki-types.js';
10
+ import { LEGACY_ENTITY_TYPES } from './diagnosis/schema.js';
11
+ import { DiagnosisStore } from './diagnosis/store.js';
9
12
  import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandidates, RELATES_TO } from './extract.js';
10
- import { extractWithModel } from './extract-model.js';
13
+ import { extractWithModel, skeletonBody } from './extract-model.js';
11
14
  import { FTS_FILE, FtsIndex, loadSqlite, segment } from './fts.js';
12
- import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, rrfFuse, selectContext, tokenSet } from './recall-rank.js';
15
+ import { insertImage, planSourceImages } from './source-images.js';
16
+ import { applyRelatedSection, buildWikiIndex, cardKey, deadLinkIssue, deadLinksOf, planRelated, stripRelatedSection, } from './wiki-finalize.js';
17
+ import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, reviewWeight, rrfFuse, tokenSet } from './recall-rank.js';
13
18
  import { legacyKnowledgeRoot } from './layout.js';
19
+ import { unlinkIfExists, writeBufferAtomic, writeTextAtomic } from './fs-atomic.js';
14
20
  import { envelopeFailure, envelopeSuccess } from 'dsh-data-contracts/envelope';
15
- // 05_diagnosis 是派生层(智能归因图谱):内容可由 02_wiki + 03_compiled 重建,
16
- // 和 03_compiled 同性质,所以给独立编号段而不是塞进源头目录。
21
+ // **知识树只有三段**(v1.1 起):`01_source_raw` / `02_wiki` / `03_compiled`。
17
22
  //
18
- // `00_schema` 在 0030 被撤掉了:它从建出来那天起就是一个**空目录**。分类注册表
19
- // 与 Wiki 类型注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是
20
- // 配置,跟着根路径走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。
21
- // 「磁盘上有一个文件夹」本身是一种承诺,承诺了却空着就是误导。
22
- const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled', '05_diagnosis'];
23
+ // 撤掉的两段,理由都写在这里,避免下一个人重新加回来:
24
+ //
25
+ // - `00_schema`(0030 撤):它从建出来那天起就是一个**空目录**。分类注册表与 Wiki 类型
26
+ // 注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是**配置**,跟着根路径
27
+ // 走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。「磁盘上有一个文件夹」
28
+ // 本身是一种承诺,承诺了却空着就是误导。**一句话:schema 在设置里编辑,不在磁盘上。**
29
+ // - `05_diagnosis`(v1.1 撤):它同时装着两类东西 —— 「一次排查的产物」(源头)与
30
+ // 「五类模板卡」(配置)—— 一个编号段混装两种性质,`rebuild --only` 的语义立刻含糊。
31
+ // 现在:一次排查的产物是一条 `agent_diagnosis_case` 的 SourceRaw(落 `01_source_raw/`),
32
+ // 模板卡由类型定义派生(不落盘)。旧目录里的 `*.jsonl` 降为**只读的迁移输入与历史快照**。
33
+ const LAYOUT = ['01_source_raw', '02_wiki', '03_compiled'];
34
+ /**
35
+ * 卡片正文里那个机器可读块的围栏标记。
36
+ *
37
+ * 与 `diagnosis/cards.ts` 的 `BLOCK_FENCE` 是同一个字符串,但这里不能 import 它 ——
38
+ * 迁移要在**不解析卡片**的前提下改块里的一个字段名(旧块的 `failure_mode`),
39
+ * 而 `cards.ts` 的 `parseCard` 已经把旧字段名归一化了,读回来的对象里根本没有它。
40
+ */
41
+ const DIAGNOSIS_BLOCK_FENCE = 'json diagnosis';
23
42
  /**
24
43
  * SourceRaw 段。段内**按来源分类再分一层**:`<段>/<source_type>/<docSlug>.md`。
25
44
  *
@@ -30,10 +49,27 @@ const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled', '05_diagnosis'];
30
49
  * 读路径不依赖这个层级:`sourceIndex()` 递归扫全段建 slug → path 索引,所以旧
31
50
  * 平铺布局与迁移中间态都照常可读(见 `migrateSourceTruthLayout`)。
32
51
  */
33
- const SOURCE_TRUTH = '01_source_truth';
52
+ const SOURCE_TRUTH = '01_source_raw';
53
+ /**
54
+ * 0051 之前的段名。**只留这一处**,两个用途:
55
+ * 1. **兼容解析**:存量正文里的图片引用写的是 `../../01_source_truth/_assets/…`
56
+ * (0047 的三种历史写法之一),改段名不改文件内容,所以解析器要同时认两个名字;
57
+ * 2. **迁移**:把整棵树搬过来(`migrateSourceTree`)。
58
+ *
59
+ * 卡片正文里的老引用**不改写**:改正文会造内容版本、让卡片重编重编码,而代价只是为了
60
+ * 让一个字符串好看一点。解析器认两个名字,读得到就行。
61
+ */
62
+ const LEGACY_SOURCE_TRUTH = '01_source_truth';
34
63
  /** Wiki 段;段内按 `entity_type` 分目录(与 SourceRaw 的分类分层同一个思路)。 */
35
64
  const WIKI = '02_wiki';
36
65
  const STATUS = ['unreviewed', 'draft', 'reviewed', 'active', 'deprecated', 'problem'];
66
+ /**
67
+ * 一次 `import_sources` 能带多少份来源(0045)。
68
+ *
69
+ * 这不是产品配额,是**一次调用能被按住的时长**:编译逐份来源串行抽实体,50 份已经要跑
70
+ * 很久。超了就分批,每批各自编译 —— 而不是让一次调用变成没人知道什么时候结束的东西。
71
+ */
72
+ export const MAX_IMPORT_SOURCES = 50;
37
73
  const SAFE_ID = /^[a-zA-Z0-9._:-]{1,160}$/;
38
74
  const VECTOR_MANIFEST = 'vector-manifest.json';
39
75
  const VECTOR_BINARY = 'vectors.binary';
@@ -55,6 +91,16 @@ const VECTOR_QUANTIZATION = 'binary+hamming+int8';
55
91
  const ENCODER_FINGERPRINT = 'wemm-official-processor-v1';
56
92
  /** Stage 2 output consumed by stage 3 recall. */
57
93
  const WIKI_NOTES = 'wiki_notes.json';
94
+ /**
95
+ * 归因图谱的编译产物(0021 §14.3)。
96
+ *
97
+ * 在这之前 `cardsToGraph()` 是**读的时候现算**的,归因图不落 `03_compiled` —— 于是
98
+ * `build-manifest.json` 不知道它存在(回答不了「这张图是哪个版本编出来的」),
99
+ * 而图召回也读不到它。现在它和 `knowledge_graph.json` 一起由同一次 `rebuild` 产出,
100
+ * **但保留两种粒度**:卡片级回答「知识长什么样」,字段级回答「这条路怎么走」。
101
+ * 合成一张会让图谱 Tab 看到一张卡片散成七个点(§14.4 的三条理由)。
102
+ */
103
+ const DIAGNOSIS_GRAPH = 'diagnosis_graph.json';
58
104
  /** Pre-0014 artifact name; kept readable so an upgrade does not force a rebuild. */
59
105
  const LEGACY_UNITS = 'knowledge_units.json';
60
106
  /**
@@ -89,6 +135,14 @@ const CARD_VERSIONS = 'card-versions';
89
135
  * 只会让下一次重建把关系重新抽一遍。
90
136
  */
91
137
  const EXTRACTED_RELATIONS = 'extracted_relations.json';
138
+ /**
139
+ * 索引页(0049):**不打开界面也能知道这个库里有什么**。
140
+ *
141
+ * 落在 `03_compiled/` 而不是 `02_wiki/`:后者的每一份 `.md` 都会被 `loadCards`
142
+ * 当成一张卡片读进卡片清单,索引页放进去就会变成一张标题叫「知识库索引」的卡片,
143
+ * 而且每次编译都变一次。它是派生产物,所以归派生目录。
144
+ */
145
+ const WIKI_INDEX = 'wiki-index.md';
92
146
  /** Sections shorter than this stay in a single note; longer cards split by heading. */
93
147
  const NOTE_SPLIT_MIN_CHARS = 800;
94
148
  /**
@@ -101,6 +155,20 @@ const NOTE_SPLIT_MIN_CHARS = 800;
101
155
  const RELATION_QUERY_WORDS = {
102
156
  supported_by: 'supported by source 来源 出处 依据 支撑',
103
157
  [RELATES_TO]: 'relates to related 相关 关联 关系 有关',
158
+ // 归因图谱的十种边(0021 §4.2)。它们住在 `diagnosis_graph.json` 里,
159
+ // 但**按关系词命中**这条读法是同一套:中文提问不会写 `may_be_caused_by`。
160
+ // 少了这十行,图召回对 `refutes` / `verified_by` 这些语义完全不可见 ——
161
+ // 而「上次这条假设是被什么否定的」正是最该被召回到的东西。
162
+ may_be_caused_by: 'may be caused by 可能是原因 可能原因 候选原因 怀疑',
163
+ verified_by: 'verified by 由…验证 怎么验证 用哪个检查 检查点',
164
+ categorized_as: 'categorized as 归类为 属于哪一类 失败原因 切面',
165
+ queries: 'queries 查询依据 要去查 查哪些表 影响面',
166
+ produces: 'produces 产出 产出了什么 跑出了什么',
167
+ supports: 'supports 支持 印证 算数',
168
+ refutes: 'refutes 否定 排除 不成立 反例',
169
+ confirmed_as: 'confirmed as 确认为根因 确认根因 定位到',
170
+ instance_of: 'instance of 实例属于 哪次排查 案例',
171
+ indexed_from: 'indexed from 反查索引 落在哪个对象上 相关资产',
104
172
  };
105
173
  /**
106
174
  * 融合与阈值的常数(0031)。
@@ -133,14 +201,32 @@ const ENCODER_SELF_SIMILARITY_MAX = 0.99;
133
201
  const MIN_QUERY_COVERAGE = 0.25;
134
202
  /** 每条命中最多带几段证据原文。 */
135
203
  const EVIDENCE_PER_HIT = 3;
136
- /** 同一张 wiki 最多几条进上下文:否则 5 条上下文可能全是同一张卡的不同小节。 */
137
- const CONTEXT_PER_WIKI_CAP = 2;
204
+ /*
205
+ * 0050 删掉了 `CONTEXT_PER_WIKI_CAP`(同一张 wiki 最多几条进上下文)。
206
+ * 它是「按 note 组装上下文」时代的补丁:那时同一张卡的多个小节能占满名额。
207
+ * 现在上下文**按卡片去重**,一张卡只有一个条目 —— 上限这个概念不再需要,
208
+ * 取而代之的是卡片级的 1 跳扩展上限(`MAX_EXPANSION_CARDS`)。
209
+ */
210
+ /**
211
+ * 1 跳扩展最多拉几张卡片(0050)。
212
+ *
213
+ * 上限是**有意的**:一张卡片在图上的邻居可以很多(一份来源编出的卡片彼此都是邻居),
214
+ * 不设限时「1 跳」会把整个连通分量拖进上下文。宁可少拉,也不要让扩展压过命中。
215
+ */
216
+ const MAX_EXPANSION_CARDS = 20;
217
+ /** 溢出文件的保留时长:超过它的旧文件在下次召回时清掉(0050)。 */
218
+ const RECALL_OVERFLOW_TTL_MS = 24 * 60 * 60 * 1000;
138
219
  const BIT_COUNTS = Array.from({ length: 256 }, (_, value) => {
139
220
  let count = 0;
140
221
  for (let bit = value; bit !== 0; bit &= bit - 1)
141
222
  count += 1;
142
223
  return count;
143
224
  });
225
+ /** 卡片上的待补项(`metadata.schema_issues`),形状不对时当空。 */
226
+ function cardIssuesOf(card) {
227
+ const raw = card.metadata.schema_issues;
228
+ return Array.isArray(raw) ? raw : [];
229
+ }
144
230
  /**
145
231
  * Both helpers delegate to the shared envelope constructors: the five-key shape
146
232
  * and the "warnings ⇒ partial" rule live in `dsh-data-contracts`,
@@ -159,15 +245,187 @@ function slug(value) {
159
245
  function hash(value) { return createHash('sha256').update(value).digest('hex').slice(0, 24); }
160
246
  /** The content hash used for `KnowledgeDocument.contentHash` and version ids. */
161
247
  function contentHashOf(content) { return hash(content); }
248
+ /** 候选的别名集合:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。 */
249
+ function aliasesOf(candidate) {
250
+ return [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))];
251
+ }
252
+ /** 累积一次「并入已有卡片」。同一条 pending 被追加两次时 `appendExtracted` 自带判重。 */
253
+ function mergeIntoPending(pending, card, sourceId, bodies, keys, at,
162
254
  /**
163
- * 把这次新抽到的正文**追加**进已有卡片的正文。
255
+ * 这次新抽到的**字段值**(类型定义里的 `formula` / `owner` 等)与摘要。
164
256
  *
165
- * 这是「wiki 编译第二条链路」的落点:实体已经在 wiki 里 → 在原卡片上追加;不在 →
166
- * 新建一张。追加而不是覆盖,是因为覆盖会把别的来源贡献的内容删掉;而**丢弃**
167
- * (第一版的行为)更糟 —— 来源改写了,卡片却永远停在第一次抽取的样子。
257
+ * 它们必须跟着走完整条路径。0044 的实现里漏过一次:模型在第二份文档里给出了更完整的
258
+ * `formula`,卡片只追加了正文,字段值停在第一份文档的版本上 —— 而正文里写着新的公式。
259
+ * 这种「正文与字段互相矛盾」比缺字段更难发现。
260
+ */
261
+ fields, summary) {
262
+ const key = `${card.entityType}:${card.entityId}`;
263
+ const previous = pending.get(key);
264
+ // 字段值真的变了才算「改到」:同一段公式被两份文档重复写下是很常见的,
265
+ // 那种情况下这一轮没有任何新东西,不该把卡片重写一遍。
266
+ const changed = Object.entries(fields ?? {}).some(([name, value]) => String(card.metadata[name] ?? '') !== value)
267
+ || Boolean(summary && summary !== (card.summary ?? ''));
268
+ if (previous) {
269
+ previous.content = appendExtracted(previous.content, bodies);
270
+ if (!previous.sourceRefs.includes(sourceId))
271
+ previous.sourceRefs.push(sourceId);
272
+ previous.metadata = { ...previous.metadata, ...(fields ?? {}) };
273
+ if (!previous.summary && summary)
274
+ previous.summary = summary;
275
+ if (changed)
276
+ previous.fieldChanged = true;
277
+ previous.merges.push({ sourceId, keys, at });
278
+ return;
279
+ }
280
+ pending.set(key, {
281
+ action: 'update',
282
+ entityType: card.entityType,
283
+ entityId: card.entityId,
284
+ title: card.title,
285
+ // **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
286
+ // 于是知识是累积的,来源改写了也不会停在旧版本上。
287
+ content: appendExtracted(card.content, bodies),
288
+ sourceRefs: card.sourceRefs.includes(sourceId) ? [...card.sourceRefs] : [...card.sourceRefs, sourceId],
289
+ metadata: { ...card.metadata, ...(fields ?? {}) },
290
+ ...(summary ?? card.summary ? { summary: summary ?? card.summary } : {}),
291
+ existing: card,
292
+ merges: [{ sourceId, keys, at }],
293
+ ...(changed ? { fieldChanged: true } : {}),
294
+ });
295
+ }
296
+ /** 来源的判重留痕:只留最近 20 条,它是「这张卡被哪些来源命中过」的痕迹,不是审计日志。 */
297
+ function mergeHistoryOf(metadata, additions) {
298
+ const history = Array.isArray(metadata.extraction_merges) ? metadata.extraction_merges : [];
299
+ return [...history, ...additions].slice(-20);
300
+ }
301
+ /**
302
+ * 从 frontmatter 里读人工备注。
168
303
  *
169
- * 已经在正文里出现过的那一段不再追加:同一份来源重建、或同一段被两个候选命中时,
170
- * 卡片不该越编译越长。判重按整段文本,所以它是幂等的。
304
+ * 形状不对的一条**丢掉,而不是让整张卡片读不出来**:备注是附加信息,为了一条写坏的
305
+ * 备注把一张知识卡片变成「读不到」,代价方向错了。
306
+ */
307
+ function parseAnnotations(value) {
308
+ if (!Array.isArray(value))
309
+ return [];
310
+ const result = [];
311
+ for (const item of value) {
312
+ if (item === null || typeof item !== 'object')
313
+ continue;
314
+ const record = item;
315
+ const note = typeof record.note === 'string' ? record.note.trim() : '';
316
+ if (!note)
317
+ continue;
318
+ result.push({
319
+ id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `a-${hash(note).slice(0, 12)}`,
320
+ kind: record.kind === 'anomaly' ? 'anomaly' : 'note',
321
+ note,
322
+ ...(typeof record.author === 'string' && record.author.trim() ? { author: record.author.trim() } : {}),
323
+ createdAt: typeof record.createdAt === 'string' && record.createdAt.trim() ? record.createdAt.trim() : '',
324
+ ...(typeof record.sourceRef === 'string' && record.sourceRef.trim() ? { sourceRef: record.sourceRef.trim() } : {}),
325
+ });
326
+ }
327
+ return result;
328
+ }
329
+ /** 从 frontmatter 里读 review 结论。缺省 = 还没人看过(调用方按 `CARD_REVIEW_BASE_SCORE` 处理)。 */
330
+ function parseReview(value) {
331
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
332
+ return undefined;
333
+ const record = value;
334
+ const score = Number(record.score);
335
+ const state = record.state === 'reviewed' ? 'reviewed' : 'unreviewed';
336
+ return {
337
+ state,
338
+ score: Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : state === 'reviewed' ? REVIEWED_SCORE_FLOOR : CARD_REVIEW_BASE_SCORE,
339
+ ...(typeof record.reviewedAt === 'string' && record.reviewedAt.trim() ? { reviewedAt: record.reviewedAt.trim() } : {}),
340
+ ...(typeof record.reviewedBy === 'string' && record.reviewedBy.trim() ? { reviewedBy: record.reviewedBy.trim() } : {}),
341
+ };
342
+ }
343
+ /**
344
+ * 一张卡片的**有效评分**(0044):有异常点备注时封顶。
345
+ *
346
+ * 为什么不把「异常」直接写进 `review.score`:异常是**关于证据的判断**(这段内容和原始
347
+ * 文档对不上),review 是**关于覆盖度的判断**(这张卡该有的小节都有了)。两者会同时成立 ——
348
+ * 一张 review 通过的卡片里照样可能被标出一个数字对不上。分成两个字段之后,
349
+ * 撤掉异常备注,review 的结论原样回来;写成一个数就再也分不开了。
350
+ */
351
+ function effectiveReviewScore(card) {
352
+ const base = card.review?.score ?? CARD_REVIEW_BASE_SCORE;
353
+ const hasAnomaly = (card.annotations ?? []).some(item => item.kind === 'anomaly');
354
+ return hasAnomaly ? Math.min(base, ANOMALY_SCORE) : base;
355
+ }
356
+ function annotationId(note, at) {
357
+ return `a-${hash(`${at}|${note}`).slice(0, 16)}`;
358
+ }
359
+ /**
360
+ * 按 `## 小节` 切开正文。没有任何标题时返回 `null`,调用方据此退回「整段拼接」。
361
+ */
362
+ function splitSections(markdown) {
363
+ const sections = [];
364
+ let current;
365
+ let seenHeading = false;
366
+ for (const line of markdown.split('\n')) {
367
+ const match = /^##\s+(.+?)\s*$/.exec(line);
368
+ if (match) {
369
+ seenHeading = true;
370
+ current = { heading: line.trim(), label: match[1].trim(), lines: [] };
371
+ sections.push(current);
372
+ continue;
373
+ }
374
+ if (!current) {
375
+ // 第一个标题出现之前的行(含前导空行)归入前言。
376
+ current = { heading: '', label: '', lines: [] };
377
+ sections.push(current);
378
+ }
379
+ current.lines.push(line);
380
+ }
381
+ if (!seenHeading)
382
+ return null;
383
+ return sections.map(section => ({ heading: section.heading, label: section.label, body: section.lines.join('\n').trim() }));
384
+ }
385
+ /**
386
+ * 一整节只是**一句括号里的占位**吗(`(文档未说明)` / `(本文档未说明适用场景。)`)?
387
+ *
388
+ * 占位是编译器的约定(`skeletonBody` 写「(文档未说明)」),模型会照着模仿。
389
+ * 判断成占位之后,后来的真实内容**替换**它而不是追加在它后面 —— 否则卡片会长成
390
+ * 「(文档未说明)」+ 具体内容,读起来像两句话,而第一句已经过期了。
391
+ */
392
+ function isPlaceholderSection(text) {
393
+ const trimmed = text.trim();
394
+ return /^[((][^(())]*[))]$/.test(trimmed);
395
+ }
396
+ /** 把一节的新内容并进旧内容:占位替换、重复跳过、其余追加。 */
397
+ function mergeSectionText(current, addition) {
398
+ const next = addition.trim();
399
+ if (!next)
400
+ return current.trim();
401
+ const previous = current.trim();
402
+ if (!previous)
403
+ return next;
404
+ if (previous.includes(next))
405
+ return previous;
406
+ if (isPlaceholderSection(previous))
407
+ return next;
408
+ return `${previous}\n\n${next}`;
409
+ }
410
+ /**
411
+ * 把一次新抽到的正文并进已有正文(0044)。
412
+ *
413
+ * ## 为什么不是简单的字符串拼接
414
+ *
415
+ * 实机验证抓到的形状:同一实体被第二份文档命中时,卡片变成
416
+ * `## 适用场景 / ## 步骤 / ## 验收与失败处理 / ## 适用场景 / ## 步骤 / …` ——
417
+ * 每一节都出现两次。正文是**按类型 schema 分节**的,重复标题意味着这张卡片不再
418
+ * 符合它的类型,读的人还得自己把两段同一节的内容在脑子里合起来。
419
+ *
420
+ * 所以按小节折进去:**每个标签只出现一次**,新内容并进同一节里;旧内容还是占位
421
+ * (「(文档未说明)」)时被新内容替换。两份正文里只要有一份没有 `##` 结构
422
+ * (规则路径切出来的原文就是这样),就退回整段拼接 —— 那种情况下没有可靠的
423
+ * 对齐依据,硬折会把内容塞错节。
424
+ *
425
+ * ## 为什么改在这里
426
+ *
427
+ * 所有「并入已有卡片」的路径都走这个函数(编译、`writeCards`、诊断卡片),
428
+ * 在上层各自折一次,必然有一处漏掉。
171
429
  */
172
430
  function appendExtracted(existing, additions) {
173
431
  let content = existing;
@@ -175,10 +433,38 @@ function appendExtracted(existing, additions) {
175
433
  const body = addition.trim();
176
434
  if (!body || content.includes(body))
177
435
  continue;
178
- content = content ? `${content}\n\n${body}` : body;
436
+ content = foldBody(content.trim(), body);
179
437
  }
180
438
  return content;
181
439
  }
440
+ /** 把 `incoming` 按小节折进 `existing`;结构对不上时整段追加。 */
441
+ function foldBody(existing, incoming) {
442
+ if (!existing)
443
+ return incoming;
444
+ const base = splitSections(existing);
445
+ const extra = splitSections(incoming);
446
+ if (!base || !extra)
447
+ return `${existing}\n\n${incoming}`;
448
+ const byLabel = new Map(base.map(section => [section.label, section]));
449
+ const appended = [];
450
+ for (const section of extra) {
451
+ const target = byLabel.get(section.label);
452
+ if (target) {
453
+ target.body = mergeSectionText(target.body, section.body);
454
+ continue;
455
+ }
456
+ // 新标签:整节接在最后,标题逐字保留。
457
+ const fresh = { heading: section.heading, label: section.label, body: section.body };
458
+ byLabel.set(section.label, fresh);
459
+ appended.push(fresh);
460
+ }
461
+ const ordered = [...base, ...appended];
462
+ const rendered = ordered
463
+ .map(section => (section.heading ? `${section.heading}\n\n${section.body}`.trimEnd() : section.body))
464
+ .filter(part => part.trim().length > 0)
465
+ .join('\n\n');
466
+ return rendered;
467
+ }
182
468
  /**
183
469
  * `2026-01-02T03:04:05.123Z` → `20260102T030405123Z`. Millisecond precision keeps
184
470
  * two overwrites inside the same second from colliding on the same version id.
@@ -202,25 +488,55 @@ function plainRecord(value) {
202
488
  * `metadata.source_path`; otherwise the upstream `source_format` label
203
489
  * (`markdown` / `office` / …); otherwise `markdown`, the store's default shape.
204
490
  */
491
+ /**
492
+ * 这份来源**是什么格式**(`web_page` / `office` / `pdf` / `markdown`…)。
493
+ *
494
+ * `source_format` 是转换内核报出来的**格式类**,有就以它为准 —— 按扩展名去猜会把
495
+ * 「网页」显示成「html」(0048 之前就是这样:`类型: .html · html`,而 manifest 里
496
+ * 明明写着 `web_page`)。没有 `source_format` 的手写来源才退回扩展名那条链。
497
+ */
205
498
  function sourceFormatOf(metadata) {
206
- const sourcePath = typeof metadata.source_path === 'string' ? metadata.source_path.trim() : '';
207
- if (sourcePath) {
208
- const base = sourcePath.split(/[\\/]/).pop() ?? '';
499
+ const format = typeof metadata.source_format === 'string' ? metadata.source_format.trim().toLowerCase() : '';
500
+ if (format)
501
+ return format;
502
+ const ext = sourceExtOf(metadata);
503
+ return ext ? ext.replace(/^\./, '') : 'markdown';
504
+ }
505
+ /** 底稿文件名 → 扩展名。`original.rel` 是 0048 起的持久位置,`source_path` 是旧形状。 */
506
+ function sourceExtOf(metadata) {
507
+ const original = plainRecord(metadata.original);
508
+ const candidates = [original.rel, metadata.source_path];
509
+ for (const value of candidates) {
510
+ if (typeof value !== 'string' || !value.trim())
511
+ continue;
512
+ const base = value.trim().split(/[\\/]/).pop() ?? '';
209
513
  const dot = base.lastIndexOf('.');
210
514
  if (dot > 0 && dot < base.length - 1)
211
- return base.slice(dot + 1).toLowerCase();
515
+ return base.slice(dot).toLowerCase();
212
516
  }
213
- const format = typeof metadata.source_format === 'string' ? metadata.source_format.trim().toLowerCase() : '';
214
- return format || 'markdown';
517
+ return '';
215
518
  }
216
519
  /** Failure payload for a document lookup that could not produce a detail. */
217
520
  function emptyDocumentDetail(docId) {
218
521
  return {
219
522
  docId, title: docId, sourceType: '', sourceTypeKnown: false, sourceFormat: '', parser: '', externalId: '', contentHash: '',
220
523
  bytes: 0, assetCount: 0, createdAt: '', updatedAt: '', versionCount: 0,
221
- relativePath: '', ext: '', content: '', frontmatter: {}, assets: [],
524
+ relativePath: '', ext: '', content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [],
222
525
  };
223
526
  }
527
+ /**
528
+ * 抽屉里的原文预览**只发**正文的前这么多字符。
529
+ *
530
+ * 依据是实跑反馈:一份几十万字的转换文档,抽屉只渲染前 4000 字符(更长的在
531
+ * 380px 宽的抽屉里没有阅读意义),而接口原来把**整份正文**发过去 —— 传输、JSON 解析、
532
+ * 塞进 React state 全按全文的量级付费,于是「打开弹层要等半天」。上面那段封顶之后,
533
+ * 弹层的耗时与文档大小**脱钩**;要读全文走独立整页(分页取,见 `documentContentPage`)。
534
+ *
535
+ * 4000 是用户明确要的阈值,所以它是一个具名常量而不是散在各处的字面量。
536
+ */
537
+ export const SOURCE_PREVIEW_CHARS = 4000;
538
+ /** 耗时统一保留一位小数:界面上不需要微秒,而 0.0 与 0 的读法不一样。 */
539
+ function round1(value) { return Math.round(value * 10) / 10; }
224
540
  function generatedId(prefix) { return `${prefix}_${hash(`${Date.now()}:${Math.random()}`)}`; }
225
541
  function parseLineage(value) {
226
542
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -285,41 +601,21 @@ export function parseMarkdown(source) {
285
601
  }
286
602
  return { frontmatter, content: source.slice(end + 4).trim() };
287
603
  }
604
+ /**
605
+ * 把旧卡片结构化块里的 `failure_mode` 改成 `failure_reason`(0040 的第二次改名)。
606
+ *
607
+ * 只动那个围栏块里的键名,不动正文 —— 正文里出现「failure_mode」这个词完全可能是
608
+ * 有意写的(比如一张讲 FMEA 的卡片)。做字符串级替换而不是解析再序列化,是因为
609
+ * 解析会**丢掉格式**(缩进、键序),而迁移的第一条纪律是不制造无谓的 diff。
610
+ */
611
+ function rewriteLegacyBlockFields(content) {
612
+ const pattern = new RegExp('(```' + DIAGNOSIS_BLOCK_FENCE + '\\s*\\n)([\\s\\S]*?)(```)');
613
+ return content.replace(pattern, (_all, head, body, tail) => head + body.replace(/"failure_mode"(\s*:)/g, '"failure_reason"$1') + tail);
614
+ }
288
615
  function markdown(frontmatter, content) {
289
616
  const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`);
290
617
  return `---\n${lines.join('\n')}\n---\n\n${content.trim()}\n`;
291
618
  }
292
- async function writeTextAtomic(path, content) {
293
- // 原子写需要一个与目标同目录的临时文件,所以目标目录必须先存在。0030 起
294
- // SourceRaw 落在 `<段>/<source_type>/` 下,而 `ensureLayout` 只建段目录 —— 少了
295
- // 这一行,第一次写某一种分类就会 ENOENT(实测)。
296
- await mkdir(dirname(path), { recursive: true });
297
- const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
298
- try {
299
- await writeFile(temporary, content, 'utf8');
300
- await rename(temporary, path);
301
- }
302
- finally {
303
- await unlinkIfExists(temporary);
304
- }
305
- }
306
- async function writeBufferAtomic(path, content) {
307
- const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
308
- try {
309
- await writeFile(temporary, content);
310
- await rename(temporary, path);
311
- }
312
- finally {
313
- await unlinkIfExists(temporary);
314
- }
315
- }
316
- async function unlinkIfExists(path) {
317
- try {
318
- const { unlink } = await import('node:fs/promises');
319
- await unlink(path);
320
- }
321
- catch { /* rename already consumed the temp file */ }
322
- }
323
619
  async function filesUnder(root) {
324
620
  try {
325
621
  const entries = await readdir(root, { withFileTypes: true });
@@ -390,8 +686,211 @@ function markdownImageRefs(content) {
390
686
  }
391
687
  return [...new Set(refs)];
392
688
  }
393
- function markdownImagePaths(content, baseDir) {
394
- return markdownImageRefs(content).map(value => isAbsolute(value) ? resolve(value) : resolve(baseDir, value));
689
+ /**
690
+ * 一份 SourceRaw 的 front matter(0048)。
691
+ *
692
+ * 键分成两类,**顺序与来源**都是刻意的:
693
+ *
694
+ * 1. **OKF v0.2 的顶层键**:`type` / `title` / `description` / `resource` /
695
+ * `tags` / `generated` / `sources`。它们是真 YAML 键,人能读、`grep` 得到、
696
+ * 外部工具能按公开规范读走。`generated.at` 就是**采集时间**(OKF 里没有「抓取
697
+ * 时间」这个概念,本文件何时被谁写出来就是它)。
698
+ * 2. **我们自己的运行键**:`source_id` / `external_id` / `created_at` / `updated_at` /
699
+ * `metadata` / `lineage`。OKF 明确允许自定义键,且要求消费者不得因为未知键拒收,
700
+ * 所以这两类可以同处一个 front matter。
701
+ *
702
+ * `metadata` 只剩**类型专属的扩展键**(转换任务 id、内容哈希、资产清单、格式…):
703
+ * 那些东西 OKF 没有对应概念,但也不该混进上面那组公开字段里。
704
+ *
705
+ * `type` 是 OKF 唯一必填键,值就是我们自己的来源分类(它本来就是「这个概念是什么」)。
706
+ * 旧文件里的 `source_type` 仍然读得到(见 `sourceTypeOf`),迁移只写新键。
707
+ */
708
+ function sourceFrontMatter(record, provenance) {
709
+ const frontMatter = {
710
+ type: record.sourceType,
711
+ title: record.title,
712
+ };
713
+ if (provenance?.description?.trim())
714
+ frontMatter.description = provenance.description.trim();
715
+ if (provenance?.resource?.trim())
716
+ frontMatter.resource = provenance.resource.trim();
717
+ if (provenance?.tags && provenance.tags.length > 0)
718
+ frontMatter.tags = provenance.tags;
719
+ const generated = {
720
+ ...(provenance?.generatedBy?.trim() ? { by: provenance.generatedBy.trim() } : {}),
721
+ ...(provenance?.generatedAt?.trim() ? { at: provenance.generatedAt.trim() } : {}),
722
+ };
723
+ if (Object.keys(generated).length > 0)
724
+ frontMatter.generated = generated;
725
+ const sources = (provenance?.sources ?? []).filter(origin => origin.resource.trim());
726
+ if (sources.length > 0) {
727
+ frontMatter.sources = sources.map((origin) => ({
728
+ id: origin.id,
729
+ resource: origin.resource,
730
+ ...(origin.title?.trim() ? { title: origin.title.trim() } : {}),
731
+ ...(origin.author?.trim() ? { author: origin.author.trim() } : {}),
732
+ ...(origin.lastModified?.trim() ? { last_modified: origin.lastModified.trim() } : {}),
733
+ }));
734
+ }
735
+ frontMatter.source_id = record.sourceId;
736
+ frontMatter.external_id = record.externalId ?? '';
737
+ frontMatter.created_at = record.createdAt;
738
+ frontMatter.updated_at = record.updatedAt;
739
+ frontMatter.metadata = record.metadata;
740
+ if (record.lineage)
741
+ frontMatter.lineage = record.lineage;
742
+ return frontMatter;
743
+ }
744
+ /** 把 `sources[0].author` 拆成 `['sources', 0, 'author']`。 */
745
+ function metadataPath(key) {
746
+ const tokens = [];
747
+ for (const part of key.split('.')) {
748
+ const name = part.replace(/\[\d+\]/g, '').trim();
749
+ if (name)
750
+ tokens.push(name);
751
+ for (const index of part.matchAll(/\[(\d+)\]/g))
752
+ tokens.push(Number(index[1]));
753
+ }
754
+ return tokens;
755
+ }
756
+ /** 这份 front matter 里,分类声明的元数据字段缺了哪些(缺 = 没有值,不是「值为空串」)。 */
757
+ function missingMetadataFields(fields, frontMatter) {
758
+ if (!fields || fields.length === 0)
759
+ return [];
760
+ const present = (key) => {
761
+ let current = frontMatter;
762
+ for (const token of metadataPath(key)) {
763
+ if (typeof token === 'number') {
764
+ if (!Array.isArray(current))
765
+ return false;
766
+ current = current[token];
767
+ }
768
+ else {
769
+ if (current === null || typeof current !== 'object')
770
+ return false;
771
+ current = current[token];
772
+ }
773
+ }
774
+ if (current === undefined || current === null)
775
+ return false;
776
+ if (typeof current === 'string')
777
+ return current.trim().length > 0;
778
+ if (Array.isArray(current))
779
+ return current.length > 0;
780
+ return true;
781
+ };
782
+ return fields.filter(field => field.required === true && !present(field.key));
783
+ }
784
+ /**
785
+ * 读一份 front matter 里的来源分类。
786
+ *
787
+ * 两个键都认:`type` 是 0048 起的规范键(OKF 必填),`source_type` 是旧文件里的键。
788
+ * 迁移会把存量文件改写成 `type`,但**读**这一侧永远两个都认 —— 别的库、别人手写的
789
+ * 文件、备份里翻出来的文件都可能还是旧形状,读不了就等于数据丢失。
790
+ */
791
+ function sourceTypeOf(frontmatter) {
792
+ const canonical = frontmatter.type;
793
+ if (typeof canonical === 'string' && canonical.trim())
794
+ return canonical.trim();
795
+ const legacy = frontmatter.source_type;
796
+ if (typeof legacy === 'string' && legacy.trim())
797
+ return legacy.trim();
798
+ return '';
799
+ }
800
+ /**
801
+ * 一个图片引用可能指向哪些磁盘路径,按优先级排。
802
+ *
803
+ * 知识库里同时存在三种写法,都是历史产物,谁也没错,所以解析要认全:
804
+ *
805
+ * | 写法 | 谁写的 | 从哪解析 |
806
+ * | --- | --- | --- |
807
+ * | `01_source_raw/_assets/<bucket>/<file>` | **规范形**:知识库根相对 | 根目录 |
808
+ * | `_assets/<bucket>/<file>` | 转换内核早期写进 SourceRaw 的形式(相对 `01_source_raw/`) | 根目录 + `01_source_raw/` |
809
+ * | `../../01_source_raw/_assets/…` | `cardHints` 给手工建卡用的形式 | 文件自己所在目录 |
810
+ *
811
+ * 顺序就是「先根目录、再 `01_source_raw/`、最后文件自己的目录」,而且只认**真实存在**
812
+ * 的那个({@link markdownImagePaths} 用 `existsSync` 挑)。这条规则与 `assetsOf` 共用,
813
+ * 因为「文档里有几张图」和「卡片正文里的图在哪」必须是同一个答案。
814
+ */
815
+ function assetRefCandidates(ref, baseDir, rootDir) {
816
+ if (isAbsolute(ref))
817
+ return [resolve(ref)];
818
+ return [
819
+ resolve(rootDir, ref),
820
+ resolve(rootDir, SOURCE_TRUTH, ref),
821
+ // 老段名的引用(0051 之前写进正文的那些):换一个名字再试一遍,别让存量卡片丢图。
822
+ ...(ref.includes(LEGACY_SOURCE_TRUTH) ? [resolve(rootDir, ref.replace(LEGACY_SOURCE_TRUTH, SOURCE_TRUTH))] : []),
823
+ ...(ref.includes(SOURCE_TRUTH) ? [resolve(rootDir, ref.replace(SOURCE_TRUTH, LEGACY_SOURCE_TRUTH))] : []),
824
+ resolve(baseDir, ref),
825
+ ];
826
+ }
827
+ /**
828
+ * 把 `from` 里的每一项并进 `to`(0051 的迁移中间态用)。
829
+ *
830
+ * 目标里已有的**不覆盖**:迁移只负责「把还没过来的搬过来」,冲突留给下一次启动或
831
+ * 人来处理。两边都是目录就递归 —— `_assets/<bucket>/` 是两层,一层不够。
832
+ */
833
+ async function mergeDir(from, to) {
834
+ await mkdir(to, { recursive: true });
835
+ for (const entry of await readdir(from, { withFileTypes: true })) {
836
+ const source = join(from, entry.name);
837
+ const target = join(to, entry.name);
838
+ if (existsSync(target)) {
839
+ if (entry.isDirectory())
840
+ await mergeDir(source, target);
841
+ continue;
842
+ }
843
+ await rename(source, target);
844
+ }
845
+ }
846
+ /**
847
+ * 文件名(不含扩展名):**用标题**(0051)。
848
+ *
849
+ * 三条规则,都是为了让它在任何文件系统上都落得下去:
850
+ * 1. 路径不安全字符与不可见字符换成 `-`(`/` `\` `:` `*` `?` `"` `<` `>` `|` 与控制字符);
851
+ * 2. 首尾的点与空格去掉(Windows 与 macOS 都不喜欢,且会让 `ls` 看起来少一个字符);
852
+ * 3. 截断到 120 字符;标题为空(或全是非法字符)时**退回 `source_id` 的 slug** ——
853
+ * 宁可文件名不好看,也不要没有文件名。
854
+ *
855
+ * 它不负责查重:目标路径上已经有人占了,由 `addSource` 按 `SOURCE_TITLE_DUPLICATED` 拒收。
856
+ */
857
+ function sourceFileBase(title, sourceId) {
858
+ const cleaned = title
859
+ .replace(/[/\\:*?"<>|\u0000-\u001f]/g, '-')
860
+ .replace(/\s+/g, ' ')
861
+ .replace(/^[.\s]+|[.\s]+$/g, '')
862
+ .slice(0, 120)
863
+ .trim();
864
+ // 只剩替换字符(`///` → `---`)等于没有名字:一个叫 `---.md` 的文件比 id 更难认。
865
+ return /^[-\s]*$/.test(cleaned) ? slug(sourceId) : cleaned;
866
+ }
867
+ /**
868
+ * 正文里的图片引用 → 绝对路径。
869
+ *
870
+ * 卡片这条路上原先少了这一步:`markdownImagePaths(content, dirname(path))` 只按文件
871
+ * 自己的目录解析。于是卡片正文里那张从来源**原样搬过来**的图,被解析成
872
+ * `02_wiki/<type>/_assets/…` —— 一个不存在的路径。0047 实机就是这么炸的:向量编码器
873
+ * 报 `FileNotFoundError: …/02_wiki/concept/_assets/…/img-2d1e420e82a8.png`,
874
+ * 整个 embedding 那一档直接降级成 unavailable。
875
+ */
876
+ function markdownImagePaths(content, baseDir, rootDir) {
877
+ return markdownImageRefs(content).map(value => {
878
+ if (isAbsolute(value))
879
+ return resolve(value);
880
+ if (rootDir) {
881
+ const found = assetRefCandidates(value, baseDir, rootDir).find(candidate => existsSync(candidate));
882
+ if (found)
883
+ return found;
884
+ }
885
+ return resolve(baseDir, value);
886
+ });
887
+ }
888
+ /** 按来源数一数「有多少张图没落位」,用来生成一条人能照着查的 warning。 */
889
+ function countBySource(images) {
890
+ const result = new Map();
891
+ for (const image of images)
892
+ result.set(image.sourceId, (result.get(image.sourceId) ?? 0) + 1);
893
+ return result;
395
894
  }
396
895
  async function imageFingerprints(paths) {
397
896
  const result = [];
@@ -613,8 +1112,16 @@ export class KnowledgeRuntime {
613
1112
  * 写入与删除后必须失效(见 `addSource` / `deleteDocument`)。
614
1113
  */
615
1114
  sourceIndex;
1115
+ /** `path → sourceId`:目标路径被谁占着(0051 的重名判定)。与 `sourceIndex` 同一次遍历建。 */
1116
+ sourcePathOwner;
616
1117
  /** 段内布局整理是否已经跑过(每个 runtime 一次)。 */
617
1118
  segmentsOrganized = false;
1119
+ /**
1120
+ * 一次性迁移里**没有静默处理掉**的事(改名冲突、迁移后比对不一致)。
1121
+ *
1122
+ * 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
1123
+ */
1124
+ migrationProblems = [];
618
1125
  vectorIndex = null;
619
1126
  vectorError;
620
1127
  embeddingWorker = null;
@@ -644,8 +1151,10 @@ export class KnowledgeRuntime {
644
1151
  * `memoryPath`,守卫换成「不许跑出 memoryDir」。
645
1152
  */
646
1153
  memoryDir;
647
- /** #3b 的模型调用接缝;undefined = 退回纯规则抽取。 */
648
- modelCall;
1154
+ /** #3b 的模型调用接缝;undefined = 退回纯规则抽取。测试注入的固定值。 */
1155
+ staticModelCall;
1156
+ /** 每次真正要用模型时问一遍的那个接缝 provider(0044)。为空则退回 `staticModelCall`。 */
1157
+ modelSeamProvider;
649
1158
  constructor(rootDir, config = {}, options = {}) {
650
1159
  this.rootDir = resolve(rootDir);
651
1160
  // `config` 里没有路径字段:这个运行时的位置就是 `this.rootDir`,配置只带
@@ -655,13 +1164,25 @@ export class KnowledgeRuntime {
655
1164
  this.sourceTypeProvider = options.sourceTypes;
656
1165
  this.wikiSchemaProvider = options.wikiSchema;
657
1166
  this.memoryDir = options.memoryRoot ? resolve(options.memoryRoot) : join(this.rootDir, '04_memory');
658
- this.modelCall = options.modelCall;
1167
+ this.staticModelCall = options.modelCall;
1168
+ this.modelSeamProvider = options.modelSeam;
659
1169
  const resources = resolveResources(options.resourceRoot);
660
1170
  this.pythonPath = config.python?.trim() || resources.python;
661
1171
  this.embeddingModelPath = config.embeddingModel?.trim() || resources.embeddingModel;
662
1172
  this.rerankModelPath = config.rerankModel?.trim() || resources.rerankModel;
663
1173
  this.embeddingModelId = resources.modelId(this.embeddingModelPath);
664
1174
  }
1175
+ /**
1176
+ * 现在能不能用模型抽实体。
1177
+ *
1178
+ * **每次现问**,不是在构造时定死:插件 apply 的时刻早于 `agent-default-model` 挂载,
1179
+ * 那时问一次只会得到 undefined,而定死之后就再也不会变(见 `modelCallProvider`)。
1180
+ */
1181
+ modelSeamNow() {
1182
+ if (this.modelSeamProvider)
1183
+ return this.modelSeamProvider();
1184
+ return this.staticModelCall ? { call: this.staticModelCall } : { why: '运行时没有注入模型接缝' };
1185
+ }
665
1186
  /**
666
1187
  * 就地应用一份新的运行时配置(0.1.7 的 volatile 改动走这条路)。
667
1188
  *
@@ -711,29 +1232,242 @@ export class KnowledgeRuntime {
711
1232
  // ------------------------------------------------- Wiki 卡片类型注册表(0024)
712
1233
  /** 当前生效的卡片 schema:类型定义 + 生效方式。 */
713
1234
  wikiSchema() {
714
- return this.wikiSchemaProvider?.() ?? { types: defaultWikiTypes(), mode: 'report' };
1235
+ return this.wikiSchemaProvider?.() ?? { types: defaultWikiTypes() };
715
1236
  }
716
1237
  /**
717
- * Agent 与设置面板共用的类型目录:注册表 + 生效方式 + 内置只读判定规则 +
718
- * 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)。
1238
+ * Agent 与设置面板共用的类型目录:注册表 + 内置只读判定规则 +
1239
+ * 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)+ **review 进度**。
1240
+ *
1241
+ * review 计数挂在这里而不是另开一路,是因为 review 就发生在这一屏(「知识库 → Wiki」)——
1242
+ * 与 lint 待补清单是同一处,不新造第二套界面。
1243
+ *
1244
+ * 0044:`proposals`(待审提议条数)改成 `reviews`。队列退役之后「还有几条等人确认」
1245
+ * 这个问题不再有意义 —— 卡片一编译就入库了,剩下的问题是「入库的这些里,我看过几条」。
719
1246
  */
720
1247
  async wikiTypeCatalog() {
721
1248
  const schema = this.wikiSchema();
722
1249
  const issues = await this.lintCards();
1250
+ const cards = await this.loadCards();
723
1251
  return success({
724
1252
  types: schema.types,
725
- mode: schema.mode,
726
1253
  rules: WIKI_TYPE_RULES,
727
1254
  issues: issues.slice(0, 50),
728
1255
  issueCount: issues.length,
1256
+ reviews: {
1257
+ total: cards.length,
1258
+ unreviewed: cards.filter(card => card.review?.state !== 'reviewed').length,
1259
+ reviewed: cards.filter(card => card.review?.state === 'reviewed').length,
1260
+ anomalies: cards.filter(card => (card.annotations ?? []).some(item => item.kind === 'anomaly')).length,
1261
+ },
729
1262
  });
730
1263
  }
731
- /** 对已落库的卡片跑三层校验。不写盘,只报告。 */
1264
+ /**
1265
+ * 智能归因图谱的存储句柄。
1266
+ *
1267
+ * 它由 runtime 自己构造(而不是由 index.ts 注入),因为 `rebuild` 要产出
1268
+ * `03_compiled/diagnosis_graph.json` —— 那次产出必须和 `knowledge_graph.json`
1269
+ * 在同一次卡片遍历的语义下发生,否则两份产物会来自两个不同版本的卡片。
1270
+ */
1271
+ diagnosis;
1272
+ diagnosisStore() {
1273
+ if (!this.diagnosis)
1274
+ this.diagnosis = new DiagnosisStore(this);
1275
+ return this.diagnosis;
1276
+ }
1277
+ /* ────────────────────────────────────── 卡片入库:直接写,不排队 ── */
1278
+ /**
1279
+ * 把一批卡片**直接写进 `02_wiki`**(0044)。
1280
+ *
1281
+ * 0040 v1.5 的这道门原来是「提一条提议 → 人在面板确认 → 才落卡」,理由是类型层是收敛的
1282
+ * 那一层,错一条所有现象的排序一起错。0044 把方向反过来:**入库在前、review 在后**。
1283
+ * 判据是实践中的两个事实:
1284
+ *
1285
+ * 1. **确认门把知识挡在了门外。** 抽到的东西停在 `memory/card-proposals/` 里等人点,
1286
+ * 而人不会一条条点。用户看到的现象就是「编译没生效」——文档导进去了,wiki 是空的。
1287
+ * 2. **review 的价值不需要排队来实现。** 「这张卡片对不对」可以在它已经可召回之后回答;
1288
+ * 而「未 review 就不许进来」等于把「暂时没人看的正确知识」和「错误知识」一起丢掉。
1289
+ *
1290
+ * 于是现在的形状是:入库 → 召回得到(评分稍低)→ 人 review → 评分抬高 / 备注异常点。
1291
+ * 拒绝某条知识的正确做法是 `set_knowledge_status(deprecated|problem)`,它会留痕,
1292
+ * 也能被撤回 —— 比「从来没见过它」更容易纠正。
1293
+ *
1294
+ * 与编译路径的差别只有一条:这里允许调用方指定 `entityId` 与正文,编译路径由候选推导。
1295
+ * 两者共用 `writeCard`,所以校验、版本快照、备注保留的行为完全一致。
1296
+ */
1297
+ async writeCards(input) {
1298
+ const empty = { written: 0, ids: [], rejected: [], warnings: [] };
1299
+ if (!input.sourceId?.trim())
1300
+ return failure(empty, 'SOURCE_REF_REQUIRED', '卡片必须挂在一条来源上', false, '先调 add_source 落一条来源,再用它的 source_id 写入卡片。');
1301
+ const sources = new Set((await this.loadSources()).map(source => source.sourceId));
1302
+ if (!sources.has(input.sourceId)) {
1303
+ return failure(empty, 'SOURCE_REF_NOT_FOUND', `source_refs 引用不存在: ${input.sourceId}`, false, '先调用 add_source 写入对应的 SourceTruth');
1304
+ }
1305
+ if (!Array.isArray(input.cards) || input.cards.length === 0) {
1306
+ return failure(empty, 'CARD_EMPTY', '至少要写一张卡片', true);
1307
+ }
1308
+ const schema = this.wikiSchema();
1309
+ const rejected = [];
1310
+ const warnings = [];
1311
+ const ids = [];
1312
+ /**
1313
+ * 判重索引只读一次。**批量里的后一张要能看到前一张刚写的卡片** —— 同一个实体在
1314
+ * 一批里出现两次时,第二次必须走并入而不是再写一遍,所以写成功后要回填这张表。
1315
+ */
1316
+ const byKey = new Map((await this.loadCards()).map(card => [`${card.entityType}:${card.entityId}`, card]));
1317
+ for (const item of input.cards) {
1318
+ const type = schema.types.find(def => def.id === item.entityType);
1319
+ if (!type) {
1320
+ rejected.push(`${item.entityId}:未知卡片类型 ${item.entityType}`);
1321
+ continue;
1322
+ }
1323
+ if (!type.enabled) {
1324
+ rejected.push(`${item.entityId}:类型 ${item.entityType} 已停用`);
1325
+ continue;
1326
+ }
1327
+ /**
1328
+ * **已有卡片时走「并入」而不是覆盖。**
1329
+ *
1330
+ * 这一步是必需的,不是优化:Agent 按「同一实体跨两份文档」调用两次时,覆盖式写入会让
1331
+ * 第一份来源与它的正文**静默消失**(实测:先 doc:a 再 doc:b,卡片只剩 `["doc:b"]`)。
1332
+ * 退役的提议存储天然按身份去重并累积 `source_refs`,所以少了这一步就是相对旧链路的一处退化。
1333
+ *
1334
+ * 三条合并规则与编译路径完全相同,两处共用 `appendExtracted`:
1335
+ * 正文追加(自带文本判重)、`source_refs` 取并集、`metadata` 合并。
1336
+ * `status` 与人工备注 / review 由 `writeCard` 自己保留,不在这里重复照顾。
1337
+ */
1338
+ const existing = byKey.get(`${item.entityType}:${item.entityId}`);
1339
+ const requestedRefs = item.sourceRefs ?? [input.sourceId];
1340
+ const sourceRefs = existing ? [...new Set([...existing.sourceRefs, ...requestedRefs])] : requestedRefs;
1341
+ const content = existing ? appendExtracted(existing.content, [item.content]) : item.content;
1342
+ const written = await this.writeCard({
1343
+ entityType: item.entityType,
1344
+ entityId: item.entityId,
1345
+ title: item.title,
1346
+ content,
1347
+ sourceRefs,
1348
+ ...(item.summary ?? existing?.summary ? { summary: item.summary ?? existing?.summary } : {}),
1349
+ ...(item.metadata || existing?.metadata ? { metadata: { ...(existing?.metadata ?? {}), ...(item.metadata ?? {}) } } : {}),
1350
+ ...(existing ? { status: existing.status } : {}),
1351
+ ...(existing?.owner ? { owner: existing.owner } : {}),
1352
+ ...(existing?.provenance ? { provenance: existing.provenance } : {}),
1353
+ origin: 'compile',
1354
+ ...(input.lineage ? { lineage: input.lineage } : {}),
1355
+ });
1356
+ if (written.ok === false) {
1357
+ rejected.push(`${item.entityId}:${written.error.code} ${written.error.message}`);
1358
+ continue;
1359
+ }
1360
+ ids.push(item.entityId);
1361
+ // 回填:这一批里后面那张卡片要按**刚写下的**样子判重,而不是按开批之前的磁盘快照。
1362
+ byKey.set(`${item.entityType}:${item.entityId}`, {
1363
+ entityId: item.entityId, entityType: item.entityType, title: item.title,
1364
+ status: existing?.status ?? 'unreviewed', content, sourceRefs,
1365
+ ...(item.summary ?? existing?.summary ? { summary: item.summary ?? existing?.summary } : {}),
1366
+ metadata: { ...(existing?.metadata ?? {}), ...(item.metadata ?? {}) }, updatedAt: new Date().toISOString(),
1367
+ });
1368
+ for (const warning of written.warnings ?? [])
1369
+ warnings.push(`${item.entityId}:${warning}`);
1370
+ }
1371
+ return success({ written: ids.length, ids, rejected, warnings }, rejected.length > 0 ? [`${rejected.length} 张卡片被拒:${rejected.join(';')}`] : []);
1372
+ }
1373
+ /* ────────────────────────────────────── 入库之后:备注与 review ── */
1374
+ /**
1375
+ * 给一张卡片加一条**人工备注**,或撤掉一条(0044)。
1376
+ *
1377
+ * 这是「wiki 不可修改」的对偶:正文改不了,但**判断改得了**。「这份文档的数字和卡片
1378
+ * 对不上」「这个口径在另一份文档里是反的」——这些都是关于卡片的判断,不是卡片的内容,
1379
+ * 所以它们落在 frontmatter 的 `annotations` 里,编译永远不碰。
1380
+ *
1381
+ * `kind: 'anomaly'` 会把卡片的有效评分压到 `ANOMALY_SCORE`(见 `effectiveReviewScore`):
1382
+ * 异常卡片照样召回得到,只是排在后面 —— 而不是被藏起来。藏起来的问题在于没人会再看到它,
1383
+ * 于是也就没人会去修。
1384
+ */
1385
+ async annotateCard(input) {
1386
+ const card = await this.readCard(input.entityId, input.entityType);
1387
+ if (!card)
1388
+ return failure({ entityId: input.entityId, annotations: [], reviewScore: 0 }, 'CARD_NOT_FOUND', `知识卡片不存在: ${input.entityId}`, true);
1389
+ const current = card.annotations ?? [];
1390
+ let next;
1391
+ if (input.removeId?.trim()) {
1392
+ next = current.filter(item => item.id !== input.removeId.trim());
1393
+ if (next.length === current.length) {
1394
+ return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, 'ANNOTATION_NOT_FOUND', `备注不存在: ${input.removeId}`, true);
1395
+ }
1396
+ }
1397
+ else {
1398
+ const note = input.note?.trim() ?? '';
1399
+ if (!note)
1400
+ return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, 'ANNOTATION_NOTE_REQUIRED', 'note 不能为空', true);
1401
+ const at = new Date().toISOString();
1402
+ next = [...current, {
1403
+ id: annotationId(note, at),
1404
+ kind: input.kind === 'anomaly' ? 'anomaly' : 'note',
1405
+ note,
1406
+ ...(input.author?.trim() ? { author: input.author.trim() } : {}),
1407
+ createdAt: at,
1408
+ ...(input.sourceRef?.trim() ? { sourceRef: input.sourceRef.trim() } : {}),
1409
+ }];
1410
+ }
1411
+ const written = await this.writeCard({ ...card, sourceRefs: card.sourceRefs, annotations: next, origin: 'internal' });
1412
+ if (written.ok === false)
1413
+ return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, written.error.code, written.error.message, written.error.retryable, written.error.suggestion);
1414
+ await this.audit('wiki.annotate', { entityId: input.entityId, added: input.removeId ? 0 : 1, removed: input.removeId ? 1 : 0, count: next.length });
1415
+ const score = effectiveReviewScore({ annotations: next, ...(card.review ? { review: card.review } : {}) });
1416
+ return success({ entityId: input.entityId, annotations: next, reviewScore: score });
1417
+ }
1418
+ /**
1419
+ * review 一张卡片(0044)。**它不是准入闸门** —— 卡片在 review 之前就已经入库、可召回了。
1420
+ *
1421
+ * `review` 把评分抬到 `REVIEWED_SCORE_FLOOR`,`unreview` 放回出厂分。两个动作都可逆:
1422
+ * review 是一个可以被推翻的判断,不是一次性的凭证。
1423
+ */
1424
+ async reviewCard(input) {
1425
+ const card = await this.readCard(input.entityId, input.entityType);
1426
+ if (!card)
1427
+ return failure({ entityId: input.entityId, review: { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE }, reviewScore: CARD_REVIEW_BASE_SCORE, status: 'unreviewed' }, 'CARD_NOT_FOUND', `知识卡片不存在: ${input.entityId}`, true);
1428
+ const at = new Date().toISOString();
1429
+ const reviewer = input.reviewer?.trim() || 'user:panel';
1430
+ const review = input.action === 'review'
1431
+ ? { state: 'reviewed', score: Math.max(card.review?.score ?? 0, REVIEWED_SCORE_FLOOR), reviewedAt: at, reviewedBy: reviewer }
1432
+ : { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE };
1433
+ /**
1434
+ * 状态跟着一起走(0044 实机验证第七批)。
1435
+ *
1436
+ * 卡片有两个字段:`status` 是**生命周期**(编译器写它,人在 `set_knowledge_status`
1437
+ * 里改),`review` 是**人的判断 + 评分**。它们本来描述不同的事,但「已经 review 过」
1438
+ * 的卡片在预览里显示 `状态 unreviewed` 会让人以为刚才那一下没生效 —— 实机验证时
1439
+ * 看到的就是这个组合。
1440
+ *
1441
+ * 所以:
1442
+ * - `review` 通过时,`unreviewed` / `draft` 推进到 `reviewed`(这个词本来就在
1443
+ * 状态词表里,含义就是这个);
1444
+ * - 撤销 review 时只把 `reviewed` 退回 `unreviewed`,**不动** `active` / `deprecated` /
1445
+ * `problem` —— 那些是人另外做过的决定,不该被一次撤销顺手抹掉。
1446
+ */
1447
+ const nextStatus = input.action === 'review'
1448
+ ? (card.status === 'unreviewed' || card.status === 'draft' ? 'reviewed' : card.status)
1449
+ : (card.status === 'reviewed' ? 'unreviewed' : card.status);
1450
+ const written = await this.writeCard({ ...card, status: nextStatus, sourceRefs: card.sourceRefs, review, origin: 'review' });
1451
+ if (written.ok === false) {
1452
+ const current = card.review ?? { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE };
1453
+ return failure({ entityId: input.entityId, review: current, reviewScore: effectiveReviewScore(card), status: card.status }, written.error.code, written.error.message, written.error.retryable, written.error.suggestion);
1454
+ }
1455
+ await this.audit('wiki.review', { entityId: input.entityId, action: input.action, reviewer, score: review.score });
1456
+ return success({ entityId: input.entityId, review, reviewScore: effectiveReviewScore({ ...card, review }), status: nextStatus });
1457
+ }
1458
+ /**
1459
+ * 对已落库的卡片跑三层校验。不写盘,只报告。
1460
+ *
1461
+ * 0049 补齐了 `reference` 那一层:正文里的 `[[类型/名称]]` 指向一张不存在的卡片,
1462
+ * 就是一条断链。此前这一层只有类型定义里的名字,没有任何产生者 —— 而「引用错说明
1463
+ * 图断了」正是它要回答的问题(format 错说明没填完,语义错说明这条知识没用)。
1464
+ */
732
1465
  async lintCards() {
733
1466
  const schema = this.wikiSchema();
734
1467
  const byId = new Map(schema.types.map(def => [def.id, def]));
735
1468
  const result = [];
736
- for (const card of await this.loadCards()) {
1469
+ const cards = await this.loadCards();
1470
+ for (const card of cards) {
737
1471
  const type = byId.get(card.entityType);
738
1472
  if (!type)
739
1473
  continue;
@@ -741,13 +1475,18 @@ export class KnowledgeRuntime {
741
1475
  result.push({ ...issue, entityId: card.entityId, entityType: card.entityType });
742
1476
  }
743
1477
  }
1478
+ // 断链与类型无关(一张类型没登记的卡片照样能有链接),所以单独扫一遍全部卡片。
1479
+ for (const link of deadLinksOf(cards)) {
1480
+ result.push({ ...deadLinkIssue(link.target), entityId: link.entityId, entityType: link.entityType });
1481
+ }
744
1482
  return result;
745
1483
  }
746
1484
  /**
747
- * `write_knowledge_card` 的类型闸门 + 三层校验。
1485
+ * 类型闸门 + 三层校验。
748
1486
  *
749
- * 返回 `{ type, issues }` 或一个失败信封。是否因 issues 拒绝写入由 `mode` 决定:
750
- * `strict` 直接拒绝,`report` 把问题写进卡片并回报——存量卡片因此不会被升级挡在门外。
1487
+ * 返回 `{ type, issues }` 或一个失败信封(只可能因为**未知类型** —— 没有类型定义就连
1488
+ * 「缺什么」都说不出来)。issues **不决定写不写**:0044 起卡片一律直接入库,问题写进
1489
+ * `metadata.schema_issues` 由面板显示成「待补」。原来的 `strict` / `report` 开关一并撤掉了。
751
1490
  */
752
1491
  cardSchemaGate(input) {
753
1492
  const schema = this.wikiSchema();
@@ -813,6 +1552,13 @@ export class KnowledgeRuntime {
813
1552
  return null;
814
1553
  }
815
1554
  directory(name) { return join(this.rootDir, name); }
1555
+ /**
1556
+ * 0051 之前的段目录路径。
1557
+ *
1558
+ * 它**不在 `LAYOUT` 里**:`LAYOUT` 是「应该建出来的目录」清单,而老段名只是要读、
1559
+ * 要搬走的东西 —— 把它加进 `LAYOUT`,`ensureLayout` 每次启动都会把那个空目录建回来。
1560
+ */
1561
+ legacySourceDir() { return join(this.rootDir, LEGACY_SOURCE_TRUTH); }
816
1562
  async ensureLayout() {
817
1563
  await Promise.all(LAYOUT.map(name => mkdir(this.directory(name), { recursive: true })));
818
1564
  // 记忆段在知识树**外面**(0032),所以要单独建。它与 LAYOUT 一起建的理由相同:
@@ -823,8 +1569,114 @@ export class KnowledgeRuntime {
823
1569
  // 写入与统计的入口。实例级标志保证每个 runtime 只扫一次。
824
1570
  if (!this.segmentsOrganized) {
825
1571
  this.segmentsOrganized = true;
1572
+ // 0051 的改名排在最前:后面几个迁移都按 `SOURCE_TRUTH` 扫目录,先把树搬过来,
1573
+ // 它们就只需要认一个名字(而老段名只留兼容解析那一处)。
1574
+ await this.migrateSourceTree();
826
1575
  await this.migrateSourceTruthLayout();
827
- await this.dropLegacySchemaSegment();
1576
+ await this.dropRetiredSegments();
1577
+ await this.migrateLegacySourceTypeRename();
1578
+ await this.migrateLegacyDiagnosisCards();
1579
+ await this.migrateSourceFrontMatter();
1580
+ await this.migrateSourceFileNames();
1581
+ }
1582
+ }
1583
+ /**
1584
+ * 0051:`01_source_truth/` → `01_source_raw/`。
1585
+ *
1586
+ * **整棵树一次 `rename`**,不逐文件搬:`_assets/` 就在那个目录里,逐文件搬会把
1587
+ * 图片落下的概率变成「每个文件各一次」。一次 rename 是原子的,要么全过来要么全没动。
1588
+ *
1589
+ * 两种中间态都要处理:
1590
+ * - 目标不存在 → 直接 rename;
1591
+ * - 两个目录都在(上一次搬到一半)→ 逐项合并:目标里没有的搬过去,两边都有的
1592
+ * (`_assets` 与分类目录)递归合并,最后删掉空壳。
1593
+ *
1594
+ * 失败不回滚、不抛:`loadSources` 两个段名都扫,搬到一半的库照常可读,
1595
+ * 下一次启动接着搬。
1596
+ */
1597
+ async migrateSourceTree() {
1598
+ const from = this.legacySourceDir();
1599
+ const to = this.directory(SOURCE_TRUTH);
1600
+ if (!existsSync(from))
1601
+ return;
1602
+ try {
1603
+ if (!existsSync(to)) {
1604
+ await rename(from, to);
1605
+ }
1606
+ else {
1607
+ await mergeDir(from, to);
1608
+ await rmdir(from).catch(() => { });
1609
+ }
1610
+ this.sourceIndex = undefined;
1611
+ this.sourcePathOwner = undefined;
1612
+ this.migrationProblems.push('段目录已改名为 01_source_raw(原 01_source_truth)');
1613
+ }
1614
+ catch (cause) {
1615
+ this.migrationProblems.push(`段目录改名未完成,下次启动继续:${cause instanceof Error ? cause.message : String(cause)}`);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * 0051:文件名改成标题。
1620
+ *
1621
+ * 只动**名字不对**的那些(可重入),而且**只 rename,不改内容** —— 正文里那些
1622
+ * `../../01_source_truth/_assets/…` 的老引用一个字节都不动,解析器认两个段名
1623
+ * (见 `assetRefCandidates`)。改内容会造内容版本、让卡片重编重编码,代价远大于
1624
+ * 一个字符串好不好看。
1625
+ *
1626
+ * 目标路径被别的来源占着时**不覆盖**:加内容哈希后缀并记一条,让人去改标题
1627
+ * (与 `addSource` 的默认拒收同一条纪律,只是这里不能拒 —— 那是历史数据)。
1628
+ */
1629
+ async migrateSourceFileNames() {
1630
+ const sources = await this.loadSources();
1631
+ const index = await this.sourceIndexMap();
1632
+ const taken = new Map();
1633
+ for (const source of sources) {
1634
+ const path = index.get(slug(source.sourceId));
1635
+ if (path === undefined)
1636
+ continue;
1637
+ taken.set(path, source.sourceId);
1638
+ }
1639
+ for (const source of sources) {
1640
+ const path = index.get(slug(source.sourceId));
1641
+ if (path === undefined)
1642
+ continue;
1643
+ /**
1644
+ * 没有合法 `type` 的文件**留在原地**(与 0030 同一条纪律:宁可少搬,不可搬错)。
1645
+ *
1646
+ * `loadSources` 把读不出分类的来源当成 `document`(读得到总比丢掉好),所以
1647
+ * 「默认成了 document」与「真的写着 document」在这里长得一样 —— 必须回文件里
1648
+ * 看那一行,否则一个没有 front matter 的散文件会被搬进 `document/`。
1649
+ */
1650
+ if (!(await this.hasDeclaredSourceType(path)))
1651
+ continue;
1652
+ let target = this.sourcePath(source.sourceId, source.sourceType, source.title);
1653
+ if (target === path)
1654
+ continue;
1655
+ const occupant = taken.get(target);
1656
+ if (occupant !== undefined && occupant !== source.sourceId) {
1657
+ target = this.safePath(SOURCE_TRUTH, source.sourceType, `${sourceFileBase(source.title, source.sourceId)}-${hash(source.sourceId).slice(0, 6)}.md`);
1658
+ this.migrationProblems.push(`来源 ${source.sourceId} 与 ${occupant} 标题相同(「${source.title}」),已落成 ${basename(target)};建议改标题`);
1659
+ }
1660
+ try {
1661
+ await mkdir(dirname(target), { recursive: true });
1662
+ await rename(path, target);
1663
+ taken.delete(path);
1664
+ taken.set(target, source.sourceId);
1665
+ }
1666
+ catch (cause) {
1667
+ this.migrationProblems.push(`来源 ${source.sourceId} 改名失败,保持原名:${cause instanceof Error ? cause.message : String(cause)}`);
1668
+ }
1669
+ }
1670
+ this.sourceIndex = undefined;
1671
+ this.sourcePathOwner = undefined;
1672
+ }
1673
+ /** 这份文件自己声明了合法分类吗(`migrateSourceFileNames` 只搬声明过的)。 */
1674
+ async hasDeclaredSourceType(path) {
1675
+ try {
1676
+ return SOURCE_TYPE_ID_PATTERN.test(sourceTypeOf(parseMarkdown(await readFile(path, 'utf8')).frontmatter));
1677
+ }
1678
+ catch {
1679
+ return false;
828
1680
  }
829
1681
  }
830
1682
  /**
@@ -850,7 +1702,7 @@ export class KnowledgeRuntime {
850
1702
  const from = join(root, entry.name);
851
1703
  let sourceType = '';
852
1704
  try {
853
- sourceType = String(parseMarkdown(await readFile(from, 'utf8')).frontmatter.source_type ?? '');
1705
+ sourceType = sourceTypeOf(parseMarkdown(await readFile(from, 'utf8')).frontmatter);
854
1706
  }
855
1707
  catch {
856
1708
  continue;
@@ -867,6 +1719,70 @@ export class KnowledgeRuntime {
867
1719
  // 目录变了,索引必须重建。
868
1720
  this.sourceIndex = undefined;
869
1721
  }
1722
+ /**
1723
+ * 0048:把存量 SourceRaw 的 front matter 改成 OKF v0.2 的形状。
1724
+ *
1725
+ * 改三件事,都只搬**已经存在**的值,不猜、不造:
1726
+ *
1727
+ * 1. `source_type` → `type`(OKF 唯一必填键)。旧键删掉 —— 同一个事实留两份,
1728
+ * 迟早会出现「改了这份、那份没改」。读取侧两个键都认(见 `sourceTypeOf`),
1729
+ * 所以迁移没跑到、或别的库还是旧形状,都不影响可用性。
1730
+ * 2. `metadata.source_url` → 顶层 `resource`(OKF 的「底层资产地址」)。
1731
+ * 3. 补 `generated{by,at}` 与 `sources[]`:`by` 取自 `metadata.parser`(谁解析的),
1732
+ * `at` 取自 `created_at`(这份文件什么时候写出来的)。**作者与发布时间补不出来**
1733
+ * —— 0048 之前适配器把它们抽出来就扔了,这里如实留空,不编一个。
1734
+ *
1735
+ * 正文一个字节都不动,也不制造内容版本(版本只认正文变化)。整个迁移可重入:
1736
+ * 已经有 `type` 的文件直接跳过。
1737
+ */
1738
+ async migrateSourceFrontMatter() {
1739
+ for (const path of await filesUnder(this.directory(SOURCE_TRUTH))) {
1740
+ let parsed;
1741
+ try {
1742
+ parsed = parseMarkdown(await readFile(path, 'utf8'));
1743
+ }
1744
+ catch {
1745
+ continue;
1746
+ }
1747
+ const frontmatter = parsed.frontmatter;
1748
+ if (typeof frontmatter.type === 'string' && frontmatter.type.trim())
1749
+ continue;
1750
+ const sourceType = sourceTypeOf(frontmatter) || 'document';
1751
+ const metadata = plainRecord(frontmatter.metadata);
1752
+ const resource = typeof metadata.source_url === 'string' && metadata.source_url.trim() ? metadata.source_url.trim() : undefined;
1753
+ const parser = typeof metadata.parser === 'string' && metadata.parser.trim() ? metadata.parser.trim() : undefined;
1754
+ const createdAt = typeof frontmatter.created_at === 'string' ? frontmatter.created_at : undefined;
1755
+ const title = typeof frontmatter.title === 'string' ? frontmatter.title : undefined;
1756
+ /**
1757
+ * `metadata.source_url` 与顶层 `resource` 是同一个事实:搬上去之后就把旧的删掉
1758
+ * —— 两份键迟早出现「改了这份、那份没改」。`source_path` 同理:它指的是暂存目录里
1759
+ * 那个**已经被删掉**的文件,留着它只会让人以为还能找回来(底稿现在由交付目录的
1760
+ * `source/` 承担,0048 起才有)。
1761
+ */
1762
+ const { source_url: _promoted, source_path: _stale, ...restMetadata } = metadata;
1763
+ const { source_type: _legacy, ...rest } = frontmatter;
1764
+ const nextMetadata = Object.keys(restMetadata).length > 0 ? restMetadata : undefined;
1765
+ const next = {
1766
+ type: sourceType,
1767
+ ...(title !== undefined ? { title } : {}),
1768
+ ...(resource !== undefined ? { resource } : {}),
1769
+ ...(parser !== undefined || createdAt !== undefined
1770
+ ? { generated: { ...(parser !== undefined ? { by: `convert/${parser}` } : {}), ...(createdAt !== undefined ? { at: createdAt } : {}) } }
1771
+ : {}),
1772
+ ...(resource !== undefined ? { sources: [{ id: 'origin', resource, ...(title !== undefined ? { title } : {}) }] } : {}),
1773
+ ...rest,
1774
+ ...(nextMetadata !== undefined ? { metadata: nextMetadata } : {}),
1775
+ };
1776
+ try {
1777
+ await writeTextAtomic(path, markdown(next, parsed.content));
1778
+ }
1779
+ catch (cause) {
1780
+ this.migrationProblems.push(`来源 ${basename(path)} 的 front matter 迁移失败:${cause instanceof Error ? cause.message : String(cause)}`);
1781
+ }
1782
+ }
1783
+ this.sourceIndex = undefined;
1784
+ this.sourceCorpusCache = undefined;
1785
+ }
870
1786
  /**
871
1787
  * 0030:删掉已经被撤掉的 `00_schema` 段。
872
1788
  *
@@ -874,8 +1790,36 @@ export class KnowledgeRuntime {
874
1790
  * 有人放了文件,那就不属于「没用的空文件夹」了 —— 那种情况下留着并让人自己判断,
875
1791
  * 比替他们删掉安全。
876
1792
  */
877
- async dropLegacySchemaSegment() {
878
- const path = this.safePath('00_schema');
1793
+ /**
1794
+ * 清掉**只装了旧版本自己生成的东西**的退役段。
1795
+ *
1796
+ * `00_schema` 从来没有一行代码写过东西,所以「空」就是它的全部历史形态 —— 空就删。
1797
+ *
1798
+ * `05_diagnosis` 不一样:旧版本在里面铺过 `templates/*.md` 与一份 README,那两样是
1799
+ * 可以重建的(模板由类型定义派生、README 是说明),删掉不丢知识。但同一个目录里
1800
+ * 可能躺着**真正的历史快照**(`*.jsonl` / `cases.json`),那是只读的迁移输入,
1801
+ * 一个字节都不能动。所以判据是**文件清单**而不是「空不空」:只认得旧版本自己写的
1802
+ * 那些名字时,才删;出现任何别的东西就整个留着。
1803
+ */
1804
+ async dropRetiredSegments() {
1805
+ await this.dropSegmentIfEmpty('00_schema');
1806
+ const legacy = this.safePath('05_diagnosis');
1807
+ try {
1808
+ const entries = await readdir(legacy, { withFileTypes: true });
1809
+ const owned = entries.every(entry => entry.isDirectory()
1810
+ ? entry.name === 'templates'
1811
+ : entry.name === 'README.md' || entry.name.endsWith('.jsonl') || entry.name === 'cases.json');
1812
+ if (!owned)
1813
+ return;
1814
+ // 有 jsonl / cases.json 就是历史快照,留着(`DiagnosisStore` 还要读它做迁移)。
1815
+ if (entries.some(entry => entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'cases.json')))
1816
+ return;
1817
+ await rm(legacy, { recursive: true, force: true });
1818
+ }
1819
+ catch { /* 不存在或删不掉都不是错误 */ }
1820
+ }
1821
+ async dropSegmentIfEmpty(name) {
1822
+ const path = this.safePath(name);
879
1823
  try {
880
1824
  const entries = await readdir(path);
881
1825
  if (entries.length > 0)
@@ -887,6 +1831,171 @@ export class KnowledgeRuntime {
887
1831
  }
888
1832
  catch { /* 不存在、非空、或删不掉都不是错误 */ }
889
1833
  }
1834
+ /**
1835
+ * 0040 §3.2 的一次性迁移:`01_source_raw/ai_chat_message/` → `agent_chat_message/`。
1836
+ *
1837
+ * 改名不能只改注册表:`01_source_raw/` 是**按分类分目录**的,存量来源的
1838
+ * frontmatter 里写着 `source_type: ai_chat_message`。只改注册表会有两个后果 ——
1839
+ * 旧目录里那些来源变成一个「注册表里没有的分类」(界面上显示成未知分类),
1840
+ * 而旧分类因为还有来源在,永远删不掉。
1841
+ *
1842
+ * 三条纪律:
1843
+ * 1. **逐份搬,不整体 rename 目录。** 目标里可能已经有同名文件(用户在新版本里
1844
+ * 重新录过同一份来源),整体搬会覆盖它。
1845
+ * 2. **同名时比对正文**:一致就删掉旧的那份(同一份东西,只是目录名变了);
1846
+ * 不一致就**两边都留**,并把冲突报给界面 —— 静默覆盖等于丢数据。
1847
+ * 3. **迁移无损才认**:写完读回来比对 frontmatter 与正文,不一致就放弃这一份。
1848
+ */
1849
+ async migrateLegacySourceTypeRename() {
1850
+ const from = this.safePath(SOURCE_TRUTH, 'ai_chat_message');
1851
+ const to = this.safePath(SOURCE_TRUTH, 'agent_chat_message');
1852
+ let names;
1853
+ try {
1854
+ names = await readdir(from);
1855
+ }
1856
+ catch {
1857
+ return;
1858
+ }
1859
+ const conflicts = [];
1860
+ for (const name of names) {
1861
+ if (extname(name).toLowerCase() !== '.md')
1862
+ continue;
1863
+ const source = join(from, name);
1864
+ let raw;
1865
+ try {
1866
+ raw = await readFile(source, 'utf8');
1867
+ }
1868
+ catch {
1869
+ continue;
1870
+ }
1871
+ const parsed = parseMarkdown(raw);
1872
+ const sourceId = String(parsed.frontmatter.source_id ?? basename(name, '.md'));
1873
+ const target = join(to, name);
1874
+ let existing;
1875
+ try {
1876
+ existing = await readFile(target, 'utf8');
1877
+ }
1878
+ catch { /* 目标里还没有 */ }
1879
+ if (existing !== undefined) {
1880
+ const previous = parseMarkdown(existing);
1881
+ if (previous.content.trim() !== parsed.content.trim())
1882
+ conflicts.push(sourceId);
1883
+ await unlinkIfExists(source);
1884
+ continue;
1885
+ }
1886
+ await writeTextAtomic(target, markdown({ ...parsed.frontmatter, type: 'agent_chat_message', source_type: 'agent_chat_message' }, parsed.content));
1887
+ const roundTrip = parseMarkdown(await readFile(target, 'utf8'));
1888
+ if (roundTrip.content.trim() !== parsed.content.trim() || sourceTypeOf(roundTrip.frontmatter) !== 'agent_chat_message') {
1889
+ // 读回来对不上:把目标撤掉,旧的那份留着 —— 宁可迁移没生效,也不要留半份。
1890
+ await unlinkIfExists(target);
1891
+ conflicts.push(`${sourceId}(迁移后比对不一致,已放弃)`);
1892
+ continue;
1893
+ }
1894
+ await unlinkIfExists(source);
1895
+ }
1896
+ try {
1897
+ await rmdir(from);
1898
+ }
1899
+ catch { /* 还有别的东西在里面 */ }
1900
+ if (conflicts.length > 0) {
1901
+ this.migrationProblems.push(`来源分类改名迁移有 ${conflicts.length} 处冲突,旧文件保留在 01_source_raw/ai_chat_message/:${conflicts.join('、')}`);
1902
+ }
1903
+ }
1904
+ /**
1905
+ * 0040 §4 的一次性迁移:两次改名(`check` → `check_point`、`failure-mode` →
1906
+ * `failure_reason`)的存量卡片。
1907
+ *
1908
+ * 两件事一起做,因为它们是同一次改名的两个面:
1909
+ * 1. **卡片类型**:`02_wiki/diagnosis/check/` → `check-point/`,
1910
+ * frontmatter 的 `entity_type` 一起改;
1911
+ * 2. **结构化块里的字段名**:`failure_mode` → `failure_reason`。这一条与卡片类型
1912
+ * 无关 —— `diagnosis/hypothesis` 卡片的类型没变,但它块里那个字段变了。
1913
+ * 只按目录过滤的话,假设卡片上的旧字段名会永远留在磁盘上,而读回来是「少一个
1914
+ * 必填字段」,正文里却明明写着它。
1915
+ *
1916
+ * 读取端本来也认别名(`cards.parseCard`),但**只在内存里认是不够的**:`writeCard`
1917
+ * 按 `entity_type` 决定写哪个目录,改一张旧卡片就会在 `check-point/` 下多写一份,
1918
+ * 而旧目录里那份还在 —— 同一张卡片变成两张,读图时看谁先被遍历到。
1919
+ *
1920
+ * 用 `writeCard` 而不是自己拼 frontmatter:它是唯一知道卡片信封长什么样、并且会
1921
+ * 顺手做版本快照与三层校验的地方。
1922
+ */
1923
+ async migrateLegacyDiagnosisCards() {
1924
+ const root = this.safePath(WIKI, 'diagnosis');
1925
+ let kinds;
1926
+ try {
1927
+ kinds = await readdir(root);
1928
+ }
1929
+ catch {
1930
+ return;
1931
+ }
1932
+ /**
1933
+ * 目录名是 `entity_type` 的**命名空间之后那一段**(`02_wiki/diagnosis/check/` 对应
1934
+ * `diagnosis/check`)。用后缀匹配而不是拿 `entity_type` 直接比目录名 —— 直接比的话
1935
+ * 一个都匹配不上,而失败方式是静默的:迁移什么都没做,卡片仍按旧类型读。
1936
+ */
1937
+ const legacyByDir = new Map();
1938
+ for (const [legacy, next] of Object.entries(LEGACY_ENTITY_TYPES)) {
1939
+ legacyByDir.set(legacy.slice(legacy.indexOf('/') + 1), next);
1940
+ }
1941
+ const problems = [];
1942
+ for (const dir of kinds) {
1943
+ const legacyDir = join(root, dir);
1944
+ const renamedType = legacyByDir.get(dir);
1945
+ // 类型没改名、也不在旧目录里的子树也要扫一遍:块字段名的改名与类型无关。
1946
+ for (const file of await filesUnder(legacyDir)) {
1947
+ let raw;
1948
+ try {
1949
+ raw = await readFile(file, 'utf8');
1950
+ }
1951
+ catch {
1952
+ continue;
1953
+ }
1954
+ const parsed = parseMarkdown(raw);
1955
+ const entityId = String(parsed.frontmatter.entity_id ?? basename(file, '.md'));
1956
+ const currentType = String(parsed.frontmatter.entity_type ?? '');
1957
+ const nextType = renamedType ?? LEGACY_ENTITY_TYPES[currentType] ?? currentType;
1958
+ if (!nextType)
1959
+ continue;
1960
+ const body = rewriteLegacyBlockFields(parsed.content);
1961
+ const typeChanged = nextType !== currentType;
1962
+ const bodyChanged = body !== parsed.content;
1963
+ // 两样都没变就什么都不做 —— 每次启动都重写一遍所有卡片会把版本历史灌满。
1964
+ if (!typeChanged && !bodyChanged)
1965
+ continue;
1966
+ const written = await this.writeCard({
1967
+ entityType: nextType,
1968
+ entityId,
1969
+ title: String(parsed.frontmatter.title ?? entityId),
1970
+ content: body,
1971
+ sourceRefs: Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : [],
1972
+ status: (STATUS.includes(String(parsed.frontmatter.status)) ? String(parsed.frontmatter.status) : 'unreviewed'),
1973
+ ...(typeof parsed.frontmatter.summary === 'string' && parsed.frontmatter.summary ? { summary: parsed.frontmatter.summary } : {}),
1974
+ ...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
1975
+ metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata : {},
1976
+ });
1977
+ if (written.ok === false) {
1978
+ problems.push(`卡片 ${entityId} 从 ${currentType || dir} 迁到 ${nextType} 失败(${written.error.code}),旧文件保留`);
1979
+ continue;
1980
+ }
1981
+ // 读回来确认它真的落在新目录、而且认得出新类型,再删旧的。
1982
+ const readBack = await this.readCard(entityId, nextType);
1983
+ if (!readBack) {
1984
+ problems.push(`卡片 ${entityId} 迁移后读不回来,旧文件保留`);
1985
+ continue;
1986
+ }
1987
+ // 正文没变、类型也没变时,`writeCard` 写的是同一个路径 —— 这时**不要**删它。
1988
+ if (written.data.path !== file)
1989
+ await unlinkIfExists(file);
1990
+ }
1991
+ try {
1992
+ await rmdir(legacyDir);
1993
+ }
1994
+ catch { /* 还有别的东西在里面 */ }
1995
+ }
1996
+ if (problems.length > 0)
1997
+ this.migrationProblems.push(...problems);
1998
+ }
890
1999
  async loadVectorIndex() {
891
2000
  if (this.vectorIndex)
892
2001
  return this.vectorIndex;
@@ -1129,6 +2238,12 @@ export class KnowledgeRuntime {
1129
2238
  this.graph = graph;
1130
2239
  }
1131
2240
  catch { /* the first build has not happened yet */ }
2241
+ try {
2242
+ const diagnosis = JSON.parse(await readFile(this.safePath('03_compiled', DIAGNOSIS_GRAPH), 'utf8'));
2243
+ if (diagnosis && Array.isArray(diagnosis.nodes) && Array.isArray(diagnosis.edges))
2244
+ this.diagnosisGraph = diagnosis;
2245
+ }
2246
+ catch { /* 归因图还没编过(这个库没做过排查),或者旧版本没有这个产物 */ }
1132
2247
  }
1133
2248
  safePath(...parts) {
1134
2249
  const path = resolve(this.rootDir, ...parts);
@@ -1179,7 +2294,7 @@ export class KnowledgeRuntime {
1179
2294
  memoryFiles: await fileCount(this.memoryDir),
1180
2295
  /**
1181
2296
  * 按**段名**取,不按位置取。这里原先是 `counts[1]`,而它之所以曾经是对的,
1182
- * 只是因为 `LAYOUT[1]` 恰好是 `01_source_truth` —— 0030 撤掉 `00_schema`
2297
+ * 只是因为 `LAYOUT[1]` 恰好是 `01_source_raw` —— 0030 撤掉 `00_schema`
1183
2298
  * 之后同一条表达式就静默变成了「wiki 卡片数」,界面上的来源数直接归零。
1184
2299
  * 布局的索引是实现的偶然,段名才是契约。
1185
2300
  */
@@ -1189,6 +2304,12 @@ export class KnowledgeRuntime {
1189
2304
  notes: this.units.length,
1190
2305
  graphNodes: this.graph.nodes.length,
1191
2306
  graphEdges: this.graph.edges.length,
2307
+ /**
2308
+ * 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
2309
+ *
2310
+ * 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
2311
+ */
2312
+ migrationProblems: this.migrationProblems,
1192
2313
  buildVersion: Number(buildManifest.version ?? 0),
1193
2314
  builtAt: buildManifest.builtAt,
1194
2315
  trackedCards: Object.keys(buildManifest.cardHashes ?? {}).length,
@@ -1211,6 +2332,109 @@ export class KnowledgeRuntime {
1211
2332
  embeddingError: this.vectorError,
1212
2333
  };
1213
2334
  }
2335
+ /**
2336
+ * 批量写入来源 + **一次**编译(0045)。
2337
+ *
2338
+ * ## 为什么值得单独一个方法,而不是让调用方循环 `add_source` + `rebuild_knowledge`
2339
+ *
2340
+ * 三件事只有放在一起才对:
2341
+ *
2342
+ * 1. **一次审批。** Agent 侧写入都要过 DSH 的审批闸门(`tools/pre-execute` 里那几个
2343
+ * 写工具)。十份文档 = 十次弹窗,而人真正要同意的是「把这十份导进去」这一件事。
2344
+ * 2. **编译只跑一次。** 每写一份就 `rebuild` 一次,第 10 份文档要付 10 次抽取的钱,
2345
+ * 而前 9 次的结果当场就被下一次覆盖。
2346
+ * 3. **失败要局部化。** 第 7 份文档格式不对,不该让前 6 份白写、后 3 份不写。
2347
+ * 所以逐条记录成败,最后一次性汇报 —— 成功的已经在库里,失败的带着原因回来,
2348
+ * 调用方可以只重试那几条。
2349
+ *
2350
+ * ## 为什么全部失败时返回 failure 而不是带 warning 的 success
2351
+ *
2352
+ * 「10 份全没进去」与「10 份都进去了,其中 1 份顺带报了个警告」是两件完全不同的事,
2353
+ * 用同一种信封表达,模型会把前一种当成后一种继续往下走。所以一份都没写成时如实失败,
2354
+ * 每条的原因仍然在 `sources[].message` 里。
2355
+ */
2356
+ async importSources(input) {
2357
+ const empty = { written: [], failed: [], attempted: 0, compile: null };
2358
+ const items = Array.isArray(input.sources) ? input.sources : [];
2359
+ if (items.length === 0)
2360
+ return failure(empty, 'SOURCES_REQUIRED', 'sources 不能为空:至少要给一份来源', true);
2361
+ /**
2362
+ * 上限只防「一次调用把整台机器按住」:编译是逐份来源串行抽实体的,50 份已经要跑很久。
2363
+ * 它**不是**产品配额 —— 超过就分批调,每批各自编译。
2364
+ */
2365
+ if (items.length > MAX_IMPORT_SOURCES) {
2366
+ return failure(empty, 'IMPORT_TOO_MANY', `一次最多导入 ${MAX_IMPORT_SOURCES} 份来源,收到 ${items.length} 份;请分批调用`, true);
2367
+ }
2368
+ const written = [];
2369
+ const failed = [];
2370
+ const warnings = [];
2371
+ for (const [index, item] of items.entries()) {
2372
+ const title = item?.title?.trim() || undefined;
2373
+ const result = await this.addSource({
2374
+ sourceType: item?.sourceType,
2375
+ ...(title ? { title } : {}),
2376
+ content: item?.content,
2377
+ ...(item?.externalId ? { externalId: item.externalId } : {}),
2378
+ ...(item?.metadata ? { metadata: item.metadata } : {}),
2379
+ ...(item?.allowDuplicateTitle === true ? { allowDuplicateTitle: true } : {}),
2380
+ ...(input.lineage ? { lineage: input.lineage } : {}),
2381
+ });
2382
+ if (result.ok === false) {
2383
+ const message = result.error?.message ?? '写入失败';
2384
+ failed.push({ index, ...(title ? { title } : {}), code: result.error?.code ?? 'SOURCE_WRITE_FAILED', message });
2385
+ // 逐条失败也要出现在 warnings 里:`ImportSourcesResult.failed` 是结构化账本,
2386
+ // 而 warnings 是不管谁在读都必须看见的那一句。
2387
+ warnings.push(`第 ${index + 1} 份来源${title ? `「${title}」` : ''}没有写入:${message}`);
2388
+ continue;
2389
+ }
2390
+ written.push({
2391
+ index,
2392
+ sourceId: result.data.sourceId,
2393
+ ...(title ? { title } : {}),
2394
+ path: result.data.path,
2395
+ bytes: typeof item?.content === 'string' ? item.content.length : 0,
2396
+ });
2397
+ }
2398
+ if (written.length === 0) {
2399
+ return failure({ ...empty, failed, attempted: items.length }, 'IMPORT_ALL_FAILED', `${items.length} 份来源全部写入失败,没有任何东西进入知识库`, true, failed[0] ? `第一份的原因:${failed[0].message}` : undefined);
2400
+ }
2401
+ /**
2402
+ * 编译。**只要有一份写成就编**:成功的那几份已经是库里的知识,
2403
+ * 「因为同批里有一份失败就不编」等于把已经进来的知识又挡在门外一次。
2404
+ */
2405
+ let compile = null;
2406
+ if (input.compile !== false) {
2407
+ const rebuilt = await this.rebuild('wiki', input.lineage);
2408
+ if (rebuilt.ok === false) {
2409
+ warnings.push(`来源已写入,但编译失败:${rebuilt.error?.message ?? '未知原因'}`);
2410
+ }
2411
+ else {
2412
+ const data = rebuilt.data;
2413
+ compile = {
2414
+ cardsCreated: data.cardsCreated,
2415
+ cardsUpdated: data.cardsUpdated,
2416
+ cardsReused: data.cardsReused,
2417
+ notesReused: data.notesReused,
2418
+ notesRecompiled: data.notesRecompiled,
2419
+ sourcesExtracted: data.sourcesExtracted,
2420
+ sourcesSkipped: data.sourcesSkipped,
2421
+ cardsRemoved: data.cardsRemoved,
2422
+ embedding: data.embedding,
2423
+ version: data.version,
2424
+ };
2425
+ warnings.push(...(rebuilt.warnings ?? []));
2426
+ }
2427
+ }
2428
+ return success({ written, failed, attempted: items.length, compile }, warnings);
2429
+ }
2430
+ /**
2431
+ * 写入一份 SourceRaw。
2432
+ *
2433
+ * `provenance` 是 OKF v0.2 那一组(`description` / `resource` / `generated` /
2434
+ * `sources[]` / `tags`),它们**写成真正的顶层键**而不是塞进 `metadata` 那个 JSON
2435
+ * 字符串里(0048):元数据要能被人读、被 `grep` 到、被外部工具按公开规范读走。
2436
+ * `metadata` 只留类型专属的扩展键。
2437
+ */
1214
2438
  async addSource(input) {
1215
2439
  if (!this.config.allowSourceTruth)
1216
2440
  return failure({ sourceId: '', path: '' }, 'SOURCE_TRUTH_DISABLED', '当前配置禁止写入 SourceTruth', false);
@@ -1236,7 +2460,33 @@ export class KnowledgeRuntime {
1236
2460
  const sourceId = input.sourceId?.trim() || `${input.sourceType}:${hash(input.externalId ?? input.content)}`;
1237
2461
  if (!SAFE_ID.test(sourceId))
1238
2462
  return failure({ sourceId: '', path: '' }, 'SOURCE_ID_INVALID', 'source_id 含有非法字符', false);
1239
- const path = this.sourcePath(sourceId, input.sourceType);
2463
+ const title = input.title?.trim() || sourceId;
2464
+ /**
2465
+ * 重名(0051):**默认拒收**。
2466
+ *
2467
+ * 文件名就是标题,所以「两份来源同一个标题」不再是看不见的事 —— 它们会争同一个
2468
+ * 路径。判据落在**目标路径**上而不是标题字符串上:标题里被换掉的非法字符
2469
+ * (`A/B` 与 `A-B`)也会撞路径,按字符串比会漏。
2470
+ *
2471
+ * 三条出路,覆盖三种真实情形:
2472
+ * 1. 同一个 `sourceId`(同一份东西重复导入)→ 目标是它自己现在的位置,不冲突;
2473
+ * 2. 另一个 `sourceId` 占着这个路径 → **拒收**,报错里带上「被谁占着」,让人改标题;
2474
+ * 3. 调用方明确要留两份同标题材料 → 传 `allowDuplicateTitle`,落成
2475
+ * `<标题>-<内容哈希前 6 位>.md`,并回一条 warning。
2476
+ *
2477
+ * 为什么不静默加后缀:那样人永远不知道库里有两份「复盘」—— 而「有两份」这件事本身
2478
+ * 就是要人处理的信号。也不静默覆盖:那等于直接丢一份知识。
2479
+ */
2480
+ let path = this.sourcePath(sourceId, input.sourceType, title);
2481
+ let duplicateNote;
2482
+ const owner = await this.sourceIdAtPath(path);
2483
+ if (owner !== undefined && owner !== sourceId) {
2484
+ if (input.allowDuplicateTitle !== true) {
2485
+ return failure({ sourceId: '', path }, 'SOURCE_TITLE_DUPLICATED', `标题「${title}」已被来源 ${owner} 占用(它写在这个路径上),改一个标题再来`, true, '换一个有区分度的标题(例如带上日期或版本),或显式传 allow_duplicate_title: true 让两份并存。');
2486
+ }
2487
+ path = this.safePath(SOURCE_TRUTH, input.sourceType, `${sourceFileBase(title, sourceId)}-${hash(sourceId).slice(0, 6)}.md`);
2488
+ duplicateNote = `标题「${title}」与来源 ${owner} 重复,已按 allow_duplicate_title 落成 ${basename(path)};建议改成有区分度的标题。`;
2489
+ }
1240
2490
  /**
1241
2491
  * 这一份文档**可能已经以另一个分类存在**。0030 起分类是目录名,所以
1242
2492
  * 「改一个来源的分类」不再是原地覆盖,而是换目录 —— 必须先解析它现在落在哪,
@@ -1266,8 +2516,18 @@ export class KnowledgeRuntime {
1266
2516
  await this.snapshotSource(sourceId, previousRaw, previous.content);
1267
2517
  }
1268
2518
  }
1269
- const record = { sourceId, sourceType: input.sourceType, title: input.title?.trim() || sourceId, content: input.content, ...(input.externalId ? { externalId: input.externalId } : {}), metadata: input.metadata ?? {}, ...(input.lineage ? { lineage: input.lineage } : {}), createdAt, updatedAt: new Date().toISOString() };
1270
- await writeTextAtomic(path, markdown({ source_id: sourceId, source_type: input.sourceType, title: record.title, created_at: record.createdAt, updated_at: record.updatedAt, external_id: input.externalId ?? '', metadata: record.metadata, ...(record.lineage ? { lineage: record.lineage } : {}) }, record.content));
2519
+ const record = { sourceId, sourceType: input.sourceType, title, content: input.content, ...(input.externalId ? { externalId: input.externalId } : {}), metadata: input.metadata ?? {}, ...(input.lineage ? { lineage: input.lineage } : {}), createdAt, updatedAt: new Date().toISOString() };
2520
+ const frontMatter = sourceFrontMatter(record, input.provenance);
2521
+ await writeTextAtomic(path, markdown(frontMatter, record.content));
2522
+ /**
2523
+ * 分类声明的必填元数据缺了就**如实报出来**,不拒收(0048)。
2524
+ *
2525
+ * 与「缺必填小节的卡片照常入库」同一条纪律:元数据缺失是**信息不全**,不是
2526
+ * 「这份知识不存在」。拒收的代价是知识直接丢了,而报出来的代价只是界面上多一句
2527
+ * 提示 —— 后者可以补,前者补不回来。
2528
+ */
2529
+ const declared = this.sourceTypes().find(def => def.id === record.sourceType)?.metadataFields;
2530
+ const missing = missingMetadataFields(declared, frontMatter);
1271
2531
  // 换了分类的那一次:新文件已经落好,旧目录里那份才删 —— 顺序反过来的话,写入
1272
2532
  // 失败就同时丢了旧的那一份。
1273
2533
  if (previousPath !== undefined && previousPath !== path) {
@@ -1276,10 +2536,26 @@ export class KnowledgeRuntime {
1276
2536
  }
1277
2537
  catch { /* already moved or removed */ }
1278
2538
  }
1279
- this.sourceIndex?.set(slug(sourceId), path);
2539
+ /**
2540
+ * 索引与归属表都要跟着**新路径**走(0051)。
2541
+ *
2542
+ * 归属表(`path → sourceId`)是重名判定的依据,漏更新会留下一个幽灵归属:改了标题
2543
+ * 之后,旧路径上明明没有文件,另一个来源想用那个标题却会被判成「重名」而拒收。
2544
+ */
2545
+ if (this.sourceIndex) {
2546
+ if (previousPath !== undefined && previousPath !== path) {
2547
+ this.sourceIndex.delete(previousPath);
2548
+ this.sourcePathOwner?.delete(previousPath);
2549
+ }
2550
+ this.sourceIndex.set(slug(sourceId), path);
2551
+ this.sourcePathOwner?.set(path, sourceId);
2552
+ }
1280
2553
  // 证据片段取自 SourceRaw 正文,写入后那份缓存必须失效(否则上下文会引用旧原文)。
1281
2554
  this.sourceCorpusCache = undefined;
1282
- return success({ sourceId, path, ...(record.lineage ? { lineage: record.lineage } : {}) });
2555
+ return success({ sourceId, path, ...(record.lineage ? { lineage: record.lineage } : {}) }, [
2556
+ ...(duplicateNote ? [duplicateNote] : []),
2557
+ ...missing.map(field => `来源缺少「${field.label}」(${field.key}):分类「${record.sourceType}」声明它是必填的。`),
2558
+ ]);
1283
2559
  }
1284
2560
  /** Directory holding one document's version snapshots. */
1285
2561
  versionDir(sourceId) { return this.memoryPath(SOURCE_VERSIONS, slug(sourceId)); }
@@ -1305,14 +2581,30 @@ export class KnowledgeRuntime {
1305
2581
  }
1306
2582
  async loadSources() {
1307
2583
  const result = [];
2584
+ /**
2585
+ * 索引在**同一次遍历**里建起来(0051)。
2586
+ *
2587
+ * 文件名从 `source_id` 改成标题之后,「按 id 找文件」不再能从文件名推出来 ——
2588
+ * 权威身份在 front matter 里。与其再扫一遍磁盘、再读一遍文件,不如在唯一一次
2589
+ * 遍历里同时建两张表:`sourceId → path`(`sourcePathOf` 用)与 `path → sourceId`
2590
+ * (`addSource` 判断目标路径被谁占着)。
2591
+ */
2592
+ const byId = new Map();
2593
+ const byPath = new Map();
1308
2594
  for (const path of await filesUnder(this.directory(SOURCE_TRUTH))) {
1309
2595
  const parsed = parseMarkdown(await readFile(path, 'utf8'));
1310
2596
  const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'));
1311
- const sourceType = String(parsed.frontmatter.source_type ?? 'document');
2597
+ byId.set(slug(sourceId), path);
2598
+ // 文件名也可能是旧布局的 id(迁移中间态):两个键都登记,谁在都不算丢。
2599
+ byId.set(basename(path, '.md'), path);
2600
+ byPath.set(path, sourceId);
2601
+ const sourceType = (sourceTypeOf(parsed.frontmatter) || 'document');
1312
2602
  // 0013:未知分类**不再静默丢弃**。分类只是元数据,内容仍然可召回;
1313
2603
  // 丢掉文档才是真正的数据损失。未知分类在列表里以「未知分类」呈现。
1314
2604
  result.push({ sourceId, sourceType, title: String(parsed.frontmatter.title ?? sourceId), content: parsed.content, externalId: typeof parsed.frontmatter.external_id === 'string' && parsed.frontmatter.external_id ? parsed.frontmatter.external_id : undefined, metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata : {}, ...(parseLineage(parsed.frontmatter.lineage) ? { lineage: parseLineage(parsed.frontmatter.lineage) } : {}), createdAt: String(parsed.frontmatter.created_at ?? ''), updatedAt: String(parsed.frontmatter.updated_at ?? '') });
1315
2605
  }
2606
+ this.sourceIndex = byId;
2607
+ this.sourcePathOwner = byPath;
1316
2608
  return result;
1317
2609
  }
1318
2610
  // ------------------------------------------------- 文档(SourceRaw)列表 / 详情
@@ -1323,18 +2615,26 @@ export class KnowledgeRuntime {
1323
2615
  * (`addSource` 的 `sourceTypeGate`),所以它一定匹配 `SOURCE_TYPE_ID_PATTERN`,
1324
2616
  * 不会引入路径穿越。
1325
2617
  */
1326
- sourcePath(sourceId, sourceType) {
1327
- return this.safePath(SOURCE_TRUTH, sourceType, `${slug(sourceId)}.md`);
2618
+ sourcePath(sourceId, sourceType, title) {
2619
+ return this.safePath(SOURCE_TRUTH, sourceType, `${sourceFileBase(title, sourceId)}.md`);
1328
2620
  }
1329
- /** 递归扫 SourceRaw 段,建 `docSlug → path` 索引;两种布局都覆盖。 */
2621
+ /**
2622
+ * `docSlug → path` 索引;两种布局都覆盖。
2623
+ *
2624
+ * 0051 起它**由 `loadSources` 建**(那一次遍历本来就要读每个文件的 front matter,
2625
+ * 而文件名不再是身份,只能从 front matter 里拿)。这里只是把「还没建」补上。
2626
+ */
1330
2627
  async sourceIndexMap() {
1331
2628
  if (this.sourceIndex)
1332
2629
  return this.sourceIndex;
1333
- const index = new Map();
1334
- for (const path of await filesUnder(this.directory(SOURCE_TRUTH)))
1335
- index.set(basename(path, '.md'), path);
1336
- this.sourceIndex = index;
1337
- return index;
2630
+ await this.loadSources();
2631
+ return this.sourceIndex ?? new Map();
2632
+ }
2633
+ /** 这个路径现在被哪一份来源占着(0051:重名判定的依据)。 */
2634
+ async sourceIdAtPath(path) {
2635
+ if (!this.sourcePathOwner)
2636
+ await this.loadSources();
2637
+ return this.sourcePathOwner?.get(path);
1338
2638
  }
1339
2639
  /** 已知文档 id 的落盘位置;不存在时 `undefined`(调用方各自决定怎么报错)。 */
1340
2640
  async sourcePathOf(docId) {
@@ -1343,13 +2643,12 @@ export class KnowledgeRuntime {
1343
2643
  /**
1344
2644
  * Assets a document really has on disk.
1345
2645
  *
1346
- * Preferred source of truth is `metadata.assets` (knowledge-root-relative
1347
- * paths written by `convert/ingest.ts`). Documents written by hand fall back
1348
- * to parsing local markdown image links. A link is resolved **knowledge-root
1349
- * first, then relative to the document's own directory**: the convert ingest
1350
- * seam re-bases assets to `_assets/<bucket>/<name>` (root-relative), while
1351
- * hand-written docs typically use a sibling path. Only existing files are
1352
- * returned, so `assetCount` is never an over-count.
2646
+ * Preferred source of truth is `metadata.assets` (paths written by
2647
+ * `convert/ingest.ts`). Documents written by hand fall back to parsing local
2648
+ * markdown image links. Both go through {@link assetRefCandidates}, so a
2649
+ * document's asset list and a card's `imagePaths` answer the same question the
2650
+ * same way. Only existing files are returned, so `assetCount` is never an
2651
+ * over-count.
1353
2652
  */
1354
2653
  async assetsOf(path, content, metadata) {
1355
2654
  const refs = new Set();
@@ -1364,7 +2663,7 @@ export class KnowledgeRuntime {
1364
2663
  const assets = [];
1365
2664
  const seen = new Set();
1366
2665
  for (const ref of refs) {
1367
- const candidates = isAbsolute(ref) ? [resolve(ref)] : [resolve(root, ref), resolve(dirname(path), ref)];
2666
+ const candidates = assetRefCandidates(ref, dirname(path), root);
1368
2667
  for (const absolute of candidates) {
1369
2668
  if (!insideRoot(absolute))
1370
2669
  continue;
@@ -1381,15 +2680,62 @@ export class KnowledgeRuntime {
1381
2680
  if (seen.has(rel))
1382
2681
  break;
1383
2682
  seen.add(rel);
1384
- const encoded = rel.split('/').map(segment => encodeURIComponent(segment)).join('/');
1385
- assets.push({ rel, bytes: details.size, url: `/knowledge/assets/${encodeURIComponent(this.baseId)}/${encoded}` });
2683
+ assets.push({ rel, bytes: details.size, url: this.assetUrlOf(rel) });
1386
2684
  break;
1387
2685
  }
1388
2686
  }
1389
2687
  return assets.sort((left, right) => left.rel.localeCompare(right.rel));
1390
2688
  }
1391
- /** Parse one on-disk SourceRaw file into the detail projection. */
1392
- async readDocumentFile(path) {
2689
+ /**
2690
+ * 图片素材的浏览器地址。面板与整页都用它加载图,所以只能有一个拼法。
2691
+ */
2692
+ assetUrlOf(rel) {
2693
+ const encoded = rel.split('/').map(segment => encodeURIComponent(segment)).join('/');
2694
+ return `/knowledge/assets/${encodeURIComponent(this.baseId)}/${encoded}`;
2695
+ }
2696
+ /**
2697
+ * 一张 wiki 卡片的图片素材(0047)。
2698
+ *
2699
+ * 卡片详情原先只回 `imagePaths` —— **磁盘绝对路径**。客户端拿它加载不了图(浏览器
2700
+ * 只认 URL),所以预览抽屉里卡片的图一直不显示;而 SourceRaw 那边早就有 `assets`
2701
+ * (`rel` + `url` + `bytes`)。这里把同一种东西补齐,两个预览走同一条路。
2702
+ *
2703
+ * 只认知识库根目录以内的文件:卡片正文里可能留着别的绝对路径(人手工写的),
2704
+ * 那种不该变成一个可下载的 URL。
2705
+ */
2706
+ async assetsOfCard(card) {
2707
+ const root = this.rootDir;
2708
+ const insideRoot = (value) => value === root || value.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
2709
+ const assets = [];
2710
+ const seen = new Set();
2711
+ for (const absolute of card.imagePaths ?? []) {
2712
+ if (!isAbsolute(absolute) || !insideRoot(absolute))
2713
+ continue;
2714
+ let details;
2715
+ try {
2716
+ details = await stat(absolute);
2717
+ }
2718
+ catch {
2719
+ continue;
2720
+ }
2721
+ if (!details.isFile())
2722
+ continue;
2723
+ const rel = relative(root, absolute).split(sep).join('/');
2724
+ if (seen.has(rel))
2725
+ continue;
2726
+ seen.add(rel);
2727
+ assets.push({ rel, bytes: details.size, url: this.assetUrlOf(rel) });
2728
+ }
2729
+ return assets.sort((left, right) => left.rel.localeCompare(right.rel));
2730
+ }
2731
+ /**
2732
+ * Parse one on-disk SourceRaw file into the detail projection.
2733
+ *
2734
+ * `previewChars` 给了就把正文截到那么长,并把**总长度**如实放进 `contentChars`
2735
+ * (见 `SOURCE_PREVIEW_CHARS` 的理由)。不给就是不截断 —— 那是「我确实要全文」的
2736
+ * 调用方,目前只有 `readDocumentBody()` 之外的内部路径会这么用。
2737
+ */
2738
+ async readDocumentFile(path, previewChars) {
1393
2739
  let raw;
1394
2740
  try {
1395
2741
  raw = await readFile(path, 'utf8');
@@ -1398,8 +2744,10 @@ export class KnowledgeRuntime {
1398
2744
  return undefined;
1399
2745
  }
1400
2746
  const parsed = parseMarkdown(raw);
2747
+ const contentChars = parsed.content.length;
2748
+ const content = previewChars !== undefined && previewChars < contentChars ? parsed.content.slice(0, previewChars) : parsed.content;
1401
2749
  const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'));
1402
- const sourceType = String(parsed.frontmatter.source_type ?? 'document');
2750
+ const sourceType = sourceTypeOf(parsed.frontmatter) || 'document';
1403
2751
  // 未知分类照样投影出来(0013);前端按 sourceTypeKnown 渲染「未知分类」徽标。
1404
2752
  const sourceTypeKnown = this.sourceTypes().some(def => def.id === sourceType);
1405
2753
  const metadata = plainRecord(parsed.frontmatter.metadata);
@@ -1433,7 +2781,9 @@ export class KnowledgeRuntime {
1433
2781
  versionCount: (await this.versionIds(sourceId)).length + 1,
1434
2782
  relativePath: relative(this.rootDir, path).split(sep).join('/'),
1435
2783
  ext,
1436
- content: parsed.content,
2784
+ content,
2785
+ contentChars,
2786
+ contentTruncated: content.length < contentChars,
1437
2787
  frontmatter: parsed.frontmatter,
1438
2788
  assets,
1439
2789
  ...(lineage ? { lineage: lineage } : {}),
@@ -1457,9 +2807,32 @@ export class KnowledgeRuntime {
1457
2807
  return documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.docId.localeCompare(right.docId));
1458
2808
  }
1459
2809
  /** 文档详情:正文、frontmatter、资产与 lineage。 */
1460
- async readDocument(docId) {
2810
+ /**
2811
+ * 一份文档的详情投影,**正文默认按 `SOURCE_PREVIEW_CHARS` 封顶**。
2812
+ *
2813
+ * 这是明细接口(抽屉里的预览)读的那一份,所以默认就是「够预览」而不是「全文」:
2814
+ * 全文由 `readDocumentBody()` 提供服务,而它只服务独立整页的分页读取。
2815
+ */
2816
+ async readDocument(docId, previewChars = SOURCE_PREVIEW_CHARS) {
2817
+ const path = await this.sourcePathOf(docId);
2818
+ return path ? this.readDocumentFile(path, previewChars) : undefined;
2819
+ }
2820
+ /**
2821
+ * 一份文档的**完整正文**(不含 frontmatter),给独立整页分页取用。
2822
+ *
2823
+ * 每次调用都重读一遍盘:独立整页是「用户滚到哪儿取哪一页」,一次请求一段文本,
2824
+ * 谁都不该拿到整份文档 —— 这正是抽屉那一版的问题。缓存留到真的量出来是瓶颈再加。
2825
+ */
2826
+ async readDocumentBody(docId) {
1461
2827
  const path = await this.sourcePathOf(docId);
1462
- return path ? this.readDocumentFile(path) : undefined;
2828
+ if (path === undefined)
2829
+ return undefined;
2830
+ try {
2831
+ return parseMarkdown(await readFile(path, 'utf8')).content;
2832
+ }
2833
+ catch {
2834
+ return undefined;
2835
+ }
1463
2836
  }
1464
2837
  /**
1465
2838
  * 版本历史,新→旧。
@@ -1520,7 +2893,7 @@ export class KnowledgeRuntime {
1520
2893
  const path = await this.sourcePathOf(docId);
1521
2894
  if (path === undefined)
1522
2895
  return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true);
1523
- const head = await this.readDocumentFile(path);
2896
+ const head = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS);
1524
2897
  if (!head)
1525
2898
  return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true);
1526
2899
  const target = typeof versionId === 'string' ? versionId.trim() : '';
@@ -1533,7 +2906,7 @@ export class KnowledgeRuntime {
1533
2906
  return failure(head, 'VERSION_NOT_FOUND', `版本不存在: ${target}`, true);
1534
2907
  const parsed = parseMarkdown(await readFile(snapshotPath, 'utf8'));
1535
2908
  const restored = await this.addSource({
1536
- sourceType: (String(parsed.frontmatter.source_type ?? 'document')),
2909
+ sourceType: (sourceTypeOf(parsed.frontmatter) || 'document'),
1537
2910
  title: typeof parsed.frontmatter.title === 'string' ? parsed.frontmatter.title : undefined,
1538
2911
  content: parsed.content,
1539
2912
  externalId: typeof parsed.frontmatter.external_id === 'string' && parsed.frontmatter.external_id ? parsed.frontmatter.external_id : undefined,
@@ -1543,7 +2916,13 @@ export class KnowledgeRuntime {
1543
2916
  });
1544
2917
  if (!restored.ok)
1545
2918
  return failure(head, restored.error?.code ?? 'REVERT_FAILED', restored.error?.message ?? '回滚写入失败', restored.error?.retryable ?? false, restored.error?.suggestion);
1546
- const document = await this.readDocumentFile(path);
2919
+ /**
2920
+ * 读的是**回滚写完之后**的路径,不是开头那个。
2921
+ *
2922
+ * 0051 起文件名跟着标题走:回滚到一版标题不同的历史,文件会被改名 —— 开头取到的
2923
+ * 路径当场过期,再按它读就是「回滚后文档不可读」,而文件其实好好的。
2924
+ */
2925
+ const document = await this.readDocumentFile(restored.data.path, SOURCE_PREVIEW_CHARS);
1547
2926
  if (!document)
1548
2927
  return failure(head, 'DOCUMENT_NOT_FOUND', `回滚后文档不可读: ${docId}`, true);
1549
2928
  await this.audit('source_truth.revert', { docId, versionId: target });
@@ -1575,7 +2954,18 @@ export class KnowledgeRuntime {
1575
2954
  const status = input.status ?? 'unreviewed';
1576
2955
  if (!STATUS.includes(status))
1577
2956
  return failure({ entityId: input.entityId, path: '' }, 'STATUS_INVALID', 'status 不合法', false);
1578
- // 0024:类型闸门 + 三层校验(format / reference / semantic)。
2957
+ /**
2958
+ * 0044:缺信息的卡片**照常入库**。
2959
+ *
2960
+ * 0040 时期这里有两道拒绝:类型不在注册表里直接拒、三层校验不通过时 `strict` 模式拒。
2961
+ * 两条都撤掉了 —— 编译的价值在于「记下来,后面有涉及到的文档再慢慢补」,而拒绝
2962
+ * 恰好把这个机会丢掉了。缺什么由 `schema_issues` 如实标出,人在 review 时补。
2963
+ *
2964
+ * 唯一保留的拦截是**未知类型**:没有类型定义就没有小节骨架,连「缺什么」都说不出来。
2965
+ * 编译侧在调用这里之前已经把未登记的类型**退回兜底类型**(`compileSource`),
2966
+ * 所以这条路只在别的调用方拿着一个没登记的类型直接写盘时才会走到 —— 那时拒绝是对的,
2967
+ * 而提示会给出可用类型的清单。
2968
+ */
1579
2969
  const gate = this.cardSchemaGate({
1580
2970
  entityType: input.entityType, title: input.title.trim(), content: input.content,
1581
2971
  sourceRefs: input.sourceRefs, metadata: input.metadata ?? {}, ...(input.owner ? { owner: input.owner } : {}), ...(input.summary ? { summary: input.summary } : {}),
@@ -1584,9 +2974,6 @@ export class KnowledgeRuntime {
1584
2974
  return failure({ entityId: input.entityId, path: '' }, gate.error.code, gate.error.message, gate.error.retryable, gate.error.suggestion);
1585
2975
  const { issues } = gate;
1586
2976
  const warnings = issues.map(issue => `${issue.level}: ${issue.message}`);
1587
- if (issues.length > 0 && this.wikiSchema().mode === 'strict') {
1588
- return failure({ entityId: input.entityId, path: '' }, 'CARD_SCHEMA_INVALID', `卡片不符合「${gate.type.label}」的 schema:${issues.map(issue => issue.message).join(';')}`, false, '补齐必填小节 / 字段后重试;或在「知识库设置 → Wiki 类型」里把生效方式改为 report(只标记不拦截)。');
1589
- }
1590
2977
  await this.ensureLayout();
1591
2978
  const path = this.safePath(WIKI, input.entityType, `${slug(input.entityId)}.md`);
1592
2979
  await mkdir(dirname(path), { recursive: true });
@@ -1596,6 +2983,8 @@ export class KnowledgeRuntime {
1596
2983
  // 「审核一次 = 多一个版本」,版本列表会被状态流转灌满。
1597
2984
  let createdAt = updatedAt;
1598
2985
  let previousRaw;
2986
+ let previousAnnotations;
2987
+ let previousReview;
1599
2988
  try {
1600
2989
  previousRaw = await readFile(path, 'utf8');
1601
2990
  }
@@ -1603,14 +2992,62 @@ export class KnowledgeRuntime {
1603
2992
  if (previousRaw !== undefined) {
1604
2993
  const previous = parseMarkdown(previousRaw);
1605
2994
  createdAt = String(previous.frontmatter.created_at ?? createdAt);
2995
+ previousAnnotations = parseAnnotations(previous.frontmatter.annotations);
2996
+ previousReview = parseReview(previous.frontmatter.review);
1606
2997
  if (previous.content.trim() !== input.content.trim()) {
2998
+ if (input.origin === 'manual') {
2999
+ return failure({ entityId: input.entityId, path }, 'WIKI_CARD_IMMUTABLE', `wiki 正文不可手工修改: ${input.entityId}`, false, '正文由编译产出(新文档会追加进来)。要指出问题请加备注:annotate_wiki_card 或面板上的「备注 / 异常点」。');
3000
+ }
1607
3001
  await this.snapshotCard(input.entityType, input.entityId, previousRaw, previous.content);
1608
3002
  }
1609
3003
  }
1610
3004
  const provenance = [...(input.provenance ?? []), ...(input.lineage ? [{ ...input.lineage, role: 'created', refType: 'knowledge_card', refId: input.entityId }] : [])];
1611
- // report 模式:把问题留在卡片上,界面据此显示"待补",而不是静默通过。
1612
- const metadata = { ...(input.metadata ?? {}), ...(issues.length > 0 ? { schema_issues: issues } : {}) };
1613
- await writeTextAtomic(path, markdown({ entity_id: input.entityId, entity_type: input.entityType, title: input.title.trim(), status, summary: input.summary ?? '', ...(input.owner ? { owner: input.owner } : {}), source_refs: input.sourceRefs, created_at: createdAt, updated_at: updatedAt, metadata, ...(provenance.length > 0 ? { provenance } : {}) }, input.content));
3005
+ /**
3006
+ * report 模式:把问题留在卡片上,界面据此显示"待补",而不是静默通过。
3007
+ *
3008
+ * `schema_issues` **一律由这里重算**(先摘掉入参里可能带着的旧值)。入参 metadata
3009
+ * 常常是「从磁盘上读回来的那一份」,里面就有上一次的 `schema_issues` —— 只做覆盖
3010
+ * 不摘旧值,一张补好了的卡片会一直挂着旧问题;而问题清空时那个键根本没被覆盖,
3011
+ * 于是「补好了」这件事在界面上永远不生效。
3012
+ */
3013
+ const { schema_issues: _staleIssues, ...inputMetadata } = input.metadata ?? {};
3014
+ void _staleIssues;
3015
+ const allIssues = [...issues, ...(input.extraIssues ?? [])];
3016
+ const metadata = { ...inputMetadata, ...(allIssues.length > 0 ? { schema_issues: allIssues } : {}) };
3017
+ // 备注与 review **默认继承磁盘上已有的**:不传不等于清空。见入参注释。
3018
+ const annotations = input.annotations ?? previousAnnotations ?? [];
3019
+ const review = input.review ?? previousReview;
3020
+ await writeTextAtomic(path, markdown({
3021
+ entity_id: input.entityId, entity_type: input.entityType, title: input.title.trim(), status, summary: input.summary ?? '',
3022
+ ...(input.owner ? { owner: input.owner } : {}), source_refs: input.sourceRefs, created_at: createdAt, updated_at: updatedAt, metadata,
3023
+ ...(annotations.length > 0 ? { annotations } : {}),
3024
+ ...(review ? { review } : {}),
3025
+ ...(provenance.length > 0 ? { provenance } : {}),
3026
+ }, input.content));
3027
+ /**
3028
+ * 同步内存里的卡片清单。
3029
+ *
3030
+ * `this.cards` 是召回组装 `context[].wiki` 时读的那一份。写盘不同步它,会出现
3031
+ * 「刚加的备注,在召回结果里看不到」—— 而人正是在召回结果里核对备注的。
3032
+ *
3033
+ * 重新 `loadCards()` 一遍是 O(整个 wiki),而一次编译会连着写几十张卡;所以这里
3034
+ * 就地替换那一条。缓存为空时什么都不做:那表示还没人读过卡片清单,下次读会拿到全量。
3035
+ */
3036
+ if (this.cards.length > 0) {
3037
+ const next = {
3038
+ entityId: input.entityId, entityType: input.entityType, title: input.title.trim(), status,
3039
+ ...(input.summary ? { summary: input.summary } : {}),
3040
+ content: input.content, sourceRefs: input.sourceRefs, metadata, updatedAt,
3041
+ ...(annotations.length > 0 ? { annotations } : {}),
3042
+ ...(review ? { review } : {}),
3043
+ ...(provenance.length > 0 ? { provenance } : {}),
3044
+ };
3045
+ const index = this.cards.findIndex(card => card.entityId === input.entityId && card.entityType === input.entityType);
3046
+ if (index >= 0)
3047
+ this.cards[index] = next;
3048
+ else
3049
+ this.cards.push(next);
3050
+ }
1614
3051
  return success({ entityId: input.entityId, path, ...(input.lineage ? { lineage: input.lineage } : {}) }, warnings);
1615
3052
  }
1616
3053
  /** Directory holding one wiki card's version snapshots. */
@@ -1727,7 +3164,21 @@ export class KnowledgeRuntime {
1727
3164
  // 0024:未知类型**不再静默丢弃**。类型只是元数据,丢掉卡片才是真正的数据损失;
1728
3165
  // 未知类型在界面上显示成「未登记类型」,由人决定是补登记还是改卡片。
1729
3166
  const refs = Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : [];
1730
- result.push({ entityId, entityType, title: String(parsed.frontmatter.title ?? entityId), status: STATUS.includes(String(parsed.frontmatter.status)) ? String(parsed.frontmatter.status) : 'draft', summary: typeof parsed.frontmatter.summary === 'string' ? parsed.frontmatter.summary : undefined, content: parsed.content, sourceRefs: refs, ...(parseProvenance(parsed.frontmatter.provenance) ? { provenance: parseProvenance(parsed.frontmatter.provenance) } : {}), metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata : {}, updatedAt: String(parsed.frontmatter.updated_at ?? ''), imagePaths: markdownImagePaths(parsed.content, dirname(path)) });
3167
+ const annotations = parseAnnotations(parsed.frontmatter.annotations);
3168
+ const review = parseReview(parsed.frontmatter.review);
3169
+ result.push({
3170
+ entityId, entityType, title: String(parsed.frontmatter.title ?? entityId),
3171
+ status: STATUS.includes(String(parsed.frontmatter.status)) ? String(parsed.frontmatter.status) : 'draft',
3172
+ summary: typeof parsed.frontmatter.summary === 'string' ? parsed.frontmatter.summary : undefined,
3173
+ content: parsed.content, sourceRefs: refs,
3174
+ ...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
3175
+ ...(parseProvenance(parsed.frontmatter.provenance) ? { provenance: parseProvenance(parsed.frontmatter.provenance) } : {}),
3176
+ metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata : {},
3177
+ updatedAt: String(parsed.frontmatter.updated_at ?? ''),
3178
+ imagePaths: markdownImagePaths(parsed.content, dirname(path), this.rootDir),
3179
+ ...(annotations.length > 0 ? { annotations } : {}),
3180
+ ...(review ? { review } : {}),
3181
+ });
1731
3182
  }
1732
3183
  return result;
1733
3184
  }
@@ -1766,9 +3217,34 @@ export class KnowledgeRuntime {
1766
3217
  return [];
1767
3218
  const notes = [];
1768
3219
  const sourceTypesFor = card.sourceRefs.flatMap(ref => { const sourceType = sourceTypes.get(ref); return sourceType ? [sourceType] : []; });
1769
- for (const [index, chunk] of splitWikiNotes(card).entries()) {
3220
+ /**
3221
+ * 卡片文件所在目录:正文里的图片引用是相对它写的(`../../01_source_raw/_assets/…`),
3222
+ * 和 `loadCards` 用的是同一个基准。
3223
+ */
3224
+ const cardDir = join(this.directory(WIKI), card.entityType);
3225
+ // 0044:note 带上父级 wiki 的**有效评分**。它不进正文、不参与向量指纹,所以
3226
+ // review 一下不会让整个语料重新编码,只会改变召回时的加权。
3227
+ const reviewScore = effectiveReviewScore(card);
3228
+ /**
3229
+ * 召回单元里**不带相关块**(0049)。
3230
+ *
3231
+ * 相关块是导航,不是知识:它写的是别的卡片的名字。切进 wikiNotes 之后,一句
3232
+ * 「毛利率怎么算」会同时命中真正讲毛利率的那张卡和所有**提到**它的卡 —— 而后者
3233
+ * 除了一个标题什么都没有。文件里留着它(人 grep 得到、编辑器里点得开),
3234
+ * 召回不吃它。
3235
+ */
3236
+ const recallBody = stripRelatedSection(card.content);
3237
+ for (const [index, chunk] of splitWikiNotes({ ...card, content: recallBody }).entries()) {
1770
3238
  const baseId = `${card.entityType}:${card.entityId}`;
1771
3239
  const unitId = index === 0 ? baseId : `${baseId}#${index}`;
3240
+ /**
3241
+ * 图按**这一节自己**的引用算,不是把整张卡片的图摊给每一节(0046)。
3242
+ *
3243
+ * 多模态编码只吃每张 note 的**第一张**图,所以「这一节的第一张图」必须是这一节
3244
+ * 自己的图 —— 否则「景气投资」那一节编码时会拿到「低估值」那节的图,而卡片
3245
+ * 正文里图片的落点本来就是按节算好的。
3246
+ */
3247
+ const chunkImages = markdownImagePaths(chunk.content, cardDir, this.rootDir);
1772
3248
  notes.push({
1773
3249
  unitId,
1774
3250
  noteId: unitId,
@@ -1782,7 +3258,8 @@ export class KnowledgeRuntime {
1782
3258
  sourceTypes: sourceTypesFor,
1783
3259
  ...(card.provenance ? { provenance: card.provenance } : {}),
1784
3260
  status: card.status,
1785
- ...(card.imagePaths?.length ? { imagePaths: card.imagePaths } : {}),
3261
+ ...(chunkImages.length > 0 ? { imagePaths: chunkImages } : {}),
3262
+ reviewScore,
1786
3263
  });
1787
3264
  }
1788
3265
  return notes;
@@ -1842,6 +3319,7 @@ export class KnowledgeRuntime {
1842
3319
  const vocabSize = Number(response?.vocabSize);
1843
3320
  return {
1844
3321
  hits: outcome.kept,
3322
+ scored: outcome.scored,
1845
3323
  result: {
1846
3324
  status: 'ready', provider, model: this.rerankModelPath,
1847
3325
  scored: outcome.kept.length, threshold, dropped: outcome.dropped,
@@ -1864,13 +3342,28 @@ export class KnowledgeRuntime {
1864
3342
  * Fingerprint of everything a wikiNote is compiled *from*.
1865
3343
  *
1866
3344
  * Deliberately excludes the fields that are cheap to refresh on a reused note
1867
- * (`provenance`, `imagePaths`, `metadata`) and includes the ones that change
1868
- * what the note says (`title`, `status`, `content`, `sourceRefs`). A card whose
1869
- * fingerprint is unchanged produces byte-identical notes, so reusing them is
1870
- * not a shortcut — it is the same answer without the churn.
3345
+ * (`provenance`, `imagePaths`, `metadata`, `annotations`) and includes the ones
3346
+ * that change what the note says (`title`, `status`, `content`, `sourceRefs`,
3347
+ * `review`). A card whose fingerprint is unchanged produces byte-identical
3348
+ * notes, so reusing them is not a shortcut — it is the same answer without the
3349
+ * churn.
3350
+ *
3351
+ * 0044 把**有效评分**算进来,因为 note 上确实带着它。少了这一条会踩一个很隐蔽的坑:
3352
+ * review 不改 `title` / `content` / `sourceRefs`,于是增量编译判定「语料没变」直接
3353
+ * 跳过,`wiki_notes.json` 停在旧评分上 —— 表现就是「review 了,但召回排序一动不动」。
3354
+ *
3355
+ * 代价可控:评分变了会重编这条 note,但**不会重新编码**。向量指纹(`vectorInputs`)
3356
+ * 只哈希 `unitId + text + sourceRefs + images`,而 review 一个字都不改这些 ——
3357
+ * 所以重编拿到的还是同一条文本、同一个向量。
3358
+ *
3359
+ * `annotations` 刻意不进指纹:备注只出现在召回结果附带的 wiki 卡片上,不进 note。
3360
+ * 把它算进来会让「加一条备注」触发一次没必要的版本升级。
1871
3361
  */
1872
3362
  cardHashOf(card) {
1873
- return contentHashOf(JSON.stringify({ title: card.title, status: card.status, content: card.content, sourceRefs: card.sourceRefs }));
3363
+ return contentHashOf(JSON.stringify({
3364
+ title: card.title, status: card.status, content: card.content, sourceRefs: card.sourceRefs,
3365
+ review: effectiveReviewScore(card),
3366
+ }));
1874
3367
  }
1875
3368
  /** Previous `wiki_notes.json` (or the pre-0014 mirror), without touching `this.units`. */
1876
3369
  async readNotesArtifact() {
@@ -1910,18 +3403,18 @@ export class KnowledgeRuntime {
1910
3403
  emptyExtractionOutcome(extractionHashes) {
1911
3404
  return {
1912
3405
  extractionHashes,
1913
- candidatesExtracted: 0, candidatesRejected: 0, cardsCreated: 0, cardsMerged: 0,
3406
+ candidatesExtracted: 0, candidatesRejected: 0, cardsCreated: 0, cardsUpdated: 0,
1914
3407
  sourcesExtracted: 0, sourcesSkipped: 0, relations: [], relationsBySource: {}, warnings: [],
1915
3408
  };
1916
3409
  }
1917
3410
  /**
1918
- * Pass 0:对指纹变了的来源做候选抽取 + 归一,并把结果落成 wiki 卡片。
3411
+ * Pass 0:对指纹变了的来源做候选抽取 + 归一,并把结果**直接落成 wiki 卡片**。
1919
3412
  *
1920
3413
  * 三条硬约束:
1921
3414
  * 1. **增量**:指纹没变的来源一次都不抽(这是 0025 增量承诺的一部分);
1922
3415
  * 2. **不重写没变的卡片**:合并只追加 `source_refs`,正文不动,所以不会凭空造
1923
3416
  * 版本快照(`snapshotCard` 只认正文变化);
1924
- * 3. **fail-soft**:一个候选被闸门拒绝、甚至一份来源整个抛错,都只记一笔,
3417
+ * 3. **fail-soft**:一个候选写不进去、甚至一份来源整个抛错,都只记一笔,
1925
3418
  * 绝不带走整次重建 —— 重建失败比少一张卡片严重得多。
1926
3419
  */
1927
3420
  async runExtractionStage(sources, previousHashes) {
@@ -1929,6 +3422,19 @@ export class KnowledgeRuntime {
1929
3422
  const enabledTypes = schema.types.filter(def => def.enabled);
1930
3423
  const registryHash = contentHashOf(JSON.stringify(enabledTypes.map(def => ({ id: def.id, description: def.description, examples: def.examples }))));
1931
3424
  const outcome = this.emptyExtractionOutcome({});
3425
+ /**
3426
+ * 模型调用**每轮解析一次**,而且解析不到时**如实报出来**(0044)。
3427
+ *
3428
+ * 静默降级的代价实测过:默认模型明明配着,接缝却永远是空的,于是编译一路按
3429
+ * `##` 小节切了好几个月,而界面上没有任何一处说「模型抽取没生效」。人只会得出
3430
+ * 一个结论 ——「这个编译没用」。降级可以是诚实的,前提是它说话。
3431
+ */
3432
+ const seam = this.config.extractWithModel ? this.modelSeamNow() : {};
3433
+ const call = seam.call;
3434
+ if (this.config.extractWithModel && !call) {
3435
+ outcome.warnings.push(`模型抽取已开启,但取不到可用的模型调用:${seam.why ?? '原因未知'} —— 本次退回规则抽取:`
3436
+ + '卡片正文是按 `##` 小节切下来的原文,判重也只能比字面,同一实体换个标题写法会落成两张卡。');
3437
+ }
1932
3438
  /**
1933
3439
  * 上一轮落下的关系产物。**跳过的来源从这里读回自己的关系** —— 没有这一步,
1934
3440
  * 一次全量重编就会把图上所有 `relates_to` 边抹掉(见 `EXTRACTED_RELATIONS`)。
@@ -1936,7 +3442,13 @@ export class KnowledgeRuntime {
1936
3442
  const persisted = await this.readExtractedRelations();
1937
3443
  // 计数器与产物不能由 readdir 的顺序决定,所以先按 sourceId 排序。
1938
3444
  const ordered = [...sources].sort((left, right) => left.sourceId.localeCompare(right.sourceId));
1939
- let cards = await this.loadCards();
3445
+ /**
3446
+ * 库里的卡片是判重的参照物。整轮结束才一次性落盘 —— 同一张卡片被多份来源命中时
3447
+ * 只写一次,正文在这一轮内累积(而不是产生 N 次互相覆盖的写入)。
3448
+ */
3449
+ const cards = await this.loadCards();
3450
+ const byKey = new Map(cards.map(card => [`${card.entityType}:${card.entityId}`, card]));
3451
+ const pending = new Map();
1940
3452
  for (const source of ordered) {
1941
3453
  const fingerprint = this.extractionFingerprint(source, registryHash);
1942
3454
  outcome.extractionHashes[source.sourceId] = fingerprint;
@@ -1949,13 +3461,136 @@ export class KnowledgeRuntime {
1949
3461
  }
1950
3462
  continue;
1951
3463
  }
1952
- outcome.sourcesExtracted += 1;
1953
- try {
1954
- cards = await this.extractFromSource(source, cards, enabledTypes, outcome);
1955
- }
1956
- catch (cause) {
1957
- outcome.warnings.push(`来源 ${source.sourceId} 的候选抽取失败,已跳过该来源:${cause instanceof Error ? cause.message : String(cause)}`);
3464
+ outcome.sourcesExtracted += 1;
3465
+ try {
3466
+ // 同一轮里后一份来源应当能看到前一份来源刚抽出来的实体,所以判重索引
3467
+ // 每份来源都用「磁盘上的卡片 + 这一轮 pending 的卡片」重建。
3468
+ await this.compileSource(source, byKey, pending, enabledTypes, outcome, call);
3469
+ }
3470
+ catch (cause) {
3471
+ outcome.warnings.push(`来源 ${source.sourceId} 的候选抽取失败,已跳过该来源:${cause instanceof Error ? cause.message : String(cause)}`);
3472
+ }
3473
+ }
3474
+ /**
3475
+ * 落**卡片**,不是提议(0044)。
3476
+ *
3477
+ * 每个计数都对应一次真实的 `writeCard`:只有真的写成功了才算「新建 / 更新」。
3478
+ * 写失败的进 `warnings` 而不是静默消失 —— 少一张卡片是一个需要被看见的事件。
3479
+ */
3480
+ /**
3481
+ * 图落位(0046)。
3482
+ *
3483
+ * 抽取那一段交给模型的是文字,模型给回来的正文里没有图,于是来源里 22 张图表
3484
+ * 一张也进不了卡片 —— 而多模态编码读的正是 `imagePaths[0]`。图是**确定性**的
3485
+ * (来源里有哪张图、它跟在哪段话后面,都不需要模型判断),所以在落盘前单独算一遍。
3486
+ *
3487
+ * 跨卡片一起算:一篇方法论文章编出 8 张卡,同一张图必须只落一张。已经引用过这张图
3488
+ * 的卡片不算「变了」,所以重复编译不会反复写盘。
3489
+ */
3490
+ const folded = new Map();
3491
+ for (const [key, item] of pending)
3492
+ folded.set(key, appendExtracted(item.existing?.content ?? '', [item.content]));
3493
+ const view = [];
3494
+ for (const [key, card] of byKey)
3495
+ view.push({ key, content: card.content, sourceRefs: card.sourceRefs });
3496
+ for (const [key, item] of pending)
3497
+ view.push({ key, content: folded.get(key) ?? '', sourceRefs: item.sourceRefs });
3498
+ const imagePlan = planSourceImages({ cards: view, sources: ordered.map(source => ({ sourceId: source.sourceId, content: source.content })) });
3499
+ for (const [key, item] of pending) {
3500
+ let content = folded.get(key) ?? '';
3501
+ for (const image of imagePlan.byCard.get(key) ?? [])
3502
+ content = insertImage(content, image);
3503
+ folded.set(key, content);
3504
+ }
3505
+ for (const [sourceId, count] of countBySource(imagePlan.unplaced)) {
3506
+ outcome.warnings.push(`来源 ${sourceId} 有 ${count} 张图没找到落点(既不在任何卡片正文里,也不属于图所在的来源小节),仍留在来源里`);
3507
+ }
3508
+ const writtenKeys = new Set();
3509
+ for (const item of pending.values()) {
3510
+ const key = `${item.entityType}:${item.entityId}`;
3511
+ const existing = item.existing;
3512
+ const metadata = { ...item.metadata, ...(item.merges.length > 0 ? { extraction_merges: mergeHistoryOf(item.metadata, item.merges) } : {}) };
3513
+ /**
3514
+ * 已经一模一样的卡片不重写。
3515
+ *
3516
+ * 判据只有两条:**正文没有新增内容**,而且**所有来源都已在 `source_refs` 里**。
3517
+ * 少了这条,重建一次就会把每张卡片重写一遍 —— 而增量编译的意义正在于
3518
+ * 「重编一次,没变的卡片不该产生任何写入」。
3519
+ *
3520
+ * 比较的是「追加之后是否还是原样」,而不是逐字比较:`item.content` 是从已有正文
3521
+ * 出发追加出来的,两者相等就说明这次抽到的东西一个字都不新。图也算在内:
3522
+ * 计划里带图而正文里没有,就是「变了」。
3523
+ */
3524
+ const content = folded.get(key) ?? item.content;
3525
+ writtenKeys.add(key);
3526
+ const unchanged = existing !== undefined
3527
+ && !item.fieldChanged
3528
+ && content === existing.content
3529
+ && item.sourceRefs.every(ref => existing.sourceRefs.includes(ref));
3530
+ if (unchanged)
3531
+ continue;
3532
+ const written = await this.writeCard({
3533
+ entityType: item.entityType,
3534
+ entityId: item.entityId,
3535
+ title: item.title,
3536
+ content,
3537
+ sourceRefs: item.sourceRefs,
3538
+ ...(item.summary ? { summary: item.summary } : {}),
3539
+ status: existing?.status ?? 'unreviewed',
3540
+ ...(existing?.owner ? { owner: existing.owner } : {}),
3541
+ ...(existing?.provenance ? { provenance: existing.provenance } : {}),
3542
+ metadata,
3543
+ origin: 'compile',
3544
+ });
3545
+ if (written.ok === false) {
3546
+ outcome.warnings.push(`卡片 ${item.entityId} 写入失败(${written.error.code}):${written.error.message}`);
3547
+ continue;
3548
+ }
3549
+ if (item.action === 'create')
3550
+ outcome.cardsCreated += 1;
3551
+ else
3552
+ outcome.cardsUpdated += 1;
3553
+ for (const warning of written.warnings ?? [])
3554
+ outcome.warnings.push(`卡片 ${item.entityId}:${warning}`);
3555
+ }
3556
+ /**
3557
+ * 只补图的卡片:指纹没变的来源这一轮**不重抽**(`sourcesSkipped`),所以它的卡片
3558
+ * 压根不在 `pending` 里 —— 而「卡片正文里有没有图」不影响抽取指纹。
3559
+ *
3560
+ * 少了这一段,0046 之前编好的库永远拿不到图:重编一次,来源被跳过,图还是不在。
3561
+ * 这段只做一件事:把算好的图插进去,正文别的部分一个字都不动。
3562
+ */
3563
+ for (const [key, images] of imagePlan.byCard) {
3564
+ if (writtenKeys.has(key))
3565
+ continue;
3566
+ const card = byKey.get(key);
3567
+ if (!card)
3568
+ continue;
3569
+ let content = card.content;
3570
+ for (const image of images)
3571
+ content = insertImage(content, image);
3572
+ if (content === card.content)
3573
+ continue;
3574
+ const written = await this.writeCard({
3575
+ entityType: card.entityType,
3576
+ entityId: card.entityId,
3577
+ title: card.title,
3578
+ content,
3579
+ sourceRefs: card.sourceRefs,
3580
+ ...(card.summary ? { summary: card.summary } : {}),
3581
+ status: card.status,
3582
+ ...(card.owner ? { owner: card.owner } : {}),
3583
+ ...(card.provenance ? { provenance: card.provenance } : {}),
3584
+ metadata: card.metadata,
3585
+ origin: 'compile',
3586
+ });
3587
+ if (written.ok === false) {
3588
+ outcome.warnings.push(`卡片 ${card.entityId} 补图失败(${written.error.code}):${written.error.message}`);
3589
+ continue;
1958
3590
  }
3591
+ outcome.cardsUpdated += 1;
3592
+ for (const warning of written.warnings ?? [])
3593
+ outcome.warnings.push(`卡片 ${card.entityId}:${warning}`);
1959
3594
  }
1960
3595
  // 只留这一轮真实存在的来源:删掉的文档不该在图上留下幽灵关系。
1961
3596
  const alive = new Set(ordered.map(source => source.sourceId));
@@ -2003,142 +3638,192 @@ export class KnowledgeRuntime {
2003
3638
  }
2004
3639
  catch { /* 写不进去就当这一轮没有产物,下一次重建会重新抽 */ }
2005
3640
  }
2006
- /** 一份来源的抽取结果:新建候选卡片、把证据来源并进已有卡片、收集关系候选。 */
2007
- async extractFromSource(source, cards, enabledTypes, outcome) {
3641
+ /**
3642
+ * 一份来源的编译结果:**按 wiki schema 抽实体**(新建 / 更新),并收集关系候选。
3643
+ *
3644
+ * ## 模型先抽实体,规则补关系
3645
+ *
3646
+ * 规则抽取把「小节标题」当实体身份,实测下来的后果是同一件东西横跨两份文档就
3647
+ * 变成两张卡片(`新建 2 / 并入 0`),而且卡片正文是按 `##` 切下来的原文片段 ——
3648
+ * 也就是「编译只是按目录截取」。所以实体这一层交给模型:它按类型的必填小节与字段
3649
+ * 写正文,并回答「这个实体是不是已经在下表里了」。
3650
+ *
3651
+ * 但仍然**照跑一遍规则抽取**,理由有两个:关系候选只有规则会抽(模型这一层不产
3652
+ * 关系),而且模型不可用时它就是那条降级路径。两者不是二选一。
3653
+ *
3654
+ * ## 缺信息也抽
3655
+ *
3656
+ * 0040 那一版在这里拒绝「没有正文」的候选、拒绝「类型未登记」的候选。两条都撤掉了:
3657
+ * 编译的价值在于**记下来,后面有涉及到的文档再慢慢补**,而拒绝恰好把这个机会丢掉。
3658
+ * 只剩一条硬要求 —— 正文不能是空的(一条什么都说不出的卡片没有东西可召回);
3659
+ * 模型路径已经保证只要给了 summary 就有骨架,规则路径的候选天然带正文。
3660
+ */
3661
+ async compileSource(source, byKey, pending, enabledTypes, outcome,
3662
+ /** 这一轮可用的模型调用;由 `runExtractionStage` 解析一次后传进来。 */
3663
+ call) {
3664
+ // 判重索引要看到「这一轮前面几份来源刚抽出来的卡片」,否则同一实体跨两份文档
3665
+ // 会各建一张 —— 而那正是这次改造要修掉的那个 bug。
3666
+ const cards = [...byKey.values()];
3667
+ // 同一份来源里的两个候选指向同一张卡片时,只该合并一次。
3668
+ const merges = new Map();
3669
+ const ruleCandidates = extractCandidates(source, enabledTypes);
3670
+ outcome.candidatesExtracted += ruleCandidates.length;
3671
+ const ruleNormalized = normalizeCandidates(ruleCandidates, buildAliasIndex(cards));
3672
+ let fresh = ruleNormalized.fresh;
3673
+ for (const merge of ruleNormalized.merges)
3674
+ if (!merges.has(merge.key))
3675
+ merges.set(merge.key, merge);
2008
3676
  /**
2009
- * #3b:**模型先抽实体,规则补关系。**
3677
+ * 这一份来源**实际用了哪条路**(0044 实机验证修的第二个诚实问题)。
2010
3678
  *
2011
- * 规则抽取把「小节标题」当实体身份,实测下来的后果是同一件东西横跨两份文档就
2012
- * 变成两张卡片(`新建 2 / 并入 0`)—— 追加分支永远不触发。所以实体这一层交给
2013
- * 模型(它能判断「毛利率变动及其驱动因素」与「毛利率(口径补充)」是同一个东西)。
3679
+ * 原来这里写的是 `extraction: call ? 'model' : 'pass0'` —— 判据是「有没有接缝」,
3680
+ * 不是「模型给的东西有没有被用上」。实机上模型确实被调用了,但输出不是 JSON,
3681
+ * 于是候选整批来自规则路径,卡片却标着 `extraction: model`:**脏的是标签,不是数据**。
3682
+ * 下一次有人排查「为什么正文还是按 `##` 切的」,会盯着这个字段得出相反结论。
2014
3683
  *
2015
- * 但仍然**照跑一遍规则抽取**,理由有两个:关系候选只有规则会抽(模型这一层不产
2016
- * 关系),而且模型不可用时它就是那条降级路径。两者不是二选一。
3684
+ * 现在的判据是产物:模型给出至少一条实体(新建或并入)才算 `model`。
2017
3685
  */
2018
- const ruleCandidates = extractCandidates(source, enabledTypes);
2019
- outcome.candidatesExtracted += ruleCandidates.length;
2020
- const ruleNormalized = normalizeCandidates(ruleCandidates, buildAliasIndex(cards));
2021
- let normalized = ruleNormalized;
2022
- if (this.modelCall && this.config.extractWithModel) {
2023
- const modelled = await extractWithModel({ source, wikiTypes: enabledTypes, existing: cards, call: this.modelCall });
3686
+ let extractionPath = 'pass0';
3687
+ if (call) {
3688
+ const modelled = await extractWithModel({
3689
+ source,
3690
+ wikiTypes: enabledTypes,
3691
+ existing: cards,
3692
+ call,
3693
+ /**
3694
+ * 路由与缓存亲和的会话身份,**按知识库稳定**(0044 实机验证第五批)。
3695
+ *
3696
+ * 没有它,OpenCode 系路由直接回 400 `MissingSessionID` —— 那件事在界面上长得
3697
+ * 像「模型抽取不可用」。用 `knowledge:<库 id>` 而不是随机 UUID:同一个库反复
3698
+ * 编译时提示词高度重复(类型定义 + 已有卡片清单),稳定的值把请求钉在同一个
3699
+ * 上游后端,命中缓存的概率才成立。
3700
+ */
3701
+ sessionId: `knowledge:${this.baseId || 'default'}`,
3702
+ });
2024
3703
  for (const warning of modelled.warnings)
2025
3704
  outcome.warnings.push(warning);
2026
3705
  // 模型一条实体都没给(或者不可用)时,完整退回规则结果,而不是丢掉整轮抽取。
2027
3706
  if (modelled.fresh.length > 0 || modelled.merges.length > 0) {
2028
- normalized = { fresh: modelled.fresh, merges: modelled.merges, relations: ruleNormalized.relations };
3707
+ extractionPath = 'model';
3708
+ fresh = modelled.fresh;
3709
+ merges.clear();
3710
+ for (const merge of modelled.merges)
3711
+ if (!merges.has(merge.key))
3712
+ merges.set(merge.key, merge);
3713
+ }
3714
+ else if (modelled.warnings.length === 0) {
3715
+ // 模型正常回答、只是没给实体 —— 这也是一次实际的降级,不该只体现在卡片标签上。
3716
+ outcome.warnings.push('模型没有给出任何实体,本次退回规则抽取(正文按 `##` 小节切)。');
2029
3717
  }
2030
3718
  }
2031
- const candidates = normalized.fresh;
2032
- if (candidates.length === 0 && normalized.merges.length === 0)
2033
- return cards;
2034
- const next = [...cards];
2035
- // 先建后并:同一轮里新建的卡片也可能被别名指回,先建才能让后面的合并找得到对象。
2036
- for (const candidate of normalized.fresh) {
3719
+ const at = new Date().toISOString();
3720
+ for (const candidate of fresh) {
3721
+ /**
3722
+ * 类型:未登记就**退回一个已启用的兜底类型**,而不是丢掉这条候选。
3723
+ *
3724
+ * 0040 时期这里是 `candidatesRejected += 1` 然后 continue。那条规则与
3725
+ * 「缺信息也编译进去」直接冲突:类型没登记是**配置问题**,不是「这条知识不存在」——
3726
+ * 而配置问题该由配置界面回答,不该让文档里的知识消失。退回时记一条 warning,
3727
+ * 把原始类型名带上,人是能顺藤摸瓜去登记它的。
3728
+ *
3729
+ * 模型路径(`extract-model.ts`)早就是这么做的,这里补齐规则路径 —— 两条路径对
3730
+ * 同一种情况给出不同结论,比任选一种都糟。
3731
+ */
3732
+ const wanted = enabledTypes.find(def => def.id === candidate.type);
3733
+ const resolvedType = wanted ?? enabledTypes.find(def => def.id === 'concept') ?? enabledTypes[0];
3734
+ if (!resolvedType) {
3735
+ outcome.candidatesRejected += 1;
3736
+ outcome.warnings.push(`候选「${candidate.name}」未入库:没有任何已启用的 wiki 类型可落(先在「知识库设置 → Wiki 类型」里登记一个)`);
3737
+ continue;
3738
+ }
3739
+ if (!wanted) {
3740
+ outcome.warnings.push(`候选「${candidate.name}」的类型 ${candidate.type} 未登记或已停用,已按「${resolvedType.id}」入库`);
3741
+ }
2037
3742
  /**
2038
- * 候选**必须带正文**才落卡。
3743
+ * 正文:候选自带的正文优先,没有就用 summary 撑一个带必填小节骨架的最小正文。
2039
3744
  *
2040
- * 0029 实跑发现:这里原来在候选没有正文时拿 `type.template` 顶上,于是
2041
- * `[[链接]]` 指到、但还没人写过的实体(那时链接候选确实没有正文)会落成一张
2042
- * **正文是模板**的卡片,而模板里的「(必填:写什么)」会被编进 wikiNotes、
2043
- * 被召回、被塞进模型的上下文包 —— 管线写出了一张它自己的校验器判为不合格的
2044
- * 卡片。链接候选现在会带上它所在小节的正文(`extract.ts`),所以这条回退基本
2045
- * 不会被走到;真走到了就**不落卡**,并如实计数:没有正文 = 没有可说的。
3745
+ * 两条路径(模型 / 规则)走同一个 `skeletonBody`,所以「只说了半句」的候选在两处
3746
+ * 得到同一种对待。撑完还是空 → 这是**唯一保留的拒绝**:一条什么都不说的卡片没有
3747
+ * 东西可召回,而它占的 id 会让下一份真正说了点什么的文档并进这张空卡里。
2046
3748
  */
2047
- const body = candidate.body?.trim() ?? '';
3749
+ const body = candidate.body?.trim() || skeletonBody(resolvedType, candidate.summary?.trim() ?? '');
2048
3750
  if (!body) {
2049
3751
  outcome.candidatesRejected += 1;
2050
- outcome.warnings.push(`候选「${candidate.name}」没有任何正文(只在别处被提到,所在小节也没取到),不落卡`);
3752
+ outcome.warnings.push(`候选「${candidate.name}」没有任何正文与摘要(只在别处被提到,所在小节也没取到),未入库`);
2051
3753
  continue;
2052
3754
  }
2053
- // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
2054
- const aliases = [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))];
3755
+ const entityId = candidateEntityId(candidate.name);
3756
+ const key = `${resolvedType.id}:${entityId}`;
2055
3757
  const metadata = {
2056
- extraction: 'pass0',
3758
+ extraction: extractionPath,
2057
3759
  candidate_kind: candidate.kind,
2058
3760
  confidence: candidate.confidence,
2059
3761
  evidence: {
2060
3762
  sourceId: candidate.evidence.sourceId,
2061
3763
  ...(candidate.evidence.heading ? { heading: candidate.evidence.heading } : {}),
2062
3764
  },
2063
- ...(aliases.length > 0 ? { aliases } : {}),
3765
+ // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
3766
+ ...(aliasesOf(candidate).length > 0 ? { aliases: aliasesOf(candidate) } : {}),
3767
+ // 类型定义里的字段(`metric.formula` 之类)落在 metadata 上,不占正文。
3768
+ ...(candidate.fields ?? {}),
2064
3769
  };
2065
- const written = await this.writeCard({
2066
- entityType: candidate.type,
2067
- entityId: candidateEntityId(candidate.name),
2068
- title: candidate.name,
2069
- content: body,
2070
- sourceRefs: [candidate.evidence.sourceId],
2071
- status: 'unreviewed',
2072
- metadata,
2073
- });
2074
- if (!written.ok) {
2075
- outcome.candidatesRejected += 1;
2076
- outcome.warnings.push(`候选「${candidate.name}」未写入(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`);
3770
+ // 库里已经有这张卡片 → 不是新建,而是「把这次抽到的正文并进去」。
3771
+ const existing = byKey.get(key);
3772
+ if (existing) {
3773
+ mergeIntoPending(pending, existing, candidate.evidence.sourceId, [body], [candidate.name], at, candidate.fields, candidate.summary);
3774
+ continue;
3775
+ }
3776
+ const previous = pending.get(key);
3777
+ if (previous) {
3778
+ previous.content = appendExtracted(previous.content, [body]);
3779
+ if (!previous.sourceRefs.includes(candidate.evidence.sourceId))
3780
+ previous.sourceRefs.push(candidate.evidence.sourceId);
3781
+ if (!previous.summary && candidate.summary)
3782
+ previous.summary = candidate.summary;
3783
+ previous.metadata = { ...previous.metadata, ...(candidate.fields ?? {}) };
3784
+ previous.merges.push({ sourceId: candidate.evidence.sourceId, keys: [candidate.name], at });
2077
3785
  continue;
2078
3786
  }
2079
- outcome.cardsCreated += 1;
2080
- next.push({
2081
- entityId: written.data.entityId,
2082
- entityType: candidate.type,
3787
+ pending.set(key, {
3788
+ action: 'create',
3789
+ entityType: resolvedType.id,
3790
+ entityId,
2083
3791
  title: candidate.name,
2084
- status: 'unreviewed',
2085
3792
  content: body,
2086
3793
  sourceRefs: [candidate.evidence.sourceId],
2087
3794
  metadata,
2088
- updatedAt: '',
3795
+ ...(candidate.summary ? { summary: candidate.summary } : {}),
3796
+ merges: [{ sourceId: candidate.evidence.sourceId, keys: [candidate.name], at }],
2089
3797
  });
2090
3798
  }
2091
- // 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并且只写一次。
2092
- const targets = new Map();
2093
- for (const merge of normalized.merges) {
2094
- const card = next.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType);
2095
- if (!card)
2096
- continue;
2097
- // 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空写也会
2098
- // 刷新 updated_at,并让「并入了多少张卡」这个计数变成谎话。
2099
- if (card.sourceRefs.includes(merge.sourceId))
3799
+ // 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并成一条。
3800
+ for (const merge of merges.values()) {
3801
+ const key = `${merge.entityType}:${merge.entityId}`;
3802
+ // 这一轮刚新建的卡片也要认:它此刻还在 `pending` 里,不在磁盘上。
3803
+ const existing = byKey.get(key);
3804
+ if (existing) {
3805
+ // 「只在没有的时候追加」:来源已在 source_refs 里、正文也已在卡片里时什么都不做 ——
3806
+ // 空写一次也会让「更新了几张卡」这个计数变成谎话。
3807
+ if (existing.sourceRefs.includes(merge.sourceId) && appendExtracted(existing.content, [merge.body]) === existing.content)
3808
+ continue;
3809
+ mergeIntoPending(pending, existing, merge.sourceId, [merge.body], [merge.key], at, merge.fields, merge.summary);
2100
3810
  continue;
2101
- const id = `${merge.entityType}:${merge.entityId}`;
2102
- const entry = targets.get(id);
2103
- if (entry) {
2104
- if (!entry.keys.includes(merge.key))
2105
- entry.keys.push(merge.key);
2106
- entry.bodies.push(merge.body);
2107
3811
  }
2108
- else
2109
- targets.set(id, { card, keys: [merge.key], sourceId: merge.sourceId, bodies: [merge.body] });
2110
- }
2111
- for (const { card, keys, sourceId, bodies } of targets.values()) {
2112
- const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : [];
2113
- // 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
2114
- const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20);
2115
- // **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
2116
- // 于是知识是累积的,来源改写了也不会停在旧版本上。
2117
- const content = appendExtracted(card.content, bodies);
2118
- const written = await this.writeCard({
2119
- ...card,
2120
- content,
2121
- sourceRefs: [...card.sourceRefs, sourceId],
2122
- metadata: { ...card.metadata, extraction_merges },
2123
- });
2124
- if (!written.ok) {
2125
- outcome.candidatesRejected += 1;
2126
- outcome.warnings.push(`候选并入卡片「${card.entityId}」失败(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`);
3812
+ const previous = pending.get(key);
3813
+ if (!previous)
2127
3814
  continue;
2128
- }
2129
- outcome.cardsMerged += 1;
2130
- // 让后面的来源看到已经追加过的正文与 source_refs,避免重复写入。
2131
- const index = next.findIndex(item => item.entityId === card.entityId && item.entityType === card.entityType);
2132
- if (index >= 0)
2133
- next[index] = { ...card, content, sourceRefs: [...card.sourceRefs, sourceId], metadata: { ...card.metadata, extraction_merges } };
3815
+ previous.content = appendExtracted(previous.content, [merge.body]);
3816
+ if (!previous.sourceRefs.includes(merge.sourceId))
3817
+ previous.sourceRefs.push(merge.sourceId);
3818
+ previous.metadata = { ...previous.metadata, ...(merge.fields ?? {}) };
3819
+ previous.merges.push({ sourceId: merge.sourceId, keys: [merge.key], at });
2134
3820
  }
2135
- outcome.relations.push(...normalized.relations);
3821
+ outcome.relations.push(...ruleNormalized.relations);
2136
3822
  // 按来源存一份:跳过的来源下次重建要靠它把关系读回来(见 EXTRACTED_RELATIONS)。
2137
3823
  outcome.relationsBySource[source.sourceId] = [
2138
3824
  ...(outcome.relationsBySource[source.sourceId] ?? []),
2139
- ...normalized.relations,
3825
+ ...ruleNormalized.relations,
2140
3826
  ];
2141
- return next;
2142
3827
  }
2143
3828
  /**
2144
3829
  * 把关系候选装配成 wiki↔wiki 的边。
@@ -2169,6 +3854,326 @@ export class KnowledgeRuntime {
2169
3854
  }
2170
3855
  return { extracted, dropped };
2171
3856
  }
3857
+ // ------------------------------------------- Finalize · 索引 / 断链 / 相关(0049)
3858
+ /**
3859
+ * 收尾段的**正文层**:给卡片补相关块、把断链写成 `reference` 问题。
3860
+ *
3861
+ * 它必须在 stage 2(切 wikiNotes)**之前**跑:相关块是正文的一部分,晚于切分写进去,
3862
+ * 这一轮编出来的召回单元就与卡片正文对不上,而下次重建又会因为正文变了重切一遍 ——
3863
+ * 每次编译都白编一次。
3864
+ *
3865
+ * 三段都是确定性的,所以这个函数可以重复跑:没变化的卡片一次都不写盘(判据是
3866
+ * 「正文 + 问题清单逐字相同」)。这也让「重编一次,卡片不该有任何写入」这条承诺
3867
+ * 在收尾段照样成立。
3868
+ */
3869
+ async runFinalizeStage(cards, relations, warnings) {
3870
+ const related = planRelated(cards, relations);
3871
+ /**
3872
+ * 断链扫的是**写完相关块之后的正文**,不是读进来的那一份。
3873
+ *
3874
+ * 相关块自己就是一堆 `[[类型/标题]]`:扫旧正文等于这一轮看不见它们,而它们马上
3875
+ * 要写进文件 —— 于是「引用是否成立」这件事被推迟到下一轮,而下一轮又会因为
3876
+ * 问题清单变了再写一遍。扫新正文,一轮就说清楚。
3877
+ */
3878
+ const nextContent = new Map(cards.map(card => [cardKey(card), applyRelatedSection(card.content, related.get(cardKey(card)) ?? [])]));
3879
+ const deadLinks = deadLinksOf(cards.map(card => ({ ...card, content: nextContent.get(cardKey(card)) ?? card.content })));
3880
+ const deadByKey = new Map();
3881
+ for (const link of deadLinks) {
3882
+ const bucket = deadByKey.get(link.key) ?? [];
3883
+ bucket.push(link.target);
3884
+ deadByKey.set(link.key, bucket);
3885
+ }
3886
+ let rewritten = 0;
3887
+ for (const card of cards) {
3888
+ const key = cardKey(card);
3889
+ const content = nextContent.get(key) ?? card.content;
3890
+ // 旧的问题清单里 `reference` 那几条是上一轮算的,先摘掉再按这一轮重算 ——
3891
+ // 断链修好之后那条问题必须消失,否则「待补」清单会越用越长。
3892
+ const kept = cardIssuesOf(card).filter(issue => issue.level !== 'reference');
3893
+ const reference = (deadByKey.get(key) ?? []).map(deadLinkIssue);
3894
+ const issues = [...kept, ...reference];
3895
+ if (content === card.content && JSON.stringify(issues) === JSON.stringify(cardIssuesOf(card)))
3896
+ continue;
3897
+ /**
3898
+ * 没有来源的卡片写不进去(`writeCard` 要求 `source_refs` 非空),而这是**唯一**
3899
+ * 一种收尾段碰不到的卡片:它已经是一张不合法的卡片了。如实报出来,不静默跳过 ——
3900
+ * 这种卡片只能由人补来源,或者删掉。
3901
+ */
3902
+ if (card.sourceRefs.length === 0) {
3903
+ warnings.push(`卡片 ${card.entityId} 没有来源,无法补相关块:正文只读,来源只能由编译或人工重新写入`);
3904
+ continue;
3905
+ }
3906
+ const written = await this.writeCard({
3907
+ entityType: card.entityType,
3908
+ entityId: card.entityId,
3909
+ title: card.title,
3910
+ content,
3911
+ sourceRefs: card.sourceRefs,
3912
+ ...(card.summary ? { summary: card.summary } : {}),
3913
+ status: card.status,
3914
+ ...(card.owner ? { owner: card.owner } : {}),
3915
+ ...(card.provenance ? { provenance: card.provenance } : {}),
3916
+ metadata: card.metadata,
3917
+ extraIssues: reference,
3918
+ origin: 'compile',
3919
+ });
3920
+ if (written.ok === false) {
3921
+ warnings.push(`卡片 ${card.entityId} 的相关块 / 断链未写入(${written.error.code}):${written.error.message}`);
3922
+ continue;
3923
+ }
3924
+ rewritten += 1;
3925
+ }
3926
+ return {
3927
+ related, deadLinks, rewritten,
3928
+ relatedLinks: [...related.values()].reduce((sum, entries) => sum + entries.length, 0),
3929
+ relatedCards: [...related.keys()].length,
3930
+ };
3931
+ }
3932
+ /** 收尾段的**产物层**:写索引页。它读的是正文层算完之后的卡片清单。 */
3933
+ async writeWikiIndex(input) {
3934
+ await writeTextAtomic(this.safePath('03_compiled', WIKI_INDEX), buildWikiIndex(input));
3935
+ }
3936
+ /** 一条路的候选 → 账本行(0050)。名次按这一路自己的顺序,没过阈值的也在。 */
3937
+ ledgerRows(rows, unitById) {
3938
+ return (rows ?? []).flatMap((row, index) => {
3939
+ const note = unitById.get(row.unitId);
3940
+ if (note === undefined)
3941
+ return [];
3942
+ return [{
3943
+ unitId: row.unitId,
3944
+ title: note.title,
3945
+ entityType: note.entityType,
3946
+ entityId: note.entityId,
3947
+ score: row.score,
3948
+ rank: index + 1,
3949
+ kept: row.kept,
3950
+ }];
3951
+ });
3952
+ }
3953
+ // ------------------------------------------- 召回组装:去重 / 扩展 / 预算(0050)
3954
+ /**
3955
+ * **1 跳扩展**:从命中的卡片出发,按图上的边拉邻居。
3956
+ *
3957
+ * 两个来源,优先级相同(都记进 `via`,一条邻居可以有多条来路):
3958
+ * 1. **图上的边**(`relates_to` 这类卡片之间的关系)—— `supported_by` 不算:
3959
+ * 它连的是「卡片 → 来源」,而来源不是 wiki 卡片,进不了上下文;
3960
+ * 2. **共同来源**:同一份 SourceRaw 编译出来的卡片也是邻居(这是最实用的一类,
3961
+ * 新库里往往一条 `relates_to` 都没有)。
3962
+ *
3963
+ * 调用点必须在**按卡片去重之后**:先扩展再去重会把邻居与命中混在一起,
3964
+ * 而上下文里必须分得清「这是召回来的」还是「这是延伸出来的」。
3965
+ */
3966
+ expandWiki(seedKeys, cardByKey, includeProblems) {
3967
+ const seeds = new Set(seedKeys);
3968
+ const visible = (card) => includeProblems || card.status !== 'problem';
3969
+ const via = new Map();
3970
+ const remember = (key, from) => {
3971
+ const bucket = via.get(key) ?? [];
3972
+ if (!bucket.includes(from))
3973
+ bucket.push(from);
3974
+ via.set(key, bucket);
3975
+ };
3976
+ const edges = [];
3977
+ for (const edge of this.graph.edges) {
3978
+ const type = String(edge.type);
3979
+ if (type === 'supported_by')
3980
+ continue;
3981
+ const from = String(edge.from);
3982
+ const to = String(edge.to);
3983
+ const fromSeed = seeds.has(from);
3984
+ const toSeed = seeds.has(to);
3985
+ // 两端都在种子里(种子里的一条内部边)或都不在(与这次召回无关):不是扩展。
3986
+ if (fromSeed === toSeed)
3987
+ continue;
3988
+ const neighbor = fromSeed ? to : from;
3989
+ const card = cardByKey.get(neighbor);
3990
+ if (card === undefined || !visible(card))
3991
+ continue;
3992
+ remember(neighbor, `${fromSeed ? from : to}|${type}`);
3993
+ edges.push(edge);
3994
+ }
3995
+ // 共同来源:反向走 `supported_by`(来源 → 它编译出的卡片)。
3996
+ const bySource = new Map();
3997
+ for (const key of seeds) {
3998
+ for (const ref of cardByKey.get(key)?.sourceRefs ?? []) {
3999
+ const bucket = bySource.get(ref) ?? [];
4000
+ bucket.push(key);
4001
+ bySource.set(ref, bucket);
4002
+ }
4003
+ }
4004
+ for (const card of cardByKey.values()) {
4005
+ const key = cardKey(card);
4006
+ if (seeds.has(key) || !visible(card))
4007
+ continue;
4008
+ for (const ref of card.sourceRefs) {
4009
+ const owners = bySource.get(ref);
4010
+ if (owners === undefined || owners.length === 0)
4011
+ continue;
4012
+ remember(key, `${owners[0]}|shared_source`);
4013
+ }
4014
+ }
4015
+ const nodes = [];
4016
+ for (const [key, sources] of via) {
4017
+ const card = cardByKey.get(key);
4018
+ if (card === undefined)
4019
+ continue;
4020
+ nodes.push({ entityType: card.entityType, entityId: card.entityId, title: card.title, via: sources });
4021
+ }
4022
+ // 排序:来路多的在前(更可能是真的相关),同数按身份,保证确定性。
4023
+ nodes.sort((left, right) => right.via.length - left.via.length || `${left.entityType}:${left.entityId}`.localeCompare(`${right.entityType}:${right.entityId}`));
4024
+ const capped = nodes.slice(0, MAX_EXPANSION_CARDS);
4025
+ const kept = new Set(capped.map(node => `${node.entityType}:${node.entityId}`));
4026
+ return {
4027
+ nodes: capped,
4028
+ // 只留两端都在扩展集里的边:一端是命中卡片的边已经写在命中卡片自己的子图里了。
4029
+ edges: edges.filter(edge => kept.has(String(edge.from)) && kept.has(String(edge.to))),
4030
+ included: [],
4031
+ };
4032
+ }
4033
+ /** 一条上下文条目「值多少字符」:命中条目算它那些 note 的正文,扩展条目算标题 + 摘要(+ 正文)。 */
4034
+ contextCost(item, full) {
4035
+ if (item.reason === 'hit')
4036
+ return item.notes.reduce((sum, note) => sum + note.title.length + note.content.length, 0);
4037
+ const card = item.wiki;
4038
+ if (card === null)
4039
+ return 0;
4040
+ const shallow = card.title.length + (card.summary?.length ?? 0);
4041
+ return full ? shallow + card.content.length : shallow;
4042
+ }
4043
+ /** 溢出目录:**系统临时目录**,不随知识库走(0050 的取舍,见 issue)。 */
4044
+ recallOverflowDir() {
4045
+ return join(tmpdir(), 'dsh-knowledge-recall', this.baseId || 'default');
4046
+ }
4047
+ /**
4048
+ * 上下文预算:装箱 + 溢出(0050)。
4049
+ *
4050
+ * 让位顺序(写死在这里,不是启发式):
4051
+ * 1. **命中的 note 正文优先**:它是证据,装不下就该少给几张卡片,而不是把证据截断;
4052
+ * 2. 扩展卡片先给**标题 + 摘要 + 来路**(便宜,且足以让模型知道「还有这么一张卡」);
4053
+ * 3. 还有余量才给扩展卡片的正文。
4054
+ *
4055
+ * 溢出的内容落**系统临时目录**(`<os.tmpdir()>/dsh-knowledge-recall/<库>/<recallId>.md`),
4056
+ * 不随库走:它是一次召回的临时产物,下一次召回就是另一份。代价是系统会清它 ——
4057
+ * 所以指路行必须说清「本次会话内有效」,并给出「读不到就重新召回」这条出路。
4058
+ */
4059
+ async packContext(input) {
4060
+ const { items, maxChars, recallId } = input;
4061
+ const hitCards = items.filter(item => item.reason === 'hit').length;
4062
+ const expandedCards = items.length - hitCards;
4063
+ const totalChars = items.reduce((sum, item) => sum + this.contextCost(item, true), 0);
4064
+ if (!Number.isFinite(maxChars) || maxChars <= 0) {
4065
+ return { items, report: { maxChars: 0, usedChars: totalChars, omittedCards: 0, omittedChars: 0, hitCards, expandedCards } };
4066
+ }
4067
+ const keptItems = [];
4068
+ let used = 0;
4069
+ for (const item of items) {
4070
+ const full = this.contextCost(item, true);
4071
+ const shallow = this.contextCost(item, false);
4072
+ // 命中条目按整条算(它的正文就是证据);扩展条目可以先按「浅」那一档进。
4073
+ const cost = item.reason === 'hit' ? full : shallow;
4074
+ if (used + cost > maxChars)
4075
+ continue;
4076
+ used += cost;
4077
+ keptItems.push(item);
4078
+ }
4079
+ const keptKeys = new Set(keptItems.map(item => item.wiki === null ? '' : `${item.wiki.entityType}:${item.wiki.entityId}`));
4080
+ const omitted = items.filter(item => !keptKeys.has(item.wiki === null ? '' : `${item.wiki.entityType}:${item.wiki.entityId}`));
4081
+ const report = {
4082
+ maxChars,
4083
+ usedChars: used,
4084
+ omittedCards: omitted.length,
4085
+ omittedChars: omitted.reduce((sum, item) => sum + this.contextCost(item, true), 0),
4086
+ hitCards: keptItems.filter(item => item.reason === 'hit').length,
4087
+ expandedCards: keptItems.filter(item => item.reason === 'expansion').length,
4088
+ };
4089
+ if (omitted.length === 0)
4090
+ return { items: keptItems, report };
4091
+ const overflowPath = await this.writeRecallOverflow({ items, recallId, usedChars: used, maxChars });
4092
+ report.overflowPath = overflowPath;
4093
+ return {
4094
+ items: keptItems,
4095
+ report,
4096
+ notice: `还有 ${report.omittedCards} 张卡片共 ${report.omittedChars} 字符没有放进上下文,完整内容在 ${overflowPath};需要更多信息时直接读这个文件(它是本次召回的临时产物,不保证长期存在)。`,
4097
+ };
4098
+ }
4099
+ /** 把**完整**的上下文写进溢出文件(含被预算挡在外面的那些)。 */
4100
+ async writeRecallOverflow(input) {
4101
+ const dir = this.recallOverflowDir();
4102
+ await mkdir(dir, { recursive: true });
4103
+ await this.pruneRecallOverflow(dir);
4104
+ const path = join(dir, `${input.recallId}.md`);
4105
+ const lines = [
4106
+ '# 这次召回的完整上下文',
4107
+ '',
4108
+ `上限 ${input.maxChars} 字符,上下文里装了 ${input.usedChars} 字符;这里是**全部**内容。`,
4109
+ '它是临时产物,读不到时重新召回一次即可。',
4110
+ '',
4111
+ ];
4112
+ for (const item of input.items) {
4113
+ const card = item.wiki;
4114
+ lines.push(`## ${card?.title ?? '(未找到卡片)'} · ${card?.entityType ?? ''}/${card?.entityId ?? ''}${item.reason === 'expansion' ? '(1 跳扩展)' : ''}`, '');
4115
+ if (item.via)
4116
+ lines.push(`来路:${item.via.entityType}/${item.via.entityId}(${item.via.relation})`, '');
4117
+ if (card?.summary)
4118
+ lines.push(`> ${card.summary}`, '');
4119
+ for (const note of item.notes)
4120
+ lines.push(`### ${note.title}`, '', note.content, '');
4121
+ if (item.reason === 'expansion' && card)
4122
+ lines.push(card.content, '');
4123
+ }
4124
+ await writeTextAtomic(path, `${lines.join('\n').replace(/\n+$/, '')}\n`);
4125
+ return path;
4126
+ }
4127
+ /** 清掉本库前缀下超过保留时长的溢出文件(自清理,不依赖系统清 tmp)。 */
4128
+ async pruneRecallOverflow(dir) {
4129
+ try {
4130
+ const cutoff = Date.now() - RECALL_OVERFLOW_TTL_MS;
4131
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
4132
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
4133
+ continue;
4134
+ const path = join(dir, entry.name);
4135
+ try {
4136
+ const info = await stat(path);
4137
+ if (info.mtimeMs < cutoff)
4138
+ await unlink(path);
4139
+ }
4140
+ catch { /* 并发删掉了就算了 */ }
4141
+ }
4142
+ }
4143
+ catch { /* 目录还不存在:没有什么要清 */ }
4144
+ }
4145
+ /**
4146
+ * 一张卡片的出链与入链(面板抽屉里的「相关」分块)。
4147
+ *
4148
+ * 与写进正文的那一块**同一份计划**:两处都调 `planRelated`,所以界面上看到的关系
4149
+ * 与文件里写的不会漂移。入链要单独算 —— 关系是双向记的,但同源那一类是「谁和我
4150
+ * 同源」,只能扫一遍别人的出链。
4151
+ */
4152
+ async relatedOfCard(card) {
4153
+ /**
4154
+ * 图上那几条关系边住在 `knowledge_graph.json`,进程刚起来时内存里是空的。
4155
+ * 少了这一次读取,「关系」那一类相关会**静默缺失**(只剩同源那几条),而界面上
4156
+ * 看不出少了什么 —— 卡片抽屉是本插件里第一个在召回之外读图的地方。
4157
+ */
4158
+ if (this.graph.edges.length === 0)
4159
+ await this.loadCompiledState();
4160
+ const cards = await this.loadCards();
4161
+ const graphRelations = this.graph.edges
4162
+ .filter(edge => String(edge.type) !== 'supported_by')
4163
+ .map(edge => ({ fromKey: String(edge.from), toKey: String(edge.to) }));
4164
+ const related = planRelated(cards, graphRelations);
4165
+ const key = cardKey(card);
4166
+ const outgoing = related.get(key) ?? [];
4167
+ const incoming = [];
4168
+ for (const [fromKey, entries] of related) {
4169
+ if (fromKey === key)
4170
+ continue;
4171
+ const entry = entries.find(item => item.key === key);
4172
+ if (entry)
4173
+ incoming.push({ ...entry, key: fromKey });
4174
+ }
4175
+ return { outgoing, incoming };
4176
+ }
2172
4177
  async rebuild(only = 'all', lineage, options = {}) {
2173
4178
  await this.ensureLayout();
2174
4179
  const startedAt = new Date().toISOString();
@@ -2194,26 +4199,75 @@ export class KnowledgeRuntime {
2194
4199
  const extraction = extractEnabled
2195
4200
  ? await this.runExtractionStage(sources, previousExtractionHashes)
2196
4201
  : this.emptyExtractionOutcome(previousExtractionHashes);
2197
- // 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
2198
- // 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
2199
- if (extraction.cardsCreated > 0 || extraction.cardsMerged > 0)
4202
+ /**
4203
+ * 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
4204
+ * 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
4205
+ *
4206
+ * 0044 之前这一行是多余的(Pass 0 只写提议,卡片清单根本不会变),所以它一直是
4207
+ * 一个**没被触发过的**承诺;现在 Pass 0 直接落卡,漏掉它就会表现成「第一次编译
4208
+ * 什么都没建出来,第二次才有」—— 一个看起来像缓存没刷新的 bug。
4209
+ */
4210
+ if (extractEnabled)
2200
4211
  this.cards = await this.loadCards();
2201
4212
  const extractionCounts = {
2202
4213
  candidatesExtracted: extraction.candidatesExtracted,
2203
4214
  candidatesRejected: extraction.candidatesRejected,
2204
4215
  cardsCreated: extraction.cardsCreated,
2205
- cardsMerged: extraction.cardsMerged,
4216
+ cardsUpdated: extraction.cardsUpdated,
2206
4217
  sourcesExtracted: extraction.sourcesExtracted,
2207
4218
  sourcesSkipped: extraction.sourcesSkipped,
2208
4219
  };
2209
4220
  const warnings = [...extraction.warnings];
2210
4221
  let relationsExtracted = 0;
2211
4222
  let relationsDropped = 0;
4223
+ // --------------------------------- Finalize · 相关块与断链(正文层,先于切分)
4224
+ /**
4225
+ * 收尾段在 stage 2 **之前**跑,理由有两条,都不是风格问题:
4226
+ *
4227
+ * 1. 相关块是正文的一部分。晚于切分写进去,这一轮编出的召回单元就与卡片正文对不上,
4228
+ * 而下一轮又会因为正文变了重切一遍 —— 每次编译都白编一次;
4229
+ * 2. 它必须参与「语料变了没有」的判定。0047 的图片那次踩过同一个坑:派生内容写盘
4230
+ * 晚于判定,增量编译判定「没有变化」,刚写进去的东西连同整个产物一起被丢掉。
4231
+ *
4232
+ * `extractEnabled` 为假(`only: 'embeddings'`)时整段不跑:它不碰 wiki 层。
4233
+ */
4234
+ const finalize = extractEnabled
4235
+ ? await this.runFinalizeStage(this.cards, extraction.relations, warnings)
4236
+ : { related: new Map(), relatedLinks: 0, relatedCards: 0, deadLinks: [], rewritten: 0 };
4237
+ // 正文层刚落过盘:stage 2 必须看到写完相关块之后的卡片。
4238
+ if (finalize.rewritten > 0)
4239
+ this.cards = await this.loadCards();
4240
+ /**
4241
+ * 索引页也是这次编译的产物,所以它变了也算「语料变了」。
4242
+ *
4243
+ * 少了这一条会踩一个很隐蔽的坑:一个**本来就没有任何关系**的知识库,收尾段一条
4244
+ * 卡片都不改,增量编译于是判定「没有变化」直接跳过 —— 索引页永远不会被写出来,
4245
+ * 而界面上没有任何一处说「这个文件没有生成」。
4246
+ */
4247
+ const indexText = buildWikiIndex({ cards: this.cards, related: finalize.related, deadLinks: finalize.deadLinks });
4248
+ const indexHash = contentHashOf(indexText);
4249
+ // 文件被手工删掉时也算「变了」:它是产物,不该因为「内容没变」就永远回不来。
4250
+ const indexChanged = indexHash !== previousManifest.indexHash || !existsSync(this.safePath('03_compiled', WIKI_INDEX));
4251
+ const finalizeCounts = {
4252
+ relatedLinks: finalize.relatedLinks,
4253
+ relatedCards: finalize.relatedCards,
4254
+ deadLinks: finalize.deadLinks.length,
4255
+ finalizeRewritten: finalize.rewritten,
4256
+ };
2212
4257
  // ------------------------------------------------ stage 2 · compile notes
2213
4258
  let cardsReused = 0;
2214
4259
  let cardsRecompiled = 0;
2215
4260
  let cardsAdded = 0;
2216
4261
  let notesReused = 0;
4262
+ /**
4263
+ * 复用 note 时**派生字段**(图片路径、来源分类、溯源链)被刷新过。
4264
+ *
4265
+ * 它必须参与「语料变了没有」的判定:这些字段不在 `cardHashOf` 里(它们能从正文
4266
+ * 重算出来),所以一张卡片可以「一个字都没变」而编译产物确实不同 —— 0047 实机
4267
+ * 踩到的就是这个:解析规则修好了,增量编译却判定「没有变化」,直接走空操作返回,
4268
+ * 刷新过的路径连同整个 `wiki_notes.json` 一起被丢掉。
4269
+ */
4270
+ let derivedRefreshed = false;
2217
4271
  let notesRecompiled = 0;
2218
4272
  const cardHashes = {};
2219
4273
  /**
@@ -2249,16 +4303,35 @@ export class KnowledgeRuntime {
2249
4303
  cardsAdded += 1;
2250
4304
  const kept = incremental && previousHashes[key] === fingerprint ? previousByWiki.get(key) : undefined;
2251
4305
  if (kept && kept.length > 0) {
2252
- // Reuse the notes verbatim, but refresh the two derived fields that can
2253
- // change *without* the card changing: a SourceRaw's business type and
2254
- // the card's provenance chain. Keeping a stale `sourceTypes` would make
2255
- // a source_type-filtered recall silently miss this card.
4306
+ // Reuse the notes verbatim, but refresh the derived fields that can
4307
+ // change *without* the card changing: a SourceRaw's business type, the
4308
+ // card's provenance chain, and the image paths. Keeping a stale
4309
+ // `sourceTypes` would make a source_type-filtered recall silently miss
4310
+ // this card; keeping stale `imagePaths` would keep the multimodal
4311
+ // encoder pointed at a path that no longer resolves (0047: the first
4312
+ // live rebuild reused notes whose paths were resolved by the old rule,
4313
+ // so the encoder still got `02_wiki/concept/_assets/…`).
4314
+ //
4315
+ // 评分不在这里刷新:它已经是 `cardHashOf` 的一部分,所以「评分变了」走的是
4316
+ // 重编那条分支,不会走到这里。
2256
4317
  const sourceTypesFor = card.sourceRefs.flatMap(ref => { const sourceType = sourceTypes.get(ref); return sourceType ? [sourceType] : []; });
2257
- notes.push(...kept.map(note => ({
2258
- ...note,
2259
- sourceTypes: sourceTypesFor,
2260
- ...(card.provenance ? { provenance: card.provenance } : {}),
2261
- })));
4318
+ const cardDir = join(this.directory(WIKI), card.entityType);
4319
+ notes.push(...kept.map(note => {
4320
+ // 图按 note **自己那一节**的引用算 —— 与 `compileCardNotes` 同一条规则,
4321
+ // 否则复用与重编会给出两套答案。
4322
+ const images = markdownImagePaths(note.content, cardDir, this.rootDir);
4323
+ if (JSON.stringify(note.imagePaths ?? []) !== JSON.stringify(images))
4324
+ derivedRefreshed = true;
4325
+ if (JSON.stringify(note.sourceTypes ?? []) !== JSON.stringify(sourceTypesFor))
4326
+ derivedRefreshed = true;
4327
+ const { imagePaths: _stale, ...rest } = note;
4328
+ return {
4329
+ ...rest,
4330
+ ...(images.length > 0 ? { imagePaths: images } : {}),
4331
+ sourceTypes: sourceTypesFor,
4332
+ ...(card.provenance ? { provenance: card.provenance } : {}),
4333
+ };
4334
+ }));
2262
4335
  cardsReused += 1;
2263
4336
  notesReused += kept.length;
2264
4337
  }
@@ -2276,7 +4349,19 @@ export class KnowledgeRuntime {
2276
4349
  // bumping the version there would invalidate the vector cache and every
2277
4350
  // qrels pointing at it for nothing. `all` never takes this path (see
2278
4351
  // `incremental` above).
2279
- const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0;
4352
+ /**
4353
+ * 归因图(字段级)也是这次编译的产物之一,所以它变了也算「语料变了」。
4354
+ *
4355
+ * 少了这一条会踩一个很隐蔽的坑:**记一次排查不改任何卡片**(它写的是一条
4356
+ * `agent_diagnosis_case` 的 SourceRaw),于是增量编译判定「没变化」直接跳过 ——
4357
+ * 而 `03_compiled/diagnosis_graph.json` 停在上一轮,图召回因此看不到这次排查
4358
+ * 新增的 `produces` / `supports` / `refutes` 边。它读的是**编译产物**,
4359
+ * 不是 `02_wiki` 的实时投影(§14.3)。
4360
+ */
4361
+ const diagnosis = await this.diagnosisStore().readGraph();
4362
+ const diagnosisHash = contentHashOf(JSON.stringify({ nodes: diagnosis.nodes, edges: diagnosis.edges }));
4363
+ const diagnosisChanged = diagnosisHash !== previousManifest.diagnosisHash;
4364
+ const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged || derivedRefreshed || indexChanged;
2280
4365
  if (!corpusChanged) {
2281
4366
  const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`;
2282
4367
  const report = {
@@ -2287,7 +4372,7 @@ export class KnowledgeRuntime {
2287
4372
  cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0,
2288
4373
  notesReused, notesRecompiled: 0,
2289
4374
  incremental: true, skipped: true,
2290
- ...extractionCounts,
4375
+ ...extractionCounts, ...finalizeCounts,
2291
4376
  // 这一轮没有重装图,所以没有一条边是「这次连上的」或「这次丢掉的」。
2292
4377
  // 报 0 而不是把图里已有的边算进来:那会把历史成果记到这次空操作头上。
2293
4378
  relationsExtracted: 0, relationsDropped: 0,
@@ -2297,7 +4382,7 @@ export class KnowledgeRuntime {
2297
4382
  // 用户刚点了编译却看到上次的统计,会以为按钮没生效。version 不变,所以
2298
4383
  // 这个写入既不改索引也不让向量缓存失效。extractionHashes 必须跟着更新:
2299
4384
  // 来源变了但没抽出卡片时,不落新指纹会让下一次又把它重抽一遍。
2300
- await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version: previousVersion, builtAt: previousManifest.builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: previousManifest.embedding, compileRunId, lastBuild: { only, incremental: true, skipped: true, cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0, notesReused, notesRecompiled: 0, ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
4385
+ 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, indexHash, compileRunId, lastBuild: { only, incremental: true, skipped: true, cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0, notesReused, notesRecompiled: 0, ...extractionCounts, ...finalizeCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
2301
4386
  return success(report, ['知识库没有变化:已复用全部 wikiNotes,未生成新的索引版本。', ...extraction.warnings]);
2302
4387
  }
2303
4388
  const nodeMap = new Map();
@@ -2321,6 +4406,23 @@ export class KnowledgeRuntime {
2321
4406
  // Compatibility mirror for tooling that predates wiki_notes.json.
2322
4407
  await writeTextAtomic(this.safePath('03_compiled', LEGACY_UNITS), JSON.stringify(this.units, null, 2));
2323
4408
  await writeTextAtomic(this.safePath('03_compiled', 'knowledge_graph.json'), JSON.stringify(this.graph, null, 2));
4409
+ /**
4410
+ * 字段级的归因图(0021 §14.3)。**同一次编译产出两份投影,各自由它的消费者读**:
4411
+ * `knowledge_graph.json` 是卡片级(图谱 Tab、图召回),这里是字段级
4412
+ * (智能归因三视图)。不合成一张 —— 消费者不同、`supported_by` 只对卡片成立、
4413
+ * 而召回权重的基准不该被顺手改掉(§14.4)。
4414
+ */
4415
+ this.diagnosisGraph = { nodes: diagnosis.nodes, edges: diagnosis.edges };
4416
+ await writeTextAtomic(this.safePath('03_compiled', DIAGNOSIS_GRAPH), JSON.stringify({ version, builtAt, nodes: diagnosis.nodes, edges: diagnosis.edges }, null, 2));
4417
+ /**
4418
+ * 收尾段的**产物层**:索引页(0049)。
4419
+ *
4420
+ * 放在图装配之后:索引页里那几条「相关」是**图上的边**,先写索引再装图,写出来的
4421
+ * 就会是上一轮的图。`indexText` 在正文层那一段已经算过一次(为了判断「这一轮到底
4422
+ * 有没有变化」),这里重算一遍是因为卡片清单可能被 stage 2 之后的写入改过 ——
4423
+ * 两处调的是同一个纯函数,所以不可能给出两种格式。
4424
+ */
4425
+ await this.writeWikiIndex({ cards: this.cards, related: finalize.related, deadLinks: finalize.deadLinks });
2324
4426
  // 关键词索引跟着 notes 一起落盘(0031):它的输入就是 notes,两者必须同时有效,
2325
4427
  // 否则会出现「卡片改了、关键词路还按旧词表召回」。它不依赖编码器,所以 wiki-only
2326
4428
  // 重建也要重建它 —— 而向量是 stage 3,仍然只在 `embeddings`/`all` 时重算。
@@ -2330,7 +4432,7 @@ export class KnowledgeRuntime {
2330
4432
  // re-encode of the whole corpus.
2331
4433
  const embeddingResult = only === 'all' ? await this.buildVectorIndex(this.units, version) : { status: 'skipped', count: 0, reused: 0 };
2332
4434
  const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`;
2333
- await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version, builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: embeddingResult, fts: ftsResult, compileRunId, lastBuild: { only, incremental: cardsReused > 0, skipped: false, cardsReused, cardsRecompiled, cardsAdded, cardsRemoved: removedCount, notesReused, notesRecompiled, ...extractionCounts, relationsExtracted, relationsDropped }, ...(lineage ? { lineage } : {}) }, null, 2));
4435
+ 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, indexHash, diagnosisGraph: { nodes: diagnosis.nodes.length, edges: diagnosis.edges.length }, lastBuild: { only, incremental: cardsReused > 0, skipped: false, cardsReused, cardsRecompiled, cardsAdded, cardsRemoved: removedCount, notesReused, notesRecompiled, ...extractionCounts, ...finalizeCounts, relationsExtracted, relationsDropped }, ...(lineage ? { lineage } : {}) }, null, 2));
2334
4436
  const report = {
2335
4437
  runId: compileRunId, compileRunId, version, only,
2336
4438
  cards: this.cards.length, units: this.units.length, notes: this.units.length,
@@ -2344,7 +4446,7 @@ export class KnowledgeRuntime {
2344
4446
  notesReused, notesRecompiled,
2345
4447
  incremental: cardsReused > 0 || notesReused > 0,
2346
4448
  skipped: false,
2347
- ...extractionCounts, relationsExtracted, relationsDropped,
4449
+ ...extractionCounts, ...finalizeCounts, relationsExtracted, relationsDropped,
2348
4450
  ...(lineage ? { lineage } : {}), startedAt, finishedAt: new Date().toISOString(),
2349
4451
  };
2350
4452
  if (embeddingResult.status === 'unavailable')
@@ -2368,8 +4470,8 @@ export class KnowledgeRuntime {
2368
4470
  vectorQuantization: embeddingResult.status === 'ready' ? VECTOR_QUANTIZATION : undefined,
2369
4471
  embeddingError: embeddingResult.error,
2370
4472
  incremental: embeddingResult.reused > 0, skipped: false,
2371
- // `embeddings` 不跑 Pass 0,所以这些计数是真的 0,不是省略。
2372
- ...extractionCounts, relationsExtracted: 0, relationsDropped: 0,
4473
+ // `embeddings` 不跑 Pass 0,也不跑收尾段,所以这些计数是真的 0,不是省略。
4474
+ ...extractionCounts, ...finalizeCounts, relationsExtracted: 0, relationsDropped: 0,
2373
4475
  ...(lineage ? { lineage } : {}), startedAt, finishedAt: new Date().toISOString(),
2374
4476
  };
2375
4477
  if (embeddingResult.status === 'unavailable')
@@ -2443,10 +4545,10 @@ export class KnowledgeRuntime {
2443
4545
  const eligibleIds = new Set(eligible.map(note => note.unitId));
2444
4546
  const sqlite = await loadSqlite();
2445
4547
  if (!sqlite)
2446
- return { ranking: [], diagnostic: { route: 'fts', status: 'degraded', reason: '当前 Node 没有 node:sqlite(需要 22.5+),关键词路已降级' } };
4548
+ return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: '当前 Node 没有 node:sqlite(需要 22.5+),关键词路已降级' } };
2447
4549
  const index = await FtsIndex.open(this.safePath('03_compiled', FTS_FILE), sqlite.DatabaseSync);
2448
4550
  if (!index)
2449
- return { ranking: [], diagnostic: { route: 'fts', status: 'degraded', reason: `关键词索引不存在(03_compiled/${FTS_FILE}),先编译一次` } };
4551
+ return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: `关键词索引不存在(03_compiled/${FTS_FILE}),先编译一次` } };
2450
4552
  try {
2451
4553
  const found = index.search(query, pool).filter(hit => eligibleIds.has(hit.unitId));
2452
4554
  /**
@@ -2469,9 +4571,10 @@ export class KnowledgeRuntime {
2469
4571
  // 单字查询(如 `表`)是刻意收窄的问法,没有「多字词覆盖率」可言:不拦它,
2470
4572
  // 交给阈值。这里如实返回空排名之外的结果需要另走一条路,所以直接用全部命中。
2471
4573
  const ranking = found.map(hit => ({ unitId: hit.unitId, score: hit.score }));
2472
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold);
4574
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold);
2473
4575
  return {
2474
4576
  ranking: kept,
4577
+ rows,
2475
4578
  diagnostic: {
2476
4579
  route: 'fts', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2477
4580
  ...(dropped > 0 ? { reason: `${dropped} 条低于关键词阈值 ${threshold} 被丢弃` } : {}),
@@ -2490,7 +4593,7 @@ export class KnowledgeRuntime {
2490
4593
  });
2491
4594
  const covered = scored.filter(hit => hit.coverage >= MIN_QUERY_COVERAGE);
2492
4595
  const ranking = covered.map(hit => ({ unitId: hit.unitId, score: hit.score }));
2493
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold);
4596
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold);
2494
4597
  const lowCoverage = scored.length - covered.length;
2495
4598
  const reasons = [];
2496
4599
  if (lowCoverage > 0)
@@ -2499,6 +4602,7 @@ export class KnowledgeRuntime {
2499
4602
  reasons.push(`${dropped} 条低于关键词阈值 ${threshold} 被丢弃`);
2500
4603
  return {
2501
4604
  ranking: kept,
4605
+ rows,
2502
4606
  diagnostic: {
2503
4607
  route: 'fts', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2504
4608
  ...(reasons.length > 0 ? { reason: reasons.join(';') } : {}),
@@ -2519,14 +4623,16 @@ export class KnowledgeRuntime {
2519
4623
  async vectorRoute(query, eligibleIds, pool, threshold) {
2520
4624
  const health = await this.checkEncoder();
2521
4625
  if (!health.ok)
2522
- return { ranking: [], diagnostic: { route: 'vector', status: 'degraded', reason: health.reason }, health };
4626
+ return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason: health.reason }, health };
2523
4627
  try {
2524
- const scores = await this.vectorRecall(query, eligibleIds, pool);
4628
+ const { scores, embedMs, scanMs } = await this.vectorRecall(query, eligibleIds, pool);
2525
4629
  const ranking = [...scores.entries()].map(([unitId, score]) => ({ unitId, score })).sort((left, right) => right.score - left.score || left.unitId.localeCompare(right.unitId));
2526
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold);
4630
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold);
2527
4631
  return {
2528
4632
  ranking: kept,
4633
+ rows,
2529
4634
  health,
4635
+ timings: { embedMs, scanMs },
2530
4636
  diagnostic: {
2531
4637
  route: 'vector', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2532
4638
  ...(dropped > 0 ? { reason: `${dropped} 条低于向量阈值 ${threshold} 被丢弃` } : {}),
@@ -2536,7 +4642,7 @@ export class KnowledgeRuntime {
2536
4642
  catch (cause) {
2537
4643
  const error = cause instanceof Error ? cause.message : String(cause);
2538
4644
  this.vectorError = error;
2539
- return { ranking: [], diagnostic: { route: 'vector', status: 'degraded', reason: `向量召回不可用:${error}` }, health };
4645
+ return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason: `向量召回不可用:${error}` }, health };
2540
4646
  }
2541
4647
  }
2542
4648
  /**
@@ -2545,7 +4651,67 @@ export class KnowledgeRuntime {
2545
4651
  * 匹配逻辑与 0030 相同(词面匹配节点 title / id 与关系词,命中节点带出 1 跳邻居并衰减),
2546
4652
  * 但返回值只用于给**已经在候选池里**的条目加权 —— 它自己不能制造候选。
2547
4653
  */
2548
- graphBoostRanking(query, eligible, includeProblems) {
4654
+ /**
4655
+ * 字段级的归因图(`03_compiled/diagnosis_graph.json`)。
4656
+ *
4657
+ * 它只用于**给已经在候选池里的条目加权**(和卡片级图一样,不能自己制造候选)。
4658
+ * 用途是把「假设 → 检查点」「证据 → 假设」这两类卡片级图看不见的语义带进召回。
4659
+ */
4660
+ diagnosisGraph = { nodes: [], edges: [] };
4661
+ /**
4662
+ * 归因图上按词面命中的节点 → 分数,含 1 跳衰减。
4663
+ *
4664
+ * 与卡片级那一段是**同一条算法、两份数据**:卡片级图里只有 `supported_by` /
4665
+ * `relates_to`,而字段级图里是十种语义边。分开算再按 `entityId` 合流,
4666
+ * 是因为两张图的节点含义不同(一个是卡片,一个是字段级节点),合成一张会让
4667
+ * 图谱 Tab 看到一张卡片散成七个点。
4668
+ */
4669
+ diagnosisHits(queryTokens) {
4670
+ const seed = new Map();
4671
+ if (this.diagnosisGraph.nodes.length === 0)
4672
+ return seed;
4673
+ for (const node of this.diagnosisGraph.nodes) {
4674
+ const title = String(node.title ?? '').toLocaleLowerCase();
4675
+ const id = String(node.id ?? '').toLocaleLowerCase();
4676
+ let score = 0;
4677
+ for (const token of queryTokens) {
4678
+ if (title.includes(token))
4679
+ score += 3;
4680
+ else if (id.includes(token))
4681
+ score += 2;
4682
+ }
4683
+ if (score > 0)
4684
+ seed.set(String(node.id), score);
4685
+ }
4686
+ // 关系词命中:两端各得 1 分。中文提问不会写 `refutes`,所以走 RELATION_QUERY_WORDS。
4687
+ for (const edge of this.diagnosisGraph.edges) {
4688
+ const type = String(edge.kind ?? '');
4689
+ const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase();
4690
+ let score = 0;
4691
+ for (const token of queryTokens)
4692
+ if (relation.includes(token))
4693
+ score += 1;
4694
+ if (score > 0) {
4695
+ seed.set(String(edge.source), Math.max(seed.get(String(edge.source)) ?? 0, score));
4696
+ seed.set(String(edge.target), Math.max(seed.get(String(edge.target)) ?? 0, score));
4697
+ }
4698
+ }
4699
+ if (seed.size === 0)
4700
+ return seed;
4701
+ const expanded = new Map(seed);
4702
+ for (const [id, score] of seed) {
4703
+ for (const edge of this.diagnosisGraph.edges) {
4704
+ const from = String(edge.source);
4705
+ const to = String(edge.target);
4706
+ if (from === id)
4707
+ expanded.set(to, Math.max(expanded.get(to) ?? 0, score * 0.5));
4708
+ else if (to === id)
4709
+ expanded.set(from, Math.max(expanded.get(from) ?? 0, score * 0.5));
4710
+ }
4711
+ }
4712
+ return expanded;
4713
+ }
4714
+ async graphBoostRanking(query, eligible, includeProblems) {
2549
4715
  if (this.graph.nodes.length === 0)
2550
4716
  return [];
2551
4717
  const queryTokens = segment(query);
@@ -2555,6 +4721,10 @@ export class KnowledgeRuntime {
2555
4721
  const boost = (key, score) => { byNodeKey.set(key, Math.max(byNodeKey.get(key) ?? 0, score)); };
2556
4722
  const visible = (node) => includeProblems || node.status !== 'problem';
2557
4723
  const nodeKey = (node) => `${String(node.type)}:${String(node.id)}`;
4724
+ /** 卡片级节点 id → 它的 `entity_type`。归因图的节点 id 与卡片 id 相等(§14.1)。 */
4725
+ const typeByEntityId = new Map();
4726
+ for (const node of this.graph.nodes)
4727
+ typeByEntityId.set(String(node.id), String(node.type));
2558
4728
  for (const node of this.graph.nodes) {
2559
4729
  if (!visible(node))
2560
4730
  continue;
@@ -2582,6 +4752,19 @@ export class KnowledgeRuntime {
2582
4752
  boost(String(edge.to), score);
2583
4753
  }
2584
4754
  }
4755
+ /**
4756
+ * **字段级的归因图**(0021 §14.4 的第二条最小路径)。
4757
+ *
4758
+ * 卡片级的图只认 `supported_by` 与 `relates_to` 两种关系词,所以「假设 → 检查点」
4759
+ * 与「证据 → 假设」这两类语义对召回完全不可见。这里按 `entityId` 把两张图接起来:
4760
+ * 归因图上命中的节点,映射回同 id 的卡片级节点加权 —— **不需要合图**,
4761
+ * 因为两张图的节点 id 本来就相等(§14.1)。
4762
+ */
4763
+ for (const [id, score] of this.diagnosisHits(queryTokens)) {
4764
+ const type = typeByEntityId.get(id);
4765
+ if (type)
4766
+ boost(`${type}:${id}`, score);
4767
+ }
2585
4768
  if (byNodeKey.size === 0)
2586
4769
  return [];
2587
4770
  const expanded = new Map(byNodeKey);
@@ -2647,6 +4830,11 @@ export class KnowledgeRuntime {
2647
4830
  }
2648
4831
  async recall(query, limit, sourceTypes = [], includeProblems = false, mode, lineage, options = {}) {
2649
4832
  const recallId = generatedId('recall');
4833
+ /**
4834
+ * 各阶段耗时(0050 第二版)。用 `performance.now()` 而不是 `Date.now()`:
4835
+ * 向量扫描这一段本身就在毫秒以下,用墙钟分辨率量不出东西来。
4836
+ */
4837
+ const startedAt = performance.now();
2650
4838
  const recallMode = mode ?? this.config.recallMode;
2651
4839
  const recallTopK = limit ?? this.config.recallTopK;
2652
4840
  const requestedTopN = options.contextTopN ?? this.config.contextTopN;
@@ -2705,37 +4893,69 @@ export class KnowledgeRuntime {
2705
4893
  const useFts = recallMode !== 'vector';
2706
4894
  const useVector = recallMode !== 'fts';
2707
4895
  const useGraphBoost = recallMode === 'hybrid' || recallMode === 'graph';
4896
+ const ftsStarted = performance.now();
2708
4897
  const ftsOutcome = useFts
2709
4898
  ? await this.ftsRoute(query, eligible, candidatePool, this.config.keywordThreshold, titleOf)
2710
- : { ranking: [], diagnostic: { route: 'fts', status: 'skipped', reason: '当前模式未启用关键词路' } };
4899
+ : { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'skipped', reason: '当前模式未启用关键词路' } };
4900
+ const ftsMs = performance.now() - ftsStarted;
4901
+ const vectorStarted = performance.now();
2711
4902
  const vectorOutcome = useVector
2712
4903
  ? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold)
2713
- : { ranking: [], diagnostic: { route: 'vector', status: 'skipped', reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } };
2714
- const graphRanking = useGraphBoost ? this.graphBoostRanking(query, eligible, includeProblems) : [];
4904
+ : { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'skipped', reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } };
4905
+ const vectorMs = performance.now() - vectorStarted;
4906
+ const graphStarted = performance.now();
4907
+ const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems) : [];
4908
+ const graphMs = performance.now() - graphStarted;
2715
4909
  if (ftsOutcome.diagnostic.status === 'degraded' && ftsOutcome.diagnostic.reason)
2716
4910
  warnings.push(ftsOutcome.diagnostic.reason);
2717
4911
  if (vectorOutcome.diagnostic.status === 'degraded' && vectorOutcome.diagnostic.reason)
2718
4912
  warnings.push(vectorOutcome.diagnostic.reason);
2719
4913
  // --- R2 · 名次级融合(RRF)+ 图谱 boost ---------------------------------
4914
+ const fuseStarted = performance.now();
2720
4915
  const fused = rrfFuse({ fts: ftsOutcome.ranking, vector: vectorOutcome.ranking }, { k: RRF_K, weights: RRF_WEIGHTS });
2721
4916
  const boosted = graphBoost(fused, graphRanking, { k: RRF_K, weights: RRF_WEIGHTS });
2722
4917
  const unitById = new Map(eligible.map(unit => [unit.unitId, unit]));
4918
+ /**
4919
+ * 0044:review 评分在这里接进排序。
4920
+ *
4921
+ * 它**只改排序键,不改 `score` 字段本身** —— 命中里那个 `score` 仍然是融合分/重排分,
4922
+ * 是「这一路觉得它多相关」的原始答案。把评分乘进去再写回 `score`,会让读的人以为
4923
+ * 相关性变了;而它变的只是「同类命中里先看谁」。
4924
+ *
4925
+ * 生效点有两处,缺一处就会出现「review 了但排序没动」:
4926
+ * 1. 重排前的候选顺序(决定哪些条目进入重排的视线);
4927
+ * 2. 重排后的截断顺序(`recallTopK`)。
4928
+ *
4929
+ * 0050 之前还有第三处(上下文选择的 MMR 相关性函数)。现在上下文按**卡片**去重、
4930
+ * 顺序直接沿用 `hits`(`notesByWiki` 的顺序就是它),所以那第三处自然消失了 ——
4931
+ * 不是被删掉,而是不再需要单独算一遍。
4932
+ */
4933
+ const rankKey = (hit) => (hit.rerankScore ?? hit.score) * reviewWeight(hit.reviewScore);
2723
4934
  let hits = boosted.hits
2724
4935
  .filter(hit => unitById.has(hit.unitId))
2725
- .map(hit => ({ ...unitById.get(hit.unitId), score: hit.score, match: matchOf(hit.ranks), ranks: hit.ranks }));
4936
+ .map(hit => ({ ...unitById.get(hit.unitId), score: hit.score, match: matchOf(hit.ranks), ranks: hit.ranks }))
4937
+ .sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId));
4938
+ const fuseMs = performance.now() - fuseStarted;
2726
4939
  // --- R3 · 对**候选池**重排,再过阈值 ------------------------------------
2727
4940
  let rerank;
4941
+ const rerankStarted = performance.now();
4942
+ /** 逐条模型分(0050 的面板账本用):`kept` 只给活下来的,账本要全部。 */
4943
+ let rerankScored = [];
2728
4944
  if (options.rerank === false || this.config.rerankProvider === 'none') {
2729
4945
  rerank = { status: 'skipped', provider: this.config.rerankProvider };
2730
4946
  hits = hits.slice(0, recallTopK);
2731
4947
  }
2732
4948
  else {
2733
4949
  const reranked = await this.rerankNotes(query, hits, this.config.rerankThreshold);
4950
+ rerankScored = reranked.scored ?? [];
2734
4951
  hits = reranked.hits.slice(0, recallTopK);
2735
4952
  rerank = reranked.result;
2736
4953
  if (reranked.warning)
2737
4954
  warnings.push(reranked.warning);
2738
4955
  }
4956
+ // 重排换的是相关性顺序,评分加权仍然要压在最上面 —— 否则「review 过的排前面一点」
4957
+ // 只在关掉重排时成立,而重排默认是开着的。
4958
+ hits = [...hits].sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId));
2739
4959
  // --- R4 · 证据片段 -----------------------------------------------------
2740
4960
  await this.attachEvidence(hits, queryTerms, corpus);
2741
4961
  // --- R5 · 弃答 ---------------------------------------------------------
@@ -2775,35 +4995,146 @@ export class KnowledgeRuntime {
2775
4995
  } : {}),
2776
4996
  };
2777
4997
  // --- 组装:片段 → 整篇 → 证据 -------------------------------------------
4998
+ const assembleStarted = performance.now();
2778
4999
  const cards = this.cards.length > 0 ? this.cards : await this.loadCards();
2779
5000
  const cardByKey = new Map(cards.map(card => [`${card.entityType}:${card.entityId}`, card]));
2780
5001
  const wikiKey = (note) => `${note.entityType}:${note.entityId}`;
2781
- const wiki = [...new Set(hits.map(wikiKey))].map(key => cardByKey.get(key)).filter((card) => card !== undefined);
2782
- const graph = this.oneHopGraph(new Set(hits.map(wikiKey)), includeProblems);
2783
- // 上下文条目的选择**不是**简单切片:每张 wiki 封顶 + MMR 去冗余,否则 5 条上下文
2784
- // 可能全是同一张卡的不同小节(一屏塞满、信息量为 1)。
2785
- const contextHits = selectContext(hits, {
2786
- relevance: hit => hit.rerankScore ?? hit.score,
2787
- tokens: hit => tokenSet(`${hit.title}\n${hit.content}`),
2788
- wikiKey: hit => hit.entityId,
2789
- limit: contextTopN,
2790
- perWikiCap: CONTEXT_PER_WIKI_CAP,
2791
- });
2792
- const context = contextHits.map(note => ({
2793
- note,
2794
- wiki: cardByKey.get(wikiKey(note)) ?? null,
2795
- graph: this.oneHopGraph(new Set([wikiKey(note)]), includeProblems),
2796
- ...(note.evidence ? { evidence: note.evidence } : {}),
2797
- }));
5002
+ /**
5003
+ * 按 **wiki 卡片**去重(0050 的第一半)。
5004
+ *
5005
+ * `hits` 已经按排序键降序,所以每张卡片第一次出现的那条就是它**最好的一条 note**
5006
+ * —— 那就是这张卡片的等级。同一张卡的其余小节挂在它下面(`notes`),
5007
+ * 不再各自占一个上下文名额。
5008
+ */
5009
+ const notesByWiki = new Map();
5010
+ for (const hit of hits) {
5011
+ const key = wikiKey(hit);
5012
+ const bucket = notesByWiki.get(key);
5013
+ if (bucket)
5014
+ bucket.push(hit);
5015
+ else
5016
+ notesByWiki.set(key, [hit]);
5017
+ }
5018
+ /**
5019
+ * 命中的卡片,顺序 = 各自最好那条 note 的顺序。
5020
+ *
5021
+ * `contextTopN` 约束的是**这里**:进上下文的命中卡片数。1 跳扩展来的卡片另算
5022
+ * (它们受上下文预算与 `MAX_EXPANSION_CARDS` 约束)—— 扩展是「延伸」,
5023
+ * 不该挤掉「证据」的名额,也不该被 top_n 卡死。
5024
+ */
5025
+ const hitCards = [...notesByWiki.entries()]
5026
+ .map(([key, notes]) => ({ key, notes, card: cardByKey.get(key) }))
5027
+ .slice(0, contextTopN);
5028
+ const wiki = hitCards.map(item => item.card).filter((card) => card !== undefined);
5029
+ const graph = this.oneHopGraph(new Set(hitCards.map(item => item.key)), includeProblems);
5030
+ /**
5031
+ * **在去重之后**做 1 跳扩展(0050 的第二半)。
5032
+ *
5033
+ * 反过来(先扩展再去重)会把邻居与被命中的卡片混在一起,分不清「这是召回来的」
5034
+ * 还是「这是邻居」—— 而这两件事在上下文里必须能分开,前者是证据,后者是延伸。
5035
+ */
5036
+ const expansion = this.expandWiki(hitCards.map(item => item.key), cardByKey, includeProblems);
5037
+ /** 上下文条目:先命中的卡片(带它们的 note),再扩展来的卡片。 */
5038
+ const items = [
5039
+ ...hitCards.map(item => ({
5040
+ wiki: item.card ?? null,
5041
+ notes: item.notes,
5042
+ note: item.notes[0],
5043
+ graph: this.oneHopGraph(new Set([item.key]), includeProblems),
5044
+ reason: 'hit',
5045
+ ...(item.notes[0].evidence ? { evidence: item.notes[0].evidence } : {}),
5046
+ })),
5047
+ ...expansion.nodes.map(node => {
5048
+ const key = `${node.entityType}:${node.entityId}`;
5049
+ const card = cardByKey.get(key) ?? null;
5050
+ const [fromKey, relation] = (node.via[0] ?? '').split('|');
5051
+ const from = fromKey === undefined ? undefined : cardByKey.get(fromKey);
5052
+ return {
5053
+ wiki: card,
5054
+ notes: [],
5055
+ graph: this.oneHopGraph(new Set([key]), includeProblems),
5056
+ reason: 'expansion',
5057
+ ...(from === undefined ? {} : { via: { entityType: from.entityType, entityId: from.entityId, title: from.title, relation: relation ?? '' } }),
5058
+ };
5059
+ }),
5060
+ ];
5061
+ /**
5062
+ * 上下文预算(0050):装箱 + 溢出。
5063
+ *
5064
+ * 让位顺序写死在 `packContext` 里:先保证命中 note 的正文完整(它是证据),
5065
+ * 再给扩展卡片的标题 + 摘要 + 关系,最后才给扩展卡片的正文。
5066
+ */
5067
+ /**
5068
+ * 每次调用可以覆盖预算(0050):指路行里就写着「读不到就调大预算重新召回」,
5069
+ * 所以那条出路必须真的存在 —— 只给一个设置项的话,Agent 改不了它。
5070
+ */
5071
+ const maxChars = options.contextMaxChars ?? this.config.contextMaxChars;
5072
+ const packed = await this.packContext({ items, maxChars, recallId });
5073
+ /** 哪些扩展卡片真的进了上下文(被预算挡住的也如实报出来)。 */
5074
+ expansion.included = packed.items
5075
+ .filter(item => item.reason === 'expansion' && item.wiki !== null)
5076
+ .map(item => `${item.wiki.entityType}:${item.wiki.entityId}`);
5077
+ if (packed.notice)
5078
+ warnings.push(packed.notice);
5079
+ /**
5080
+ * 面板账本(0050):`detail: true` 才建。
5081
+ *
5082
+ * 它回答的是「这一条是怎么进来的、算了几分、有没有被丢」—— 而这三问分别对应
5083
+ * 关键词路 / 向量路 / 重排。工具调用不带它:几十行候选分数进模型上下文是纯噪声。
5084
+ */
5085
+ const ledger = options.detail === true
5086
+ ? {
5087
+ fts: this.ledgerRows(ftsOutcome.rows, unitById),
5088
+ vector: this.ledgerRows(vectorOutcome.rows, unitById),
5089
+ rerank: (() => {
5090
+ const byUnit = new Map(rerankScored.map(row => [row.unitId, row]));
5091
+ const fusedByUnit = new Map(boosted.hits.map(hit => [hit.unitId, hit.score]));
5092
+ const order = rerankScored.length > 0 ? rerankScored.map(row => row.unitId) : hits.map(hit => hit.unitId);
5093
+ return order.flatMap((unitId, index) => {
5094
+ const note = unitById.get(unitId);
5095
+ if (note === undefined)
5096
+ return [];
5097
+ const model = byUnit.get(unitId);
5098
+ return [{
5099
+ unitId,
5100
+ title: note.title,
5101
+ entityType: note.entityType,
5102
+ entityId: note.entityId,
5103
+ score: model?.rerankScore ?? fusedByUnit.get(unitId) ?? 0,
5104
+ rank: index + 1,
5105
+ kept: model?.kept ?? true,
5106
+ fused: fusedByUnit.get(unitId) ?? 0,
5107
+ ...(model === undefined ? {} : { logit: model.logit, rerankScore: model.rerankScore }),
5108
+ // 没有模型分就是「重排没跑」或「这条没进重排」;有模型分而没 kept 就是被阈值丢了。
5109
+ dropped: model !== undefined && !model.kept,
5110
+ }];
5111
+ });
5112
+ })(),
5113
+ }
5114
+ : undefined;
5115
+ /** 每个数都是**这一段自己**的耗时(不是累计),量级差得远时一眼能看出是谁慢。 */
5116
+ const timings = {
5117
+ ftsMs: round1(ftsMs),
5118
+ vectorMs: round1(vectorMs),
5119
+ ...(vectorOutcome.timings === undefined ? {} : { embedMs: round1(vectorOutcome.timings.embedMs), scanMs: round1(vectorOutcome.timings.scanMs) }),
5120
+ graphMs: round1(graphMs),
5121
+ fuseMs: round1(fuseMs),
5122
+ ...(rerank.status === 'ready' ? { rerankMs: round1(performance.now() - rerankStarted) } : {}),
5123
+ // 组装从「按卡片去重」算起(重排之后),所以它不含重排自己的时间。
5124
+ assembleMs: round1(performance.now() - assembleStarted),
5125
+ totalMs: round1(performance.now() - startedAt),
5126
+ };
2798
5127
  const data = {
2799
- hits, context, wiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, contextTopN,
2800
- insufficientEvidence, diagnostics, recallId,
5128
+ hits, context: packed.items, wiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, contextTopN,
5129
+ insufficientEvidence, diagnostics, recallId, timings,
5130
+ expansion, budget: packed.report,
5131
+ ...(ledger === undefined ? {} : { ledger }),
2801
5132
  ...(indexVersion === undefined ? {} : { indexVersion }),
2802
5133
  ...(compileRunId === undefined ? {} : { compileRunId }),
2803
5134
  ...(eventLineage ? { lineage: eventLineage } : {}),
2804
5135
  };
2805
5136
  await this.audit('knowledge.recall', {
2806
- query: query.slice(0, 4000), count: hits.length, context: context.length, backend, routes: ranRoutes,
5137
+ query: query.slice(0, 4000), count: hits.length, context: packed.items.length, backend, routes: ranRoutes,
2807
5138
  rerank: rerank.status, rerankThreshold: this.config.rerankThreshold, candidatePool,
2808
5139
  ...(rerank.dropped === undefined ? {} : { rerankDropped: rerank.dropped }),
2809
5140
  ...(rerank.topLogit === undefined ? {} : { rerankTopLogit: Number(rerank.topLogit.toFixed(4)) }),
@@ -2830,7 +5161,12 @@ export class KnowledgeRuntime {
2830
5161
  throw new Error('VECTOR_INDEX_NOT_READY');
2831
5162
  if (index.manifest.provider !== this.config.embeddingProvider || index.manifest.model !== this.embeddingModelId || index.manifest.dimension !== this.config.embeddingDimension)
2832
5163
  throw new Error('VECTOR_INDEX_CONFIG_MISMATCH');
5164
+ // 查询编码单独计时:模型没加载时这一段是 8 秒,加载之后是 0.3 秒 —— 这一段的
5165
+ // 数字就是「测试为什么慢」的答案本身,混进扫描时间里就看不出来了。
5166
+ const embedStarted = performance.now();
2833
5167
  const encoded = await this.encodeEmbeddings([{ text: query }]);
5168
+ const embedMs = performance.now() - embedStarted;
5169
+ const scanStarted = performance.now();
2834
5170
  const queryBinary = binaryVector(encoded.vectors[0], index.manifest.dimension);
2835
5171
  const queryInt8 = int8Vector(encoded.vectors[0], index.manifest.dimension);
2836
5172
  const binaryWidth = Math.ceil(index.manifest.dimension / 8);
@@ -2859,7 +5195,7 @@ export class KnowledgeRuntime {
2859
5195
  dot += signedByte(queryInt8[dimension]) * signedByte(index.int8[offset + dimension]);
2860
5196
  scores.set(item.unitId, Math.max(-1, Math.min(1, dot / (127 * 127))));
2861
5197
  }
2862
- return scores;
5198
+ return { scores, embedMs, scanMs: performance.now() - scanStarted };
2863
5199
  }
2864
5200
  /**
2865
5201
  * 解析后的三个「家当」路径(0029)。
@@ -2930,7 +5266,7 @@ export class KnowledgeRuntime {
2930
5266
  content,
2931
5267
  // 索引命中不了时退回按 id + 分类算出来的路径:那条路径**就是**写入时用的,
2932
5268
  // 所以它仍然指向真实文件(索引缺失只可能是并发写入还没失效缓存)。
2933
- path: index.get(slug(source.sourceId)) ?? this.sourcePath(source.sourceId, source.sourceType),
5269
+ path: index.get(slug(source.sourceId)) ?? this.sourcePath(source.sourceId, source.sourceType, source.title),
2934
5270
  returnedChars: content.length,
2935
5271
  totalChars: source.content.length,
2936
5272
  });
@@ -2941,9 +5277,26 @@ export class KnowledgeRuntime {
2941
5277
  }
2942
5278
  async graphQuery(action, entityId, entityType, depth = 1, options = {}, lineage) {
2943
5279
  const eventLineage = withRun(lineage, 'graph-query');
2944
- if (this.units.length === 0)
2945
- await this.rebuild('all', eventLineage);
5280
+ /**
5281
+ * **读盘,不编译。**
5282
+ *
5283
+ * `units` 为空只说明这个 runtime 还没读过 `03_compiled/`,**不等于**盘上没有编译
5284
+ * 产物:runtime 是按库缓存的,设置一变就被丢掉(`KnowledgeBases.invalidate()`),
5285
+ * 下一次读图时内存里又是空的。原来的写法把这两种情况当成同一件事,在这里直接
5286
+ * `rebuild('all')` —— 于是一次**读**变成一次全量编译:面板刷新时那个
5287
+ * `GET /knowledge/graph` 在几百篇文档的库上要等 3~5 秒,而这段时间整个知识库面板
5288
+ * 连同「类型定义」都是不可操作的。
5289
+ *
5290
+ * 编译是**写**动作,归「编译」按钮与 `POST /knowledge/rebuild`。读路径只如实报告
5291
+ * 「盘上还没有编译产物」(`needsBuild`),要不要编由界面和 Agent 自己决定 ——
5292
+ * 与 `recall` 同一条规矩:先读盘,读不到再说。
5293
+ */
5294
+ if (this.units.length === 0 && this.graph.nodes.length === 0)
5295
+ await this.loadCompiledState();
5296
+ const needsBuild = this.units.length === 0 && this.graph.nodes.length === 0;
2946
5297
  const attachLineage = (data) => eventLineage ? { ...data, lineage: eventLineage } : data;
5298
+ /** 空图 + 一句为什么空:调用方(尤其是 Agent)不能把「还没编译」读成「这个库没有知识」。 */
5299
+ const attachBuild = (data) => needsBuild ? { ...data, needsBuild: true } : data;
2947
5300
  if (depth < 1 || depth > 3)
2948
5301
  return failure({}, 'DEPTH_INVALID', 'depth 必须在 1 到 3 之间', false);
2949
5302
  const limit = options.limit ?? 50;
@@ -2959,7 +5312,7 @@ export class KnowledgeRuntime {
2959
5312
  const nodes = allNodes.filter(node => !entityType || node.type === entityType);
2960
5313
  const edges = this.graph.edges.filter(edge => allNodes.some(node => `${node.type}:${node.id}` === String(edge.from)) && allNodes.some(node => `${node.type}:${node.id}` === String(edge.to)));
2961
5314
  if (action === 'list')
2962
- return success(attachLineage({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) }));
5315
+ return success(attachLineage(attachBuild({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) })));
2963
5316
  const nodeKey = (rawId, rawType) => {
2964
5317
  if (!rawId)
2965
5318
  return undefined;
@@ -2998,14 +5351,14 @@ export class KnowledgeRuntime {
2998
5351
  }
2999
5352
  }
3000
5353
  if (!found)
3001
- return success(attachLineage({ path: [], nodes: [], edges: [] }));
5354
+ return success(attachLineage(attachBuild({ path: [], nodes: [], edges: [] })));
3002
5355
  return success(attachLineage({ path: found, nodes: allNodes.filter(node => found.includes(`${node.type}:${node.id}`)), edges: edges.filter(edge => found.includes(String(edge.from)) && found.includes(String(edge.to))) }));
3003
5356
  }
3004
5357
  const id = nodeKey(entityId, entityType);
3005
5358
  if (!id)
3006
5359
  return failure({}, 'ENTITY_REQUIRED', '该 graph action 需要 entity_id', true);
3007
5360
  if (action === 'entity')
3008
- return success(attachLineage({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) }));
5361
+ return success(attachLineage(attachBuild({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) })));
3009
5362
  if (action === 'neighbors') {
3010
5363
  const ids = new Set([id]);
3011
5364
  for (let index = 0; index < depth; index++)
@@ -3015,7 +5368,7 @@ export class KnowledgeRuntime {
3015
5368
  if (ids.has(String(edge.to)))
3016
5369
  ids.add(String(edge.from));
3017
5370
  }
3018
- return success(attachLineage({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) }));
5371
+ return success(attachLineage(attachBuild({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) })));
3019
5372
  }
3020
5373
  return failure({}, 'ACTION_INVALID', `不支持的 graph action: ${action}`, false);
3021
5374
  }