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
package/src/runtime.ts CHANGED
@@ -3,36 +3,59 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { appendFile, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } 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
8
  import {
8
9
  buildSourceTypeCatalog, defaultSourceTypes, DEFAULT_KNOWLEDGE_CONFIG, deriveSourceExt,
9
10
  FALLBACK_EMBEDDING_MODEL, FALLBACK_RERANK_MODEL, legacyResourceHome, SOURCE_TYPE_ID_PATTERN,
10
11
  LOCAL_EMBEDDING_MODEL_DIR, LOCAL_RERANK_MODEL_DIR,
11
- type CardSchemaIssue, type ContextGraph, type ContextItem, type DocumentVersion, type Envelope,
12
+ ANOMALY_SCORE, CARD_REVIEW_BASE_SCORE, REVIEWED_SCORE_FLOOR,
13
+ type CardAnnotation, type CardReview, type CardSchemaIssue, type ContextGraph, type ContextItem, type DocumentVersion, type Envelope,
12
14
  type KnowledgeAssetRef, type KnowledgeCard, type KnowledgeConfig, type KnowledgeDocument,
13
15
  type KnowledgeDocumentDetail, type KnowledgeError, type KnowledgeStatus, type KnowledgeUnit,
14
- type RecallDiagnostics, type RecallEvidence, type RecallHit, type RecallMode, type RecallResult, type RecallRoute, type RecallRouteDiagnostic, type RebuildReport,
15
- type RerankResult, type RuntimeKnowledgeConfig, type SchemaType, type SearchHit, type SourceRaw, type SourceRawRead, type SourceType,
16
+ type ContextBudgetReport, type RecallDiagnostics, type RecallEvidence, type RecallExpansion, type RecallHit, type RecallLedger, type RecallLedgerRow, type RecallMode, type RecallResult, type RecallTimings, type RecallRoute, type RecallRouteDiagnostic, type RebuildReport,
17
+ type RerankResult, type RuntimeKnowledgeConfig, type SchemaType, type SearchHit, type SourceMetadataField, type SourceOriginRef, type SourceProvenance, type SourceRaw, type SourceRawRead, type SourceType,
16
18
  type SourceTypeCatalog, type SourceTypeDef, type WikiCardVersion, type WikiNote,
17
- type WikiSchemaMode, type WikiTypeCatalog, type WikiTypeDef,
19
+ type WikiTypeCatalog, type WikiTypeDef,
18
20
  } from './types.js'
19
21
  import { defaultWikiTypes, validateCardAgainstType, WIKI_TYPE_RULES } from './wiki-types.js'
22
+ import { LEGACY_ENTITY_TYPES } from './diagnosis/schema.js'
23
+ import { DiagnosisStore } from './diagnosis/store.js'
20
24
  import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandidates, RELATES_TO, type NormalizedCandidates } from './extract.js'
21
- import { extractWithModel, type ModelCall } from './extract-model.js'
25
+ import { extractWithModel, skeletonBody, type ModelCall } from './extract-model.js'
22
26
  import { FTS_FILE, FtsIndex, loadSqlite, segment } from './fts.js'
23
- import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, rrfFuse, selectContext, tokenSet, type RouteRanking } from './recall-rank.js'
27
+ import { insertImage, planSourceImages, type SourceImage } from './source-images.js'
28
+ import {
29
+ applyRelatedSection, buildWikiIndex, cardKey, deadLinkIssue, deadLinksOf, planRelated, stripRelatedSection,
30
+ type DeadLink, type RelatedEntry,
31
+ } from './wiki-finalize.js'
32
+ import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, reviewWeight, rrfFuse, tokenSet, type RouteRanking } from './recall-rank.js'
24
33
  import { legacyKnowledgeRoot } from './layout.js'
34
+ import { unlinkIfExists, writeBufferAtomic, writeTextAtomic } from './fs-atomic.js'
25
35
  import { envelopeFailure, envelopeSuccess } from 'dsh-data-contracts/envelope'
26
36
  import type { LineageContext, LineageRef } from 'dsh-data-contracts/refs'
27
37
 
28
- // 05_diagnosis 是派生层(智能归因图谱):内容可由 02_wiki + 03_compiled 重建,
29
- // 和 03_compiled 同性质,所以给独立编号段而不是塞进源头目录。
38
+ // **知识树只有三段**(v1.1 起):`01_source_raw` / `02_wiki` / `03_compiled`。
39
+ //
40
+ // 撤掉的两段,理由都写在这里,避免下一个人重新加回来:
30
41
  //
31
- // `00_schema` 在 0030 被撤掉了:它从建出来那天起就是一个**空目录**。分类注册表
32
- // 与 Wiki 类型注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是
33
- // 配置,跟着根路径走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。
34
- // 「磁盘上有一个文件夹」本身是一种承诺,承诺了却空着就是误导。
35
- const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled', '05_diagnosis'] as const
42
+ // - `00_schema`(0030 撤):它从建出来那天起就是一个**空目录**。分类注册表与 Wiki 类型
43
+ // 注册表一直住在 `<agentRoot>/resource/knowledge-bases.json`(那是**配置**,跟着根路径
44
+ // 走,见 `knowledge-bases.ts`),而这个段里没有一行代码写过东西。「磁盘上有一个文件夹」
45
+ // 本身是一种承诺,承诺了却空着就是误导。**一句话:schema 在设置里编辑,不在磁盘上。**
46
+ // - `05_diagnosis`(v1.1 撤):它同时装着两类东西 —— 「一次排查的产物」(源头)与
47
+ // 「五类模板卡」(配置)—— 一个编号段混装两种性质,`rebuild --only` 的语义立刻含糊。
48
+ // 现在:一次排查的产物是一条 `agent_diagnosis_case` 的 SourceRaw(落 `01_source_raw/`),
49
+ // 模板卡由类型定义派生(不落盘)。旧目录里的 `*.jsonl` 降为**只读的迁移输入与历史快照**。
50
+ const LAYOUT = ['01_source_raw', '02_wiki', '03_compiled'] as const
51
+ /**
52
+ * 卡片正文里那个机器可读块的围栏标记。
53
+ *
54
+ * 与 `diagnosis/cards.ts` 的 `BLOCK_FENCE` 是同一个字符串,但这里不能 import 它 ——
55
+ * 迁移要在**不解析卡片**的前提下改块里的一个字段名(旧块的 `failure_mode`),
56
+ * 而 `cards.ts` 的 `parseCard` 已经把旧字段名归一化了,读回来的对象里根本没有它。
57
+ */
58
+ const DIAGNOSIS_BLOCK_FENCE = 'json diagnosis'
36
59
  /**
37
60
  * SourceRaw 段。段内**按来源分类再分一层**:`<段>/<source_type>/<docSlug>.md`。
38
61
  *
@@ -43,10 +66,27 @@ const LAYOUT = ['01_source_truth', '02_wiki', '03_compiled', '05_diagnosis'] as
43
66
  * 读路径不依赖这个层级:`sourceIndex()` 递归扫全段建 slug → path 索引,所以旧
44
67
  * 平铺布局与迁移中间态都照常可读(见 `migrateSourceTruthLayout`)。
45
68
  */
46
- const SOURCE_TRUTH = '01_source_truth'
69
+ const SOURCE_TRUTH = '01_source_raw'
70
+ /**
71
+ * 0051 之前的段名。**只留这一处**,两个用途:
72
+ * 1. **兼容解析**:存量正文里的图片引用写的是 `../../01_source_truth/_assets/…`
73
+ * (0047 的三种历史写法之一),改段名不改文件内容,所以解析器要同时认两个名字;
74
+ * 2. **迁移**:把整棵树搬过来(`migrateSourceTree`)。
75
+ *
76
+ * 卡片正文里的老引用**不改写**:改正文会造内容版本、让卡片重编重编码,而代价只是为了
77
+ * 让一个字符串好看一点。解析器认两个名字,读得到就行。
78
+ */
79
+ const LEGACY_SOURCE_TRUTH = '01_source_truth'
47
80
  /** Wiki 段;段内按 `entity_type` 分目录(与 SourceRaw 的分类分层同一个思路)。 */
48
81
  const WIKI = '02_wiki'
49
82
  const STATUS: KnowledgeStatus[] = ['unreviewed', 'draft', 'reviewed', 'active', 'deprecated', 'problem']
83
+ /**
84
+ * 一次 `import_sources` 能带多少份来源(0045)。
85
+ *
86
+ * 这不是产品配额,是**一次调用能被按住的时长**:编译逐份来源串行抽实体,50 份已经要跑
87
+ * 很久。超了就分批,每批各自编译 —— 而不是让一次调用变成没人知道什么时候结束的东西。
88
+ */
89
+ export const MAX_IMPORT_SOURCES = 50
50
90
  const SAFE_ID = /^[a-zA-Z0-9._:-]{1,160}$/
51
91
  const VECTOR_MANIFEST = 'vector-manifest.json'
52
92
  const VECTOR_BINARY = 'vectors.binary'
@@ -68,6 +108,16 @@ const VECTOR_QUANTIZATION = 'binary+hamming+int8' as const
68
108
  const ENCODER_FINGERPRINT = 'wemm-official-processor-v1'
69
109
  /** Stage 2 output consumed by stage 3 recall. */
70
110
  const WIKI_NOTES = 'wiki_notes.json'
111
+ /**
112
+ * 归因图谱的编译产物(0021 §14.3)。
113
+ *
114
+ * 在这之前 `cardsToGraph()` 是**读的时候现算**的,归因图不落 `03_compiled` —— 于是
115
+ * `build-manifest.json` 不知道它存在(回答不了「这张图是哪个版本编出来的」),
116
+ * 而图召回也读不到它。现在它和 `knowledge_graph.json` 一起由同一次 `rebuild` 产出,
117
+ * **但保留两种粒度**:卡片级回答「知识长什么样」,字段级回答「这条路怎么走」。
118
+ * 合成一张会让图谱 Tab 看到一张卡片散成七个点(§14.4 的三条理由)。
119
+ */
120
+ const DIAGNOSIS_GRAPH = 'diagnosis_graph.json'
71
121
  /** Pre-0014 artifact name; kept readable so an upgrade does not force a rebuild. */
72
122
  const LEGACY_UNITS = 'knowledge_units.json'
73
123
  /**
@@ -102,6 +152,14 @@ const CARD_VERSIONS = 'card-versions'
102
152
  * 只会让下一次重建把关系重新抽一遍。
103
153
  */
104
154
  const EXTRACTED_RELATIONS = 'extracted_relations.json'
155
+ /**
156
+ * 索引页(0049):**不打开界面也能知道这个库里有什么**。
157
+ *
158
+ * 落在 `03_compiled/` 而不是 `02_wiki/`:后者的每一份 `.md` 都会被 `loadCards`
159
+ * 当成一张卡片读进卡片清单,索引页放进去就会变成一张标题叫「知识库索引」的卡片,
160
+ * 而且每次编译都变一次。它是派生产物,所以归派生目录。
161
+ */
162
+ const WIKI_INDEX = 'wiki-index.md'
105
163
  /** Sections shorter than this stay in a single note; longer cards split by heading. */
106
164
  const NOTE_SPLIT_MIN_CHARS = 800
107
165
  /**
@@ -114,6 +172,20 @@ const NOTE_SPLIT_MIN_CHARS = 800
114
172
  const RELATION_QUERY_WORDS: Record<string, string> = {
115
173
  supported_by: 'supported by source 来源 出处 依据 支撑',
116
174
  [RELATES_TO]: 'relates to related 相关 关联 关系 有关',
175
+ // 归因图谱的十种边(0021 §4.2)。它们住在 `diagnosis_graph.json` 里,
176
+ // 但**按关系词命中**这条读法是同一套:中文提问不会写 `may_be_caused_by`。
177
+ // 少了这十行,图召回对 `refutes` / `verified_by` 这些语义完全不可见 ——
178
+ // 而「上次这条假设是被什么否定的」正是最该被召回到的东西。
179
+ may_be_caused_by: 'may be caused by 可能是原因 可能原因 候选原因 怀疑',
180
+ verified_by: 'verified by 由…验证 怎么验证 用哪个检查 检查点',
181
+ categorized_as: 'categorized as 归类为 属于哪一类 失败原因 切面',
182
+ queries: 'queries 查询依据 要去查 查哪些表 影响面',
183
+ produces: 'produces 产出 产出了什么 跑出了什么',
184
+ supports: 'supports 支持 印证 算数',
185
+ refutes: 'refutes 否定 排除 不成立 反例',
186
+ confirmed_as: 'confirmed as 确认为根因 确认根因 定位到',
187
+ instance_of: 'instance of 实例属于 哪次排查 案例',
188
+ indexed_from: 'indexed from 反查索引 落在哪个对象上 相关资产',
117
189
  }
118
190
  /**
119
191
  * 融合与阈值的常数(0031)。
@@ -146,8 +218,21 @@ const ENCODER_SELF_SIMILARITY_MAX = 0.99
146
218
  const MIN_QUERY_COVERAGE = 0.25
147
219
  /** 每条命中最多带几段证据原文。 */
148
220
  const EVIDENCE_PER_HIT = 3
