dsh-data-domain-knowledge 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (147) hide show
  1. package/README.md +64 -13
  2. package/eval/README.md +19 -8
  3. package/eval/evaluate-retrieval.mjs +17 -8
  4. package/eval/queries.example.jsonl +1 -0
  5. package/lib/client.js +2294 -2096
  6. package/lib/doc-page.js +1 -1
  7. package/lib/pages.js +2 -2
  8. package/lib/types/axis-registry.d.ts +9 -4
  9. package/lib/types/axis-registry.d.ts.map +1 -1
  10. package/lib/types/axis-registry.js +62 -23
  11. package/lib/types/axis-registry.js.map +1 -1
  12. package/lib/types/card-organize.d.ts +5 -0
  13. package/lib/types/card-organize.d.ts.map +1 -1
  14. package/lib/types/card-organize.js +15 -0
  15. package/lib/types/card-organize.js.map +1 -1
  16. package/lib/types/compiler/embedding-identity.d.ts +3 -0
  17. package/lib/types/compiler/embedding-identity.d.ts.map +1 -0
  18. package/lib/types/compiler/embedding-identity.js +24 -0
  19. package/lib/types/compiler/embedding-identity.js.map +1 -0
  20. package/lib/types/compiler/snapshot.d.ts +16 -0
  21. package/lib/types/compiler/snapshot.d.ts.map +1 -0
  22. package/lib/types/compiler/snapshot.js +2 -0
  23. package/lib/types/compiler/snapshot.js.map +1 -0
  24. package/lib/types/diagnosis/store.d.ts.map +1 -1
  25. package/lib/types/diagnosis/store.js +2 -0
  26. package/lib/types/diagnosis/store.js.map +1 -1
  27. package/lib/types/environment/install.d.ts +7 -0
  28. package/lib/types/environment/install.d.ts.map +1 -0
  29. package/lib/types/environment/install.js +172 -0
  30. package/lib/types/environment/install.js.map +1 -0
  31. package/lib/types/environment/manifest.d.ts +208 -0
  32. package/lib/types/environment/manifest.d.ts.map +1 -0
  33. package/lib/types/environment/manifest.js +26 -0
  34. package/lib/types/environment/manifest.js.map +1 -0
  35. package/lib/types/environment/model-catalog.d.ts +191 -0
  36. package/lib/types/environment/model-catalog.d.ts.map +1 -0
  37. package/lib/types/environment/model-catalog.js +91 -0
  38. package/lib/types/environment/model-catalog.js.map +1 -0
  39. package/lib/types/environment/process.d.ts +10 -0
  40. package/lib/types/environment/process.d.ts.map +1 -0
  41. package/lib/types/environment/process.js +54 -0
  42. package/lib/types/environment/process.js.map +1 -0
  43. package/lib/types/environment/selection.d.ts +9 -0
  44. package/lib/types/environment/selection.d.ts.map +1 -0
  45. package/lib/types/environment/selection.js +29 -0
  46. package/lib/types/environment/selection.js.map +1 -0
  47. package/lib/types/environment/service.d.ts +47 -0
  48. package/lib/types/environment/service.d.ts.map +1 -0
  49. package/lib/types/environment/service.js +253 -0
  50. package/lib/types/environment/service.js.map +1 -0
  51. package/lib/types/environment/types.d.ts +37 -0
  52. package/lib/types/environment/types.d.ts.map +1 -0
  53. package/lib/types/environment/types.js +2 -0
  54. package/lib/types/environment/types.js.map +1 -0
  55. package/lib/types/events.d.ts +10 -0
  56. package/lib/types/events.d.ts.map +1 -1
  57. package/lib/types/events.js +38 -1
  58. package/lib/types/events.js.map +1 -1
  59. package/lib/types/images.d.ts +26 -0
  60. package/lib/types/images.d.ts.map +1 -0
  61. package/lib/types/images.js +196 -0
  62. package/lib/types/images.js.map +1 -0
  63. package/lib/types/index.d.ts.map +1 -1
  64. package/lib/types/index.js +59 -3
  65. package/lib/types/index.js.map +1 -1
  66. package/lib/types/knowledge-bases.d.ts +3 -0
  67. package/lib/types/knowledge-bases.d.ts.map +1 -1
  68. package/lib/types/knowledge-bases.js +9 -0
  69. package/lib/types/knowledge-bases.js.map +1 -1
  70. package/lib/types/knowledge.d.ts +14 -6
  71. package/lib/types/knowledge.d.ts.map +1 -1
  72. package/lib/types/knowledge.js +56 -39
  73. package/lib/types/knowledge.js.map +1 -1
  74. package/lib/types/layout.d.ts +2 -0
  75. package/lib/types/layout.d.ts.map +1 -1
  76. package/lib/types/layout.js +2 -0
  77. package/lib/types/layout.js.map +1 -1
  78. package/lib/types/retrieval/vector-index.d.ts +31 -0
  79. package/lib/types/retrieval/vector-index.d.ts.map +1 -0
  80. package/lib/types/retrieval/vector-index.js +15 -0
  81. package/lib/types/retrieval/vector-index.js.map +1 -0
  82. package/lib/types/retrieval/visibility.d.ts +6 -0
  83. package/lib/types/retrieval/visibility.d.ts.map +1 -0
  84. package/lib/types/retrieval/visibility.js +11 -0
  85. package/lib/types/retrieval/visibility.js.map +1 -0
  86. package/lib/types/runtime.d.ts +36 -6
  87. package/lib/types/runtime.d.ts.map +1 -1
  88. package/lib/types/runtime.js +520 -190
  89. package/lib/types/runtime.js.map +1 -1
  90. package/lib/types/source-evidence.d.ts +6 -0
  91. package/lib/types/source-evidence.d.ts.map +1 -0
  92. package/lib/types/source-evidence.js +28 -0
  93. package/lib/types/source-evidence.js.map +1 -0
  94. package/lib/types/store/axis-references.d.ts +5 -0
  95. package/lib/types/store/axis-references.d.ts.map +1 -0
  96. package/lib/types/store/axis-references.js +106 -0
  97. package/lib/types/store/axis-references.js.map +1 -0
  98. package/lib/types/store/derived.d.ts +11 -0
  99. package/lib/types/store/derived.d.ts.map +1 -1
  100. package/lib/types/store/derived.js +31 -6
  101. package/lib/types/store/derived.js.map +1 -1
  102. package/lib/types/store/knowledge-db.d.ts +4 -1
  103. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  104. package/lib/types/store/knowledge-db.js +15 -9
  105. package/lib/types/store/knowledge-db.js.map +1 -1
  106. package/lib/types/store/migrate.d.ts.map +1 -1
  107. package/lib/types/store/migrate.js +16 -5
  108. package/lib/types/store/migrate.js.map +1 -1
  109. package/lib/types/types.d.ts +7 -1
  110. package/lib/types/types.d.ts.map +1 -1
  111. package/lib/types/types.js +3 -4
  112. package/lib/types/types.js.map +1 -1
  113. package/package.json +3 -1
  114. package/python/prepare-models.py +19 -0
  115. package/python/requirements.txt +8 -6
  116. package/scripts/doctor.mjs +14 -2
  117. package/scripts/environment.mjs +29 -0
  118. package/scripts/setup-embedding.sh +11 -2
  119. package/src/axis-registry.ts +59 -23
  120. package/src/card-organize.ts +15 -0
  121. package/src/client/EnvironmentSetup.tsx +145 -0
  122. package/src/client/KnowledgePanel.tsx +9 -4
  123. package/src/compiler/embedding-identity.ts +21 -0
  124. package/src/compiler/snapshot.ts +17 -0
  125. package/src/diagnosis/store.ts +1 -0
  126. package/src/environment/install.ts +129 -0
  127. package/src/environment/manifest.ts +28 -0
  128. package/src/environment/model-catalog.ts +105 -0
  129. package/src/environment/process.ts +54 -0
  130. package/src/environment/selection.ts +29 -0
  131. package/src/environment/service.ts +211 -0
  132. package/src/environment/types.ts +34 -0
  133. package/src/events.ts +46 -0
  134. package/src/images.ts +203 -0
  135. package/src/index.ts +54 -3
  136. package/src/knowledge-bases.ts +9 -0
  137. package/src/knowledge.ts +56 -40
  138. package/src/layout.ts +2 -0
  139. package/src/retrieval/vector-index.ts +44 -0
  140. package/src/retrieval/visibility.ts +14 -0
  141. package/src/runtime.ts +473 -233
  142. package/src/source-evidence.ts +30 -0
  143. package/src/store/axis-references.ts +96 -0
  144. package/src/store/derived.ts +31 -6
  145. package/src/store/knowledge-db.ts +16 -9
  146. package/src/store/migrate.ts +15 -5
  147. package/src/types.ts +10 -5
@@ -13,6 +13,7 @@ import { MAX_LOG_ITEMS, OperationLog, operationId } from './oplog.js';
13
13
  import { ExtractionRunStore, extractionRunId } from './extraction-runs.js';
14
14
  import { AxisRegistry, normalizeAxisName } from './axis-registry.js';
15
15
  import { KNOWLEDGE_STATE_LABEL, KnowledgeStore } from './knowledge.js';
16
+ import { compositeEventIdentity, eventEntityFromRecords } from './events.js';
16
17
  import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandidates, RELATES_TO } from './extract.js';
17
18
  import { skeletonBody } from './extract-model.js';
18
19
  import { applyTrialAxisResolution, normalizeKnowledge, runWikiTrial, trialSummary } from './wiki-trial.js';
@@ -22,14 +23,22 @@ import { FTS_FILE, loadSqlite, segment } from './fts.js';
22
23
  import { attachImagesToKnowledge, insertImage, planSourceImages } from './source-images.js';
23
24
  import { buildWikiIndex, cardKey, deadLinksOf, planRelated, relatedEntriesOf, stripRelatedSection, RELATED_METADATA_KEY, } from './wiki-finalize.js';
24
25
  import { applyRerank, applyThreshold, evidenceExcerpts, graphBoost, matchOf, noteIndexText, reviewWeight, rrfFuse, tokenSet } from './recall-rank.js';
25
- import { ASSETS_DIR, KNOWLEDGE_DB_FILE, SOURCE_RAW_DIR, defaultAgentRoot, knowledgeBaseRoot } from './layout.js';
26
+ import { ASSETS_DIR, KNOWLEDGE_DB_FILE, PUBLISHED_IMAGE_MARKER, SOURCE_RAW_DIR, defaultAgentRoot, knowledgeBaseRoot } from './layout.js';
26
27
  import { KnowledgeDb } from './store/knowledge-db.js';
27
28
  import { exportKnowledgeTree as exportTree, migrateKnowledgeTree } from './store/migrate.js';
29
+ import { COMPILE_SNAPSHOT } from './compiler/snapshot.js';
30
+ import { decodeVectorIndex } from './retrieval/vector-index.js';
31
+ import { liveGraphKeys, visibleGraph } from './retrieval/visibility.js';
32
+ import { embeddingFingerprint } from './compiler/embedding-identity.js';
28
33
  import { DerivedDb, DERIVED_DB_FILE } from './store/derived.js';
29
34
  import { cardVersions as viewCardVersions, documentDetail as viewDocumentDetail, documentList as viewDocumentList, documentVersions as viewDocumentVersions } from './store/views.js';
35
+ import { assessKnowledgeEvidence, hasCurrentSupport } from './source-evidence.js';
36
+ import { redirectAxisReferences } from './store/axis-references.js';
30
37
  import { unlinkIfExists, writeTextAtomic } from './fs-atomic.js';
31
38
  import { envelopeFailure, envelopeSuccess } from 'dsh-data-contracts/envelope';
32
39
  import { analyzeQuery, relationCandidates } from './query-analysis.js';
40
+ import { readModelSelection, selectedEmbedding } from './environment/selection.js';
41
+ import { rerankModel as rerankModelOption } from './environment/model-catalog.js';
33
42
  // **知识树只有三段**(v1.1 起):`01_source_raw` / `02_wiki` / `03_compiled`。
34
43
  //
35
44
  // 撤掉的两段,理由都写在这里,避免下一个人重新加回来:
