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,692 @@
1
+ """DeepTutor orchestration for the LightRAG 1.5 native document pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass, field, replace
8
+ import logging
9
+ from pathlib import Path
10
+ import shutil
11
+ import traceback
12
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
13
+
14
+ if TYPE_CHECKING:
15
+ from deeptutor.services.embedding.config import EmbeddingConfig
16
+
17
+ from deeptutor.runtime.home import get_runtime_data_root
18
+ from deeptutor.services.rag.index_versioning import (
19
+ list_kb_versions,
20
+ resolve_storage_dir_for_rebuild,
21
+ )
22
+ from deeptutor.services.rag.kb_paths import resolve_kb_dir
23
+
24
+ from . import block_policy, cache_reuse, engine, indexing_policy, ingress, storage
25
+ from . import config as lr_config
26
+ from .indexing_policy import (
27
+ IndexingPolicyError,
28
+ IndexingPolicySnapshot,
29
+ freeze_default_snapshot,
30
+ resolve_write_snapshot,
31
+ )
32
+ from .worker import OwnerLoopBridge, run_in_worker_loop
33
+
34
+ logger = logging.getLogger(__name__)
35
+ DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BatchOutcome:
40
+ requested: int
41
+ preflight_failed: dict[str, str] = field(default_factory=dict)
42
+ accepted: int = 0
43
+ processed: tuple[str, ...] = ()
44
+ failed: dict[str, str] = field(default_factory=dict)
45
+ nonterminal: dict[str, str] = field(default_factory=dict)
46
+ missing: tuple[str, ...] = ()
47
+ track_id: str = ""
48
+ vlm_used: bool = False
49
+ indexing_policy: dict[str, Any] = field(default_factory=dict)
50
+
51
+ @property
52
+ def complete(self) -> bool:
53
+ return (
54
+ self.requested > 0
55
+ and len(self.processed) == self.requested
56
+ and not self.preflight_failed
57
+ and not self.failed
58
+ and not self.nonterminal
59
+ and not self.missing
60
+ )
61
+
62
+
63
+ class LightRagBatchError(RuntimeError):
64
+ def __init__(self, outcome: BatchOutcome) -> None:
65
+ self.outcome = outcome
66
+ failures = {**outcome.preflight_failed, **outcome.failed}
67
+ detail = "; ".join(f"{name}: {error}" for name, error in sorted(failures.items()))
68
+ message = (
69
+ f"LightRAG batch incomplete: added {len(outcome.processed)}, "
70
+ f"failed {len(failures)}, missing {len(outcome.missing)}, "
71
+ f"nonterminal {len(outcome.nonterminal)}"
72
+ )
73
+ super().__init__(f"{message}: {detail}" if detail else message)
74
+
75
+
76
+ class LightRagNeedsReindexError(RuntimeError):
77
+ """Raised when an append targets a pre-native LightRAG index."""
78
+
79
+
80
+ def _status_text(row: Any) -> str:
81
+ status = getattr(row, "status", None)
82
+ value = getattr(status, "value", status)
83
+ return str(value or "").strip().lower()
84
+
85
+
86
+ def _row_name(row: Any) -> str:
87
+ return Path(str(getattr(row, "file_path", "") or "")).name
88
+
89
+
90
+ def _row_error(row: Any) -> str:
91
+ for key in ("error_msg", "error"):
92
+ value = getattr(row, key, None)
93
+ if isinstance(value, str) and value.strip():
94
+ return value.strip()
95
+ return _status_text(row) or "unknown LightRAG failure"
96
+
97
+
98
+ class LightRagPipeline:
99
+ def __init__(self, kb_base_dir: Optional[str] = None, **_: Any) -> None:
100
+ self.logger = logging.getLogger(__name__)
101
+ self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
102
+ self._status_poll_seconds = 0.2
103
+ self._status_no_progress_seconds = 600.0
104
+
105
+ def _ensure_available(self) -> None:
106
+ if not lr_config.is_lightrag_available():
107
+ raise lr_config.LightRagNotAvailableError(
108
+ "LightRAG is not installed. Install it with "
109
+ "`pip install 'deeptutor[rag-lightrag]'` to use LightRAG knowledge bases."
110
+ )
111
+
112
+ def _resolve_mode(self, kb_name: str, kwargs: dict[str, Any]) -> str:
113
+ from ..modes import resolve_kb_mode
114
+
115
+ return resolve_kb_mode(
116
+ self.kb_base_dir,
117
+ kb_name,
118
+ storage.PROVIDER,
119
+ explicit=kwargs.get("mode"),
120
+ supported=lr_config.SUPPORTED_MODES,
121
+ default=lr_config.DEFAULT_MODE,
122
+ )
123
+
124
+ def _stage_documents(
125
+ self,
126
+ working_dir: Path,
127
+ file_paths: List[str],
128
+ *,
129
+ vision_available: bool,
130
+ ) -> tuple[list[ingress.StagedDocument], dict[str, str]]:
131
+ from deeptutor.services.parsing import get_parse_service
132
+
133
+ parse_service = get_parse_service()
134
+ staged: list[ingress.StagedDocument] = []
135
+ failures: dict[str, str] = {}
136
+ seen: set[str] = set()
137
+ for raw_path in file_paths:
138
+ path = Path(raw_path)
139
+ name = path.name
140
+ if name in seen:
141
+ failures[name] = "duplicate canonical basename in request"
142
+ continue
143
+ seen.add(name)
144
+ try:
145
+ parsed = parse_service.parse(path)
146
+ item = ingress.freeze_document(working_dir, path, parsed)
147
+ if "i" in item.process_options and not vision_available:
148
+ item = replace(item, process_options=item.process_options.replace("i", ""))
149
+ staged.append(item)
150
+ except Exception as exc:
151
+ failures[name or str(path)] = str(exc)
152
+ return staged, failures
153
+
154
+ async def _reconcile(
155
+ self,
156
+ rag: Any,
157
+ staged: list[ingress.StagedDocument],
158
+ preflight_failed: dict[str, str],
159
+ track_id: str,
160
+ io_bridge: OwnerLoopBridge,
161
+ progress_callback: Callable[[int, int], Any] | None,
162
+ ) -> BatchOutcome:
163
+ expected = {item.canonical_name: item for item in staged}
164
+ last_snapshot: tuple[tuple[str, str], ...] | None = None
165
+ last_progress = asyncio.get_running_loop().time()
166
+ terminal_count = 0
167
+ while True:
168
+ io_bridge.raise_if_cancelled()
169
+ rows = await rag.aget_docs_by_track_id(track_id)
170
+ if not isinstance(rows, dict):
171
+ raise engine.LightRagContractError("aget_docs_by_track_id must return an object")
172
+ by_name: dict[str, tuple[str, Any]] = {}
173
+ duplicates: set[str] = set()
174
+ for doc_id, row in rows.items():
175
+ name = _row_name(row)
176
+ if name in by_name:
177
+ duplicates.add(name)
178
+ by_name[name] = (str(doc_id), row)
179
+ if duplicates:
180
+ raise engine.LightRagContractError(
181
+ f"track_id returned duplicate canonical basenames: {sorted(duplicates)}"
182
+ )
183
+
184
+ snapshot = tuple(
185
+ sorted((name, _status_text(row)) for name, (_, row) in by_name.items())
186
+ )
187
+ if snapshot != last_snapshot:
188
+ last_snapshot = snapshot
189
+ last_progress = asyncio.get_running_loop().time()
190
+ processed = tuple(
191
+ sorted(
192
+ name
193
+ for name, (_, row) in by_name.items()
194
+ if name in expected and _status_text(row) == "processed"
195
+ )
196
+ )
197
+ failed = {
198
+ name: _row_error(row)
199
+ for name, (_, row) in by_name.items()
200
+ if name in expected and _status_text(row) == "failed"
201
+ }
202
+ unknown = {
203
+ name: _status_text(row)
204
+ for name, (_, row) in by_name.items()
205
+ if name in expected
206
+ and _status_text(row)
207
+ not in {
208
+ "pending",
209
+ "parsing",
210
+ "analyzing",
211
+ "processing",
212
+ "preprocessed",
213
+ "processed",
214
+ "failed",
215
+ }
216
+ }
217
+ active = {
218
+ name: _status_text(row)
219
+ for name, (_, row) in by_name.items()
220
+ if name in expected
221
+ and _status_text(row)
222
+ in {"pending", "parsing", "analyzing", "processing", "preprocessed"}
223
+ }
224
+ missing = tuple(sorted(set(expected) - set(by_name)))
225
+ current_terminal = len(processed) + len(failed)
226
+ if progress_callback is not None and current_terminal != terminal_count:
227
+ terminal_count = current_terminal
228
+ await io_bridge.call(
229
+ progress_callback, terminal_count, len(expected) + len(preflight_failed)
230
+ )
231
+ if not active and not missing:
232
+ outcome = BatchOutcome(
233
+ requested=len(expected) + len(preflight_failed),
234
+ preflight_failed=preflight_failed,
235
+ accepted=len(expected),
236
+ processed=processed,
237
+ failed=failed,
238
+ nonterminal=unknown,
239
+ missing=(),
240
+ track_id=track_id,
241
+ )
242
+ for name in processed:
243
+ item = expected[name]
244
+ if item.audit_ledger is not None:
245
+ doc_id = by_name[name][0]
246
+ block_policy.write_decision_ledger(
247
+ Path(rag.working_dir), doc_id, item.audit_ledger
248
+ )
249
+ return replace(
250
+ outcome,
251
+ vlm_used=any("i" in getattr(item, "process_options", "") for item in staged),
252
+ )
253
+ if unknown:
254
+ return BatchOutcome(
255
+ requested=len(expected) + len(preflight_failed),
256
+ preflight_failed=preflight_failed,
257
+ accepted=len(expected),
258
+ processed=processed,
259
+ failed=failed,
260
+ nonterminal=unknown,
261
+ missing=missing,
262
+ track_id=track_id,
263
+ )
264
+ if (
265
+ asyncio.get_running_loop().time() - last_progress
266
+ >= self._status_no_progress_seconds
267
+ ):
268
+ return BatchOutcome(
269
+ requested=len(expected) + len(preflight_failed),
270
+ preflight_failed=preflight_failed,
271
+ accepted=len(expected),
272
+ processed=processed,
273
+ failed=failed,
274
+ nonterminal=active,
275
+ missing=missing,
276
+ track_id=track_id,
277
+ )
278
+ await asyncio.sleep(self._status_poll_seconds)
279
+
280
+ async def _run_indexing(
281
+ self,
282
+ working_dir: Path,
283
+ file_paths: List[str],
284
+ progress_callback: Callable[[int, int], Any] | None,
285
+ snapshot: IndexingPolicySnapshot | None = None,
286
+ *,
287
+ embedding_config: EmbeddingConfig | None = None,
288
+ ) -> BatchOutcome:
289
+ if snapshot is None:
290
+ snapshot = freeze_default_snapshot()
291
+ if embedding_config is None:
292
+ from deeptutor.services.embedding import get_embedding_config
293
+
294
+ embedding_config = deepcopy(get_embedding_config())
295
+
296
+ async def job(io_bridge: OwnerLoopBridge) -> BatchOutcome:
297
+ io_bridge.raise_if_cancelled()
298
+ staged, preflight_failed = self._stage_documents(
299
+ working_dir,
300
+ file_paths,
301
+ vision_available=snapshot.vision_available and snapshot.image_analysis is not False,
302
+ )
303
+ if not staged:
304
+ raise LightRagBatchError(
305
+ BatchOutcome(requested=len(file_paths), preflight_failed=preflight_failed)
306
+ )
307
+ try:
308
+ rag = engine.build_rag(
309
+ working_dir,
310
+ io_bridge=io_bridge,
311
+ enable_vlm=any("i" in item.process_options for item in staged),
312
+ indexing_snapshot=snapshot,
313
+ embedding_config=embedding_config,
314
+ )
315
+ except BaseException:
316
+ for item in staged:
317
+ ingress.remove_unaccepted(item)
318
+ raise
319
+ failed = True
320
+ accepted = False
321
+ enqueue_started = False
322
+ cleanup: list[ingress.StagedDocument] = []
323
+ try:
324
+ await engine.initialize(rag)
325
+ enqueue_started = True
326
+ track_id = await engine.enqueue(rag, staged)
327
+ if not isinstance(track_id, str) or not track_id.strip():
328
+ raise engine.LightRagContractError(
329
+ "LightRAG enqueue did not return a valid track_id"
330
+ )
331
+ accepted = True
332
+ await rag.apipeline_process_enqueue_documents()
333
+ outcome = await self._reconcile(
334
+ rag,
335
+ staged,
336
+ preflight_failed,
337
+ track_id,
338
+ io_bridge,
339
+ progress_callback,
340
+ )
341
+ if not outcome.complete:
342
+ raise LightRagBatchError(outcome)
343
+ failed = False
344
+ return outcome
345
+ except BaseException:
346
+ if not enqueue_started:
347
+ cleanup = staged
348
+ elif not accepted:
349
+ try:
350
+ cleanup = await engine.confirmed_unaccepted(rag, staged)
351
+ except BaseException:
352
+ self.logger.exception(
353
+ "Could not confirm which LightRAG documents were rejected; "
354
+ "retaining all staged ingress"
355
+ )
356
+ raise
357
+ finally:
358
+ try:
359
+ await engine.finalize(rag, cancel_pending=failed)
360
+ except BaseException:
361
+ if not failed:
362
+ raise
363
+ self.logger.exception("LightRAG cleanup failed while indexing was aborting")
364
+ finally:
365
+ for item in cleanup:
366
+ ingress.remove_unaccepted(item)
367
+
368
+ outcome = await run_in_worker_loop(job)
369
+ published_policy = snapshot.persisted_policy()
370
+ if published_policy.get("policy") == "pending_pinned":
371
+ published_policy["policy"] = "pinned"
372
+ return replace(outcome, indexing_policy=published_policy)
373
+
374
+ def _remove_zero_accepted_candidate(self, root_dir: Path) -> None:
375
+ if not root_dir.is_dir() or storage.has_any_doc_status(root_dir):
376
+ return
377
+ for ingress_dir in (ingress.pending_root(root_dir), ingress.bundles_root(root_dir)):
378
+ if ingress_dir.is_dir() and any(path.is_file() for path in ingress_dir.rglob("*")):
379
+ return
380
+ shutil.rmtree(root_dir)
381
+
382
+ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
383
+ from .write_lock import write_ownership
384
+
385
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
386
+ with write_ownership(kb_dir):
387
+ if validate_binding := kwargs.pop("validate_embedding_binding", None):
388
+ validate_binding()
389
+ snapshot = kwargs.get("indexing_snapshot") or kwargs.get("accepted_indexing_snapshot")
390
+ if snapshot is not None:
391
+ indexing_policy.validate_target(snapshot, kb_dir)
392
+ fresh = indexing_policy.revalidate_snapshot(snapshot)
393
+ key = (
394
+ "indexing_snapshot"
395
+ if kwargs.get("indexing_snapshot") is not None
396
+ else "accepted_indexing_snapshot"
397
+ )
398
+ kwargs[key] = fresh
399
+ return await self._initialize_owned(kb_name, file_paths, **kwargs)
400
+
401
+ def _publish_new_version(
402
+ self,
403
+ root_dir: Path,
404
+ policy: dict[str, Any],
405
+ embedding_config: Any,
406
+ publish_binding: Callable[[], None] | None,
407
+ ) -> None:
408
+ storage.write_meta(root_dir, indexing_policy=policy, embedding_config=embedding_config)
409
+ try:
410
+ if publish_binding is not None:
411
+ publish_binding()
412
+ except BaseException:
413
+ # A same-embedding rebuild would otherwise become the newest
414
+ # compatible version even though its binding publication failed.
415
+ # Withdraw only this new candidate's publication marker; retain
416
+ # its data and the previous version for recovery.
417
+ (root_dir / "meta.json").unlink()
418
+ raise
419
+
420
+ async def _initialize_owned(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
421
+ self._ensure_available()
422
+ from deeptutor.services.embedding import get_embedding_config
423
+
424
+ embedding_config = deepcopy(get_embedding_config())
425
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
426
+ snapshot = kwargs.get("indexing_snapshot") or kwargs.get("accepted_indexing_snapshot")
427
+ if snapshot is None:
428
+ snapshot = freeze_default_snapshot()
429
+ snapshot = indexing_policy.with_embedding(snapshot)
430
+ embedding_config = snapshot.embedding_config
431
+ if "image_analysis" in kwargs:
432
+ snapshot = indexing_policy.with_image_analysis(snapshot, kwargs["image_analysis"])
433
+ root_dir = resolve_storage_dir_for_rebuild(kb_dir, None)
434
+ try:
435
+ persisted_policy = getattr(snapshot, "persisted_policy", None)
436
+ if callable(persisted_policy):
437
+ await asyncio.to_thread(
438
+ cache_reuse.inherit_index_cache,
439
+ kb_dir,
440
+ root_dir,
441
+ persisted_policy(),
442
+ )
443
+ outcome = await self._run_indexing(
444
+ root_dir,
445
+ file_paths,
446
+ kwargs.get("progress_callback"),
447
+ snapshot,
448
+ embedding_config=embedding_config,
449
+ )
450
+ if not storage.has_output(root_dir):
451
+ raise RuntimeError(f"LightRAG did not produce a ready index for {kb_name!r}")
452
+ policy = dict(outcome.indexing_policy)
453
+ policy["vlm_used"] = outcome.vlm_used
454
+ before_publish = kwargs.get("before_publish")
455
+ if before_publish is not None:
456
+ before_publish(root_dir)
457
+ self._publish_new_version(
458
+ root_dir,
459
+ policy,
460
+ snapshot.embedding_config,
461
+ kwargs.get("publish_embedding_binding") if outcome.complete else None,
462
+ )
463
+ self._clear_pending_policy(kb_name)
464
+ if outcome.complete and (indexed_file_callback := kwargs.get("indexed_file_callback")):
465
+ # Reconciliation confirmed every requested file as processed
466
+ # before publication, so these paths can be hashed (#1481).
467
+ indexed_file_callback(file_paths)
468
+ return outcome.complete
469
+ except asyncio.CancelledError:
470
+ raise
471
+ except LightRagBatchError as exc:
472
+ if exc.outcome.accepted == 0:
473
+ self._remove_zero_accepted_candidate(root_dir)
474
+ raise
475
+ except Exception:
476
+ self._remove_zero_accepted_candidate(root_dir)
477
+ raise
478
+
479
+ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
480
+ from .write_lock import write_ownership
481
+
482
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
483
+ with write_ownership(kb_dir):
484
+ if validate_binding := kwargs.pop("validate_embedding_binding", None):
485
+ validate_binding()
486
+ if kwargs.get("indexing_snapshot") is not None and (
487
+ storage.latest_published_root(kb_dir) is not None or list_kb_versions(kb_dir)
488
+ ):
489
+ raise IndexingPolicyError(
490
+ "An explicit LightRAG indexing model cannot override an existing index; "
491
+ "run a full re-index."
492
+ )
493
+ snapshot = kwargs.get("indexing_snapshot") or kwargs.get("accepted_indexing_snapshot")
494
+ if snapshot is not None:
495
+ indexing_policy.validate_target(snapshot, kb_dir)
496
+ fresh = indexing_policy.revalidate_snapshot(snapshot)
497
+ key = (
498
+ "indexing_snapshot"
499
+ if kwargs.get("indexing_snapshot") is not None
500
+ else "accepted_indexing_snapshot"
501
+ )
502
+ kwargs[key] = fresh
503
+ return await self._add_documents_owned(kb_name, file_paths, **kwargs)
504
+
505
+ async def _add_documents_owned(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
506
+ self._ensure_available()
507
+ from deeptutor.services.embedding import get_embedding_config
508
+
509
+ embedding_config = deepcopy(get_embedding_config())
510
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
511
+ existing = storage.latest_published_root(kb_dir)
512
+ from deeptutor.services.rag.embedding_binding import bound_graph_storage_root
513
+
514
+ existing = bound_graph_storage_root(kb_dir, storage.PROVIDER, existing)
515
+ versions = list_kb_versions(kb_dir)
516
+ explicit = kwargs.get("indexing_snapshot")
517
+ if existing is None and versions:
518
+ raise LightRagNeedsReindexError(
519
+ "This LightRAG index is legacy, unpublished, or corrupt and must be rebuilt "
520
+ "before appending."
521
+ )
522
+ accepted_snapshot = kwargs.get("accepted_indexing_snapshot")
523
+ compatibility_config = getattr(accepted_snapshot, "embedding_config", None)
524
+ if existing is not None:
525
+ storage.require_compatible_embedding(existing, compatibility_config or embedding_config)
526
+ snapshot = accepted_snapshot or resolve_write_snapshot(
527
+ kb_dir,
528
+ base_dir=self.kb_base_dir,
529
+ kb_name=kb_name,
530
+ explicit=explicit,
531
+ )
532
+ snapshot = indexing_policy.with_embedding(snapshot)
533
+ embedding_config = snapshot.embedding_config
534
+ if "image_analysis" in kwargs:
535
+ snapshot = indexing_policy.with_image_analysis(snapshot, kwargs["image_analysis"])
536
+ if existing is not None:
537
+ root_dir = existing
538
+ is_update = True
539
+ else:
540
+ root_dir = resolve_storage_dir_for_rebuild(kb_dir, None)
541
+ is_update = False
542
+ try:
543
+ outcome = await self._run_indexing(
544
+ root_dir,
545
+ file_paths,
546
+ kwargs.get("progress_callback"),
547
+ snapshot,
548
+ embedding_config=embedding_config,
549
+ )
550
+ if not storage.has_output(root_dir):
551
+ raise RuntimeError(f"LightRAG did not produce a ready index for {kb_name!r}")
552
+ policy = dict(outcome.indexing_policy)
553
+ previous_policy = storage.read_published_policy(existing) or {}
554
+ policy["vlm_used"] = outcome.vlm_used or previous_policy.get("vlm_used") is True
555
+ if not is_update:
556
+ before_publish = kwargs.get("before_publish")
557
+ if before_publish is not None:
558
+ before_publish(root_dir)
559
+ self._publish_new_version(
560
+ root_dir,
561
+ policy,
562
+ snapshot.embedding_config,
563
+ kwargs.get("publish_embedding_binding") if outcome.complete else None,
564
+ )
565
+ self._clear_pending_policy(kb_name)
566
+ else:
567
+ try:
568
+ storage.write_meta(
569
+ root_dir, indexing_policy=policy, embedding_config=snapshot.embedding_config
570
+ )
571
+ self._clear_pending_policy(kb_name)
572
+ except Exception:
573
+ self.logger.warning(
574
+ "LightRAG append completed but metadata refresh failed; preserving the "
575
+ "existing published policy",
576
+ exc_info=True,
577
+ )
578
+ if outcome.complete and (
579
+ publish_binding := kwargs.get("publish_embedding_binding")
580
+ ):
581
+ publish_binding()
582
+ return outcome.complete
583
+ except LightRagBatchError as exc:
584
+ if not is_update and exc.outcome.accepted == 0:
585
+ self._remove_zero_accepted_candidate(root_dir)
586
+ raise
587
+ except Exception:
588
+ if not is_update:
589
+ self._remove_zero_accepted_candidate(root_dir)
590
+ raise
591
+
592
+ def _clear_pending_policy(self, kb_name: str) -> None:
593
+ """Best-effort cleanup after version metadata became authoritative."""
594
+ try:
595
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
596
+
597
+ manager = KnowledgeBaseManager(base_dir=self.kb_base_dir)
598
+ entry = manager.config.get("knowledge_bases", {}).get(kb_name) or {}
599
+ if "pending_indexing_policy" in entry:
600
+ entry.pop("pending_indexing_policy", None)
601
+ manager._save_config()
602
+ except Exception:
603
+ self.logger.warning(
604
+ "Published LightRAG policy for %s but could not clear pending cache",
605
+ kb_name,
606
+ exc_info=True,
607
+ )
608
+
609
+ async def search(self, query: str, kb_name: str, **kwargs) -> Dict[str, Any]:
610
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
611
+ root_dir = storage.latest_published_root(kb_dir)
612
+ from deeptutor.services.rag.embedding_binding import bound_graph_storage_root
613
+
614
+ root_dir = bound_graph_storage_root(kb_dir, storage.PROVIDER, root_dir)
615
+ if root_dir is None:
616
+ return {
617
+ "query": query,
618
+ "answer": "This LightRAG knowledge base must be rebuilt for the native pipeline.",
619
+ "content": "",
620
+ "sources": [],
621
+ "provider": storage.PROVIDER,
622
+ "needs_reindex": True,
623
+ }
624
+ mode = self._resolve_mode(kb_name, kwargs)
625
+ try:
626
+ self._ensure_available()
627
+ from deeptutor.services.embedding import get_embedding_config
628
+
629
+ from .roles import resolve_query_roles
630
+
631
+ embedding_config = deepcopy(get_embedding_config())
632
+ storage.require_compatible_embedding(root_dir, embedding_config)
633
+ query_roles = resolve_query_roles()
634
+
635
+ async def job(io_bridge: OwnerLoopBridge) -> Any:
636
+ rag = engine.build_rag(
637
+ root_dir,
638
+ io_bridge=io_bridge,
639
+ query_roles=query_roles,
640
+ embedding_config=embedding_config,
641
+ )
642
+ failed = True
643
+ try:
644
+ await engine.initialize(rag)
645
+ result = await engine.query_with_sources(rag, query, mode)
646
+ failed = False
647
+ return result
648
+ finally:
649
+ await engine.finalize(rag, cancel_pending=failed)
650
+
651
+ answer, sources = await run_in_worker_loop(job)
652
+ except lr_config.LightRagNotAvailableError as exc:
653
+ return self._error_result(query, exc, error_type="not_configured")
654
+ except indexing_policy.EmbeddingMismatchError as exc:
655
+ return self._error_result(query, exc, error_type=exc.code)
656
+ except Exception as exc:
657
+ self.logger.error("LightRAG search failed: %s", exc)
658
+ self.logger.error(traceback.format_exc())
659
+ return self._error_result(query, exc, error_type="retrieval_error")
660
+ return {
661
+ "query": query,
662
+ "answer": answer,
663
+ "content": answer,
664
+ "sources": sources,
665
+ "provider": storage.PROVIDER,
666
+ "mode": mode,
667
+ }
668
+
669
+ def _error_result(self, query: str, exc: Exception, *, error_type: str) -> Dict[str, Any]:
670
+ return {
671
+ "query": query,
672
+ "answer": str(exc),
673
+ "content": "",
674
+ "sources": [],
675
+ "provider": storage.PROVIDER,
676
+ "error_type": error_type,
677
+ }
678
+
679
+ async def delete(self, kb_name: str, **kwargs) -> bool:
680
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
681
+ if kb_dir.exists():
682
+ shutil.rmtree(kb_dir)
683
+ return True
684
+ return False
685
+
686
+
687
+ __all__ = [
688
+ "BatchOutcome",
689
+ "LightRagBatchError",
690
+ "LightRagNeedsReindexError",
691
+ "LightRagPipeline",
692
+ ]