149
- /** 同一张 wiki 最多几条进上下文:否则 5 条上下文可能全是同一张卡的不同小节。 */
150
- const CONTEXT_PER_WIKI_CAP = 2
221
+ /*
222
+ * 0050 删掉了 `CONTEXT_PER_WIKI_CAP`(同一张 wiki 最多几条进上下文)。
223
+ * 它是「按 note 组装上下文」时代的补丁:那时同一张卡的多个小节能占满名额。
224
+ * 现在上下文**按卡片去重**,一张卡只有一个条目 —— 上限这个概念不再需要,
225
+ * 取而代之的是卡片级的 1 跳扩展上限(`MAX_EXPANSION_CARDS`)。
226
+ */
227
+ /**
228
+ * 1 跳扩展最多拉几张卡片(0050)。
229
+ *
230
+ * 上限是**有意的**:一张卡片在图上的邻居可以很多(一份来源编出的卡片彼此都是邻居),
231
+ * 不设限时「1 跳」会把整个连通分量拖进上下文。宁可少拉,也不要让扩展压过命中。
232
+ */
233
+ const MAX_EXPANSION_CARDS = 20
234
+ /** 溢出文件的保留时长:超过它的旧文件在下次召回时清掉(0050)。 */
235
+ const RECALL_OVERFLOW_TTL_MS = 24 * 60 * 60 * 1000
151
236
  const BIT_COUNTS = Array.from({ length: 256 }, (_, value) => {
152
237
  let count = 0
153
238
  for (let bit = value; bit !== 0; bit &= bit - 1) count += 1
@@ -207,8 +292,16 @@ interface ExtractionOutcome {
207
292
  extractionHashes: Record<string, string>
208
293
  candidatesExtracted: number
209
294
  candidatesRejected: number
295
+ /**
296
+ * 这一轮**新建**的 wiki 卡片数。
297
+ *
298
+ * 名字从 `proposalsCreated` 改回来(0044):Pass 0 重新直接落卡。0040 v1.5 那道
299
+ * 「人确认之后才入库」的门撤掉了 —— 入库在前、review 在后,所以计数必须如实说
300
+ * 「新建了 N 张卡片」,而磁盘上真的有 N 张。
301
+ */
210
302
  cardsCreated: number
211
- cardsMerged: number
303
+ /** 这一轮**更新**(把新抽到的正文追加进去、或补上来源)的已有卡片数。 */
304
+ cardsUpdated: number
212
305
  sourcesExtracted: number
213
306
  sourcesSkipped: number
214
307
  relations: Array<{ fromKey: string; toKey: string; relation: string; sourceId: string }>
@@ -220,6 +313,29 @@ interface ExtractionOutcome {
220
313
  warnings: string[]
221
314
  }
222
315
 
316
+ /**
317
+ * Finalize(收尾段)一次运行的结果(0049)。
318
+ *
319
+ * `related` 与 `deadLinks` 要**继续往下传**:正文层算出来的计划,产物层(索引页)
320
+ * 与接口层(卡片抽屉的相关分块)读的是同一份 —— 三处各算一遍就会漂移。
321
+ */
322
+ interface FinalizeOutcome {
323
+ related: Map<string, RelatedEntry[]>
324
+ /** 相关链接总条数(含双向),报告里显示的就是它。 */
325
+ relatedLinks: number
326
+ /** 有几张卡片至少有了一条相关。 */
327
+ relatedCards: number
328
+ deadLinks: DeadLink[]
329
+ /** 这一轮因为相关块或断链问题而重写的卡片数。 */
330
+ rewritten: number
331
+ }
332
+
333
+ /** 卡片上的待补项(`metadata.schema_issues`),形状不对时当空。 */
334
+ function cardIssuesOf(card: KnowledgeCard): CardSchemaIssue[] {
335
+ const raw = card.metadata.schema_issues
336
+ return Array.isArray(raw) ? raw as CardSchemaIssue[] : []
337
+ }
338
+
223
339
  /**
224
340
  * Both helpers delegate to the shared envelope constructors: the five-key shape
225
341
  * and the "warnings ⇒ partial" rule live in `dsh-data-contracts`,
@@ -252,16 +368,332 @@ function contentHashOf(content: string): string { return hash(content) }
252
368
  * 已经在正文里出现过的那一段不再追加:同一份来源重建、或同一段被两个候选命中时,
253
369
  * 卡片不该越编译越长。判重按整段文本,所以它是幂等的。
254
370
  */
371
+ /** 批量导入里成功写入的一条来源(0045)。`index` 回指输入数组,调用方能对上号。 */
372
+ export interface ImportedSourceReport {
373
+ index: number
374
+ sourceId: string
375
+ title?: string
376
+ path: string
377
+ bytes: number
378
+ }
379
+
380
+ /** 批量导入里失败的一条。**带着原因回来**,因为局部失败要求调用方能只重试那几条。 */
381
+ export interface ImportFailure {
382
+ index: number
383
+ title?: string
384
+ code: string
385
+ message: string
386
+ }
387
+
388
+ /**
389
+ * 批量导入顺带那次编译的结果。
390
+ *
391
+ * 只带**能被人读懂**的几个数:新建 / 并入 / 复用 / 重编 / 重抽 / 跳过 / 移除、索引版本、
392
+ * 向量那一段的状态。整份 `RebuildReport` 有三十多个字段(指纹、计数、诊断),
393
+ * 而这里要回答的是「刚才那一下让库里多了什么」。
394
+ */
395
+ export interface ImportCompileReport {
396
+ cardsCreated: number
397
+ cardsUpdated: number
398
+ cardsReused?: number
399
+ notesReused?: number
400
+ notesRecompiled?: number
401
+ sourcesExtracted?: number
402
+ sourcesSkipped?: number
403
+ cardsRemoved?: number
404
+ embedding?: string
405
+ version?: number
406
+ }
407
+
408
+ /** 一次 `import_sources` 的账本:写了哪些、哪些没写成、顺带编译出了什么。 */
409
+ export interface ImportSourcesResult {
410
+ written: ImportedSourceReport[]
411
+ failed: ImportFailure[]
412
+ /** 输入里一共有几份 —— 成功数 + 失败数 = 它,除非整批在写入前就被拒了。 */
413
+ attempted: number
414
+ /** `compile: false` 时是 `null`:明确表示「这次没编译」,而不是「编译了但没数字」。 */
415
+ compile: ImportCompileReport | null
416
+ }
417
+
418
+ /**
419
+ * Pass 0 收集到的一条**待落卡片**。
420
+ *
421
+ * 它存在的原因是**整轮结束才落盘**:一份来源里同一个实体可能被抽到两次,两份来源也
422
+ * 可能抽到同一个实体 —— 逐次写会得到 N 次覆盖,而增量指纹要的是「这一轮这张卡片
423
+ * 最终长什么样」。
424
+ *
425
+ * `card` 是**已有卡片**(更新时),带着它的状态、备注、评分与 metadata —— 更新一张
426
+ * 卡片不该把人写过的备注、review 结论、以及别处来源的字段一起抹掉。
427
+ */
428
+ interface PendingCard {
429
+ action: 'create' | 'update'
430
+ entityType: SchemaType
431
+ entityId: string
432
+ title: string
433
+ content: string
434
+ sourceRefs: string[]
435
+ metadata: Record<string, unknown>
436
+ summary?: string
437
+ /**
438
+ * 更新的是哪张已有卡片。
439
+ *
440
+ * 带着它是为了**保住正文之外的一切**:状态、人工备注(`annotations`)、review 评分
441
+ * (`review`)、别处来源留下的字段。只凭 `type:id` 重新拼一张卡片,会把这些悄悄抹掉 ——
442
+ * 而「备注不会被编译覆盖」正是 0044 承诺的东西。
443
+ */
444
+ existing?: KnowledgeCard
445
+ /** 这一轮判重命中过它的来源与候选键,写进 `metadata.extraction_merges`。 */
446
+ merges: Array<{ sourceId: string; keys: string[]; at: string }>
447
+ /**
448
+ * 这一轮有没有**改到卡片的字段值**(类型定义里的 `formula` / `owner` 之类,或摘要)。
449
+ *
450
+ * 单独记一个布尔而不是去比整个 `metadata`:`metadata` 里还有 `evidence`、`confidence`、
451
+ * `extraction_merges` 这些每次都不同的东西,整体比较只会永远为真,于是「没变的卡片不重写」
452
+ * 这条增量承诺就悄悄失效了。这里只回答那个真正要紧的问题 —— 字段值变了没有。
453
+ */
454
+ fieldChanged?: boolean
455
+ }
456
+
457
+ /** 候选的别名集合:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。 */
458
+ function aliasesOf(candidate: { name: string; aliases?: string[] }): string[] {
459
+ return [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))]
460
+ }
461
+
462
+ /** 累积一次「并入已有卡片」。同一条 pending 被追加两次时 `appendExtracted` 自带判重。 */
463
+ function mergeIntoPending(
464
+ pending: Map<string, PendingCard>,
465
+ card: KnowledgeCard,
466
+ sourceId: string,
467
+ bodies: string[],
468
+ keys: string[],
469
+ at: string,
470
+ /**
471
+ * 这次新抽到的**字段值**(类型定义里的 `formula` / `owner` 等)与摘要。
472
+ *
473
+ * 它们必须跟着走完整条路径。0044 的实现里漏过一次:模型在第二份文档里给出了更完整的
474
+ * `formula`,卡片只追加了正文,字段值停在第一份文档的版本上 —— 而正文里写着新的公式。
475
+ * 这种「正文与字段互相矛盾」比缺字段更难发现。
476
+ */
477
+ fields?: Record<string, string>,
478
+ summary?: string,
479
+ ): void {
480
+ const key = `${card.entityType}:${card.entityId}`
481
+ const previous = pending.get(key)
482
+ // 字段值真的变了才算「改到」:同一段公式被两份文档重复写下是很常见的,
483
+ // 那种情况下这一轮没有任何新东西,不该把卡片重写一遍。
484
+ const changed = Object.entries(fields ?? {}).some(([name, value]) => String(card.metadata[name] ?? '') !== value)
485
+ || Boolean(summary && summary !== (card.summary ?? ''))
486
+ if (previous) {
487
+ previous.content = appendExtracted(previous.content, bodies)
488
+ if (!previous.sourceRefs.includes(sourceId)) previous.sourceRefs.push(sourceId)
489
+ previous.metadata = { ...previous.metadata, ...(fields ?? {}) }
490
+ if (!previous.summary && summary) previous.summary = summary
491
+ if (changed) previous.fieldChanged = true
492
+ previous.merges.push({ sourceId, keys, at })
493
+ return
494
+ }
495
+ pending.set(key, {
496
+ action: 'update',
497
+ entityType: card.entityType,
498
+ entityId: card.entityId,
499
+ title: card.title,
500
+ // **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
501
+ // 于是知识是累积的,来源改写了也不会停在旧版本上。
502
+ content: appendExtracted(card.content, bodies),
503
+ sourceRefs: card.sourceRefs.includes(sourceId) ? [...card.sourceRefs] : [...card.sourceRefs, sourceId],
504
+ metadata: { ...card.metadata, ...(fields ?? {}) },
505
+ ...(summary ?? card.summary ? { summary: summary ?? card.summary } : {}),
506
+ existing: card,
507
+ merges: [{ sourceId, keys, at }],
508
+ ...(changed ? { fieldChanged: true } : {}),
509
+ })
510
+ }
511
+
512
+ /** 来源的判重留痕:只留最近 20 条,它是「这张卡被哪些来源命中过」的痕迹,不是审计日志。 */
513
+ function mergeHistoryOf(metadata: Record<string, unknown>, additions: PendingCard['merges']): Array<Record<string, unknown>> {
514
+ const history = Array.isArray(metadata.extraction_merges) ? metadata.extraction_merges : []
515
+ return [...history, ...additions].slice(-20) as Array<Record<string, unknown>>
516
+ }
517
+
518
+ /**
519
+ * 从 frontmatter 里读人工备注。
520
+ *
521
+ * 形状不对的一条**丢掉,而不是让整张卡片读不出来**:备注是附加信息,为了一条写坏的
522
+ * 备注把一张知识卡片变成「读不到」,代价方向错了。
523
+ */
524
+ function parseAnnotations(value: unknown): CardAnnotation[] {
525
+ if (!Array.isArray(value)) return []
526
+ const result: CardAnnotation[] = []
527
+ for (const item of value) {
528
+ if (item === null || typeof item !== 'object') continue
529
+ const record = item as Record<string, unknown>
530
+ const note = typeof record.note === 'string' ? record.note.trim() : ''
531
+ if (!note) continue
532
+ result.push({
533
+ id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `a-${hash(note).slice(0, 12)}`,
534
+ kind: record.kind === 'anomaly' ? 'anomaly' : 'note',
535
+ note,
536
+ ...(typeof record.author === 'string' && record.author.trim() ? { author: record.author.trim() } : {}),
537
+ createdAt: typeof record.createdAt === 'string' && record.createdAt.trim() ? record.createdAt.trim() : '',
538
+ ...(typeof record.sourceRef === 'string' && record.sourceRef.trim() ? { sourceRef: record.sourceRef.trim() } : {}),
539
+ })
540
+ }
541
+ return result
542
+ }
543
+
544
+ /** 从 frontmatter 里读 review 结论。缺省 = 还没人看过(调用方按 `CARD_REVIEW_BASE_SCORE` 处理)。 */
545
+ function parseReview(value: unknown): CardReview | undefined {
546
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
547
+ const record = value as Record<string, unknown>
548
+ const score = Number(record.score)
549
+ const state = record.state === 'reviewed' ? 'reviewed' : 'unreviewed'
550
+ return {
551
+ state,
552
+ score: Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : state === 'reviewed' ? REVIEWED_SCORE_FLOOR : CARD_REVIEW_BASE_SCORE,
553
+ ...(typeof record.reviewedAt === 'string' && record.reviewedAt.trim() ? { reviewedAt: record.reviewedAt.trim() } : {}),
554
+ ...(typeof record.reviewedBy === 'string' && record.reviewedBy.trim() ? { reviewedBy: record.reviewedBy.trim() } : {}),
555
+ }
556
+ }
557
+
558
+ /**
559
+ * 一张卡片的**有效评分**(0044):有异常点备注时封顶。
560
+ *
561
+ * 为什么不把「异常」直接写进 `review.score`:异常是**关于证据的判断**(这段内容和原始
562
+ * 文档对不上),review 是**关于覆盖度的判断**(这张卡该有的小节都有了)。两者会同时成立 ——
563
+ * 一张 review 通过的卡片里照样可能被标出一个数字对不上。分成两个字段之后,
564
+ * 撤掉异常备注,review 的结论原样回来;写成一个数就再也分不开了。
565
+ */
566
+ function effectiveReviewScore(card: Pick<KnowledgeCard, 'review' | 'annotations'>): number {
567
+ const base = card.review?.score ?? CARD_REVIEW_BASE_SCORE
568
+ const hasAnomaly = (card.annotations ?? []).some(item => item.kind === 'anomaly')
569
+ return hasAnomaly ? Math.min(base, ANOMALY_SCORE) : base
570
+ }
571
+
572
+ function annotationId(note: string, at: string): string {
573
+ return `a-${hash(`${at}|${note}`).slice(0, 16)}`
574
+ }
575
+
576
+ /**
577
+ * 一份正文的小节结构:`## 标签` + 它下面的内容。
578
+ *
579
+ * `heading` 保留原始那一行(`## 定义`),重建时逐字写回去,所以合并不会顺手把
580
+ * 「## 定义」改成别的写法。首个标题之前的内容收进 `label: ''` 的那一格 ——
581
+ * 模型偶尔会先写一句总述,丢掉它比多一格更糟。
582
+ */
583
+ interface BodySection {
584
+ heading: string
585
+ label: string
586
+ body: string
587
+ }
588
+
589
+ /**
590
+ * 按 `## 小节` 切开正文。没有任何标题时返回 `null`,调用方据此退回「整段拼接」。
591
+ */
592
+ function splitSections(markdown: string): BodySection[] | null {
593
+ const sections: Array<{ heading: string; label: string; lines: string[] }> = []
594
+ let current: { heading: string; label: string; lines: string[] } | undefined
595
+ let seenHeading = false
596
+ for (const line of markdown.split('\n')) {
597
+ const match = /^##\s+(.+?)\s*$/.exec(line)
598
+ if (match) {
599
+ seenHeading = true
600
+ current = { heading: line.trim(), label: match[1].trim(), lines: [] }
601
+ sections.push(current)
602
+ continue
603
+ }
604
+ if (!current) {
605
+ // 第一个标题出现之前的行(含前导空行)归入前言。
606
+ current = { heading: '', label: '', lines: [] }
607
+ sections.push(current)
608
+ }
609
+ current.lines.push(line)
610
+ }
611
+ if (!seenHeading) return null
612
+ return sections.map(section => ({ heading: section.heading, label: section.label, body: section.lines.join('\n').trim() }))
613
+ }
614
+
615
+ /**
616
+ * 一整节只是**一句括号里的占位**吗(`(文档未说明)` / `(本文档未说明适用场景。)`)?
617
+ *
618
+ * 占位是编译器的约定(`skeletonBody` 写「(文档未说明)」),模型会照着模仿。
619
+ * 判断成占位之后,后来的真实内容**替换**它而不是追加在它后面 —— 否则卡片会长成
620
+ * 「(文档未说明)」+ 具体内容,读起来像两句话,而第一句已经过期了。
621
+ */
622
+ function isPlaceholderSection(text: string): boolean {
623
+ const trimmed = text.trim()
624
+ return /^[((][^(())]*[))]$/.test(trimmed)
625
+ }
626
+
627
+ /** 把一节的新内容并进旧内容:占位替换、重复跳过、其余追加。 */
628
+ function mergeSectionText(current: string, addition: string): string {
629
+ const next = addition.trim()
630
+ if (!next) return current.trim()
631
+ const previous = current.trim()
632
+ if (!previous) return next
633
+ if (previous.includes(next)) return previous
634
+ if (isPlaceholderSection(previous)) return next
635
+ return `${previous}\n\n${next}`
636
+ }
637
+
638
+ /**
639
+ * 把一次新抽到的正文并进已有正文(0044)。
640
+ *
641
+ * ## 为什么不是简单的字符串拼接
642
+ *
643
+ * 实机验证抓到的形状:同一实体被第二份文档命中时,卡片变成
644
+ * `## 适用场景 / ## 步骤 / ## 验收与失败处理 / ## 适用场景 / ## 步骤 / …` ——
645
+ * 每一节都出现两次。正文是**按类型 schema 分节**的,重复标题意味着这张卡片不再
646
+ * 符合它的类型,读的人还得自己把两段同一节的内容在脑子里合起来。
647
+ *
648
+ * 所以按小节折进去:**每个标签只出现一次**,新内容并进同一节里;旧内容还是占位
649
+ * (「(文档未说明)」)时被新内容替换。两份正文里只要有一份没有 `##` 结构
650
+ * (规则路径切出来的原文就是这样),就退回整段拼接 —— 那种情况下没有可靠的
651
+ * 对齐依据,硬折会把内容塞错节。
652
+ *
653
+ * ## 为什么改在这里
654
+ *
655
+ * 所有「并入已有卡片」的路径都走这个函数(编译、`writeCards`、诊断卡片),
656
+ * 在上层各自折一次,必然有一处漏掉。
657
+ */
255
658
  function appendExtracted(existing: string, additions: string[]): string {
256
659
  let content = existing
257
660
  for (const addition of additions) {
258
661
  const body = addition.trim()
259
662
  if (!body || content.includes(body)) continue
260
- content = content ? `${content}\n\n${body}` : body
663
+ content = foldBody(content.trim(), body)
261
664
  }
262
665
  return content
263
666
  }
264
667
 
668
+ /** 把 `incoming` 按小节折进 `existing`;结构对不上时整段追加。 */
669
+ function foldBody(existing: string, incoming: string): string {
670
+ if (!existing) return incoming
671
+ const base = splitSections(existing)
672
+ const extra = splitSections(incoming)
673
+ if (!base || !extra) return `${existing}\n\n${incoming}`
674
+
675
+ const byLabel = new Map(base.map(section => [section.label, section]))
676
+ const appended: BodySection[] = []
677
+ for (const section of extra) {
678
+ const target = byLabel.get(section.label)
679
+ if (target) {
680
+ target.body = mergeSectionText(target.body, section.body)
681
+ continue
682
+ }
683
+ // 新标签:整节接在最后,标题逐字保留。
684
+ const fresh = { heading: section.heading, label: section.label, body: section.body }
685
+ byLabel.set(section.label, fresh)
686
+ appended.push(fresh)
687
+ }
688
+
689
+ const ordered = [...base, ...appended]
690
+ const rendered = ordered
691
+ .map(section => (section.heading ? `${section.heading}\n\n${section.body}`.trimEnd() : section.body))
692
+ .filter(part => part.trim().length > 0)
693
+ .join('\n\n')
694
+ return rendered
695
+ }
696
+
265
697
  /**
266
698
  * `2026-01-02T03:04:05.123Z` → `20260102T030405123Z`. Millisecond precision keeps
267
699
  * two overwrites inside the same second from colliding on the same version id.
@@ -288,15 +720,31 @@ function plainRecord(value: unknown): Record<string, unknown> {
288
720
  * `metadata.source_path`; otherwise the upstream `source_format` label
289
721
  * (`markdown` / `office` / …); otherwise `markdown`, the store's default shape.
290
722
  */
723
+ /**
724
+ * 这份来源**是什么格式**(`web_page` / `office` / `pdf` / `markdown`…)。
725
+ *
726
+ * `source_format` 是转换内核报出来的**格式类**,有就以它为准 —— 按扩展名去猜会把
727
+ * 「网页」显示成「html」(0048 之前就是这样:`类型: .html · html`,而 manifest 里
728
+ * 明明写着 `web_page`)。没有 `source_format` 的手写来源才退回扩展名那条链。
729
+ */
291
730
  function sourceFormatOf(metadata: Record<string, unknown>): string {
292
- const sourcePath = typeof metadata.source_path === 'string' ? metadata.source_path.trim() : ''
293
- if (sourcePath) {
294
- const base = sourcePath.split(/[\\/]/).pop() ?? ''
731
+ const format = typeof metadata.source_format === 'string' ? metadata.source_format.trim().toLowerCase() : ''
732
+ if (format) return format
733
+ const ext = sourceExtOf(metadata)
734
+ return ext ? ext.replace(/^\./, '') : 'markdown'
735
+ }
736
+
737
+ /** 底稿文件名 → 扩展名。`original.rel` 是 0048 起的持久位置,`source_path` 是旧形状。 */
738
+ function sourceExtOf(metadata: Record<string, unknown>): string {
739
+ const original = plainRecord(metadata.original)
740
+ const candidates = [original.rel, metadata.source_path]
741
+ for (const value of candidates) {
742
+ if (typeof value !== 'string' || !value.trim()) continue
743
+ const base = value.trim().split(/[\\/]/).pop() ?? ''
295
744
  const dot = base.lastIndexOf('.')
296
- if (dot > 0 && dot < base.length - 1) return base.slice(dot + 1).toLowerCase()
745
+ if (dot > 0 && dot < base.length - 1) return base.slice(dot).toLowerCase()
297
746
  }
298
- const format = typeof metadata.source_format === 'string' ? metadata.source_format.trim().toLowerCase() : ''
299
- return format || 'markdown'
747
+ return ''
300
748
  }
301
749
 
302
750
  /** Failure payload for a document lookup that could not produce a detail. */
@@ -304,10 +752,25 @@ function emptyDocumentDetail(docId: string): KnowledgeDocumentDetail {
304
752
  return {
305
753
  docId, title: docId, sourceType: '', sourceTypeKnown: false, sourceFormat: '', parser: '', externalId: '', contentHash: '',
306
754
  bytes: 0, assetCount: 0, createdAt: '', updatedAt: '', versionCount: 0,
307
- relativePath: '', ext: '', content: '', frontmatter: {}, assets: [],
755
+ relativePath: '', ext: '', content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [],
308
756
  }
309
757
  }
310
758
 
759
+ /**
760
+ * 抽屉里的原文预览**只发**正文的前这么多字符。
761
+ *
762
+ * 依据是实跑反馈:一份几十万字的转换文档,抽屉只渲染前 4000 字符(更长的在
763
+ * 380px 宽的抽屉里没有阅读意义),而接口原来把**整份正文**发过去 —— 传输、JSON 解析、
764
+ * 塞进 React state 全按全文的量级付费,于是「打开弹层要等半天」。上面那段封顶之后,
765
+ * 弹层的耗时与文档大小**脱钩**;要读全文走独立整页(分页取,见 `documentContentPage`)。
766
+ *
767
+ * 4000 是用户明确要的阈值,所以它是一个具名常量而不是散在各处的字面量。
768
+ */
769
+ export const SOURCE_PREVIEW_CHARS = 4000
770
+
771
+ /** 耗时统一保留一位小数:界面上不需要微秒,而 0.0 与 0 的读法不一样。 */
772
+ function round1(value: number): number { return Math.round(value * 10) / 10 }
773
+
311
774
  function generatedId(prefix: string): string { return `${prefix}_${hash(`${Date.now()}:${Math.random()}`)}` }
312
775
 
313
776
  function parseLineage(value: unknown): LineageContext | undefined {
@@ -362,42 +825,24 @@ export function parseMarkdown(source: string): { frontmatter: Record<string, unk
362
825
  return { frontmatter, content: source.slice(end + 4).trim() }
363
826
  }
364
827
 
828
+ /**
829
+ * 把旧卡片结构化块里的 `failure_mode` 改成 `failure_reason`(0040 的第二次改名)。
830
+ *
831
+ * 只动那个围栏块里的键名,不动正文 —— 正文里出现「failure_mode」这个词完全可能是
832
+ * 有意写的(比如一张讲 FMEA 的卡片)。做字符串级替换而不是解析再序列化,是因为
833
+ * 解析会**丢掉格式**(缩进、键序),而迁移的第一条纪律是不制造无谓的 diff。
834
+ */
835
+ function rewriteLegacyBlockFields(content: string): string {
836
+ const pattern = new RegExp('(```' + DIAGNOSIS_BLOCK_FENCE + '\\s*\\n)([\\s\\S]*?)(```)')
837
+ return content.replace(pattern, (_all, head: string, body: string, tail: string) =>
838
+ head + body.replace(/"failure_mode"(\s*:)/g, '"failure_reason"$1') + tail)
839
+ }
840
+
365
841
  function markdown(frontmatter: Record<string, unknown>, content: string): string {
366
842
  const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
367
843
  return `---\n${lines.join('\n')}\n---\n\n${content.trim()}\n`
368
844
  }
369
845
 
370
- async function writeTextAtomic(path: string, content: string): Promise<void> {
371
- // 原子写需要一个与目标同目录的临时文件,所以目标目录必须先存在。0030 起
372
- // SourceRaw 落在 `<段>/<source_type>/` 下,而 `ensureLayout` 只建段目录 —— 少了
373
- // 这一行,第一次写某一种分类就会 ENOENT(实测)。
374
- await mkdir(dirname(path), { recursive: true })
375
- const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
376
- try {
377
- await writeFile(temporary, content, 'utf8')
378
- await rename(temporary, path)
379
- } finally {
380
- await unlinkIfExists(temporary)
381
- }
382
- }
383
-
384
- async function writeBufferAtomic(path: string, content: Buffer): Promise<void> {
385
- const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
386
- try {
387
- await writeFile(temporary, content)
388
- await rename(temporary, path)
389
- } finally {
390
- await unlinkIfExists(temporary)
391
- }
392
- }
393
-
394
- async function unlinkIfExists(path: string): Promise<void> {
395
- try {
396
- const { unlink } = await import('node:fs/promises')
397
- await unlink(path)
398
- } catch { /* rename already consumed the temp file */ }
399
- }
400
-
401
846
  async function filesUnder(root: string): Promise<string[]> {
402
847
  try {
403
848
  const entries = await readdir(root, { withFileTypes: true })
@@ -458,8 +903,198 @@ function markdownImageRefs(content: string): string[] {
458
903
  return [...new Set(refs)]
459
904
  }
460
905
 
461
- function markdownImagePaths(content: string, baseDir: string): string[] {
462
- return markdownImageRefs(content).map(value => isAbsolute(value) ? resolve(value) : resolve(baseDir, value))
906
+ /**
907
+ * 一份 SourceRaw 的 front matter(0048)。
908
+ *
909
+ * 键分成两类,**顺序与来源**都是刻意的:
910
+ *
911
+ * 1. **OKF v0.2 的顶层键**:`type` / `title` / `description` / `resource` /
912
+ * `tags` / `generated` / `sources`。它们是真 YAML 键,人能读、`grep` 得到、
913
+ * 外部工具能按公开规范读走。`generated.at` 就是**采集时间**(OKF 里没有「抓取
914
+ * 时间」这个概念,本文件何时被谁写出来就是它)。
915
+ * 2. **我们自己的运行键**:`source_id` / `external_id` / `created_at` / `updated_at` /
916
+ * `metadata` / `lineage`。OKF 明确允许自定义键,且要求消费者不得因为未知键拒收,
917
+ * 所以这两类可以同处一个 front matter。
918
+ *
919
+ * `metadata` 只剩**类型专属的扩展键**(转换任务 id、内容哈希、资产清单、格式…):
920
+ * 那些东西 OKF 没有对应概念,但也不该混进上面那组公开字段里。
921
+ *
922
+ * `type` 是 OKF 唯一必填键,值就是我们自己的来源分类(它本来就是「这个概念是什么」)。
923
+ * 旧文件里的 `source_type` 仍然读得到(见 `sourceTypeOf`),迁移只写新键。
924
+ */
925
+ function sourceFrontMatter(record: SourceRaw, provenance?: SourceProvenance): Record<string, unknown> {
926
+ const frontMatter: Record<string, unknown> = {
927
+ type: record.sourceType,
928
+ title: record.title,
929
+ }
930
+ if (provenance?.description?.trim()) frontMatter.description = provenance.description.trim()
931
+ if (provenance?.resource?.trim()) frontMatter.resource = provenance.resource.trim()
932
+ if (provenance?.tags && provenance.tags.length > 0) frontMatter.tags = provenance.tags
933
+ const generated = {
934
+ ...(provenance?.generatedBy?.trim() ? { by: provenance.generatedBy.trim() } : {}),
935
+ ...(provenance?.generatedAt?.trim() ? { at: provenance.generatedAt.trim() } : {}),
936
+ }
937
+ if (Object.keys(generated).length > 0) frontMatter.generated = generated
938
+ const sources = (provenance?.sources ?? []).filter(origin => origin.resource.trim())
939
+ if (sources.length > 0) {
940
+ frontMatter.sources = sources.map((origin: SourceOriginRef) => ({
941
+ id: origin.id,
942
+ resource: origin.resource,
943
+ ...(origin.title?.trim() ? { title: origin.title.trim() } : {}),
944
+ ...(origin.author?.trim() ? { author: origin.author.trim() } : {}),
945
+ ...(origin.lastModified?.trim() ? { last_modified: origin.lastModified.trim() } : {}),
946
+ }))
947
+ }
948
+ frontMatter.source_id = record.sourceId
949
+ frontMatter.external_id = record.externalId ?? ''
950
+ frontMatter.created_at = record.createdAt
951
+ frontMatter.updated_at = record.updatedAt
952
+ frontMatter.metadata = record.metadata
953
+ if (record.lineage) frontMatter.lineage = record.lineage
954
+ return frontMatter
955
+ }
956
+
957
+ /** 把 `sources[0].author` 拆成 `['sources', 0, 'author']`。 */
958
+ function metadataPath(key: string): Array<string | number> {
959
+ const tokens: Array<string | number> = []
960
+ for (const part of key.split('.')) {
961
+ const name = part.replace(/\[\d+\]/g, '').trim()
962
+ if (name) tokens.push(name)
963
+ for (const index of part.matchAll(/\[(\d+)\]/g)) tokens.push(Number(index[1]))
964
+ }
965
+ return tokens
966
+ }
967
+
968
+ /** 这份 front matter 里,分类声明的元数据字段缺了哪些(缺 = 没有值,不是「值为空串」)。 */
969
+ function missingMetadataFields(fields: SourceMetadataField[] | undefined, frontMatter: Record<string, unknown>): SourceMetadataField[] {
970
+ if (!fields || fields.length === 0) return []
971
+ const present = (key: string): boolean => {
972
+ let current: unknown = frontMatter
973
+ for (const token of metadataPath(key)) {
974
+ if (typeof token === 'number') {
975
+ if (!Array.isArray(current)) return false
976
+ current = current[token]
977
+ } else {
978
+ if (current === null || typeof current !== 'object') return false
979
+ current = (current as Record<string, unknown>)[token]
980
+ }
981
+ }
982
+ if (current === undefined || current === null) return false
983
+ if (typeof current === 'string') return current.trim().length > 0
984
+ if (Array.isArray(current)) return current.length > 0
985
+ return true
986
+ }
987
+ return fields.filter(field => field.required === true && !present(field.key))
988
+ }
989
+
990
+ /**
991
+ * 读一份 front matter 里的来源分类。
992
+ *
993
+ * 两个键都认:`type` 是 0048 起的规范键(OKF 必填),`source_type` 是旧文件里的键。
994
+ * 迁移会把存量文件改写成 `type`,但**读**这一侧永远两个都认 —— 别的库、别人手写的
995
+ * 文件、备份里翻出来的文件都可能还是旧形状,读不了就等于数据丢失。
996
+ */
997
+ function sourceTypeOf(frontmatter: Record<string, unknown>): string {
998
+ const canonical = frontmatter.type
999
+ if (typeof canonical === 'string' && canonical.trim()) return canonical.trim()
1000
+ const legacy = frontmatter.source_type
1001
+ if (typeof legacy === 'string' && legacy.trim()) return legacy.trim()
1002
+ return ''
1003
+ }
1004
+
1005
+ /**
1006
+ * 一个图片引用可能指向哪些磁盘路径,按优先级排。
1007
+ *
1008
+ * 知识库里同时存在三种写法,都是历史产物,谁也没错,所以解析要认全:
1009
+ *
1010
+ * | 写法 | 谁写的 | 从哪解析 |
1011
+ * | --- | --- | --- |
1012
+ * | `01_source_raw/_assets/<bucket>/<file>` | **规范形**:知识库根相对 | 根目录 |
1013
+ * | `_assets/<bucket>/<file>` | 转换内核早期写进 SourceRaw 的形式(相对 `01_source_raw/`) | 根目录 + `01_source_raw/` |
1014
+ * | `../../01_source_raw/_assets/…` | `cardHints` 给手工建卡用的形式 | 文件自己所在目录 |
1015
+ *
1016
+ * 顺序就是「先根目录、再 `01_source_raw/`、最后文件自己的目录」,而且只认**真实存在**
1017
+ * 的那个({@link markdownImagePaths} 用 `existsSync` 挑)。这条规则与 `assetsOf` 共用,
1018
+ * 因为「文档里有几张图」和「卡片正文里的图在哪」必须是同一个答案。
1019
+ */
1020
+ function assetRefCandidates(ref: string, baseDir: string, rootDir: string): string[] {
1021
+ if (isAbsolute(ref)) return [resolve(ref)]
1022
+ return [
1023
+ resolve(rootDir, ref),
1024
+ resolve(rootDir, SOURCE_TRUTH, ref),
1025
+ // 老段名的引用(0051 之前写进正文的那些):换一个名字再试一遍,别让存量卡片丢图。
1026
+ ...(ref.includes(LEGACY_SOURCE_TRUTH) ? [resolve(rootDir, ref.replace(LEGACY_SOURCE_TRUTH, SOURCE_TRUTH))] : []),
1027
+ ...(ref.includes(SOURCE_TRUTH) ? [resolve(rootDir, ref.replace(SOURCE_TRUTH, LEGACY_SOURCE_TRUTH))] : []),
1028
+ resolve(baseDir, ref),
1029
+ ]
1030
+ }
1031
+
1032
+ /**
1033
+ * 把 `from` 里的每一项并进 `to`(0051 的迁移中间态用)。
1034
+ *
1035
+ * 目标里已有的**不覆盖**:迁移只负责「把还没过来的搬过来」,冲突留给下一次启动或
1036
+ * 人来处理。两边都是目录就递归 —— `_assets/<bucket>/` 是两层,一层不够。
1037
+ */
1038
+ async function mergeDir(from: string, to: string): Promise<void> {
1039
+ await mkdir(to, { recursive: true })
1040
+ for (const entry of await readdir(from, { withFileTypes: true })) {
1041
+ const source = join(from, entry.name)
1042
+ const target = join(to, entry.name)
1043
+ if (existsSync(target)) {
1044
+ if (entry.isDirectory()) await mergeDir(source, target)
1045
+ continue
1046
+ }
1047
+ await rename(source, target)
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * 文件名(不含扩展名):**用标题**(0051)。
1053
+ *
1054
+ * 三条规则,都是为了让它在任何文件系统上都落得下去:
1055
+ * 1. 路径不安全字符与不可见字符换成 `-`(`/` `\` `:` `*` `?` `"` `<` `>` `|` 与控制字符);
1056
+ * 2. 首尾的点与空格去掉(Windows 与 macOS 都不喜欢,且会让 `ls` 看起来少一个字符);
1057
+ * 3. 截断到 120 字符;标题为空(或全是非法字符)时**退回 `source_id` 的 slug** ——
1058
+ * 宁可文件名不好看,也不要没有文件名。
1059
+ *
1060
+ * 它不负责查重:目标路径上已经有人占了,由 `addSource` 按 `SOURCE_TITLE_DUPLICATED` 拒收。
1061
+ */
1062
+ function sourceFileBase(title: string, sourceId: string): string {
1063
+ const cleaned = title
1064
+ .replace(/[/\\:*?"<>|\u0000-\u001f]/g, '-')
1065
+ .replace(/\s+/g, ' ')
1066
+ .replace(/^[.\s]+|[.\s]+$/g, '')
1067
+ .slice(0, 120)
1068
+ .trim()
1069
+ // 只剩替换字符(`///` → `---`)等于没有名字:一个叫 `---.md` 的文件比 id 更难认。
1070
+ return /^[-\s]*$/.test(cleaned) ? slug(sourceId) : cleaned
1071
+ }
1072
+
1073
+ /**
1074
+ * 正文里的图片引用 → 绝对路径。
1075
+ *
1076
+ * 卡片这条路上原先少了这一步:`markdownImagePaths(content, dirname(path))` 只按文件
1077
+ * 自己的目录解析。于是卡片正文里那张从来源**原样搬过来**的图,被解析成
1078
+ * `02_wiki/<type>/_assets/…` —— 一个不存在的路径。0047 实机就是这么炸的:向量编码器
1079
+ * 报 `FileNotFoundError: …/02_wiki/concept/_assets/…/img-2d1e420e82a8.png`,
1080
+ * 整个 embedding 那一档直接降级成 unavailable。
1081
+ */
1082
+ function markdownImagePaths(content: string, baseDir: string, rootDir?: string): string[] {
1083
+ return markdownImageRefs(content).map(value => {
1084
+ if (isAbsolute(value)) return resolve(value)
1085
+ if (rootDir) {
1086
+ const found = assetRefCandidates(value, baseDir, rootDir).find(candidate => existsSync(candidate))
1087
+ if (found) return found
1088
+ }
1089
+ return resolve(baseDir, value)
1090
+ })
1091
+ }
1092
+
1093
+ /** 按来源数一数「有多少张图没落位」,用来生成一条人能照着查的 warning。 */
1094
+ function countBySource(images: SourceImage[]): Map<string, number> {
1095
+ const result = new Map<string, number>()
1096
+ for (const image of images) result.set(image.sourceId, (result.get(image.sourceId) ?? 0) + 1)
1097
+ return result
463
1098
  }
464
1099
 
465
1100
  async function imageFingerprints(paths: string[]): Promise<string[]> {
@@ -604,7 +1239,6 @@ export type SourceTypeProvider = () => SourceTypeDef[]
604
1239
  /** Wiki 卡片类型注册表的读取入口(0024):类型定义 + schema 生效方式。 */
605
1240
  export interface WikiSchemaSnapshot {
606
1241
  types: WikiTypeDef[]
607
- mode: WikiSchemaMode
608
1242
  }
609
1243
  export type WikiSchemaProvider = () => WikiSchemaSnapshot
610
1244
 
@@ -688,6 +1322,30 @@ export interface KnowledgeRuntimeOptions {
688
1322
  * 不能让「没有模型就跑不动」成为新的前提。
689
1323
  */
690
1324
  modelCall?: ModelCall
1325
+ /**
1326
+ * **按需解析**模型接缝(0044)。
1327
+ *
1328
+ * 为什么不能只用上面那个 `modelCall`:它是**构造时读一次**的值,而插件 apply 的
1329
+ * 时刻早于 `agent-default-model` 那个 bundle 挂载 —— profile 的组合顺序是
1330
+ * 「package.json 的 bundles → cordis.patch.yml」,而 `agent-default-model` 在 patch 层。
1331
+ * 于是那一次读到的永远是 undefined,而它**再也不会变**,模型抽取一次都不跑。
1332
+ *
1333
+ * 实跑证据(2026-09-27):默认模型配的是 `opencode-go / deepseek-v4.1-flash`,而编译出来的
1334
+ * 卡片 `metadata.extraction` 是 `pass0`(规则路径),正文是整篇文章 —— 也就是用户报的
1335
+ * 「编译只是按目录截取」。静默降级了很久,因为没有任何一处说「模型抽取没生效」。
1336
+ *
1337
+ * 所以两者都给:`modelCall` 供测试注入固定值,`modelSeam` 供插件层延迟解析,
1338
+ * **并且必须能说出拿不到的原因** —— `why` 会原样进 `rebuild` 的 warnings。
1339
+ * 一个说不出理由的降级,等于没有降级信息。
1340
+ */
1341
+ modelSeam?: () => ModelSeam
1342
+ }
1343
+
1344
+ /** 一次模型接缝解析的结果。两件东西都要有交代:能不能用,以及不能用时为什么。 */
1345
+ export interface ModelSeam {
1346
+ call?: ModelCall
1347
+ /** 拿不到 `call` 的原因,人话。会被原样写进 `rebuild` 的 warnings。 */
1348
+ why?: string
691
1349
  }
692
1350
 
693
1351
  export class KnowledgeRuntime {
@@ -720,8 +1378,16 @@ export class KnowledgeRuntime {
720
1378
  * 写入与删除后必须失效(见 `addSource` / `deleteDocument`)。
721
1379
  */
722
1380
  private sourceIndex: Map<string, string> | undefined
1381
+ /** `path → sourceId`:目标路径被谁占着(0051 的重名判定)。与 `sourceIndex` 同一次遍历建。 */
1382
+ private sourcePathOwner: Map<string, string> | undefined
723
1383
  /** 段内布局整理是否已经跑过(每个 runtime 一次)。 */
724
1384
  private segmentsOrganized = false
1385
+ /**
1386
+ * 一次性迁移里**没有静默处理掉**的事(改名冲突、迁移后比对不一致)。
1387
+ *
1388
+ * 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
1389
+ */
1390
+ private migrationProblems: string[] = []
725
1391
  private vectorIndex: LoadedVectorIndex | null = null
726
1392
  private vectorError: string | undefined
727
1393
  private embeddingWorker: JsonLineWorker | null = null
@@ -751,8 +1417,10 @@ export class KnowledgeRuntime {
751
1417
  * `memoryPath`,守卫换成「不许跑出 memoryDir」。
752
1418
  */
753
1419
  private readonly memoryDir: string
754
- /** #3b 的模型调用接缝;undefined = 退回纯规则抽取。 */
755
- private readonly modelCall: ModelCall | undefined
1420
+ /** #3b 的模型调用接缝;undefined = 退回纯规则抽取。测试注入的固定值。 */
1421
+ private readonly staticModelCall: ModelCall | undefined
1422
+ /** 每次真正要用模型时问一遍的那个接缝 provider(0044)。为空则退回 `staticModelCall`。 */
1423
+ private readonly modelSeamProvider?: () => ModelSeam
756
1424
 
757
1425
  constructor(rootDir: string, config: Partial<RuntimeKnowledgeConfig> = {}, options: KnowledgeRuntimeOptions = {}) {
758
1426
  this.rootDir = resolve(rootDir)
@@ -763,7 +1431,8 @@ export class KnowledgeRuntime {
763
1431
  this.sourceTypeProvider = options.sourceTypes
764
1432
  this.wikiSchemaProvider = options.wikiSchema
765
1433
  this.memoryDir = options.memoryRoot ? resolve(options.memoryRoot) : join(this.rootDir, '04_memory')
766
- this.modelCall = options.modelCall
1434
+ this.staticModelCall = options.modelCall
1435
+ this.modelSeamProvider = options.modelSeam
767
1436
  const resources = resolveResources(options.resourceRoot)
768
1437
  this.pythonPath = config.python?.trim() || resources.python
769
1438
  this.embeddingModelPath = config.embeddingModel?.trim() || resources.embeddingModel
@@ -771,6 +1440,17 @@ export class KnowledgeRuntime {
771
1440
  this.embeddingModelId = resources.modelId(this.embeddingModelPath)
772
1441
  }
773
1442
 
1443
+ /**
1444
+ * 现在能不能用模型抽实体。
1445
+ *
1446
+ * **每次现问**,不是在构造时定死:插件 apply 的时刻早于 `agent-default-model` 挂载,
1447
+ * 那时问一次只会得到 undefined,而定死之后就再也不会变(见 `modelCallProvider`)。
1448
+ */
1449
+ private modelSeamNow(): ModelSeam {
1450
+ if (this.modelSeamProvider) return this.modelSeamProvider()
1451
+ return this.staticModelCall ? { call: this.staticModelCall } : { why: '运行时没有注入模型接缝' }
1452
+ }
1453
+
774
1454
  /**
775
1455
  * 就地应用一份新的运行时配置(0.1.7 的 volatile 改动走这条路)。
776
1456
  *
@@ -823,45 +1503,288 @@ export class KnowledgeRuntime {
823
1503
 
824
1504
  /** 当前生效的卡片 schema:类型定义 + 生效方式。 */
825
1505
  wikiSchema(): WikiSchemaSnapshot {
826
- return this.wikiSchemaProvider?.() ?? { types: defaultWikiTypes(), mode: 'report' }
1506
+ return this.wikiSchemaProvider?.() ?? { types: defaultWikiTypes() }
827
1507
  }
828
1508
 
829
1509
  /**
830
- * Agent 与设置面板共用的类型目录:注册表 + 生效方式 + 内置只读判定规则 +
831
- * 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)。
1510
+ * Agent 与设置面板共用的类型目录:注册表 + 内置只读判定规则 +
1511
+ * 现有卡片的三层校验结果(`report` 模式下这是"待补"清单)+ **review 进度**。
1512
+ *
1513
+ * review 计数挂在这里而不是另开一路,是因为 review 就发生在这一屏(「知识库 → Wiki」)——
1514
+ * 与 lint 待补清单是同一处,不新造第二套界面。
1515
+ *
1516
+ * 0044:`proposals`(待审提议条数)改成 `reviews`。队列退役之后「还有几条等人确认」
1517
+ * 这个问题不再有意义 —— 卡片一编译就入库了,剩下的问题是「入库的这些里,我看过几条」。
832
1518
  */
833
1519
  async wikiTypeCatalog(): Promise<Envelope<WikiTypeCatalog>> {
834
1520
  const schema = this.wikiSchema()
835
1521
  const issues = await this.lintCards()
1522
+ const cards = await this.loadCards()
836
1523
  return success({
837
1524
  types: schema.types,
838
- mode: schema.mode,
839
1525
  rules: WIKI_TYPE_RULES,
840
1526
  issues: issues.slice(0, 50),
841
1527
  issueCount: issues.length,
1528
+ reviews: {
1529
+ total: cards.length,
1530
+ unreviewed: cards.filter(card => card.review?.state !== 'reviewed').length,
1531
+ reviewed: cards.filter(card => card.review?.state === 'reviewed').length,
1532
+ anomalies: cards.filter(card => (card.annotations ?? []).some(item => item.kind === 'anomaly')).length,
1533
+ },
842
1534
  })
843
1535
  }
844
1536
 
845
- /** 对已落库的卡片跑三层校验。不写盘,只报告。 */
1537
+ /**
1538
+ * 智能归因图谱的存储句柄。
1539
+ *
1540
+ * 它由 runtime 自己构造(而不是由 index.ts 注入),因为 `rebuild` 要产出
1541
+ * `03_compiled/diagnosis_graph.json` —— 那次产出必须和 `knowledge_graph.json`
1542
+ * 在同一次卡片遍历的语义下发生,否则两份产物会来自两个不同版本的卡片。
1543
+ */
1544
+ private diagnosis?: DiagnosisStore
1545
+
1546
+ diagnosisStore(): DiagnosisStore {
1547
+ if (!this.diagnosis) this.diagnosis = new DiagnosisStore(this)
1548
+ return this.diagnosis
1549
+ }
1550
+
1551
+ /* ────────────────────────────────────── 卡片入库:直接写,不排队 ── */
1552
+
1553
+ /**
1554
+ * 把一批卡片**直接写进 `02_wiki`**(0044)。
1555
+ *
1556
+ * 0040 v1.5 的这道门原来是「提一条提议 → 人在面板确认 → 才落卡」,理由是类型层是收敛的
1557
+ * 那一层,错一条所有现象的排序一起错。0044 把方向反过来:**入库在前、review 在后**。
1558
+ * 判据是实践中的两个事实:
1559
+ *
1560
+ * 1. **确认门把知识挡在了门外。** 抽到的东西停在 `memory/card-proposals/` 里等人点,
1561
+ * 而人不会一条条点。用户看到的现象就是「编译没生效」——文档导进去了,wiki 是空的。
1562
+ * 2. **review 的价值不需要排队来实现。** 「这张卡片对不对」可以在它已经可召回之后回答;
1563
+ * 而「未 review 就不许进来」等于把「暂时没人看的正确知识」和「错误知识」一起丢掉。
1564
+ *
1565
+ * 于是现在的形状是:入库 → 召回得到(评分稍低)→ 人 review → 评分抬高 / 备注异常点。
1566
+ * 拒绝某条知识的正确做法是 `set_knowledge_status(deprecated|problem)`,它会留痕,
1567
+ * 也能被撤回 —— 比「从来没见过它」更容易纠正。
1568
+ *
1569
+ * 与编译路径的差别只有一条:这里允许调用方指定 `entityId` 与正文,编译路径由候选推导。
1570
+ * 两者共用 `writeCard`,所以校验、版本快照、备注保留的行为完全一致。
1571
+ */
1572
+ async writeCards(input: {
1573
+ sourceId: string
1574
+ cards: Array<{
1575
+ action?: 'create' | 'update' | 'merge'
1576
+ entityType: string
1577
+ entityId: string
1578
+ title: string
1579
+ summary?: string
1580
+ content: string
1581
+ sourceRefs?: string[]
1582
+ metadata?: Record<string, unknown>
1583
+ }>
1584
+ lineage?: LineageContext
1585
+ }): Promise<Envelope<{ written: number; ids: string[]; rejected: string[]; warnings: string[] }>> {
1586
+ const empty = { written: 0, ids: [] as string[], rejected: [] as string[], warnings: [] as string[] }
1587
+ if (!input.sourceId?.trim()) return failure(empty, 'SOURCE_REF_REQUIRED', '卡片必须挂在一条来源上', false, '先调 add_source 落一条来源,再用它的 source_id 写入卡片。')
1588
+ const sources = new Set((await this.loadSources()).map(source => source.sourceId))
1589
+ if (!sources.has(input.sourceId)) {
1590
+ return failure(empty, 'SOURCE_REF_NOT_FOUND', `source_refs 引用不存在: ${input.sourceId}`, false, '先调用 add_source 写入对应的 SourceTruth')
1591
+ }
1592
+ if (!Array.isArray(input.cards) || input.cards.length === 0) {
1593
+ return failure(empty, 'CARD_EMPTY', '至少要写一张卡片', true)
1594
+ }
1595
+ const schema = this.wikiSchema()
1596
+ const rejected: string[] = []
1597
+ const warnings: string[] = []
1598
+ const ids: string[] = []
1599
+ /**
1600
+ * 判重索引只读一次。**批量里的后一张要能看到前一张刚写的卡片** —— 同一个实体在
1601
+ * 一批里出现两次时,第二次必须走并入而不是再写一遍,所以写成功后要回填这张表。
1602
+ */
1603
+ const byKey = new Map((await this.loadCards()).map(card => [`${card.entityType}:${card.entityId}`, card]))
1604
+ for (const item of input.cards) {
1605
+ const type = schema.types.find(def => def.id === item.entityType)
1606
+ if (!type) { rejected.push(`${item.entityId}:未知卡片类型 ${item.entityType}`); continue }
1607
+ if (!type.enabled) { rejected.push(`${item.entityId}:类型 ${item.entityType} 已停用`); continue }
1608
+ /**
1609
+ * **已有卡片时走「并入」而不是覆盖。**
1610
+ *
1611
+ * 这一步是必需的,不是优化:Agent 按「同一实体跨两份文档」调用两次时,覆盖式写入会让
1612
+ * 第一份来源与它的正文**静默消失**(实测:先 doc:a 再 doc:b,卡片只剩 `["doc:b"]`)。
1613
+ * 退役的提议存储天然按身份去重并累积 `source_refs`,所以少了这一步就是相对旧链路的一处退化。
1614
+ *
1615
+ * 三条合并规则与编译路径完全相同,两处共用 `appendExtracted`:
1616
+ * 正文追加(自带文本判重)、`source_refs` 取并集、`metadata` 合并。
1617
+ * `status` 与人工备注 / review 由 `writeCard` 自己保留,不在这里重复照顾。
1618
+ */
1619
+ const existing = byKey.get(`${item.entityType}:${item.entityId}`)
1620
+ const requestedRefs = item.sourceRefs ?? [input.sourceId]
1621
+ const sourceRefs = existing ? [...new Set([...existing.sourceRefs, ...requestedRefs])] : requestedRefs
1622
+ const content = existing ? appendExtracted(existing.content, [item.content]) : item.content
1623
+ const written = await this.writeCard({
1624
+ entityType: item.entityType as SchemaType,
1625
+ entityId: item.entityId,
1626
+ title: item.title,
1627
+ content,
1628
+ sourceRefs,
1629
+ ...(item.summary ?? existing?.summary ? { summary: item.summary ?? existing?.summary } : {}),
1630
+ ...(item.metadata || existing?.metadata ? { metadata: { ...(existing?.metadata ?? {}), ...(item.metadata ?? {}) } } : {}),
1631
+ ...(existing ? { status: existing.status } : {}),
1632
+ ...(existing?.owner ? { owner: existing.owner } : {}),
1633
+ ...(existing?.provenance ? { provenance: existing.provenance } : {}),
1634
+ origin: 'compile',
1635
+ ...(input.lineage ? { lineage: input.lineage } : {}),
1636
+ })
1637
+ if (written.ok === false) {
1638
+ rejected.push(`${item.entityId}:${written.error.code} ${written.error.message}`)
1639
+ continue
1640
+ }
1641
+ ids.push(item.entityId)
1642
+ // 回填:这一批里后面那张卡片要按**刚写下的**样子判重,而不是按开批之前的磁盘快照。
1643
+ byKey.set(`${item.entityType}:${item.entityId}`, {
1644
+ entityId: item.entityId, entityType: item.entityType as SchemaType, title: item.title,
1645
+ status: existing?.status ?? 'unreviewed', content, sourceRefs,
1646
+ ...(item.summary ?? existing?.summary ? { summary: item.summary ?? existing?.summary } : {}),
1647
+ metadata: { ...(existing?.metadata ?? {}), ...(item.metadata ?? {}) }, updatedAt: new Date().toISOString(),
1648
+ })
1649
+ for (const warning of written.warnings ?? []) warnings.push(`${item.entityId}:${warning}`)
1650
+ }
1651
+ return success(
1652
+ { written: ids.length, ids, rejected, warnings },
1653
+ rejected.length > 0 ? [`${rejected.length} 张卡片被拒:${rejected.join(';')}`] : [],
1654
+ )
1655
+ }
1656
+
1657
+ /* ────────────────────────────────────── 入库之后:备注与 review ── */
1658
+
1659
+ /**
1660
+ * 给一张卡片加一条**人工备注**,或撤掉一条(0044)。
1661
+ *
1662
+ * 这是「wiki 不可修改」的对偶:正文改不了,但**判断改得了**。「这份文档的数字和卡片
1663
+ * 对不上」「这个口径在另一份文档里是反的」——这些都是关于卡片的判断,不是卡片的内容,
1664
+ * 所以它们落在 frontmatter 的 `annotations` 里,编译永远不碰。
1665
+ *
1666
+ * `kind: 'anomaly'` 会把卡片的有效评分压到 `ANOMALY_SCORE`(见 `effectiveReviewScore`):
1667
+ * 异常卡片照样召回得到,只是排在后面 —— 而不是被藏起来。藏起来的问题在于没人会再看到它,
1668
+ * 于是也就没人会去修。
1669
+ */
1670
+ async annotateCard(input: {
1671
+ entityId: string
1672
+ entityType?: SchemaType
1673
+ /** 不传 `note` 时必须是「撤销一条」:给出 `removeId`。 */
1674
+ kind?: 'note' | 'anomaly'
1675
+ note?: string
1676
+ author?: string
1677
+ sourceRef?: string
1678
+ removeId?: string
1679
+ lineage?: LineageContext
1680
+ }): Promise<Envelope<{ entityId: string; annotations: CardAnnotation[]; reviewScore: number }>> {
1681
+ const card = await this.readCard(input.entityId, input.entityType)
1682
+ if (!card) return failure({ entityId: input.entityId, annotations: [], reviewScore: 0 }, 'CARD_NOT_FOUND', `知识卡片不存在: ${input.entityId}`, true)
1683
+ const current = card.annotations ?? []
1684
+ let next: CardAnnotation[]
1685
+ if (input.removeId?.trim()) {
1686
+ next = current.filter(item => item.id !== input.removeId!.trim())
1687
+ if (next.length === current.length) {
1688
+ return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, 'ANNOTATION_NOT_FOUND', `备注不存在: ${input.removeId}`, true)
1689
+ }
1690
+ } else {
1691
+ const note = input.note?.trim() ?? ''
1692
+ if (!note) return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, 'ANNOTATION_NOTE_REQUIRED', 'note 不能为空', true)
1693
+ const at = new Date().toISOString()
1694
+ next = [...current, {
1695
+ id: annotationId(note, at),
1696
+ kind: input.kind === 'anomaly' ? 'anomaly' : 'note',
1697
+ note,
1698
+ ...(input.author?.trim() ? { author: input.author.trim() } : {}),
1699
+ createdAt: at,
1700
+ ...(input.sourceRef?.trim() ? { sourceRef: input.sourceRef.trim() } : {}),
1701
+ }]
1702
+ }
1703
+ const written = await this.writeCard({ ...card, sourceRefs: card.sourceRefs, annotations: next, origin: 'internal' as const })
1704
+ if (written.ok === false) return failure({ entityId: input.entityId, annotations: current, reviewScore: effectiveReviewScore(card) }, written.error.code, written.error.message, written.error.retryable, written.error.suggestion)
1705
+ await this.audit('wiki.annotate', { entityId: input.entityId, added: input.removeId ? 0 : 1, removed: input.removeId ? 1 : 0, count: next.length })
1706
+ const score = effectiveReviewScore({ annotations: next, ...(card.review ? { review: card.review } : {}) })
1707
+ return success({ entityId: input.entityId, annotations: next, reviewScore: score })
1708
+ }
1709
+
1710
+ /**
1711
+ * review 一张卡片(0044)。**它不是准入闸门** —— 卡片在 review 之前就已经入库、可召回了。
1712
+ *
1713
+ * `review` 把评分抬到 `REVIEWED_SCORE_FLOOR`,`unreview` 放回出厂分。两个动作都可逆:
1714
+ * review 是一个可以被推翻的判断,不是一次性的凭证。
1715
+ */
1716
+ async reviewCard(input: {
1717
+ entityId: string
1718
+ entityType?: SchemaType
1719
+ action: 'review' | 'unreview'
1720
+ reviewer?: string
1721
+ lineage?: LineageContext
1722
+ }): Promise<Envelope<{ entityId: string; review: CardReview; reviewScore: number; status: KnowledgeStatus }>> {
1723
+ const card = await this.readCard(input.entityId, input.entityType)
1724
+ if (!card) return failure({ entityId: input.entityId, review: { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE }, reviewScore: CARD_REVIEW_BASE_SCORE, status: 'unreviewed' as KnowledgeStatus }, 'CARD_NOT_FOUND', `知识卡片不存在: ${input.entityId}`, true)
1725
+ const at = new Date().toISOString()
1726
+ const reviewer = input.reviewer?.trim() || 'user:panel'
1727
+ const review: CardReview = input.action === 'review'
1728
+ ? { state: 'reviewed', score: Math.max(card.review?.score ?? 0, REVIEWED_SCORE_FLOOR), reviewedAt: at, reviewedBy: reviewer }
1729
+ : { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE }
1730
+ /**
1731
+ * 状态跟着一起走(0044 实机验证第七批)。
1732
+ *
1733
+ * 卡片有两个字段:`status` 是**生命周期**(编译器写它,人在 `set_knowledge_status`
1734
+ * 里改),`review` 是**人的判断 + 评分**。它们本来描述不同的事,但「已经 review 过」
1735
+ * 的卡片在预览里显示 `状态 unreviewed` 会让人以为刚才那一下没生效 —— 实机验证时
1736
+ * 看到的就是这个组合。
1737
+ *
1738
+ * 所以:
1739
+ * - `review` 通过时,`unreviewed` / `draft` 推进到 `reviewed`(这个词本来就在
1740
+ * 状态词表里,含义就是这个);
1741
+ * - 撤销 review 时只把 `reviewed` 退回 `unreviewed`,**不动** `active` / `deprecated` /
1742
+ * `problem` —— 那些是人另外做过的决定,不该被一次撤销顺手抹掉。
1743
+ */
1744
+ const nextStatus: KnowledgeStatus = input.action === 'review'
1745
+ ? (card.status === 'unreviewed' || card.status === 'draft' ? 'reviewed' : card.status)
1746
+ : (card.status === 'reviewed' ? 'unreviewed' : card.status)
1747
+ const written = await this.writeCard({ ...card, status: nextStatus, sourceRefs: card.sourceRefs, review, origin: 'review' as const })
1748
+ if (written.ok === false) {
1749
+ const current: CardReview = card.review ?? { state: 'unreviewed', score: CARD_REVIEW_BASE_SCORE }
1750
+ 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)
1751
+ }
1752
+ await this.audit('wiki.review', { entityId: input.entityId, action: input.action, reviewer, score: review.score })
1753
+ return success({ entityId: input.entityId, review, reviewScore: effectiveReviewScore({ ...card, review }), status: nextStatus })
1754
+ }
1755
+
1756
+ /**
1757
+ * 对已落库的卡片跑三层校验。不写盘,只报告。
1758
+ *
1759
+ * 0049 补齐了 `reference` 那一层:正文里的 `[[类型/名称]]` 指向一张不存在的卡片,
1760
+ * 就是一条断链。此前这一层只有类型定义里的名字,没有任何产生者 —— 而「引用错说明
1761
+ * 图断了」正是它要回答的问题(format 错说明没填完,语义错说明这条知识没用)。
1762
+ */
846
1763
  async lintCards(): Promise<Array<CardSchemaIssue & { entityId: string; entityType: string }>> {
847
1764
  const schema = this.wikiSchema()
848
1765
  const byId = new Map(schema.types.map(def => [def.id, def]))
849
1766
  const result: Array<CardSchemaIssue & { entityId: string; entityType: string }> = []
850
- for (const card of await this.loadCards()) {
1767
+ const cards = await this.loadCards()
1768
+ for (const card of cards) {
851
1769
  const type = byId.get(card.entityType)
852
1770
  if (!type) continue
853
1771
  for (const issue of validateCardAgainstType(type, card)) {
854
1772
  result.push({ ...issue, entityId: card.entityId, entityType: card.entityType })
855
1773
  }
856
1774
  }
1775
+ // 断链与类型无关(一张类型没登记的卡片照样能有链接),所以单独扫一遍全部卡片。
1776
+ for (const link of deadLinksOf(cards)) {
1777
+ result.push({ ...deadLinkIssue(link.target), entityId: link.entityId, entityType: link.entityType })
1778
+ }
857
1779
  return result
858
1780
  }
859
1781
 
860
1782
  /**
861
- * `write_knowledge_card` 的类型闸门 + 三层校验。
1783
+ * 类型闸门 + 三层校验。
862
1784
  *
863
- * 返回 `{ type, issues }` 或一个失败信封。是否因 issues 拒绝写入由 `mode` 决定:
864
- * `strict` 直接拒绝,`report` 把问题写进卡片并回报——存量卡片因此不会被升级挡在门外。
1785
+ * 返回 `{ type, issues }` 或一个失败信封(只可能因为**未知类型** —— 没有类型定义就连
1786
+ * 「缺什么」都说不出来)。issues **不决定写不写**:0044 起卡片一律直接入库,问题写进
1787
+ * `metadata.schema_issues` 由面板显示成「待补」。原来的 `strict` / `report` 开关一并撤掉了。
865
1788
  */
866
1789
  private cardSchemaGate(input: { entityType: string; title: string; content: string; sourceRefs: string[]; metadata: Record<string, unknown>; owner?: string; summary?: string }):
867
1790
  { type: WikiTypeDef; issues: CardSchemaIssue[] } | { error: KnowledgeError } {
@@ -933,6 +1856,13 @@ export class KnowledgeRuntime {
933
1856
  }
934
1857
 
935
1858
  directory(name: typeof LAYOUT[number]): string { return join(this.rootDir, name) }
1859
+ /**
1860
+ * 0051 之前的段目录路径。
1861
+ *
1862
+ * 它**不在 `LAYOUT` 里**:`LAYOUT` 是「应该建出来的目录」清单,而老段名只是要读、
1863
+ * 要搬走的东西 —— 把它加进 `LAYOUT`,`ensureLayout` 每次启动都会把那个空目录建回来。
1864
+ */
1865
+ private legacySourceDir(): string { return join(this.rootDir, LEGACY_SOURCE_TRUTH) }
936
1866
  async ensureLayout(): Promise<void> {
937
1867
  await Promise.all(LAYOUT.map(name => mkdir(this.directory(name), { recursive: true })))
938
1868
  // 记忆段在知识树**外面**(0032),所以要单独建。它与 LAYOUT 一起建的理由相同:
@@ -943,11 +1873,109 @@ export class KnowledgeRuntime {
943
1873
  // 写入与统计的入口。实例级标志保证每个 runtime 只扫一次。
944
1874
  if (!this.segmentsOrganized) {
945
1875
  this.segmentsOrganized = true
1876
+ // 0051 的改名排在最前:后面几个迁移都按 `SOURCE_TRUTH` 扫目录,先把树搬过来,
1877
+ // 它们就只需要认一个名字(而老段名只留兼容解析那一处)。
1878
+ await this.migrateSourceTree()
946
1879
  await this.migrateSourceTruthLayout()
947
- await this.dropLegacySchemaSegment()
1880
+ await this.dropRetiredSegments()
1881
+ await this.migrateLegacySourceTypeRename()
1882
+ await this.migrateLegacyDiagnosisCards()
1883
+ await this.migrateSourceFrontMatter()
1884
+ await this.migrateSourceFileNames()
948
1885
  }
949
1886
  }
950
1887
 
1888
+ /**
1889
+ * 0051:`01_source_truth/` → `01_source_raw/`。
1890
+ *
1891
+ * **整棵树一次 `rename`**,不逐文件搬:`_assets/` 就在那个目录里,逐文件搬会把
1892
+ * 图片落下的概率变成「每个文件各一次」。一次 rename 是原子的,要么全过来要么全没动。
1893
+ *
1894
+ * 两种中间态都要处理:
1895
+ * - 目标不存在 → 直接 rename;
1896
+ * - 两个目录都在(上一次搬到一半)→ 逐项合并:目标里没有的搬过去,两边都有的
1897
+ * (`_assets` 与分类目录)递归合并,最后删掉空壳。
1898
+ *
1899
+ * 失败不回滚、不抛:`loadSources` 两个段名都扫,搬到一半的库照常可读,
1900
+ * 下一次启动接着搬。
1901
+ */
1902
+ private async migrateSourceTree(): Promise<void> {
1903
+ const from = this.legacySourceDir()
1904
+ const to = this.directory(SOURCE_TRUTH)
1905
+ if (!existsSync(from)) return
1906
+ try {
1907
+ if (!existsSync(to)) {
1908
+ await rename(from, to)
1909
+ } else {
1910
+ await mergeDir(from, to)
1911
+ await rmdir(from).catch(() => { /* 还有搬不动的,下次再来 */ })
1912
+ }
1913
+ this.sourceIndex = undefined
1914
+ this.sourcePathOwner = undefined
1915
+ this.migrationProblems.push('段目录已改名为 01_source_raw(原 01_source_truth)')
1916
+ } catch (cause) {
1917
+ this.migrationProblems.push(`段目录改名未完成,下次启动继续:${cause instanceof Error ? cause.message : String(cause)}`)
1918
+ }
1919
+ }
1920
+
1921
+ /**
1922
+ * 0051:文件名改成标题。
1923
+ *
1924
+ * 只动**名字不对**的那些(可重入),而且**只 rename,不改内容** —— 正文里那些
1925
+ * `../../01_source_truth/_assets/…` 的老引用一个字节都不动,解析器认两个段名
1926
+ * (见 `assetRefCandidates`)。改内容会造内容版本、让卡片重编重编码,代价远大于
1927
+ * 一个字符串好不好看。
1928
+ *
1929
+ * 目标路径被别的来源占着时**不覆盖**:加内容哈希后缀并记一条,让人去改标题
1930
+ * (与 `addSource` 的默认拒收同一条纪律,只是这里不能拒 —— 那是历史数据)。
1931
+ */
1932
+ private async migrateSourceFileNames(): Promise<void> {
1933
+ const sources = await this.loadSources()
1934
+ const index = await this.sourceIndexMap()
1935
+ const taken = new Map<string, string>()
1936
+ for (const source of sources) {
1937
+ const path = index.get(slug(source.sourceId))
1938
+ if (path === undefined) continue
1939
+ taken.set(path, source.sourceId)
1940
+ }
1941
+ for (const source of sources) {
1942
+ const path = index.get(slug(source.sourceId))
1943
+ if (path === undefined) continue
1944
+ /**
1945
+ * 没有合法 `type` 的文件**留在原地**(与 0030 同一条纪律:宁可少搬,不可搬错)。
1946
+ *
1947
+ * `loadSources` 把读不出分类的来源当成 `document`(读得到总比丢掉好),所以
1948
+ * 「默认成了 document」与「真的写着 document」在这里长得一样 —— 必须回文件里
1949
+ * 看那一行,否则一个没有 front matter 的散文件会被搬进 `document/`。
1950
+ */
1951
+ if (!(await this.hasDeclaredSourceType(path))) continue
1952
+ let target = this.sourcePath(source.sourceId, source.sourceType, source.title)
1953
+ if (target === path) continue
1954
+ const occupant = taken.get(target)
1955
+ if (occupant !== undefined && occupant !== source.sourceId) {
1956
+ target = this.safePath(SOURCE_TRUTH, source.sourceType, `${sourceFileBase(source.title, source.sourceId)}-${hash(source.sourceId).slice(0, 6)}.md`)
1957
+ this.migrationProblems.push(`来源 ${source.sourceId} 与 ${occupant} 标题相同(「${source.title}」),已落成 ${basename(target)};建议改标题`)
1958
+ }
1959
+ try {
1960
+ await mkdir(dirname(target), { recursive: true })
1961
+ await rename(path, target)
1962
+ taken.delete(path)
1963
+ taken.set(target, source.sourceId)
1964
+ } catch (cause) {
1965
+ this.migrationProblems.push(`来源 ${source.sourceId} 改名失败,保持原名:${cause instanceof Error ? cause.message : String(cause)}`)
1966
+ }
1967
+ }
1968
+ this.sourceIndex = undefined
1969
+ this.sourcePathOwner = undefined
1970
+ }
1971
+
1972
+ /** 这份文件自己声明了合法分类吗(`migrateSourceFileNames` 只搬声明过的)。 */
1973
+ private async hasDeclaredSourceType(path: string): Promise<boolean> {
1974
+ try {
1975
+ return SOURCE_TYPE_ID_PATTERN.test(sourceTypeOf(parseMarkdown(await readFile(path, 'utf8')).frontmatter))
1976
+ } catch { return false }
1977
+ }
1978
+
951
1979
  /**
952
1980
  * 0030:把平铺的 `<段>/*.md` 搬进 `<段>/<source_type>/*.md`。
953
1981
  *
@@ -965,7 +1993,7 @@ export class KnowledgeRuntime {
965
1993
  const from = join(root, entry.name)
966
1994
  let sourceType = ''
967
1995
  try {
968
- sourceType = String(parseMarkdown(await readFile(from, 'utf8')).frontmatter.source_type ?? '')
1996
+ sourceType = sourceTypeOf(parseMarkdown(await readFile(from, 'utf8')).frontmatter)
969
1997
  } catch { continue }
970
1998
  if (!SOURCE_TYPE_ID_PATTERN.test(sourceType)) continue
971
1999
  const target = join(root, sourceType, entry.name)
@@ -978,6 +2006,64 @@ export class KnowledgeRuntime {
978
2006
  this.sourceIndex = undefined
979
2007
  }
980
2008
 
2009
+ /**
2010
+ * 0048:把存量 SourceRaw 的 front matter 改成 OKF v0.2 的形状。
2011
+ *
2012
+ * 改三件事,都只搬**已经存在**的值,不猜、不造:
2013
+ *
2014
+ * 1. `source_type` → `type`(OKF 唯一必填键)。旧键删掉 —— 同一个事实留两份,
2015
+ * 迟早会出现「改了这份、那份没改」。读取侧两个键都认(见 `sourceTypeOf`),
2016
+ * 所以迁移没跑到、或别的库还是旧形状,都不影响可用性。
2017
+ * 2. `metadata.source_url` → 顶层 `resource`(OKF 的「底层资产地址」)。
2018
+ * 3. 补 `generated{by,at}` 与 `sources[]`:`by` 取自 `metadata.parser`(谁解析的),
2019
+ * `at` 取自 `created_at`(这份文件什么时候写出来的)。**作者与发布时间补不出来**
2020
+ * —— 0048 之前适配器把它们抽出来就扔了,这里如实留空,不编一个。
2021
+ *
2022
+ * 正文一个字节都不动,也不制造内容版本(版本只认正文变化)。整个迁移可重入:
2023
+ * 已经有 `type` 的文件直接跳过。
2024
+ */
2025
+ private async migrateSourceFrontMatter(): Promise<void> {
2026
+ for (const path of await filesUnder(this.directory(SOURCE_TRUTH))) {
2027
+ let parsed
2028
+ try { parsed = parseMarkdown(await readFile(path, 'utf8')) } catch { continue }
2029
+ const frontmatter = parsed.frontmatter
2030
+ if (typeof frontmatter.type === 'string' && frontmatter.type.trim()) continue
2031
+ const sourceType = sourceTypeOf(frontmatter) || 'document'
2032
+ const metadata = plainRecord(frontmatter.metadata)
2033
+ const resource = typeof metadata.source_url === 'string' && metadata.source_url.trim() ? metadata.source_url.trim() : undefined
2034
+ const parser = typeof metadata.parser === 'string' && metadata.parser.trim() ? metadata.parser.trim() : undefined
2035
+ const createdAt = typeof frontmatter.created_at === 'string' ? frontmatter.created_at : undefined
2036
+ const title = typeof frontmatter.title === 'string' ? frontmatter.title : undefined
2037
+ /**
2038
+ * `metadata.source_url` 与顶层 `resource` 是同一个事实:搬上去之后就把旧的删掉
2039
+ * —— 两份键迟早出现「改了这份、那份没改」。`source_path` 同理:它指的是暂存目录里
2040
+ * 那个**已经被删掉**的文件,留着它只会让人以为还能找回来(底稿现在由交付目录的
2041
+ * `source/` 承担,0048 起才有)。
2042
+ */
2043
+ const { source_url: _promoted, source_path: _stale, ...restMetadata } = metadata
2044
+ const { source_type: _legacy, ...rest } = frontmatter
2045
+ const nextMetadata = Object.keys(restMetadata).length > 0 ? restMetadata : undefined
2046
+ const next: Record<string, unknown> = {
2047
+ type: sourceType,
2048
+ ...(title !== undefined ? { title } : {}),
2049
+ ...(resource !== undefined ? { resource } : {}),
2050
+ ...(parser !== undefined || createdAt !== undefined
2051
+ ? { generated: { ...(parser !== undefined ? { by: `convert/${parser}` } : {}), ...(createdAt !== undefined ? { at: createdAt } : {}) } }
2052
+ : {}),
2053
+ ...(resource !== undefined ? { sources: [{ id: 'origin', resource, ...(title !== undefined ? { title } : {}) }] } : {}),
2054
+ ...rest,
2055
+ ...(nextMetadata !== undefined ? { metadata: nextMetadata } : {}),
2056
+ }
2057
+ try {
2058
+ await writeTextAtomic(path, markdown(next, parsed.content))
2059
+ } catch (cause) {
2060
+ this.migrationProblems.push(`来源 ${basename(path)} 的 front matter 迁移失败:${cause instanceof Error ? cause.message : String(cause)}`)
2061
+ }
2062
+ }
2063
+ this.sourceIndex = undefined
2064
+ this.sourceCorpusCache = undefined
2065
+ }
2066
+
981
2067
  /**
982
2068
  * 0030:删掉已经被撤掉的 `00_schema` 段。
983
2069
  *
@@ -985,8 +2071,35 @@ export class KnowledgeRuntime {
985
2071
  * 有人放了文件,那就不属于「没用的空文件夹」了 —— 那种情况下留着并让人自己判断,
986
2072
  * 比替他们删掉安全。
987
2073
  */
988
- private async dropLegacySchemaSegment(): Promise<void> {
989
- const path = this.safePath('00_schema')
2074
+ /**
2075
+ * 清掉**只装了旧版本自己生成的东西**的退役段。
2076
+ *
2077
+ * `00_schema` 从来没有一行代码写过东西,所以「空」就是它的全部历史形态 —— 空就删。
2078
+ *
2079
+ * `05_diagnosis` 不一样:旧版本在里面铺过 `templates/*.md` 与一份 README,那两样是
2080
+ * 可以重建的(模板由类型定义派生、README 是说明),删掉不丢知识。但同一个目录里
2081
+ * 可能躺着**真正的历史快照**(`*.jsonl` / `cases.json`),那是只读的迁移输入,
2082
+ * 一个字节都不能动。所以判据是**文件清单**而不是「空不空」:只认得旧版本自己写的
2083
+ * 那些名字时,才删;出现任何别的东西就整个留着。
2084
+ */
2085
+ private async dropRetiredSegments(): Promise<void> {
2086
+ await this.dropSegmentIfEmpty('00_schema')
2087
+ const legacy = this.safePath('05_diagnosis')
2088
+ try {
2089
+ const entries = await readdir(legacy, { withFileTypes: true })
2090
+ const owned = entries.every(entry =>
2091
+ entry.isDirectory()
2092
+ ? entry.name === 'templates'
2093
+ : entry.name === 'README.md' || entry.name.endsWith('.jsonl') || entry.name === 'cases.json')
2094
+ if (!owned) return
2095
+ // 有 jsonl / cases.json 就是历史快照,留着(`DiagnosisStore` 还要读它做迁移)。
2096
+ if (entries.some(entry => entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'cases.json'))) return
2097
+ await rm(legacy, { recursive: true, force: true })
2098
+ } catch { /* 不存在或删不掉都不是错误 */ }
2099
+ }
2100
+
2101
+ private async dropSegmentIfEmpty(name: string): Promise<void> {
2102
+ const path = this.safePath(name)
990
2103
  try {
991
2104
  const entries = await readdir(path)
992
2105
  if (entries.length > 0) return
@@ -997,6 +2110,137 @@ export class KnowledgeRuntime {
997
2110
  } catch { /* 不存在、非空、或删不掉都不是错误 */ }
998
2111
  }
999
2112
 
2113
+ /**
2114
+ * 0040 §3.2 的一次性迁移:`01_source_raw/ai_chat_message/` → `agent_chat_message/`。
2115
+ *
2116
+ * 改名不能只改注册表:`01_source_raw/` 是**按分类分目录**的,存量来源的
2117
+ * frontmatter 里写着 `source_type: ai_chat_message`。只改注册表会有两个后果 ——
2118
+ * 旧目录里那些来源变成一个「注册表里没有的分类」(界面上显示成未知分类),
2119
+ * 而旧分类因为还有来源在,永远删不掉。
2120
+ *
2121
+ * 三条纪律:
2122
+ * 1. **逐份搬,不整体 rename 目录。** 目标里可能已经有同名文件(用户在新版本里
2123
+ * 重新录过同一份来源),整体搬会覆盖它。
2124
+ * 2. **同名时比对正文**:一致就删掉旧的那份(同一份东西,只是目录名变了);
2125
+ * 不一致就**两边都留**,并把冲突报给界面 —— 静默覆盖等于丢数据。
2126
+ * 3. **迁移无损才认**:写完读回来比对 frontmatter 与正文,不一致就放弃这一份。
2127
+ */
2128
+ private async migrateLegacySourceTypeRename(): Promise<void> {
2129
+ const from = this.safePath(SOURCE_TRUTH, 'ai_chat_message')
2130
+ const to = this.safePath(SOURCE_TRUTH, 'agent_chat_message')
2131
+ let names: string[]
2132
+ try { names = await readdir(from) } catch { return }
2133
+ const conflicts: string[] = []
2134
+ for (const name of names) {
2135
+ if (extname(name).toLowerCase() !== '.md') continue
2136
+ const source = join(from, name)
2137
+ let raw: string
2138
+ try { raw = await readFile(source, 'utf8') } catch { continue }
2139
+ const parsed = parseMarkdown(raw)
2140
+ const sourceId = String(parsed.frontmatter.source_id ?? basename(name, '.md'))
2141
+ const target = join(to, name)
2142
+ let existing: string | undefined
2143
+ try { existing = await readFile(target, 'utf8') } catch { /* 目标里还没有 */ }
2144
+ if (existing !== undefined) {
2145
+ const previous = parseMarkdown(existing)
2146
+ if (previous.content.trim() !== parsed.content.trim()) conflicts.push(sourceId)
2147
+ await unlinkIfExists(source)
2148
+ continue
2149
+ }
2150
+ await writeTextAtomic(target, markdown({ ...parsed.frontmatter, type: 'agent_chat_message', source_type: 'agent_chat_message' }, parsed.content))
2151
+ const roundTrip = parseMarkdown(await readFile(target, 'utf8'))
2152
+ if (roundTrip.content.trim() !== parsed.content.trim() || sourceTypeOf(roundTrip.frontmatter) !== 'agent_chat_message') {
2153
+ // 读回来对不上:把目标撤掉,旧的那份留着 —— 宁可迁移没生效,也不要留半份。
2154
+ await unlinkIfExists(target)
2155
+ conflicts.push(`${sourceId}(迁移后比对不一致,已放弃)`)
2156
+ continue
2157
+ }
2158
+ await unlinkIfExists(source)
2159
+ }
2160
+ try { await rmdir(from) } catch { /* 还有别的东西在里面 */ }
2161
+ if (conflicts.length > 0) {
2162
+ this.migrationProblems.push(`来源分类改名迁移有 ${conflicts.length} 处冲突,旧文件保留在 01_source_raw/ai_chat_message/:${conflicts.join('、')}`)
2163
+ }
2164
+ }
2165
+
2166
+ /**
2167
+ * 0040 §4 的一次性迁移:两次改名(`check` → `check_point`、`failure-mode` →
2168
+ * `failure_reason`)的存量卡片。
2169
+ *
2170
+ * 两件事一起做,因为它们是同一次改名的两个面:
2171
+ * 1. **卡片类型**:`02_wiki/diagnosis/check/` → `check-point/`,
2172
+ * frontmatter 的 `entity_type` 一起改;
2173
+ * 2. **结构化块里的字段名**:`failure_mode` → `failure_reason`。这一条与卡片类型
2174
+ * 无关 —— `diagnosis/hypothesis` 卡片的类型没变,但它块里那个字段变了。
2175
+ * 只按目录过滤的话,假设卡片上的旧字段名会永远留在磁盘上,而读回来是「少一个
2176
+ * 必填字段」,正文里却明明写着它。
2177
+ *
2178
+ * 读取端本来也认别名(`cards.parseCard`),但**只在内存里认是不够的**:`writeCard`
2179
+ * 按 `entity_type` 决定写哪个目录,改一张旧卡片就会在 `check-point/` 下多写一份,
2180
+ * 而旧目录里那份还在 —— 同一张卡片变成两张,读图时看谁先被遍历到。
2181
+ *
2182
+ * 用 `writeCard` 而不是自己拼 frontmatter:它是唯一知道卡片信封长什么样、并且会
2183
+ * 顺手做版本快照与三层校验的地方。
2184
+ */
2185
+ private async migrateLegacyDiagnosisCards(): Promise<void> {
2186
+ const root = this.safePath(WIKI, 'diagnosis')
2187
+ let kinds: string[]
2188
+ try { kinds = await readdir(root) } catch { return }
2189
+ /**
2190
+ * 目录名是 `entity_type` 的**命名空间之后那一段**(`02_wiki/diagnosis/check/` 对应
2191
+ * `diagnosis/check`)。用后缀匹配而不是拿 `entity_type` 直接比目录名 —— 直接比的话
2192
+ * 一个都匹配不上,而失败方式是静默的:迁移什么都没做,卡片仍按旧类型读。
2193
+ */
2194
+ const legacyByDir = new Map<string, string>()
2195
+ for (const [legacy, next] of Object.entries(LEGACY_ENTITY_TYPES)) {
2196
+ legacyByDir.set(legacy.slice(legacy.indexOf('/') + 1), next)
2197
+ }
2198
+ const problems: string[] = []
2199
+ for (const dir of kinds) {
2200
+ const legacyDir = join(root, dir)
2201
+ const renamedType = legacyByDir.get(dir)
2202
+ // 类型没改名、也不在旧目录里的子树也要扫一遍:块字段名的改名与类型无关。
2203
+ for (const file of await filesUnder(legacyDir)) {
2204
+ let raw: string
2205
+ try { raw = await readFile(file, 'utf8') } catch { continue }
2206
+ const parsed = parseMarkdown(raw)
2207
+ const entityId = String(parsed.frontmatter.entity_id ?? basename(file, '.md'))
2208
+ const currentType = String(parsed.frontmatter.entity_type ?? '')
2209
+ const nextType = renamedType ?? LEGACY_ENTITY_TYPES[currentType] ?? currentType
2210
+ if (!nextType) continue
2211
+ const body = rewriteLegacyBlockFields(parsed.content)
2212
+ const typeChanged = nextType !== currentType
2213
+ const bodyChanged = body !== parsed.content
2214
+ // 两样都没变就什么都不做 —— 每次启动都重写一遍所有卡片会把版本历史灌满。
2215
+ if (!typeChanged && !bodyChanged) continue
2216
+ const written = await this.writeCard({
2217
+ entityType: nextType as SchemaType,
2218
+ entityId,
2219
+ title: String(parsed.frontmatter.title ?? entityId),
2220
+ content: body,
2221
+ sourceRefs: Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : [],
2222
+ status: (STATUS.includes(String(parsed.frontmatter.status) as KnowledgeStatus) ? String(parsed.frontmatter.status) : 'unreviewed') as KnowledgeStatus,
2223
+ ...(typeof parsed.frontmatter.summary === 'string' && parsed.frontmatter.summary ? { summary: parsed.frontmatter.summary } : {}),
2224
+ ...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
2225
+ metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata as Record<string, unknown> : {},
2226
+ })
2227
+ if (written.ok === false) {
2228
+ problems.push(`卡片 ${entityId} 从 ${currentType || dir} 迁到 ${nextType} 失败(${written.error.code}),旧文件保留`)
2229
+ continue
2230
+ }
2231
+ // 读回来确认它真的落在新目录、而且认得出新类型,再删旧的。
2232
+ const readBack = await this.readCard(entityId, nextType as SchemaType)
2233
+ if (!readBack) {
2234
+ problems.push(`卡片 ${entityId} 迁移后读不回来,旧文件保留`)
2235
+ continue
2236
+ }
2237
+ // 正文没变、类型也没变时,`writeCard` 写的是同一个路径 —— 这时**不要**删它。
2238
+ if (written.data.path !== file) await unlinkIfExists(file)
2239
+ }
2240
+ try { await rmdir(legacyDir) } catch { /* 还有别的东西在里面 */ }
2241
+ }
2242
+ if (problems.length > 0) this.migrationProblems.push(...problems)
2243
+ }
1000
2244
  private async loadVectorIndex(): Promise<LoadedVectorIndex | null> {
1001
2245
  if (this.vectorIndex) return this.vectorIndex
1002
2246
  try {
@@ -1228,6 +2472,10 @@ export class KnowledgeRuntime {
1228
2472
  const graph = JSON.parse(await readFile(this.safePath('03_compiled', 'knowledge_graph.json'), 'utf8'))
1229
2473
  if (graph && Array.isArray(graph.nodes) && Array.isArray(graph.edges)) this.graph = graph as typeof this.graph
1230
2474
  } catch { /* the first build has not happened yet */ }
2475
+ try {
2476
+ const diagnosis = JSON.parse(await readFile(this.safePath('03_compiled', DIAGNOSIS_GRAPH), 'utf8'))
2477
+ if (diagnosis && Array.isArray(diagnosis.nodes) && Array.isArray(diagnosis.edges)) this.diagnosisGraph = diagnosis as typeof this.diagnosisGraph
2478
+ } catch { /* 归因图还没编过(这个库没做过排查),或者旧版本没有这个产物 */ }
1231
2479
  }
1232
2480
 
1233
2481
  private safePath(...parts: string[]): string {
@@ -1270,9 +2518,9 @@ export class KnowledgeRuntime {
1270
2518
  only?: string; incremental?: boolean; skipped?: boolean
1271
2519
  cardsReused?: number; cardsRecompiled?: number; cardsAdded?: number; cardsRemoved?: number
1272
2520
  notesReused?: number; notesRecompiled?: number
1273
- // Pass 0 的计数:面板据此显示「这一轮抽了几个候选、建了几张卡」。
2521
+ // Pass 0 的计数:面板据此显示「这一轮抽了几个候选、新建/更新了几张卡片」。
1274
2522
  candidatesExtracted?: number; candidatesRejected?: number
1275
- cardsCreated?: number; cardsMerged?: number
2523
+ cardsCreated?: number; cardsUpdated?: number
1276
2524
  relationsExtracted?: number; relationsDropped?: number
1277
2525
  sourcesExtracted?: number; sourcesSkipped?: number
1278
2526
  }
@@ -1293,7 +2541,7 @@ export class KnowledgeRuntime {
1293
2541
  memoryFiles: await fileCount(this.memoryDir),
1294
2542
  /**
1295
2543
  * 按**段名**取,不按位置取。这里原先是 `counts[1]`,而它之所以曾经是对的,
1296
- * 只是因为 `LAYOUT[1]` 恰好是 `01_source_truth` —— 0030 撤掉 `00_schema`
2544
+ * 只是因为 `LAYOUT[1]` 恰好是 `01_source_raw` —— 0030 撤掉 `00_schema`
1297
2545
  * 之后同一条表达式就静默变成了「wiki 卡片数」,界面上的来源数直接归零。
1298
2546
  * 布局的索引是实现的偶然,段名才是契约。
1299
2547
  */
@@ -1303,6 +2551,12 @@ export class KnowledgeRuntime {
1303
2551
  notes: this.units.length,
1304
2552
  graphNodes: this.graph.nodes.length,
1305
2553
  graphEdges: this.graph.edges.length,
2554
+ /**
2555
+ * 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
2556
+ *
2557
+ * 它必须能被界面读到:迁移失败而没人知道,等价于数据已经丢了。
2558
+ */
2559
+ migrationProblems: this.migrationProblems,
1306
2560
  buildVersion: Number(buildManifest.version ?? 0),
1307
2561
  builtAt: buildManifest.builtAt,
1308
2562
  trackedCards: Object.keys(buildManifest.cardHashes ?? {}).length,
@@ -1326,7 +2580,133 @@ export class KnowledgeRuntime {
1326
2580
  }
1327
2581
  }
1328
2582
 
1329
- async addSource(input: { sourceType: SourceType; title?: string; content: string; externalId?: string; metadata?: Record<string, unknown>; sourceId?: string; lineage?: LineageContext }): Promise<Envelope<{ sourceId: string; path: string; lineage?: LineageContext }>> {
2583
+ /**
2584
+ * 批量写入来源 + **一次**编译(0045)。
2585
+ *
2586
+ * ## 为什么值得单独一个方法,而不是让调用方循环 `add_source` + `rebuild_knowledge`
2587
+ *
2588
+ * 三件事只有放在一起才对:
2589
+ *
2590
+ * 1. **一次审批。** Agent 侧写入都要过 DSH 的审批闸门(`tools/pre-execute` 里那几个
2591
+ * 写工具)。十份文档 = 十次弹窗,而人真正要同意的是「把这十份导进去」这一件事。
2592
+ * 2. **编译只跑一次。** 每写一份就 `rebuild` 一次,第 10 份文档要付 10 次抽取的钱,
2593
+ * 而前 9 次的结果当场就被下一次覆盖。
2594
+ * 3. **失败要局部化。** 第 7 份文档格式不对,不该让前 6 份白写、后 3 份不写。
2595
+ * 所以逐条记录成败,最后一次性汇报 —— 成功的已经在库里,失败的带着原因回来,
2596
+ * 调用方可以只重试那几条。
2597
+ *
2598
+ * ## 为什么全部失败时返回 failure 而不是带 warning 的 success
2599
+ *
2600
+ * 「10 份全没进去」与「10 份都进去了,其中 1 份顺带报了个警告」是两件完全不同的事,
2601
+ * 用同一种信封表达,模型会把前一种当成后一种继续往下走。所以一份都没写成时如实失败,
2602
+ * 每条的原因仍然在 `sources[].message` 里。
2603
+ */
2604
+ async importSources(input: {
2605
+ sources: Array<{ sourceType: SourceType; title?: string; content: string; externalId?: string; metadata?: Record<string, unknown>; allowDuplicateTitle?: boolean }>
2606
+ /** 默认编译(`only: 'wiki'`)。关掉它就只做写入,留给调用方自己决定何时编译。 */
2607
+ compile?: boolean
2608
+ lineage?: LineageContext
2609
+ }): Promise<Envelope<ImportSourcesResult>> {
2610
+ const empty: ImportSourcesResult = { written: [], failed: [], attempted: 0, compile: null }
2611
+ const items = Array.isArray(input.sources) ? input.sources : []
2612
+ if (items.length === 0) return failure(empty, 'SOURCES_REQUIRED', 'sources 不能为空:至少要给一份来源', true)
2613
+ /**
2614
+ * 上限只防「一次调用把整台机器按住」:编译是逐份来源串行抽实体的,50 份已经要跑很久。
2615
+ * 它**不是**产品配额 —— 超过就分批调,每批各自编译。
2616
+ */
2617
+ if (items.length > MAX_IMPORT_SOURCES) {
2618
+ return failure(empty, 'IMPORT_TOO_MANY', `一次最多导入 ${MAX_IMPORT_SOURCES} 份来源,收到 ${items.length} 份;请分批调用`, true)
2619
+ }
2620
+
2621
+ const written: ImportedSourceReport[] = []
2622
+ const failed: ImportFailure[] = []
2623
+ const warnings: string[] = []
2624
+ for (const [index, item] of items.entries()) {
2625
+ const title = item?.title?.trim() || undefined
2626
+ const result = await this.addSource({
2627
+ sourceType: item?.sourceType,
2628
+ ...(title ? { title } : {}),
2629
+ content: item?.content,
2630
+ ...(item?.externalId ? { externalId: item.externalId } : {}),
2631
+ ...(item?.metadata ? { metadata: item.metadata } : {}),
2632
+ ...(item?.allowDuplicateTitle === true ? { allowDuplicateTitle: true } : {}),
2633
+ ...(input.lineage ? { lineage: input.lineage } : {}),
2634
+ })
2635
+ if (result.ok === false) {
2636
+ const message = result.error?.message ?? '写入失败'
2637
+ failed.push({ index, ...(title ? { title } : {}), code: result.error?.code ?? 'SOURCE_WRITE_FAILED', message })
2638
+ // 逐条失败也要出现在 warnings 里:`ImportSourcesResult.failed` 是结构化账本,
2639
+ // 而 warnings 是不管谁在读都必须看见的那一句。
2640
+ warnings.push(`第 ${index + 1} 份来源${title ? `「${title}」` : ''}没有写入:${message}`)
2641
+ continue
2642
+ }
2643
+ written.push({
2644
+ index,
2645
+ sourceId: result.data.sourceId,
2646
+ ...(title ? { title } : {}),
2647
+ path: result.data.path,
2648
+ bytes: typeof item?.content === 'string' ? item.content.length : 0,
2649
+ })
2650
+ }
2651
+
2652
+ if (written.length === 0) {
2653
+ return failure({ ...empty, failed, attempted: items.length }, 'IMPORT_ALL_FAILED', `${items.length} 份来源全部写入失败,没有任何东西进入知识库`, true,
2654
+ failed[0] ? `第一份的原因:${failed[0].message}` : undefined)
2655
+ }
2656
+
2657
+ /**
2658
+ * 编译。**只要有一份写成就编**:成功的那几份已经是库里的知识,
2659
+ * 「因为同批里有一份失败就不编」等于把已经进来的知识又挡在门外一次。
2660
+ */
2661
+ let compile: ImportCompileReport | null = null
2662
+ if (input.compile !== false) {
2663
+ const rebuilt = await this.rebuild('wiki', input.lineage)
2664
+ if (rebuilt.ok === false) {
2665
+ warnings.push(`来源已写入,但编译失败:${rebuilt.error?.message ?? '未知原因'}`)
2666
+ } else {
2667
+ const data = rebuilt.data
2668
+ compile = {
2669
+ cardsCreated: data.cardsCreated,
2670
+ cardsUpdated: data.cardsUpdated,
2671
+ cardsReused: data.cardsReused,
2672
+ notesReused: data.notesReused,
2673
+ notesRecompiled: data.notesRecompiled,
2674
+ sourcesExtracted: data.sourcesExtracted,
2675
+ sourcesSkipped: data.sourcesSkipped,
2676
+ cardsRemoved: data.cardsRemoved,
2677
+ embedding: data.embedding,
2678
+ version: data.version,
2679
+ }
2680
+ warnings.push(...(rebuilt.warnings ?? []))
2681
+ }
2682
+ }
2683
+
2684
+ return success({ written, failed, attempted: items.length, compile }, warnings)
2685
+ }
2686
+
2687
+ /**
2688
+ * 写入一份 SourceRaw。
2689
+ *
2690
+ * `provenance` 是 OKF v0.2 那一组(`description` / `resource` / `generated` /
2691
+ * `sources[]` / `tags`),它们**写成真正的顶层键**而不是塞进 `metadata` 那个 JSON
2692
+ * 字符串里(0048):元数据要能被人读、被 `grep` 到、被外部工具按公开规范读走。
2693
+ * `metadata` 只留类型专属的扩展键。
2694
+ */
2695
+ async addSource(input: {
2696
+ sourceType: SourceType
2697
+ title?: string
2698
+ content: string
2699
+ externalId?: string
2700
+ metadata?: Record<string, unknown>
2701
+ sourceId?: string
2702
+ lineage?: LineageContext
2703
+ provenance?: SourceProvenance
2704
+ /**
2705
+ * 允许与已有的来源同标题(0051)。默认 `false`:重名是错误,拒收并要求改标题。
2706
+ * 传 `true` 时落成 `<标题>-<内容哈希前 6 位>.md`,并回一条 warning。
2707
+ */
2708
+ allowDuplicateTitle?: boolean
2709
+ }): Promise<Envelope<{ sourceId: string; path: string; lineage?: LineageContext }>> {
1330
2710
  if (!this.config.allowSourceTruth) return failure({ sourceId: '', path: '' }, 'SOURCE_TRUTH_DISABLED', '当前配置禁止写入 SourceTruth', false)
1331
2711
  // 分类闸门走注册表(0013),不再是硬编码枚举:自定义分类建完即可用。
1332
2712
  const gate = this.sourceTypeGate(input.sourceType)
@@ -1347,7 +2727,39 @@ export class KnowledgeRuntime {
1347
2727
  await this.ensureLayout()
1348
2728
  const sourceId = input.sourceId?.trim() || `${input.sourceType}:${hash(input.externalId ?? input.content)}`
1349
2729
  if (!SAFE_ID.test(sourceId)) return failure({ sourceId: '', path: '' }, 'SOURCE_ID_INVALID', 'source_id 含有非法字符', false)
1350
- const path = this.sourcePath(sourceId, input.sourceType)
2730
+ const title = input.title?.trim() || sourceId
2731
+ /**
2732
+ * 重名(0051):**默认拒收**。
2733
+ *
2734
+ * 文件名就是标题,所以「两份来源同一个标题」不再是看不见的事 —— 它们会争同一个
2735
+ * 路径。判据落在**目标路径**上而不是标题字符串上:标题里被换掉的非法字符
2736
+ * (`A/B` 与 `A-B`)也会撞路径,按字符串比会漏。
2737
+ *
2738
+ * 三条出路,覆盖三种真实情形:
2739
+ * 1. 同一个 `sourceId`(同一份东西重复导入)→ 目标是它自己现在的位置,不冲突;
2740
+ * 2. 另一个 `sourceId` 占着这个路径 → **拒收**,报错里带上「被谁占着」,让人改标题;
2741
+ * 3. 调用方明确要留两份同标题材料 → 传 `allowDuplicateTitle`,落成
2742
+ * `<标题>-<内容哈希前 6 位>.md`,并回一条 warning。
2743
+ *
2744
+ * 为什么不静默加后缀:那样人永远不知道库里有两份「复盘」—— 而「有两份」这件事本身
2745
+ * 就是要人处理的信号。也不静默覆盖:那等于直接丢一份知识。
2746
+ */
2747
+ let path = this.sourcePath(sourceId, input.sourceType, title)
2748
+ let duplicateNote: string | undefined
2749
+ const owner = await this.sourceIdAtPath(path)
2750
+ if (owner !== undefined && owner !== sourceId) {
2751
+ if (input.allowDuplicateTitle !== true) {
2752
+ return failure(
2753
+ { sourceId: '', path },
2754
+ 'SOURCE_TITLE_DUPLICATED',
2755
+ `标题「${title}」已被来源 ${owner} 占用(它写在这个路径上),改一个标题再来`,
2756
+ true,
2757
+ '换一个有区分度的标题(例如带上日期或版本),或显式传 allow_duplicate_title: true 让两份并存。',
2758
+ )
2759
+ }
2760
+ path = this.safePath(SOURCE_TRUTH, input.sourceType, `${sourceFileBase(title, sourceId)}-${hash(sourceId).slice(0, 6)}.md`)
2761
+ duplicateNote = `标题「${title}」与来源 ${owner} 重复,已按 allow_duplicate_title 落成 ${basename(path)};建议改成有区分度的标题。`
2762
+ }
1351
2763
  /**
1352
2764
  * 这一份文档**可能已经以另一个分类存在**。0030 起分类是目录名,所以
1353
2765
  * 「改一个来源的分类」不再是原地覆盖,而是换目录 —— 必须先解析它现在落在哪,
@@ -1372,17 +2784,46 @@ export class KnowledgeRuntime {
1372
2784
  await this.snapshotSource(sourceId, previousRaw, previous.content)
1373
2785
  }
1374
2786
  }
1375
- const record: SourceRaw = { 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() }
1376
- 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))
2787
+ const record: SourceRaw = { 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() }
2788
+ const frontMatter = sourceFrontMatter(record, input.provenance)
2789
+ await writeTextAtomic(path, markdown(frontMatter, record.content))
2790
+ /**
2791
+ * 分类声明的必填元数据缺了就**如实报出来**,不拒收(0048)。
2792
+ *
2793
+ * 与「缺必填小节的卡片照常入库」同一条纪律:元数据缺失是**信息不全**,不是
2794
+ * 「这份知识不存在」。拒收的代价是知识直接丢了,而报出来的代价只是界面上多一句
2795
+ * 提示 —— 后者可以补,前者补不回来。
2796
+ */
2797
+ const declared = this.sourceTypes().find(def => def.id === record.sourceType)?.metadataFields
2798
+ const missing = missingMetadataFields(declared, frontMatter)
1377
2799
  // 换了分类的那一次:新文件已经落好,旧目录里那份才删 —— 顺序反过来的话,写入
1378
2800
  // 失败就同时丢了旧的那一份。
1379
2801
  if (previousPath !== undefined && previousPath !== path) {
1380
2802
  try { await unlink(previousPath) } catch { /* already moved or removed */ }
1381
2803
  }
1382
- this.sourceIndex?.set(slug(sourceId), path)
2804
+ /**
2805
+ * 索引与归属表都要跟着**新路径**走(0051)。
2806
+ *
2807
+ * 归属表(`path → sourceId`)是重名判定的依据,漏更新会留下一个幽灵归属:改了标题
2808
+ * 之后,旧路径上明明没有文件,另一个来源想用那个标题却会被判成「重名」而拒收。
2809
+ */
2810
+ if (this.sourceIndex) {
2811
+ if (previousPath !== undefined && previousPath !== path) {
2812
+ this.sourceIndex.delete(previousPath)
2813
+ this.sourcePathOwner?.delete(previousPath)
2814
+ }
2815
+ this.sourceIndex.set(slug(sourceId), path)
2816
+ this.sourcePathOwner?.set(path, sourceId)
2817
+ }
1383
2818
  // 证据片段取自 SourceRaw 正文,写入后那份缓存必须失效(否则上下文会引用旧原文)。
1384
2819
  this.sourceCorpusCache = undefined
1385
- return success({ sourceId, path, ...(record.lineage ? { lineage: record.lineage } : {}) })
2820
+ return success(
2821
+ { sourceId, path, ...(record.lineage ? { lineage: record.lineage } : {}) },
2822
+ [
2823
+ ...(duplicateNote ? [duplicateNote] : []),
2824
+ ...missing.map(field => `来源缺少「${field.label}」(${field.key}):分类「${record.sourceType}」声明它是必填的。`),
2825
+ ],
2826
+ )
1386
2827
  }
1387
2828
 
1388
2829
  /** Directory holding one document's version snapshots. */
@@ -1408,14 +2849,30 @@ export class KnowledgeRuntime {
1408
2849
 
1409
2850
  async loadSources(): Promise<SourceRaw[]> {
1410
2851
  const result: SourceRaw[] = []
2852
+ /**
2853
+ * 索引在**同一次遍历**里建起来(0051)。
2854
+ *
2855
+ * 文件名从 `source_id` 改成标题之后,「按 id 找文件」不再能从文件名推出来 ——
2856
+ * 权威身份在 front matter 里。与其再扫一遍磁盘、再读一遍文件,不如在唯一一次
2857
+ * 遍历里同时建两张表:`sourceId → path`(`sourcePathOf` 用)与 `path → sourceId`
2858
+ * (`addSource` 判断目标路径被谁占着)。
2859
+ */
2860
+ const byId = new Map<string, string>()
2861
+ const byPath = new Map<string, string>()
1411
2862
  for (const path of await filesUnder(this.directory(SOURCE_TRUTH))) {
1412
2863
  const parsed = parseMarkdown(await readFile(path, 'utf8'))
1413
2864
  const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'))
1414
- const sourceType = String(parsed.frontmatter.source_type ?? 'document') as SourceType
2865
+ byId.set(slug(sourceId), path)
2866
+ // 文件名也可能是旧布局的 id(迁移中间态):两个键都登记,谁在都不算丢。
2867
+ byId.set(basename(path, '.md'), path)
2868
+ byPath.set(path, sourceId)
2869
+ const sourceType = (sourceTypeOf(parsed.frontmatter) || 'document') as SourceType
1415
2870
  // 0013:未知分类**不再静默丢弃**。分类只是元数据,内容仍然可召回;
1416
2871
  // 丢掉文档才是真正的数据损失。未知分类在列表里以「未知分类」呈现。
1417
2872
  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 as Record<string, unknown> : {}, ...(parseLineage(parsed.frontmatter.lineage) ? { lineage: parseLineage(parsed.frontmatter.lineage) } : {}), createdAt: String(parsed.frontmatter.created_at ?? ''), updatedAt: String(parsed.frontmatter.updated_at ?? '') })
1418
2873
  }
2874
+ this.sourceIndex = byId
2875
+ this.sourcePathOwner = byPath
1419
2876
  return result
1420
2877
  }
1421
2878
 
@@ -1428,17 +2885,26 @@ export class KnowledgeRuntime {
1428
2885
  * (`addSource` 的 `sourceTypeGate`),所以它一定匹配 `SOURCE_TYPE_ID_PATTERN`,
1429
2886
  * 不会引入路径穿越。
1430
2887
  */
1431
- private sourcePath(sourceId: string, sourceType: string): string {
1432
- return this.safePath(SOURCE_TRUTH, sourceType, `${slug(sourceId)}.md`)
2888
+ private sourcePath(sourceId: string, sourceType: string, title: string): string {
2889
+ return this.safePath(SOURCE_TRUTH, sourceType, `${sourceFileBase(title, sourceId)}.md`)
1433
2890
  }
1434
2891
 
1435
- /** 递归扫 SourceRaw 段,建 `docSlug → path` 索引;两种布局都覆盖。 */
2892
+ /**
2893
+ * `docSlug → path` 索引;两种布局都覆盖。
2894
+ *
2895
+ * 0051 起它**由 `loadSources` 建**(那一次遍历本来就要读每个文件的 front matter,
2896
+ * 而文件名不再是身份,只能从 front matter 里拿)。这里只是把「还没建」补上。
2897
+ */
1436
2898
  private async sourceIndexMap(): Promise<Map<string, string>> {
1437
2899
  if (this.sourceIndex) return this.sourceIndex
1438
- const index = new Map<string, string>()
1439
- for (const path of await filesUnder(this.directory(SOURCE_TRUTH))) index.set(basename(path, '.md'), path)
1440
- this.sourceIndex = index
1441
- return index
2900
+ await this.loadSources()
2901
+ return this.sourceIndex ?? new Map()
2902
+ }
2903
+
2904
+ /** 这个路径现在被哪一份来源占着(0051:重名判定的依据)。 */
2905
+ private async sourceIdAtPath(path: string): Promise<string | undefined> {
2906
+ if (!this.sourcePathOwner) await this.loadSources()
2907
+ return this.sourcePathOwner?.get(path)
1442
2908
  }
1443
2909
 
1444
2910
  /** 已知文档 id 的落盘位置;不存在时 `undefined`(调用方各自决定怎么报错)。 */
@@ -1449,13 +2915,12 @@ export class KnowledgeRuntime {
1449
2915
  /**
1450
2916
  * Assets a document really has on disk.
1451
2917
  *
1452
- * Preferred source of truth is `metadata.assets` (knowledge-root-relative
1453
- * paths written by `convert/ingest.ts`). Documents written by hand fall back
1454
- * to parsing local markdown image links. A link is resolved **knowledge-root
1455
- * first, then relative to the document's own directory**: the convert ingest
1456
- * seam re-bases assets to `_assets/<bucket>/<name>` (root-relative), while
1457
- * hand-written docs typically use a sibling path. Only existing files are
1458
- * returned, so `assetCount` is never an over-count.
2918
+ * Preferred source of truth is `metadata.assets` (paths written by
2919
+ * `convert/ingest.ts`). Documents written by hand fall back to parsing local
2920
+ * markdown image links. Both go through {@link assetRefCandidates}, so a
2921
+ * document's asset list and a card's `imagePaths` answer the same question the
2922
+ * same way. Only existing files are returned, so `assetCount` is never an
2923
+ * over-count.
1459
2924
  */
1460
2925
  private async assetsOf(path: string, content: string, metadata: Record<string, unknown>): Promise<KnowledgeAssetRef[]> {
1461
2926
  const refs = new Set<string>()
@@ -1466,7 +2931,7 @@ export class KnowledgeRuntime {
1466
2931
  const assets: KnowledgeAssetRef[] = []
1467
2932
  const seen = new Set<string>()
1468
2933
  for (const ref of refs) {
1469
- const candidates = isAbsolute(ref) ? [resolve(ref)] : [resolve(root, ref), resolve(dirname(path), ref)]
2934
+ const candidates = assetRefCandidates(ref, dirname(path), root)
1470
2935
  for (const absolute of candidates) {
1471
2936
  if (!insideRoot(absolute)) continue
1472
2937
  let details
@@ -1475,21 +2940,64 @@ export class KnowledgeRuntime {
1475
2940
  const rel = relative(root, absolute).split(sep).join('/')
1476
2941
  if (seen.has(rel)) break
1477
2942
  seen.add(rel)
1478
- const encoded = rel.split('/').map(segment => encodeURIComponent(segment)).join('/')
1479
- assets.push({ rel, bytes: details.size, url: `/knowledge/assets/${encodeURIComponent(this.baseId)}/${encoded}` })
2943
+ assets.push({ rel, bytes: details.size, url: this.assetUrlOf(rel) })
1480
2944
  break
1481
2945
  }
1482
2946
  }
1483
2947
  return assets.sort((left, right) => left.rel.localeCompare(right.rel))
1484
2948
  }
1485
2949
 
1486
- /** Parse one on-disk SourceRaw file into the detail projection. */
1487
- private async readDocumentFile(path: string): Promise<KnowledgeDocumentDetail | undefined> {
2950
+ /**
2951
+ * 图片素材的浏览器地址。面板与整页都用它加载图,所以只能有一个拼法。
2952
+ */
2953
+ private assetUrlOf(rel: string): string {
2954
+ const encoded = rel.split('/').map(segment => encodeURIComponent(segment)).join('/')
2955
+ return `/knowledge/assets/${encodeURIComponent(this.baseId)}/${encoded}`
2956
+ }
2957
+
2958
+ /**
2959
+ * 一张 wiki 卡片的图片素材(0047)。
2960
+ *
2961
+ * 卡片详情原先只回 `imagePaths` —— **磁盘绝对路径**。客户端拿它加载不了图(浏览器
2962
+ * 只认 URL),所以预览抽屉里卡片的图一直不显示;而 SourceRaw 那边早就有 `assets`
2963
+ * (`rel` + `url` + `bytes`)。这里把同一种东西补齐,两个预览走同一条路。
2964
+ *
2965
+ * 只认知识库根目录以内的文件:卡片正文里可能留着别的绝对路径(人手工写的),
2966
+ * 那种不该变成一个可下载的 URL。
2967
+ */
2968
+ async assetsOfCard(card: KnowledgeCard): Promise<KnowledgeAssetRef[]> {
2969
+ const root = this.rootDir
2970
+ const insideRoot = (value: string): boolean => value === root || value.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
2971
+ const assets: KnowledgeAssetRef[] = []
2972
+ const seen = new Set<string>()
2973
+ for (const absolute of card.imagePaths ?? []) {
2974
+ if (!isAbsolute(absolute) || !insideRoot(absolute)) continue
2975
+ let details
2976
+ try { details = await stat(absolute) } catch { continue }
2977
+ if (!details.isFile()) continue
2978
+ const rel = relative(root, absolute).split(sep).join('/')
2979
+ if (seen.has(rel)) continue
2980
+ seen.add(rel)
2981
+ assets.push({ rel, bytes: details.size, url: this.assetUrlOf(rel) })
2982
+ }
2983
+ return assets.sort((left, right) => left.rel.localeCompare(right.rel))
2984
+ }
2985
+
2986
+ /**
2987
+ * Parse one on-disk SourceRaw file into the detail projection.
2988
+ *
2989
+ * `previewChars` 给了就把正文截到那么长,并把**总长度**如实放进 `contentChars`
2990
+ * (见 `SOURCE_PREVIEW_CHARS` 的理由)。不给就是不截断 —— 那是「我确实要全文」的
2991
+ * 调用方,目前只有 `readDocumentBody()` 之外的内部路径会这么用。
2992
+ */
2993
+ private async readDocumentFile(path: string, previewChars?: number): Promise<KnowledgeDocumentDetail | undefined> {
1488
2994
  let raw: string
1489
2995
  try { raw = await readFile(path, 'utf8') } catch { return undefined }
1490
2996
  const parsed = parseMarkdown(raw)
2997
+ const contentChars = parsed.content.length
2998
+ const content = previewChars !== undefined && previewChars < contentChars ? parsed.content.slice(0, previewChars) : parsed.content
1491
2999
  const sourceId = String(parsed.frontmatter.source_id ?? basename(path, '.md'))
1492
- const sourceType = String(parsed.frontmatter.source_type ?? 'document')
3000
+ const sourceType = sourceTypeOf(parsed.frontmatter) || 'document'
1493
3001
  // 未知分类照样投影出来(0013);前端按 sourceTypeKnown 渲染「未知分类」徽标。
1494
3002
  const sourceTypeKnown = this.sourceTypes().some(def => def.id === sourceType)
1495
3003
  const metadata = plainRecord(parsed.frontmatter.metadata)
@@ -1523,7 +3031,9 @@ export class KnowledgeRuntime {
1523
3031
  versionCount: (await this.versionIds(sourceId)).length + 1,
1524
3032
  relativePath: relative(this.rootDir, path).split(sep).join('/'),
1525
3033
  ext,
1526
- content: parsed.content,
3034
+ content,
3035
+ contentChars,
3036
+ contentTruncated: content.length < contentChars,
1527
3037
  frontmatter: parsed.frontmatter,
1528
3038
  assets,
1529
3039
  ...(lineage ? { lineage: lineage as Record<string, string> } : {}),
@@ -1548,9 +3058,27 @@ export class KnowledgeRuntime {
1548
3058
  }
1549
3059
 
1550
3060
  /** 文档详情:正文、frontmatter、资产与 lineage。 */
1551
- async readDocument(docId: string): Promise<KnowledgeDocumentDetail | undefined> {
3061
+ /**
3062
+ * 一份文档的详情投影,**正文默认按 `SOURCE_PREVIEW_CHARS` 封顶**。
3063
+ *
3064
+ * 这是明细接口(抽屉里的预览)读的那一份,所以默认就是「够预览」而不是「全文」:
3065
+ * 全文由 `readDocumentBody()` 提供服务,而它只服务独立整页的分页读取。
3066
+ */
3067
+ async readDocument(docId: string, previewChars: number = SOURCE_PREVIEW_CHARS): Promise<KnowledgeDocumentDetail | undefined> {
1552
3068
  const path = await this.sourcePathOf(docId)
1553
- return path ? this.readDocumentFile(path) : undefined
3069
+ return path ? this.readDocumentFile(path, previewChars) : undefined
3070
+ }
3071
+
3072
+ /**
3073
+ * 一份文档的**完整正文**(不含 frontmatter),给独立整页分页取用。
3074
+ *
3075
+ * 每次调用都重读一遍盘:独立整页是「用户滚到哪儿取哪一页」,一次请求一段文本,
3076
+ * 谁都不该拿到整份文档 —— 这正是抽屉那一版的问题。缓存留到真的量出来是瓶颈再加。
3077
+ */
3078
+ async readDocumentBody(docId: string): Promise<string | undefined> {
3079
+ const path = await this.sourcePathOf(docId)
3080
+ if (path === undefined) return undefined
3081
+ try { return parseMarkdown(await readFile(path, 'utf8')).content } catch { return undefined }
1554
3082
  }
1555
3083
 
1556
3084
  /**
@@ -1605,7 +3133,7 @@ export class KnowledgeRuntime {
1605
3133
  async revertDocument(docId: string, versionId: string): Promise<Envelope<KnowledgeDocumentDetail>> {
1606
3134
  const path = await this.sourcePathOf(docId)
1607
3135
  if (path === undefined) return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true)
1608
- const head = await this.readDocumentFile(path)
3136
+ const head = await this.readDocumentFile(path, SOURCE_PREVIEW_CHARS)
1609
3137
  if (!head) return failure(emptyDocumentDetail(docId), 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true)
1610
3138
  const target = typeof versionId === 'string' ? versionId.trim() : ''
1611
3139
  if (!target) return failure(head, 'VERSION_ID_REQUIRED', 'versionId 不能为空', false, '用 list_versions / GET /knowledge/documents/:docId/versions 先取版本列表。')
@@ -1614,7 +3142,7 @@ export class KnowledgeRuntime {
1614
3142
  if (!existsSync(snapshotPath)) return failure(head, 'VERSION_NOT_FOUND', `版本不存在: ${target}`, true)
1615
3143
  const parsed = parseMarkdown(await readFile(snapshotPath, 'utf8'))
1616
3144
  const restored = await this.addSource({
1617
- sourceType: (String(parsed.frontmatter.source_type ?? 'document')) as SourceType,
3145
+ sourceType: (sourceTypeOf(parsed.frontmatter) || 'document') as SourceType,
1618
3146
  title: typeof parsed.frontmatter.title === 'string' ? parsed.frontmatter.title : undefined,
1619
3147
  content: parsed.content,
1620
3148
  externalId: typeof parsed.frontmatter.external_id === 'string' && parsed.frontmatter.external_id ? parsed.frontmatter.external_id : undefined,
@@ -1623,7 +3151,13 @@ export class KnowledgeRuntime {
1623
3151
  lineage: parseLineage(parsed.frontmatter.lineage),
1624
3152
  })
1625
3153
  if (!restored.ok) return failure(head, restored.error?.code ?? 'REVERT_FAILED', restored.error?.message ?? '回滚写入失败', restored.error?.retryable ?? false, restored.error?.suggestion)
1626
- const document = await this.readDocumentFile(path)
3154
+ /**
3155
+ * 读的是**回滚写完之后**的路径,不是开头那个。
3156
+ *
3157
+ * 0051 起文件名跟着标题走:回滚到一版标题不同的历史,文件会被改名 —— 开头取到的
3158
+ * 路径当场过期,再按它读就是「回滚后文档不可读」,而文件其实好好的。
3159
+ */
3160
+ const document = await this.readDocumentFile(restored.data.path, SOURCE_PREVIEW_CHARS)
1627
3161
  if (!document) return failure(head, 'DOCUMENT_NOT_FOUND', `回滚后文档不可读: ${docId}`, true)
1628
3162
  await this.audit('source_truth.revert', { docId, versionId: target })
1629
3163
  return success(document)
@@ -1645,7 +3179,24 @@ export class KnowledgeRuntime {
1645
3179
  * 负责人。它属于 0021 §6.4 的卡片信封(智能归因的五类卡片里 `check.owner` 是必填 ——
1646
3180
  * 「这一步该找谁」和成本、判定阈值一样是执行信息);其余 entity_type 不使用它。
1647
3181
  */
1648
- owner?: string; metadata?: Record<string, unknown>; lineage?: LineageContext; provenance?: LineageRef[] }): Promise<Envelope<{ entityId: string; path: string; lineage?: LineageContext }>> {
3182
+ owner?: string; metadata?: Record<string, unknown>; lineage?: LineageContext; provenance?: LineageRef[]; /**
3183
+ * 额外的校验问题(0049)。编译收尾段算出「正文里的 wiki 链接指向不存在的卡片」,
3184
+ * 它是 `reference` 那一层的问题 —— 与 format / semantic 一起写进 `metadata.schema_issues`,
3185
+ * 卡片抽屉上就会出现一枚「引用」徽标。收尾段不能自己拼 metadata:那样会把写入时
3186
+ * 重算的 format / semantic 覆盖掉。
3187
+ */
3188
+ extraIssues?: CardSchemaIssue[]; /**
3189
+ * 人工备注(0044)。**不传 = 保留磁盘上已有的那些**,不是清空 —— 编译路径不传它,
3190
+ * 所以「重新编译一次不会抹掉备注」是结构上的保证,而不是靠每个调用方记得带上。
3191
+ */
3192
+ annotations?: CardAnnotation[]; /** review 结论与评分(0044)。同样:不传 = 保留已有的。 */ review?: CardReview; /**
3193
+ * 谁在写这张卡片。
3194
+ *
3195
+ * `'manual'` 是**唯一会被正文不可变规则拦下**的来源:已有卡片的正文字节不同 →
3196
+ * `WIKI_CARD_IMMUTABLE`。编译(`'compile'`)追加正文、review 与备注写信封字段,
3197
+ * 两者都不受这条规则约束 —— 把它们也拦掉,卡片就再也不会长了。
3198
+ */
3199
+ origin?: 'compile' | 'review' | 'manual' | 'internal' }): Promise<Envelope<{ entityId: string; path: string; lineage?: LineageContext }>> {
1649
3200
  if (!SAFE_ID.test(input.entityId)) return failure({ entityId: input.entityId, path: '' }, 'ENTITY_ID_INVALID', 'entity_id 必须是稳定且安全的标识符', false)
1650
3201
  if (!input.title?.trim() || !input.content?.trim()) return failure({ entityId: input.entityId, path: '' }, 'CARD_CONTENT_REQUIRED', 'title 和 content 不能为空', true)
1651
3202
  if (!Array.isArray(input.sourceRefs) || input.sourceRefs.length === 0) return failure({ entityId: input.entityId, path: '' }, 'SOURCE_REFS_REQUIRED', '知识卡片必须引用 SourceTruth', false)
@@ -1654,7 +3205,18 @@ export class KnowledgeRuntime {
1654
3205
  if (missingRefs.length > 0) return failure({ entityId: input.entityId, path: '' }, 'SOURCE_REF_NOT_FOUND', `source_refs 引用不存在: ${missingRefs.join(', ')}`, false, '先调用 add_source 写入对应的 SourceTruth')
1655
3206
  const status = input.status ?? 'unreviewed'
1656
3207
  if (!STATUS.includes(status)) return failure({ entityId: input.entityId, path: '' }, 'STATUS_INVALID', 'status 不合法', false)
1657
- // 0024:类型闸门 + 三层校验(format / reference / semantic)。
3208
+ /**
3209
+ * 0044:缺信息的卡片**照常入库**。
3210
+ *
3211
+ * 0040 时期这里有两道拒绝:类型不在注册表里直接拒、三层校验不通过时 `strict` 模式拒。
3212
+ * 两条都撤掉了 —— 编译的价值在于「记下来,后面有涉及到的文档再慢慢补」,而拒绝
3213
+ * 恰好把这个机会丢掉了。缺什么由 `schema_issues` 如实标出,人在 review 时补。
3214
+ *
3215
+ * 唯一保留的拦截是**未知类型**:没有类型定义就没有小节骨架,连「缺什么」都说不出来。
3216
+ * 编译侧在调用这里之前已经把未登记的类型**退回兜底类型**(`compileSource`),
3217
+ * 所以这条路只在别的调用方拿着一个没登记的类型直接写盘时才会走到 —— 那时拒绝是对的,
3218
+ * 而提示会给出可用类型的清单。
3219
+ */
1658
3220
  const gate = this.cardSchemaGate({
1659
3221
  entityType: input.entityType, title: input.title.trim(), content: input.content,
1660
3222
  sourceRefs: input.sourceRefs, metadata: input.metadata ?? {}, ...(input.owner ? { owner: input.owner } : {}), ...(input.summary ? { summary: input.summary } : {}),
@@ -1662,15 +3224,6 @@ export class KnowledgeRuntime {
1662
3224
  if ('error' in gate) return failure({ entityId: input.entityId, path: '' }, gate.error.code, gate.error.message, gate.error.retryable, gate.error.suggestion)
1663
3225
  const { issues } = gate
1664
3226
  const warnings = issues.map(issue => `${issue.level}: ${issue.message}`)
1665
- if (issues.length > 0 && this.wikiSchema().mode === 'strict') {
1666
- return failure(
1667
- { entityId: input.entityId, path: '' },
1668
- 'CARD_SCHEMA_INVALID',
1669
- `卡片不符合「${gate.type.label}」的 schema:${issues.map(issue => issue.message).join(';')}`,
1670
- false,
1671
- '补齐必填小节 / 字段后重试;或在「知识库设置 → Wiki 类型」里把生效方式改为 report(只标记不拦截)。',
1672
- )
1673
- }
1674
3227
  await this.ensureLayout()
1675
3228
  const path = this.safePath(WIKI, input.entityType, `${slug(input.entityId)}.md`)
1676
3229
  await mkdir(dirname(path), { recursive: true })
@@ -1680,18 +3233,72 @@ export class KnowledgeRuntime {
1680
3233
  // 「审核一次 = 多一个版本」,版本列表会被状态流转灌满。
1681
3234
  let createdAt = updatedAt
1682
3235
  let previousRaw: string | undefined
3236
+ let previousAnnotations: CardAnnotation[] | undefined
3237
+ let previousReview: CardReview | undefined
1683
3238
  try { previousRaw = await readFile(path, 'utf8') } catch { /* 首次写入这张卡片 */ }
1684
3239
  if (previousRaw !== undefined) {
1685
3240
  const previous = parseMarkdown(previousRaw)
1686
3241
  createdAt = String(previous.frontmatter.created_at ?? createdAt)
3242
+ previousAnnotations = parseAnnotations(previous.frontmatter.annotations)
3243
+ previousReview = parseReview(previous.frontmatter.review)
1687
3244
  if (previous.content.trim() !== input.content.trim()) {
3245
+ if (input.origin === 'manual') {
3246
+ return failure(
3247
+ { entityId: input.entityId, path },
3248
+ 'WIKI_CARD_IMMUTABLE',
3249
+ `wiki 正文不可手工修改: ${input.entityId}`,
3250
+ false,
3251
+ '正文由编译产出(新文档会追加进来)。要指出问题请加备注:annotate_wiki_card 或面板上的「备注 / 异常点」。',
3252
+ )
3253
+ }
1688
3254
  await this.snapshotCard(input.entityType, input.entityId, previousRaw, previous.content)
1689
3255
  }
1690
3256
  }
1691
3257
  const provenance = [...(input.provenance ?? []), ...(input.lineage ? [{ ...input.lineage, role: 'created' as const, refType: 'knowledge_card', refId: input.entityId }] : [])]
1692
- // report 模式:把问题留在卡片上,界面据此显示"待补",而不是静默通过。
1693
- const metadata = { ...(input.metadata ?? {}), ...(issues.length > 0 ? { schema_issues: issues } : {}) }
1694
- 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))
3258
+ /**
3259
+ * report 模式:把问题留在卡片上,界面据此显示"待补",而不是静默通过。
3260
+ *
3261
+ * `schema_issues` **一律由这里重算**(先摘掉入参里可能带着的旧值)。入参 metadata
3262
+ * 常常是「从磁盘上读回来的那一份」,里面就有上一次的 `schema_issues` —— 只做覆盖
3263
+ * 不摘旧值,一张补好了的卡片会一直挂着旧问题;而问题清空时那个键根本没被覆盖,
3264
+ * 于是「补好了」这件事在界面上永远不生效。
3265
+ */
3266
+ const { schema_issues: _staleIssues, ...inputMetadata } = input.metadata ?? {}
3267
+ void _staleIssues
3268
+ const allIssues = [...issues, ...(input.extraIssues ?? [])]
3269
+ const metadata = { ...inputMetadata, ...(allIssues.length > 0 ? { schema_issues: allIssues } : {}) }
3270
+ // 备注与 review **默认继承磁盘上已有的**:不传不等于清空。见入参注释。
3271
+ const annotations = input.annotations ?? previousAnnotations ?? []
3272
+ const review = input.review ?? previousReview
3273
+ await writeTextAtomic(path, markdown({
3274
+ entity_id: input.entityId, entity_type: input.entityType, title: input.title.trim(), status, summary: input.summary ?? '',
3275
+ ...(input.owner ? { owner: input.owner } : {}), source_refs: input.sourceRefs, created_at: createdAt, updated_at: updatedAt, metadata,
3276
+ ...(annotations.length > 0 ? { annotations } : {}),
3277
+ ...(review ? { review } : {}),
3278
+ ...(provenance.length > 0 ? { provenance } : {}),
3279
+ }, input.content))
3280
+ /**
3281
+ * 同步内存里的卡片清单。
3282
+ *
3283
+ * `this.cards` 是召回组装 `context[].wiki` 时读的那一份。写盘不同步它,会出现
3284
+ * 「刚加的备注,在召回结果里看不到」—— 而人正是在召回结果里核对备注的。
3285
+ *
3286
+ * 重新 `loadCards()` 一遍是 O(整个 wiki),而一次编译会连着写几十张卡;所以这里
3287
+ * 就地替换那一条。缓存为空时什么都不做:那表示还没人读过卡片清单,下次读会拿到全量。
3288
+ */
3289
+ if (this.cards.length > 0) {
3290
+ const next: KnowledgeCard = {
3291
+ entityId: input.entityId, entityType: input.entityType, title: input.title.trim(), status,
3292
+ ...(input.summary ? { summary: input.summary } : {}),
3293
+ content: input.content, sourceRefs: input.sourceRefs, metadata, updatedAt,
3294
+ ...(annotations.length > 0 ? { annotations } : {}),
3295
+ ...(review ? { review } : {}),
3296
+ ...(provenance.length > 0 ? { provenance } : {}),
3297
+ }
3298
+ const index = this.cards.findIndex(card => card.entityId === input.entityId && card.entityType === input.entityType)
3299
+ if (index >= 0) this.cards[index] = next
3300
+ else this.cards.push(next)
3301
+ }
1695
3302
  return success({ entityId: input.entityId, path, ...(input.lineage ? { lineage: input.lineage } : {}) }, warnings)
1696
3303
  }
1697
3304
 
@@ -1804,7 +3411,21 @@ export class KnowledgeRuntime {
1804
3411
  // 0024:未知类型**不再静默丢弃**。类型只是元数据,丢掉卡片才是真正的数据损失;
1805
3412
  // 未知类型在界面上显示成「未登记类型」,由人决定是补登记还是改卡片。
1806
3413
  const refs = Array.isArray(parsed.frontmatter.source_refs) ? parsed.frontmatter.source_refs.map(String) : []
1807
- result.push({ entityId, entityType, title: String(parsed.frontmatter.title ?? entityId), status: STATUS.includes(String(parsed.frontmatter.status) as KnowledgeStatus) ? String(parsed.frontmatter.status) as KnowledgeStatus : '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 as Record<string, unknown> : {}, updatedAt: String(parsed.frontmatter.updated_at ?? ''), imagePaths: markdownImagePaths(parsed.content, dirname(path)) })
3414
+ const annotations = parseAnnotations(parsed.frontmatter.annotations)
3415
+ const review = parseReview(parsed.frontmatter.review)
3416
+ result.push({
3417
+ entityId, entityType, title: String(parsed.frontmatter.title ?? entityId),
3418
+ status: STATUS.includes(String(parsed.frontmatter.status) as KnowledgeStatus) ? String(parsed.frontmatter.status) as KnowledgeStatus : 'draft',
3419
+ summary: typeof parsed.frontmatter.summary === 'string' ? parsed.frontmatter.summary : undefined,
3420
+ content: parsed.content, sourceRefs: refs,
3421
+ ...(typeof parsed.frontmatter.owner === 'string' && parsed.frontmatter.owner ? { owner: parsed.frontmatter.owner } : {}),
3422
+ ...(parseProvenance(parsed.frontmatter.provenance) ? { provenance: parseProvenance(parsed.frontmatter.provenance) } : {}),
3423
+ metadata: typeof parsed.frontmatter.metadata === 'object' && parsed.frontmatter.metadata ? parsed.frontmatter.metadata as Record<string, unknown> : {},
3424
+ updatedAt: String(parsed.frontmatter.updated_at ?? ''),
3425
+ imagePaths: markdownImagePaths(parsed.content, dirname(path), this.rootDir),
3426
+ ...(annotations.length > 0 ? { annotations } : {}),
3427
+ ...(review ? { review } : {}),
3428
+ })
1808
3429
  }
1809
3430
  return result
1810
3431
  }
@@ -1841,9 +3462,34 @@ export class KnowledgeRuntime {
1841
3462
  if (card.status === 'problem') return []
1842
3463
  const notes: WikiNote[] = []
1843
3464
  const sourceTypesFor = card.sourceRefs.flatMap(ref => { const sourceType = sourceTypes.get(ref); return sourceType ? [sourceType] : [] })
1844
- for (const [index, chunk] of splitWikiNotes(card).entries()) {
3465
+ /**
3466
+ * 卡片文件所在目录:正文里的图片引用是相对它写的(`../../01_source_raw/_assets/…`),
3467
+ * 和 `loadCards` 用的是同一个基准。
3468
+ */
3469
+ const cardDir = join(this.directory(WIKI), card.entityType)
3470
+ // 0044:note 带上父级 wiki 的**有效评分**。它不进正文、不参与向量指纹,所以
3471
+ // review 一下不会让整个语料重新编码,只会改变召回时的加权。
3472
+ const reviewScore = effectiveReviewScore(card)
3473
+ /**
3474
+ * 召回单元里**不带相关块**(0049)。
3475
+ *
3476
+ * 相关块是导航,不是知识:它写的是别的卡片的名字。切进 wikiNotes 之后,一句
3477
+ * 「毛利率怎么算」会同时命中真正讲毛利率的那张卡和所有**提到**它的卡 —— 而后者
3478
+ * 除了一个标题什么都没有。文件里留着它(人 grep 得到、编辑器里点得开),
3479
+ * 召回不吃它。
3480
+ */
3481
+ const recallBody = stripRelatedSection(card.content)
3482
+ for (const [index, chunk] of splitWikiNotes({ ...card, content: recallBody }).entries()) {
1845
3483
  const baseId = `${card.entityType}:${card.entityId}`
1846
3484
  const unitId = index === 0 ? baseId : `${baseId}#${index}`
3485
+ /**
3486
+ * 图按**这一节自己**的引用算,不是把整张卡片的图摊给每一节(0046)。
3487
+ *
3488
+ * 多模态编码只吃每张 note 的**第一张**图,所以「这一节的第一张图」必须是这一节
3489
+ * 自己的图 —— 否则「景气投资」那一节编码时会拿到「低估值」那节的图,而卡片
3490
+ * 正文里图片的落点本来就是按节算好的。
3491
+ */
3492
+ const chunkImages = markdownImagePaths(chunk.content, cardDir, this.rootDir)
1847
3493
  notes.push({
1848
3494
  unitId,
1849
3495
  noteId: unitId,
@@ -1857,7 +3503,8 @@ export class KnowledgeRuntime {
1857
3503
  sourceTypes: sourceTypesFor,
1858
3504
  ...(card.provenance ? { provenance: card.provenance } : {}),
1859
3505
  status: card.status,
1860
- ...(card.imagePaths?.length ? { imagePaths: card.imagePaths } : {}),
3506
+ ...(chunkImages.length > 0 ? { imagePaths: chunkImages } : {}),
3507
+ reviewScore,
1861
3508
  })
1862
3509
  }
1863
3510
  return notes
@@ -1893,7 +3540,7 @@ export class KnowledgeRuntime {
1893
3540
  * sigmoid 之后的展示值,两者单调等价,排序不受影响。刻度换算集中在
1894
3541
  * `applyRerank`(纯函数,有测试),这里不再自己乘除。
1895
3542
  */
1896
- private async rerankNotes(query: string, hits: RecallHit[], threshold: number): Promise<{ hits: RecallHit[]; result: RerankResult; warning?: string }> {
3543
+ private async rerankNotes(query: string, hits: RecallHit[], threshold: number): Promise<{ hits: RecallHit[]; result: RerankResult; scored?: Array<{ unitId: string; logit: number; rerankScore: number; kept: boolean }>; warning?: string }> {
1897
3544
  const provider = this.config.rerankProvider
1898
3545
  if (provider === 'none') return { hits, result: { status: 'skipped', provider, threshold } }
1899
3546
  if (hits.length < 2) return { hits, result: { status: 'skipped', provider, model: this.rerankModelPath, scored: hits.length, threshold } }
@@ -1912,6 +3559,7 @@ export class KnowledgeRuntime {
1912
3559
  const vocabSize = Number(response?.vocabSize)
1913
3560
  return {
1914
3561
  hits: outcome.kept,
3562
+ scored: outcome.scored,
1915
3563
  result: {
1916
3564
  status: 'ready', provider, model: this.rerankModelPath,
1917
3565
  scored: outcome.kept.length, threshold, dropped: outcome.dropped,
@@ -1934,13 +3582,28 @@ export class KnowledgeRuntime {
1934
3582
  * Fingerprint of everything a wikiNote is compiled *from*.
1935
3583
  *
1936
3584
  * Deliberately excludes the fields that are cheap to refresh on a reused note
1937
- * (`provenance`, `imagePaths`, `metadata`) and includes the ones that change
1938
- * what the note says (`title`, `status`, `content`, `sourceRefs`). A card whose
1939
- * fingerprint is unchanged produces byte-identical notes, so reusing them is
1940
- * not a shortcut — it is the same answer without the churn.
3585
+ * (`provenance`, `imagePaths`, `metadata`, `annotations`) and includes the ones
3586
+ * that change what the note says (`title`, `status`, `content`, `sourceRefs`,
3587
+ * `review`). A card whose fingerprint is unchanged produces byte-identical
3588
+ * notes, so reusing them is not a shortcut — it is the same answer without the
3589
+ * churn.
3590
+ *
3591
+ * 0044 把**有效评分**算进来,因为 note 上确实带着它。少了这一条会踩一个很隐蔽的坑:
3592
+ * review 不改 `title` / `content` / `sourceRefs`,于是增量编译判定「语料没变」直接
3593
+ * 跳过,`wiki_notes.json` 停在旧评分上 —— 表现就是「review 了,但召回排序一动不动」。
3594
+ *
3595
+ * 代价可控:评分变了会重编这条 note,但**不会重新编码**。向量指纹(`vectorInputs`)
3596
+ * 只哈希 `unitId + text + sourceRefs + images`,而 review 一个字都不改这些 ——
3597
+ * 所以重编拿到的还是同一条文本、同一个向量。
3598
+ *
3599
+ * `annotations` 刻意不进指纹:备注只出现在召回结果附带的 wiki 卡片上,不进 note。
3600
+ * 把它算进来会让「加一条备注」触发一次没必要的版本升级。
1941
3601
  */
1942
3602
  private cardHashOf(card: KnowledgeCard): string {
1943
- return contentHashOf(JSON.stringify({ title: card.title, status: card.status, content: card.content, sourceRefs: card.sourceRefs }))
3603
+ return contentHashOf(JSON.stringify({
3604
+ title: card.title, status: card.status, content: card.content, sourceRefs: card.sourceRefs,
3605
+ review: effectiveReviewScore(card),
3606
+ }))
1944
3607
  }
1945
3608
 
1946
3609
  /** Previous `wiki_notes.json` (or the pre-0014 mirror), without touching `this.units`. */
@@ -1982,19 +3645,19 @@ export class KnowledgeRuntime {
1982
3645
  private emptyExtractionOutcome(extractionHashes: Record<string, string>): ExtractionOutcome {
1983
3646
  return {
1984
3647
  extractionHashes,
1985
- candidatesExtracted: 0, candidatesRejected: 0, cardsCreated: 0, cardsMerged: 0,
3648
+ candidatesExtracted: 0, candidatesRejected: 0, cardsCreated: 0, cardsUpdated: 0,
1986
3649
  sourcesExtracted: 0, sourcesSkipped: 0, relations: [], relationsBySource: {}, warnings: [],
1987
3650
  }
1988
3651
  }
1989
3652
 
1990
3653
  /**
1991
- * Pass 0:对指纹变了的来源做候选抽取 + 归一,并把结果落成 wiki 卡片。
3654
+ * Pass 0:对指纹变了的来源做候选抽取 + 归一,并把结果**直接落成 wiki 卡片**。
1992
3655
  *
1993
3656
  * 三条硬约束:
1994
3657
  * 1. **增量**:指纹没变的来源一次都不抽(这是 0025 增量承诺的一部分);
1995
3658
  * 2. **不重写没变的卡片**:合并只追加 `source_refs`,正文不动,所以不会凭空造
1996
3659
  * 版本快照(`snapshotCard` 只认正文变化);
1997
- * 3. **fail-soft**:一个候选被闸门拒绝、甚至一份来源整个抛错,都只记一笔,
3660
+ * 3. **fail-soft**:一个候选写不进去、甚至一份来源整个抛错,都只记一笔,
1998
3661
  * 绝不带走整次重建 —— 重建失败比少一张卡片严重得多。
1999
3662
  */
2000
3663
  private async runExtractionStage(sources: SourceRaw[], previousHashes: Record<string, string>): Promise<ExtractionOutcome> {
@@ -2002,6 +3665,21 @@ export class KnowledgeRuntime {
2002
3665
  const enabledTypes = schema.types.filter(def => def.enabled)
2003
3666
  const registryHash = contentHashOf(JSON.stringify(enabledTypes.map(def => ({ id: def.id, description: def.description, examples: def.examples }))))
2004
3667
  const outcome = this.emptyExtractionOutcome({})
3668
+ /**
3669
+ * 模型调用**每轮解析一次**,而且解析不到时**如实报出来**(0044)。
3670
+ *
3671
+ * 静默降级的代价实测过:默认模型明明配着,接缝却永远是空的,于是编译一路按
3672
+ * `##` 小节切了好几个月,而界面上没有任何一处说「模型抽取没生效」。人只会得出
3673
+ * 一个结论 ——「这个编译没用」。降级可以是诚实的,前提是它说话。
3674
+ */
3675
+ const seam = this.config.extractWithModel ? this.modelSeamNow() : {}
3676
+ const call = seam.call
3677
+ if (this.config.extractWithModel && !call) {
3678
+ outcome.warnings.push(
3679
+ `模型抽取已开启,但取不到可用的模型调用:${seam.why ?? '原因未知'} —— 本次退回规则抽取:`
3680
+ + '卡片正文是按 `##` 小节切下来的原文,判重也只能比字面,同一实体换个标题写法会落成两张卡。',
3681
+ )
3682
+ }
2005
3683
  /**
2006
3684
  * 上一轮落下的关系产物。**跳过的来源从这里读回自己的关系** —— 没有这一步,
2007
3685
  * 一次全量重编就会把图上所有 `relates_to` 边抹掉(见 `EXTRACTED_RELATIONS`)。
@@ -2009,7 +3687,13 @@ export class KnowledgeRuntime {
2009
3687
  const persisted = await this.readExtractedRelations()
2010
3688
  // 计数器与产物不能由 readdir 的顺序决定,所以先按 sourceId 排序。
2011
3689
  const ordered = [...sources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))
2012
- let cards = await this.loadCards()
3690
+ /**
3691
+ * 库里的卡片是判重的参照物。整轮结束才一次性落盘 —— 同一张卡片被多份来源命中时
3692
+ * 只写一次,正文在这一轮内累积(而不是产生 N 次互相覆盖的写入)。
3693
+ */
3694
+ const cards = await this.loadCards()
3695
+ const byKey = new Map(cards.map(card => [`${card.entityType}:${card.entityId}`, card]))
3696
+ const pending = new Map<string, PendingCard>()
2013
3697
  for (const source of ordered) {
2014
3698
  const fingerprint = this.extractionFingerprint(source, registryHash)
2015
3699
  outcome.extractionHashes[source.sourceId] = fingerprint
@@ -2024,11 +3708,121 @@ export class KnowledgeRuntime {
2024
3708
  }
2025
3709
  outcome.sourcesExtracted += 1
2026
3710
  try {
2027
- cards = await this.extractFromSource(source, cards, enabledTypes, outcome)
3711
+ // 同一轮里后一份来源应当能看到前一份来源刚抽出来的实体,所以判重索引
3712
+ // 每份来源都用「磁盘上的卡片 + 这一轮 pending 的卡片」重建。
3713
+ await this.compileSource(source, byKey, pending, enabledTypes, outcome, call)
2028
3714
  } catch (cause) {
2029
3715
  outcome.warnings.push(`来源 ${source.sourceId} 的候选抽取失败,已跳过该来源:${cause instanceof Error ? cause.message : String(cause)}`)
2030
3716
  }
2031
3717
  }
3718
+ /**
3719
+ * 落**卡片**,不是提议(0044)。
3720
+ *
3721
+ * 每个计数都对应一次真实的 `writeCard`:只有真的写成功了才算「新建 / 更新」。
3722
+ * 写失败的进 `warnings` 而不是静默消失 —— 少一张卡片是一个需要被看见的事件。
3723
+ */
3724
+ /**
3725
+ * 图落位(0046)。
3726
+ *
3727
+ * 抽取那一段交给模型的是文字,模型给回来的正文里没有图,于是来源里 22 张图表
3728
+ * 一张也进不了卡片 —— 而多模态编码读的正是 `imagePaths[0]`。图是**确定性**的
3729
+ * (来源里有哪张图、它跟在哪段话后面,都不需要模型判断),所以在落盘前单独算一遍。
3730
+ *
3731
+ * 跨卡片一起算:一篇方法论文章编出 8 张卡,同一张图必须只落一张。已经引用过这张图
3732
+ * 的卡片不算「变了」,所以重复编译不会反复写盘。
3733
+ */
3734
+ const folded = new Map<string, string>()
3735
+ for (const [key, item] of pending) folded.set(key, appendExtracted(item.existing?.content ?? '', [item.content]))
3736
+ const view: Array<{ key: string; content: string; sourceRefs: string[] }> = []
3737
+ for (const [key, card] of byKey) view.push({ key, content: card.content, sourceRefs: card.sourceRefs })
3738
+ for (const [key, item] of pending) view.push({ key, content: folded.get(key) ?? '', sourceRefs: item.sourceRefs })
3739
+ const imagePlan = planSourceImages({ cards: view, sources: ordered.map(source => ({ sourceId: source.sourceId, content: source.content })) })
3740
+ for (const [key, item] of pending) {
3741
+ let content = folded.get(key) ?? ''
3742
+ for (const image of imagePlan.byCard.get(key) ?? []) content = insertImage(content, image)
3743
+ folded.set(key, content)
3744
+ }
3745
+ for (const [sourceId, count] of countBySource(imagePlan.unplaced)) {
3746
+ outcome.warnings.push(`来源 ${sourceId} 有 ${count} 张图没找到落点(既不在任何卡片正文里,也不属于图所在的来源小节),仍留在来源里`)
3747
+ }
3748
+ const writtenKeys = new Set<string>()
3749
+ for (const item of pending.values()) {
3750
+ const key = `${item.entityType}:${item.entityId}`
3751
+ const existing = item.existing
3752
+ const metadata = { ...item.metadata, ...(item.merges.length > 0 ? { extraction_merges: mergeHistoryOf(item.metadata, item.merges) } : {}) }
3753
+ /**
3754
+ * 已经一模一样的卡片不重写。
3755
+ *
3756
+ * 判据只有两条:**正文没有新增内容**,而且**所有来源都已在 `source_refs` 里**。
3757
+ * 少了这条,重建一次就会把每张卡片重写一遍 —— 而增量编译的意义正在于
3758
+ * 「重编一次,没变的卡片不该产生任何写入」。
3759
+ *
3760
+ * 比较的是「追加之后是否还是原样」,而不是逐字比较:`item.content` 是从已有正文
3761
+ * 出发追加出来的,两者相等就说明这次抽到的东西一个字都不新。图也算在内:
3762
+ * 计划里带图而正文里没有,就是「变了」。
3763
+ */
3764
+ const content = folded.get(key) ?? item.content
3765
+ writtenKeys.add(key)
3766
+ const unchanged = existing !== undefined
3767
+ && !item.fieldChanged
3768
+ && content === existing.content
3769
+ && item.sourceRefs.every(ref => existing.sourceRefs.includes(ref))
3770
+ if (unchanged) continue
3771
+ const written = await this.writeCard({
3772
+ entityType: item.entityType,
3773
+ entityId: item.entityId,
3774
+ title: item.title,
3775
+ content,
3776
+ sourceRefs: item.sourceRefs,
3777
+ ...(item.summary ? { summary: item.summary } : {}),
3778
+ status: existing?.status ?? 'unreviewed',
3779
+ ...(existing?.owner ? { owner: existing.owner } : {}),
3780
+ ...(existing?.provenance ? { provenance: existing.provenance } : {}),
3781
+ metadata,
3782
+ origin: 'compile',
3783
+ })
3784
+ if (written.ok === false) {
3785
+ outcome.warnings.push(`卡片 ${item.entityId} 写入失败(${written.error.code}):${written.error.message}`)
3786
+ continue
3787
+ }
3788
+ if (item.action === 'create') outcome.cardsCreated += 1
3789
+ else outcome.cardsUpdated += 1
3790
+ for (const warning of written.warnings ?? []) outcome.warnings.push(`卡片 ${item.entityId}:${warning}`)
3791
+ }
3792
+ /**
3793
+ * 只补图的卡片:指纹没变的来源这一轮**不重抽**(`sourcesSkipped`),所以它的卡片
3794
+ * 压根不在 `pending` 里 —— 而「卡片正文里有没有图」不影响抽取指纹。
3795
+ *
3796
+ * 少了这一段,0046 之前编好的库永远拿不到图:重编一次,来源被跳过,图还是不在。
3797
+ * 这段只做一件事:把算好的图插进去,正文别的部分一个字都不动。
3798
+ */
3799
+ for (const [key, images] of imagePlan.byCard) {
3800
+ if (writtenKeys.has(key)) continue
3801
+ const card = byKey.get(key)
3802
+ if (!card) continue
3803
+ let content = card.content
3804
+ for (const image of images) content = insertImage(content, image)
3805
+ if (content === card.content) continue
3806
+ const written = await this.writeCard({
3807
+ entityType: card.entityType,
3808
+ entityId: card.entityId,
3809
+ title: card.title,
3810
+ content,
3811
+ sourceRefs: card.sourceRefs,
3812
+ ...(card.summary ? { summary: card.summary } : {}),
3813
+ status: card.status,
3814
+ ...(card.owner ? { owner: card.owner } : {}),
3815
+ ...(card.provenance ? { provenance: card.provenance } : {}),
3816
+ metadata: card.metadata,
3817
+ origin: 'compile',
3818
+ })
3819
+ if (written.ok === false) {
3820
+ outcome.warnings.push(`卡片 ${card.entityId} 补图失败(${written.error.code}):${written.error.message}`)
3821
+ continue
3822
+ }
3823
+ outcome.cardsUpdated += 1
3824
+ for (const warning of written.warnings ?? []) outcome.warnings.push(`卡片 ${card.entityId}:${warning}`)
3825
+ }
2032
3826
  // 只留这一轮真实存在的来源:删掉的文档不该在图上留下幽灵关系。
2033
3827
  const alive = new Set(ordered.map(source => source.sourceId))
2034
3828
  const next: Record<string, ExtractionOutcome['relations']> = {}
@@ -2070,138 +3864,193 @@ export class KnowledgeRuntime {
2070
3864
  } catch { /* 写不进去就当这一轮没有产物,下一次重建会重新抽 */ }
2071
3865
  }
2072
3866
 
2073
- /** 一份来源的抽取结果:新建候选卡片、把证据来源并进已有卡片、收集关系候选。 */
2074
- private async extractFromSource(source: SourceRaw, cards: KnowledgeCard[], enabledTypes: WikiTypeDef[], outcome: ExtractionOutcome): Promise<KnowledgeCard[]> {
2075
- /**
2076
- * #3b:**模型先抽实体,规则补关系。**
2077
- *
2078
- * 规则抽取把「小节标题」当实体身份,实测下来的后果是同一件东西横跨两份文档就
2079
- * 变成两张卡片(`新建 2 / 并入 0`)—— 追加分支永远不触发。所以实体这一层交给
2080
- * 模型(它能判断「毛利率变动及其驱动因素」与「毛利率(口径补充)」是同一个东西)。
2081
- *
2082
- * 但仍然**照跑一遍规则抽取**,理由有两个:关系候选只有规则会抽(模型这一层不产
2083
- * 关系),而且模型不可用时它就是那条降级路径。两者不是二选一。
2084
- */
3867
+ /**
3868
+ * 一份来源的编译结果:**按 wiki schema 抽实体**(新建 / 更新),并收集关系候选。
3869
+ *
3870
+ * ## 模型先抽实体,规则补关系
3871
+ *
3872
+ * 规则抽取把「小节标题」当实体身份,实测下来的后果是同一件东西横跨两份文档就
3873
+ * 变成两张卡片(`新建 2 / 并入 0`),而且卡片正文是按 `##` 切下来的原文片段 ——
3874
+ * 也就是「编译只是按目录截取」。所以实体这一层交给模型:它按类型的必填小节与字段
3875
+ * 写正文,并回答「这个实体是不是已经在下表里了」。
3876
+ *
3877
+ * 但仍然**照跑一遍规则抽取**,理由有两个:关系候选只有规则会抽(模型这一层不产
3878
+ * 关系),而且模型不可用时它就是那条降级路径。两者不是二选一。
3879
+ *
3880
+ * ## 缺信息也抽
3881
+ *
3882
+ * 0040 那一版在这里拒绝「没有正文」的候选、拒绝「类型未登记」的候选。两条都撤掉了:
3883
+ * 编译的价值在于**记下来,后面有涉及到的文档再慢慢补**,而拒绝恰好把这个机会丢掉。
3884
+ * 只剩一条硬要求 —— 正文不能是空的(一条什么都说不出的卡片没有东西可召回);
3885
+ * 模型路径已经保证只要给了 summary 就有骨架,规则路径的候选天然带正文。
3886
+ */
3887
+ private async compileSource(
3888
+ source: SourceRaw,
3889
+ byKey: Map<string, KnowledgeCard>,
3890
+ pending: Map<string, PendingCard>,
3891
+ enabledTypes: WikiTypeDef[],
3892
+ outcome: ExtractionOutcome,
3893
+ /** 这一轮可用的模型调用;由 `runExtractionStage` 解析一次后传进来。 */
3894
+ call: ModelCall | undefined,
3895
+ ): Promise<void> {
3896
+ // 判重索引要看到「这一轮前面几份来源刚抽出来的卡片」,否则同一实体跨两份文档
3897
+ // 会各建一张 —— 而那正是这次改造要修掉的那个 bug。
3898
+ const cards = [...byKey.values()]
3899
+ // 同一份来源里的两个候选指向同一张卡片时,只该合并一次。
3900
+ const merges = new Map<string, NormalizedCandidates['merges'][number]>()
3901
+
2085
3902
  const ruleCandidates = extractCandidates(source, enabledTypes)
2086
3903
  outcome.candidatesExtracted += ruleCandidates.length
2087
3904
  const ruleNormalized = normalizeCandidates(ruleCandidates, buildAliasIndex(cards))
2088
3905
 
2089
- let normalized: NormalizedCandidates = ruleNormalized
2090
- if (this.modelCall && this.config.extractWithModel) {
2091
- const modelled = await extractWithModel({ source, wikiTypes: enabledTypes, existing: cards, call: this.modelCall })
3906
+ let fresh = ruleNormalized.fresh
3907
+ for (const merge of ruleNormalized.merges) if (!merges.has(merge.key)) merges.set(merge.key, merge)
3908
+
3909
+ /**
3910
+ * 这一份来源**实际用了哪条路**(0044 实机验证修的第二个诚实问题)。
3911
+ *
3912
+ * 原来这里写的是 `extraction: call ? 'model' : 'pass0'` —— 判据是「有没有接缝」,
3913
+ * 不是「模型给的东西有没有被用上」。实机上模型确实被调用了,但输出不是 JSON,
3914
+ * 于是候选整批来自规则路径,卡片却标着 `extraction: model`:**脏的是标签,不是数据**。
3915
+ * 下一次有人排查「为什么正文还是按 `##` 切的」,会盯着这个字段得出相反结论。
3916
+ *
3917
+ * 现在的判据是产物:模型给出至少一条实体(新建或并入)才算 `model`。
3918
+ */
3919
+ let extractionPath: 'model' | 'pass0' = 'pass0'
3920
+ if (call) {
3921
+ const modelled = await extractWithModel({
3922
+ source,
3923
+ wikiTypes: enabledTypes,
3924
+ existing: cards,
3925
+ call,
3926
+ /**
3927
+ * 路由与缓存亲和的会话身份,**按知识库稳定**(0044 实机验证第五批)。
3928
+ *
3929
+ * 没有它,OpenCode 系路由直接回 400 `MissingSessionID` —— 那件事在界面上长得
3930
+ * 像「模型抽取不可用」。用 `knowledge:<库 id>` 而不是随机 UUID:同一个库反复
3931
+ * 编译时提示词高度重复(类型定义 + 已有卡片清单),稳定的值把请求钉在同一个
3932
+ * 上游后端,命中缓存的概率才成立。
3933
+ */
3934
+ sessionId: `knowledge:${this.baseId || 'default'}`,
3935
+ })
2092
3936
  for (const warning of modelled.warnings) outcome.warnings.push(warning)
2093
3937
  // 模型一条实体都没给(或者不可用)时,完整退回规则结果,而不是丢掉整轮抽取。
2094
3938
  if (modelled.fresh.length > 0 || modelled.merges.length > 0) {
2095
- normalized = { fresh: modelled.fresh, merges: modelled.merges, relations: ruleNormalized.relations }
3939
+ extractionPath = 'model'
3940
+ fresh = modelled.fresh
3941
+ merges.clear()
3942
+ for (const merge of modelled.merges) if (!merges.has(merge.key)) merges.set(merge.key, merge)
3943
+ } else if (modelled.warnings.length === 0) {
3944
+ // 模型正常回答、只是没给实体 —— 这也是一次实际的降级,不该只体现在卡片标签上。
3945
+ outcome.warnings.push('模型没有给出任何实体,本次退回规则抽取(正文按 `##` 小节切)。')
2096
3946
  }
2097
3947
  }
2098
- const candidates = normalized.fresh
2099
- if (candidates.length === 0 && normalized.merges.length === 0) return cards
2100
- const next = [...cards]
2101
3948
 
2102
- // 先建后并:同一轮里新建的卡片也可能被别名指回,先建才能让后面的合并找得到对象。
2103
- for (const candidate of normalized.fresh) {
3949
+ const at = new Date().toISOString()
3950
+ for (const candidate of fresh) {
3951
+ /**
3952
+ * 类型:未登记就**退回一个已启用的兜底类型**,而不是丢掉这条候选。
3953
+ *
3954
+ * 0040 时期这里是 `candidatesRejected += 1` 然后 continue。那条规则与
3955
+ * 「缺信息也编译进去」直接冲突:类型没登记是**配置问题**,不是「这条知识不存在」——
3956
+ * 而配置问题该由配置界面回答,不该让文档里的知识消失。退回时记一条 warning,
3957
+ * 把原始类型名带上,人是能顺藤摸瓜去登记它的。
3958
+ *
3959
+ * 模型路径(`extract-model.ts`)早就是这么做的,这里补齐规则路径 —— 两条路径对
3960
+ * 同一种情况给出不同结论,比任选一种都糟。
3961
+ */
3962
+ const wanted = enabledTypes.find(def => def.id === candidate.type)
3963
+ const resolvedType = wanted ?? enabledTypes.find(def => def.id === 'concept') ?? enabledTypes[0]
3964
+ if (!resolvedType) {
3965
+ outcome.candidatesRejected += 1
3966
+ outcome.warnings.push(`候选「${candidate.name}」未入库:没有任何已启用的 wiki 类型可落(先在「知识库设置 → Wiki 类型」里登记一个)`)
3967
+ continue
3968
+ }
3969
+ if (!wanted) {
3970
+ outcome.warnings.push(`候选「${candidate.name}」的类型 ${candidate.type} 未登记或已停用,已按「${resolvedType.id}」入库`)
3971
+ }
2104
3972
  /**
2105
- * 候选**必须带正文**才落卡。
3973
+ * 正文:候选自带的正文优先,没有就用 summary 撑一个带必填小节骨架的最小正文。
2106
3974
  *
2107
- * 0029 实跑发现:这里原来在候选没有正文时拿 `type.template` 顶上,于是
2108
- * `[[链接]]` 指到、但还没人写过的实体(那时链接候选确实没有正文)会落成一张
2109
- * **正文是模板**的卡片,而模板里的「(必填:写什么)」会被编进 wikiNotes、
2110
- * 被召回、被塞进模型的上下文包 —— 管线写出了一张它自己的校验器判为不合格的
2111
- * 卡片。链接候选现在会带上它所在小节的正文(`extract.ts`),所以这条回退基本
2112
- * 不会被走到;真走到了就**不落卡**,并如实计数:没有正文 = 没有可说的。
3975
+ * 两条路径(模型 / 规则)走同一个 `skeletonBody`,所以「只说了半句」的候选在两处
3976
+ * 得到同一种对待。撑完还是空 → 这是**唯一保留的拒绝**:一条什么都不说的卡片没有
3977
+ * 东西可召回,而它占的 id 会让下一份真正说了点什么的文档并进这张空卡里。
2113
3978
  */
2114
- const body = candidate.body?.trim() ?? ''
3979
+ const body = candidate.body?.trim() || skeletonBody(resolvedType, candidate.summary?.trim() ?? '')
2115
3980
  if (!body) {
2116
3981
  outcome.candidatesRejected += 1
2117
- outcome.warnings.push(`候选「${candidate.name}」没有任何正文(只在别处被提到,所在小节也没取到),不落卡`)
3982
+ outcome.warnings.push(`候选「${candidate.name}」没有任何正文与摘要(只在别处被提到,所在小节也没取到),未入库`)
2118
3983
  continue
2119
3984
  }
2120
- // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
2121
- const aliases = [...new Set([candidate.name, ...(candidate.aliases ?? [])].map(value => value.trim()).filter(Boolean))]
3985
+ const entityId = candidateEntityId(candidate.name)
3986
+ const key = `${resolvedType.id}:${entityId}`
2122
3987
  const metadata: Record<string, unknown> = {
2123
- extraction: 'pass0',
3988
+ extraction: extractionPath,
2124
3989
  candidate_kind: candidate.kind,
2125
3990
  confidence: candidate.confidence,
2126
3991
  evidence: {
2127
3992
  sourceId: candidate.evidence.sourceId,
2128
3993
  ...(candidate.evidence.heading ? { heading: candidate.evidence.heading } : {}),
2129
3994
  },
2130
- ...(aliases.length > 0 ? { aliases } : {}),
3995
+ // 重复的写法都进 aliases:判重认的是名字,不是卡片 id(中文名会退化成哈希 id)。
3996
+ ...(aliasesOf(candidate).length > 0 ? { aliases: aliasesOf(candidate) } : {}),
3997
+ // 类型定义里的字段(`metric.formula` 之类)落在 metadata 上,不占正文。
3998
+ ...(candidate.fields ?? {}),
2131
3999
  }
2132
- const written = await this.writeCard({
2133
- entityType: candidate.type,
2134
- entityId: candidateEntityId(candidate.name),
2135
- title: candidate.name,
2136
- content: body,
2137
- sourceRefs: [candidate.evidence.sourceId],
2138
- status: 'unreviewed',
2139
- metadata,
2140
- })
2141
- if (!written.ok) {
2142
- outcome.candidatesRejected += 1
2143
- outcome.warnings.push(`候选「${candidate.name}」未写入(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`)
4000
+ // 库里已经有这张卡片 → 不是新建,而是「把这次抽到的正文并进去」。
4001
+ const existing = byKey.get(key)
4002
+ if (existing) {
4003
+ mergeIntoPending(pending, existing, candidate.evidence.sourceId, [body], [candidate.name], at, candidate.fields, candidate.summary)
4004
+ continue
4005
+ }
4006
+ const previous = pending.get(key)
4007
+ if (previous) {
4008
+ previous.content = appendExtracted(previous.content, [body])
4009
+ if (!previous.sourceRefs.includes(candidate.evidence.sourceId)) previous.sourceRefs.push(candidate.evidence.sourceId)
4010
+ if (!previous.summary && candidate.summary) previous.summary = candidate.summary
4011
+ previous.metadata = { ...previous.metadata, ...(candidate.fields ?? {}) }
4012
+ previous.merges.push({ sourceId: candidate.evidence.sourceId, keys: [candidate.name], at })
2144
4013
  continue
2145
4014
  }
2146
- outcome.cardsCreated += 1
2147
- next.push({
2148
- entityId: written.data.entityId,
2149
- entityType: candidate.type,
4015
+ pending.set(key, {
4016
+ action: 'create',
4017
+ entityType: resolvedType.id as SchemaType,
4018
+ entityId,
2150
4019
  title: candidate.name,
2151
- status: 'unreviewed',
2152
4020
  content: body,
2153
4021
  sourceRefs: [candidate.evidence.sourceId],
2154
4022
  metadata,
2155
- updatedAt: '',
4023
+ ...(candidate.summary ? { summary: candidate.summary } : {}),
4024
+ merges: [{ sourceId: candidate.evidence.sourceId, keys: [candidate.name], at }],
2156
4025
  })
2157
4026
  }
2158
4027
 
2159
- // 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并且只写一次。
2160
- const targets = new Map<string, { card: KnowledgeCard; keys: string[]; sourceId: string; bodies: string[] }>()
2161
- for (const merge of normalized.merges) {
2162
- const card = next.find(item => item.entityId === merge.entityId && item.entityType === merge.entityType)
2163
- if (!card) continue
2164
- // 「只在没有的时候追加」:来源已在 source_refs 里就什么都不做 —— 空写也会
2165
- // 刷新 updated_at,并让「并入了多少张卡」这个计数变成谎话。
2166
- if (card.sourceRefs.includes(merge.sourceId)) continue
2167
- const id = `${merge.entityType}:${merge.entityId}`
2168
- const entry = targets.get(id)
2169
- if (entry) {
2170
- if (!entry.keys.includes(merge.key)) entry.keys.push(merge.key)
2171
- entry.bodies.push(merge.body)
2172
- } else targets.set(id, { card, keys: [merge.key], sourceId: merge.sourceId, bodies: [merge.body] })
2173
- }
2174
- for (const { card, keys, sourceId, bodies } of targets.values()) {
2175
- const history = Array.isArray(card.metadata.extraction_merges) ? card.metadata.extraction_merges : []
2176
- // 只留最近 20 条:这是「这张卡被哪些来源判重命中过」的可见痕迹,不是审计日志。
2177
- const extraction_merges = [...history, { sourceId, keys, at: new Date().toISOString() }].slice(-20)
2178
- // **追加**而不是覆盖,也不是丢弃:实体已存在时,这次新抽到的正文并进原卡片,
2179
- // 于是知识是累积的,来源改写了也不会停在旧版本上。
2180
- const content = appendExtracted(card.content, bodies)
2181
- const written = await this.writeCard({
2182
- ...card,
2183
- content,
2184
- sourceRefs: [...card.sourceRefs, sourceId],
2185
- metadata: { ...card.metadata, extraction_merges },
2186
- })
2187
- if (!written.ok) {
2188
- outcome.candidatesRejected += 1
2189
- outcome.warnings.push(`候选并入卡片「${card.entityId}」失败(${written.error?.code ?? 'UNKNOWN'}):${written.error?.message ?? ''}`)
4028
+ // 归一:命中的候选把证据来源并进已有卡片。同一张卡片在这一轮里的多次命中合并成一条。
4029
+ for (const merge of merges.values()) {
4030
+ const key = `${merge.entityType}:${merge.entityId}`
4031
+ // 这一轮刚新建的卡片也要认:它此刻还在 `pending` 里,不在磁盘上。
4032
+ const existing = byKey.get(key)
4033
+ if (existing) {
4034
+ // 「只在没有的时候追加」:来源已在 source_refs 里、正文也已在卡片里时什么都不做 ——
4035
+ // 空写一次也会让「更新了几张卡」这个计数变成谎话。
4036
+ if (existing.sourceRefs.includes(merge.sourceId) && appendExtracted(existing.content, [merge.body]) === existing.content) continue
4037
+ mergeIntoPending(pending, existing, merge.sourceId, [merge.body], [merge.key], at, merge.fields, merge.summary)
2190
4038
  continue
2191
4039
  }
2192
- outcome.cardsMerged += 1
2193
- // 让后面的来源看到已经追加过的正文与 source_refs,避免重复写入。
2194
- const index = next.findIndex(item => item.entityId === card.entityId && item.entityType === card.entityType)
2195
- if (index >= 0) next[index] = { ...card, content, sourceRefs: [...card.sourceRefs, sourceId], metadata: { ...card.metadata, extraction_merges } }
4040
+ const previous = pending.get(key)
4041
+ if (!previous) continue
4042
+ previous.content = appendExtracted(previous.content, [merge.body])
4043
+ if (!previous.sourceRefs.includes(merge.sourceId)) previous.sourceRefs.push(merge.sourceId)
4044
+ previous.metadata = { ...previous.metadata, ...(merge.fields ?? {}) }
4045
+ previous.merges.push({ sourceId: merge.sourceId, keys: [merge.key], at })
2196
4046
  }
2197
4047
 
2198
- outcome.relations.push(...normalized.relations)
4048
+ outcome.relations.push(...ruleNormalized.relations)
2199
4049
  // 按来源存一份:跳过的来源下次重建要靠它把关系读回来(见 EXTRACTED_RELATIONS)。
2200
4050
  outcome.relationsBySource[source.sourceId] = [
2201
4051
  ...(outcome.relationsBySource[source.sourceId] ?? []),
2202
- ...normalized.relations,
4052
+ ...ruleNormalized.relations,
2203
4053
  ]
2204
- return next
2205
4054
  }
2206
4055
 
2207
4056
  /**
@@ -2232,6 +4081,326 @@ export class KnowledgeRuntime {
2232
4081
  return { extracted, dropped }
2233
4082
  }
2234
4083
 
4084
+ // ------------------------------------------- Finalize · 索引 / 断链 / 相关(0049)
4085
+
4086
+ /**
4087
+ * 收尾段的**正文层**:给卡片补相关块、把断链写成 `reference` 问题。
4088
+ *
4089
+ * 它必须在 stage 2(切 wikiNotes)**之前**跑:相关块是正文的一部分,晚于切分写进去,
4090
+ * 这一轮编出来的召回单元就与卡片正文对不上,而下次重建又会因为正文变了重切一遍 ——
4091
+ * 每次编译都白编一次。
4092
+ *
4093
+ * 三段都是确定性的,所以这个函数可以重复跑:没变化的卡片一次都不写盘(判据是
4094
+ * 「正文 + 问题清单逐字相同」)。这也让「重编一次,卡片不该有任何写入」这条承诺
4095
+ * 在收尾段照样成立。
4096
+ */
4097
+ private async runFinalizeStage(
4098
+ cards: KnowledgeCard[],
4099
+ relations: Array<{ fromKey: string; toKey: string }>,
4100
+ warnings: string[],
4101
+ ): Promise<FinalizeOutcome> {
4102
+ const related = planRelated(cards, relations)
4103
+ /**
4104
+ * 断链扫的是**写完相关块之后的正文**,不是读进来的那一份。
4105
+ *
4106
+ * 相关块自己就是一堆 `[[类型/标题]]`:扫旧正文等于这一轮看不见它们,而它们马上
4107
+ * 要写进文件 —— 于是「引用是否成立」这件事被推迟到下一轮,而下一轮又会因为
4108
+ * 问题清单变了再写一遍。扫新正文,一轮就说清楚。
4109
+ */
4110
+ const nextContent = new Map(cards.map(card => [cardKey(card), applyRelatedSection(card.content, related.get(cardKey(card)) ?? [])]))
4111
+ const deadLinks = deadLinksOf(cards.map(card => ({ ...card, content: nextContent.get(cardKey(card)) ?? card.content })))
4112
+ const deadByKey = new Map<string, string[]>()
4113
+ for (const link of deadLinks) {
4114
+ const bucket = deadByKey.get(link.key) ?? []
4115
+ bucket.push(link.target)
4116
+ deadByKey.set(link.key, bucket)
4117
+ }
4118
+ let rewritten = 0
4119
+ for (const card of cards) {
4120
+ const key = cardKey(card)
4121
+ const content = nextContent.get(key) ?? card.content
4122
+ // 旧的问题清单里 `reference` 那几条是上一轮算的,先摘掉再按这一轮重算 ——
4123
+ // 断链修好之后那条问题必须消失,否则「待补」清单会越用越长。
4124
+ const kept = cardIssuesOf(card).filter(issue => issue.level !== 'reference')
4125
+ const reference = (deadByKey.get(key) ?? []).map(deadLinkIssue)
4126
+ const issues = [...kept, ...reference]
4127
+ if (content === card.content && JSON.stringify(issues) === JSON.stringify(cardIssuesOf(card))) continue
4128
+ /**
4129
+ * 没有来源的卡片写不进去(`writeCard` 要求 `source_refs` 非空),而这是**唯一**
4130
+ * 一种收尾段碰不到的卡片:它已经是一张不合法的卡片了。如实报出来,不静默跳过 ——
4131
+ * 这种卡片只能由人补来源,或者删掉。
4132
+ */
4133
+ if (card.sourceRefs.length === 0) {
4134
+ warnings.push(`卡片 ${card.entityId} 没有来源,无法补相关块:正文只读,来源只能由编译或人工重新写入`)
4135
+ continue
4136
+ }
4137
+ const written = await this.writeCard({
4138
+ entityType: card.entityType,
4139
+ entityId: card.entityId,
4140
+ title: card.title,
4141
+ content,
4142
+ sourceRefs: card.sourceRefs,
4143
+ ...(card.summary ? { summary: card.summary } : {}),
4144
+ status: card.status,
4145
+ ...(card.owner ? { owner: card.owner } : {}),
4146
+ ...(card.provenance ? { provenance: card.provenance } : {}),
4147
+ metadata: card.metadata,
4148
+ extraIssues: reference,
4149
+ origin: 'compile',
4150
+ })
4151
+ if (written.ok === false) {
4152
+ warnings.push(`卡片 ${card.entityId} 的相关块 / 断链未写入(${written.error.code}):${written.error.message}`)
4153
+ continue
4154
+ }
4155
+ rewritten += 1
4156
+ }
4157
+ return {
4158
+ related, deadLinks, rewritten,
4159
+ relatedLinks: [...related.values()].reduce((sum, entries) => sum + entries.length, 0),
4160
+ relatedCards: [...related.keys()].length,
4161
+ }
4162
+ }
4163
+
4164
+ /** 收尾段的**产物层**:写索引页。它读的是正文层算完之后的卡片清单。 */
4165
+ private async writeWikiIndex(input: {
4166
+ cards: KnowledgeCard[]
4167
+ related: Map<string, RelatedEntry[]>
4168
+ deadLinks: DeadLink[]
4169
+ }): Promise<void> {
4170
+ await writeTextAtomic(this.safePath('03_compiled', WIKI_INDEX), buildWikiIndex(input))
4171
+ }
4172
+
4173
+ /** 一条路的候选 → 账本行(0050)。名次按这一路自己的顺序,没过阈值的也在。 */
4174
+ private ledgerRows(rows: Array<RouteRanking & { kept: boolean }> | undefined, unitById: Map<string, WikiNote>): RecallLedgerRow[] {
4175
+ return (rows ?? []).flatMap((row, index) => {
4176
+ const note = unitById.get(row.unitId)
4177
+ if (note === undefined) return []
4178
+ return [{
4179
+ unitId: row.unitId,
4180
+ title: note.title,
4181
+ entityType: note.entityType,
4182
+ entityId: note.entityId,
4183
+ score: row.score,
4184
+ rank: index + 1,
4185
+ kept: row.kept,
4186
+ }]
4187
+ })
4188
+ }
4189
+
4190
+ // ------------------------------------------- 召回组装:去重 / 扩展 / 预算(0050)
4191
+
4192
+ /**
4193
+ * **1 跳扩展**:从命中的卡片出发,按图上的边拉邻居。
4194
+ *
4195
+ * 两个来源,优先级相同(都记进 `via`,一条邻居可以有多条来路):
4196
+ * 1. **图上的边**(`relates_to` 这类卡片之间的关系)—— `supported_by` 不算:
4197
+ * 它连的是「卡片 → 来源」,而来源不是 wiki 卡片,进不了上下文;
4198
+ * 2. **共同来源**:同一份 SourceRaw 编译出来的卡片也是邻居(这是最实用的一类,
4199
+ * 新库里往往一条 `relates_to` 都没有)。
4200
+ *
4201
+ * 调用点必须在**按卡片去重之后**:先扩展再去重会把邻居与命中混在一起,
4202
+ * 而上下文里必须分得清「这是召回来的」还是「这是延伸出来的」。
4203
+ */
4204
+ private expandWiki(seedKeys: string[], cardByKey: Map<string, KnowledgeCard>, includeProblems: boolean): RecallExpansion {
4205
+ const seeds = new Set(seedKeys)
4206
+ const visible = (card: KnowledgeCard): boolean => includeProblems || card.status !== 'problem'
4207
+ const via = new Map<string, string[]>()
4208
+ const remember = (key: string, from: string): void => {
4209
+ const bucket = via.get(key) ?? []
4210
+ if (!bucket.includes(from)) bucket.push(from)
4211
+ via.set(key, bucket)
4212
+ }
4213
+ const edges: Array<Record<string, unknown>> = []
4214
+ for (const edge of this.graph.edges) {
4215
+ const type = String(edge.type)
4216
+ if (type === 'supported_by') continue
4217
+ const from = String(edge.from)
4218
+ const to = String(edge.to)
4219
+ const fromSeed = seeds.has(from)
4220
+ const toSeed = seeds.has(to)
4221
+ // 两端都在种子里(种子里的一条内部边)或都不在(与这次召回无关):不是扩展。
4222
+ if (fromSeed === toSeed) continue
4223
+ const neighbor = fromSeed ? to : from
4224
+ const card = cardByKey.get(neighbor)
4225
+ if (card === undefined || !visible(card)) continue
4226
+ remember(neighbor, `${fromSeed ? from : to}|${type}`)
4227
+ edges.push(edge)
4228
+ }
4229
+ // 共同来源:反向走 `supported_by`(来源 → 它编译出的卡片)。
4230
+ const bySource = new Map<string, string[]>()
4231
+ for (const key of seeds) {
4232
+ for (const ref of cardByKey.get(key)?.sourceRefs ?? []) {
4233
+ const bucket = bySource.get(ref) ?? []
4234
+ bucket.push(key)
4235
+ bySource.set(ref, bucket)
4236
+ }
4237
+ }
4238
+ for (const card of cardByKey.values()) {
4239
+ const key = cardKey(card)
4240
+ if (seeds.has(key) || !visible(card)) continue
4241
+ for (const ref of card.sourceRefs) {
4242
+ const owners = bySource.get(ref)
4243
+ if (owners === undefined || owners.length === 0) continue
4244
+ remember(key, `${owners[0]}|shared_source`)
4245
+ }
4246
+ }
4247
+ const nodes: RecallExpansion['nodes'] = []
4248
+ for (const [key, sources] of via) {
4249
+ const card = cardByKey.get(key)
4250
+ if (card === undefined) continue
4251
+ nodes.push({ entityType: card.entityType, entityId: card.entityId, title: card.title, via: sources })
4252
+ }
4253
+ // 排序:来路多的在前(更可能是真的相关),同数按身份,保证确定性。
4254
+ nodes.sort((left, right) => right.via.length - left.via.length || `${left.entityType}:${left.entityId}`.localeCompare(`${right.entityType}:${right.entityId}`))
4255
+ const capped = nodes.slice(0, MAX_EXPANSION_CARDS)
4256
+ const kept = new Set(capped.map(node => `${node.entityType}:${node.entityId}`))
4257
+ return {
4258
+ nodes: capped,
4259
+ // 只留两端都在扩展集里的边:一端是命中卡片的边已经写在命中卡片自己的子图里了。
4260
+ edges: edges.filter(edge => kept.has(String(edge.from)) && kept.has(String(edge.to))),
4261
+ included: [],
4262
+ }
4263
+ }
4264
+
4265
+ /** 一条上下文条目「值多少字符」:命中条目算它那些 note 的正文,扩展条目算标题 + 摘要(+ 正文)。 */
4266
+ private contextCost(item: ContextItem, full: boolean): number {
4267
+ if (item.reason === 'hit') return item.notes.reduce((sum, note) => sum + note.title.length + note.content.length, 0)
4268
+ const card = item.wiki
4269
+ if (card === null) return 0
4270
+ const shallow = card.title.length + (card.summary?.length ?? 0)
4271
+ return full ? shallow + card.content.length : shallow
4272
+ }
4273
+
4274
+ /** 溢出目录:**系统临时目录**,不随知识库走(0050 的取舍,见 issue)。 */
4275
+ private recallOverflowDir(): string {
4276
+ return join(tmpdir(), 'dsh-knowledge-recall', this.baseId || 'default')
4277
+ }
4278
+
4279
+ /**
4280
+ * 上下文预算:装箱 + 溢出(0050)。
4281
+ *
4282
+ * 让位顺序(写死在这里,不是启发式):
4283
+ * 1. **命中的 note 正文优先**:它是证据,装不下就该少给几张卡片,而不是把证据截断;
4284
+ * 2. 扩展卡片先给**标题 + 摘要 + 来路**(便宜,且足以让模型知道「还有这么一张卡」);
4285
+ * 3. 还有余量才给扩展卡片的正文。
4286
+ *
4287
+ * 溢出的内容落**系统临时目录**(`<os.tmpdir()>/dsh-knowledge-recall/<库>/<recallId>.md`),
4288
+ * 不随库走:它是一次召回的临时产物,下一次召回就是另一份。代价是系统会清它 ——
4289
+ * 所以指路行必须说清「本次会话内有效」,并给出「读不到就重新召回」这条出路。
4290
+ */
4291
+ private async packContext(input: { items: ContextItem[]; maxChars: number; recallId: string }): Promise<{
4292
+ items: ContextItem[]
4293
+ report: ContextBudgetReport
4294
+ notice?: string
4295
+ }> {
4296
+ const { items, maxChars, recallId } = input
4297
+ const hitCards = items.filter(item => item.reason === 'hit').length
4298
+ const expandedCards = items.length - hitCards
4299
+ const totalChars = items.reduce((sum, item) => sum + this.contextCost(item, true), 0)
4300
+ if (!Number.isFinite(maxChars) || maxChars <= 0) {
4301
+ return { items, report: { maxChars: 0, usedChars: totalChars, omittedCards: 0, omittedChars: 0, hitCards, expandedCards } }
4302
+ }
4303
+ const keptItems: ContextItem[] = []
4304
+ let used = 0
4305
+ for (const item of items) {
4306
+ const full = this.contextCost(item, true)
4307
+ const shallow = this.contextCost(item, false)
4308
+ // 命中条目按整条算(它的正文就是证据);扩展条目可以先按「浅」那一档进。
4309
+ const cost = item.reason === 'hit' ? full : shallow
4310
+ if (used + cost > maxChars) continue
4311
+ used += cost
4312
+ keptItems.push(item)
4313
+ }
4314
+ const keptKeys = new Set(keptItems.map(item => item.wiki === null ? '' : `${item.wiki.entityType}:${item.wiki.entityId}`))
4315
+ const omitted = items.filter(item => !keptKeys.has(item.wiki === null ? '' : `${item.wiki.entityType}:${item.wiki.entityId}`))
4316
+ const report: ContextBudgetReport = {
4317
+ maxChars,
4318
+ usedChars: used,
4319
+ omittedCards: omitted.length,
4320
+ omittedChars: omitted.reduce((sum, item) => sum + this.contextCost(item, true), 0),
4321
+ hitCards: keptItems.filter(item => item.reason === 'hit').length,
4322
+ expandedCards: keptItems.filter(item => item.reason === 'expansion').length,
4323
+ }
4324
+ if (omitted.length === 0) return { items: keptItems, report }
4325
+ const overflowPath = await this.writeRecallOverflow({ items, recallId, usedChars: used, maxChars })
4326
+ report.overflowPath = overflowPath
4327
+ return {
4328
+ items: keptItems,
4329
+ report,
4330
+ notice: `还有 ${report.omittedCards} 张卡片共 ${report.omittedChars} 字符没有放进上下文,完整内容在 ${overflowPath};需要更多信息时直接读这个文件(它是本次召回的临时产物,不保证长期存在)。`,
4331
+ }
4332
+ }
4333
+
4334
+ /** 把**完整**的上下文写进溢出文件(含被预算挡在外面的那些)。 */
4335
+ private async writeRecallOverflow(input: { items: ContextItem[]; recallId: string; usedChars: number; maxChars: number }): Promise<string> {
4336
+ const dir = this.recallOverflowDir()
4337
+ await mkdir(dir, { recursive: true })
4338
+ await this.pruneRecallOverflow(dir)
4339
+ const path = join(dir, `${input.recallId}.md`)
4340
+ const lines: string[] = [
4341
+ '# 这次召回的完整上下文',
4342
+ '',
4343
+ `上限 ${input.maxChars} 字符,上下文里装了 ${input.usedChars} 字符;这里是**全部**内容。`,
4344
+ '它是临时产物,读不到时重新召回一次即可。',
4345
+ '',
4346
+ ]
4347
+ for (const item of input.items) {
4348
+ const card = item.wiki
4349
+ lines.push(`## ${card?.title ?? '(未找到卡片)'} · ${card?.entityType ?? ''}/${card?.entityId ?? ''}${item.reason === 'expansion' ? '(1 跳扩展)' : ''}`, '')
4350
+ if (item.via) lines.push(`来路:${item.via.entityType}/${item.via.entityId}(${item.via.relation})`, '')
4351
+ if (card?.summary) lines.push(`> ${card.summary}`, '')
4352
+ for (const note of item.notes) lines.push(`### ${note.title}`, '', note.content, '')
4353
+ if (item.reason === 'expansion' && card) lines.push(card.content, '')
4354
+ }
4355
+ await writeTextAtomic(path, `${lines.join('\n').replace(/\n+$/, '')}\n`)
4356
+ return path
4357
+ }
4358
+
4359
+ /** 清掉本库前缀下超过保留时长的溢出文件(自清理,不依赖系统清 tmp)。 */
4360
+ private async pruneRecallOverflow(dir: string): Promise<void> {
4361
+ try {
4362
+ const cutoff = Date.now() - RECALL_OVERFLOW_TTL_MS
4363
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
4364
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
4365
+ const path = join(dir, entry.name)
4366
+ try {
4367
+ const info = await stat(path)
4368
+ if (info.mtimeMs < cutoff) await unlink(path)
4369
+ } catch { /* 并发删掉了就算了 */ }
4370
+ }
4371
+ } catch { /* 目录还不存在:没有什么要清 */ }
4372
+ }
4373
+
4374
+ /**
4375
+ * 一张卡片的出链与入链(面板抽屉里的「相关」分块)。
4376
+ *
4377
+ * 与写进正文的那一块**同一份计划**:两处都调 `planRelated`,所以界面上看到的关系
4378
+ * 与文件里写的不会漂移。入链要单独算 —— 关系是双向记的,但同源那一类是「谁和我
4379
+ * 同源」,只能扫一遍别人的出链。
4380
+ */
4381
+ async relatedOfCard(card: KnowledgeCard): Promise<{ outgoing: RelatedEntry[]; incoming: RelatedEntry[] }> {
4382
+ /**
4383
+ * 图上那几条关系边住在 `knowledge_graph.json`,进程刚起来时内存里是空的。
4384
+ * 少了这一次读取,「关系」那一类相关会**静默缺失**(只剩同源那几条),而界面上
4385
+ * 看不出少了什么 —— 卡片抽屉是本插件里第一个在召回之外读图的地方。
4386
+ */
4387
+ if (this.graph.edges.length === 0) await this.loadCompiledState()
4388
+ const cards = await this.loadCards()
4389
+ const graphRelations = this.graph.edges
4390
+ .filter(edge => String(edge.type) !== 'supported_by')
4391
+ .map(edge => ({ fromKey: String(edge.from), toKey: String(edge.to) }))
4392
+ const related = planRelated(cards, graphRelations)
4393
+ const key = cardKey(card)
4394
+ const outgoing = related.get(key) ?? []
4395
+ const incoming: RelatedEntry[] = []
4396
+ for (const [fromKey, entries] of related) {
4397
+ if (fromKey === key) continue
4398
+ const entry = entries.find(item => item.key === key)
4399
+ if (entry) incoming.push({ ...entry, key: fromKey })
4400
+ }
4401
+ return { outgoing, incoming }
4402
+ }
4403
+
2235
4404
  async rebuild(only: 'all' | 'schema' | 'wiki' | 'embeddings' = 'all', lineage?: LineageContext, options: { extract?: boolean } = {}): Promise<Envelope<RebuildReport>> {
2236
4405
  await this.ensureLayout()
2237
4406
  const startedAt = new Date().toISOString()
@@ -2240,7 +4409,7 @@ export class KnowledgeRuntime {
2240
4409
  const sourceTypes = new Map(sources.map(source => [source.sourceId, source.sourceType]))
2241
4410
 
2242
4411
  // ------------------------------------------------ previous build state
2243
- let previousManifest: { version?: number; builtAt?: string; cardHashes?: Record<string, string>; extractionHashes?: Record<string, string>; embedding?: unknown } = {}
4412
+ let previousManifest: { version?: number; builtAt?: string; cardHashes?: Record<string, string>; extractionHashes?: Record<string, string>; embedding?: unknown; diagnosisHash?: string; indexHash?: string } = {}
2244
4413
  try {
2245
4414
  previousManifest = JSON.parse(await readFile(this.safePath('03_compiled', 'build-manifest.json'), 'utf8')) as typeof previousManifest
2246
4415
  } catch { /* first build */ }
@@ -2258,14 +4427,20 @@ export class KnowledgeRuntime {
2258
4427
  const extraction = extractEnabled
2259
4428
  ? await this.runExtractionStage(sources, previousExtractionHashes)
2260
4429
  : this.emptyExtractionOutcome(previousExtractionHashes)
2261
- // 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
2262
- // 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
2263
- if (extraction.cardsCreated > 0 || extraction.cardsMerged > 0) this.cards = await this.loadCards()
4430
+ /**
4431
+ * 抽取刚落过盘:stage 2 必须看到新的卡片清单,否则这一轮建的卡片要等下一次
4432
+ * 重建才进 wikiNotes,用户点了编译却召不回刚抽出来的东西。
4433
+ *
4434
+ * 0044 之前这一行是多余的(Pass 0 只写提议,卡片清单根本不会变),所以它一直是
4435
+ * 一个**没被触发过的**承诺;现在 Pass 0 直接落卡,漏掉它就会表现成「第一次编译
4436
+ * 什么都没建出来,第二次才有」—— 一个看起来像缓存没刷新的 bug。
4437
+ */
4438
+ if (extractEnabled) this.cards = await this.loadCards()
2264
4439
  const extractionCounts = {
2265
4440
  candidatesExtracted: extraction.candidatesExtracted,
2266
4441
  candidatesRejected: extraction.candidatesRejected,
2267
4442
  cardsCreated: extraction.cardsCreated,
2268
- cardsMerged: extraction.cardsMerged,
4443
+ cardsUpdated: extraction.cardsUpdated,
2269
4444
  sourcesExtracted: extraction.sourcesExtracted,
2270
4445
  sourcesSkipped: extraction.sourcesSkipped,
2271
4446
  }
@@ -2273,11 +4448,54 @@ export class KnowledgeRuntime {
2273
4448
  let relationsExtracted = 0
2274
4449
  let relationsDropped = 0
2275
4450
 
4451
+ // --------------------------------- Finalize · 相关块与断链(正文层,先于切分)
4452
+ /**
4453
+ * 收尾段在 stage 2 **之前**跑,理由有两条,都不是风格问题:
4454
+ *
4455
+ * 1. 相关块是正文的一部分。晚于切分写进去,这一轮编出的召回单元就与卡片正文对不上,
4456
+ * 而下一轮又会因为正文变了重切一遍 —— 每次编译都白编一次;
4457
+ * 2. 它必须参与「语料变了没有」的判定。0047 的图片那次踩过同一个坑:派生内容写盘
4458
+ * 晚于判定,增量编译判定「没有变化」,刚写进去的东西连同整个产物一起被丢掉。
4459
+ *
4460
+ * `extractEnabled` 为假(`only: 'embeddings'`)时整段不跑:它不碰 wiki 层。
4461
+ */
4462
+ const finalize: FinalizeOutcome = extractEnabled
4463
+ ? await this.runFinalizeStage(this.cards, extraction.relations, warnings)
4464
+ : { related: new Map(), relatedLinks: 0, relatedCards: 0, deadLinks: [], rewritten: 0 }
4465
+ // 正文层刚落过盘:stage 2 必须看到写完相关块之后的卡片。
4466
+ if (finalize.rewritten > 0) this.cards = await this.loadCards()
4467
+ /**
4468
+ * 索引页也是这次编译的产物,所以它变了也算「语料变了」。
4469
+ *
4470
+ * 少了这一条会踩一个很隐蔽的坑:一个**本来就没有任何关系**的知识库,收尾段一条
4471
+ * 卡片都不改,增量编译于是判定「没有变化」直接跳过 —— 索引页永远不会被写出来,
4472
+ * 而界面上没有任何一处说「这个文件没有生成」。
4473
+ */
4474
+ const indexText = buildWikiIndex({ cards: this.cards, related: finalize.related, deadLinks: finalize.deadLinks })
4475
+ const indexHash = contentHashOf(indexText)
4476
+ // 文件被手工删掉时也算「变了」:它是产物,不该因为「内容没变」就永远回不来。
4477
+ const indexChanged = indexHash !== previousManifest.indexHash || !existsSync(this.safePath('03_compiled', WIKI_INDEX))
4478
+ const finalizeCounts = {
4479
+ relatedLinks: finalize.relatedLinks,
4480
+ relatedCards: finalize.relatedCards,
4481
+ deadLinks: finalize.deadLinks.length,
4482
+ finalizeRewritten: finalize.rewritten,
4483
+ }
4484
+
2276
4485
  // ------------------------------------------------ stage 2 · compile notes
2277
4486
  let cardsReused = 0
2278
4487
  let cardsRecompiled = 0
2279
4488
  let cardsAdded = 0
2280
4489
  let notesReused = 0
4490
+ /**
4491
+ * 复用 note 时**派生字段**(图片路径、来源分类、溯源链)被刷新过。
4492
+ *
4493
+ * 它必须参与「语料变了没有」的判定:这些字段不在 `cardHashOf` 里(它们能从正文
4494
+ * 重算出来),所以一张卡片可以「一个字都没变」而编译产物确实不同 —— 0047 实机
4495
+ * 踩到的就是这个:解析规则修好了,增量编译却判定「没有变化」,直接走空操作返回,
4496
+ * 刷新过的路径连同整个 `wiki_notes.json` 一起被丢掉。
4497
+ */
4498
+ let derivedRefreshed = false
2281
4499
  let notesRecompiled = 0
2282
4500
  const cardHashes: Record<string, string> = {}
2283
4501
  /**
@@ -2308,16 +4526,33 @@ export class KnowledgeRuntime {
2308
4526
  if (!previousHashes[key]) cardsAdded += 1
2309
4527
  const kept = incremental && previousHashes[key] === fingerprint ? previousByWiki.get(key) : undefined
2310
4528
  if (kept && kept.length > 0) {
2311
- // Reuse the notes verbatim, but refresh the two derived fields that can
2312
- // change *without* the card changing: a SourceRaw's business type and
2313
- // the card's provenance chain. Keeping a stale `sourceTypes` would make
2314
- // a source_type-filtered recall silently miss this card.
4529
+ // Reuse the notes verbatim, but refresh the derived fields that can
4530
+ // change *without* the card changing: a SourceRaw's business type, the
4531
+ // card's provenance chain, and the image paths. Keeping a stale
4532
+ // `sourceTypes` would make a source_type-filtered recall silently miss
4533
+ // this card; keeping stale `imagePaths` would keep the multimodal
4534
+ // encoder pointed at a path that no longer resolves (0047: the first
4535
+ // live rebuild reused notes whose paths were resolved by the old rule,
4536
+ // so the encoder still got `02_wiki/concept/_assets/…`).
4537
+ //
4538
+ // 评分不在这里刷新:它已经是 `cardHashOf` 的一部分,所以「评分变了」走的是
4539
+ // 重编那条分支,不会走到这里。
2315
4540
  const sourceTypesFor = card.sourceRefs.flatMap(ref => { const sourceType = sourceTypes.get(ref); return sourceType ? [sourceType] : [] })
2316
- notes.push(...kept.map(note => ({
2317
- ...note,
2318
- sourceTypes: sourceTypesFor,
2319
- ...(card.provenance ? { provenance: card.provenance } : {}),
2320
- })))
4541
+ const cardDir = join(this.directory(WIKI), card.entityType)
4542
+ notes.push(...kept.map(note => {
4543
+ // 图按 note **自己那一节**的引用算 —— 与 `compileCardNotes` 同一条规则,
4544
+ // 否则复用与重编会给出两套答案。
4545
+ const images = markdownImagePaths(note.content, cardDir, this.rootDir)
4546
+ if (JSON.stringify(note.imagePaths ?? []) !== JSON.stringify(images)) derivedRefreshed = true
4547
+ if (JSON.stringify(note.sourceTypes ?? []) !== JSON.stringify(sourceTypesFor)) derivedRefreshed = true
4548
+ const { imagePaths: _stale, ...rest } = note
4549
+ return {
4550
+ ...rest,
4551
+ ...(images.length > 0 ? { imagePaths: images } : {}),
4552
+ sourceTypes: sourceTypesFor,
4553
+ ...(card.provenance ? { provenance: card.provenance } : {}),
4554
+ }
4555
+ }))
2321
4556
  cardsReused += 1
2322
4557
  notesReused += kept.length
2323
4558
  } else {
@@ -2335,7 +4570,19 @@ export class KnowledgeRuntime {
2335
4570
  // bumping the version there would invalidate the vector cache and every
2336
4571
  // qrels pointing at it for nothing. `all` never takes this path (see
2337
4572
  // `incremental` above).
2338
- const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0
4573
+ /**
4574
+ * 归因图(字段级)也是这次编译的产物之一,所以它变了也算「语料变了」。
4575
+ *
4576
+ * 少了这一条会踩一个很隐蔽的坑:**记一次排查不改任何卡片**(它写的是一条
4577
+ * `agent_diagnosis_case` 的 SourceRaw),于是增量编译判定「没变化」直接跳过 ——
4578
+ * 而 `03_compiled/diagnosis_graph.json` 停在上一轮,图召回因此看不到这次排查
4579
+ * 新增的 `produces` / `supports` / `refutes` 边。它读的是**编译产物**,
4580
+ * 不是 `02_wiki` 的实时投影(§14.3)。
4581
+ */
4582
+ const diagnosis = await this.diagnosisStore().readGraph()
4583
+ const diagnosisHash = contentHashOf(JSON.stringify({ nodes: diagnosis.nodes, edges: diagnosis.edges }))
4584
+ const diagnosisChanged = diagnosisHash !== previousManifest.diagnosisHash
4585
+ const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged || derivedRefreshed || indexChanged
2339
4586
  if (!corpusChanged) {
2340
4587
  const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`
2341
4588
  const report: RebuildReport = {
@@ -2346,7 +4593,7 @@ export class KnowledgeRuntime {
2346
4593
  cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0,
2347
4594
  notesReused, notesRecompiled: 0,
2348
4595
  incremental: true, skipped: true,
2349
- ...extractionCounts,
4596
+ ...extractionCounts, ...finalizeCounts,
2350
4597
  // 这一轮没有重装图,所以没有一条边是「这次连上的」或「这次丢掉的」。
2351
4598
  // 报 0 而不是把图里已有的边算进来:那会把历史成果记到这次空操作头上。
2352
4599
  relationsExtracted: 0, relationsDropped: 0,
@@ -2356,7 +4603,7 @@ export class KnowledgeRuntime {
2356
4603
  // 用户刚点了编译却看到上次的统计,会以为按钮没生效。version 不变,所以
2357
4604
  // 这个写入既不改索引也不让向量缓存失效。extractionHashes 必须跟着更新:
2358
4605
  // 来源变了但没抽出卡片时,不落新指纹会让下一次又把它重抽一遍。
2359
- await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version: previousVersion, builtAt: previousManifest.builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: previousManifest.embedding, compileRunId, lastBuild: { only, incremental: true, skipped: true, cardsReused, cardsRecompiled: 0, cardsAdded: 0, cardsRemoved: 0, notesReused, notesRecompiled: 0, ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2))
4606
+ 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))
2360
4607
  return success(report, ['知识库没有变化:已复用全部 wikiNotes,未生成新的索引版本。', ...extraction.warnings])
2361
4608
  }
2362
4609
 
@@ -2381,6 +4628,23 @@ export class KnowledgeRuntime {
2381
4628
  // Compatibility mirror for tooling that predates wiki_notes.json.
2382
4629
  await writeTextAtomic(this.safePath('03_compiled', LEGACY_UNITS), JSON.stringify(this.units, null, 2))
2383
4630
  await writeTextAtomic(this.safePath('03_compiled', 'knowledge_graph.json'), JSON.stringify(this.graph, null, 2))
4631
+ /**
4632
+ * 字段级的归因图(0021 §14.3)。**同一次编译产出两份投影,各自由它的消费者读**:
4633
+ * `knowledge_graph.json` 是卡片级(图谱 Tab、图召回),这里是字段级
4634
+ * (智能归因三视图)。不合成一张 —— 消费者不同、`supported_by` 只对卡片成立、
4635
+ * 而召回权重的基准不该被顺手改掉(§14.4)。
4636
+ */
4637
+ this.diagnosisGraph = { nodes: diagnosis.nodes as unknown as Array<Record<string, unknown>>, edges: diagnosis.edges as unknown as Array<Record<string, unknown>> }
4638
+ await writeTextAtomic(this.safePath('03_compiled', DIAGNOSIS_GRAPH), JSON.stringify({ version, builtAt, nodes: diagnosis.nodes, edges: diagnosis.edges }, null, 2))
4639
+ /**
4640
+ * 收尾段的**产物层**:索引页(0049)。
4641
+ *
4642
+ * 放在图装配之后:索引页里那几条「相关」是**图上的边**,先写索引再装图,写出来的
4643
+ * 就会是上一轮的图。`indexText` 在正文层那一段已经算过一次(为了判断「这一轮到底
4644
+ * 有没有变化」),这里重算一遍是因为卡片清单可能被 stage 2 之后的写入改过 ——
4645
+ * 两处调的是同一个纯函数,所以不可能给出两种格式。
4646
+ */
4647
+ await this.writeWikiIndex({ cards: this.cards, related: finalize.related, deadLinks: finalize.deadLinks })
2384
4648
  // 关键词索引跟着 notes 一起落盘(0031):它的输入就是 notes,两者必须同时有效,
2385
4649
  // 否则会出现「卡片改了、关键词路还按旧词表召回」。它不依赖编码器,所以 wiki-only
2386
4650
  // 重建也要重建它 —— 而向量是 stage 3,仍然只在 `embeddings`/`all` 时重算。
@@ -2390,7 +4654,7 @@ export class KnowledgeRuntime {
2390
4654
  // re-encode of the whole corpus.
2391
4655
  const embeddingResult = only === 'all' ? await this.buildVectorIndex(this.units, version) : { status: 'skipped' as const, count: 0, reused: 0 }
2392
4656
  const compileRunId = `rebuild_${hash(`${startedAt}:${this.cards.length}`)}`
2393
- await writeTextAtomic(this.safePath('03_compiled', 'build-manifest.json'), JSON.stringify({ version, builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: embeddingResult, fts: ftsResult, compileRunId, lastBuild: { only, incremental: cardsReused > 0, skipped: false, cardsReused, cardsRecompiled, cardsAdded, cardsRemoved: removedCount, notesReused, notesRecompiled, ...extractionCounts, relationsExtracted, relationsDropped }, ...(lineage ? { lineage } : {}) }, null, 2))
4657
+ 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))
2394
4658
  const report: RebuildReport = {
2395
4659
  runId: compileRunId, compileRunId, version, only,
2396
4660
  cards: this.cards.length, units: this.units.length, notes: this.units.length,
@@ -2404,7 +4668,7 @@ export class KnowledgeRuntime {
2404
4668
  notesReused, notesRecompiled,
2405
4669
  incremental: cardsReused > 0 || notesReused > 0,
2406
4670
  skipped: false,
2407
- ...extractionCounts, relationsExtracted, relationsDropped,
4671
+ ...extractionCounts, ...finalizeCounts, relationsExtracted, relationsDropped,
2408
4672
  ...(lineage ? { lineage } : {}), startedAt, finishedAt: new Date().toISOString(),
2409
4673
  }
2410
4674
  if (embeddingResult.status === 'unavailable') warnings.push(`embedding 不可用,已保留 FTS fallback:${embeddingResult.error ?? 'unknown error'}`)
@@ -2428,8 +4692,8 @@ export class KnowledgeRuntime {
2428
4692
  vectorQuantization: embeddingResult.status === 'ready' ? VECTOR_QUANTIZATION : undefined,
2429
4693
  embeddingError: embeddingResult.error,
2430
4694
  incremental: embeddingResult.reused > 0, skipped: false,
2431
- // `embeddings` 不跑 Pass 0,所以这些计数是真的 0,不是省略。
2432
- ...extractionCounts, relationsExtracted: 0, relationsDropped: 0,
4695
+ // `embeddings` 不跑 Pass 0,也不跑收尾段,所以这些计数是真的 0,不是省略。
4696
+ ...extractionCounts, ...finalizeCounts, relationsExtracted: 0, relationsDropped: 0,
2433
4697
  ...(lineage ? { lineage } : {}), startedAt, finishedAt: new Date().toISOString(),
2434
4698
  }
2435
4699
  if (embeddingResult.status === 'unavailable') warnings.push(`embedding 不可用,已保留 FTS fallback:${embeddingResult.error ?? 'unknown error'}`)
@@ -2499,12 +4763,12 @@ export class KnowledgeRuntime {
2499
4763
  }
2500
4764
 
2501
4765
  /** 关键词路:FTS5 + BM25(0031)。索引不在时如实降级,不退回旧的计数打分。 */
2502
- private async ftsRoute(query: string, eligible: WikiNote[], pool: number, threshold: number, titleOf: (sourceId: string) => string | undefined): Promise<{ ranking: RouteRanking[]; diagnostic: RecallRouteDiagnostic }> {
4766
+ private async ftsRoute(query: string, eligible: WikiNote[], pool: number, threshold: number, titleOf: (sourceId: string) => string | undefined): Promise<{ ranking: RouteRanking[]; rows: Array<RouteRanking & { kept: boolean }>; diagnostic: RecallRouteDiagnostic }> {
2503
4767
  const eligibleIds = new Set(eligible.map(note => note.unitId))
2504
4768
  const sqlite = await loadSqlite()
2505
- if (!sqlite) return { ranking: [], diagnostic: { route: 'fts', status: 'degraded', reason: '当前 Node 没有 node:sqlite(需要 22.5+),关键词路已降级' } }
4769
+ if (!sqlite) return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: '当前 Node 没有 node:sqlite(需要 22.5+),关键词路已降级' } }
2506
4770
  const index = await FtsIndex.open(this.safePath('03_compiled', FTS_FILE), sqlite.DatabaseSync)
2507
- if (!index) return { ranking: [], diagnostic: { route: 'fts', status: 'degraded', reason: `关键词索引不存在(03_compiled/${FTS_FILE}),先编译一次` } }
4771
+ if (!index) return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: `关键词索引不存在(03_compiled/${FTS_FILE}),先编译一次` } }
2508
4772
  try {
2509
4773
  const found = index.search(query, pool).filter(hit => eligibleIds.has(hit.unitId))
2510
4774
  /**
@@ -2527,9 +4791,10 @@ export class KnowledgeRuntime {
2527
4791
  // 单字查询(如 `表`)是刻意收窄的问法,没有「多字词覆盖率」可言:不拦它,
2528
4792
  // 交给阈值。这里如实返回空排名之外的结果需要另走一条路,所以直接用全部命中。
2529
4793
  const ranking = found.map(hit => ({ unitId: hit.unitId, score: hit.score }))
2530
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold)
4794
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold)
2531
4795
  return {
2532
4796
  ranking: kept,
4797
+ rows,
2533
4798
  diagnostic: {
2534
4799
  route: 'fts', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2535
4800
  ...(dropped > 0 ? { reason: `${dropped} 条低于关键词阈值 ${threshold} 被丢弃` } : {}),
@@ -2546,13 +4811,14 @@ export class KnowledgeRuntime {
2546
4811
  })
2547
4812
  const covered = scored.filter(hit => hit.coverage >= MIN_QUERY_COVERAGE)
2548
4813
  const ranking = covered.map(hit => ({ unitId: hit.unitId, score: hit.score }))
2549
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold)
4814
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold)
2550
4815
  const lowCoverage = scored.length - covered.length
2551
4816
  const reasons: string[] = []
2552
4817
  if (lowCoverage > 0) reasons.push(`${lowCoverage} 条查询词覆盖率低于 ${MIN_QUERY_COVERAGE} 被丢弃`)
2553
4818
  if (dropped > 0) reasons.push(`${dropped} 条低于关键词阈值 ${threshold} 被丢弃`)
2554
4819
  return {
2555
4820
  ranking: kept,
4821
+ rows,
2556
4822
  diagnostic: {
2557
4823
  route: 'fts', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2558
4824
  ...(reasons.length > 0 ? { reason: reasons.join(';') } : {}),
@@ -2570,16 +4836,18 @@ export class KnowledgeRuntime {
2570
4836
  * 阈值就完全失去意义(余弦 0 会被映射成 0.5,轻松过线)。原始余弦是绝对量,可以直接
2571
4837
  * 用来判「这段内容和问题到底像不像」—— 弃答主要靠它和重排阈值。
2572
4838
  */
2573
- private async vectorRoute(query: string, eligibleIds: Set<string>, pool: number, threshold: number): Promise<{ ranking: RouteRanking[]; diagnostic: RecallRouteDiagnostic; health: { ok: boolean; reason?: string } }> {
4839
+ private async vectorRoute(query: string, eligibleIds: Set<string>, pool: number, threshold: number): Promise<{ ranking: RouteRanking[]; rows: Array<RouteRanking & { kept: boolean }>; diagnostic: RecallRouteDiagnostic; health: { ok: boolean; reason?: string }; timings?: { embedMs: number; scanMs: number } }> {
2574
4840
  const health = await this.checkEncoder()
2575
- if (!health.ok) return { ranking: [], diagnostic: { route: 'vector', status: 'degraded', reason: health.reason }, health }
4841
+ if (!health.ok) return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason: health.reason }, health }
2576
4842
  try {
2577
- const scores = await this.vectorRecall(query, eligibleIds, pool)
4843
+ const { scores, embedMs, scanMs } = await this.vectorRecall(query, eligibleIds, pool)
2578
4844
  const ranking = [...scores.entries()].map(([unitId, score]) => ({ unitId, score })).sort((left, right) => right.score - left.score || left.unitId.localeCompare(right.unitId))
2579
- const { kept, dropped, topScore } = applyThreshold(ranking, threshold)
4845
+ const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold)
2580
4846
  return {
2581
4847
  ranking: kept,
4848
+ rows,
2582
4849
  health,
4850
+ timings: { embedMs, scanMs },
2583
4851
  diagnostic: {
2584
4852
  route: 'vector', status: 'ran', candidates: ranking.length, kept: kept.length, topScore, threshold,
2585
4853
  ...(dropped > 0 ? { reason: `${dropped} 条低于向量阈值 ${threshold} 被丢弃` } : {}),
@@ -2588,7 +4856,7 @@ export class KnowledgeRuntime {
2588
4856
  } catch (cause) {
2589
4857
  const error = cause instanceof Error ? cause.message : String(cause)
2590
4858
  this.vectorError = error
2591
- return { ranking: [], diagnostic: { route: 'vector', status: 'degraded', reason: `向量召回不可用:${error}` }, health }
4859
+ return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason: `向量召回不可用:${error}` }, health }
2592
4860
  }
2593
4861
  }
2594
4862
 
@@ -2598,7 +4866,60 @@ export class KnowledgeRuntime {
2598
4866
  * 匹配逻辑与 0030 相同(词面匹配节点 title / id 与关系词,命中节点带出 1 跳邻居并衰减),
2599
4867
  * 但返回值只用于给**已经在候选池里**的条目加权 —— 它自己不能制造候选。
2600
4868
  */
2601
- private graphBoostRanking(query: string, eligible: WikiNote[], includeProblems: boolean): RouteRanking[] {
4869
+ /**
4870
+ * 字段级的归因图(`03_compiled/diagnosis_graph.json`)。
4871
+ *
4872
+ * 它只用于**给已经在候选池里的条目加权**(和卡片级图一样,不能自己制造候选)。
4873
+ * 用途是把「假设 → 检查点」「证据 → 假设」这两类卡片级图看不见的语义带进召回。
4874
+ */
4875
+ private diagnosisGraph: { nodes: Array<Record<string, unknown>>; edges: Array<Record<string, unknown>> } = { nodes: [], edges: [] }
4876
+
4877
+ /**
4878
+ * 归因图上按词面命中的节点 → 分数,含 1 跳衰减。
4879
+ *
4880
+ * 与卡片级那一段是**同一条算法、两份数据**:卡片级图里只有 `supported_by` /
4881
+ * `relates_to`,而字段级图里是十种语义边。分开算再按 `entityId` 合流,
4882
+ * 是因为两张图的节点含义不同(一个是卡片,一个是字段级节点),合成一张会让
4883
+ * 图谱 Tab 看到一张卡片散成七个点。
4884
+ */
4885
+ private diagnosisHits(queryTokens: string[]): Map<string, number> {
4886
+ const seed = new Map<string, number>()
4887
+ if (this.diagnosisGraph.nodes.length === 0) return seed
4888
+ for (const node of this.diagnosisGraph.nodes) {
4889
+ const title = String(node.title ?? '').toLocaleLowerCase()
4890
+ const id = String(node.id ?? '').toLocaleLowerCase()
4891
+ let score = 0
4892
+ for (const token of queryTokens) {
4893
+ if (title.includes(token)) score += 3
4894
+ else if (id.includes(token)) score += 2
4895
+ }
4896
+ if (score > 0) seed.set(String(node.id), score)
4897
+ }
4898
+ // 关系词命中:两端各得 1 分。中文提问不会写 `refutes`,所以走 RELATION_QUERY_WORDS。
4899
+ for (const edge of this.diagnosisGraph.edges) {
4900
+ const type = String(edge.kind ?? '')
4901
+ const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase()
4902
+ let score = 0
4903
+ for (const token of queryTokens) if (relation.includes(token)) score += 1
4904
+ if (score > 0) {
4905
+ seed.set(String(edge.source), Math.max(seed.get(String(edge.source)) ?? 0, score))
4906
+ seed.set(String(edge.target), Math.max(seed.get(String(edge.target)) ?? 0, score))
4907
+ }
4908
+ }
4909
+ if (seed.size === 0) return seed
4910
+ const expanded = new Map<string, number>(seed)
4911
+ for (const [id, score] of seed) {
4912
+ for (const edge of this.diagnosisGraph.edges) {
4913
+ const from = String(edge.source)
4914
+ const to = String(edge.target)
4915
+ if (from === id) expanded.set(to, Math.max(expanded.get(to) ?? 0, score * 0.5))
4916
+ else if (to === id) expanded.set(from, Math.max(expanded.get(from) ?? 0, score * 0.5))
4917
+ }
4918
+ }
4919
+ return expanded
4920
+ }
4921
+
4922
+ private async graphBoostRanking(query: string, eligible: WikiNote[], includeProblems: boolean): Promise<RouteRanking[]> {
2602
4923
  if (this.graph.nodes.length === 0) return []
2603
4924
  const queryTokens = segment(query)
2604
4925
  if (queryTokens.length === 0) return []
@@ -2606,6 +4927,9 @@ export class KnowledgeRuntime {
2606
4927
  const boost = (key: string, score: number): void => { byNodeKey.set(key, Math.max(byNodeKey.get(key) ?? 0, score)) }
2607
4928
  const visible = (node: Record<string, unknown>): boolean => includeProblems || node.status !== 'problem'
2608
4929
  const nodeKey = (node: Record<string, unknown>): string => `${String(node.type)}:${String(node.id)}`
4930
+ /** 卡片级节点 id → 它的 `entity_type`。归因图的节点 id 与卡片 id 相等(§14.1)。 */
4931
+ const typeByEntityId = new Map<string, string>()
4932
+ for (const node of this.graph.nodes) typeByEntityId.set(String(node.id), String(node.type))
2609
4933
 
2610
4934
  for (const node of this.graph.nodes) {
2611
4935
  if (!visible(node)) continue
@@ -2625,6 +4949,18 @@ export class KnowledgeRuntime {
2625
4949
  for (const token of queryTokens) if (relation.includes(token)) score += 1
2626
4950
  if (score > 0) { boost(String(edge.from), score); boost(String(edge.to), score) }
2627
4951
  }
4952
+ /**
4953
+ * **字段级的归因图**(0021 §14.4 的第二条最小路径)。
4954
+ *
4955
+ * 卡片级的图只认 `supported_by` 与 `relates_to` 两种关系词,所以「假设 → 检查点」
4956
+ * 与「证据 → 假设」这两类语义对召回完全不可见。这里按 `entityId` 把两张图接起来:
4957
+ * 归因图上命中的节点,映射回同 id 的卡片级节点加权 —— **不需要合图**,
4958
+ * 因为两张图的节点 id 本来就相等(§14.1)。
4959
+ */
4960
+ for (const [id, score] of this.diagnosisHits(queryTokens)) {
4961
+ const type = typeByEntityId.get(id)
4962
+ if (type) boost(`${type}:${id}`, score)
4963
+ }
2628
4964
  if (byNodeKey.size === 0) return []
2629
4965
  const expanded = new Map<string, number>(byNodeKey)
2630
4966
  for (const [key, score] of byNodeKey) {
@@ -2691,9 +5027,14 @@ export class KnowledgeRuntime {
2691
5027
  includeProblems = false,
2692
5028
  mode?: RecallMode,
2693
5029
  lineage?: LineageContext,
2694
- options: { contextTopN?: number; rerank?: boolean } = {},
5030
+ options: { contextTopN?: number; rerank?: boolean; contextMaxChars?: number; detail?: boolean } = {},
2695
5031
  ): Promise<Envelope<RecallResult>> {
2696
5032
  const recallId = generatedId('recall')
5033
+ /**
5034
+ * 各阶段耗时(0050 第二版)。用 `performance.now()` 而不是 `Date.now()`:
5035
+ * 向量扫描这一段本身就在毫秒以下,用墙钟分辨率量不出东西来。
5036
+ */
5037
+ const startedAt = performance.now()
2697
5038
  const recallMode = mode ?? this.config.recallMode
2698
5039
  const recallTopK = limit ?? this.config.recallTopK
2699
5040
  const requestedTopN = options.contextTopN ?? this.config.contextTopN
@@ -2749,37 +5090,70 @@ export class KnowledgeRuntime {
2749
5090
  const useVector = recallMode !== 'fts'
2750
5091
  const useGraphBoost = recallMode === 'hybrid' || recallMode === 'graph'
2751
5092
 
5093
+ const ftsStarted = performance.now()
2752
5094
  const ftsOutcome = useFts
2753
5095
  ? await this.ftsRoute(query, eligible, candidatePool, this.config.keywordThreshold, titleOf)
2754
- : { ranking: [] as RouteRanking[], diagnostic: { route: 'fts' as const, status: 'skipped' as const, reason: '当前模式未启用关键词路' } }
5096
+ : { ranking: [] as RouteRanking[], rows: [] as Array<RouteRanking & { kept: boolean }>, diagnostic: { route: 'fts' as const, status: 'skipped' as const, reason: '当前模式未启用关键词路' } }
5097
+ const ftsMs = performance.now() - ftsStarted
5098
+ const vectorStarted = performance.now()
2755
5099
  const vectorOutcome = useVector
2756
5100
  ? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold)
2757
- : { ranking: [] as RouteRanking[], diagnostic: { route: 'vector' as const, status: 'skipped' as const, reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } }
2758
- const graphRanking = useGraphBoost ? this.graphBoostRanking(query, eligible, includeProblems) : []
5101
+ : { ranking: [] as RouteRanking[], rows: [] as Array<RouteRanking & { kept: boolean }>, diagnostic: { route: 'vector' as const, status: 'skipped' as const, reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } }
5102
+ const vectorMs = performance.now() - vectorStarted
5103
+ const graphStarted = performance.now()
5104
+ const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems) : []
5105
+ const graphMs = performance.now() - graphStarted
2759
5106
 
2760
5107
  if (ftsOutcome.diagnostic.status === 'degraded' && ftsOutcome.diagnostic.reason) warnings.push(ftsOutcome.diagnostic.reason)
2761
5108
  if (vectorOutcome.diagnostic.status === 'degraded' && vectorOutcome.diagnostic.reason) warnings.push(vectorOutcome.diagnostic.reason)
2762
5109
 
2763
5110
  // --- R2 · 名次级融合(RRF)+ 图谱 boost ---------------------------------
5111
+ const fuseStarted = performance.now()
2764
5112
  const fused = rrfFuse({ fts: ftsOutcome.ranking, vector: vectorOutcome.ranking }, { k: RRF_K, weights: RRF_WEIGHTS })
2765
5113
  const boosted = graphBoost(fused, graphRanking, { k: RRF_K, weights: RRF_WEIGHTS })
2766
5114
 
2767
5115
  const unitById = new Map(eligible.map(unit => [unit.unitId, unit]))
5116
+ /**
5117
+ * 0044:review 评分在这里接进排序。
5118
+ *
5119
+ * 它**只改排序键,不改 `score` 字段本身** —— 命中里那个 `score` 仍然是融合分/重排分,
5120
+ * 是「这一路觉得它多相关」的原始答案。把评分乘进去再写回 `score`,会让读的人以为
5121
+ * 相关性变了;而它变的只是「同类命中里先看谁」。
5122
+ *
5123
+ * 生效点有两处,缺一处就会出现「review 了但排序没动」:
5124
+ * 1. 重排前的候选顺序(决定哪些条目进入重排的视线);
5125
+ * 2. 重排后的截断顺序(`recallTopK`)。
5126
+ *
5127
+ * 0050 之前还有第三处(上下文选择的 MMR 相关性函数)。现在上下文按**卡片**去重、
5128
+ * 顺序直接沿用 `hits`(`notesByWiki` 的顺序就是它),所以那第三处自然消失了 ——
5129
+ * 不是被删掉,而是不再需要单独算一遍。
5130
+ */
5131
+ const rankKey = (hit: RecallHit): number => (hit.rerankScore ?? hit.score) * reviewWeight(hit.reviewScore)
2768
5132
  let hits: RecallHit[] = boosted.hits
2769
5133
  .filter(hit => unitById.has(hit.unitId))
2770
5134
  .map(hit => ({ ...unitById.get(hit.unitId)!, score: hit.score, match: matchOf(hit.ranks), ranks: hit.ranks }))
5135
+ .sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId))
5136
+
5137
+ const fuseMs = performance.now() - fuseStarted
2771
5138
 
2772
5139
  // --- R3 · 对**候选池**重排,再过阈值 ------------------------------------
2773
5140
  let rerank: RerankResult
5141
+ const rerankStarted = performance.now()
5142
+ /** 逐条模型分(0050 的面板账本用):`kept` 只给活下来的,账本要全部。 */
5143
+ let rerankScored: Array<{ unitId: string; logit: number; rerankScore: number; kept: boolean }> = []
2774
5144
  if (options.rerank === false || this.config.rerankProvider === 'none') {
2775
5145
  rerank = { status: 'skipped', provider: this.config.rerankProvider }
2776
5146
  hits = hits.slice(0, recallTopK)
2777
5147
  } else {
2778
5148
  const reranked = await this.rerankNotes(query, hits, this.config.rerankThreshold)
5149
+ rerankScored = reranked.scored ?? []
2779
5150
  hits = reranked.hits.slice(0, recallTopK)
2780
5151
  rerank = reranked.result
2781
5152
  if (reranked.warning) warnings.push(reranked.warning)
2782
5153
  }
5154
+ // 重排换的是相关性顺序,评分加权仍然要压在最上面 —— 否则「review 过的排前面一点」
5155
+ // 只在关掉重排时成立,而重排默认是开着的。
5156
+ hits = [...hits].sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId))
2783
5157
 
2784
5158
  // --- R4 · 证据片段 -----------------------------------------------------
2785
5159
  await this.attachEvidence(hits, queryTerms, corpus)
@@ -2822,36 +5196,149 @@ export class KnowledgeRuntime {
2822
5196
  }
2823
5197
 
2824
5198
  // --- 组装:片段 → 整篇 → 证据 -------------------------------------------
5199
+ const assembleStarted = performance.now()
2825
5200
  const cards = this.cards.length > 0 ? this.cards : await this.loadCards()
2826
5201
  const cardByKey = new Map(cards.map(card => [`${card.entityType}:${card.entityId}`, card]))
2827
5202
  const wikiKey = (note: Pick<RecallHit, 'entityType' | 'entityId'>) => `${note.entityType}:${note.entityId}`
2828
- const wiki = [...new Set(hits.map(wikiKey))].map(key => cardByKey.get(key)).filter((card): card is KnowledgeCard => card !== undefined)
2829
- const graph = this.oneHopGraph(new Set(hits.map(wikiKey)), includeProblems)
2830
- // 上下文条目的选择**不是**简单切片:每张 wiki 封顶 + MMR 去冗余,否则 5 条上下文
2831
- // 可能全是同一张卡的不同小节(一屏塞满、信息量为 1)。
2832
- const contextHits = selectContext(hits, {
2833
- relevance: hit => hit.rerankScore ?? hit.score,
2834
- tokens: hit => tokenSet(`${hit.title}\n${hit.content}`),
2835
- wikiKey: hit => hit.entityId,
2836
- limit: contextTopN,
2837
- perWikiCap: CONTEXT_PER_WIKI_CAP,
2838
- })
2839
- const context: ContextItem[] = contextHits.map(note => ({
2840
- note,
2841
- wiki: cardByKey.get(wikiKey(note)) ?? null,
2842
- graph: this.oneHopGraph(new Set([wikiKey(note)]), includeProblems),
2843
- ...(note.evidence ? { evidence: note.evidence } : {}),
2844
- }))
5203
+
5204
+ /**
5205
+ * 按 **wiki 卡片**去重(0050 的第一半)。
5206
+ *
5207
+ * `hits` 已经按排序键降序,所以每张卡片第一次出现的那条就是它**最好的一条 note**
5208
+ * —— 那就是这张卡片的等级。同一张卡的其余小节挂在它下面(`notes`),
5209
+ * 不再各自占一个上下文名额。
5210
+ */
5211
+ const notesByWiki = new Map<string, RecallHit[]>()
5212
+ for (const hit of hits) {
5213
+ const key = wikiKey(hit)
5214
+ const bucket = notesByWiki.get(key)
5215
+ if (bucket) bucket.push(hit)
5216
+ else notesByWiki.set(key, [hit])
5217
+ }
5218
+ /**
5219
+ * 命中的卡片,顺序 = 各自最好那条 note 的顺序。
5220
+ *
5221
+ * `contextTopN` 约束的是**这里**:进上下文的命中卡片数。1 跳扩展来的卡片另算
5222
+ * (它们受上下文预算与 `MAX_EXPANSION_CARDS` 约束)—— 扩展是「延伸」,
5223
+ * 不该挤掉「证据」的名额,也不该被 top_n 卡死。
5224
+ */
5225
+ const hitCards = [...notesByWiki.entries()]
5226
+ .map(([key, notes]) => ({ key, notes, card: cardByKey.get(key) }))
5227
+ .slice(0, contextTopN)
5228
+ const wiki = hitCards.map(item => item.card).filter((card): card is KnowledgeCard => card !== undefined)
5229
+ const graph = this.oneHopGraph(new Set(hitCards.map(item => item.key)), includeProblems)
5230
+
5231
+ /**
5232
+ * **在去重之后**做 1 跳扩展(0050 的第二半)。
5233
+ *
5234
+ * 反过来(先扩展再去重)会把邻居与被命中的卡片混在一起,分不清「这是召回来的」
5235
+ * 还是「这是邻居」—— 而这两件事在上下文里必须能分开,前者是证据,后者是延伸。
5236
+ */
5237
+ const expansion = this.expandWiki(hitCards.map(item => item.key), cardByKey, includeProblems)
5238
+
5239
+ /** 上下文条目:先命中的卡片(带它们的 note),再扩展来的卡片。 */
5240
+ const items: ContextItem[] = [
5241
+ ...hitCards.map(item => ({
5242
+ wiki: item.card ?? null,
5243
+ notes: item.notes,
5244
+ note: item.notes[0]!,
5245
+ graph: this.oneHopGraph(new Set([item.key]), includeProblems),
5246
+ reason: 'hit' as const,
5247
+ ...(item.notes[0]!.evidence ? { evidence: item.notes[0]!.evidence } : {}),
5248
+ })),
5249
+ ...expansion.nodes.map(node => {
5250
+ const key = `${node.entityType}:${node.entityId}`
5251
+ const card = cardByKey.get(key) ?? null
5252
+ const [fromKey, relation] = (node.via[0] ?? '').split('|')
5253
+ const from = fromKey === undefined ? undefined : cardByKey.get(fromKey)
5254
+ return {
5255
+ wiki: card,
5256
+ notes: [],
5257
+ graph: this.oneHopGraph(new Set([key]), includeProblems),
5258
+ reason: 'expansion' as const,
5259
+ ...(from === undefined ? {} : { via: { entityType: from.entityType, entityId: from.entityId, title: from.title, relation: relation ?? '' } }),
5260
+ }
5261
+ }),
5262
+ ]
5263
+
5264
+ /**
5265
+ * 上下文预算(0050):装箱 + 溢出。
5266
+ *
5267
+ * 让位顺序写死在 `packContext` 里:先保证命中 note 的正文完整(它是证据),
5268
+ * 再给扩展卡片的标题 + 摘要 + 关系,最后才给扩展卡片的正文。
5269
+ */
5270
+ /**
5271
+ * 每次调用可以覆盖预算(0050):指路行里就写着「读不到就调大预算重新召回」,
5272
+ * 所以那条出路必须真的存在 —— 只给一个设置项的话,Agent 改不了它。
5273
+ */
5274
+ const maxChars = options.contextMaxChars ?? this.config.contextMaxChars
5275
+ const packed = await this.packContext({ items, maxChars, recallId })
5276
+ /** 哪些扩展卡片真的进了上下文(被预算挡住的也如实报出来)。 */
5277
+ expansion.included = packed.items
5278
+ .filter(item => item.reason === 'expansion' && item.wiki !== null)
5279
+ .map(item => `${item.wiki!.entityType}:${item.wiki!.entityId}`)
5280
+ if (packed.notice) warnings.push(packed.notice)
5281
+
5282
+ /**
5283
+ * 面板账本(0050):`detail: true` 才建。
5284
+ *
5285
+ * 它回答的是「这一条是怎么进来的、算了几分、有没有被丢」—— 而这三问分别对应
5286
+ * 关键词路 / 向量路 / 重排。工具调用不带它:几十行候选分数进模型上下文是纯噪声。
5287
+ */
5288
+ const ledger: RecallLedger | undefined = options.detail === true
5289
+ ? {
5290
+ fts: this.ledgerRows(ftsOutcome.rows, unitById),
5291
+ vector: this.ledgerRows(vectorOutcome.rows, unitById),
5292
+ rerank: (() => {
5293
+ const byUnit = new Map(rerankScored.map(row => [row.unitId, row]))
5294
+ const fusedByUnit = new Map(boosted.hits.map(hit => [hit.unitId, hit.score]))
5295
+ const order = rerankScored.length > 0 ? rerankScored.map(row => row.unitId) : hits.map(hit => hit.unitId)
5296
+ return order.flatMap((unitId, index) => {
5297
+ const note = unitById.get(unitId)
5298
+ if (note === undefined) return []
5299
+ const model = byUnit.get(unitId)
5300
+ return [{
5301
+ unitId,
5302
+ title: note.title,
5303
+ entityType: note.entityType,
5304
+ entityId: note.entityId,
5305
+ score: model?.rerankScore ?? fusedByUnit.get(unitId) ?? 0,
5306
+ rank: index + 1,
5307
+ kept: model?.kept ?? true,
5308
+ fused: fusedByUnit.get(unitId) ?? 0,
5309
+ ...(model === undefined ? {} : { logit: model.logit, rerankScore: model.rerankScore }),
5310
+ // 没有模型分就是「重排没跑」或「这条没进重排」;有模型分而没 kept 就是被阈值丢了。
5311
+ dropped: model !== undefined && !model.kept,
5312
+ }]
5313
+ })
5314
+ })(),
5315
+ }
5316
+ : undefined
5317
+
5318
+ /** 每个数都是**这一段自己**的耗时(不是累计),量级差得远时一眼能看出是谁慢。 */
5319
+ const timings: RecallTimings = {
5320
+ ftsMs: round1(ftsMs),
5321
+ vectorMs: round1(vectorMs),
5322
+ ...(vectorOutcome.timings === undefined ? {} : { embedMs: round1(vectorOutcome.timings.embedMs), scanMs: round1(vectorOutcome.timings.scanMs) }),
5323
+ graphMs: round1(graphMs),
5324
+ fuseMs: round1(fuseMs),
5325
+ ...(rerank.status === 'ready' ? { rerankMs: round1(performance.now() - rerankStarted) } : {}),
5326
+ // 组装从「按卡片去重」算起(重排之后),所以它不含重排自己的时间。
5327
+ assembleMs: round1(performance.now() - assembleStarted),
5328
+ totalMs: round1(performance.now() - startedAt),
5329
+ }
2845
5330
 
2846
5331
  const data: RecallResult = {
2847
- hits, context, wiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, contextTopN,
2848
- insufficientEvidence, diagnostics, recallId,
5332
+ hits, context: packed.items, wiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, contextTopN,
5333
+ insufficientEvidence, diagnostics, recallId, timings,
5334
+ expansion, budget: packed.report,
5335
+ ...(ledger === undefined ? {} : { ledger }),
2849
5336
  ...(indexVersion === undefined ? {} : { indexVersion }),
2850
5337
  ...(compileRunId === undefined ? {} : { compileRunId }),
2851
5338
  ...(eventLineage ? { lineage: eventLineage } : {}),
2852
5339
  }
2853
5340
  await this.audit('knowledge.recall', {
2854
- query: query.slice(0, 4000), count: hits.length, context: context.length, backend, routes: ranRoutes,
5341
+ query: query.slice(0, 4000), count: hits.length, context: packed.items.length, backend, routes: ranRoutes,
2855
5342
  rerank: rerank.status, rerankThreshold: this.config.rerankThreshold, candidatePool,
2856
5343
  ...(rerank.dropped === undefined ? {} : { rerankDropped: rerank.dropped }),
2857
5344
  ...(rerank.topLogit === undefined ? {} : { rerankTopLogit: Number(rerank.topLogit.toFixed(4)) }),
@@ -2873,11 +5360,16 @@ export class KnowledgeRuntime {
2873
5360
  * 2. **量纲**:返回**原始余弦**,不再做 `(cos+1)/2` 的映射 —— 那个映射让 `0.15` 这类阈值
2874
5361
  * 失去意义(余弦 0 → 0.5,轻松过线)。原始余弦是绝对量,可以直接用于弃答。
2875
5362
  */
2876
- private async vectorRecall(query: string, eligibleIds: Set<string>, candidateLimit: number): Promise<Map<string, number>> {
5363
+ private async vectorRecall(query: string, eligibleIds: Set<string>, candidateLimit: number): Promise<{ scores: Map<string, number>; embedMs: number; scanMs: number }> {
2877
5364
  const index = await this.loadVectorIndex()
2878
5365
  if (!index || index.manifest.itemCount === 0) throw new Error('VECTOR_INDEX_NOT_READY')
2879
5366
  if (index.manifest.provider !== this.config.embeddingProvider || index.manifest.model !== this.embeddingModelId || index.manifest.dimension !== this.config.embeddingDimension) throw new Error('VECTOR_INDEX_CONFIG_MISMATCH')
5367
+ // 查询编码单独计时:模型没加载时这一段是 8 秒,加载之后是 0.3 秒 —— 这一段的
5368
+ // 数字就是「测试为什么慢」的答案本身,混进扫描时间里就看不出来了。
5369
+ const embedStarted = performance.now()
2880
5370
  const encoded = await this.encodeEmbeddings([{ text: query }])
5371
+ const embedMs = performance.now() - embedStarted
5372
+ const scanStarted = performance.now()
2881
5373
  const queryBinary = binaryVector(encoded.vectors[0]!, index.manifest.dimension)
2882
5374
  const queryInt8 = int8Vector(encoded.vectors[0]!, index.manifest.dimension)
2883
5375
  const binaryWidth = Math.ceil(index.manifest.dimension / 8)
@@ -2902,7 +5394,7 @@ export class KnowledgeRuntime {
2902
5394
  for (let dimension = 0; dimension < index.manifest.dimension; dimension += 1) dot += signedByte(queryInt8[dimension]!) * signedByte(index.int8[offset + dimension]!)
2903
5395
  scores.set(item.unitId, Math.max(-1, Math.min(1, dot / (127 * 127))))
2904
5396
  }
2905
- return scores
5397
+ return { scores, embedMs, scanMs: performance.now() - scanStarted }
2906
5398
  }
2907
5399
 
2908
5400
  /**
@@ -2972,7 +5464,7 @@ export class KnowledgeRuntime {
2972
5464
  content,
2973
5465
  // 索引命中不了时退回按 id + 分类算出来的路径:那条路径**就是**写入时用的,
2974
5466
  // 所以它仍然指向真实文件(索引缺失只可能是并发写入还没失效缓存)。
2975
- path: index.get(slug(source.sourceId)) ?? this.sourcePath(source.sourceId, source.sourceType),
5467
+ path: index.get(slug(source.sourceId)) ?? this.sourcePath(source.sourceId, source.sourceType, source.title),
2976
5468
  returnedChars: content.length,
2977
5469
  totalChars: source.content.length,
2978
5470
  })
@@ -2984,8 +5476,25 @@ export class KnowledgeRuntime {
2984
5476
 
2985
5477
  async graphQuery(action: 'entity' | 'neighbors' | 'path' | 'list' | 'types', entityId?: string, entityType?: string, depth = 1, options: { fromId?: string; toId?: string; status?: string; includeProblem?: boolean; limit?: number } = {}, lineage?: LineageContext): Promise<Envelope<Record<string, unknown>>> {
2986
5478
  const eventLineage = withRun(lineage, 'graph-query')
2987
- if (this.units.length === 0) await this.rebuild('all', eventLineage)
5479
+ /**
5480
+ * **读盘,不编译。**
5481
+ *
5482
+ * `units` 为空只说明这个 runtime 还没读过 `03_compiled/`,**不等于**盘上没有编译
5483
+ * 产物:runtime 是按库缓存的,设置一变就被丢掉(`KnowledgeBases.invalidate()`),
5484
+ * 下一次读图时内存里又是空的。原来的写法把这两种情况当成同一件事,在这里直接
5485
+ * `rebuild('all')` —— 于是一次**读**变成一次全量编译:面板刷新时那个
5486
+ * `GET /knowledge/graph` 在几百篇文档的库上要等 3~5 秒,而这段时间整个知识库面板
5487
+ * 连同「类型定义」都是不可操作的。
5488
+ *
5489
+ * 编译是**写**动作,归「编译」按钮与 `POST /knowledge/rebuild`。读路径只如实报告
5490
+ * 「盘上还没有编译产物」(`needsBuild`),要不要编由界面和 Agent 自己决定 ——
5491
+ * 与 `recall` 同一条规矩:先读盘,读不到再说。
5492
+ */
5493
+ if (this.units.length === 0 && this.graph.nodes.length === 0) await this.loadCompiledState()
5494
+ const needsBuild = this.units.length === 0 && this.graph.nodes.length === 0
2988
5495
  const attachLineage = (data: Record<string, unknown>): Record<string, unknown> => eventLineage ? { ...data, lineage: eventLineage } : data
5496
+ /** 空图 + 一句为什么空:调用方(尤其是 Agent)不能把「还没编译」读成「这个库没有知识」。 */
5497
+ const attachBuild = (data: Record<string, unknown>): Record<string, unknown> => needsBuild ? { ...data, needsBuild: true } : data
2989
5498
  if (depth < 1 || depth > 3) return failure({}, 'DEPTH_INVALID', 'depth 必须在 1 到 3 之间', false)
2990
5499
  const limit = options.limit ?? 50
2991
5500
  if (!Number.isInteger(limit) || limit < 1 || limit > 50) return failure({}, 'LIMIT_INVALID', 'limit 必须在 1 到 50 之间', false)
@@ -2998,7 +5507,7 @@ export class KnowledgeRuntime {
2998
5507
  .filter(node => !options.status || node.status === options.status)
2999
5508
  const nodes = allNodes.filter(node => !entityType || node.type === entityType)
3000
5509
  const edges = this.graph.edges.filter(edge => allNodes.some(node => `${node.type}:${node.id}` === String(edge.from)) && allNodes.some(node => `${node.type}:${node.id}` === String(edge.to)))
3001
- if (action === 'list') return success(attachLineage({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) }))
5510
+ if (action === 'list') return success(attachLineage(attachBuild({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) })))
3002
5511
 
3003
5512
  const nodeKey = (rawId: string | undefined, rawType?: string): string | undefined => {
3004
5513
  if (!rawId) return undefined
@@ -3024,20 +5533,20 @@ export class KnowledgeRuntime {
3024
5533
  })
3025
5534
  for (const next of nextKeys) if (!visited.has(next)) { visited.add(next); queue.push({ key: next, path: [...current.path, next] }) }
3026
5535
  }
3027
- if (!found) return success(attachLineage({ path: [], nodes: [], edges: [] }))
5536
+ if (!found) return success(attachLineage(attachBuild({ path: [], nodes: [], edges: [] })))
3028
5537
  return success(attachLineage({ path: found, nodes: allNodes.filter(node => found!.includes(`${node.type}:${node.id}`)), edges: edges.filter(edge => found!.includes(String(edge.from)) && found!.includes(String(edge.to))) }))
3029
5538
  }
3030
5539
 
3031
5540
  const id = nodeKey(entityId, entityType)
3032
5541
  if (!id) return failure({}, 'ENTITY_REQUIRED', '该 graph action 需要 entity_id', true)
3033
- if (action === 'entity') return success(attachLineage({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) }))
5542
+ if (action === 'entity') return success(attachLineage(attachBuild({ nodes: nodes.filter(node => `${node.type}:${node.id}` === id).slice(0, limit) })))
3034
5543
  if (action === 'neighbors') {
3035
5544
  const ids = new Set([id])
3036
5545
  for (let index = 0; index < depth; index++) for (const edge of edges) {
3037
5546
  if (ids.has(String(edge.from))) ids.add(String(edge.to))
3038
5547
  if (ids.has(String(edge.to))) ids.add(String(edge.from))
3039
5548
  }
3040
- return success(attachLineage({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) }))
5549
+ return success(attachLineage(attachBuild({ nodes: allNodes.filter(node => ids.has(`${node.type}:${node.id}`)).slice(0, limit), edges: edges.filter(edge => ids.has(String(edge.from)) || ids.has(String(edge.to))).slice(0, limit) })))
3041
5550
  }
3042
5551
  return failure({}, 'ACTION_INVALID', `不支持的 graph action: ${action}`, false)
3043
5552
  }