deeptutor-knowledge 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,734 @@
1
+ #!/usr/bin/env python
2
+ """Incrementally add documents to an existing knowledge base."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, List, Optional
14
+
15
+ from deeptutor.knowledge.staging import content_hash, stage_source
16
+ from deeptutor.services.config import resolve_llm_runtime_config
17
+ from deeptutor.services.file_io import atomic_write_json
18
+ from deeptutor.services.rag.factory import (
19
+ DEFAULT_PROVIDER,
20
+ LIGHTRAG_PROVIDER,
21
+ has_ready_provider_index,
22
+ normalize_provider_name,
23
+ )
24
+ from deeptutor.services.rag.index_probe import provider_failure_summary
25
+ from deeptutor.services.rag.index_versioning import list_kb_versions
26
+ from deeptutor.services.rag.provider_binding import resolve_bound_provider
27
+ from deeptutor.services.rag.service import RAGService
28
+ from deeptutor.services.setup.data_volume import (
29
+ DataVolumePermissionError,
30
+ ensure_data_volume_writable,
31
+ format_data_volume_permission_error,
32
+ )
33
+ from deeptutor.utils.file_types import FileTypeRouter
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ DEFAULT_BASE_DIR = "./data/knowledge_bases"
38
+
39
+ if TYPE_CHECKING:
40
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DocumentIndexFailure:
45
+ """One file that could not be added to the provider index."""
46
+
47
+ file_path: Path
48
+ error: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DocumentIndexResult:
53
+ """Structured incremental-index result.
54
+
55
+ A task can no longer infer success from "no exception": every staged file is
56
+ explicitly accounted for as either processed or failed.
57
+ """
58
+
59
+ processed_files: list[Path]
60
+ failures: list[DocumentIndexFailure]
61
+
62
+ @property
63
+ def processed_count(self) -> int:
64
+ return len(self.processed_files)
65
+
66
+ @property
67
+ def failed_count(self) -> int:
68
+ return len(self.failures)
69
+
70
+ @property
71
+ def has_failures(self) -> bool:
72
+ return bool(self.failures)
73
+
74
+ def failure_summary(self, *, limit: int = 3) -> str:
75
+ if not self.failures:
76
+ return ""
77
+ shown = [f"{failure.file_path.name}: {failure.error}" for failure in self.failures[:limit]]
78
+ remaining = len(self.failures) - len(shown)
79
+ if remaining > 0:
80
+ shown.append(f"... and {remaining} more file(s)")
81
+ return "; ".join(shown)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class RawDocumentRemoval:
86
+ """Outcome of removing one raw document from a knowledge base."""
87
+
88
+ rel_path: str
89
+ was_indexed: bool
90
+
91
+
92
+ def _read_metadata(metadata_file: Path) -> dict:
93
+ """Load a KB's metadata.json, returning {} when absent or unreadable."""
94
+ if not metadata_file.exists():
95
+ return {}
96
+ try:
97
+ with open(metadata_file, "r", encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ except Exception:
100
+ return {}
101
+ return data if isinstance(data, dict) else {}
102
+
103
+
104
+ def _write_metadata(metadata_file: Path, metadata: dict) -> None:
105
+ """Persist a KB's metadata.json atomically (pretty-printed, non-ASCII preserved)."""
106
+ atomic_write_json(metadata_file, metadata)
107
+
108
+
109
+ def _raw_hash_key(file_path: Path, raw_dir: Path) -> str:
110
+ """Stable ``file_hashes`` key for a staged file.
111
+
112
+ The key is the POSIX path relative to ``raw/`` (so folder uploads keep
113
+ distinct entries), falling back to the basename for anything that resolves
114
+ outside ``raw/``. Indexing and removal MUST share this rule, otherwise a
115
+ removed file's hash record would leak and silently skip a later re-add.
116
+ """
117
+ try:
118
+ return file_path.resolve().relative_to(raw_dir.resolve()).as_posix()
119
+ except ValueError:
120
+ return file_path.name
121
+
122
+
123
+ def hashes_for_indexed_files(file_paths: list[str], raw_dir: Path) -> dict[str, str]:
124
+ """Hash source files already confirmed in an index, keyed by their raw path."""
125
+ return {
126
+ _raw_hash_key(Path(file_path), raw_dir): content_hash(Path(file_path))
127
+ for file_path in file_paths
128
+ }
129
+
130
+
131
+ def remove_raw_document(kb_dir: Path, file_path: Path) -> RawDocumentRemoval:
132
+ """Delete one staged raw file and drop its indexed-hash record.
133
+
134
+ Deliberately decoupled from :class:`DocumentAdder` (whose constructor
135
+ requires a ready provider index): a KB stuck in an *error* state often has
136
+ no index at all, yet its raw/ files must still be removable so the user can
137
+ drop an unparseable document instead of deleting and rebuilding the whole
138
+ KB. Vectors are not touched here — the providers index whole KBs, so any
139
+ vectors from an already-indexed file are cleared by a subsequent re-index,
140
+ which the returned ``was_indexed`` flag lets the caller surface.
141
+
142
+ ``file_path`` must already be sandbox-resolved under the KB's raw/ dir.
143
+ """
144
+ raw_dir = kb_dir / "raw"
145
+ hash_key = _raw_hash_key(file_path, raw_dir)
146
+
147
+ target = file_path.resolve()
148
+ if target.exists():
149
+ target.unlink()
150
+
151
+ metadata_file = kb_dir / "metadata.json"
152
+ metadata = _read_metadata(metadata_file)
153
+ hashes = metadata.get("file_hashes")
154
+ was_indexed = isinstance(hashes, dict) and hash_key in hashes
155
+ if was_indexed:
156
+ del hashes[hash_key]
157
+ _write_metadata(metadata_file, metadata)
158
+
159
+ return RawDocumentRemoval(rel_path=hash_key, was_indexed=was_indexed)
160
+
161
+
162
+ class DocumentAdder:
163
+ """Stage and index new files through a KB's bound RAG provider."""
164
+
165
+ def __init__(
166
+ self,
167
+ kb_name: str,
168
+ base_dir: str = DEFAULT_BASE_DIR,
169
+ api_key: str | None = None,
170
+ base_url: str | None = None,
171
+ progress_tracker=None,
172
+ rag_provider: str | None = None,
173
+ accepted_indexing_snapshot=None,
174
+ ):
175
+ self.kb_name = kb_name
176
+ self.base_dir = Path(base_dir)
177
+ self.kb_dir = self.base_dir / kb_name
178
+
179
+ if not self.kb_dir.exists():
180
+ raise ValueError(f"Knowledge base does not exist: {kb_name}")
181
+
182
+ self.raw_dir = self.kb_dir / "raw"
183
+ self.llamaindex_storage_dir = self.kb_dir / "llamaindex_storage"
184
+ self.legacy_rag_storage_dir = self.kb_dir / "rag_storage"
185
+ self.metadata_file = self.kb_dir / "metadata.json"
186
+
187
+ # Fail on UID-mismatched / unwritable volumes before the "not initialized"
188
+ # check, which otherwise hides Unraid bind-mount permission errors.
189
+ ensure_data_volume_writable(self.kb_dir)
190
+
191
+ # Incremental adds must use the engine DeepTutor has bound to this KB. An
192
+ # explicit rag_provider (from the API, already matched against the KB)
193
+ # wins; otherwise use the shared binding resolver.
194
+ if rag_provider:
195
+ self.rag_provider = normalize_provider_name(rag_provider)
196
+ else:
197
+ self.rag_provider = self._provider_from_binding()
198
+
199
+ has_provider_index = has_ready_provider_index(self.kb_dir, self.rag_provider)
200
+
201
+ if (
202
+ self.rag_provider == DEFAULT_PROVIDER
203
+ and not has_provider_index
204
+ and self.legacy_rag_storage_dir.exists()
205
+ ):
206
+ raise ValueError(
207
+ f"Knowledge base '{kb_name}' uses legacy index format and requires reindex before incremental add"
208
+ )
209
+
210
+ # Both pipelines create their first index on add; existing broken versions
211
+ # still require reindex instead of being silently replaced (#1458).
212
+ versions = list_kb_versions(self.kb_dir)
213
+ allows_bootstrap = (
214
+ self.rag_provider
215
+ in {
216
+ DEFAULT_PROVIDER,
217
+ LIGHTRAG_PROVIDER,
218
+ }
219
+ and not versions
220
+ )
221
+ if not has_provider_index and not allows_bootstrap:
222
+ if versions:
223
+ summary = provider_failure_summary(self.kb_dir, self.rag_provider)
224
+ raise ValueError(
225
+ f"Knowledge base has no ready {self.rag_provider} index; reindex required: "
226
+ f"{summary or 'stored index version is incomplete'}"
227
+ )
228
+ raise ValueError(f"Knowledge base not initialized ({self.rag_provider}): {kb_name}")
229
+
230
+ self.accepted_indexing_snapshot = accepted_indexing_snapshot
231
+ if self.rag_provider == LIGHTRAG_PROVIDER and self.accepted_indexing_snapshot is None:
232
+ from deeptutor.services.rag.pipelines.lightrag.indexing_policy import (
233
+ bind_target,
234
+ resolve_write_snapshot,
235
+ )
236
+
237
+ self.accepted_indexing_snapshot = bind_target(
238
+ resolve_write_snapshot(
239
+ self.kb_dir, base_dir=str(self.base_dir), kb_name=self.kb_name
240
+ ),
241
+ self.kb_dir,
242
+ )
243
+
244
+ self.api_key = api_key
245
+ self.base_url = base_url
246
+ self.progress_tracker = progress_tracker
247
+
248
+ self.raw_dir.mkdir(parents=True, exist_ok=True)
249
+
250
+ def _provider_from_binding(self) -> str:
251
+ return resolve_bound_provider(self.base_dir, self.kb_name)
252
+
253
+ def _get_file_hash(self, file_path: Path) -> str:
254
+ return content_hash(file_path)
255
+
256
+ def get_ingested_hashes(self) -> dict[str, str]:
257
+ if self.metadata_file.exists():
258
+ try:
259
+ with open(self.metadata_file, "r", encoding="utf-8") as f:
260
+ data = json.load(f)
261
+ return data.get("file_hashes", {})
262
+ except Exception:
263
+ return {}
264
+ return {}
265
+
266
+ def add_documents(
267
+ self,
268
+ source_files: List[str],
269
+ allow_duplicates: bool = False,
270
+ source_root: str | Path | None = None,
271
+ ) -> List[Path]:
272
+ """Validate and stage files into raw/ before indexing.
273
+
274
+ ``source_root``, when given, is the root a caller is syncing from
275
+ (e.g. a linked folder). Any source file found under it is staged at
276
+ the same path *relative to that root*, instead of being flattened to
277
+ its bare filename: without this, every externally-sourced subfolder
278
+ collapses into raw/'s top level and same-named files in different
279
+ subfolders collide.
280
+ """
281
+ logger.info(f"Validating documents for '{self.kb_name}'...")
282
+
283
+ ingested_hashes = self.get_ingested_hashes()
284
+ files_to_process: list[Path] = []
285
+ resolved_source_root = Path(source_root).resolve() if source_root else None
286
+
287
+ for source in source_files:
288
+ source_path = Path(source)
289
+ if not source_path.exists() or not source_path.is_file():
290
+ logger.warning(f"Missing file: {source}")
291
+ continue
292
+
293
+ current_hash = self._get_file_hash(source_path)
294
+ if current_hash in ingested_hashes.values() and not allow_duplicates:
295
+ logger.info(f"Skipped (content already indexed): {source_path.name}")
296
+ continue
297
+
298
+ resolved_source = source_path.resolve()
299
+
300
+ # Files already saved under raw/ (e.g. by the upload route, possibly
301
+ # inside a folder) are indexed in place — never flattened to the
302
+ # basename — so the uploaded folder structure is preserved verbatim.
303
+ if resolved_source.is_relative_to(self.raw_dir.resolve()):
304
+ files_to_process.append(source_path)
305
+ continue
306
+
307
+ if resolved_source_root and resolved_source.is_relative_to(resolved_source_root):
308
+ dest_path = self.raw_dir / resolved_source.relative_to(resolved_source_root)
309
+ else:
310
+ dest_path = self.raw_dir / source_path.name
311
+
312
+ staged_path, copied = stage_source(source_path, dest_path, source_hash=current_hash)
313
+ if copied:
314
+ logger.info(f"Staged to raw: {staged_path.relative_to(self.raw_dir).as_posix()}")
315
+ else:
316
+ logger.info(f"Recovering staged file: {source_path.name}")
317
+ files_to_process.append(staged_path)
318
+
319
+ return files_to_process
320
+
321
+ async def process_new_documents(self, new_files: List[Path]) -> DocumentIndexResult:
322
+ """Index staged files via the KB's bound provider."""
323
+ if not new_files:
324
+ return DocumentIndexResult(processed_files=[], failures=[])
325
+
326
+ if self.rag_provider == LIGHTRAG_PROVIDER:
327
+ return await self._process_lightrag_batch(new_files)
328
+
329
+ rag_service = RAGService(kb_base_dir=str(self.base_dir), provider=self.rag_provider)
330
+ processed_files: list[Path] = []
331
+ failures: list[DocumentIndexFailure] = []
332
+ total_files = len(new_files)
333
+
334
+ for idx, doc_file in enumerate(new_files, 1):
335
+ try:
336
+ if self.progress_tracker is not None:
337
+ from deeptutor.knowledge.progress_tracker import ProgressStage
338
+
339
+ self.progress_tracker.update(
340
+ ProgressStage.PROCESSING_FILE,
341
+ message_key="Indexing {{name}}",
342
+ message_params={"name": doc_file.name},
343
+ current=len(processed_files),
344
+ total=total_files,
345
+ )
346
+
347
+ success = await rag_service.add_documents(self.kb_name, [str(doc_file)])
348
+ if success:
349
+ processed_files.append(doc_file)
350
+ # Re-hashes the whole file, so it stays off the event loop:
351
+ # this runs inside a FastAPI BackgroundTasks entry, which is
352
+ # awaited on the loop and would otherwise stall unrelated
353
+ # requests once per indexed file (#777).
354
+ try:
355
+ await asyncio.to_thread(self._record_successful_hash, doc_file)
356
+ except Exception:
357
+ if self.rag_provider != LIGHTRAG_PROVIDER:
358
+ raise
359
+ logger.warning(
360
+ "LightRAG published '%s' before its duplicate-detection hash "
361
+ "was recorded",
362
+ doc_file.name,
363
+ exc_info=True,
364
+ )
365
+ logger.info(f"Processed: {doc_file.name}")
366
+ if self.progress_tracker is not None:
367
+ self.progress_tracker.update(
368
+ ProgressStage.PROCESSING_FILE,
369
+ message_key="Indexed {{name}}",
370
+ message_params={"name": doc_file.name},
371
+ current=len(processed_files),
372
+ total=total_files,
373
+ )
374
+ else:
375
+ error = "Provider returned failure without details."
376
+ failures.append(DocumentIndexFailure(doc_file, error))
377
+ logger.error(f"Failed to index: {doc_file.name}")
378
+ except PermissionError as e:
379
+ logger.exception("Permission denied while indexing %s: %s", doc_file.name, e)
380
+ raise DataVolumePermissionError(
381
+ format_data_volume_permission_error(self.kb_dir, cause=e)
382
+ ) from e
383
+ except Exception as e:
384
+ logger.exception(f"Failed {doc_file.name}: {e}")
385
+ failures.append(DocumentIndexFailure(doc_file, str(e)))
386
+
387
+ return DocumentIndexResult(processed_files=processed_files, failures=failures)
388
+
389
+ async def _process_lightrag_batch(self, files: List[Path]) -> DocumentIndexResult:
390
+ """Hold native write ownership across the entire accepted batch."""
391
+ from deeptutor.services.rag.pipelines.lightrag.pipeline import LightRagBatchError
392
+
393
+ def prepare_publication(root: Path) -> None:
394
+ from deeptutor.knowledge.progress_tracker import ProgressStage, ProgressTracker
395
+
396
+ tracker = self.progress_tracker or ProgressTracker(self.kb_name, self.base_dir)
397
+ tracker.update(
398
+ ProgressStage.COMPLETED,
399
+ message="Document indexing complete",
400
+ current=len(files),
401
+ total=len(files),
402
+ indexed_count=len(files),
403
+ index_changed=True,
404
+ index_action="upload",
405
+ publication_version=root.name,
406
+ )
407
+ tracker.verify_terminal(
408
+ current=len(files),
409
+ total=len(files),
410
+ indexed_count=len(files),
411
+ index_action="upload",
412
+ publication_version=root.name,
413
+ )
414
+
415
+ service = RAGService(kb_base_dir=str(self.base_dir), provider=LIGHTRAG_PROVIDER)
416
+ try:
417
+ success = await service.add_documents(
418
+ self.kb_name,
419
+ [str(path) for path in files],
420
+ accepted_indexing_snapshot=self.accepted_indexing_snapshot,
421
+ before_publish=prepare_publication,
422
+ )
423
+ if not success:
424
+ raise RuntimeError("LightRAG returned failure without details.")
425
+ processed = files
426
+ failures = []
427
+ except LightRagBatchError as exc:
428
+ processed = [path for path in files if path.name in exc.outcome.processed]
429
+ failures = [
430
+ DocumentIndexFailure(path, str(exc)) for path in files if path not in processed
431
+ ]
432
+ except Exception as exc:
433
+ return DocumentIndexResult([], [DocumentIndexFailure(path, str(exc)) for path in files])
434
+ for path in processed:
435
+ try:
436
+ await asyncio.to_thread(self._record_successful_hash, path)
437
+ except Exception:
438
+ logger.warning(
439
+ "Indexed document hash could not be recorded: %s", path.name, exc_info=True
440
+ )
441
+ return DocumentIndexResult(processed, failures)
442
+
443
+ def _record_successful_hash(self, file_path: Path) -> None:
444
+ file_hash = self._get_file_hash(file_path)
445
+ metadata = _read_metadata(self.metadata_file)
446
+ hash_key = _raw_hash_key(file_path, self.raw_dir)
447
+ metadata.setdefault("file_hashes", {})[hash_key] = file_hash
448
+ _write_metadata(self.metadata_file, metadata)
449
+
450
+ def update_metadata(self, added_count: int) -> None:
451
+ """Update metadata after incremental add."""
452
+ metadata: dict = {}
453
+ if self.metadata_file.exists():
454
+ try:
455
+ with open(self.metadata_file, "r", encoding="utf-8") as f:
456
+ metadata = json.load(f)
457
+ except Exception:
458
+ metadata = {}
459
+
460
+ metadata["rag_provider"] = self.rag_provider
461
+ metadata["needs_reindex"] = False
462
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
463
+ metadata["last_updated"] = timestamp
464
+ if added_count > 0:
465
+ metadata["last_indexed_at"] = timestamp
466
+ metadata["last_indexed_count"] = added_count
467
+ metadata["last_indexed_action"] = "upload"
468
+
469
+ history = metadata.get("update_history", [])
470
+ history.append(
471
+ {
472
+ "timestamp": metadata["last_updated"],
473
+ "action": "incremental_add",
474
+ "count": added_count,
475
+ "provider": self.rag_provider,
476
+ }
477
+ )
478
+ metadata["update_history"] = history
479
+
480
+ _write_metadata(self.metadata_file, metadata)
481
+
482
+
483
+ async def _bootstrap_index_from_files(
484
+ kb_name: str,
485
+ source_files: list[str],
486
+ base_dir: str,
487
+ manager: "KnowledgeBaseManager",
488
+ ) -> int:
489
+ """Create a fresh index for an empty KB from the given source files.
490
+
491
+ Called when :class:`DocumentAdder` rejects an add because the KB has no
492
+ existing index (it was created empty, e.g. via the no-files fast path or
493
+ a web/GitHub source sync before any documents were indexed). Uses
494
+ :meth:`RAGService.initialize` to build the index in one batch, then
495
+ records file hashes so subsequent incremental adds can detect duplicates.
496
+ """
497
+ rag_service = RAGService(kb_base_dir=base_dir)
498
+ kb_dir = Path(base_dir) / kb_name
499
+ raw_dir = kb_dir / "raw"
500
+ metadata_file = kb_dir / "metadata.json"
501
+
502
+ requested = {str(Path(path).resolve()): str(Path(path)) for path in source_files}
503
+ confirmed: set[str] = set()
504
+
505
+ def on_indexed_file(paths: list[str]) -> None:
506
+ for path in paths:
507
+ canonical = str(Path(path).resolve())
508
+ if canonical in requested:
509
+ confirmed.add(canonical)
510
+
511
+ success = await rag_service.initialize(
512
+ kb_name=kb_name,
513
+ file_paths=source_files,
514
+ indexed_file_callback=on_indexed_file,
515
+ )
516
+ if not success:
517
+ raise RuntimeError(
518
+ f"Failed to initialize index for KB '{kb_name}' from {len(source_files)} file(s)"
519
+ )
520
+
521
+ indexed_files = [requested[path] for path in sorted(confirmed)]
522
+ indexed = len(indexed_files)
523
+ provider = rag_service._resolve_provider(kb_name)
524
+ try:
525
+ # Record hashes so future syncs detect unchanged files.
526
+ metadata = _read_metadata(metadata_file)
527
+ metadata.setdefault("file_hashes", {}).update(
528
+ hashes_for_indexed_files(indexed_files, raw_dir)
529
+ )
530
+ metadata["rag_provider"] = provider
531
+ metadata["needs_reindex"] = False
532
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
533
+ metadata["last_updated"] = ts
534
+ metadata["last_indexed_at"] = ts
535
+ metadata["last_indexed_count"] = indexed
536
+ metadata["last_indexed_action"] = "create"
537
+ _write_metadata(metadata_file, metadata)
538
+
539
+ manager.update_kb_status(
540
+ name=kb_name,
541
+ status="ready",
542
+ progress={
543
+ "stage": "completed",
544
+ "message": f"Initialized index with {indexed} file(s).",
545
+ "percent": 100,
546
+ "current": indexed,
547
+ "total": max(indexed, 1),
548
+ "file_name": "",
549
+ "error": None,
550
+ "timestamp": datetime.now().isoformat(),
551
+ "indexed_count": indexed,
552
+ "index_changed": True,
553
+ "index_action": "create",
554
+ },
555
+ )
556
+ except Exception:
557
+ if provider != LIGHTRAG_PROVIDER:
558
+ raise
559
+ logger.warning(
560
+ "LightRAG bootstrap for '%s' published before bookkeeping failed",
561
+ kb_name,
562
+ exc_info=True,
563
+ )
564
+ logger.info("Bootstrapped index for empty KB '%s' with %d file(s)", kb_name, indexed)
565
+ return indexed
566
+
567
+
568
+ async def add_documents(
569
+ kb_name: str,
570
+ source_files: list[str],
571
+ base_dir: str = DEFAULT_BASE_DIR,
572
+ api_key: Optional[str] = None,
573
+ base_url: Optional[str] = None,
574
+ allow_duplicates: bool = False,
575
+ accepted_indexing_snapshot=None,
576
+ ) -> int:
577
+ """Convenience function used by CLI wrappers."""
578
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
579
+
580
+ manager = KnowledgeBaseManager(base_dir=base_dir)
581
+ published_count = 0
582
+ try:
583
+ manager.update_kb_status(
584
+ name=kb_name,
585
+ status="processing",
586
+ progress={
587
+ "stage": "processing_documents",
588
+ "message": "Processing uploaded documents...",
589
+ "percent": 0,
590
+ "current": 0,
591
+ "total": max(len(source_files), 1),
592
+ "file_name": "",
593
+ "error": None,
594
+ "timestamp": datetime.now().isoformat(),
595
+ },
596
+ )
597
+
598
+ try:
599
+ adder = DocumentAdder(
600
+ kb_name=kb_name,
601
+ base_dir=base_dir,
602
+ api_key=api_key,
603
+ base_url=base_url,
604
+ accepted_indexing_snapshot=accepted_indexing_snapshot,
605
+ )
606
+ except ValueError as exc:
607
+ if "not initialized" not in str(exc).lower() or not source_files:
608
+ raise
609
+ # Empty KB (created without initial documents): bootstrap the
610
+ # index from these files instead of erroring.
611
+ return await _bootstrap_index_from_files(
612
+ kb_name=kb_name,
613
+ source_files=source_files,
614
+ base_dir=base_dir,
615
+ manager=manager,
616
+ )
617
+
618
+ new_files = adder.add_documents(source_files, allow_duplicates=allow_duplicates)
619
+ if not new_files:
620
+ manager.update_kb_status(
621
+ name=kb_name,
622
+ status="ready",
623
+ progress={
624
+ "stage": "completed",
625
+ "message": "No new unique documents to process.",
626
+ "percent": 100,
627
+ "current": 1,
628
+ "total": 1,
629
+ "file_name": "",
630
+ "error": None,
631
+ "timestamp": datetime.now().isoformat(),
632
+ },
633
+ )
634
+ return 0
635
+ result = await adder.process_new_documents(new_files)
636
+ if result.has_failures:
637
+ raise RuntimeError(
638
+ f"Failed to index {result.failed_count}/{len(new_files)} file(s): "
639
+ f"{result.failure_summary()}"
640
+ )
641
+ if adder.rag_provider == LIGHTRAG_PROVIDER:
642
+ published_count = result.processed_count
643
+ adder.update_metadata(result.processed_count)
644
+
645
+ manager.update_kb_status(
646
+ name=kb_name,
647
+ status="ready",
648
+ progress={
649
+ "stage": "completed",
650
+ "message": f"Successfully processed {result.processed_count} files!",
651
+ "percent": 100,
652
+ "current": result.processed_count,
653
+ "total": max(len(new_files), 1),
654
+ "file_name": "",
655
+ "error": None,
656
+ "timestamp": datetime.now().isoformat(),
657
+ "indexed_count": result.processed_count,
658
+ "index_changed": result.processed_count > 0,
659
+ "index_action": "upload",
660
+ },
661
+ )
662
+ return result.processed_count
663
+ except Exception as exc:
664
+ if published_count:
665
+ logger.warning(
666
+ "LightRAG append for '%s' published before bookkeeping failed",
667
+ kb_name,
668
+ exc_info=True,
669
+ )
670
+ return published_count
671
+ manager.update_kb_status(
672
+ name=kb_name,
673
+ status="error",
674
+ progress={
675
+ "stage": "error",
676
+ "message": "Document upload failed",
677
+ "percent": 0,
678
+ "current": 0,
679
+ "total": max(len(source_files), 1),
680
+ "file_name": "",
681
+ "error": str(exc),
682
+ "timestamp": datetime.now().isoformat(),
683
+ },
684
+ )
685
+ raise
686
+
687
+
688
+ async def main() -> None:
689
+ try:
690
+ llm_config = resolve_llm_runtime_config()
691
+ default_api_key = llm_config.api_key
692
+ default_base_url = llm_config.effective_url
693
+ except Exception:
694
+ default_api_key = ""
695
+ default_base_url = ""
696
+
697
+ parser = argparse.ArgumentParser(description="Incrementally add documents to a KB")
698
+ parser.add_argument("kb_name", help="KB Name")
699
+ parser.add_argument("--docs", nargs="+", help="Files")
700
+ parser.add_argument("--docs-dir", help="Directory")
701
+ parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR)
702
+ parser.add_argument("--api-key", default=default_api_key)
703
+ parser.add_argument("--base-url", default=default_base_url)
704
+ parser.add_argument("--allow-duplicates", action="store_true")
705
+
706
+ args = parser.parse_args()
707
+ doc_files: list[str] = []
708
+ if args.docs:
709
+ doc_files.extend(args.docs)
710
+ if args.docs_dir:
711
+ p = Path(args.docs_dir)
712
+ doc_files.extend(str(f) for f in FileTypeRouter.collect_supported_files(p))
713
+
714
+ if not doc_files:
715
+ logger.error("No documents provided.")
716
+ return
717
+
718
+ processed_count = await add_documents(
719
+ kb_name=args.kb_name,
720
+ source_files=doc_files,
721
+ base_dir=args.base_dir,
722
+ api_key=args.api_key,
723
+ base_url=args.base_url,
724
+ allow_duplicates=args.allow_duplicates,
725
+ )
726
+
727
+ if processed_count:
728
+ logger.info(f"Done! Successfully added {processed_count} documents.")
729
+ else:
730
+ logger.info("No new unique documents to add.")
731
+
732
+
733
+ if __name__ == "__main__":
734
+ asyncio.run(main())