@@ -405,12 +414,18 @@ function withTitleAlias(metadata, previousTitle, nextTitle) {
405
414
  * 同一句话在上下文里出现很多遍。完整出处在知识接口里。
406
415
  */
407
416
  function recallKnowledgeOf(row, section) {
408
- const first = row.sources.find(source => source.quote.trim() !== '') ?? row.sources[0];
417
+ const first = row.sources.find(source => source.evidenceStatus === 'current' && source.quoteInCurrentVersion)
418
+ ?? row.sources.find(source => source.quote.trim() !== '') ?? row.sources[0];
409
419
  return {
410
420
  id: row.id,
411
421
  text: row.text,
412
422
  state: row.status,
413
423
  section,
424
+ claimVerification: 'unverified',
425
+ requiresReview: !hasCurrentSupport(row),
426
+ ...(first?.evidenceStatus ? { evidenceStatus: first.evidenceStatus } : {}),
427
+ ...(first?.toVersion === undefined ? {} : { sourceVersion: first.toVersion }),
428
+ ...(first?.currentVersion === undefined ? {} : { currentSourceVersion: first.currentVersion }),
414
429
  ...(first?.quote ? { quote: first.quote } : {}),
415
430
  ...(first?.documentId ? { sourceId: first.documentId } : {}),
416
431
  ...(first && first.headingPath.length > 0 ? { headingPath: first.headingPath } : {}),
@@ -701,42 +716,6 @@ function withEventReferences(content, rawRefs, eventsById) {
701
716
  });
702
717
  return `${base ? `${base}\n\n` : ''}## 关联 Event\n\n${bullets.join('\n')}`;
703
718
  }
704
- function eventEntityFromRecords(input) {
705
- const { id, identityKey, identityType, records } = input;
706
- const first = records[0];
707
- const firstCoordinate = records.find(record => record.coordinateIds.objectId || record.coordinateIds.actionId || record.coordinateIds.sceneId);
708
- const sources = new Map();
709
- for (const record of records) {
710
- for (const source of record.sources)
711
- sources.set(`${source.documentId}\u0001${source.quote}`, source);
712
- }
713
- return {
714
- id,
715
- identityKey,
716
- identityType,
717
- externalId: records.find(record => record.externalId)?.externalId ?? null,
718
- title: first?.title ?? id,
719
- summary: records.find(record => record.summary.trim())?.summary ?? first?.title ?? id,
720
- coordinate: {
721
- object: records.find(record => record.coordinate.object)?.coordinate.object ?? null,
722
- action: records.find(record => record.coordinate.action)?.coordinate.action ?? null,
723
- scene: records.find(record => record.coordinate.scene)?.coordinate.scene ?? null,
724
- },
725
- coordinateIds: firstCoordinate?.coordinateIds ?? { objectId: '', actionId: '', sceneId: '' },
726
- occurredAt: records.find(record => record.occurredAt)?.occurredAt ?? null,
727
- symptom: records.find(record => record.symptom)?.symptom ?? null,
728
- timeline: records.flatMap(record => record.timeline.map(step => ({ ...step, sourceId: record.sourceId }))),
729
- rootCauses: records.flatMap(record => record.rootCause
730
- ? [{ text: record.rootCause, status: record.rootCauseStatus, sourceId: record.sourceId }]
731
- : []),
732
- outcomes: records.flatMap(record => record.outcome ? [{ text: record.outcome, sourceId: record.sourceId }] : []),
733
- missing: [...new Set(records.flatMap(record => record.missing))],
734
- sources: [...sources.values()],
735
- records,
736
- createdAt: input.createdAt,
737
- updatedAt: input.updatedAt ?? new Date().toISOString(),
738
- };
739
- }
740
719
  /** 把 `incoming` 按小节折进 `existing`;结构对不上时整段追加。 */
741
720
  function foldBody(existing, incoming) {
742
721
  if (!existing)
@@ -1410,8 +1389,8 @@ class JsonLineWorker {
1410
1389
  /**
1411
1390
  * 解析三个「家当」路径(0029)。
1412
1391
  *
1413
- * 优先级:**配置里显式写的值**(由构造函数处理)> `<agentRoot>/resource/…`(存在时)
1414
- * > 兜底(`python3` / HF 模型名)。
1392
+ * 资源目录内的隔离解释器优先于 DSH_PYTHON;自定义配置路径仍由构造函数处理。
1393
+ * 未准备资源时回退到 DSH_PYTHON、`python3` 或 HF 模型名。
1415
1394
  *
1416
1395
  * `modelId` 把本地模型的**身份**与它的位置解耦,避免同一 Agent 根目录下的
1417
1396
  * resource 目录变化导致向量清单误判为配置不匹配。
@@ -1425,15 +1404,22 @@ function resolveResources(resourceRoot) {
1425
1404
  return fallback;
1426
1405
  };
1427
1406
  const python = firstExisting([
1407
+ root && process.platform === 'win32' ? join(root, 'python', 'Scripts', 'python.exe') : undefined,
1428
1408
  root ? join(root, 'python', 'bin', 'python') : undefined,
1429
- ], 'python3');
1409
+ ], process.env.DSH_PYTHON || 'python3');
1410
+ const selected = selectedEmbedding(root);
1411
+ const selection = root ? readModelSelection(root) : undefined;
1412
+ const selectedRerank = root && selection ? join(root, 'models', rerankModelOption(selection.rerank).directory) : undefined;
1430
1413
  const embeddingModel = firstExisting([
1414
+ selected?.path,
1431
1415
  root ? join(root, 'models', PREFERRED_LOCAL_EMBEDDING_MODEL_DIR) : undefined,
1432
1416
  root ? join(root, 'models', LOCAL_EMBEDDING_MODEL_DIR) : undefined,
1433
1417
  ], FALLBACK_EMBEDDING_MODEL);
1434
1418
  const rerankModel = firstExisting([
1419
+ selectedRerank,
1435
1420
  root ? join(root, 'models', LOCAL_RERANK_MODEL_DIR) : undefined,
1436
1421
  ], FALLBACK_RERANK_MODEL);
1422
+ const embeddingDimension = selected?.path && embeddingModel === selected.path ? selected.dimension : undefined;
1437
1423
  const modelId = (path) => {
1438
1424
  for (const base of [root]) {
1439
1425
  if (!base || !path.startsWith(`${base}${sep}`))
@@ -1443,7 +1429,13 @@ function resolveResources(resourceRoot) {
1443
1429
  }
1444
1430
  return path;
1445
1431
  };
1446
- return { python, embeddingModel, rerankModel, modelId };
1432
+ return { python, embeddingModel, rerankModel, embeddingDimension, modelId };
1433
+ }
1434
+ /** An inherited DSH_PYTHON value selects the base interpreter, not the dependency environment. */
1435
+ function resolveConfiguredPython(configured, resourcePython) {
1436
+ const selected = configured?.trim();
1437
+ const hostPython = process.env.DSH_PYTHON?.trim();
1438
+ return selected && selected !== hostPython ? selected : resourcePython;
1447
1439
  }
1448
1440
  class CompileTaskCancelledError extends Error {
1449
1441
  constructor() {
@@ -1504,6 +1496,7 @@ function blankProgress(runId, only, startedAt, state = 'running', queuedAt = sta
1504
1496
  };
1505
1497
  }
1506
1498
  export class KnowledgeRuntime {
1499
+ environmentGuard;
1507
1500
  /** 当前生效的配置。volatile 改动会就地合并进来(见 `updateConfig`),所以它不是只读的。 */
1508
1501
  config;
1509
1502
  rootDir;
@@ -1599,6 +1592,8 @@ export class KnowledgeRuntime {
1599
1592
  */
1600
1593
  derived;
1601
1594
  derivedProblem;
1595
+ publication;
1596
+ recallSnapshotCache;
1602
1597
  /** 段内布局整理是否已经跑过(每个 runtime 一次)。 */
1603
1598
  segmentsOrganized = false;
1604
1599
  /**
@@ -1632,6 +1627,7 @@ export class KnowledgeRuntime {
1632
1627
  vectorIndex = null;
1633
1628
  vectorError;
1634
1629
  embeddingWorker = null;
1630
+ embeddingWorkerFingerprint;
1635
1631
  /** Separate process so a rerank model failure cannot take embeddings down. */
1636
1632
  rerankWorker = null;
1637
1633
  /** Cache the first rerank failure; do not respawn a doomed model per recall. */
@@ -1650,6 +1646,7 @@ export class KnowledgeRuntime {
1650
1646
  * 收到一次全量重编码。清单该记「是哪一个模型」,不是「它在哪」。
1651
1647
  */
1652
1648
  embeddingModelId;
1649
+ resourceRoot;
1653
1650
  /**
1654
1651
  * 这个知识库的记忆段(0032)。
1655
1652
  *
@@ -1671,9 +1668,13 @@ export class KnowledgeRuntime {
1671
1668
  this.extractionNoteProvider = options.extractionNote;
1672
1669
  this.memoryDir = options.memoryRoot ? resolve(options.memoryRoot) : join(this.rootDir, '04_memory');
1673
1670
  this.staticModelCall = options.modelCall;
1671
+ this.environmentGuard = options.environmentGuard;
1674
1672
  this.modelSeamProvider = options.modelSeam;
1673
+ this.resourceRoot = options.resourceRoot;
1675
1674
  const resources = resolveResources(options.resourceRoot);
1676
- this.pythonPath = config.python?.trim() || resources.python;
1675
+ if (!this.config.embeddingModel?.trim() && resources.embeddingDimension)
1676
+ this.config.embeddingDimension = resources.embeddingDimension;
1677
+ this.pythonPath = resolveConfiguredPython(config.python, resources.python);
1677
1678
  this.embeddingModelPath = config.embeddingModel?.trim() || resources.embeddingModel;
1678
1679
  this.rerankModelPath = config.rerankModel?.trim() || resources.rerankModel;
1679
1680
  this.embeddingModelId = resources.modelId(this.embeddingModelPath);
@@ -1684,6 +1685,9 @@ export class KnowledgeRuntime {
1684
1685
  * **每次现问**,不是在构造时定死:插件 apply 的时刻早于 `agent-default-model` 挂载,
1685
1686
  * 那时问一次只会得到 undefined,而定死之后就再也不会变(见 `modelCallProvider`)。
1686
1687
  */
1688
+ embeddingFingerprint() {
1689
+ return embeddingFingerprint(this.embeddingModelPath, this.resourceRoot, this.config.embeddingProvider === 'ollama' ? this.config.embeddingBaseUrl : undefined);
1690
+ }
1687
1691
  modelSeamNow() {
1688
1692
  if (this.modelSeamProvider)
1689
1693
  return this.modelSeamProvider(this.baseId);
@@ -1708,10 +1712,16 @@ export class KnowledgeRuntime {
1708
1712
  updateConfig(patch) {
1709
1713
  const previous = this.config;
1710
1714
  this.config = { ...previous, ...patch };
1715
+ const resources = resolveResources(this.resourceRoot);
1716
+ // A managed model preset owns its output dimension. Custom model paths keep
1717
+ // the advanced dimension setting from the plugin config.
1718
+ if (!this.config.embeddingModel?.trim() && resources.embeddingDimension)
1719
+ this.config.embeddingDimension = resources.embeddingDimension;
1711
1720
  const modelChanged = previous.embeddingProvider !== this.config.embeddingProvider ||
1712
1721
  previous.embeddingModel !== this.config.embeddingModel ||
1713
1722
  previous.embeddingDimension !== this.config.embeddingDimension ||
1714
1723
  previous.embeddingDevice !== this.config.embeddingDevice ||
1724
+ previous.embeddingBaseUrl !== this.config.embeddingBaseUrl ||
1715
1725
  previous.python !== this.config.python;
1716
1726
  const rerankChanged = previous.rerankProvider !== this.config.rerankProvider ||
1717
1727
  previous.rerankModel !== this.config.rerankModel ||
@@ -1722,18 +1732,19 @@ export class KnowledgeRuntime {
1722
1732
  // 旧索引建在旧模型/旧维度下,留着它只会给出没有意义的相似度。
1723
1733
  this.vectorIndex = null;
1724
1734
  this.vectorError = undefined;
1735
+ this.encoderHealth = undefined;
1725
1736
  }
1726
1737
  if (rerankChanged) {
1727
1738
  this.rerankWorker?.close();
1728
1739
  this.rerankWorker = null;
1729
1740
  this.rerankUnavailable = undefined;
1730
1741
  }
1731
- if (patch.python !== undefined)
1732
- this.pythonPath = patch.python.trim() || this.pythonPath;
1742
+ this.pythonPath = resolveConfiguredPython(this.config.python, resources.python);
1733
1743
  if (patch.embeddingModel !== undefined)
1734
- this.embeddingModelPath = patch.embeddingModel.trim() || this.embeddingModelPath;
1744
+ this.embeddingModelPath = patch.embeddingModel.trim() || resources.embeddingModel;
1735
1745
  if (patch.rerankModel !== undefined)
1736
- this.rerankModelPath = patch.rerankModel.trim() || this.rerankModelPath;
1746
+ this.rerankModelPath = patch.rerankModel.trim() || resources.rerankModel;
1747
+ this.embeddingModelId = resources.modelId(this.embeddingModelPath);
1737
1748
  }
1738
1749
  // ------------------------------------------------- 固定卡片模板
1739
1750
  /** 当前系统固定的卡片模板;不再按知识库维护卡片类型注册表。 */
@@ -2200,22 +2211,7 @@ export class KnowledgeRuntime {
2200
2211
  if (this.vectorIndex)
2201
2212
  return this.vectorIndex;
2202
2213
  try {
2203
- const loaded = (await this.derivedHandle())?.vectorIndex();
2204
- if (loaded === undefined)
2205
- return null;
2206
- const manifest = loaded.manifest;
2207
- if (manifest.quantization !== VECTOR_QUANTIZATION || !Number.isInteger(manifest.dimension) || !Array.isArray(manifest.items))
2208
- return null;
2209
- // 编码实现换了 → 库里这份索引不是「同一个东西」,当作不存在(于是 recall 会如实降级,
2210
- // 而不是拿一份用别的编码器算出来的向量去排序)。
2211
- if (manifest.encoder !== ENCODER_FINGERPRINT)
2212
- return null;
2213
- const binary = loaded.binary;
2214
- const int8 = loaded.int8;
2215
- const binaryWidth = Math.ceil(manifest.dimension / 8);
2216
- if (manifest.itemCount !== manifest.items.length || binary.length !== manifest.itemCount * binaryWidth || int8.length !== manifest.itemCount * manifest.dimension)
2217
- return null;
2218
- this.vectorIndex = { manifest, binary, int8 };
2214
+ this.vectorIndex = decodeVectorIndex((await this.derivedHandle())?.vectorIndex(), ENCODER_FINGERPRINT);
2219
2215
  return this.vectorIndex;
2220
2216
  }
2221
2217
  catch {
@@ -2261,6 +2257,13 @@ export class KnowledgeRuntime {
2261
2257
  async encodeEmbeddings(inputs) {
2262
2258
  if (this.config.embeddingProvider === 'none')
2263
2259
  throw new Error('EMBEDDING_DISABLED');
2260
+ const fingerprint = this.embeddingFingerprint();
2261
+ if (fingerprint !== this.embeddingWorkerFingerprint) {
2262
+ this.embeddingWorker?.close();
2263
+ this.embeddingWorker = null;
2264
+ this.embeddingWorkerFingerprint = fingerprint;
2265
+ this.encoderHealth = undefined;
2266
+ }
2264
2267
  // Keep each JSON response bounded for large stores. The WeMM process is
2265
2268
  // persistent, so chunking does not reload the 1.8GB model between chunks.
2266
2269
  const chunkSize = Math.max(1, this.config.embeddingBatchSize * 64);
@@ -2289,10 +2292,23 @@ export class KnowledgeRuntime {
2289
2292
  return { vectors, device };
2290
2293
  }
2291
2294
  close() {
2295
+ for (const task of this.compileTasks.values()) {
2296
+ if (['succeeded', 'failed', 'cancelled'].includes(task.progress.state))
2297
+ continue;
2298
+ task.cancelRequested = true;
2299
+ task.pauseRequested = false;
2300
+ task.resume?.();
2301
+ }
2292
2302
  this.embeddingWorker?.close();
2293
2303
  this.embeddingWorker = null;
2294
2304
  this.rerankWorker?.close();
2295
2305
  this.rerankWorker = null;
2306
+ this.oplog?.close();
2307
+ this.extractionRunsStore?.close();
2308
+ this.axesStore?.close();
2309
+ this.knowledgeDb?.close();
2310
+ this.derived?.close();
2311
+ this.store?.close();
2296
2312
  }
2297
2313
  /** Both the encoder and the reranker speak the same JSONL worker protocol. */
2298
2314
  spawnWorker(label = 'EMBEDDING') {
@@ -2333,11 +2349,18 @@ export class KnowledgeRuntime {
2333
2349
  // 「这份台账派生出了哪些结论」就回到关键词路回答 —— 那本来就是词面问题,不是图问题。
2334
2350
  const titleOf = (sourceId) => byId.get(sourceId);
2335
2351
  try {
2336
- await (await this.derivedHandle())?.rebuildFts(units.map(unit => ({
2352
+ const db = await this.derivedHandle();
2353
+ if (!db)
2354
+ throw new Error('DERIVED_STORE_UNAVAILABLE');
2355
+ const documents = units.map(unit => ({
2337
2356
  unitId: unit.unitId,
2338
2357
  title: unit.title,
2339
2358
  content: noteIndexText({ title: unit.title, content: unit.content, sourceRefs: unit.sourceRefs }, titleOf),
2340
- })));
2359
+ }));
2360
+ if (this.publication)
2361
+ this.publication.fts = documents;
2362
+ else
2363
+ db.rebuildFts(documents);
2341
2364
  this.ftsError = undefined;
2342
2365
  return 'ready';
2343
2366
  }
@@ -2347,6 +2370,8 @@ export class KnowledgeRuntime {
2347
2370
  }
2348
2371
  }
2349
2372
  async buildVectorIndex(units, version) {
2373
+ if (this.publication)
2374
+ this.publication.vectors = null;
2350
2375
  if (this.config.embeddingProvider === 'none')
2351
2376
  return { status: 'skipped', count: 0, reused: 0 };
2352
2377
  if (this.config.embeddingDimension % 8 !== 0)
@@ -2355,7 +2380,7 @@ export class KnowledgeRuntime {
2355
2380
  if (inputs.length === 0)
2356
2381
  return { status: 'ready', count: 0, reused: 0, dimension: this.config.embeddingDimension, device: 'none' };
2357
2382
  const previous = await this.loadVectorIndex();
2358
- const compatiblePrevious = previous && previous.manifest.dimension === this.config.embeddingDimension && previous.manifest.provider === this.config.embeddingProvider && previous.manifest.model === this.embeddingModelId && previous.manifest.encoder === ENCODER_FINGERPRINT ? previous : null;
2383
+ const compatiblePrevious = previous && previous.manifest.dimension === this.config.embeddingDimension && previous.manifest.provider === this.config.embeddingProvider && previous.manifest.model === this.embeddingModelId && previous.manifest.modelFingerprint === this.embeddingFingerprint() && previous.manifest.encoder === ENCODER_FINGERPRINT ? previous : null;
2359
2384
  const previousById = new Map((compatiblePrevious?.manifest.items ?? []).map((item, index) => [item.unitId, { item, index }]));
2360
2385
  const binaryWidth = Math.ceil(this.config.embeddingDimension / 8);
2361
2386
  const binary = Buffer.alloc(inputs.length * binaryWidth);
@@ -2408,9 +2433,20 @@ export class KnowledgeRuntime {
2408
2433
  this.encoderHealth = { ok: false, reason: `编码器退化:${inputs.length} 条 note 只产出 ${distinct.size} 个不同向量` };
2409
2434
  throw new Error(`EMBEDDING_INDEX_DEGENERATE: ${inputs.length} 条只产出 ${distinct.size} 个不同向量`);
2410
2435
  }
2411
- const manifest = { version, createdAt: new Date().toISOString(), provider: this.config.embeddingProvider, model: this.embeddingModelId, encoder: ENCODER_FINGERPRINT, device, dimension: this.config.embeddingDimension, quantization: VECTOR_QUANTIZATION, itemCount: inputs.length, items: inputs.map(item => ({ unitId: item.unit.unitId, contentHash: item.contentHash })) };
2412
- (await this.derivedHandle())?.putVectorIndex(manifest, binary, int8);
2413
- this.vectorIndex = { manifest, binary, int8 };
2436
+ const manifest = { version, createdAt: new Date().toISOString(), provider: this.config.embeddingProvider, model: this.embeddingModelId, modelFingerprint: this.embeddingFingerprint(), encoder: ENCODER_FINGERPRINT, device, dimension: this.config.embeddingDimension, quantization: VECTOR_QUANTIZATION, itemCount: inputs.length, items: inputs.map(item => ({ unitId: item.unit.unitId, contentHash: item.contentHash })) };
2437
+ /*
2438
+ * 向量、它的量化缓冲与清单**一起**写进派生库:三个东西必须同一次落地,
2439
+ * 分三次写文件时崩在中间会留下「清单说有 40 条、缓冲只有 30 条」的半成品。
2440
+ */
2441
+ const db = await this.derivedHandle();
2442
+ if (!db)
2443
+ throw new Error('DERIVED_STORE_UNAVAILABLE');
2444
+ if (this.publication)
2445
+ this.publication.vectors = { manifest, binary, int8 };
2446
+ else {
2447
+ db.putVectorIndex(manifest, binary, int8);
2448
+ this.vectorIndex = { manifest, binary, int8 };
2449
+ }
2414
2450
  this.vectorError = undefined;
2415
2451
  return { status: 'ready', count: inputs.length, reused, dimension: this.config.embeddingDimension, device };
2416
2452
  }
@@ -2426,7 +2462,7 @@ export class KnowledgeRuntime {
2426
2462
  for (const artifact of [WIKI_NOTES, LEGACY_UNITS]) {
2427
2463
  try {
2428
2464
  const notes = JSON.parse(await this.readArtifact(artifact));
2429
- if (Array.isArray(notes) && notes.length > 0) {
2465
+ if (Array.isArray(notes)) {
2430
2466
  this.units = notes;
2431
2467
  break;
2432
2468
  }
@@ -2650,7 +2686,15 @@ export class KnowledgeRuntime {
2650
2686
  */
2651
2687
  let compile = null;
2652
2688
  if (input.compile !== false) {
2653
- const rebuilt = await this.rebuild('wiki', input.lineage);
2689
+ let rebuilt;
2690
+ try {
2691
+ rebuilt = await this.rebuild('wiki', input.lineage);
2692
+ }
2693
+ catch (cause) {
2694
+ warnings.push(`来源已写入,编译未完成:${cause instanceof Error ? cause.message : String(cause)}`);
2695
+ }
2696
+ if (rebuilt === undefined)
2697
+ return success({ written, failed, attempted: items.length, compile }, warnings);
2654
2698
  if (rebuilt.ok === false) {
2655
2699
  warnings.push(`来源已写入,但编译失败:${rebuilt.error?.message ?? '未知原因'}`);
2656
2700
  }
@@ -2939,7 +2983,14 @@ export class KnowledgeRuntime {
2939
2983
  *
2940
2984
  * 失败一律返回 `null`,**不抛** —— 「知识库不可用」是一条要显示的结论,不是一个异常。
2941
2985
  */
2986
+ storeHandleOpening;
2942
2987
  async storeHandle() {
2988
+ if (this.store !== undefined)
2989
+ return this.store;
2990
+ this.storeHandleOpening ??= this.openStoreHandle();
2991
+ return this.storeHandleOpening;
2992
+ }
2993
+ async openStoreHandle() {
2943
2994
  if (this.store !== undefined)
2944
2995
  return this.store;
2945
2996
  const sqlite = await loadSqlite();
@@ -3124,7 +3175,14 @@ export class KnowledgeRuntime {
3124
3175
  this.migrationNotes.push(`已把 03_compiled 的 ${imported} 份产物搬进 derived.db`);
3125
3176
  }
3126
3177
  /** 派生库(`<库根>/derived.db`):编译产物,**可整体删掉重算**。 */
3178
+ derivedHandleOpening;
3127
3179
  async derivedHandle() {
3180
+ if (this.derived !== undefined)
3181
+ return this.derived;
3182
+ this.derivedHandleOpening ??= this.openDerivedHandle();
3183
+ return this.derivedHandleOpening;
3184
+ }
3185
+ async openDerivedHandle() {
3128
3186
  if (this.derived !== undefined)
3129
3187
  return this.derived;
3130
3188
  const sqlite = await loadSqlite();
@@ -3157,7 +3215,10 @@ export class KnowledgeRuntime {
3157
3215
  const db = await this.derivedHandle();
3158
3216
  if (db === null)
3159
3217
  throw new Error('派生库不可用');
3160
- db.putArtifact(name, body);
3218
+ if (this.publication)
3219
+ this.publication.artifacts.set(name, body);
3220
+ else
3221
+ db.putArtifact(name, body);
3161
3222
  }
3162
3223
  async hasArtifact(name) {
3163
3224
  return (await this.derivedHandle())?.artifact(name) !== undefined;
@@ -3586,7 +3647,31 @@ export class KnowledgeRuntime {
3586
3647
  }
3587
3648
  for (const card of (await this.loadCards()).filter(item => item.sourceRefs.includes(docId))) {
3588
3649
  try {
3589
- await this.writeCard({ ...card, sourceRefs: card.sourceRefs.filter(ref => ref !== docId), origin: 'internal' });
3650
+ const placements = db.placementsOf([card.entityId]).get(card.entityId) ?? [];
3651
+ const remaining = [];
3652
+ for (const row of placements) {
3653
+ const item = await store?.get(row.knowledgeId);
3654
+ if (item)
3655
+ remaining.push({ row, knowledge: item });
3656
+ }
3657
+ const sections = new Map();
3658
+ for (const { row, knowledge: item } of remaining.sort((a, b) => a.row.ordinal - b.row.ordinal)) {
3659
+ const heading = row.section || '知识';
3660
+ const lines = sections.get(heading) ?? [];
3661
+ lines.push(`${item.text}[^${item.id}]`);
3662
+ sections.set(heading, lines);
3663
+ }
3664
+ const content = sections.size > 0
3665
+ ? [...sections].map(([heading, lines]) => `## ${heading}\n\n${lines.join('\n\n')}`).join('\n\n')
3666
+ : card.content;
3667
+ const updated = await this.writeCard({
3668
+ ...card, content,
3669
+ ...(sections.size > 0 ? { summary: remaining[0].knowledge.text, placements: remaining.map(item => item.row) } : {}),
3670
+ metadata: { ...card.metadata, layout: undefined, citations: undefined, evidence_needs_review: sections.size === 0 },
3671
+ sourceRefs: card.sourceRefs.filter(ref => ref !== docId), origin: 'internal',
3672
+ });
3673
+ if (!updated.ok)
3674
+ throw new Error(updated.error.message);
3590
3675
  cardsDetached += 1;
3591
3676
  }
3592
3677
  catch (cause) {
@@ -3839,7 +3924,7 @@ export class KnowledgeRuntime {
3839
3924
  }));
3840
3925
  const hash = layoutHash({
3841
3926
  knowledgeIds: knowledge.map(item => item.id),
3842
- knowledgeContext: knowledge.map(item => `${item.id}:${item.sourceHeadings.join('|')}`),
3927
+ knowledgeContext: listed.knowledge.map(item => JSON.stringify({ id: item.id, text: item.text, version: item.version, headings: item.sources.map(source => source.headingPath), coordinate: input.coordinate })),
3843
3928
  profileId: input.profileId,
3844
3929
  });
3845
3930
  const layout = { profile: input.profileId, hash, origin: 'model' };
@@ -3938,7 +4023,7 @@ export class KnowledgeRuntime {
3938
4023
  raw: raw.trim().replace(/\s+/g, ' ').slice(0, 400),
3939
4024
  });
3940
4025
  }
3941
- const checked = checkOrganize(plan, context.knowledge.map(item => item.id), { ...(await this.relatedForCard(card)) });
4026
+ const checked = checkOrganize(plan, context.knowledge.map(item => item.id), { knowledge: context.knowledge, ...(await this.relatedForCard(card)) });
3942
4027
  const problems = [...parsed.problems.map(problem => problem.message), ...checked.problems.map(problem => problem.message)];
3943
4028
  const placements = problems.length === 0 ? placementsOf(plan, checked.cited) : [];
3944
4029
  const images = await this.imagesForCard(card);
@@ -4127,6 +4212,7 @@ export class KnowledgeRuntime {
4127
4212
  const problems = [...parsed.problems];
4128
4213
  if (plan !== undefined) {
4129
4214
  const checked = checkOrganize(plan, knowledge.map(item => item.id), {
4215
+ knowledge,
4130
4216
  ...(input.related === undefined ? {} : { related: input.related }),
4131
4217
  });
4132
4218
  problems.push(...checked.problems);
@@ -4474,7 +4560,7 @@ export class KnowledgeRuntime {
4474
4560
  return notes;
4475
4561
  }
4476
4562
  /** 1-hop neighbourhood of a set of wiki cards inside the compiled graph. */
4477
- oneHopGraph(keys, includeProblem) {
4563
+ oneHopGraph(keys, includeProblem, graph = this.graph) {
4478
4564
  const visible = (node) => includeProblem || node.status !== 'problem';
4479
4565
  const edges = this.graph.edges.filter(edge => keys.has(String(edge.from)) || keys.has(String(edge.to))).slice(0, 50);
4480
4566
  const ids = new Set(keys);
@@ -4579,7 +4665,7 @@ export class KnowledgeRuntime {
4579
4665
  for (const artifact of [WIKI_NOTES, LEGACY_UNITS]) {
4580
4666
  try {
4581
4667
  const notes = JSON.parse(await this.readArtifact(artifact));
4582
- if (Array.isArray(notes) && notes.length > 0)
4668
+ if (Array.isArray(notes))
4583
4669
  return notes;
4584
4670
  }
4585
4671
  catch { /* the first build has not happened yet */ }
@@ -4603,7 +4689,11 @@ export class KnowledgeRuntime {
4603
4689
  return contentHashOf(JSON.stringify({
4604
4690
  sourceId: source.sourceId,
4605
4691
  content: contentHashOf(source.content),
4606
- updatedAt: source.updatedAt,
4692
+ title: source.title,
4693
+ compiler: 'knowledge-extract-v2',
4694
+ extractWithModel: this.config.extractWithModel,
4695
+ extractionNote: this.extractionNote(),
4696
+ layoutProfile: this.config.layoutProfileId,
4607
4697
  declared: contentHashOf(JSON.stringify(declared)),
4608
4698
  registry: registryHash,
4609
4699
  }));
@@ -4639,7 +4729,7 @@ export class KnowledgeRuntime {
4639
4729
  onOrganize, runId) {
4640
4730
  const schema = this.wikiSchema();
4641
4731
  const enabledTypes = schema.types.filter(def => def.enabled);
4642
- const registryHash = contentHashOf(JSON.stringify(enabledTypes.map(def => ({ id: def.id, description: def.description, examples: def.examples }))));
4732
+ const registry = await this.axisRegistry();
4643
4733
  const outcome = this.emptyExtractionOutcome({});
4644
4734
  /**
4645
4735
  * 模型调用**每轮解析一次**,而且解析不到时**如实报出来**(0044)。
@@ -4650,6 +4740,7 @@ export class KnowledgeRuntime {
4650
4740
  */
4651
4741
  const seam = this.config.extractWithModel ? this.modelSeamNow() : {};
4652
4742
  const call = seam.call;
4743
+ const registryHash = contentHashOf(JSON.stringify({ types: enabledTypes, axesRevision: registry?.inputRevision() ?? 0, model: { available: Boolean(call), provider: seam.provider, model: seam.model, reasoningEffort: seam.reasoningEffort } }));
4653
4744
  if (this.config.extractWithModel && !call) {
4654
4745
  outcome.warnings.push(`模型抽取已开启,但取不到可用的模型调用:${seam.why ?? '原因未知'} —— 本次退回规则抽取:`
4655
4746
  + '卡片正文是按 `##` 小节切下来的原文,判重也只能比字面,同一实体换个标题写法会落成两张卡。');
@@ -4676,9 +4767,9 @@ export class KnowledgeRuntime {
4676
4767
  if (runId)
4677
4768
  await this.checkpointCompileTask(runId);
4678
4769
  const fingerprint = this.extractionFingerprint(source, registryHash);
4679
- outcome.extractionHashes[source.sourceId] = fingerprint;
4680
4770
  if (previousHashes[source.sourceId] === fingerprint) {
4681
4771
  outcome.sourcesSkipped += 1;
4772
+ outcome.extractionHashes[source.sourceId] = fingerprint;
4682
4773
  const kept = persisted[source.sourceId] ?? [];
4683
4774
  if (kept.length > 0) {
4684
4775
  outcome.relationsBySource[source.sourceId] = kept;
@@ -4692,7 +4783,9 @@ export class KnowledgeRuntime {
4692
4783
  try {
4693
4784
  // 同一轮里后一份来源应当能看到前一份来源刚抽出来的实体,所以判重索引
4694
4785
  // 每份来源都用「磁盘上的卡片 + 这一轮 pending 的卡片」重建。
4695
- await this.compileSource(source, byKey, pending, enabledTypes, outcome, call, knowledgeIds, runId, seam);
4786
+ const complete = await this.compileSource(source, byKey, pending, enabledTypes, outcome, call, knowledgeIds, runId, seam);
4787
+ if (complete !== false)
4788
+ outcome.extractionHashes[source.sourceId] = fingerprint;
4696
4789
  }
4697
4790
  catch (cause) {
4698
4791
  if (cause instanceof CompileTaskCancelledError)
@@ -4724,7 +4817,7 @@ export class KnowledgeRuntime {
4724
4817
  */
4725
4818
  const folded = new Map();
4726
4819
  for (const [key, item] of pending)
4727
- folded.set(key, appendExtracted(item.existing?.content ?? '', [item.content]));
4820
+ folded.set(key, item.content);
4728
4821
  const view = [];
4729
4822
  for (const [key, card] of byKey)
4730
4823
  view.push({ key, content: card.content, sourceRefs: card.sourceRefs });
@@ -4801,9 +4894,14 @@ export class KnowledgeRuntime {
4801
4894
  const contentBeforeEvents = preserveModelLayout ? existing.content : content;
4802
4895
  const finalContent = withEventReferences(contentBeforeEvents, item.metadata.event_refs, eventsById);
4803
4896
  const finalSummary = preserveModelLayout ? existing.summary : item.summary;
4804
- const finalPlacements = preserveModelLayout || item.placements === undefined
4897
+ let finalPlacements = preserveModelLayout || item.placements === undefined
4805
4898
  ? undefined
4806
4899
  : translatePlacements(item.placements, knowledgeIds);
4900
+ if (finalPlacements !== undefined && existing) {
4901
+ const db = await this.storeHandle();
4902
+ const previous = db?.placementsOf([existing.entityId]).get(existing.entityId) ?? [];
4903
+ finalPlacements = [...new Map([...previous, ...finalPlacements].map(row => [row.knowledgeId, row])).values()];
4904
+ }
4807
4905
  const titledMetadata = withTitleAlias(metadata, existing?.title, finalTitle);
4808
4906
  const finalMetadata = titledMetadata;
4809
4907
  const unchanged = existing !== undefined
@@ -4831,6 +4929,8 @@ export class KnowledgeRuntime {
4831
4929
  origin: 'compile',
4832
4930
  });
4833
4931
  if (written.ok === false) {
4932
+ for (const ref of item.sourceRefs)
4933
+ delete outcome.extractionHashes[ref];
4834
4934
  outcome.warnings.push(`卡片 ${item.entityId} 写入失败(${written.error.code}):${written.error.message}`);
4835
4935
  continue;
4836
4936
  }
@@ -5096,6 +5196,7 @@ export class KnowledgeRuntime {
5096
5196
  * 后一份文档可能把前一份的知识并进去,落位要指活着的那一行。
5097
5197
  */
5098
5198
  knowledgeIds, compileRunId, modelInfo) {
5199
+ let extractionComplete = call === undefined;
5099
5200
  // 判重索引要看到「这一轮前面几份来源刚抽出来的卡片」,否则同一实体跨两份文档
5100
5201
  // 会各建一张 —— 而那正是这次改造要修掉的那个 bug。
5101
5202
  const cards = [...byKey.values()];
@@ -5128,6 +5229,9 @@ export class KnowledgeRuntime {
5128
5229
  }
5129
5230
  /** 撞名报了没有 —— 同一个名字被三个坐标撞到时报三次是噪声。 */
5130
5231
  const collisionReported = new Set();
5232
+ const sourceDb = await this.storeHandle();
5233
+ const sourceHash = createHash('sha256').update(source.content, 'utf8').digest('hex');
5234
+ const sourceVersion = sourceDb?.versions('source', source.sourceId).find(row => row.blobHash === sourceHash)?.version;
5131
5235
  const ruleCandidates = extractCandidates(source, enabledTypes);
5132
5236
  outcome.candidatesExtracted += ruleCandidates.length;
5133
5237
  const ruleNormalized = normalizeCandidates(ruleCandidates, buildAliasIndex(cards));
@@ -5229,9 +5333,10 @@ export class KnowledgeRuntime {
5229
5333
  }),
5230
5334
  });
5231
5335
  /**
5232
- * 落轨迹、收知识。两件都**不影响这一次编译成不成** —— 审计与真源是旁路,
5233
- * 旁路坏了不能把主路一起拖垮(与操作日志同一条纪律)。
5336
+ * 记录抽取轨迹,再写入事实真源。轨迹是审计;事实写入不完整时不能保存成功指纹,
5337
+ * 下一次增量编译必须能够重试。
5234
5338
  */
5339
+ extractionComplete = report.model.ok;
5235
5340
  const summary = trialSummary(report);
5236
5341
  await this.recordExtractionRun({
5237
5342
  id: runId, docId: source.sourceId, title: source.title, summary,
@@ -5241,6 +5346,7 @@ export class KnowledgeRuntime {
5241
5346
  await this.checkpointCompileTask(compileRunId);
5242
5347
  const ingested = await this.ingestTrialKnowledge({
5243
5348
  runId, knowledge: report.knowledge,
5349
+ sourceVersions: { [source.sourceId]: sourceVersion },
5244
5350
  objects: report.objects, actions: report.actions, scenes: report.scenes,
5245
5351
  });
5246
5352
  let eventIngested = {
@@ -5260,6 +5366,8 @@ export class KnowledgeRuntime {
5260
5366
  outcome.warnings.push(`Event 没有写入知识库:${cause instanceof Error ? cause.message : String(cause)}`);
5261
5367
  }
5262
5368
  outcome.warnings.push(...eventIngested.warnings);
5369
+ if (ingested.skipped > 0 || eventIngested.skipped > 0)
5370
+ extractionComplete = false;
5263
5371
  // 落位要用库内的 id。这一份报告里每一条知识的映射都并进整轮那一份 ——
5264
5372
  // 后面的来源可能命中同一张卡,那时落位指的行也得是活的。
5265
5373
  Object.assign(knowledgeIds, ingested.ids);
@@ -5276,7 +5384,7 @@ export class KnowledgeRuntime {
5276
5384
  outcome.axesAmbiguous += axes.filter(axis => axis.outcome === 'ambiguous').length;
5277
5385
  outcome.knowledgeMerged += ingested.merged;
5278
5386
  if (ingested.skipped > 0) {
5279
- outcome.warnings.push(`${ingested.skipped} 条知识没有核对得上的出处,只进了抽取轨迹,没有进真源`);
5387
+ outcome.warnings.push(`${ingested.skipped} 条知识未进入真源(出处核验或写入失败),已保留抽取轨迹,下一次编译会重试`);
5280
5388
  }
5281
5389
  if (eventIngested.skipped > 0) {
5282
5390
  outcome.warnings.push(`${eventIngested.skipped} 个 Event 没有可核验出处,未进入 Event 真源`);
@@ -5515,6 +5623,7 @@ export class KnowledgeRuntime {
5515
5623
  ...(outcome.relationsBySource[source.sourceId] ?? []),
5516
5624
  ...ruleNormalized.relations,
5517
5625
  ];
5626
+ return extractionComplete;
5518
5627
  }
5519
5628
  /**
5520
5629
  * 把关系候选装配成 wiki↔wiki 的边。
@@ -5645,7 +5754,7 @@ export class KnowledgeRuntime {
5645
5754
  * 调用点必须在**按卡片去重之后**:先扩展再去重会把邻居与命中混在一起,
5646
5755
  * 而上下文里必须分得清「这是召回来的」还是「这是延伸出来的」。
5647
5756
  */
5648
- expandWiki(seedKeys, cardByKey, includeProblems) {
5757
+ expandWiki(seedKeys, cardByKey, includeProblems, graph = this.graph) {
5649
5758
  const seeds = new Set(seedKeys);
5650
5759
  const visible = (card) => includeProblems || card.status !== 'problem';
5651
5760
  const via = new Map();
@@ -5656,7 +5765,7 @@ export class KnowledgeRuntime {
5656
5765
  via.set(key, bucket);
5657
5766
  };
5658
5767
  const edges = [];
5659
- for (const edge of this.graph.edges) {
5768
+ for (const edge of graph.edges) {
5660
5769
  const type = String(edge.type);
5661
5770
  if (type === 'supported_by')
5662
5771
  continue;
@@ -6207,7 +6316,12 @@ export class KnowledgeRuntime {
6207
6316
  * 与抽取轨迹**同一个数据库文件**(`<memoryDir>/knowledge.sqlite`):它们是同一层
6208
6317
  * (领域实体与它的审计),而解析一条知识要同时读两边 —— 分两个文件只是多一次打开。
6209
6318
  */
6319
+ axisRegistryOpening;
6210
6320
  async axisRegistry() {
6321
+ this.axisRegistryOpening ??= this.openAxisRegistry();
6322
+ return this.axisRegistryOpening;
6323
+ }
6324
+ async openAxisRegistry() {
6211
6325
  if (this.axesReady !== null)
6212
6326
  return this.axesReady ? this.axesStore : null;
6213
6327
  /*
@@ -6215,7 +6329,10 @@ export class KnowledgeRuntime {
6215
6329
  * 记忆段里那个 `knowledge.sqlite`。理由是同一条:它们是**知识**(不可重建的命名决定
6216
6330
  * 与人的裁决),必须跟着知识库一起备份、迁移、删除;而记忆段只装交互记录。
6217
6331
  */
6218
- const registry = new AxisRegistry(this.safePath(KNOWLEDGE_DB_FILE));
6332
+ const db = await this.storeHandle();
6333
+ if (db === null)
6334
+ return null;
6335
+ const registry = new AxisRegistry(this.safePath(KNOWLEDGE_DB_FILE), db.connection());
6219
6336
  const ok = await registry.open();
6220
6337
  this.axesStore = ok ? registry : null;
6221
6338
  this.axesReady = ok;
@@ -6728,7 +6845,13 @@ export class KnowledgeRuntime {
6728
6845
  /** 并两个实体。被并的那个不删,转成 `merged` 并指向目标。 */
6729
6846
  async mergeAxes(fromId, intoId) {
6730
6847
  const registry = await this.axisRegistry();
6731
- return registry === null ? false : registry.merge(fromId, intoId);
6848
+ const db = await this.storeHandle();
6849
+ if (!registry || !db)
6850
+ return false;
6851
+ const merged = await registry.merge(fromId, intoId, kind => redirectAxisReferences(db, kind, fromId, intoId));
6852
+ if (merged)
6853
+ this.cards = await this.loadCards();
6854
+ return merged;
6732
6855
  }
6733
6856
  /** 加一个别名。`scopeId` 空串 = 全局。 */
6734
6857
  async addAxisAlias(id, alias, scopeId = '') {
@@ -6743,15 +6866,23 @@ export class KnowledgeRuntime {
6743
6866
  /**
6744
6867
  * Knowledge 库(0063 §2):事实真源。
6745
6868
  *
6746
- * 与轴注册表**同一个文件**(`<memoryDir>/knowledge.sqlite`)、**各自的表**。
6869
+ * 与轴注册表共享经过校验的 `knowledge.db` 连接,使用各自的表。
6747
6870
  * 两个模块互不认识,靠 id 字符串耦合 —— 代价是没有数据库级引用完整性,
6748
6871
  * 收益是「一条知识挂了一个不存在的坐标」会被 §5.7 的坐标核验**报出来**,
6749
6872
  * 而不是被数据库静默拒绝。
6750
6873
  */
6874
+ knowledgeStoreOpening;
6751
6875
  async knowledgeStore() {
6876
+ this.knowledgeStoreOpening ??= this.openKnowledgeStore();
6877
+ return this.knowledgeStoreOpening;
6878
+ }
6879
+ async openKnowledgeStore() {
6752
6880
  if (this.knowledgeReady !== null)
6753
6881
  return this.knowledgeReady ? this.knowledgeDb : null;
6754
- const store = new KnowledgeStore(this.safePath(KNOWLEDGE_DB_FILE));
6882
+ const db = await this.storeHandle();
6883
+ if (db === null)
6884
+ return null;
6885
+ const store = new KnowledgeStore(this.safePath(KNOWLEDGE_DB_FILE), db.connection());
6755
6886
  const ok = await store.open();
6756
6887
  this.knowledgeDb = ok ? store : null;
6757
6888
  this.knowledgeReady = ok;
@@ -6774,13 +6905,12 @@ export class KnowledgeRuntime {
6774
6905
  /*
6775
6906
  * 给每一处出处补上**它依据的是哪一版**(0062 v2)。
6776
6907
  *
6777
- * 补在这里而不是让每个调用方自己带:抽取与编译那条链路传的是 `documentId`,而
6778
- * 「现在是第几版」只有存储层知道。漏补的后果不是报错,而是「可追溯」少一半 ——
6779
- * 来源被人改过之后,没有任何东西能回答那条知识还算不算数。
6908
+ * 抽取链路带实际读取版本;直接写入没有指定版本时,补当前来源版本。
6909
+ * 版本用于追踪证据变化,不能据此裁定 Knowledge 是否失效。
6780
6910
  */
6781
6911
  const db = await this.storeHandle();
6782
6912
  const sources = (input.sources ?? []).map(source => {
6783
- if (!source.documentId)
6913
+ if (!source.documentId || source.toVersion !== undefined)
6784
6914
  return source;
6785
6915
  const version = db?.source(source.documentId)?.version;
6786
6916
  return version === undefined ? source : { ...source, toVersion: version };
@@ -6806,7 +6936,9 @@ export class KnowledgeRuntime {
6806
6936
  * 调用方可以显式传 `false` 关掉它(试跑那条链路不需要这个字段)。
6807
6937
  */
6808
6938
  try {
6809
- return await store.list({ withCards: true, ...query });
6939
+ const page = await store.list({ withCards: true, ...query });
6940
+ const db = await this.storeHandle();
6941
+ return db ? { ...page, knowledge: page.knowledge.map(row => assessKnowledgeEvidence(row, db)) } : page;
6810
6942
  }
6811
6943
  catch {
6812
6944
  return null;
@@ -6821,7 +6953,8 @@ export class KnowledgeRuntime {
6821
6953
  const knowledge = await store.get(id);
6822
6954
  if (knowledge === null)
6823
6955
  return null;
6824
- return { knowledge, versions: await store.versions(id) };
6956
+ const db = await this.storeHandle();
6957
+ return { knowledge: db ? assessKnowledgeEvidence(knowledge, db) : knowledge, versions: await store.versions(id) };
6825
6958
  }
6826
6959
  catch {
6827
6960
  return null;
@@ -6889,7 +7022,7 @@ export class KnowledgeRuntime {
6889
7022
  const cardOrphans = cards.filter(card => card.sourceRefs.every(ref => !live.has(ref))).length;
6890
7023
  const events = (await this.loadEvents()).filter(event => event.records.some(record => !live.has(record.sourceId))).length;
6891
7024
  /**
6892
- * 图片也进这张账(2026-10-05)。
7025
+ * 未被引用的来源图片也进这张账(2026-10-05)。`images.publish` 写入的独立图片会被识别并保留。
6893
7026
  *
6894
7027
  * 少了它,「来源与卡片都清完了、只剩一堆没人引用的图」这种状态在界面上**一个字都没有**
6895
7028
  * —— 清理孤儿的入口因此不出现,而用户唯一能看到的读数(「收录 N 个文件」)又把它算成
@@ -6897,7 +7030,7 @@ export class KnowledgeRuntime {
6897
7030
  * 「按一下能清掉几个」是同几个。
6898
7031
  */
6899
7032
  const names = await this.assetBucketNames();
6900
- const assets = names.length === 0 ? 0 : this.unreferencedBuckets(names, sources, cards).length;
7033
+ const assets = names.length === 0 ? 0 : (await this.unreferencedBuckets(names, sources, cards)).length;
6901
7034
  return { knowledge, cards: cardOrphans, events, assets };
6902
7035
  }
6903
7036
  /** 图片池里的桶名(`01_source_raw/_assets/<bucket>/`)。没有池子就是空的。 */
@@ -6925,8 +7058,9 @@ export class KnowledgeRuntime {
6925
7058
  return [...names];
6926
7059
  }
6927
7060
  /**
6928
- * 还有没有人在引用这个桶 —— 判据是**三处文本里出现过 `_assets/<bucket>/`**:
6929
- * 来源正文、来源 front matter 的 `metadata.assets`、卡片正文。
7061
+ * 还有没有人在引用这个桶 —— 判据是来源正文、来源 front matter 的 `metadata.assets`、
7062
+ * 卡片正文出现过 `_assets/<bucket>/`,或者它是 `images.publish` 写入的独立图床图片。
7063
+ * 独立图片没有来源/卡片引用是正常状态,必须保留。
6930
7064
  *
6931
7065
  * 为什么三处都要看:桶名是**内容哈希的前 12 位**(`convert/ingest.ts`),不是来源 id。
6932
7066
  * 同一份内容以两个分类导入会共用同一个桶;而卡片正文会把图片引用**复制进去**
@@ -6934,7 +7068,7 @@ export class KnowledgeRuntime {
6934
7068
  *
6935
7069
  * 不看 `03_compiled/`:那是派生物,下一次 `rebuild` 会把指向已删图片的引用一起扔掉。
6936
7070
  */
6937
- unreferencedBuckets(names, sources, cards) {
7071
+ async unreferencedBuckets(names, sources, cards) {
6938
7072
  const referenced = new Set();
6939
7073
  /**
6940
7074
  * **两种引用形式都算**:`assets/<桶>/`(新写的)与 `_assets/<桶>/`(存量)。
@@ -6958,7 +7092,44 @@ export class KnowledgeRuntime {
6958
7092
  }
6959
7093
  for (const card of cards)
6960
7094
  collect(card.content);
6961
- return names.filter(name => !referenced.has(name));
7095
+ const unreferenced = [];
7096
+ for (const name of names) {
7097
+ if (referenced.has(name))
7098
+ continue;
7099
+ if (await this.isPublishedImageBucket(name))
7100
+ continue;
7101
+ unreferenced.push(name);
7102
+ }
7103
+ return unreferenced;
7104
+ }
7105
+ /**
7106
+ * `images.publish` 的图片不需要被来源或卡片引用。新写入的桶有明确标记;同时兼容
7107
+ * 标记加入前已发布的图片:它们以图片自身的 SHA-256 前 12 位作为桶名。
7108
+ */
7109
+ async isPublishedImageBucket(name) {
7110
+ for (const root of this.assetRoots()) {
7111
+ if (existsSync(join(root, name, PUBLISHED_IMAGE_MARKER)))
7112
+ return true;
7113
+ }
7114
+ // 旧版本 images.publish 只会写入知识库根目录的 assets/,因此只在这里做哈希识别。
7115
+ if (!/^[a-f0-9]{12}$/i.test(name))
7116
+ return false;
7117
+ const dir = join(this.rootDir, ASSETS_DIR, name);
7118
+ try {
7119
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
7120
+ if (!entry.isFile() || !/\.(?:png|jpe?g|gif|webp|avif|bmp|svg)$/i.test(entry.name))
7121
+ continue;
7122
+ const path = join(dir, entry.name);
7123
+ const details = await stat(path);
7124
+ if (details.size > 20 * 1024 * 1024)
7125
+ continue;
7126
+ const bytes = await readFile(path);
7127
+ if (createHash('sha256').update(bytes).digest('hex').slice(0, 12) === name)
7128
+ return true;
7129
+ }
7130
+ }
7131
+ catch { /* 桶目录可能在扫描期间被删除 */ }
7132
+ return false;
6962
7133
  }
6963
7134
  /**
6964
7135
  * 回收 `source_raw/` 里**没有人再指向的底稿桶**(0067)。
@@ -7036,7 +7207,7 @@ export class KnowledgeRuntime {
7036
7207
  return { buckets: 0, files: 0, problems: [] };
7037
7208
  const problems = [];
7038
7209
  const cards = this.cards.length > 0 ? this.cards : await this.loadCards();
7039
- const doomed = this.unreferencedBuckets(names, await this.loadSources(), cards);
7210
+ const doomed = await this.unreferencedBuckets(names, await this.loadSources(), cards);
7040
7211
  let buckets = 0;
7041
7212
  let files = 0;
7042
7213
  for (const name of doomed) {
@@ -7191,7 +7362,7 @@ export class KnowledgeRuntime {
7191
7362
  async ingestTrialKnowledge(input) {
7192
7363
  const store = await this.knowledgeStore();
7193
7364
  if (store === null)
7194
- return { saved: 0, merged: 0, skipped: 0, ids: {} };
7365
+ return { saved: 0, merged: 0, skipped: input.knowledge.length, ids: {} };
7195
7366
  const axes = { objects: input.objects, actions: input.actions, scenes: input.scenes };
7196
7367
  let saved = 0;
7197
7368
  let merged = 0;
@@ -7205,7 +7376,7 @@ export class KnowledgeRuntime {
7205
7376
  }
7206
7377
  try {
7207
7378
  const coordinate = coordinateIdsOf(axes, item.object, item.action, item.scene);
7208
- const result = await store.ingest({
7379
+ const result = await this.ingestKnowledge({
7209
7380
  text: item.text,
7210
7381
  objectId: coordinate.objectId,
7211
7382
  actionId: coordinate.actionId,
@@ -7213,11 +7384,16 @@ export class KnowledgeRuntime {
7213
7384
  runId: input.runId,
7214
7385
  sources: usable.map(source => ({
7215
7386
  documentId: source.documentId,
7387
+ ...(input.sourceVersions?.[source.documentId] === undefined ? {} : { toVersion: input.sourceVersions[source.documentId] }),
7216
7388
  quote: source.quote,
7217
7389
  headingPath: source.headingPath,
7218
7390
  ...(source.locator ? { locator: source.locator } : {}),
7219
7391
  })),
7220
7392
  });
7393
+ if (result === null) {
7394
+ skipped += 1;
7395
+ continue;
7396
+ }
7221
7397
  if (result.merged)
7222
7398
  merged += 1;
7223
7399
  else
@@ -7402,13 +7578,7 @@ export class KnowledgeRuntime {
7402
7578
  }
7403
7579
  }
7404
7580
  else if (preciseTime(event.occurredAt) && hasCompleteCoordinate(coordinateIds) && event.symptom?.trim()) {
7405
- identityKey = 'composite:' + hash(JSON.stringify([
7406
- event.occurredAt,
7407
- coordinateIds.objectId,
7408
- coordinateIds.actionId,
7409
- coordinateIds.sceneId,
7410
- normalizeKnowledge(event.symptom),
7411
- ]));
7581
+ identityKey = compositeEventIdentity(event.occurredAt, coordinateIds, event.symptom);
7412
7582
  identityType = 'composite';
7413
7583
  }
7414
7584
  else {
@@ -7526,7 +7696,14 @@ export class KnowledgeRuntime {
7526
7696
  */
7527
7697
  async rebuild(only = 'all', lineage, options = {}) {
7528
7698
  const runId = newCompileRunId();
7529
- const result = await this.enqueueCompile(runId, only, lineage, options);
7699
+ let result;
7700
+ try {
7701
+ result = await this.enqueueCompile(runId, only, lineage, options);
7702
+ }
7703
+ catch (cause) {
7704
+ await this.settleProgressFailure(runId, cause);
7705
+ throw cause;
7706
+ }
7530
7707
  /*
7531
7708
  * 同步调用方拿的是返回值,但**别人可能在轮询同一条进度记录**(面板正开着编译对话框,
7532
7709
  * 而 Agent 起了这一次)。结论两个出口都要有。
@@ -7620,7 +7797,7 @@ export class KnowledgeRuntime {
7620
7797
  queued: false,
7621
7798
  finishedAt: current.finishedAt ?? new Date().toISOString(),
7622
7799
  failed: true,
7623
- outcome: current.outcome ?? `编译失败:${message}`,
7800
+ outcome: `编译失败:${message}`,
7624
7801
  });
7625
7802
  await this.patchCompileOperation(runId, 'failed', `编译失败:${message}`);
7626
7803
  }
@@ -7676,6 +7853,52 @@ export class KnowledgeRuntime {
7676
7853
  * 它」—— 那两件事分别在 `enqueueCompile()` 与 `rebuild()` / `startCompile()` 里。
7677
7854
  */
7678
7855
  async compileOnce(runId, only = 'all', lineage, options = {}) {
7856
+ await this.environmentGuard?.();
7857
+ this.publication = { artifacts: new Map() };
7858
+ const configAtStart = JSON.stringify({ config: this.config, fingerprint: this.embeddingFingerprint() });
7859
+ try {
7860
+ await this.loadCompiledState();
7861
+ const result = await this.prepareCompile(runId, only, lineage, options);
7862
+ await this.checkpointCompileTask(runId);
7863
+ if (result.ok) {
7864
+ const db = await this.derivedHandle();
7865
+ if (!db)
7866
+ throw new Error('DERIVED_STORE_UNAVAILABLE');
7867
+ const truth = await this.storeHandle();
7868
+ const knowledge = await this.knowledgeStore();
7869
+ const rows = [];
7870
+ if (knowledge) {
7871
+ for (let offset = 0;; offset += 500) {
7872
+ const page = await knowledge.list({ limit: 500, offset });
7873
+ rows.push(...page.knowledge);
7874
+ if (offset + page.knowledge.length >= page.total)
7875
+ break;
7876
+ }
7877
+ }
7878
+ // Embeddings-only rebuild keeps the published card and knowledge generation.
7879
+ const previous = db.artifact(COMPILE_SNAPSHOT);
7880
+ const snapshot = only === 'embeddings' && previous
7881
+ ? { ...JSON.parse(previous), version: result.data.version, compileRunId: runId }
7882
+ : {
7883
+ version: result.data.version, compileRunId: runId,
7884
+ cards: this.cards, notes: this.units, graph: this.graph,
7885
+ diagnosisGraph: this.diagnosisGraph,
7886
+ placements: [...(truth?.placements() ?? new Map())], knowledge: rows,
7887
+ };
7888
+ this.publication.artifacts.set(COMPILE_SNAPSHOT, JSON.stringify(snapshot));
7889
+ await this.checkpointCompileTask(runId);
7890
+ if (JSON.stringify({ config: this.config, fingerprint: this.embeddingFingerprint() }) !== configAtStart)
7891
+ throw new Error('COMPILE_CONFIG_CHANGED: 编译期间设置已变化,请重新编译');
7892
+ db.publish(this.publication);
7893
+ this.vectorIndex = null;
7894
+ }
7895
+ return result;
7896
+ }
7897
+ finally {
7898
+ this.publication = undefined;
7899
+ }
7900
+ }
7901
+ async prepareCompile(runId, only = 'all', lineage, options = {}) {
7679
7902
  /**
7680
7903
  * 进度记录**先占位**:`runId` 一落下去,这一趟就有了名字 —— 之后无论从哪个出口结束
7681
7904
  * (三种 return,或者抛异常),收口都能认出这条记录属于谁。
@@ -7744,24 +7967,17 @@ export class KnowledgeRuntime {
7744
7967
  this.writeCompileProgress({ ...(this.compileProgress ?? blankProgress(runId, only, startedAt, 'running', queuedAt)), ...patch });
7745
7968
  };
7746
7969
  /**
7747
- * 收口:四段一起标成完成,并写下一句结论。
7748
- *
7749
- * 它在 `rebuild()` 的**每一个** return 之前调用(一共三处出口)。少了任何一处,
7750
- * 那次编译的进度就会永远停在「进行中」—— 而界面拿它当「正在跑」,于是按钮一直灰着。
7970
+ * 四段准备完成,保存结论。事务发布之前仍是 running;发布成功后由
7971
+ * settleProgressReport 收口,发布失败则由 settleProgressFailure 收口。
7751
7972
  */
7752
7973
  const finishProgress = (outcome) => {
7753
7974
  const current = this.compileProgress;
7754
7975
  if (current === null)
7755
7976
  return;
7756
- /*
7757
- * 收口时**立刻存档**,不只写槽位:槽位会被下一趟(排在后面的那一趟)顶掉,
7758
- * 而存档按编号留着 —— 发起编译的那一方无论何时来读,都读得到自己那一份。
7759
- */
7760
- this.rememberCompile({
7977
+ this.writeCompileProgress({
7761
7978
  ...current,
7762
- state: 'succeeded',
7979
+ state: 'running',
7763
7980
  queued: false,
7764
- finishedAt: new Date().toISOString(),
7765
7981
  sourcesDone: Math.max(current.sourcesDone, current.sourcesTotal),
7766
7982
  steps: current.steps.map(step => ({ ...step, state: 'done' })),
7767
7983
  outcome,
@@ -8023,8 +8239,8 @@ export class KnowledgeRuntime {
8023
8239
  */
8024
8240
  const incremental = only === 'wiki';
8025
8241
  if (only === 'embeddings') {
8026
- if (this.units.length === 0)
8027
- await this.loadCompiledState();
8242
+ await this.loadCompiledState();
8243
+ Object.assign(cardHashes, previousHashes);
8028
8244
  }
8029
8245
  else {
8030
8246
  const previousNotes = incremental ? await this.readNotesArtifact() : [];
@@ -8106,7 +8322,9 @@ export class KnowledgeRuntime {
8106
8322
  const diagnosis = await this.diagnosisStore().readGraph();
8107
8323
  const diagnosisHash = contentHashOf(JSON.stringify({ nodes: diagnosis.nodes, edges: diagnosis.edges }));
8108
8324
  const diagnosisChanged = diagnosisHash !== previousManifest.diagnosisHash;
8109
- const corpusChanged = !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged || derivedRefreshed || indexChanged;
8325
+ const embeddingIdentity = JSON.stringify({ provider: this.config.embeddingProvider, model: this.embeddingModelId, fingerprint: this.embeddingFingerprint(), dimension: this.config.embeddingDimension, encoder: ENCODER_FINGERPRINT, baseUrl: this.config.embeddingBaseUrl });
8326
+ const embeddingChanged = previousManifest.embeddingIdentity !== embeddingIdentity;
8327
+ const corpusChanged = embeddingChanged || !incremental || previousVersion === 0 || cardsRecompiled > 0 || cardsRemoved > 0 || diagnosisChanged || derivedRefreshed || indexChanged;
8110
8328
  if (!corpusChanged) {
8111
8329
  await this.checkpointCompileTask(runId);
8112
8330
  // 这一趟的编号是调用方给的(0066):进度记录、报告、manifest 用同一个编号。
@@ -8129,7 +8347,7 @@ export class KnowledgeRuntime {
8129
8347
  // 用户刚点了编译却看到上次的统计,会以为按钮没生效。version 不变,所以
8130
8348
  // 这个写入既不改索引也不让向量缓存失效。extractionHashes 必须跟着更新:
8131
8349
  // 来源变了但没抽出卡片时,不落新指纹会让下一次又把它重抽一遍。
8132
- await this.writeArtifact('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));
8350
+ await this.writeArtifact('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, embeddingIdentity: JSON.stringify({ provider: this.config.embeddingProvider, model: this.embeddingModelId, fingerprint: this.embeddingFingerprint(), dimension: this.config.embeddingDimension, encoder: ENCODER_FINGERPRINT, baseUrl: this.config.embeddingBaseUrl }), 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));
8133
8351
  const detail = detailOf({ skipped: true, version: previousVersion, cards: { created: [], updated: [], reused: reusedTitles.slice(0, MAX_LOG_ITEMS), removed: [] } });
8134
8352
  report.detail = detail;
8135
8353
  await settleOp(detail, `没有变化:复用 ${notesReused} 条召回片段,索引仍为 v${previousVersion}。`, 'done');
@@ -8198,6 +8416,8 @@ export class KnowledgeRuntime {
8198
8416
  await this.writeArtifact(DIAGNOSIS_GRAPH, JSON.stringify({ version, builtAt, nodes: diagnosis.nodes, edges: diagnosis.edges }, null, 2));
8199
8417
  await this.checkpointCompileTask(runId);
8200
8418
  const embeddingResult = await this.buildVectorIndex(this.units, version);
8419
+ if (this.environmentGuard && embeddingResult.status === 'unavailable')
8420
+ throw new Error(`向量索引构建失败:${embeddingResult.error ?? '未知原因'}。请重新自检环境后重试编译`);
8201
8421
  await this.checkpointCompileTask(runId);
8202
8422
  setStep('build', 'done', [
8203
8423
  `知识图谱 ${this.graph.nodes.length} 节点 / ${this.graph.edges.length} 边`
@@ -8210,7 +8430,7 @@ export class KnowledgeRuntime {
8210
8430
  ]);
8211
8431
  // 这一趟的编号是调用方给的(0066):进度记录、报告、manifest 用同一个编号。
8212
8432
  const compileRunId = runId;
8213
- await this.writeArtifact('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));
8433
+ await this.writeArtifact('build-manifest.json', JSON.stringify({ version, builtAt, sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embeddingIdentity: JSON.stringify({ provider: this.config.embeddingProvider, model: this.embeddingModelId, fingerprint: this.embeddingFingerprint(), dimension: this.config.embeddingDimension, encoder: ENCODER_FINGERPRINT, baseUrl: this.config.embeddingBaseUrl }), 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));
8214
8434
  const report = {
8215
8435
  runId: compileRunId, compileRunId, version, only,
8216
8436
  cards: this.cards.length, units: this.units.length, notes: this.units.length,
@@ -8258,6 +8478,8 @@ export class KnowledgeRuntime {
8258
8478
  const version = previousVersion + 1;
8259
8479
  await this.checkpointCompileTask(runId);
8260
8480
  const embeddingResult = await this.buildVectorIndex(this.units, version);
8481
+ if (this.environmentGuard && embeddingResult.status === 'unavailable')
8482
+ throw new Error(`向量索引构建失败:${embeddingResult.error ?? '未知原因'}。请重新自检环境后重试编译`);
8261
8483
  await this.checkpointCompileTask(runId);
8262
8484
  // 只重建向量这一趟只跑第五段(其余四段在开跑时就标成「本次不涉及」,见 `blankSteps`)。
8263
8485
  setStep('build', 'done', [
@@ -8268,7 +8490,7 @@ export class KnowledgeRuntime {
8268
8490
  ]);
8269
8491
  // 这一趟的编号是调用方给的(0066):进度记录、报告、manifest 用同一个编号。
8270
8492
  const compileRunId = runId;
8271
- await this.writeArtifact('build-manifest.json', JSON.stringify({ version, builtAt: new Date().toISOString(), sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embedding: embeddingResult, compileRunId, lastBuild: { only, incremental: embeddingResult.reused > 0, skipped: false, notesReused: embeddingResult.reused, notesRecompiled: Math.max(0, embeddingResult.count - embeddingResult.reused), ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
8493
+ await this.writeArtifact('build-manifest.json', JSON.stringify({ ...previousManifest, version, builtAt: new Date().toISOString(), sourceCount: sources.length, cardCount: this.cards.length, noteCount: this.units.length, cardHashes, extractionHashes: extraction.extractionHashes, embeddingIdentity: JSON.stringify({ provider: this.config.embeddingProvider, model: this.embeddingModelId, fingerprint: this.embeddingFingerprint(), dimension: this.config.embeddingDimension, encoder: ENCODER_FINGERPRINT, baseUrl: this.config.embeddingBaseUrl }), embedding: embeddingResult, compileRunId, lastBuild: { only, incremental: embeddingResult.reused > 0, skipped: false, notesReused: embeddingResult.reused, notesRecompiled: Math.max(0, embeddingResult.count - embeddingResult.reused), ...extractionCounts, relationsExtracted: 0, relationsDropped: 0 }, ...(lineage ? { lineage } : {}) }, null, 2));
8272
8494
  const report = {
8273
8495
  runId: compileRunId, compileRunId, version, only,
8274
8496
  cards: this.cards.length, units: this.units.length, notes: this.units.length,
@@ -8347,6 +8569,8 @@ export class KnowledgeRuntime {
8347
8569
  rightNorm += right[index] * right[index];
8348
8570
  }
8349
8571
  const cosine = dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm) || 1);
8572
+ if (leftNorm < 1e-12 || rightNorm < 1e-12)
8573
+ throw new Error('ENCODER_PROBE_ZERO_VECTOR: 编码器返回零向量');
8350
8574
  if (!Number.isFinite(cosine) || cosine > ENCODER_SELF_SIMILARITY_MAX) {
8351
8575
  this.encoderHealth = { ok: false, reason: `编码器对两段无关文本给出同一个向量(cos = ${cosine.toFixed(4)}),向量路已停用` };
8352
8576
  return this.encoderHealth;
@@ -8485,15 +8709,15 @@ export class KnowledgeRuntime {
8485
8709
  }
8486
8710
  }
8487
8711
  /** 关键词路:FTS5 + BM25(0031)。索引不在时如实降级,不退回旧的计数打分。 */
8488
- async ftsRoute(query, eligible, pool, threshold, titleOf) {
8712
+ async ftsRoute(query, eligible, pool, threshold, titleOf, captured) {
8489
8713
  const eligibleIds = new Set(eligible.map(note => note.unitId));
8490
8714
  const derived = await this.derivedHandle();
8491
8715
  if (derived === null)
8492
8716
  return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: '当前 Node 没有 node:sqlite(需要 22.5+),关键词路已降级' } };
8493
- if (!derived.hasFts())
8717
+ if (!(captured?.ready ?? derived.hasFts()))
8494
8718
  return { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'degraded', reason: '关键词索引还没建(在 derived.db 里),先编译一次' } };
8495
8719
  try {
8496
- const found = derived.searchFts(query, pool).filter(hit => eligibleIds.has(hit.unitId));
8720
+ const found = (captured?.hits ?? derived.searchFts(query, pool)).filter(hit => eligibleIds.has(hit.unitId));
8497
8721
  /**
8498
8722
  * 覆盖率门槛(见 `MIN_QUERY_COVERAGE`):只蹭到一两个高频单字的命中在这里淘汰,
8499
8723
  * 于是「整批都弱」也能弃答 —— 这是相对阈值做不到的那一半。
@@ -8563,8 +8787,8 @@ export class KnowledgeRuntime {
8563
8787
  * 阈值就完全失去意义(余弦 0 会被映射成 0.5,轻松过线)。原始余弦是绝对量,可以直接
8564
8788
  * 用来判「这段内容和问题到底像不像」—— 弃答主要靠它和重排阈值。
8565
8789
  */
8566
- async vectorRoute(query, eligibleIds, pool, threshold, expectedVersion) {
8567
- const index = await this.loadVectorIndex();
8790
+ async vectorRoute(query, eligibleIds, pool, threshold, expectedVersion, captured) {
8791
+ const index = captured === undefined ? await this.loadVectorIndex() : captured;
8568
8792
  if (!index) {
8569
8793
  const reason = '向量索引不存在或损坏,请执行 embeddings 编译重建';
8570
8794
  return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason }, health: { ok: false, reason } };
@@ -8577,7 +8801,7 @@ export class KnowledgeRuntime {
8577
8801
  if (!health.ok)
8578
8802
  return { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'degraded', reason: health.reason }, health };
8579
8803
  try {
8580
- const { scores, embedMs, scanMs } = await this.vectorRecall(query, eligibleIds, pool);
8804
+ const { scores, embedMs, scanMs } = await this.vectorRecall(query, eligibleIds, pool, index);
8581
8805
  const ranking = [...scores.entries()].map(([unitId, score]) => ({ unitId, score })).sort((left, right) => right.score - left.score || left.unitId.localeCompare(right.unitId));
8582
8806
  const { kept, dropped, topScore, rows } = applyThreshold(ranking, threshold);
8583
8807
  return {
@@ -8618,11 +8842,11 @@ export class KnowledgeRuntime {
8618
8842
  * 是因为两张图的节点含义不同(一个是卡片,一个是字段级节点),合成一张会让
8619
8843
  * 图谱 Tab 看到一张卡片散成七个点。
8620
8844
  */
8621
- diagnosisHits(queryTokens) {
8845
+ diagnosisHits(queryTokens, graph = this.diagnosisGraph) {
8622
8846
  const seed = new Map();
8623
- if (this.diagnosisGraph.nodes.length === 0)
8847
+ if (graph.nodes.length === 0)
8624
8848
  return seed;
8625
- for (const node of this.diagnosisGraph.nodes) {
8849
+ for (const node of graph.nodes) {
8626
8850
  const title = String(node.title ?? '').toLocaleLowerCase();
8627
8851
  const id = String(node.id ?? '').toLocaleLowerCase();
8628
8852
  let score = 0;
@@ -8636,7 +8860,7 @@ export class KnowledgeRuntime {
8636
8860
  seed.set(String(node.id), score);
8637
8861
  }
8638
8862
  // 关系词命中:两端各得 1 分。中文提问不会写 `refutes`,所以走 RELATION_QUERY_WORDS。
8639
- for (const edge of this.diagnosisGraph.edges) {
8863
+ for (const edge of graph.edges) {
8640
8864
  const type = String(edge.kind ?? '');
8641
8865
  const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase();
8642
8866
  let score = 0;
@@ -8652,7 +8876,7 @@ export class KnowledgeRuntime {
8652
8876
  return seed;
8653
8877
  const expanded = new Map(seed);
8654
8878
  for (const [id, score] of seed) {
8655
- for (const edge of this.diagnosisGraph.edges) {
8879
+ for (const edge of graph.edges) {
8656
8880
  const from = String(edge.source);
8657
8881
  const to = String(edge.target);
8658
8882
  if (from === id)
@@ -8663,8 +8887,8 @@ export class KnowledgeRuntime {
8663
8887
  }
8664
8888
  return expanded;
8665
8889
  }
8666
- async graphBoostRanking(query, eligible, includeProblems) {
8667
- if (this.graph.nodes.length === 0)
8890
+ async graphBoostRanking(query, eligible, includeProblems, graph = this.graph, diagnosisGraph = this.diagnosisGraph) {
8891
+ if (graph.nodes.length === 0)
8668
8892
  return [];
8669
8893
  const queryTokens = segment(query);
8670
8894
  if (queryTokens.length === 0)
@@ -8675,9 +8899,9 @@ export class KnowledgeRuntime {
8675
8899
  const nodeKey = (node) => `${String(node.type)}:${String(node.id)}`;
8676
8900
  /** 卡片级节点 id → 它的 `entity_type`。归因图的节点 id 与卡片 id 相等(§14.1)。 */
8677
8901
  const typeByEntityId = new Map();
8678
- for (const node of this.graph.nodes)
8902
+ for (const node of graph.nodes)
8679
8903
  typeByEntityId.set(String(node.id), String(node.type));
8680
- for (const node of this.graph.nodes) {
8904
+ for (const node of graph.nodes) {
8681
8905
  if (!visible(node))
8682
8906
  continue;
8683
8907
  const title = String(node.title ?? '').toLocaleLowerCase();
@@ -8692,7 +8916,7 @@ export class KnowledgeRuntime {
8692
8916
  if (score > 0)
8693
8917
  boost(nodeKey(node), score);
8694
8918
  }
8695
- for (const edge of this.graph.edges) {
8919
+ for (const edge of graph.edges) {
8696
8920
  const type = String(edge.type ?? '');
8697
8921
  const relation = (RELATION_QUERY_WORDS[type] ?? type.toLocaleLowerCase().replace(/_/g, ' ')).toLocaleLowerCase();
8698
8922
  let score = 0;
@@ -8712,7 +8936,7 @@ export class KnowledgeRuntime {
8712
8936
  * 归因图上命中的节点,映射回同 id 的卡片级节点加权 —— **不需要合图**,
8713
8937
  * 因为两张图的节点 id 本来就相等(§14.1)。
8714
8938
  */
8715
- for (const [id, score] of this.diagnosisHits(queryTokens)) {
8939
+ for (const [id, score] of this.diagnosisHits(queryTokens, diagnosisGraph)) {
8716
8940
  const type = typeByEntityId.get(id);
8717
8941
  if (type)
8718
8942
  boost(`${type}:${id}`, score);
@@ -8721,7 +8945,7 @@ export class KnowledgeRuntime {
8721
8945
  return [];
8722
8946
  const expanded = new Map(byNodeKey);
8723
8947
  for (const [key, score] of byNodeKey) {
8724
- for (const edge of this.graph.edges) {
8948
+ for (const edge of graph.edges) {
8725
8949
  const from = String(edge.from);
8726
8950
  const to = String(edge.to);
8727
8951
  if (from === key)
@@ -8804,7 +9028,7 @@ export class KnowledgeRuntime {
8804
9028
  * 这次召回少一条证据,而正确的答案是换成它被并进的那一条(`mergedInto` 只跳一跳:
8805
9029
  * 并进的目标自己不会再被并 —— 那正是「并」这个动作的定义)。
8806
9030
  */
8807
- async attachKnowledge(hits) {
9031
+ async attachKnowledge(hits, snapshot) {
8808
9032
  if (hits.length === 0)
8809
9033
  return;
8810
9034
  const store = await this.storeHandle();
@@ -8819,7 +9043,7 @@ export class KnowledgeRuntime {
8819
9043
  * 一次召回十来张卡,而库里可能有上千张。
8820
9044
  */
8821
9045
  try {
8822
- placements = store.placementsOf([...new Set(hits.map(hit => hit.entityId))]);
9046
+ placements = snapshot?.placements.length ? new Map(snapshot.placements) : store.placementsOf([...new Set(hits.map(hit => hit.entityId))]);
8823
9047
  }
8824
9048
  catch {
8825
9049
  return;
@@ -8841,7 +9065,14 @@ export class KnowledgeRuntime {
8841
9065
  const wanted = new Set(perHit.flat().map(row => row.knowledgeId));
8842
9066
  if (wanted.size === 0)
8843
9067
  return;
8844
- const rows = await this.readKnowledgeRows(knowledge, [...wanted]);
9068
+ const rows = snapshot && snapshot.knowledge.length > 0
9069
+ ? new Map(snapshot.knowledge.filter(row => wanted.has(row.id) && knowledge !== null)
9070
+ .flatMap(row => {
9071
+ // Deleted facts never reappear through a historical snapshot.
9072
+ const live = store.raw('SELECT status FROM knowledge WHERE id = ?').get(row.id);
9073
+ return live ? [[row.id, recallKnowledgeOf(assessKnowledgeEvidence({ ...row, status: live.status }, store), '')]] : [];
9074
+ }))
9075
+ : await this.readKnowledgeRows(knowledge, [...wanted]);
8845
9076
  if (rows.size === 0)
8846
9077
  return;
8847
9078
  hits.forEach((hit, index) => {
@@ -8872,12 +9103,12 @@ export class KnowledgeRuntime {
8872
9103
  if (row.mergedInto !== '')
8873
9104
  followup.set(row.mergedInto, row.id);
8874
9105
  else
8875
- out.set(row.id, recallKnowledgeOf(row, ''));
9106
+ out.set(row.id, recallKnowledgeOf(assessKnowledgeEvidence(row, (await this.storeHandle())), ''));
8876
9107
  }
8877
9108
  if (followup.size > 0) {
8878
9109
  rows = await store.list({ ids: [...followup.keys()], limit: 500 });
8879
9110
  for (const row of rows.knowledge)
8880
- out.set(row.id, recallKnowledgeOf(row, ''));
9111
+ out.set(row.id, recallKnowledgeOf(assessKnowledgeEvidence(row, (await this.storeHandle())), ''));
8881
9112
  // 目标也没了(例如它的来源被删了):那条落位就是悬空的,如实**不显示**,
8882
9113
  // 而不是把一条已经不存在的事实塞进上下文。
8883
9114
  }
@@ -8927,27 +9158,68 @@ export class KnowledgeRuntime {
8927
9158
  return failure(empty(), 'LIMIT_INVALID', '召回 top_k 必须是 1 到 50 的整数', false);
8928
9159
  if (!Number.isInteger(requestedTopN) || requestedTopN < 1 || (options.contextTopN !== undefined && requestedTopN > recallTopK))
8929
9160
  return failure(empty(), 'CONTEXT_TOP_N_INVALID', `上下文 top_n 必须是 1 到 ${recallTopK} 的整数`, false);
8930
- if (this.units.length === 0) {
8931
- await this.loadCompiledState();
8932
- if (this.units.length === 0) {
8933
- return failure(empty(), 'INDEX_NOT_READY', '知识索引尚未构建,请先编译知识库', true, '先执行 rebuild_knowledge,再进行召回。');
8934
- }
8935
- }
8936
- const eventLineage = withRun(lineage, 'recall');
8937
- const indexVersion = (await this.loadVectorIndex())?.manifest.version;
8938
- let compileRunId;
8939
- let knowledgeIndexVersion;
8940
9161
  try {
8941
- const manifest = JSON.parse(await this.readArtifact('build-manifest.json'));
8942
- if (typeof manifest.compileRunId === 'string' && manifest.compileRunId.trim())
8943
- compileRunId = manifest.compileRunId;
8944
- if (typeof manifest.version === 'number' && Number.isInteger(manifest.version))
8945
- knowledgeIndexVersion = manifest.version;
9162
+ await this.environmentGuard?.();
8946
9163
  }
8947
- catch { /* the first build has not happened yet */ }
8948
- const eligible = this.units.filter(unit => includeProblems || unit.status !== 'problem');
9164
+ catch (cause) {
9165
+ return failure(empty(), 'ENVIRONMENT_NOT_READY', cause instanceof Error ? cause.message : String(cause), true, '在 Tita 设置中完成环境准备并重新自检。');
9166
+ }
9167
+ const derived = await this.derivedHandle();
9168
+ const liveCards = await this.loadCards();
9169
+ const liveKeys = new Set(liveCards.map(card => cardKey(card)));
9170
+ const snapshotHash = derived?.artifactHash(COMPILE_SNAPSHOT);
9171
+ const cached = snapshotHash && this.recallSnapshotCache?.hash === snapshotHash ? this.recallSnapshotCache : undefined;
9172
+ const raw = cached ? undefined : derived?.artifact(COMPILE_SNAPSHOT);
9173
+ let snapshot;
9174
+ if (cached)
9175
+ snapshot = cached.snapshot;
9176
+ else if (raw)
9177
+ snapshot = JSON.parse(raw);
9178
+ else {
9179
+ // Upgrade path for stores built before compiled snapshots existed.
9180
+ const manifest = JSON.parse(derived?.artifact('build-manifest.json') ?? '{}');
9181
+ snapshot = {
9182
+ version: manifest.version ?? 0, compileRunId: manifest.compileRunId,
9183
+ notes: JSON.parse(derived?.artifact(WIKI_NOTES) ?? derived?.artifact(LEGACY_UNITS) ?? '[]'),
9184
+ cards: liveCards, graph: JSON.parse(derived?.artifact('knowledge_graph.json') ?? '{"nodes":[],"edges":[]}'),
9185
+ diagnosisGraph: JSON.parse(derived?.artifact(DIAGNOSIS_GRAPH) ?? '{"nodes":[],"edges":[]}'),
9186
+ placements: [], knowledge: [],
9187
+ };
9188
+ }
9189
+ if (snapshot.notes.length === 0)
9190
+ return failure(empty(), 'INDEX_NOT_READY', '知识索引尚未构建,请先编译知识库', true, '先执行 rebuild_knowledge,再进行召回。');
9191
+ const capturedFts = { ready: derived?.hasFts() ?? false, hits: derived?.searchFts(query, candidatePool) ?? [] };
9192
+ const capturedVector = cached ? cached.vector : decodeVectorIndex(derived?.vectorIndex(), ENCODER_FINGERPRINT);
9193
+ if (this.environmentGuard && recallMode !== 'fts' && (!capturedVector
9194
+ || capturedVector.manifest.provider !== this.config.embeddingProvider
9195
+ || capturedVector.manifest.dimension !== this.config.embeddingDimension
9196
+ || capturedVector.manifest.model !== this.embeddingModelId
9197
+ || capturedVector.manifest.modelFingerprint !== this.embeddingFingerprint())) {
9198
+ return failure(empty(), 'INDEX_NOT_READY', '环境已准备,但当前模型的向量索引尚未构建,请重新编译知识库', true, '执行 rebuild_knowledge,完成后再召回。');
9199
+ }
9200
+ if (snapshotHash)
9201
+ this.recallSnapshotCache = { hash: snapshotHash, snapshot, vector: capturedVector };
9202
+ const eventLineage = withRun(lineage, 'recall');
9203
+ const indexVersion = capturedVector?.manifest.version;
9204
+ const compileRunId = snapshot.compileRunId;
9205
+ const knowledgeIndexVersion = snapshot.version;
9206
+ const truthAtRead = await this.storeHandle();
9207
+ const liveSourceIds = new Set(truthAtRead?.raw('SELECT entity_id FROM source').all()?.map(row => row.entity_id) ?? []);
9208
+ const snapshotCardByKey = new Map(snapshot.cards.map(card => [cardKey(card), card]));
9209
+ for (const key of liveKeys) {
9210
+ if (snapshotCardByKey.get(key)?.sourceRefs.some(ref => !liveSourceIds.has(ref)))
9211
+ liveKeys.delete(key);
9212
+ }
9213
+ let publishedGraph = visibleGraph(snapshot.graph, truthAtRead ? liveGraphKeys(truthAtRead) : new Set());
9214
+ const currentByKey = new Map(liveCards.map(card => [cardKey(card), card]));
9215
+ let cards = snapshot.cards.filter(card => liveKeys.has(cardKey(card))).map(card => {
9216
+ const current = currentByKey.get(cardKey(card));
9217
+ return { ...card, status: current.status, annotations: current.annotations, review: current.review,
9218
+ metadata: { ...card.metadata, ...(current.metadata.evidence_needs_review === true ? { evidence_needs_review: true } : {}) } };
9219
+ });
9220
+ const eligible = snapshot.notes.filter(unit => liveKeys.has(`${unit.entityType}:${unit.entityId}`)
9221
+ && (includeProblems || currentByKey.get(`${unit.entityType}:${unit.entityId}`)?.status !== 'problem'));
8949
9222
  const eligibleIds = new Set(eligible.map(unit => unit.unitId));
8950
- const cards = this.cards.length > 0 ? this.cards : await this.loadCards();
8951
9223
  const queryTerms = [...new Set(segment(query))];
8952
9224
  const warnings = [];
8953
9225
  // 一份 SourceRaw 语料,两处要用:关键词路的**查询词覆盖率**必须对着「建索引时那份
@@ -8977,16 +9249,19 @@ export class KnowledgeRuntime {
8977
9249
  const relationMs = performance.now() - relationStarted;
8978
9250
  const ftsStarted = performance.now();
8979
9251
  const ftsOutcome = useFts
8980
- ? await this.ftsRoute(query, eligible, candidatePool, this.config.keywordThreshold, titleOf)
9252
+ ? await this.ftsRoute(query, eligible, candidatePool, this.config.keywordThreshold, titleOf, capturedFts)
8981
9253
  : { ranking: [], rows: [], diagnostic: { route: 'fts', status: 'skipped', reason: '当前模式未启用关键词路' } };
8982
9254
  const ftsMs = performance.now() - ftsStarted;
8983
9255
  const vectorStarted = performance.now();
8984
9256
  const vectorOutcome = useVector
8985
- ? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold, knowledgeIndexVersion)
9257
+ ? await this.vectorRoute(query, eligibleIds, candidatePool, this.config.vectorThreshold, knowledgeIndexVersion, capturedVector)
8986
9258
  : { ranking: [], rows: [], diagnostic: { route: 'vector', status: 'skipped', reason: '当前模式未启用向量路' }, health: { ok: false, reason: '当前模式未启用向量路' } };
9259
+ if (this.environmentGuard && useVector && vectorOutcome.diagnostic.status === 'degraded') {
9260
+ return failure(empty(), 'ENVIRONMENT_NOT_READY', `向量推理失败:${vectorOutcome.diagnostic.reason ?? '未知原因'}`, true, '在 Tita 设置中重新自检环境后再召回。');
9261
+ }
8987
9262
  const vectorMs = performance.now() - vectorStarted;
8988
9263
  const graphStarted = performance.now();
8989
- const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems) : [];
9264
+ const graphRanking = useGraphBoost ? await this.graphBoostRanking(query, eligible, includeProblems, publishedGraph, snapshot.diagnosisGraph) : [];
8990
9265
  const graphMs = performance.now() - graphStarted;
8991
9266
  if (coordinateForRecall.diagnostic.status === 'degraded' && coordinateForRecall.diagnostic.reason)
8992
9267
  warnings.push(coordinateForRecall.diagnostic.reason);
@@ -9057,10 +9332,18 @@ export class KnowledgeRuntime {
9057
9332
  if (reranked.warning)
9058
9333
  warnings.push(reranked.warning);
9059
9334
  }
9335
+ if (this.environmentGuard && rerank.status === 'unavailable') {
9336
+ return failure(empty(), 'ENVIRONMENT_NOT_READY', `重排推理失败:${rerank.error ?? '未知原因'}`, true, '在 Tita 设置中重新自检环境后再召回。');
9337
+ }
9060
9338
  // 重排换的是相关性顺序,评分加权仍然要压在最上面 —— 否则「review 过的排前面一点」
9061
9339
  // 只在关掉重排时成立,而重排默认是开着的。
9062
9340
  hits = [...hits].sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId));
9063
9341
  // --- R4 · 证据片段 -----------------------------------------------------
9342
+ const truth = await this.storeHandle();
9343
+ cards = cards.filter(card => truth?.card(card.entityId) !== undefined && card.sourceRefs.every(ref => truth?.source(ref) !== undefined));
9344
+ const finalKeys = new Set(cards.map(card => cardKey(card)));
9345
+ publishedGraph = visibleGraph(publishedGraph, truth ? liveGraphKeys(truth) : new Set());
9346
+ hits = hits.filter(hit => finalKeys.has(`${hit.entityType}:${hit.entityId}`));
9064
9347
  await this.attachEvidence(hits, queryTerms, corpus);
9065
9348
  /**
9066
9349
  * --- R4.5 · 支撑这一段的知识(0069)--------------------------------------
@@ -9068,7 +9351,13 @@ export class KnowledgeRuntime {
9068
9351
  * 排在证据**之后**、去重**之前**:它挂在每条 note 上,按卡片去重时自然跟着
9069
9352
  * 被选中的那一条走(`ContextItem.knowledge` 读的就是 `notes[0].knowledge`)。
9070
9353
  */
9071
- await this.attachKnowledge(hits);
9354
+ await this.attachKnowledge(hits, snapshot);
9355
+ if (cards.some(card => card.metadata.evidence_needs_review === true))
9356
+ warnings.push('部分卡片缺少知识落位记录,删除来源后无法核对每段正文的归属,需要人工复核。');
9357
+ if (hits.some(hit => hit.knowledge?.some(row => row.requiresReview)))
9358
+ warnings.push('部分知识依据历史或未记录版本的证据,需人工复核;证据变化不表示知识已失效。');
9359
+ if (hits.some(hit => hit.knowledge?.length))
9360
+ warnings.push('引用核验仅检查来源定位;引用完整不代表陈述已通过语义核验。');
9072
9361
  // --- R5 · 弃答 ---------------------------------------------------------
9073
9362
  // 一路都没过阈值 = 这个知识库里没有能回答这个问题的东西。与其凑满 topN 让模型编,
9074
9363
  // 不如明确说「没有」:这是产品诚实性的一部分,也是 eval 里「不应召回」样本的落点。
@@ -9136,21 +9425,21 @@ export class KnowledgeRuntime {
9136
9425
  .map(([key, notes]) => ({ key, notes, card: cardByKey.get(key) }))
9137
9426
  .slice(0, contextTopN);
9138
9427
  const wiki = hitCards.map(item => item.card).filter((card) => card !== undefined);
9139
- const graph = this.oneHopGraph(new Set(hitCards.map(item => item.key)), includeProblems);
9428
+ const graph = this.oneHopGraph(new Set(hitCards.map(item => item.key)), includeProblems, publishedGraph);
9140
9429
  /**
9141
9430
  * **在去重之后**做 1 跳扩展(0050 的第二半)。
9142
9431
  *
9143
9432
  * 反过来(先扩展再去重)会把邻居与被命中的卡片混在一起,分不清「这是召回来的」
9144
9433
  * 还是「这是邻居」—— 而这两件事在上下文里必须能分开,前者是证据,后者是延伸。
9145
9434
  */
9146
- const expansion = this.expandWiki(hitCards.map(item => item.key), cardByKey, includeProblems);
9435
+ const expansion = this.expandWiki(hitCards.map(item => item.key), cardByKey, includeProblems, publishedGraph);
9147
9436
  /** 上下文条目:先命中的卡片(带它们的 note),再扩展来的卡片。 */
9148
9437
  const items = [
9149
9438
  ...hitCards.map(item => ({
9150
9439
  wiki: item.card ?? null,
9151
9440
  notes: item.notes,
9152
9441
  note: item.notes[0],
9153
- graph: this.oneHopGraph(new Set([item.key]), includeProblems),
9442
+ graph: this.oneHopGraph(new Set([item.key]), includeProblems, publishedGraph),
9154
9443
  reason: 'hit',
9155
9444
  ...(item.notes[0].evidence ? { evidence: item.notes[0].evidence } : {}),
9156
9445
  ...(item.notes[0].knowledge ? { knowledge: item.notes[0].knowledge } : {}),
@@ -9163,7 +9452,7 @@ export class KnowledgeRuntime {
9163
9452
  return {
9164
9453
  wiki: card,
9165
9454
  notes: [],
9166
- graph: this.oneHopGraph(new Set([key]), includeProblems),
9455
+ graph: this.oneHopGraph(new Set([key]), includeProblems, publishedGraph),
9167
9456
  reason: 'expansion',
9168
9457
  ...(from === undefined ? {} : { via: { entityType: from.entityType, entityId: from.entityId, title: from.title, relation: relation ?? '' } }),
9169
9458
  };
@@ -9271,11 +9560,11 @@ export class KnowledgeRuntime {
9271
9560
  * 2. **量纲**:返回**原始余弦**,不再做 `(cos+1)/2` 的映射 —— 那个映射让 `0.15` 这类阈值
9272
9561
  * 失去意义(余弦 0 → 0.5,轻松过线)。原始余弦是绝对量,可以直接用于弃答。
9273
9562
  */
9274
- async vectorRecall(query, eligibleIds, candidateLimit) {
9275
- const index = await this.loadVectorIndex();
9563
+ async vectorRecall(query, eligibleIds, candidateLimit, captured) {
9564
+ const index = captured ?? await this.loadVectorIndex();
9276
9565
  if (!index || index.manifest.itemCount === 0)
9277
9566
  throw new Error('VECTOR_INDEX_NOT_READY');
9278
- if (index.manifest.provider !== this.config.embeddingProvider || index.manifest.model !== this.embeddingModelId || index.manifest.dimension !== this.config.embeddingDimension)
9567
+ if (index.manifest.provider !== this.config.embeddingProvider || index.manifest.model !== this.embeddingModelId || index.manifest.modelFingerprint !== this.embeddingFingerprint() || index.manifest.dimension !== this.config.embeddingDimension)
9279
9568
  throw new Error('VECTOR_INDEX_CONFIG_MISMATCH');
9280
9569
  // 查询编码单独计时:模型没加载时这一段是 8 秒,加载之后是 0.3 秒 —— 这一段的
9281
9570
  // 数字就是「测试为什么慢」的答案本身,混进扫描时间里就看不出来了。
@@ -9327,6 +9616,44 @@ export class KnowledgeRuntime {
9327
9616
  embeddingModelId: this.embeddingModelId,
9328
9617
  };
9329
9618
  }
9619
+ /** Installed resources become available without restarting the host. */
9620
+ reloadResources() {
9621
+ this.embeddingWorker?.close();
9622
+ this.embeddingWorker = null;
9623
+ this.rerankWorker?.close();
9624
+ this.rerankWorker = null;
9625
+ this.encoderHealth = undefined;
9626
+ this.rerankUnavailable = undefined;
9627
+ this.updateConfig({ python: this.config.python, embeddingModel: this.config.embeddingModel, rerankModel: this.config.rerankModel });
9628
+ }
9629
+ async environmentProbe(kind) {
9630
+ await mkdir(this.rootDir, { recursive: true });
9631
+ if (kind === 'embedding') {
9632
+ this.encoderHealth = undefined;
9633
+ const health = await this.checkEncoder();
9634
+ return { ok: health.ok, message: health.ok ? `已完成 ${this.config.embeddingDimension} 维向量推理,未发现零向量或相同向量退化` : health.reason ?? '向量推理自检失败' };
9635
+ }
9636
+ if (this.config.rerankProvider === 'none')
9637
+ return { ok: false, message: '重排模型未启用' };
9638
+ try {
9639
+ if (!this.rerankWorker)
9640
+ this.rerankWorker = this.spawnWorker('RERANK');
9641
+ const response = await this.rerankWorker.request({ task: 'rerank', model: this.rerankModelPath, device: this.config.embeddingDevice,
9642
+ query: '销售额按签收时间统计', documents: ['销售额按签收时间统计,签收后计入销售额。', '烹饪意大利面需要煮沸清水并加入面条。'] }, this.config.rerankTimeoutMs);
9643
+ const scores = response?.scores;
9644
+ if (!Array.isArray(scores) || scores.length !== 2 || scores.some(value => typeof value !== 'number' || !Number.isFinite(value)))
9645
+ throw new Error('重排自检返回分数无效');
9646
+ if (Number(response.vocabSize) < 1000 || !Number.isFinite(Number(response.vocabSize)))
9647
+ throw new Error('重排模型分词器词表缺失或退化');
9648
+ if (scores[0] <= scores[1])
9649
+ throw new Error('重排模型未能把直接相关文本排在无关文本前');
9650
+ this.rerankUnavailable = undefined;
9651
+ return { ok: true, message: '分词器与实际重排推理通过自检' };
9652
+ }
9653
+ catch (cause) {
9654
+ return { ok: false, message: cause instanceof Error ? cause.message : String(cause) };
9655
+ }
9656
+ }
9330
9657
  async embeddingHealth() {
9331
9658
  const index = await this.loadVectorIndex();
9332
9659
  return success({
@@ -9437,9 +9764,12 @@ export class KnowledgeRuntime {
9437
9764
  * 「盘上还没有编译产物」(`needsBuild`),要不要编由界面和 Agent 自己决定 ——
9438
9765
  * 与 `recall` 同一条规矩:先读盘,读不到再说。
9439
9766
  */
9440
- if (this.units.length === 0 && this.graph.nodes.length === 0)
9441
- await this.loadCompiledState();
9442
- const needsBuild = this.units.length === 0 && this.graph.nodes.length === 0;
9767
+ const derived = await this.derivedHandle();
9768
+ const snapshot = derived?.artifact(COMPILE_SNAPSHOT);
9769
+ const rawGraph = snapshot ? JSON.parse(snapshot).graph : JSON.parse(derived?.artifact('knowledge_graph.json') ?? '{"nodes":[],"edges":[]}');
9770
+ const truth = await this.storeHandle();
9771
+ const graph = visibleGraph(rawGraph, truth ? liveGraphKeys(truth) : new Set());
9772
+ const needsBuild = !snapshot && graph.nodes.length === 0;
9443
9773
  const attachLineage = (data) => eventLineage ? { ...data, lineage: eventLineage } : data;
9444
9774
  /** 空图 + 一句为什么空:调用方(尤其是 Agent)不能把「还没编译」读成「这个库没有知识」。 */
9445
9775
  const attachBuild = (data) => needsBuild ? { ...data, needsBuild: true } : data;
@@ -9452,11 +9782,11 @@ export class KnowledgeRuntime {
9452
9782
  return success(attachLineage({ types: this.wikiSchema().types.filter(def => def.enabled).map(def => def.id), relationTypes: ['supported_by', RELATES_TO] }));
9453
9783
  const includeProblem = options.includeProblem === true;
9454
9784
  const visible = (node) => includeProblem || node.status !== 'problem';
9455
- const allNodes = this.graph.nodes
9785
+ const allNodes = graph.nodes
9456
9786
  .filter(visible)
9457
9787
  .filter(node => !options.status || node.status === options.status);
9458
9788
  const nodes = allNodes.filter(node => !entityType || node.type === entityType);
9459
- 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)));
9789
+ const edges = graph.edges.filter(edge => allNodes.some(node => `${node.type}:${node.id}` === String(edge.from)) && allNodes.some(node => `${node.type}:${node.id}` === String(edge.to)));
9460
9790
  if (action === 'list')
9461
9791
  return success(attachLineage(attachBuild({ nodes: nodes.slice(0, limit), edges: edges.slice(0, limit) })));
9462
9792
  const nodeKey = (rawId, rawType) => {