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