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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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()
|