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,338 @@
1
+ """On-disk layout for a LightRAG-backed knowledge base.
2
+
3
+ Like the GraphRAG/PageIndex pipelines, a LightRAG KB keeps a self-contained
4
+ store inside the KB's flat ``version-N`` directory (reused from
5
+ ``index_versioning`` with a ``None`` signature). That dir is LightRAG's
6
+ ``working_dir``: LightRAG writes its KV stores, vector DBs and the knowledge
7
+ graph there::
8
+
9
+ <kb_dir>/version-N/
10
+ kv_store_*.json
11
+ vdb_*.json
12
+ graph_chunk_entity_relation.graphml
13
+ meta.json # synthetic "ready" marker (see write_meta)
14
+
15
+ The synthetic ``meta.json`` makes the existing "is this KB initialised?" and
16
+ index-versions UI checks treat a LightRAG KB as ready without teaching the
17
+ manager about LightRAG internals.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timezone
23
+ import json
24
+ import logging
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ if TYPE_CHECKING:
29
+ from deeptutor.services.embedding.config import EmbeddingConfig
30
+ from deeptutor.services.rag.index_versioning import EmbeddingSignature
31
+
32
+ from deeptutor.services.file_io import atomic_write_json
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ META_FILENAME = "meta.json"
37
+ PROVIDER = "lightrag"
38
+ ADAPTER_SCHEMA = 2
39
+ PUBLISHED_STATE = "published"
40
+
41
+ # Glob patterns LightRAG writes once it has actually built chunk/vector data.
42
+ # A graphml file alone is not enough: LightRAG creates an empty graph at startup
43
+ # before any document is successfully processed.
44
+ _OUTPUT_GLOBS = ("vdb_*.json", "kv_store_text_chunks.json")
45
+ _DOC_STATUS_FILENAME = "kv_store_doc_status.json"
46
+ _SUCCESS_STATUSES = {"processed", "completed", "done", "success", "indexed"}
47
+ _FAILED_STATUSES = {"failed", "error"}
48
+
49
+
50
+ def working_dir(root_dir: Path) -> Path:
51
+ """LightRAG's working dir == the version-N root."""
52
+ return Path(root_dir)
53
+
54
+
55
+ def _store_root(root_dir: Path) -> Path:
56
+ """Resolve native workspace storage while retaining legacy flat reads."""
57
+ root = Path(root_dir)
58
+ if (root / _DOC_STATUS_FILENAME).exists() or any(root.glob("vdb_*.json")):
59
+ return root
60
+ meta = _read_meta(root)
61
+ workspace = str((meta or {}).get("workspace") or "").strip()
62
+ if not workspace:
63
+ from .engine import workspace_for
64
+
65
+ workspace = workspace_for(root)
66
+ candidate = root / workspace
67
+ return candidate if candidate.is_dir() else root
68
+
69
+
70
+ def has_output(root_dir: Path | None) -> bool:
71
+ """True when LightRAG has at least one successfully indexed document."""
72
+ if root_dir is None:
73
+ return False
74
+ root = _store_root(Path(root_dir))
75
+ if not root.is_dir():
76
+ return False
77
+
78
+ status_signal = _doc_status_has_success(root)
79
+ if status_signal is not None:
80
+ return status_signal
81
+
82
+ for pattern in _OUTPUT_GLOBS:
83
+ for path in root.glob(pattern):
84
+ try:
85
+ if path.is_file() and path.stat().st_size > 2:
86
+ return True
87
+ except OSError:
88
+ continue
89
+ return False
90
+
91
+
92
+ def _doc_status_has_success(root_dir: Path) -> bool | None:
93
+ payload = _read_doc_status(root_dir)
94
+ if not payload:
95
+ return None
96
+
97
+ saw_failure = False
98
+ for item in payload.values():
99
+ if not isinstance(item, dict):
100
+ continue
101
+ chunks = item.get("chunks_list")
102
+ if isinstance(chunks, list) and len(chunks) > 0:
103
+ return True
104
+ status = str(item.get("status") or "").lower()
105
+ if status in _SUCCESS_STATUSES:
106
+ return True
107
+ if status in _FAILED_STATUSES:
108
+ saw_failure = True
109
+
110
+ return False if saw_failure else None
111
+
112
+
113
+ def failure_summary(root_dir: Path | None, *, limit: int = 3) -> str:
114
+ """Return a short human-readable summary of failed LightRAG documents."""
115
+ if root_dir is None:
116
+ return ""
117
+ payload = _read_doc_status(_store_root(Path(root_dir)))
118
+ if not payload:
119
+ return ""
120
+
121
+ failures: list[str] = []
122
+ for item in payload.values():
123
+ if not isinstance(item, dict):
124
+ continue
125
+ status = str(item.get("status") or "").lower()
126
+ error = str(item.get("error_msg") or "").strip()
127
+ if status not in _FAILED_STATUSES and not error:
128
+ continue
129
+ name = str(item.get("file_path") or "document").strip()
130
+ failures.append(f"{name}: {error or status}")
131
+ if len(failures) >= limit:
132
+ break
133
+ return "; ".join(failures)
134
+
135
+
136
+ def document_error(root_dir: Path | None, doc_id: str) -> str:
137
+ """Return the stored LightRAG error for one document, if present."""
138
+ if root_dir is None or not doc_id:
139
+ return ""
140
+ payload = _read_doc_status(_store_root(Path(root_dir)))
141
+ if not payload:
142
+ return ""
143
+ item = payload.get(doc_id)
144
+ if not isinstance(item, dict):
145
+ return ""
146
+ status = str(item.get("status") or "").lower()
147
+ error = str(item.get("error_msg") or "").strip()
148
+ if status in _FAILED_STATUSES or error:
149
+ return error or status
150
+ return ""
151
+
152
+
153
+ def _read_doc_status(root_dir: Path) -> dict[str, Any] | None:
154
+ path = root_dir / _DOC_STATUS_FILENAME
155
+ if not path.exists():
156
+ return None
157
+ try:
158
+ with open(path, encoding="utf-8") as handle:
159
+ payload = json.load(handle)
160
+ return payload if isinstance(payload, dict) else None
161
+ except Exception as exc:
162
+ logger.warning("Failed to read LightRAG doc status %s: %s", path, exc)
163
+ return None
164
+
165
+
166
+ def has_any_doc_status(root_dir: Path | None) -> bool:
167
+ if root_dir is None:
168
+ return False
169
+ payload = _read_doc_status(_store_root(Path(root_dir)))
170
+ return bool(payload)
171
+
172
+
173
+ def _read_meta(root_dir: Path) -> dict[str, Any] | None:
174
+ path = Path(root_dir) / META_FILENAME
175
+ try:
176
+ payload = json.loads(path.read_text(encoding="utf-8"))
177
+ except (OSError, json.JSONDecodeError):
178
+ return None
179
+ return payload if isinstance(payload, dict) else None
180
+
181
+
182
+ def meta_is_native_published(root_dir: Path | None) -> bool:
183
+ if root_dir is None:
184
+ return False
185
+ meta = _read_meta(Path(root_dir))
186
+ return bool(
187
+ meta
188
+ and meta.get("provider") == PROVIDER
189
+ and meta.get("signature") == PROVIDER
190
+ and meta.get("lightrag_adapter_schema") == ADAPTER_SCHEMA
191
+ and meta.get("parser_bridge_schema") == 1
192
+ and meta.get("state") == PUBLISHED_STATE
193
+ and has_output(Path(root_dir))
194
+ )
195
+
196
+
197
+ def _parser_inputs(root_dir: Path) -> list[dict[str, str]]:
198
+ from .ingress import bundles_root, load_verified_bundle
199
+
200
+ records: set[tuple[str, str]] = set()
201
+ for bundle in sorted(bundles_root(root_dir).glob("*.bundle")):
202
+ canonical_name = bundle.name.removesuffix(".bundle")
203
+ manifest, _ = load_verified_bundle(root_dir, canonical_name)
204
+ parser = manifest.get("parser")
205
+ if not isinstance(parser, dict):
206
+ continue
207
+ records.add(
208
+ (
209
+ str(parser.get("engine") or ""),
210
+ str(parser.get("parser_signature") or ""),
211
+ )
212
+ )
213
+ return [
214
+ {"engine": engine, "parser_signature": signature} for engine, signature in sorted(records)
215
+ ]
216
+
217
+
218
+ def read_published_policy(root_dir: Path | None) -> dict[str, Any] | None:
219
+ if root_dir is None or not meta_is_native_published(root_dir):
220
+ return None
221
+ meta = _read_meta(Path(root_dir)) or {}
222
+ policy = meta.get("indexing_policy")
223
+ if isinstance(policy, dict):
224
+ return policy
225
+ return {"policy": "legacy_unpinned"}
226
+
227
+
228
+ def latest_published_root(kb_dir: Path) -> Path | None:
229
+ """Return the newest native published version, ignoring failed candidates."""
230
+ from deeptutor.services.rag.index_versioning import list_kb_versions
231
+
232
+ for entry in list_kb_versions(Path(kb_dir)):
233
+ root = Path(str(entry.get("storage_path") or ""))
234
+ if meta_is_native_published(root):
235
+ return root
236
+ return None
237
+
238
+
239
+ def published_root_for_embedding(kb_dir: Path, signature: str | None) -> Path | None:
240
+ """Find provenance for the recorded binding, even after catalog identity drift."""
241
+ if signature is None:
242
+ return latest_published_root(kb_dir)
243
+ from deeptutor.services.rag.index_versioning import list_kb_versions
244
+
245
+ for entry in list_kb_versions(kb_dir):
246
+ root = Path(entry["storage_path"])
247
+ if entry.get("embedding_signature") == signature and meta_is_native_published(root):
248
+ return root
249
+ return None
250
+
251
+
252
+ def embedding_matches(root_dir: Path, signature: EmbeddingSignature | None) -> bool:
253
+ """Compare the published vector identity, never just its dimension."""
254
+ meta = _read_meta(root_dir) or {}
255
+ return signature is not None and meta.get("embedding_signature") == signature.hash()
256
+
257
+
258
+ def require_compatible_embedding(root_dir: Path, config: EmbeddingConfig) -> None:
259
+ """Reject reads and appends when the published embedding identity differs."""
260
+ from deeptutor.services.rag.embedding_signature import signature_from_config
261
+
262
+ from .indexing_policy import EmbeddingMismatchError
263
+
264
+ if not embedding_matches(root_dir, signature_from_config(config)):
265
+ raise EmbeddingMismatchError(
266
+ "The current embedding configuration does not match this LightRAG index. "
267
+ "Restore the original embedding configuration or rebuild with the current embedding "
268
+ "before querying or adding documents."
269
+ )
270
+
271
+
272
+ def write_meta(
273
+ root_dir: Path,
274
+ *,
275
+ indexing_policy: dict[str, Any] | None = None,
276
+ embedding_config: EmbeddingConfig | None = None,
277
+ ) -> None:
278
+ """Write a flat-layout ``meta.json`` so the version lists as ready.
279
+
280
+ Mirrors ``index_versioning.write_version_meta`` but carries a synthetic
281
+ ``lightrag`` signature instead of an embedding hash. The embedding identity
282
+ is stamped alongside so an externally-linked index can be checked for
283
+ embedding compatibility at connect time (LightRAG otherwise fails retrieval
284
+ silently on a dimension mismatch).
285
+ """
286
+ from deeptutor.services.rag.embedding_signature import (
287
+ embedding_meta_fields,
288
+ signature_from_config,
289
+ )
290
+
291
+ from .engine import installed_version, workspace_for
292
+
293
+ target = Path(root_dir)
294
+ previous = _read_meta(target) or {}
295
+ now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z"
296
+ if embedding_config is None:
297
+ embedding_fields = embedding_meta_fields()
298
+ else:
299
+ signature = signature_from_config(embedding_config)
300
+ embedding_fields = {
301
+ "embedding_signature": signature.hash(),
302
+ "embedding_model": signature.model,
303
+ "embedding_dim": signature.dimension,
304
+ }
305
+ payload = {
306
+ "version": target.name,
307
+ "signature": PROVIDER,
308
+ "provider": PROVIDER,
309
+ "state": PUBLISHED_STATE,
310
+ "lightrag_adapter_schema": ADAPTER_SCHEMA,
311
+ "lightrag_package_version": installed_version(),
312
+ "parser_bridge_schema": 1,
313
+ "parser_inputs": _parser_inputs(target),
314
+ "workspace": workspace_for(target),
315
+ "layout": "flat",
316
+ "created_at": str(previous.get("created_at") or now),
317
+ "updated_at": now,
318
+ "indexing_policy": indexing_policy
319
+ or previous.get("indexing_policy")
320
+ or {"policy": "legacy_unpinned"},
321
+ **embedding_fields,
322
+ }
323
+ atomic_write_json(target / META_FILENAME, payload)
324
+
325
+
326
+ __all__ = [
327
+ "META_FILENAME",
328
+ "PROVIDER",
329
+ "document_error",
330
+ "failure_summary",
331
+ "has_any_doc_status",
332
+ "working_dir",
333
+ "has_output",
334
+ "meta_is_native_published",
335
+ "latest_published_root",
336
+ "read_published_policy",
337
+ "write_meta",
338
+ ]
@@ -0,0 +1,259 @@
1
+ """Event-loop isolation helpers for local LightRAG indexing.
2
+
3
+ LightRAG's local storage backends perform synchronous graph merging and
4
+ JSON serialization from inside async methods. Running those methods on the
5
+ service event loop therefore stalls unrelated API and LLM work. This module
6
+ provides one narrow boundary: run local LightRAG coroutines on a process-wide
7
+ worker event loop, while explicitly forwarding network I/O and callbacks to
8
+ the event loop that owns each request. LightRAG's storage locks are process-wide.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from collections.abc import Awaitable, Callable
15
+ import concurrent.futures
16
+ import contextvars
17
+ import inspect
18
+ import logging
19
+ import os
20
+ import threading
21
+ from typing import Any, TypeVar
22
+
23
+ T = TypeVar("T")
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ DEFAULT_WORKER_CANCEL_GRACE_SECONDS = 10.0
28
+ _WORKER_LOOP_LOCK = threading.Lock()
29
+ _WORKER_LOOP: asyncio.AbstractEventLoop | None = None
30
+ _WORKER_THREAD: threading.Thread | None = None
31
+ _WORKER_PID: int | None = None
32
+
33
+
34
+ def _worker_loop() -> asyncio.AbstractEventLoop:
35
+ """Return the one loop that owns all local LightRAG asyncio locks (#1578)."""
36
+ global _WORKER_LOOP, _WORKER_THREAD, _WORKER_PID
37
+ with _WORKER_LOOP_LOCK:
38
+ pid = os.getpid()
39
+ if _WORKER_PID != pid:
40
+ # A fork inherits Python globals, but not the worker's thread.
41
+ _WORKER_LOOP = None
42
+ _WORKER_THREAD = None
43
+ _WORKER_PID = pid
44
+ if _WORKER_LOOP is None:
45
+ loop = asyncio.new_event_loop()
46
+ thread = threading.Thread(
47
+ target=loop.run_forever,
48
+ name="lightrag-worker-loop",
49
+ daemon=True,
50
+ )
51
+ thread.start()
52
+ _WORKER_LOOP = loop
53
+ _WORKER_THREAD = thread
54
+ elif _WORKER_THREAD is None or not _WORKER_THREAD.is_alive():
55
+ # A new loop in this process would reuse locks bound to the old one.
56
+ raise RuntimeError("LightRAG worker loop stopped; restart DeepTutor.")
57
+ return _WORKER_LOOP
58
+
59
+
60
+ class _WorkerLoopController:
61
+ """Thread-safe cancellation handle for the worker loop's top-level task."""
62
+
63
+ def __init__(self) -> None:
64
+ self._lock = threading.Lock()
65
+ self._cancel_requested = threading.Event()
66
+ self._loop: asyncio.AbstractEventLoop | None = None
67
+ self._task: asyncio.Task[Any] | None = None
68
+
69
+ def bind_current_task(self) -> None:
70
+ """Bind from inside the worker loop and honor an earlier cancellation."""
71
+ loop = asyncio.get_running_loop()
72
+ task = asyncio.current_task()
73
+ if task is None: # pragma: no cover - asyncio always owns this coroutine
74
+ raise RuntimeError("Worker loop has no current task")
75
+ with self._lock:
76
+ self._loop = loop
77
+ self._task = task
78
+ cancel_requested = self._cancel_requested.is_set()
79
+ if cancel_requested:
80
+ task.cancel()
81
+
82
+ def clear(self) -> None:
83
+ """Drop references once the worker job reaches a terminal state."""
84
+ with self._lock:
85
+ self._loop = None
86
+ self._task = None
87
+
88
+ def cancel(self) -> None:
89
+ """Cancel the worker's actual top-level task, including before bind."""
90
+ if self._cancel_requested.is_set():
91
+ return
92
+ self._cancel_requested.set()
93
+ with self._lock:
94
+ loop = self._loop
95
+ task = self._task
96
+ if loop is None or task is None:
97
+ return
98
+ try:
99
+ loop.call_soon_threadsafe(task.cancel)
100
+ except RuntimeError:
101
+ # The worker loop failed between the snapshot and the signal.
102
+ pass
103
+
104
+
105
+ class OwnerLoopBridge:
106
+ """Run selected awaitables and callbacks on the service event loop.
107
+
108
+ The worker receives a copy of the caller's :mod:`contextvars` context.
109
+ ``run`` schedules from that copied context, so request-local model and user
110
+ configuration remains visible on the owner loop.
111
+ """
112
+
113
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
114
+ self._loop = loop
115
+ self._cancelled = threading.Event()
116
+ self._pending_lock = threading.Lock()
117
+ self._pending: set[concurrent.futures.Future[Any]] = set()
118
+
119
+ def cancel(self) -> None:
120
+ """Reject new owner-loop work and cancel requests already in flight."""
121
+ self._cancelled.set()
122
+ with self._pending_lock:
123
+ pending = tuple(self._pending)
124
+ for future in pending:
125
+ future.cancel()
126
+
127
+ def raise_if_cancelled(self) -> None:
128
+ """Cooperatively stop the worker at a safe async boundary."""
129
+ if self._cancelled.is_set():
130
+ raise asyncio.CancelledError
131
+
132
+ async def run(self, factory: Callable[[], Awaitable[T]]) -> T:
133
+ """Await ``factory`` on the owner loop and propagate its result/error."""
134
+ self.raise_if_cancelled()
135
+ if asyncio.get_running_loop() is self._loop:
136
+ return await factory()
137
+
138
+ async def invoke() -> T:
139
+ return await factory()
140
+
141
+ coroutine = invoke()
142
+ try:
143
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
144
+ except BaseException:
145
+ coroutine.close()
146
+ raise
147
+ with self._pending_lock:
148
+ if self._cancelled.is_set():
149
+ future.cancel()
150
+ else:
151
+ self._pending.add(future)
152
+ try:
153
+ return await asyncio.wrap_future(future)
154
+ except asyncio.CancelledError:
155
+ future.cancel()
156
+ raise
157
+ finally:
158
+ with self._pending_lock:
159
+ self._pending.discard(future)
160
+
161
+ async def call(self, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
162
+ """Invoke a sync or async callback on the owner loop."""
163
+
164
+ async def invoke() -> Any:
165
+ result = callback(*args, **kwargs)
166
+ if inspect.isawaitable(result):
167
+ return await result
168
+ return result
169
+
170
+ return await self.run(invoke)
171
+
172
+
173
+ async def run_in_worker_loop(
174
+ job: Callable[[OwnerLoopBridge], Awaitable[T]],
175
+ *,
176
+ cancel_grace_seconds: float = DEFAULT_WORKER_CANCEL_GRACE_SECONDS,
177
+ ) -> T:
178
+ """Run one local LightRAG job on the process-wide worker event loop.
179
+
180
+ ``job`` and every object it creates should remain confined to that worker.
181
+ The supplied bridge is the only supported route back to the owner loop.
182
+ Worker exceptions are re-raised in the awaiting task.
183
+ """
184
+ owner_loop = asyncio.get_running_loop()
185
+ bridge = OwnerLoopBridge(owner_loop)
186
+ controller = _WorkerLoopController()
187
+ caller_context = contextvars.copy_context()
188
+
189
+ finished: concurrent.futures.Future[T] = concurrent.futures.Future()
190
+
191
+ async def run_bound_job() -> None:
192
+ try:
193
+ controller.bind_current_task()
194
+ result = await job(bridge)
195
+ except BaseException as exc:
196
+ finished.set_exception(exc)
197
+ else:
198
+ finished.set_result(result)
199
+ finally:
200
+ controller.clear()
201
+
202
+ worker_loop = _worker_loop()
203
+
204
+ def submit() -> None:
205
+ try:
206
+ worker_loop.create_task(run_bound_job(), context=caller_context)
207
+ except BaseException as exc:
208
+ finished.set_exception(exc)
209
+
210
+ worker_loop.call_soon_threadsafe(submit)
211
+ # run_coroutine_threadsafe's Future becomes cancelled before its coroutine
212
+ # finishes cleanup. This completion Future resolves only after job exits.
213
+ worker = asyncio.wrap_future(finished)
214
+ try:
215
+ # Shielding keeps request cancellation from cancelling the completion
216
+ # signal. The owner waits for the worker's cleanup below.
217
+ return await asyncio.shield(worker)
218
+ except asyncio.CancelledError:
219
+ bridge.cancel()
220
+ controller.cancel()
221
+ grace = max(float(cancel_grace_seconds), 0.0)
222
+ deadline = owner_loop.time() + grace
223
+ while not worker.done() and owner_loop.time() < deadline:
224
+ try:
225
+ remaining = max(deadline - owner_loop.time(), 0.0)
226
+ await asyncio.wait({worker}, timeout=remaining)
227
+ except asyncio.CancelledError:
228
+ # Repeated cancellation still must not strand the worker on a
229
+ # request scheduled back to this owner loop.
230
+ bridge.cancel()
231
+ controller.cancel()
232
+ if not worker.done():
233
+ # Stopping this loop would strand unrelated jobs and leave locks
234
+ # bound to a stopped loop. Wait for confirmed cleanup instead.
235
+ logger.error(
236
+ "LightRAG worker did not stop within %.1fs; waiting for confirmed cleanup",
237
+ grace,
238
+ )
239
+ while not worker.done():
240
+ try:
241
+ await asyncio.wait({worker})
242
+ except asyncio.CancelledError:
243
+ bridge.cancel()
244
+ controller.cancel()
245
+ # Retrieve the terminal exception so the completion Future never emits
246
+ # an "exception was never retrieved" warning. The caller's
247
+ # cancellation remains authoritative.
248
+ try:
249
+ worker.result()
250
+ except BaseException:
251
+ pass
252
+ raise
253
+
254
+
255
+ __all__ = [
256
+ "DEFAULT_WORKER_CANCEL_GRACE_SECONDS",
257
+ "OwnerLoopBridge",
258
+ "run_in_worker_loop",
259
+ ]
@@ -0,0 +1,55 @@
1
+ """One nonblocking writer per knowledge base, shared across worker processes."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from .indexing_policy import IndexingPolicyError
10
+
11
+
12
+ @contextmanager
13
+ def write_ownership(kb_dir: Path) -> Iterator[None]:
14
+ """Hold exclusive nonblocking write ownership for a knowledge base.
15
+
16
+ Yields:
17
+ None while the cross-process indexing lock is held.
18
+
19
+ Raises:
20
+ IndexingPolicyError: The target uses a symlink or another writer owns it."""
21
+ kb_dir = Path(kb_dir)
22
+ if kb_dir.is_symlink():
23
+ raise IndexingPolicyError("A writable knowledge-base directory cannot be a symbolic link.")
24
+ lock_dir = kb_dir.parent / ".lightrag-locks"
25
+ if lock_dir.is_symlink():
26
+ raise IndexingPolicyError("The indexing lock directory cannot be a symbolic link.")
27
+ lock_dir.mkdir(parents=True, exist_ok=True)
28
+ key = hashlib.sha256(str(kb_dir.resolve()).encode("utf-8")).hexdigest()
29
+ lock_path = lock_dir / f"{key}.lock"
30
+ if lock_path.is_symlink():
31
+ raise IndexingPolicyError("The indexing lock cannot be a symbolic link.")
32
+ descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
33
+ with os.fdopen(descriptor, "r+b") as handle:
34
+ try:
35
+ if os.name == "nt":
36
+ import msvcrt
37
+
38
+ handle.seek(0)
39
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
40
+ else:
41
+ import fcntl
42
+
43
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
44
+ except OSError as exc:
45
+ raise IndexingPolicyError(
46
+ "Another indexing operation is active for this knowledge base; resubmit after it finishes."
47
+ ) from exc
48
+ try:
49
+ yield
50
+ finally:
51
+ if os.name == "nt":
52
+ handle.seek(0)
53
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
54
+ else:
55
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@@ -0,0 +1,15 @@
1
+ """Retrieval-only RAG pipeline backed by an external LightRAG server.
2
+
3
+ A KB bound to the ``lightrag-server`` provider is a connection pointer to a
4
+ standalone LightRAG server the user runs and indexed themselves. DeepTutor never
5
+ indexes or stores anything locally: retrieval is offloaded to the server's
6
+ ``/query`` endpoint (context only — DeepTutor's chat LLM still writes the
7
+ answer), and the endpoint is configured per-KB. See ``client`` for the wire and
8
+ ``probe`` for the connect-time health check.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .pipeline import LightRagServerPipeline
14
+
15
+ __all__ = ["LightRagServerPipeline"]