deeptutor-knowledge 1.7.0__py3-none-any.whl

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 (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2570 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Knowledge Base Manager
4
+
5
+ Manages multiple knowledge bases and provides utilities for accessing them.
6
+ """
7
+
8
+ from contextlib import contextmanager
9
+ from copy import deepcopy
10
+ from datetime import datetime, timedelta
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ from pathlib import Path
16
+ import shutil
17
+ import stat
18
+ import sys
19
+ from threading import RLock
20
+ import time
21
+ from typing import Any
22
+ from urllib.parse import urlparse
23
+
24
+ from deeptutor.knowledge.kb_types import (
25
+ IMA_KB_TYPE,
26
+ LIGHTRAG_SERVER_KB_TYPE,
27
+ LINKED_KB_TYPE,
28
+ MARGINNOTE4_KB_TYPE,
29
+ OBSIDIAN_KB_TYPE,
30
+ SUBAGENT_KB_TYPE,
31
+ WEKNORA_KB_TYPE,
32
+ external_root_of,
33
+ is_connected_kb,
34
+ )
35
+ from deeptutor.knowledge.manifest import iter_kb_documents
36
+ from deeptutor.knowledge.naming import validate_knowledge_base_name
37
+ from deeptutor.services.file_io import atomic_write_json
38
+ from deeptutor.services.rag.factory import (
39
+ DEFAULT_PROVIDER,
40
+ IMA_PROVIDER,
41
+ KNOWN_PROVIDERS,
42
+ LIGHTRAG_PROVIDER,
43
+ LIGHTRAG_SERVER_PROVIDER,
44
+ PAGEINDEX_OSS_PROVIDER,
45
+ PAGEINDEX_PROVIDER,
46
+ WEKNORA_PROVIDER,
47
+ has_ready_provider_index,
48
+ normalize_provider_name,
49
+ provider_uses_embedding_versions,
50
+ )
51
+ from deeptutor.services.rag.index_probe import (
52
+ inspect_kb_versions,
53
+ inspect_provider_version,
54
+ provider_failure_summary,
55
+ )
56
+ from deeptutor.services.web_source.crawler import MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES
57
+ from deeptutor.utils.file_types import FileTypeRouter
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ def _assert_move_id_available(base_dir: Path, name: str) -> None:
63
+ """Prevent a new registration from shadowing a saved moved-KB redirect."""
64
+ from deeptutor.services.workspace.knowledge import (
65
+ canonical_kb_id,
66
+ qualified_kb_id,
67
+ workspace_id_for_kb_base_dir,
68
+ )
69
+
70
+ workspace_id = workspace_id_for_kb_base_dir(base_dir)
71
+ if workspace_id is None:
72
+ # A manager targeting a partner's separate directory has no catalog ID.
73
+ return
74
+ resource_id = qualified_kb_id(name, workspace_id)
75
+ if canonical_kb_id(resource_id) != resource_id:
76
+ raise ValueError("This knowledge base ID is reserved by an earlier move.")
77
+
78
+
79
+ # How long an entry can be missing its KB directory before ``list_knowledge_bases``
80
+ # treats it as a stale orphan. The KB create flow writes the "initializing"
81
+ # config entry before the on-disk folder is created, so a too-short grace would
82
+ # let a list-call mid-creation racy-delete the entry. 60s is comfortably longer
83
+ # than the create handshake while still keeping multi-day zombies out.
84
+ _ORPHAN_PRUNE_GRACE_SECONDS = 60
85
+
86
+ # External/manual index changes are re-probed within this interval (#1711).
87
+ # Normal indexing writes kb_config.json, which invalidates the snapshot at once.
88
+ _CATALOG_CACHE_SECONDS = 2.0
89
+
90
+
91
+ def _entry_updated_after(kb_entry: dict | None, cutoff: datetime) -> bool:
92
+ """Return True when the entry's ``updated_at`` is strictly after ``cutoff``.
93
+
94
+ Entries without a parseable timestamp are treated as old (return False) —
95
+ a long-stuck orphan that crashed before recording a timestamp should still
96
+ get pruned.
97
+ """
98
+ if not isinstance(kb_entry, dict):
99
+ return False
100
+ raw = kb_entry.get("updated_at")
101
+ if not isinstance(raw, str):
102
+ return False
103
+ try:
104
+ return datetime.fromisoformat(raw) > cutoff
105
+ except ValueError:
106
+ return False
107
+
108
+
109
+ def _provider_from_version_entry(entry: dict[str, Any]) -> str:
110
+ provider = str(entry.get("provider") or "").strip().lower()
111
+ if provider in KNOWN_PROVIDERS:
112
+ return provider
113
+ signature = str(entry.get("signature") or "").strip().lower()
114
+ return signature if signature in KNOWN_PROVIDERS else DEFAULT_PROVIDER
115
+
116
+
117
+ def _detect_provider_from_versions(versions: list[dict[str, Any]]) -> str:
118
+ for entry in versions:
119
+ provider = _provider_from_version_entry(entry)
120
+ if provider != DEFAULT_PROVIDER:
121
+ return provider
122
+ return DEFAULT_PROVIDER
123
+
124
+
125
+ # Cross-platform file locking. Writers no longer take locks — every JSON
126
+ # write in this module goes through ``atomic_write_json`` (temp file +
127
+ # ``os.replace``), so readers always see a complete previous or new file.
128
+ @contextmanager
129
+ def file_lock_shared(file_handle):
130
+ """Acquire a shared (read) lock on a file - cross-platform."""
131
+ if sys.platform == "win32":
132
+ import msvcrt
133
+
134
+ msvcrt.locking(file_handle.fileno(), msvcrt.LK_NBLCK, 1)
135
+ try:
136
+ yield
137
+ finally:
138
+ file_handle.seek(0)
139
+ msvcrt.locking(file_handle.fileno(), msvcrt.LK_UNLCK, 1)
140
+ else:
141
+ import fcntl
142
+
143
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_SH)
144
+ try:
145
+ yield
146
+ finally:
147
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
148
+
149
+
150
+ def _get_embedding_fingerprint() -> tuple[str, int] | None:
151
+ """Return ``(model_name, dimension)`` of the active embedding config."""
152
+ try:
153
+ from deeptutor.services.embedding import get_embedding_config
154
+
155
+ cfg = get_embedding_config()
156
+ return (cfg.model, cfg.dim)
157
+ except Exception:
158
+ return None
159
+
160
+
161
+ def _reconcile_embedding_flags(knowledge_bases: dict, base_dir: Path | None = None) -> bool:
162
+ """Reconcile per-KB embedding flags against the on-disk index versions.
163
+
164
+ For each KB we check the flat ``version-N`` directories (plus legacy
165
+ layouts) for a version matching the active embedding signature:
166
+
167
+ * Match found → clear ``needs_reindex`` and ``embedding_mismatch`` (the
168
+ user has switched back to a previously-indexed configuration).
169
+ * No match, but the KB has a stored ``embedding_model`` that differs
170
+ from the active fingerprint → set both flags so the UI surfaces a
171
+ "Re-index" CTA.
172
+
173
+ Returns ``True`` when any entry changed.
174
+ """
175
+ from deeptutor.services.rag.embedding_signature import signature_from_embedding_config
176
+ from deeptutor.services.rag.index_versioning import (
177
+ find_matching_version,
178
+ )
179
+
180
+ fp = _get_embedding_fingerprint()
181
+ signature = signature_from_embedding_config()
182
+ changed = False
183
+ if base_dir is not None:
184
+ from deeptutor.services.rag.embedding_binding import reconcile_bindings
185
+
186
+ changed = reconcile_bindings(knowledge_bases, base_dir)
187
+
188
+ for kb_name, kb_entry in knowledge_bases.items():
189
+ if not isinstance(kb_entry, dict):
190
+ continue
191
+
192
+ # Connected KBs (Obsidian vaults, linked indexes) are pointers with no
193
+ # embedding lifecycle we manage — compatibility is checked once at
194
+ # connect time, never reconciled here.
195
+ if is_connected_kb(kb_entry):
196
+ continue
197
+
198
+ provider = normalize_provider_name(kb_entry.get("rag_provider"))
199
+ if provider == LIGHTRAG_PROVIDER and base_dir is not None:
200
+ from deeptutor.services.embedding.config import embedding_config_scope
201
+ from deeptutor.services.rag.embedding_binding import (
202
+ binding_status,
203
+ bound_graph_storage_root,
204
+ entry_signature,
205
+ )
206
+ from deeptutor.services.rag.pipelines.lightrag.storage import (
207
+ embedding_matches,
208
+ latest_published_root,
209
+ )
210
+
211
+ kb_dir = base_dir / kb_name
212
+ kb_entry["index_versions"] = inspect_kb_versions(kb_dir, provider)
213
+ published = latest_published_root(kb_dir)
214
+ state, bound_config = binding_status(kb_entry)
215
+ if state in {"missing", "unconfigured"}:
216
+ continue
217
+ if bound_config is not None:
218
+ with embedding_config_scope(bound_config):
219
+ try:
220
+ published = bound_graph_storage_root(kb_dir, provider, published)
221
+ except ValueError:
222
+ pass # Check the unmatched published index below.
223
+ lightrag_mismatch = published is not None and not embedding_matches(
224
+ published, entry_signature(kb_entry)
225
+ )
226
+ lightrag_mismatch = lightrag_mismatch or state == "changed"
227
+ if lightrag_mismatch and not kb_entry.get("embedding_mismatch"):
228
+ kb_entry["embedding_mismatch"] = True
229
+ kb_entry["needs_reindex"] = True
230
+ changed = True
231
+ elif (
232
+ published is not None
233
+ and not lightrag_mismatch
234
+ and kb_entry.pop("embedding_mismatch", None)
235
+ ):
236
+ kb_entry["needs_reindex"] = False
237
+ changed = True
238
+ continue
239
+
240
+ if kb_entry.get("embedding_selection"):
241
+ continue
242
+
243
+ if signature is None and not fp:
244
+ continue
245
+
246
+ if not provider_uses_embedding_versions(provider):
247
+ kb_dir = (base_dir / kb_name) if base_dir is not None else None
248
+ if kb_dir is not None:
249
+ versions = inspect_kb_versions(kb_dir, provider)
250
+ kb_entry["index_versions"] = versions
251
+ if has_ready_provider_index(kb_dir, provider):
252
+ mutated_local = False
253
+ had_embedding_mismatch = bool(kb_entry.get("embedding_mismatch"))
254
+ if kb_entry.get("embedding_mismatch"):
255
+ kb_entry.pop("embedding_mismatch", None)
256
+ mutated_local = True
257
+ if had_embedding_mismatch and kb_entry.get("needs_reindex"):
258
+ kb_entry["needs_reindex"] = False
259
+ mutated_local = True
260
+ if mutated_local:
261
+ changed = True
262
+ continue
263
+
264
+ kb_dir = (base_dir / kb_name) if base_dir is not None else None
265
+ matched = False
266
+ if kb_dir is not None and signature is not None:
267
+ matched_entry = find_matching_version(kb_dir, signature)
268
+ matched = (
269
+ matched_entry is not None
270
+ and inspect_provider_version(matched_entry, DEFAULT_PROVIDER).ready
271
+ )
272
+
273
+ if matched:
274
+ mutated_local = False
275
+ if kb_entry.get("needs_reindex"):
276
+ kb_entry["needs_reindex"] = False
277
+ mutated_local = True
278
+ if kb_entry.get("embedding_mismatch"):
279
+ kb_entry.pop("embedding_mismatch", None)
280
+ mutated_local = True
281
+ if mutated_local:
282
+ changed = True
283
+ # Refresh the surfaced version list either way so the UI sees
284
+ # accurate state.
285
+ if kb_dir is not None:
286
+ kb_entry["index_versions"] = inspect_kb_versions(kb_dir, provider)
287
+ continue
288
+
289
+ # No matching ready index version on disk.
290
+ stored_model = kb_entry.get("embedding_model")
291
+ # Empty/in-progress version dirs are created before indexing finishes.
292
+ # They should not mark a brand-new KB as needing re-index.
293
+ versions = []
294
+ has_ready_version = False
295
+ if kb_dir is not None:
296
+ versions = inspect_kb_versions(kb_dir, provider)
297
+ has_ready_version = any(bool(version.get("ready")) for version in versions)
298
+ kb_entry["index_versions"] = versions
299
+
300
+ if not has_ready_version and not stored_model:
301
+ continue
302
+
303
+ current_model = fp[0] if fp else ""
304
+ current_dim = fp[1] if fp else 0
305
+ stored_dim = kb_entry.get("embedding_dim")
306
+ mismatch = (stored_model and stored_model != current_model) or (
307
+ stored_dim is not None and current_dim and stored_dim != current_dim
308
+ )
309
+ # If ready versions exist but none match active signature, that's also a mismatch.
310
+ if has_ready_version:
311
+ mismatch = True
312
+
313
+ if mismatch and not kb_entry.get("embedding_mismatch"):
314
+ kb_entry["embedding_mismatch"] = True
315
+ if not kb_entry.get("needs_reindex"):
316
+ kb_entry["needs_reindex"] = True
317
+ changed = True
318
+ elif not mismatch and kb_entry.get("embedding_mismatch"):
319
+ kb_entry.pop("embedding_mismatch", None)
320
+ changed = True
321
+
322
+ return changed
323
+
324
+
325
+ class KnowledgeBaseManager:
326
+ """Manager for knowledge bases"""
327
+
328
+ def __init__(self, base_dir="./data/knowledge_bases"):
329
+ self.base_dir = Path(base_dir)
330
+ self.base_dir.mkdir(parents=True, exist_ok=True)
331
+
332
+ # Config file to track knowledge bases
333
+ self.config_file = self.base_dir / "kb_config.json"
334
+ self._catalog_lock = RLock()
335
+ self._config_cache = None
336
+ self._info_cache: dict[tuple, tuple] = {}
337
+ self.config = self._load_config()
338
+
339
+ # PocketBase sync — enabled when integrations.pocketbase_url is set.
340
+ # The local JSON file stays the source of truth; PocketBase gets a
341
+ # mirrored copy for admin-panel visibility and future multi-user access.
342
+ from deeptutor.services.pocketbase_client import is_pocketbase_enabled
343
+
344
+ self._pb_enabled = is_pocketbase_enabled()
345
+
346
+ def _catalog_revision(self) -> tuple:
347
+ from deeptutor.multi_user.context import get_current_user_or_none
348
+ from deeptutor.services.rag.embedding_signature import signature_from_embedding_config
349
+ from deeptutor.services.workspace.context import current_workspace_id
350
+
351
+ try:
352
+ stat_result = self.config_file.stat()
353
+ revision = (stat_result.st_ino, stat_result.st_mtime_ns, stat_result.st_size)
354
+ except FileNotFoundError:
355
+ revision = None
356
+ signature = signature_from_embedding_config()
357
+ user = get_current_user_or_none()
358
+ return (
359
+ revision,
360
+ signature.hash() if signature is not None else None,
361
+ user.id if user is not None else None,
362
+ current_workspace_id(),
363
+ )
364
+
365
+ def _load_config(self) -> dict:
366
+ # The registry is atomic; one stat detects writes by other processes.
367
+ # Single-flight the expensive reconciliation on slow volumes (#1711).
368
+ with self._catalog_lock:
369
+ revision = self._catalog_revision()
370
+ cached = self._config_cache
371
+ if cached and cached[0] == revision and time.monotonic() < cached[1]:
372
+ return deepcopy(cached[2])
373
+ config = self._read_and_reconcile_config()
374
+ self._config_cache = (
375
+ self._catalog_revision(),
376
+ time.monotonic() + _CATALOG_CACHE_SECONDS,
377
+ deepcopy(config),
378
+ )
379
+ return config
380
+
381
+ def _read_and_reconcile_config(self) -> dict:
382
+ """Load knowledge base configuration from the canonical kb_config.json file."""
383
+ if self.config_file.exists():
384
+ try:
385
+ with open(self.config_file, encoding="utf-8") as f:
386
+ with file_lock_shared(f):
387
+ content = f.read()
388
+ if not content.strip():
389
+ # Empty file, return default
390
+ return {"knowledge_bases": {}}
391
+ config = json.loads(content)
392
+
393
+ # Ensure knowledge_bases key exists
394
+ if "knowledge_bases" not in config:
395
+ config["knowledge_bases"] = {}
396
+
397
+ # Migration: remove old "default" field if present
398
+ if "default" in config:
399
+ del config["default"]
400
+ # Note: Don't save during load to avoid recursion issues
401
+ # The next _save_config() call will persist this change
402
+
403
+ # Migration: normalize unknown/removed providers to the default
404
+ # and mark them for rebuild. Known non-default providers are
405
+ # first-class engines and must be preserved.
406
+ knowledge_bases = config.get("knowledge_bases", {})
407
+ config_changed = False
408
+ for kb_name, kb_entry in knowledge_bases.items():
409
+ if not isinstance(kb_entry, dict):
410
+ continue
411
+
412
+ # Connected KBs (Obsidian vaults, linked indexes) are
413
+ # pointers with no index pipeline — none of the
414
+ # provider/embedding normalization below applies. Leave
415
+ # their type/external pointer untouched.
416
+ if is_connected_kb(kb_entry):
417
+ continue
418
+
419
+ raw_provider = kb_entry.get("rag_provider")
420
+ provider = normalize_provider_name(raw_provider)
421
+ if kb_entry.get("rag_provider") != provider:
422
+ kb_entry["rag_provider"] = provider
423
+ config_changed = True
424
+
425
+ raw_provider_text = str(raw_provider or "").strip().lower()
426
+ if raw_provider_text and raw_provider_text not in KNOWN_PROVIDERS:
427
+ if not kb_entry.get("needs_reindex", False):
428
+ kb_entry["needs_reindex"] = True
429
+ config_changed = True
430
+
431
+ kb_dir = self.base_dir / kb_name
432
+ legacy_storage = kb_dir / "rag_storage"
433
+ has_llamaindex_index = has_ready_provider_index(kb_dir, DEFAULT_PROVIDER)
434
+ if (
435
+ provider == DEFAULT_PROVIDER
436
+ and legacy_storage.exists()
437
+ and legacy_storage.is_dir()
438
+ and not has_llamaindex_index
439
+ ):
440
+ if not kb_entry.get("needs_reindex", False):
441
+ kb_entry["needs_reindex"] = True
442
+ config_changed = True
443
+ if kb_entry.get("status") == "ready":
444
+ kb_entry["status"] = "needs_reindex"
445
+ config_changed = True
446
+
447
+ if _reconcile_embedding_flags(knowledge_bases, self.base_dir):
448
+ config_changed = True
449
+
450
+ if config_changed:
451
+ try:
452
+ atomic_write_json(self.config_file, config)
453
+ except Exception as save_err:
454
+ logger.warning(f"Failed to persist normalized KB config: {save_err}")
455
+
456
+ return config
457
+ except (json.JSONDecodeError, Exception) as e:
458
+ logger.warning(f"Error loading config: {e}")
459
+ return {"knowledge_bases": {}}
460
+ return {"knowledge_bases": {}}
461
+
462
+ def _save_config(self):
463
+ """Save knowledge base configuration.
464
+
465
+ Written via temp-file + ``os.replace`` so concurrent readers only
466
+ ever see the previous or the new file — ``open(..., "w")`` used to
467
+ truncate the config before the lock was even acquired.
468
+ """
469
+ with self._catalog_lock:
470
+ atomic_write_json(self.config_file, self.config)
471
+ self._config_cache = None
472
+ self._info_cache.clear()
473
+
474
+ def _sync_kb_to_pb(self, name: str, kb_entry: dict) -> None:
475
+ """
476
+ Mirror a KB metadata entry to PocketBase (best-effort, non-blocking).
477
+ Called after every local config save when PocketBase is enabled.
478
+ """
479
+ if not self._pb_enabled:
480
+ return
481
+ try:
482
+ from deeptutor.services.pocketbase_client import get_pb_client
483
+
484
+ pb = get_pb_client()
485
+ records = pb.collection("knowledge_bases").get_full_list(
486
+ query_params={"filter": f'kb_name="{name}"'}
487
+ )
488
+ payload = {
489
+ "kb_name": name,
490
+ "description": kb_entry.get("description", f"Knowledge base: {name}"),
491
+ "rag_provider": kb_entry.get("rag_provider", "llamaindex"),
492
+ "needs_reindex": bool(kb_entry.get("needs_reindex", False)),
493
+ "status": kb_entry.get("status", "unknown"),
494
+ "kb_created_at": kb_entry.get("created_at", ""),
495
+ }
496
+ if records:
497
+ pb.collection("knowledge_bases").update(records[0].id, payload)
498
+ else:
499
+ pb.collection("knowledge_bases").create(payload)
500
+ except Exception as exc:
501
+ logger.debug(f"PocketBase KB sync failed for '{name}': {exc}")
502
+
503
+ def update_kb_status(
504
+ self,
505
+ name: str,
506
+ status: str,
507
+ progress: dict | None = None,
508
+ ):
509
+ """
510
+ Update knowledge base status and progress in kb_config.json.
511
+
512
+ When PocketBase is enabled, the updated entry is also mirrored to the
513
+ PocketBase knowledge_bases collection (best-effort).
514
+
515
+ Args:
516
+ name: Knowledge base name
517
+ status: Status string ("initializing", "processing", "ready", "error")
518
+ progress: Optional progress dict with keys like:
519
+ - stage: Current stage name
520
+ - message: Human-readable message
521
+ - percent: Progress percentage (0-100)
522
+ - current: Current item number
523
+ - total: Total items
524
+ - file_name: Current file being processed
525
+ - error: Error message (if status is "error")
526
+ """
527
+ # Reload config to get latest state
528
+ self.config = self._load_config()
529
+
530
+ if "knowledge_bases" not in self.config:
531
+ self.config["knowledge_bases"] = {}
532
+
533
+ if name not in self.config["knowledge_bases"]:
534
+ # Auto-register if not exists
535
+ self.config["knowledge_bases"][name] = {
536
+ "path": name,
537
+ "description": f"Knowledge base: {name}",
538
+ }
539
+
540
+ kb_config = self.config["knowledge_bases"][name]
541
+ kb_config["status"] = status
542
+ kb_config["updated_at"] = datetime.now().isoformat()
543
+ index_changed = False
544
+ indexed_count: int | None = None
545
+ index_action: str | None = None
546
+ if isinstance(progress, dict):
547
+ raw_indexed_count = progress.get("indexed_count")
548
+ if isinstance(raw_indexed_count, bool):
549
+ indexed_count = int(raw_indexed_count)
550
+ elif isinstance(raw_indexed_count, (int, float)):
551
+ indexed_count = int(raw_indexed_count)
552
+ elif isinstance(raw_indexed_count, str):
553
+ try:
554
+ indexed_count = int(raw_indexed_count)
555
+ except ValueError:
556
+ indexed_count = None
557
+
558
+ index_changed = bool(progress.get("index_changed")) or (
559
+ indexed_count is not None and indexed_count > 0
560
+ )
561
+ raw_index_action = progress.get("index_action")
562
+ if isinstance(raw_index_action, str) and raw_index_action.strip():
563
+ index_action = raw_index_action.strip()
564
+
565
+ if status == "ready":
566
+ # Ready KBs should look like stable resources in the UI instead of
567
+ # permanently carrying a "completed" progress banner.
568
+ kb_config.pop("progress", None)
569
+ kb_config.pop("last_error", None)
570
+ kb_config.pop("last_error_at", None)
571
+ if progress is not None:
572
+ kb_config["last_completed_at"] = (
573
+ progress.get("timestamp") or datetime.now().isoformat()
574
+ )
575
+ if index_changed:
576
+ kb_config["last_indexed_at"] = kb_config["last_completed_at"]
577
+ if indexed_count is not None:
578
+ kb_config["last_indexed_count"] = max(indexed_count, 0)
579
+ if index_action:
580
+ kb_config["last_indexed_action"] = index_action
581
+ elif status == "error":
582
+ if progress is not None:
583
+ kb_config["progress"] = progress
584
+ kb_config["last_error"] = progress.get("error") or progress.get("message")
585
+ kb_config["last_error_at"] = progress.get("timestamp") or datetime.now().isoformat()
586
+ elif progress is not None:
587
+ kb_config["progress"] = progress
588
+
589
+ if status == "ready":
590
+ provider = normalize_provider_name(kb_config.get("rag_provider"))
591
+ pageindex_provider = provider in {PAGEINDEX_PROVIDER, PAGEINDEX_OSS_PROVIDER}
592
+ if pageindex_provider:
593
+ for key in (
594
+ "embedding_model",
595
+ "embedding_dim",
596
+ "embedding_signature",
597
+ "embedding_mismatch",
598
+ ):
599
+ kb_config.pop(key, None)
600
+ elif not kb_config.get("embedding_selection"):
601
+ fp = _get_embedding_fingerprint()
602
+ if fp:
603
+ kb_config["embedding_model"], kb_config["embedding_dim"] = fp
604
+ # Record the active signature + the on-disk version registry so
605
+ # the UI can render version chips without recomputing.
606
+ try:
607
+ from deeptutor.services.rag.embedding_binding import entry_signature
608
+
609
+ sig = None if pageindex_provider else entry_signature(kb_config)
610
+ if sig is not None and not kb_config.get("embedding_selection"):
611
+ kb_config["embedding_signature"] = sig.hash()
612
+ kb_dir = self.base_dir / name
613
+ if kb_dir.is_dir():
614
+ kb_config["index_versions"] = inspect_kb_versions(kb_dir, provider)
615
+ except Exception as exc: # best-effort metadata
616
+ logger.warning(
617
+ f"Failed to refresh embedding/index metadata for KB '{name}' "
618
+ f"on ready transition: {exc}"
619
+ )
620
+
621
+ try:
622
+ self._save_config()
623
+ except Exception as exc:
624
+ logger.error(
625
+ f"Failed to persist KB status update for '{name}' (status='{status}'): {exc}"
626
+ )
627
+ raise
628
+ self._sync_kb_to_pb(name, kb_config)
629
+
630
+ def get_kb_entry(self, name: str) -> dict | None:
631
+ """The KB's raw ``kb_config.json`` record, or ``None`` if unregistered.
632
+
633
+ A cheap read for callers that need the registered facts (provider,
634
+ status, connected-KB pointers) without paying for :meth:`get_info`,
635
+ which additionally probes every index version on disk — and parses a
636
+ provider's docstore to do it.
637
+ """
638
+ self.config = self._load_config()
639
+ entry = self.config.get("knowledge_bases", {}).get(name)
640
+ return dict(entry) if isinstance(entry, dict) else None
641
+
642
+ def get_kb_status(self, name: str) -> dict | None:
643
+ """Get status and progress for a knowledge base."""
644
+ self.config = self._load_config()
645
+ kb_config = self.config.get("knowledge_bases", {}).get(name)
646
+ if not kb_config:
647
+ return None
648
+ return {
649
+ "status": kb_config.get("status", "unknown"),
650
+ "progress": kb_config.get("progress"),
651
+ "updated_at": kb_config.get("updated_at"),
652
+ }
653
+
654
+ def list_knowledge_bases(self) -> list[str]:
655
+ with self._catalog_lock:
656
+ return self._list_knowledge_bases()
657
+
658
+ def _list_knowledge_bases(self) -> list[str]:
659
+ """List all available knowledge bases.
660
+
661
+ This method:
662
+ 1. Loads registered KBs from kb_config.json
663
+ 2. Drops registered entries whose on-disk directory no longer exists
664
+ (orphans from failed inits or manual ``rm -rf`` of a KB folder).
665
+ 3. Scans the directory for existing KBs not yet registered
666
+ 4. Auto-registers discovered KBs with valid raw/index structure
667
+ """
668
+ # Always reload config from file to ensure we have the latest data
669
+ self.config = self._load_config()
670
+
671
+ config_kbs = self.config.get("knowledge_bases", {})
672
+ kb_list: set[str] = set()
673
+ config_changed = False
674
+
675
+ # Filter out orphan entries whose KB directory is gone. The on-disk
676
+ # folder is the source of truth for existence — without it the KB
677
+ # has no documents, no index, and surfacing it in the UI just shows
678
+ # zombies that the user can't act on.
679
+ #
680
+ # Grace period: a freshly-created KB writes its config entry before
681
+ # ``create_directory_structure`` mkdir-s the folder (so the UI can
682
+ # render the "initializing" row immediately). If ``list`` races into
683
+ # that window we'd prune a perfectly healthy in-flight KB. Skip the
684
+ # prune when ``updated_at`` is recent enough that an init could still
685
+ # be wiring things up.
686
+ base_exists = self.base_dir.exists()
687
+ grace_cutoff = datetime.now() - timedelta(seconds=_ORPHAN_PRUNE_GRACE_SECONDS)
688
+ for kb_name, kb_entry in list(config_kbs.items()):
689
+ # Connected KBs (Obsidian vaults, linked indexes) live outside
690
+ # ``base_dir`` — they have no on-disk KB folder by design, so the
691
+ # orphan prune below would wrongly delete them. Keep them
692
+ # unconditionally.
693
+ if is_connected_kb(kb_entry):
694
+ kb_list.add(kb_name)
695
+ continue
696
+ rel_path = (kb_entry or {}).get("path", kb_name)
697
+ kb_dir = self.base_dir / rel_path
698
+ if base_exists and not kb_dir.exists():
699
+ if _entry_updated_after(kb_entry, grace_cutoff):
700
+ kb_list.add(kb_name)
701
+ continue
702
+ logger.warning(
703
+ "Pruning orphaned KB entry '%s': directory %s no longer exists.",
704
+ kb_name,
705
+ kb_dir,
706
+ )
707
+ del config_kbs[kb_name]
708
+ config_changed = True
709
+ continue
710
+ kb_list.add(kb_name)
711
+
712
+ # Also scan directory for KBs that may not be registered yet
713
+ # This ensures backward compatibility and auto-discovery
714
+ if base_exists:
715
+ for item in self.base_dir.iterdir():
716
+ if not item.is_dir() or item.name.startswith(("__", ".")):
717
+ continue
718
+
719
+ # Skip if already in config
720
+ if item.name in kb_list:
721
+ continue
722
+
723
+ # Check if this is a valid KB directory (flat versions or legacy stores)
724
+ rag_storage = item / "rag_storage"
725
+ from deeptutor.services.rag.index_versioning import list_kb_versions
726
+
727
+ versions = list_kb_versions(item)
728
+ detected_provider = _detect_provider_from_versions(versions)
729
+ is_valid_kb = has_ready_provider_index(item, detected_provider) or (
730
+ rag_storage.exists() and rag_storage.is_dir()
731
+ )
732
+
733
+ if is_valid_kb:
734
+ # Auto-register this KB to kb_config.json
735
+ kb_list.add(item.name)
736
+ self._auto_register_kb(item.name)
737
+ config_changed = True
738
+
739
+ # Save config if we pruned orphans or registered new KBs
740
+ if config_changed:
741
+ self._save_config()
742
+
743
+ return sorted(kb_list)
744
+
745
+ def _auto_register_kb(self, name: str):
746
+ """Auto-register an existing KB to kb_config.json.
747
+
748
+ Reads info from metadata.json (if exists) for backward compatibility.
749
+ """
750
+ kb_dir = self.base_dir / name
751
+ from deeptutor.services.rag.index_versioning import list_kb_versions
752
+
753
+ rag_storage = kb_dir / "rag_storage"
754
+ versions = list_kb_versions(kb_dir)
755
+ detected_provider = _detect_provider_from_versions(versions)
756
+
757
+ # Default values
758
+ kb_entry: dict[str, Any] = {
759
+ "path": name,
760
+ "description": f"Knowledge base: {name}",
761
+ "updated_at": datetime.now().isoformat(),
762
+ }
763
+
764
+ # Try to read metadata.json for existing info (backward compatibility)
765
+ metadata_file = kb_dir / "metadata.json"
766
+ if metadata_file.exists():
767
+ try:
768
+ with open(metadata_file, encoding="utf-8") as f:
769
+ metadata = json.load(f)
770
+ # Migrate relevant fields
771
+ if metadata.get("description"):
772
+ kb_entry["description"] = metadata["description"]
773
+ if metadata.get("rag_provider"):
774
+ raw_provider = str(metadata["rag_provider"]).strip().lower()
775
+ kb_entry["rag_provider"] = normalize_provider_name(raw_provider)
776
+ if raw_provider and raw_provider not in KNOWN_PROVIDERS:
777
+ kb_entry["needs_reindex"] = True
778
+ if metadata.get("created_at"):
779
+ kb_entry["created_at"] = metadata["created_at"]
780
+ if metadata.get("last_updated"):
781
+ kb_entry["updated_at"] = metadata["last_updated"]
782
+ if metadata.get("last_indexed_at"):
783
+ kb_entry["last_indexed_at"] = metadata["last_indexed_at"]
784
+ elif metadata.get("last_updated"):
785
+ kb_entry["last_indexed_at"] = metadata["last_updated"]
786
+ if metadata.get("last_indexed_count") is not None:
787
+ kb_entry["last_indexed_count"] = metadata["last_indexed_count"]
788
+ if metadata.get("last_indexed_action"):
789
+ kb_entry["last_indexed_action"] = metadata["last_indexed_action"]
790
+ except Exception as e:
791
+ logger.warning(f"Failed to read metadata.json for '{name}': {e}")
792
+
793
+ # Detect rag_provider from storage type if not set
794
+ if "rag_provider" not in kb_entry:
795
+ if has_ready_provider_index(kb_dir, detected_provider):
796
+ kb_entry["rag_provider"] = detected_provider
797
+ elif rag_storage.exists():
798
+ kb_entry["rag_provider"] = DEFAULT_PROVIDER
799
+ kb_entry["needs_reindex"] = True
800
+
801
+ provider = normalize_provider_name(kb_entry.get("rag_provider"))
802
+ if has_ready_provider_index(kb_dir, provider):
803
+ kb_entry["status"] = "ready"
804
+ elif rag_storage.exists() and rag_storage.is_dir():
805
+ kb_entry["status"] = "needs_reindex"
806
+ kb_entry["needs_reindex"] = True
807
+ else:
808
+ kb_entry["status"] = "unknown"
809
+
810
+ # Add to config
811
+ if "knowledge_bases" not in self.config:
812
+ self.config["knowledge_bases"] = {}
813
+ self.config["knowledge_bases"][name] = kb_entry
814
+
815
+ logger.info(f"Auto-registered KB '{name}' to kb_config.json")
816
+
817
+ def register_knowledge_base(self, name: str, description: str = "", set_default: bool = False):
818
+ """Register a knowledge base"""
819
+ name = validate_knowledge_base_name(name)
820
+ _assert_move_id_available(self.base_dir, name)
821
+ kb_dir = self.base_dir / name
822
+ if not kb_dir.exists():
823
+ raise ValueError(f"Knowledge base directory does not exist: {kb_dir}")
824
+
825
+ if "knowledge_bases" not in self.config:
826
+ self.config["knowledge_bases"] = {}
827
+
828
+ self.config["knowledge_bases"][name] = {"path": name, "description": description}
829
+
830
+ # Only set default if explicitly requested
831
+ if set_default:
832
+ self.set_default(name)
833
+
834
+ self._save_config()
835
+
836
+ def register_connected_entry(self, name: str, entry: dict) -> bool:
837
+ """Adopt an already-built connected-KB entry under this manager.
838
+
839
+ Every other ``register_*`` method builds a pointer entry from user
840
+ input. This one takes an entry that already exists elsewhere: a
841
+ connected KB has no ``<kb>/`` tree under ``base_dir``, so handing one
842
+ to a partner workspace means copying its ``kb_config.json`` row rather
843
+ than the folder that provisioning copies for an indexed KB.
844
+
845
+ Returns ``False`` when the name is already registered here, leaving
846
+ the existing entry untouched, so callers can provision idempotently.
847
+ """
848
+ name = (name or "").strip()
849
+ if not name:
850
+ raise ValueError("Knowledge base name is required.")
851
+ if not is_connected_kb(entry):
852
+ raise ValueError(f"Not a connected knowledge base entry: {name}")
853
+ _assert_move_id_available(self.base_dir, name)
854
+
855
+ self.config = self._load_config()
856
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
857
+ if name in knowledge_bases:
858
+ return False
859
+ knowledge_bases[name] = dict(entry)
860
+ self._save_config()
861
+ return True
862
+
863
+ def register_obsidian_vault(self, name: str, vault_path: str, description: str = "") -> dict:
864
+ """Register a connected Obsidian vault as a pointer-type KB.
865
+
866
+ Unlike a normal KB this creates no folder under ``base_dir`` and runs no
867
+ index pipeline: it records a ``type: obsidian`` entry pointing at the
868
+ user's existing vault directory, which the Obsidian capability reads
869
+ live. Raises ``ValueError`` on a missing/invalid path or a name clash.
870
+ """
871
+ name = validate_knowledge_base_name(name)
872
+ _assert_move_id_available(self.base_dir, name)
873
+ vault = Path(vault_path).expanduser()
874
+ if not vault.is_dir():
875
+ raise ValueError(f"Vault path is not a directory: {vault_path}")
876
+
877
+ self.config = self._load_config()
878
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
879
+ if name in knowledge_bases:
880
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
881
+
882
+ now = datetime.now().isoformat()
883
+ entry = {
884
+ "path": name,
885
+ "type": OBSIDIAN_KB_TYPE,
886
+ "vault_path": str(vault.resolve()),
887
+ "description": description or f"Obsidian vault: {name}",
888
+ "status": "ready",
889
+ "created_at": now,
890
+ "updated_at": now,
891
+ }
892
+ knowledge_bases[name] = entry
893
+ self._save_config()
894
+ return entry
895
+
896
+ def register_linked_kb(
897
+ self,
898
+ name: str,
899
+ external_path: str,
900
+ provider: str,
901
+ *,
902
+ description: str = "",
903
+ stats: dict | None = None,
904
+ ) -> dict:
905
+ """Register a pointer to a pre-built engine index as a ``linked`` KB.
906
+
907
+ Like :meth:`register_obsidian_vault` this creates no folder under
908
+ ``base_dir`` and runs no index pipeline: it records an
909
+ ``external_path`` the bound ``provider`` reads in place, so retrieval
910
+ skips indexing entirely. ``stats`` (embedding model/dim/signature, doc
911
+ count) is surfaced read-only in the UI. Callers should validate the
912
+ folder with the probe helper first; this only guards basic invariants.
913
+ Raises ``ValueError`` on a missing/invalid path or a name clash.
914
+ """
915
+ name = validate_knowledge_base_name(name)
916
+ _assert_move_id_available(self.base_dir, name)
917
+ provider = normalize_provider_name(provider)
918
+ folder = Path(external_path).expanduser()
919
+ if not folder.is_dir():
920
+ raise ValueError(f"Folder path is not a directory: {external_path}")
921
+
922
+ self.config = self._load_config()
923
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
924
+ if name in knowledge_bases:
925
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
926
+
927
+ now = datetime.now().isoformat()
928
+ entry: dict[str, Any] = {
929
+ "path": name,
930
+ "type": LINKED_KB_TYPE,
931
+ "external_path": str(folder.resolve()),
932
+ "rag_provider": provider,
933
+ "description": description or f"Linked {provider} index: {name}",
934
+ "status": "ready",
935
+ "needs_reindex": False,
936
+ "created_at": now,
937
+ "updated_at": now,
938
+ }
939
+ for key in ("embedding_model", "embedding_dim", "embedding_signature"):
940
+ if stats and stats.get(key) is not None:
941
+ entry[key] = stats[key]
942
+ if stats and stats.get("doc_count") is not None:
943
+ entry["last_indexed_count"] = stats["doc_count"]
944
+ entry["last_indexed_action"] = "link"
945
+ knowledge_bases[name] = entry
946
+ self._save_config()
947
+ return entry
948
+
949
+ def register_subagent_connection(
950
+ self,
951
+ name: str,
952
+ agent_kind: str,
953
+ *,
954
+ cwd: str = "",
955
+ description: str = "",
956
+ ) -> dict:
957
+ """Register a local or remote agent connection without creating an index."""
958
+ name = validate_knowledge_base_name(name)
959
+ _assert_move_id_available(self.base_dir, name)
960
+ agent_kind = (agent_kind or "").strip()
961
+ if agent_kind == "partner":
962
+ raise ValueError("Select partners directly through Ask partner instead.")
963
+ if not agent_kind:
964
+ raise ValueError("agent_kind is required.")
965
+ resolved_cwd = ""
966
+ if cwd:
967
+ folder = Path(cwd).expanduser()
968
+ if not folder.is_dir():
969
+ raise ValueError(f"Working directory is not a directory: {cwd}")
970
+ resolved_cwd = str(folder.resolve())
971
+
972
+ self.config = self._load_config()
973
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
974
+ if name in knowledge_bases:
975
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
976
+
977
+ now = datetime.now().isoformat()
978
+ entry = {
979
+ "path": name,
980
+ "type": SUBAGENT_KB_TYPE,
981
+ "agent_kind": agent_kind,
982
+ "cwd": resolved_cwd,
983
+ "description": description or f"Connected subagent: {name}",
984
+ "status": "ready",
985
+ "created_at": now,
986
+ "updated_at": now,
987
+ }
988
+ knowledge_bases[name] = entry
989
+ self._save_config()
990
+ return entry
991
+
992
+ def register_lightrag_server_kb(
993
+ self,
994
+ name: str,
995
+ server_url: str,
996
+ *,
997
+ api_key: str = "",
998
+ search_mode: str = "",
999
+ description: str = "",
1000
+ ) -> dict:
1001
+ """Register a pointer to an external LightRAG server as a connected KB.
1002
+
1003
+ Like the other connected types this creates no folder under ``base_dir``
1004
+ and runs no index pipeline: it records a ``type: lightrag_server`` entry
1005
+ whose ``server_url`` (+ optional ``api_key``) the ``lightrag-server``
1006
+ provider queries over HTTP. The server owns indexing entirely. Callers
1007
+ should validate reachability with the probe helper first; this only
1008
+ guards basic invariants. Raises ``ValueError`` on a missing name/URL or a
1009
+ name clash.
1010
+ """
1011
+ name = validate_knowledge_base_name(name)
1012
+ _assert_move_id_available(self.base_dir, name)
1013
+ server_url = (server_url or "").strip().rstrip("/")
1014
+ if not server_url:
1015
+ raise ValueError("LightRAG server URL is required.")
1016
+
1017
+ self.config = self._load_config()
1018
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
1019
+ if name in knowledge_bases:
1020
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
1021
+
1022
+ now = datetime.now().isoformat()
1023
+ entry: dict[str, Any] = {
1024
+ "path": name,
1025
+ "type": LIGHTRAG_SERVER_KB_TYPE,
1026
+ "rag_provider": LIGHTRAG_SERVER_PROVIDER,
1027
+ "server_url": server_url,
1028
+ "api_key": (api_key or "").strip(),
1029
+ "description": description or f"LightRAG server: {name}",
1030
+ "status": "ready",
1031
+ "needs_reindex": False,
1032
+ "created_at": now,
1033
+ "updated_at": now,
1034
+ }
1035
+ search_mode = (search_mode or "").strip().lower()
1036
+ if search_mode:
1037
+ entry["search_mode"] = search_mode
1038
+ knowledge_bases[name] = entry
1039
+ self._save_config()
1040
+ return entry
1041
+
1042
+ def register_marginnote4_kb(
1043
+ self,
1044
+ name: str,
1045
+ *,
1046
+ db_path: str = "",
1047
+ description: str = "",
1048
+ ) -> dict:
1049
+ """Register a connected MarginNote 4 library as a pointer KB.
1050
+
1051
+ Creates no folder under ``base_dir`` and runs no index pipeline: it
1052
+ records a ``type: marginnote4`` entry whose ``db_path`` (when given)
1053
+ the MarginNote capability binds to. When ``db_path`` is omitted the
1054
+ capability derives a default SQLite path from the KB name, so callers
1055
+ can leave it blank for the simple single-library case. Raises
1056
+ ``ValueError`` on a missing name, a name clash, or a store already
1057
+ claimed by another library.
1058
+ """
1059
+ name = validate_knowledge_base_name(name)
1060
+ _assert_move_id_available(self.base_dir, name)
1061
+
1062
+ self.config = self._load_config()
1063
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
1064
+ if name in knowledge_bases:
1065
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
1066
+
1067
+ db_path = (db_path or "").strip()
1068
+ claimed_by = self._marginnote4_store_owner(name, db_path, knowledge_bases)
1069
+ if claimed_by:
1070
+ # Distinct names can still derive one store: the default path keeps
1071
+ # only alphanumerics, `-` and `_`, so "My Lib" and "My/Lib" both
1072
+ # land on My_Lib.db. Sharing it would merge two libraries' objects
1073
+ # and let either one's devices sync into the other.
1074
+ raise ValueError(
1075
+ f"Knowledge base '{claimed_by}' already uses that MarginNote store. "
1076
+ "Pick a name that differs by more than punctuation."
1077
+ )
1078
+
1079
+ now = datetime.now().isoformat()
1080
+ entry: dict[str, Any] = {
1081
+ "path": name,
1082
+ "type": MARGINNOTE4_KB_TYPE,
1083
+ "description": description or f"MarginNote 4 library: {name}",
1084
+ "status": "ready",
1085
+ "needs_reindex": False,
1086
+ "created_at": now,
1087
+ "updated_at": now,
1088
+ }
1089
+ if db_path:
1090
+ entry["db_path"] = db_path
1091
+ knowledge_bases[name] = entry
1092
+ self._save_config()
1093
+ return entry
1094
+
1095
+ @staticmethod
1096
+ def _marginnote4_store_owner(
1097
+ name: str,
1098
+ db_path: str,
1099
+ knowledge_bases: dict[str, Any],
1100
+ ) -> str | None:
1101
+ """Name of the MarginNote library already using this store, if any."""
1102
+ from deeptutor.capabilities.marginnote4.store import resolve_db_path
1103
+
1104
+ def _store(kb_name: str, entry: dict) -> Path:
1105
+ return resolve_db_path(kb_name, metadata=entry).expanduser().resolve()
1106
+
1107
+ wanted = _store(name, {"db_path": db_path})
1108
+ for other_name, other in knowledge_bases.items():
1109
+ if not isinstance(other, dict) or other.get("type") != MARGINNOTE4_KB_TYPE:
1110
+ continue
1111
+ if _store(other_name, other) == wanted:
1112
+ return other_name
1113
+ return None
1114
+
1115
+ def register_ima_kb(
1116
+ self,
1117
+ name: str,
1118
+ client_id: str,
1119
+ api_key: str,
1120
+ knowledge_base_id: str,
1121
+ *,
1122
+ description: str = "",
1123
+ ) -> dict:
1124
+ """Register a pointer to a Tencent IMA knowledge base as a connected KB.
1125
+
1126
+ Like the other connected types this creates no folder under ``base_dir``
1127
+ and runs no index pipeline: it records a ``type: ima`` entry whose
1128
+ library id the ``ima`` provider queries over IMA's OpenAPI. IMA owns
1129
+ indexing entirely.
1130
+
1131
+ Credentials are optional here: leave them empty and the KB retrieves
1132
+ with the account-level pair from the engine settings, so rotating that
1133
+ key updates every such KB at once. Passing a pair pins this KB to it —
1134
+ the way to reach a second IMA account. Callers should validate the
1135
+ binding with the probe helper first; this only guards basic invariants.
1136
+ Raises ``ValueError`` on a missing field, a half-filled credential pair,
1137
+ or a name clash.
1138
+ """
1139
+ name = validate_knowledge_base_name(name)
1140
+ _assert_move_id_available(self.base_dir, name)
1141
+ client_id = (client_id or "").strip()
1142
+ api_key = (api_key or "").strip()
1143
+ knowledge_base_id = (knowledge_base_id or "").strip()
1144
+ if bool(client_id) != bool(api_key):
1145
+ raise ValueError("IMA Client ID and API Key must be given together.")
1146
+ if not knowledge_base_id:
1147
+ raise ValueError("IMA knowledge base ID is required.")
1148
+
1149
+ self.config = self._load_config()
1150
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
1151
+ if name in knowledge_bases:
1152
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
1153
+
1154
+ now = datetime.now().isoformat()
1155
+ entry: dict[str, Any] = {
1156
+ "path": name,
1157
+ "type": IMA_KB_TYPE,
1158
+ "rag_provider": IMA_PROVIDER,
1159
+ # Written only when this KB overrides the account credentials;
1160
+ # absent means "resolve them from the engine settings".
1161
+ **({"client_id": client_id, "api_key": api_key} if client_id else {}),
1162
+ "knowledge_base_id": knowledge_base_id,
1163
+ "description": description or f"Tencent IMA: {name}",
1164
+ "status": "ready",
1165
+ "needs_reindex": False,
1166
+ "created_at": now,
1167
+ "updated_at": now,
1168
+ }
1169
+ knowledge_bases[name] = entry
1170
+ self._save_config()
1171
+ return entry
1172
+
1173
+ def register_weknora_kb(
1174
+ self,
1175
+ name: str,
1176
+ server_url: str,
1177
+ api_key: str,
1178
+ knowledge_base_id: str,
1179
+ *,
1180
+ description: str = "",
1181
+ ) -> dict:
1182
+ """Register a self-hosted WeKnora knowledge base as a pointer KB."""
1183
+ name = validate_knowledge_base_name(name)
1184
+ _assert_move_id_available(self.base_dir, name)
1185
+ server_url = (server_url or "").strip().rstrip("/")
1186
+ api_key = (api_key or "").strip()
1187
+ knowledge_base_id = (knowledge_base_id or "").strip()
1188
+ if not server_url or not knowledge_base_id:
1189
+ raise ValueError("WeKnora server URL and knowledge base ID are required.")
1190
+ if not api_key:
1191
+ raise ValueError("A WeKnora API key is required.")
1192
+
1193
+ self.config = self._load_config()
1194
+ knowledge_bases = self.config.setdefault("knowledge_bases", {})
1195
+ if name in knowledge_bases:
1196
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
1197
+
1198
+ now = datetime.now().isoformat()
1199
+ entry: dict[str, Any] = {
1200
+ "path": name,
1201
+ "type": WEKNORA_KB_TYPE,
1202
+ "rag_provider": WEKNORA_PROVIDER,
1203
+ "server_url": server_url,
1204
+ "api_key": api_key,
1205
+ "knowledge_base_id": knowledge_base_id,
1206
+ "description": description or f"WeKnora knowledge base: {name}",
1207
+ "status": "ready",
1208
+ "needs_reindex": False,
1209
+ "created_at": now,
1210
+ "updated_at": now,
1211
+ }
1212
+ knowledge_bases[name] = entry
1213
+ self._save_config()
1214
+ return entry
1215
+
1216
+ def register_kiwix_kb(
1217
+ self,
1218
+ name: str,
1219
+ server_url: str,
1220
+ zim_name: str,
1221
+ *,
1222
+ zim_title: str = "",
1223
+ ) -> dict:
1224
+ """Register a read-only pointer to one ZIM already served by Kiwix."""
1225
+ from deeptutor.knowledge.kb_types import KIWIX_KB_TYPE
1226
+ from deeptutor.services.rag.pipelines.kiwix.client import (
1227
+ normalize_base_url,
1228
+ validate_zim_name,
1229
+ )
1230
+
1231
+ name = validate_knowledge_base_name(name)
1232
+ server_url = normalize_base_url(server_url)
1233
+ zim_name = validate_zim_name(zim_name)
1234
+ self.config = self._load_config()
1235
+ bases = self.config.setdefault("knowledge_bases", {})
1236
+ if name in bases:
1237
+ raise ValueError(f"A knowledge base named '{name}' already exists.")
1238
+ now = datetime.now().isoformat()
1239
+ entry = {
1240
+ "path": name,
1241
+ "type": KIWIX_KB_TYPE,
1242
+ "rag_provider": KIWIX_KB_TYPE,
1243
+ "server_url": server_url,
1244
+ "zim_name": zim_name,
1245
+ "zim_title": zim_title[:300],
1246
+ "description": f"Kiwix archive: {zim_title or zim_name}",
1247
+ "status": "ready",
1248
+ "needs_reindex": False,
1249
+ "created_at": now,
1250
+ "updated_at": now,
1251
+ }
1252
+ bases[name] = entry
1253
+ self._save_config()
1254
+ return entry
1255
+
1256
+ def get_knowledge_base_path(self, name: str | None = None) -> Path:
1257
+ """Get path to a knowledge base.
1258
+
1259
+ Connected KBs (Obsidian vaults, linked indexes) live outside
1260
+ ``base_dir`` — resolve them to their external pointer so callers that
1261
+ ask for "where is this KB's data" reach the right place.
1262
+ """
1263
+ self.config = self._load_config()
1264
+ if name is None:
1265
+ name = self.config.get("default")
1266
+ if name is None:
1267
+ raise ValueError("No default knowledge base set")
1268
+
1269
+ entry = self.config.get("knowledge_bases", {}).get(name, {})
1270
+ external = external_root_of(entry)
1271
+ if external:
1272
+ folder = Path(external).expanduser()
1273
+ if not folder.is_dir():
1274
+ raise ValueError(f"Linked folder is no longer available: {external}")
1275
+ return folder
1276
+
1277
+ kb_dir = self.base_dir / name
1278
+ if not kb_dir.exists():
1279
+ raise ValueError(f"Knowledge base not found: {name}")
1280
+
1281
+ return kb_dir
1282
+
1283
+ def get_rag_storage_path(self, name: str | None = None) -> Path:
1284
+ """Get active index storage path for a knowledge base."""
1285
+ kb_dir = self.get_knowledge_base_path(name)
1286
+ from deeptutor.services.rag.embedding_binding import binding_status, entry_signature
1287
+ from deeptutor.services.rag.index_versioning import (
1288
+ resolve_storage_dir_for_read,
1289
+ )
1290
+
1291
+ entry = self.config.get("knowledge_bases", {}).get(name or kb_dir.name, {})
1292
+ if entry.get("embedding_selection") and binding_status(entry)[0] != "ready":
1293
+ raise ValueError(
1294
+ "The bound embedding model is unavailable or changed. Check this knowledge base's model settings."
1295
+ )
1296
+ active_storage = resolve_storage_dir_for_read(kb_dir, entry_signature(entry))
1297
+ legacy_storage = kb_dir / "rag_storage"
1298
+ if active_storage is not None:
1299
+ return active_storage
1300
+ if legacy_storage.exists():
1301
+ return legacy_storage
1302
+ raise ValueError(f"Index storage not found for knowledge base: {name or 'default'}")
1303
+
1304
+ def get_images_path(self, name: str | None = None) -> Path:
1305
+ """Get images path for a knowledge base"""
1306
+ kb_dir = self.get_knowledge_base_path(name)
1307
+ return kb_dir / "images"
1308
+
1309
+ def get_content_list_path(self, name: str | None = None) -> Path:
1310
+ """Get content list path for a knowledge base"""
1311
+ kb_dir = self.get_knowledge_base_path(name)
1312
+ return kb_dir / "content_list"
1313
+
1314
+ def get_raw_path(self, name: str | None = None) -> Path:
1315
+ """Get raw documents path for a knowledge base"""
1316
+ kb_dir = self.get_knowledge_base_path(name)
1317
+ return kb_dir / "raw"
1318
+
1319
+ def set_default(self, name: str):
1320
+ """Set default knowledge base using centralized config service."""
1321
+ if name not in self.list_knowledge_bases():
1322
+ raise ValueError(f"Knowledge base not found: {name}")
1323
+
1324
+ # Persist default KB selection via the canonical KB config service.
1325
+ try:
1326
+ from deeptutor.services.config import get_kb_config_service
1327
+
1328
+ kb_config_service = get_kb_config_service()
1329
+ kb_config_service.set_default_kb(name)
1330
+ except Exception as e:
1331
+ logger.warning(f"Failed to save default to centralized config: {e}")
1332
+
1333
+ def get_default(self, *, available_names: list[str] | None = None) -> str | None:
1334
+ """
1335
+ Get default knowledge base name.
1336
+
1337
+ Priority:
1338
+ 1. Canonical KB config service (`data/knowledge_bases/kb_config.json`)
1339
+ 2. First knowledge base in the list (auto-fallback)
1340
+
1341
+ Args:
1342
+ available_names: An already-reconciled knowledge-base list. Passing
1343
+ this avoids rescanning every index when the caller just listed
1344
+ the available knowledge bases.
1345
+ """
1346
+ kb_list = available_names if available_names is not None else self.list_knowledge_bases()
1347
+
1348
+ # Try centralized config first
1349
+ try:
1350
+ from deeptutor.services.config import get_kb_config_service
1351
+
1352
+ kb_config_service = get_kb_config_service()
1353
+ default_kb = kb_config_service.get_default_kb()
1354
+ if default_kb and default_kb in kb_list:
1355
+ return default_kb
1356
+ except Exception:
1357
+ pass
1358
+
1359
+ # Fallback to first knowledge base in sorted list
1360
+ if kb_list:
1361
+ return kb_list[0]
1362
+
1363
+ return None
1364
+
1365
+ @staticmethod
1366
+ def _embedding_fields(kb_config: dict) -> dict:
1367
+ """Extract embedding fingerprint fields from a KB config entry."""
1368
+ fields = {}
1369
+ for key in ("embedding_model", "embedding_dim", "embedding_selection", "embedding_status"):
1370
+ val = kb_config.get(key)
1371
+ if val is not None:
1372
+ fields[key] = val
1373
+ if kb_config.get("embedding_mismatch"):
1374
+ fields["embedding_mismatch"] = True
1375
+ return fields
1376
+
1377
+ def get_metadata(self, name: str | None = None) -> dict:
1378
+ """Get knowledge base metadata.
1379
+
1380
+ Source:
1381
+ 1. kb_config.json (authoritative source)
1382
+ """
1383
+ kb_name = name
1384
+ if kb_name is None:
1385
+ kb_name = self.get_default()
1386
+ if kb_name is None:
1387
+ return {}
1388
+
1389
+ # First, try kb_config.json (authoritative source)
1390
+ self.config = self._load_config()
1391
+ kb_config = self.config.get("knowledge_bases", {}).get(kb_name, {})
1392
+
1393
+ if kb_config:
1394
+ # Build metadata from config
1395
+ metadata = {
1396
+ "name": kb_name,
1397
+ "description": kb_config.get("description", f"Knowledge base: {kb_name}"),
1398
+ "rag_provider": normalize_provider_name(kb_config.get("rag_provider")),
1399
+ "needs_reindex": bool(kb_config.get("needs_reindex", False)),
1400
+ "created_at": kb_config.get("created_at"),
1401
+ "last_updated": kb_config.get("updated_at"),
1402
+ "last_indexed_at": kb_config.get("last_indexed_at"),
1403
+ "last_indexed_count": kb_config.get("last_indexed_count"),
1404
+ "last_indexed_action": kb_config.get("last_indexed_action"),
1405
+ # Connected-KB fields (None for ordinary indexed KBs, dropped below).
1406
+ "type": kb_config.get("type"),
1407
+ "vault_path": kb_config.get("vault_path"),
1408
+ "external_path": kb_config.get("external_path"),
1409
+ # MarginNote 4 pointer (SQLite store path for synced data).
1410
+ "db_path": kb_config.get("db_path"),
1411
+ # LightRAG server pointer (the URL is safe to surface; the API
1412
+ # key deliberately is not).
1413
+ "server_url": kb_config.get("server_url"),
1414
+ "zim_name": kb_config.get("zim_name"),
1415
+ "zim_title": kb_config.get("zim_title"),
1416
+ # IMA pointer. The library id identifies which IMA knowledge
1417
+ # base this KB reads; the client id and API key are credentials
1418
+ # and are deliberately absent from this allowlist.
1419
+ "knowledge_base_id": kb_config.get("knowledge_base_id"),
1420
+ # Subagent connection fields (None for non-subagent KBs).
1421
+ "agent_kind": kb_config.get("agent_kind"),
1422
+ "cwd": kb_config.get("cwd"),
1423
+ "partner_id": kb_config.get("partner_id"),
1424
+ }
1425
+ metadata.update(self._embedding_fields(kb_config))
1426
+ # Remove None values
1427
+ metadata = {k: v for k, v in metadata.items() if v is not None}
1428
+ return metadata
1429
+
1430
+ return {}
1431
+
1432
+ def get_info(
1433
+ self,
1434
+ name: str | None = None,
1435
+ *,
1436
+ refresh_config: bool = True,
1437
+ default_name: str | None = None,
1438
+ ) -> dict:
1439
+ if refresh_config:
1440
+ return self._get_info(name, default_name=default_name)
1441
+ with self._catalog_lock:
1442
+ key = (name, default_name)
1443
+ revision = (
1444
+ self._catalog_revision(),
1445
+ json.dumps(
1446
+ self.config.get("knowledge_bases", {}).get(name or default_name, {}),
1447
+ sort_keys=True,
1448
+ default=str,
1449
+ ),
1450
+ )
1451
+ cached = self._info_cache.get(key)
1452
+ if cached and cached[0] == revision and time.monotonic() < cached[1]:
1453
+ return deepcopy(cached[2])
1454
+ info = self._get_info(name, refresh_config=False, default_name=default_name)
1455
+ # Keep only the current catalog's entries, including connected KBs.
1456
+ if len(self._info_cache) >= max(1, len(self.config.get("knowledge_bases", {})) * 2):
1457
+ self._info_cache.clear()
1458
+ self._info_cache[key] = (
1459
+ revision,
1460
+ time.monotonic() + _CATALOG_CACHE_SECONDS,
1461
+ deepcopy(info),
1462
+ )
1463
+ return info
1464
+
1465
+ def _get_info(
1466
+ self,
1467
+ name: str | None = None,
1468
+ *,
1469
+ refresh_config: bool = True,
1470
+ default_name: str | None = None,
1471
+ ) -> dict:
1472
+ """Get detailed information about a knowledge base.
1473
+
1474
+ This method:
1475
+ 1. Gets the KB name (from parameter or default)
1476
+ 2. Reads all config from kb_config.json (authoritative source)
1477
+ 3. Falls back to metadata.json for legacy KBs
1478
+ 4. Collects statistics about files and RAG status
1479
+
1480
+ Args:
1481
+ name: Knowledge-base name, or the configured default when omitted.
1482
+ refresh_config: Reload and reconcile the complete configuration.
1483
+ Bulk callers that just invoked :meth:`list_knowledge_bases`
1484
+ can reuse that snapshot by passing ``False``.
1485
+ default_name: A pre-resolved default name for bulk callers. This
1486
+ avoids rescanning the knowledge-base list for every item.
1487
+ """
1488
+ # Reload config to get latest status
1489
+ if refresh_config:
1490
+ self.config = self._load_config()
1491
+
1492
+ resolved_default = default_name
1493
+ if resolved_default is None:
1494
+ resolved_default = self.get_default()
1495
+
1496
+ kb_name = name or resolved_default
1497
+ if kb_name is None:
1498
+ raise ValueError("No knowledge base name provided and no default set")
1499
+
1500
+ # Get config from kb_config.json (authoritative source)
1501
+ kb_config = self.config.get("knowledge_bases", {}).get(kb_name, {})
1502
+
1503
+ # Connected KBs live outside ``base_dir``; resolve to their external
1504
+ # pointer so the on-disk stats/index-version scan below reflect reality.
1505
+ external = external_root_of(kb_config)
1506
+ kb_dir = Path(external).expanduser() if external else self.base_dir / kb_name
1507
+
1508
+ status = kb_config.get("status")
1509
+ progress = kb_config.get("progress")
1510
+ description = kb_config.get("description", f"Knowledge base: {kb_name}")
1511
+ rag_provider = normalize_provider_name(kb_config.get("rag_provider"))
1512
+ needs_reindex = bool(kb_config.get("needs_reindex", False))
1513
+ created_at = kb_config.get("created_at")
1514
+ updated_at = kb_config.get("updated_at")
1515
+
1516
+ live_status = status in {"initializing", "processing"}
1517
+ if live_status and isinstance(progress, dict):
1518
+ live_status = progress.get("stage") not in {"completed", "error"}
1519
+ effective_needs_reindex = needs_reindex and not live_status
1520
+
1521
+ # KB might not have a directory yet if still initializing
1522
+ dir_exists = kb_dir.exists()
1523
+ index_versions: list[dict[str, Any]] = []
1524
+ has_ready_provider = False
1525
+ if dir_exists:
1526
+ index_versions = inspect_kb_versions(kb_dir, rag_provider)
1527
+ has_ready_provider = any(bool(version.get("ready")) for version in index_versions)
1528
+ provider_error_summary = (
1529
+ provider_failure_summary(kb_dir, rag_provider, versions=index_versions)
1530
+ if dir_exists
1531
+ else ""
1532
+ )
1533
+
1534
+ # For old KBs without status field, determine status from rag_storage
1535
+ if effective_needs_reindex:
1536
+ status = "needs_reindex"
1537
+ elif status == "ready" and not has_ready_provider and provider_error_summary:
1538
+ status = "error"
1539
+ progress = {
1540
+ "stage": "error",
1541
+ "message": "Previous indexing failed.",
1542
+ "error": provider_error_summary,
1543
+ }
1544
+ elif (
1545
+ status in {"processing", "initializing"}
1546
+ and has_ready_provider
1547
+ and not (isinstance(progress, dict) and progress.get("stage") == "error")
1548
+ ):
1549
+ # A ready index version exists on disk but the persisted status is
1550
+ # still a "live" sentinel — typically because the progress writer
1551
+ # crashed (or the process was killed) after the index was finalised
1552
+ # but before status was promoted to "ready". Recover the actual
1553
+ # state on read so the UI does not show a perpetual processing
1554
+ # banner. The persistent kb_config.json is left untouched; the
1555
+ # next legitimate update_kb_status() call will clean it up.
1556
+ # See issue #418.
1557
+ status = "ready"
1558
+ progress = None
1559
+ elif not status and dir_exists:
1560
+ rag_storage_dir = kb_dir / "rag_storage"
1561
+ if has_ready_provider:
1562
+ status = "ready"
1563
+ elif rag_storage_dir.exists() and any(rag_storage_dir.iterdir()):
1564
+ status = "needs_reindex"
1565
+ needs_reindex = True
1566
+ effective_needs_reindex = True
1567
+ else:
1568
+ status = "unknown"
1569
+ elif not status:
1570
+ status = "unknown"
1571
+
1572
+ # Build metadata from kb_config.json (authoritative source)
1573
+ metadata = {
1574
+ "name": kb_name,
1575
+ "description": description,
1576
+ "rag_provider": rag_provider,
1577
+ "needs_reindex": effective_needs_reindex,
1578
+ }
1579
+ if created_at:
1580
+ metadata["created_at"] = created_at
1581
+ if updated_at:
1582
+ metadata["last_updated"] = updated_at
1583
+ if kb_config.get("last_indexed_at"):
1584
+ metadata["last_indexed_at"] = kb_config.get("last_indexed_at")
1585
+ if kb_config.get("last_indexed_count") is not None:
1586
+ metadata["last_indexed_count"] = kb_config.get("last_indexed_count")
1587
+ if kb_config.get("last_indexed_action"):
1588
+ metadata["last_indexed_action"] = kb_config.get("last_indexed_action")
1589
+ if kb_config.get("last_error"):
1590
+ metadata["last_error"] = kb_config.get("last_error")
1591
+ if kb_config.get("last_error_at"):
1592
+ metadata["last_error_at"] = kb_config.get("last_error_at")
1593
+ # Connected-KB fields, so the UI can badge it and show the path.
1594
+ if kb_config.get("type"):
1595
+ metadata["type"] = kb_config.get("type")
1596
+ if kb_config.get("vault_path"):
1597
+ metadata["vault_path"] = kb_config.get("vault_path")
1598
+ if kb_config.get("external_path"):
1599
+ metadata["external_path"] = kb_config.get("external_path")
1600
+ if kb_config.get("db_path"):
1601
+ metadata["db_path"] = kb_config.get("db_path")
1602
+ if kb_config.get("agent_kind"):
1603
+ metadata["agent_kind"] = kb_config.get("agent_kind")
1604
+ # The server URL is shown read-only in the UI; the API key never leaves
1605
+ # the backend, so it is deliberately not surfaced here.
1606
+ if kb_config.get("server_url"):
1607
+ metadata["server_url"] = kb_config.get("server_url")
1608
+ # Same split for IMA: the library id is shown, the credentials are not.
1609
+ if kb_config.get("knowledge_base_id"):
1610
+ metadata["knowledge_base_id"] = kb_config.get("knowledge_base_id")
1611
+ if kb_config.get("zim_name"):
1612
+ metadata["zim_name"] = kb_config.get("zim_name")
1613
+ metadata["zim_title"] = kb_config.get("zim_title") or ""
1614
+
1615
+ if rag_provider == LIGHTRAG_PROVIDER:
1616
+ from deeptutor.services.rag.pipelines.lightrag.storage import (
1617
+ published_root_for_embedding,
1618
+ read_published_policy,
1619
+ )
1620
+
1621
+ binding_signature = (
1622
+ kb_config.get("embedding_signature")
1623
+ if kb_config.get("embedding_selection")
1624
+ else None
1625
+ )
1626
+ published_root = (
1627
+ published_root_for_embedding(kb_dir, binding_signature) if dir_exists else None
1628
+ )
1629
+ indexing_policy = read_published_policy(published_root)
1630
+ if published_root is not None:
1631
+ metadata["indexed_version"] = published_root.name
1632
+ if indexing_policy is None:
1633
+ pending = kb_config.get("pending_indexing_policy")
1634
+ indexing_policy = (
1635
+ {"policy": "defaults"}
1636
+ if not index_versions
1637
+ else pending
1638
+ if isinstance(pending, dict)
1639
+ else {"policy": "legacy_unpinned"}
1640
+ )
1641
+ from deeptutor.services.rag.pipelines.lightrag.indexing_policy import public_policy
1642
+
1643
+ metadata["indexing_policy"] = public_policy(indexing_policy)
1644
+ if published_root is not None and indexing_policy.get("policy") == "pinned":
1645
+ from deeptutor.services.rag.pipelines.lightrag.indexing_policy import (
1646
+ IndexingPolicyError,
1647
+ snapshot_from_persisted,
1648
+ )
1649
+
1650
+ try:
1651
+ snapshot_from_persisted(indexing_policy)
1652
+ except (IndexingPolicyError, ValueError, PermissionError):
1653
+ # This is local catalog/access validation, not a model health probe.
1654
+ # Keep credential/provider exception details out of public metadata.
1655
+ metadata["indexing_model_unavailable"] = True
1656
+ for version in index_versions:
1657
+ if isinstance(version.get("indexing_policy"), dict):
1658
+ version["indexing_policy"] = public_policy(version["indexing_policy"])
1659
+ if published_root is not None or kb_config.get("embedding_selection"):
1660
+ from deeptutor.services.rag.embedding_binding import entry_signature
1661
+
1662
+ published = next(
1663
+ (
1664
+ v
1665
+ for v in index_versions
1666
+ if published_root is not None and v.get("version") == published_root.name
1667
+ ),
1668
+ {},
1669
+ )
1670
+ metadata["indexed_embedding_model"] = published.get(
1671
+ "embedding_model"
1672
+ ) or kb_config.get("embedding_model")
1673
+ metadata["indexed_embedding_dim"] = published.get("embedding_dim") or kb_config.get(
1674
+ "embedding_dim"
1675
+ )
1676
+ current_embedding = entry_signature(kb_config)
1677
+ if current_embedding is not None:
1678
+ metadata["current_embedding_model"] = current_embedding.model
1679
+ metadata["current_embedding_dim"] = current_embedding.dimension
1680
+
1681
+ metadata.update(self._embedding_fields(kb_config))
1682
+
1683
+ # Remove None values
1684
+ metadata = {k: v for k, v in metadata.items() if v is not None}
1685
+
1686
+ info = {
1687
+ "name": kb_name,
1688
+ "path": str(kb_dir),
1689
+ "is_default": kb_name == resolved_default,
1690
+ "metadata": metadata,
1691
+ "status": status,
1692
+ "progress": progress,
1693
+ }
1694
+
1695
+ # Count files - handle errors gracefully
1696
+ raw_dir = kb_dir / "raw" if dir_exists else None
1697
+ images_dir = kb_dir / "images" if dir_exists else None
1698
+ content_list_dir = kb_dir / "content_list" if dir_exists else None
1699
+
1700
+ raw_count = 0
1701
+ images_count = 0
1702
+ content_lists_count = 0
1703
+
1704
+ if dir_exists:
1705
+ try:
1706
+ # One definition of "a document in a KB", shared with the chat
1707
+ # manifest / ``kb_files`` so a user is never told two different
1708
+ # counts for the same KB (see :mod:`deeptutor.knowledge.manifest`).
1709
+ raw_count = sum(1 for _ in iter_kb_documents(raw_dir)) if raw_dir else 0
1710
+ except Exception:
1711
+ pass
1712
+
1713
+ try:
1714
+ images_count = (
1715
+ len([f for f in images_dir.iterdir() if f.is_file()]) if images_dir else 0
1716
+ )
1717
+ except Exception:
1718
+ pass
1719
+
1720
+ try:
1721
+ content_lists_count = (
1722
+ len(list(content_list_dir.glob("*.json"))) if content_list_dir else 0
1723
+ )
1724
+ except Exception:
1725
+ pass
1726
+
1727
+ # Check rag_initialized from provider-owned real output, not metadata alone.
1728
+ from deeptutor.services.rag.index_versioning import (
1729
+ find_matching_version,
1730
+ )
1731
+
1732
+ kb_probe_dir = kb_dir if dir_exists else None
1733
+ rag_initialized = has_ready_provider
1734
+
1735
+ pageindex_provider = rag_provider in {PAGEINDEX_PROVIDER, PAGEINDEX_OSS_PROVIDER}
1736
+ from deeptutor.services.rag.embedding_binding import entry_signature
1737
+
1738
+ active_signature = None if pageindex_provider else entry_signature(kb_config)
1739
+ if provider_uses_embedding_versions(rag_provider):
1740
+ matched_entry = (
1741
+ find_matching_version(kb_probe_dir, active_signature)
1742
+ if (kb_probe_dir and active_signature)
1743
+ else None
1744
+ )
1745
+ active_match = False
1746
+ if matched_entry:
1747
+ # Reuse the probe results already computed for ``index_versions``
1748
+ # instead of probing the matched storage a second time — probing
1749
+ # parses provider-owned files (e.g. the multi-MB LlamaIndex
1750
+ # docstore.json) and is the dominant cost of kb list / the
1751
+ # knowledge API (see issue #859).
1752
+ matched_path = matched_entry.get("storage_path")
1753
+ active_match = any(
1754
+ entry.get("storage_path") == matched_path and entry.get("ready")
1755
+ for entry in index_versions
1756
+ )
1757
+ else:
1758
+ active_match = rag_initialized
1759
+
1760
+ if kb_config.get("embedding_status") in {"missing", "changed", "unconfigured"}:
1761
+ active_match = False
1762
+
1763
+ info["statistics"] = {
1764
+ "raw_documents": raw_count,
1765
+ "images": images_count,
1766
+ "content_lists": content_lists_count,
1767
+ "rag_initialized": rag_initialized,
1768
+ "rag_provider": rag_provider,
1769
+ "needs_reindex": effective_needs_reindex,
1770
+ "index_versions": index_versions,
1771
+ "active_signature": active_signature.hash() if active_signature else None,
1772
+ "active_match": active_match,
1773
+ # Include status and progress in statistics for backward compatibility
1774
+ "status": status,
1775
+ "progress": progress,
1776
+ }
1777
+
1778
+ return info
1779
+
1780
+ def delete_knowledge_base(self, name: str, confirm: bool = False) -> bool:
1781
+ """
1782
+ Delete a knowledge base
1783
+
1784
+ Args:
1785
+ name: Knowledge base name
1786
+ confirm: If True, skip confirmation (use with caution!)
1787
+
1788
+ Returns:
1789
+ True if deleted successfully
1790
+ """
1791
+ # Look up against the raw config rather than ``list_knowledge_bases``:
1792
+ # the latter prunes orphan entries (dir missing) as a side effect, so
1793
+ # calling it here would race-delete the entry we are about to clean up
1794
+ # and then raise "not found" on the now-empty config.
1795
+ self.config = self._load_config()
1796
+ config_kbs = self.config.get("knowledge_bases", {})
1797
+ if name not in config_kbs and not (self.base_dir / name).exists():
1798
+ raise ValueError(f"Knowledge base not found: {name}")
1799
+
1800
+ # Resolve the directory directly to stay idempotent: if the on-disk
1801
+ # folder was already removed (e.g. manually rm-rf'd) we still want to
1802
+ # purge the orphaned entry from kb_config.json instead of failing.
1803
+ kb_dir = self.base_dir / name
1804
+ dir_exists = kb_dir.exists()
1805
+
1806
+ # Connected KBs (Obsidian vaults, linked indexes, subagent pointers)
1807
+ # reference the user's own external resource — or, for subagents, no
1808
+ # folder at all. Deleting one must only drop our pointer entry; never
1809
+ # touch what it references, and don't warn about the "missing" folder.
1810
+ entry = config_kbs.get(name, {})
1811
+ connected = is_connected_kb(entry)
1812
+ if connected:
1813
+ dir_exists = False
1814
+ # One connected kind does own storage we created: a MarginNote library's
1815
+ # synced objects live in a SQLite file under our own data directory, not
1816
+ # in an external resource the user manages. Leaving it behind would also
1817
+ # resurrect every paired device the moment a library of the same name is
1818
+ # connected again.
1819
+ if not confirm:
1820
+ # Ask for confirmation in CLI
1821
+ print(f"⚠️ Warning: This will permanently delete the knowledge base '{name}'")
1822
+ print(f" Path: {kb_dir}")
1823
+ response = input("Are you sure? Type 'yes' to confirm: ")
1824
+ if response.lower() != "yes":
1825
+ print("Deletion cancelled.")
1826
+ return False
1827
+
1828
+ if entry.get("type") == MARGINNOTE4_KB_TYPE:
1829
+ self._delete_marginnote4_store(name, entry)
1830
+
1831
+ if dir_exists:
1832
+
1833
+ def _on_rmtree_error(func, path, exc_info):
1834
+ exc = exc_info[1]
1835
+ if isinstance(exc, FileNotFoundError):
1836
+ # Race: something else removed the entry between walk and unlink.
1837
+ return
1838
+ # On Windows (and some bind-mounted filesystems) a read-only bit
1839
+ # or a stale handle from a failed RAG init can block removal.
1840
+ # Clear the read-only bit and retry once; if it still fails, log
1841
+ # and continue so the config entry gets cleaned up regardless —
1842
+ # leaving the KB stuck in the list is worse than orphan files on
1843
+ # disk (issue #370).
1844
+ try:
1845
+ current_mode = os.stat(path).st_mode
1846
+ writable_mode = current_mode | stat.S_IWRITE
1847
+ if stat.S_ISDIR(current_mode):
1848
+ # POSIX directories need execute permission to remain
1849
+ # traversable. Replacing the whole mode with S_IWRITE
1850
+ # leaves an orphan that even later cleanup cannot enter.
1851
+ writable_mode |= stat.S_IXUSR
1852
+ os.chmod(path, writable_mode)
1853
+ func(path)
1854
+ except Exception as retry_exc:
1855
+ logger.warning(
1856
+ f"Could not remove '{path}' while deleting KB '{name}': "
1857
+ f"{retry_exc}. Continuing; orphan files may remain on disk."
1858
+ )
1859
+
1860
+ shutil.rmtree(kb_dir, onerror=_on_rmtree_error)
1861
+ elif not connected:
1862
+ logger.warning(
1863
+ f"KB directory '{kb_dir}' missing on disk; cleaning up orphaned config entry."
1864
+ )
1865
+
1866
+ # Remove from config
1867
+ if name in self.config.get("knowledge_bases", {}):
1868
+ del self.config["knowledge_bases"][name]
1869
+
1870
+ # Update default if this was the default
1871
+ if self.config.get("default") == name:
1872
+ remaining = [n for n in self.config.get("knowledge_bases", {}).keys() if n != name]
1873
+ self.config["default"] = sorted(remaining)[0] if remaining else None
1874
+
1875
+ self._save_config()
1876
+ return True
1877
+
1878
+ def _delete_marginnote4_store(self, name: str, entry: dict) -> None:
1879
+ """Remove a MarginNote library's SQLite store, best-effort.
1880
+
1881
+ A failure here must not strand the config entry: leaving the KB in the
1882
+ list is worse than an orphan file, exactly as for the index directory
1883
+ above.
1884
+ """
1885
+ from deeptutor.capabilities.marginnote4.store import resolve_db_path
1886
+
1887
+ try:
1888
+ db_path = resolve_db_path(name, metadata=entry)
1889
+ db_path.unlink(missing_ok=True)
1890
+ # SQLite's WAL companions, when the last connection left them.
1891
+ for suffix in ("-wal", "-shm"):
1892
+ db_path.with_name(db_path.name + suffix).unlink(missing_ok=True)
1893
+ except Exception as exc: # noqa: BLE001 - orphan file beats a stuck entry
1894
+ logger.warning(
1895
+ f"Could not remove the MarginNote store for KB '{name}': {exc}. "
1896
+ "Continuing; the config entry is still cleaned up."
1897
+ )
1898
+
1899
+ def clean_rag_storage(self, name: str | None = None, backup: bool = True) -> bool:
1900
+ """
1901
+ Clean (delete) index storage for a knowledge base.
1902
+
1903
+ Args:
1904
+ name: Knowledge base name (default if not specified)
1905
+ backup: If True, backup storage before deleting
1906
+
1907
+ Returns:
1908
+ True if cleaned successfully
1909
+ """
1910
+ kb_name = name or self.get_default()
1911
+ kb_dir = self.get_knowledge_base_path(kb_name)
1912
+ from deeptutor.services.rag.index_versioning import (
1913
+ LEGACY_VERSION_DIRNAME,
1914
+ VERSION_PREFIX,
1915
+ )
1916
+
1917
+ legacy_llamaindex_storage_dir = kb_dir / "llamaindex_storage"
1918
+ legacy_versions_dir = kb_dir / LEGACY_VERSION_DIRNAME
1919
+ legacy_storage_dir = kb_dir / "rag_storage"
1920
+
1921
+ flat_version_dirs = [
1922
+ path
1923
+ for path in kb_dir.iterdir()
1924
+ if path.is_dir() and path.name.startswith(VERSION_PREFIX)
1925
+ ]
1926
+
1927
+ if (
1928
+ not flat_version_dirs
1929
+ and not legacy_versions_dir.exists()
1930
+ and not legacy_llamaindex_storage_dir.exists()
1931
+ and not legacy_storage_dir.exists()
1932
+ ):
1933
+ logger.info(f"Index storage does not exist for '{kb_name}'")
1934
+ return False
1935
+
1936
+ targets = []
1937
+ for version_dir in flat_version_dirs:
1938
+ targets.append((version_dir.name, version_dir))
1939
+ if legacy_versions_dir.exists():
1940
+ targets.append((LEGACY_VERSION_DIRNAME, legacy_versions_dir))
1941
+ if legacy_llamaindex_storage_dir.exists():
1942
+ targets.append(("llamaindex_storage", legacy_llamaindex_storage_dir))
1943
+ if legacy_storage_dir.exists():
1944
+ targets.append(("rag_storage", legacy_storage_dir))
1945
+
1946
+ for label, target in targets:
1947
+ if backup:
1948
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1949
+ backup_dir = kb_dir / f"{label}_backup_{timestamp}"
1950
+ shutil.copytree(target, backup_dir)
1951
+ logger.info(f"Backed up {label} to: {backup_dir}")
1952
+
1953
+ shutil.rmtree(target)
1954
+ logger.info(f"Cleaned {label} for '{kb_name}'")
1955
+
1956
+ return True
1957
+
1958
+ def link_folder(self, kb_name: str, folder_path: str) -> dict:
1959
+ """
1960
+ Link a local folder to a knowledge base.
1961
+
1962
+ Args:
1963
+ kb_name: Knowledge base name
1964
+ folder_path: Path to local folder (supports ~, relative paths)
1965
+
1966
+ Returns:
1967
+ Dict with folder info including id, path, and file count
1968
+
1969
+ Raises:
1970
+ ValueError: If KB not found or folder doesn't exist
1971
+ """
1972
+ if kb_name not in self.list_knowledge_bases():
1973
+ raise ValueError(f"Knowledge base not found: {kb_name}")
1974
+
1975
+ # Normalize path (cross-platform: handles ~, relative paths, etc.)
1976
+ folder = Path(folder_path).expanduser().resolve()
1977
+
1978
+ if not folder.exists():
1979
+ raise ValueError(f"Folder does not exist: {folder}")
1980
+ if not folder.is_dir():
1981
+ raise ValueError(f"Path is not a directory: {folder}")
1982
+
1983
+ files = FileTypeRouter.collect_supported_files(folder, recursive=True)
1984
+
1985
+ # Generate folder ID
1986
+
1987
+ folder_id = hashlib.md5( # noqa: S324
1988
+ str(folder).encode(), usedforsecurity=False
1989
+ ).hexdigest()[:8]
1990
+
1991
+ # Load existing linked folders from metadata
1992
+ kb_dir = self.base_dir / kb_name
1993
+ metadata_file = kb_dir / "metadata.json"
1994
+ metadata: dict = {}
1995
+
1996
+ if metadata_file.exists():
1997
+ try:
1998
+ with open(metadata_file, encoding="utf-8") as fp:
1999
+ metadata = json.load(fp)
2000
+ except Exception:
2001
+ metadata = {}
2002
+
2003
+ if "linked_folders" not in metadata:
2004
+ metadata["linked_folders"] = []
2005
+
2006
+ # Check if already linked
2007
+ existing_ids = [item["id"] for item in metadata.get("linked_folders", [])]
2008
+ if folder_id in existing_ids:
2009
+ # If already linked, treat as success (idempotent)
2010
+ # Find and return existing info
2011
+ for item in metadata.get("linked_folders", []):
2012
+ if item["id"] == folder_id:
2013
+ return item
2014
+
2015
+ # Add folder info
2016
+ folder_info = {
2017
+ "id": folder_id,
2018
+ "path": str(folder),
2019
+ "added_at": datetime.now().isoformat(),
2020
+ "file_count": len(files),
2021
+ "last_sync": None,
2022
+ }
2023
+ metadata["linked_folders"].append(folder_info)
2024
+
2025
+ atomic_write_json(metadata_file, metadata)
2026
+
2027
+ return folder_info
2028
+
2029
+ def get_linked_folders(self, kb_name: str) -> list[dict]:
2030
+ """
2031
+ Get list of linked folders for a knowledge base.
2032
+
2033
+ Args:
2034
+ kb_name: Knowledge base name
2035
+
2036
+ Returns:
2037
+ List of linked folder info dicts
2038
+ """
2039
+ if kb_name not in self.list_knowledge_bases():
2040
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2041
+
2042
+ kb_dir = self.base_dir / kb_name
2043
+ metadata_file = kb_dir / "metadata.json"
2044
+
2045
+ if not metadata_file.exists():
2046
+ return []
2047
+
2048
+ try:
2049
+ with open(metadata_file, encoding="utf-8") as f:
2050
+ metadata = json.load(f)
2051
+ return metadata.get("linked_folders", [])
2052
+ except Exception:
2053
+ return []
2054
+
2055
+ def unlink_folder(self, kb_name: str, folder_id: str) -> bool:
2056
+ """
2057
+ Unlink a folder from a knowledge base.
2058
+
2059
+ Args:
2060
+ kb_name: Knowledge base name
2061
+ folder_id: Folder ID to unlink
2062
+
2063
+ Returns:
2064
+ True if unlinked successfully, False if not found
2065
+ """
2066
+ if kb_name not in self.list_knowledge_bases():
2067
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2068
+
2069
+ kb_dir = self.base_dir / kb_name
2070
+ metadata_file = kb_dir / "metadata.json"
2071
+
2072
+ if not metadata_file.exists():
2073
+ return False
2074
+
2075
+ try:
2076
+ with open(metadata_file, encoding="utf-8") as f:
2077
+ metadata = json.load(f)
2078
+ except Exception:
2079
+ return False
2080
+
2081
+ linked = metadata.get("linked_folders", [])
2082
+ new_linked = [f for f in linked if f["id"] != folder_id]
2083
+
2084
+ if len(new_linked) == len(linked):
2085
+ return False # Not found
2086
+
2087
+ metadata["linked_folders"] = new_linked
2088
+
2089
+ atomic_write_json(metadata_file, metadata)
2090
+
2091
+ return True
2092
+
2093
+ def scan_linked_folder(self, folder_path: str, provider: str = DEFAULT_PROVIDER) -> list[str]:
2094
+ """
2095
+ Scan a linked folder and return list of supported file paths.
2096
+
2097
+ Args:
2098
+ folder_path: Path to folder
2099
+ provider: RAG provider to determine supported extensions (default: llamaindex)
2100
+
2101
+ Returns:
2102
+ List of file paths (as strings)
2103
+ """
2104
+ folder = Path(folder_path).expanduser().resolve()
2105
+
2106
+ if not folder.exists() or not folder.is_dir():
2107
+ return []
2108
+
2109
+ files = [
2110
+ str(file_path)
2111
+ for file_path in FileTypeRouter.collect_supported_files(folder, recursive=True)
2112
+ ]
2113
+
2114
+ return sorted(files)
2115
+
2116
+ def detect_folder_changes(self, kb_name: str, folder_id: str) -> dict:
2117
+ """
2118
+ Detect new and modified files in a linked folder since last sync.
2119
+
2120
+ This enables automatic sync of changes from local folders that may
2121
+ be synced with cloud services like SharePoint, Google Drive, etc.
2122
+
2123
+ Args:
2124
+ kb_name: Knowledge base name
2125
+ folder_id: Folder ID to check for changes
2126
+
2127
+ Returns:
2128
+ Dict with 'new_files', 'modified_files', and 'has_changes' keys
2129
+ """
2130
+ if kb_name not in self.list_knowledge_bases():
2131
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2132
+
2133
+ # Get folder info
2134
+ folders = self.get_linked_folders(kb_name)
2135
+ folder_info = next((f for f in folders if f["id"] == folder_id), None)
2136
+
2137
+ if not folder_info:
2138
+ raise ValueError(f"Linked folder not found: {folder_id}")
2139
+
2140
+ folder_path = Path(folder_info["path"]).expanduser().resolve()
2141
+ synced_files = folder_info.get("synced_files", {})
2142
+
2143
+ new_files = []
2144
+ modified_files = []
2145
+
2146
+ for file_path in FileTypeRouter.collect_supported_files(folder_path, recursive=True):
2147
+ file_str = str(file_path)
2148
+ file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
2149
+
2150
+ if file_str in synced_files:
2151
+ # Check if modified since last sync
2152
+ prev_mtime_str = synced_files[file_str]
2153
+ try:
2154
+ prev_mtime = datetime.fromisoformat(prev_mtime_str)
2155
+ if file_mtime > prev_mtime:
2156
+ modified_files.append(file_str)
2157
+ except Exception:
2158
+ modified_files.append(file_str)
2159
+ else:
2160
+ # New file (not in synced files)
2161
+ new_files.append(file_str)
2162
+
2163
+ return {
2164
+ "new_files": sorted(new_files),
2165
+ "modified_files": sorted(modified_files),
2166
+ "has_changes": len(new_files) > 0 or len(modified_files) > 0,
2167
+ "new_count": len(new_files),
2168
+ "modified_count": len(modified_files),
2169
+ }
2170
+
2171
+ def update_folder_sync_state(
2172
+ self,
2173
+ kb_name: str,
2174
+ folder_id: str,
2175
+ synced_files: list[str],
2176
+ source_mtimes: dict[str, str] | None = None,
2177
+ ):
2178
+ """
2179
+ Update the sync state for a linked folder after successful sync.
2180
+
2181
+ Records which files were synced and their modification times,
2182
+ enabling future change detection.
2183
+
2184
+ Args:
2185
+ kb_name: Knowledge base name
2186
+ folder_id: Folder ID
2187
+ synced_files: List of file paths that were successfully synced
2188
+ source_mtimes: Modification times captured before source staging.
2189
+ """
2190
+ if kb_name not in self.list_knowledge_bases():
2191
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2192
+
2193
+ kb_dir = self.base_dir / kb_name
2194
+ metadata_file = kb_dir / "metadata.json"
2195
+
2196
+ if not metadata_file.exists():
2197
+ return
2198
+
2199
+ try:
2200
+ with open(metadata_file, encoding="utf-8") as f:
2201
+ metadata = json.load(f)
2202
+ except Exception:
2203
+ return
2204
+
2205
+ linked = metadata.get("linked_folders", [])
2206
+
2207
+ for folder in linked:
2208
+ if folder["id"] == folder_id:
2209
+ # Record sync timestamp
2210
+ folder["last_sync"] = datetime.now().isoformat()
2211
+
2212
+ # Record file modification times
2213
+ file_states = folder.get("synced_files", {})
2214
+ for file_path in synced_files:
2215
+ try:
2216
+ if source_mtimes is not None:
2217
+ if file_path in source_mtimes:
2218
+ file_states[file_path] = source_mtimes[file_path]
2219
+ else:
2220
+ p = Path(file_path)
2221
+ if p.exists():
2222
+ mtime = datetime.fromtimestamp(p.stat().st_mtime)
2223
+ file_states[file_path] = mtime.isoformat()
2224
+ except Exception as exc:
2225
+ # Recording failed: leave the file un-recorded so the
2226
+ # next scan re-syncs it instead of trusting a missing
2227
+ # mtime, and make the degradation visible in logs.
2228
+ logger.warning(
2229
+ f"Failed to record sync state for '{file_path}' in folder "
2230
+ f"'{folder_id}' of KB '{kb_name}': {exc}"
2231
+ )
2232
+
2233
+ folder["synced_files"] = file_states
2234
+ folder["file_count"] = len(file_states)
2235
+ atomic_write_json(metadata_file, metadata)
2236
+ break
2237
+
2238
+ # ------------------------------------------------------------------
2239
+ # GitHub source management
2240
+ # ------------------------------------------------------------------
2241
+
2242
+ def add_github_source(self, kb_name, repo, branch="main", path="", glob="*.md"):
2243
+ """Register a GitHub repo as a document source for a KB."""
2244
+ if kb_name not in self.list_knowledge_bases():
2245
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2246
+ repo_clean = repo.strip().rstrip("/")
2247
+ if repo_clean.endswith(".git"):
2248
+ repo_clean = repo_clean[:-4]
2249
+ if "github.com/" in repo_clean:
2250
+ repo_clean = repo_clean.split("github.com/", 1)[-1]
2251
+ repo_clean = repo_clean.strip("/")
2252
+ source_id = hashlib.md5( # noqa: S324
2253
+ f"{repo_clean}:{branch}:{path}".encode(), usedforsecurity=False
2254
+ ).hexdigest()[:8]
2255
+ kb_dir = self.base_dir / kb_name
2256
+ metadata_file = kb_dir / "metadata.json"
2257
+ metadata = self._read_kb_metadata(metadata_file)
2258
+ sources = metadata.get("github_sources", [])
2259
+ for existing in sources:
2260
+ if existing.get("id") == source_id:
2261
+ return existing
2262
+ source_info = {
2263
+ "id": source_id,
2264
+ "repo": repo_clean,
2265
+ "branch": branch,
2266
+ "path": path,
2267
+ "glob": glob,
2268
+ "enabled": True,
2269
+ "last_synced_sha": "",
2270
+ "last_synced_at": "",
2271
+ "last_sync_status": "pending",
2272
+ "last_sync_error": None,
2273
+ "files_synced": 0,
2274
+ "added_at": datetime.now().isoformat(),
2275
+ }
2276
+ sources.append(source_info)
2277
+ metadata["github_sources"] = sources
2278
+ atomic_write_json(metadata_file, metadata)
2279
+ return source_info
2280
+
2281
+ def remove_github_source(self, kb_name, source_id):
2282
+ """Remove a GitHub source from a KB."""
2283
+ if kb_name not in self.list_knowledge_bases():
2284
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2285
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2286
+ metadata = self._read_kb_metadata(metadata_file)
2287
+ sources = metadata.get("github_sources", [])
2288
+ new_sources = [s for s in sources if s.get("id") != source_id]
2289
+ if len(new_sources) == len(sources):
2290
+ return False
2291
+ metadata["github_sources"] = new_sources
2292
+ atomic_write_json(metadata_file, metadata)
2293
+ return True
2294
+
2295
+ def get_github_sources(self, kb_name):
2296
+ """Return all GitHub sources registered for a KB."""
2297
+ if kb_name not in self.list_knowledge_bases():
2298
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2299
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2300
+ metadata = self._read_kb_metadata(metadata_file)
2301
+ return metadata.get("github_sources", [])
2302
+
2303
+ def update_github_source_state(self, kb_name, source_id, **fields):
2304
+ """Persist sync state fields into a GitHub source entry."""
2305
+ if kb_name not in self.list_knowledge_bases():
2306
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2307
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2308
+ metadata = self._read_kb_metadata(metadata_file)
2309
+ sources = metadata.get("github_sources", [])
2310
+ for src in sources:
2311
+ if src.get("id") == source_id:
2312
+ src.update(fields)
2313
+ atomic_write_json(metadata_file, metadata)
2314
+ return
2315
+
2316
+ def get_all_github_sources(self):
2317
+ """Scan every KB and return (kb_name, source_dict) pairs."""
2318
+ result = []
2319
+ for kb_name in self.list_knowledge_bases():
2320
+ for src in self.get_github_sources(kb_name):
2321
+ result.append((kb_name, src))
2322
+ return result
2323
+
2324
+ # ------------------------------------------------------------------
2325
+ # Web source management
2326
+ # ------------------------------------------------------------------
2327
+
2328
+ def add_web_source(
2329
+ self,
2330
+ kb_name: str,
2331
+ url: str,
2332
+ max_depth: int = 3,
2333
+ max_pages: int = 200,
2334
+ ) -> dict:
2335
+ """Register a documentation site URL as a document source for a KB."""
2336
+ if kb_name not in self.list_knowledge_bases():
2337
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2338
+ if not 1 <= max_depth <= MAX_CRAWL_DEPTH:
2339
+ raise ValueError(f"Web source crawl depth must be between 1 and {MAX_CRAWL_DEPTH}")
2340
+ if not 1 <= max_pages <= MAX_CRAWL_PAGES:
2341
+ raise ValueError(f"Web source crawl page count must be between 1 and {MAX_CRAWL_PAGES}")
2342
+ normalized_url = url.strip()
2343
+ parsed_url = urlparse(normalized_url)
2344
+ if parsed_url.scheme.lower() not in ("http", "https") or not parsed_url.hostname:
2345
+ raise ValueError("Web source URL must be an absolute http(s) URL")
2346
+ source_id = hashlib.md5( # noqa: S324
2347
+ normalized_url.encode(), usedforsecurity=False
2348
+ ).hexdigest()[:8]
2349
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2350
+ metadata = self._read_kb_metadata(metadata_file)
2351
+ sources = metadata.get("web_sources", [])
2352
+ for existing in sources:
2353
+ if existing.get("id") == source_id:
2354
+ return existing
2355
+
2356
+ source_info = {
2357
+ "id": source_id,
2358
+ "url": normalized_url,
2359
+ "max_depth": max_depth,
2360
+ "max_pages": max_pages,
2361
+ "enabled": True,
2362
+ "auto_sync_enabled": True,
2363
+ "sync_interval_hours": 24,
2364
+ "page_hashes": {},
2365
+ "page_count": 0,
2366
+ "last_synced_at": "",
2367
+ "last_sync_status": "pending",
2368
+ "last_sync_error": None,
2369
+ "added_at": datetime.now().isoformat(),
2370
+ }
2371
+ sources.append(source_info)
2372
+ metadata["web_sources"] = sources
2373
+ atomic_write_json(metadata_file, metadata)
2374
+ return source_info
2375
+
2376
+ def remove_web_source(self, kb_name: str, source_id: str) -> bool:
2377
+ """Remove a web source from a KB."""
2378
+ if kb_name not in self.list_knowledge_bases():
2379
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2380
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2381
+ metadata = self._read_kb_metadata(metadata_file)
2382
+ sources = metadata.get("web_sources", [])
2383
+ remaining = [source for source in sources if source.get("id") != source_id]
2384
+ if len(remaining) == len(sources):
2385
+ return False
2386
+
2387
+ metadata["web_sources"] = remaining
2388
+ atomic_write_json(metadata_file, metadata)
2389
+ return True
2390
+
2391
+ def get_web_sources(self, kb_name: str) -> list[dict]:
2392
+ """Return all web sources registered for a KB."""
2393
+ if kb_name not in self.list_knowledge_bases():
2394
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2395
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2396
+ metadata = self._read_kb_metadata(metadata_file)
2397
+ return metadata.get("web_sources", [])
2398
+
2399
+ def update_web_source_state(self, kb_name: str, source_id: str, **fields: object) -> None:
2400
+ """Persist sync state fields into a web source entry."""
2401
+ if kb_name not in self.list_knowledge_bases():
2402
+ raise ValueError(f"Knowledge base not found: {kb_name}")
2403
+ metadata_file = self.base_dir / kb_name / "metadata.json"
2404
+ metadata = self._read_kb_metadata(metadata_file)
2405
+ for source in metadata.get("web_sources", []):
2406
+ if source.get("id") == source_id:
2407
+ source.update(fields)
2408
+ atomic_write_json(metadata_file, metadata)
2409
+ return
2410
+
2411
+ def update_web_source_schedule(
2412
+ self,
2413
+ kb_name: str,
2414
+ source_id: str,
2415
+ *,
2416
+ auto_sync_enabled: bool,
2417
+ sync_interval_hours: int,
2418
+ ) -> dict:
2419
+ """Persist the reviewable schedule fields for one web source."""
2420
+ source = next(
2421
+ (item for item in self.get_web_sources(kb_name) if item.get("id") == source_id),
2422
+ None,
2423
+ )
2424
+ if source is None:
2425
+ raise ValueError(f"Source '{source_id}' not found")
2426
+ self.update_web_source_state(
2427
+ kb_name,
2428
+ source_id,
2429
+ auto_sync_enabled=auto_sync_enabled,
2430
+ sync_interval_hours=sync_interval_hours,
2431
+ )
2432
+ return next(item for item in self.get_web_sources(kb_name) if item.get("id") == source_id)
2433
+
2434
+ def get_all_web_sources(self) -> list[tuple[str, dict]]:
2435
+ """Scan every KB and return (kb_name, source_dict) pairs."""
2436
+ result = []
2437
+ for kb_name in self.list_knowledge_bases():
2438
+ for source in self.get_web_sources(kb_name):
2439
+ result.append((kb_name, source))
2440
+ return result
2441
+
2442
+ @staticmethod
2443
+ def _read_kb_metadata(metadata_file):
2444
+ """Load metadata.json, returning {} on absence or parse error."""
2445
+ if not metadata_file.exists():
2446
+ return {}
2447
+ try:
2448
+ with open(metadata_file, encoding="utf-8") as f:
2449
+ data = json.load(f)
2450
+ except Exception:
2451
+ return {}
2452
+ return data if isinstance(data, dict) else {}
2453
+
2454
+
2455
+ def main():
2456
+ """Command-line interface for knowledge base manager"""
2457
+ import argparse
2458
+
2459
+ parser = argparse.ArgumentParser(description="Knowledge Base Manager")
2460
+ parser.add_argument(
2461
+ "--base-dir", default="./knowledge_bases", help="Base directory for knowledge bases"
2462
+ )
2463
+
2464
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
2465
+
2466
+ # List command
2467
+ subparsers.add_parser("list", help="List all knowledge bases")
2468
+
2469
+ # Info command
2470
+ info_parser = subparsers.add_parser("info", help="Show knowledge base information")
2471
+ info_parser.add_argument(
2472
+ "name", nargs="?", help="Knowledge base name (default if not specified)"
2473
+ )
2474
+
2475
+ # Set default command
2476
+ default_parser = subparsers.add_parser("set-default", help="Set default knowledge base")
2477
+ default_parser.add_argument("name", help="Knowledge base name")
2478
+
2479
+ # Delete command
2480
+ delete_parser = subparsers.add_parser("delete", help="Delete a knowledge base")
2481
+ delete_parser.add_argument("name", help="Knowledge base name")
2482
+ delete_parser.add_argument("--force", action="store_true", help="Skip confirmation")
2483
+
2484
+ # Clean RAG command
2485
+ clean_parser = subparsers.add_parser(
2486
+ "clean-rag", help="Clean RAG storage (useful for corrupted data)"
2487
+ )
2488
+ clean_parser.add_argument(
2489
+ "name", nargs="?", help="Knowledge base name (default if not specified)"
2490
+ )
2491
+ clean_parser.add_argument(
2492
+ "--no-backup", action="store_true", help="Don't backup before cleaning"
2493
+ )
2494
+
2495
+ args = parser.parse_args()
2496
+
2497
+ manager = KnowledgeBaseManager(args.base_dir)
2498
+
2499
+ if args.command == "list":
2500
+ kb_list = manager.list_knowledge_bases()
2501
+ default_kb = manager.get_default()
2502
+
2503
+ print("\nAvailable Knowledge Bases:")
2504
+ print("=" * 60)
2505
+ if not kb_list:
2506
+ print("No knowledge bases found")
2507
+ else:
2508
+ for kb_name in kb_list:
2509
+ default_marker = " (default)" if kb_name == default_kb else ""
2510
+ print(f" • {kb_name}{default_marker}")
2511
+ print()
2512
+
2513
+ elif args.command == "info":
2514
+ try:
2515
+ info = manager.get_info(args.name)
2516
+
2517
+ print("\nKnowledge Base Information:")
2518
+ print("=" * 60)
2519
+ print(f"Name: {info['name']}")
2520
+ print(f"Path: {info['path']}")
2521
+ print(f"Default: {'Yes' if info['is_default'] else 'No'}")
2522
+
2523
+ if info.get("metadata"):
2524
+ print("\nMetadata:")
2525
+ for key, value in info["metadata"].items():
2526
+ print(f" {key}: {value}")
2527
+
2528
+ print("\nStatistics:")
2529
+ stats = info["statistics"]
2530
+ print(f" Raw documents: {stats['raw_documents']}")
2531
+ print(f" Images: {stats['images']}")
2532
+ print(f" Content lists: {stats['content_lists']}")
2533
+ print(f" RAG initialized: {'Yes' if stats['rag_initialized'] else 'No'}")
2534
+
2535
+ if "rag" in stats:
2536
+ print("\n RAG Statistics:")
2537
+ for key, value in stats["rag"].items():
2538
+ print(f" {key}: {value}")
2539
+
2540
+ print()
2541
+ except Exception as e:
2542
+ print(f"Error: {e!s}")
2543
+
2544
+ elif args.command == "set-default":
2545
+ try:
2546
+ manager.set_default(args.name)
2547
+ print(f"✓ Set '{args.name}' as default knowledge base")
2548
+ except Exception as e:
2549
+ print(f"Error: {e!s}")
2550
+
2551
+ elif args.command == "delete":
2552
+ try:
2553
+ success = manager.delete_knowledge_base(args.name, confirm=args.force)
2554
+ if success:
2555
+ print(f"✓ Deleted knowledge base '{args.name}'")
2556
+ except Exception as e:
2557
+ print(f"Error: {e!s}")
2558
+
2559
+ elif args.command == "clean-rag":
2560
+ try:
2561
+ manager.clean_rag_storage(args.name, backup=not args.no_backup)
2562
+ except Exception as e:
2563
+ print(f"Error: {e!s}")
2564
+
2565
+ else:
2566
+ parser.print_help()
2567
+
2568
+
2569
+ if __name__ == "__main__":
2570
+ main()