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,149 @@
|
|
|
1
|
+
"""Thin async HTTP client for an external LightRAG server's REST API.
|
|
2
|
+
|
|
3
|
+
We talk to the documented endpoints directly (``httpx`` only) — the calls map
|
|
4
|
+
1:1 onto our retrieval-only contract:
|
|
5
|
+
|
|
6
|
+
* ``POST /query`` with ``only_need_context=True`` — return the grounded context
|
|
7
|
+
the server retrieved, WITHOUT its own generation. DeepTutor's chat loop keeps
|
|
8
|
+
ownership of the answer; the server is used purely as a retriever.
|
|
9
|
+
* ``GET /auth-status`` — reachability + whether the server requires an API key
|
|
10
|
+
(whitelisted on the server, so it answers without credentials).
|
|
11
|
+
* ``GET /documents/pipeline_status`` — an auth-gated, side-effect-free call used
|
|
12
|
+
only to validate that a configured API key is accepted.
|
|
13
|
+
|
|
14
|
+
Mirrors :class:`PageIndexClient`: a fresh :class:`httpx.AsyncClient` per call so
|
|
15
|
+
the object is safe to construct once and reuse, and an injectable ``transport``
|
|
16
|
+
so tests can stub the wire without a live server.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .config import LightRagServerConfig
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LightRagServerAPIError(RuntimeError):
|
|
32
|
+
"""Raised when the LightRAG server returns an error or unexpected payload."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LightRagServerClient:
|
|
36
|
+
"""Stateless wrapper over an external LightRAG server's REST API."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
config: LightRagServerConfig,
|
|
41
|
+
*,
|
|
42
|
+
timeout: float = 60.0,
|
|
43
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._config = config
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._transport = transport
|
|
48
|
+
|
|
49
|
+
def _open(self) -> httpx.AsyncClient:
|
|
50
|
+
headers = {"Accept": "application/json"}
|
|
51
|
+
if self._config.api_key:
|
|
52
|
+
# LightRAG server authenticates with an ``X-API-Key`` header
|
|
53
|
+
# (its ``LIGHTRAG_API_KEY``); absent when the server runs open.
|
|
54
|
+
headers["X-API-Key"] = self._config.api_key
|
|
55
|
+
return httpx.AsyncClient(
|
|
56
|
+
base_url=self._config.base_url,
|
|
57
|
+
headers=headers,
|
|
58
|
+
timeout=self._timeout,
|
|
59
|
+
transport=self._transport,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _json(resp: httpx.Response) -> dict[str, Any]:
|
|
64
|
+
if resp.status_code >= 400:
|
|
65
|
+
raise LightRagServerAPIError(
|
|
66
|
+
f"LightRAG server returned {resp.status_code}: {resp.text[:300]}"
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
data = resp.json()
|
|
70
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
71
|
+
raise LightRagServerAPIError(
|
|
72
|
+
f"LightRAG server returned a non-JSON response: {exc}"
|
|
73
|
+
) from exc
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise LightRagServerAPIError(f"LightRAG server returned unexpected payload: {data!r}")
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
# ----- retrieval ------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
async def query_context(self, query: str, mode: str) -> dict[str, Any]:
|
|
81
|
+
"""Retrieve grounded context for ``query`` without server-side generation.
|
|
82
|
+
|
|
83
|
+
Returns ``{"content": <context string>, "sources": [...]}``. ``sources``
|
|
84
|
+
are derived from the server's ``references`` list when present (one entry
|
|
85
|
+
per cited source file); an older server that omits references yields an
|
|
86
|
+
empty list rather than an error.
|
|
87
|
+
"""
|
|
88
|
+
async with self._open() as client:
|
|
89
|
+
resp = await client.post(
|
|
90
|
+
"/query",
|
|
91
|
+
json={"query": query, "mode": mode, "only_need_context": True},
|
|
92
|
+
)
|
|
93
|
+
data = self._json(resp)
|
|
94
|
+
content = str(data.get("response") or "")
|
|
95
|
+
sources = _sources_from_references(data.get("references"))
|
|
96
|
+
return {"content": content, "sources": sources}
|
|
97
|
+
|
|
98
|
+
# ----- probing --------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async def auth_status(self) -> dict[str, Any]:
|
|
101
|
+
"""Fetch ``/auth-status`` (no credentials) to probe reachability.
|
|
102
|
+
|
|
103
|
+
The presence of LightRAG-specific keys (``auth_configured`` /
|
|
104
|
+
``core_version``) doubles as a "this really is a LightRAG server" signal.
|
|
105
|
+
"""
|
|
106
|
+
async with self._open() as client:
|
|
107
|
+
resp = await client.get("/auth-status")
|
|
108
|
+
return self._json(resp)
|
|
109
|
+
|
|
110
|
+
async def verify_key(self) -> bool:
|
|
111
|
+
"""Return whether the configured API key is accepted by the server.
|
|
112
|
+
|
|
113
|
+
Hits the auth-gated, read-only ``/documents/pipeline_status``: a 2xx
|
|
114
|
+
means the key (or open access) is valid; 401/403 means it was rejected.
|
|
115
|
+
Any other transport error propagates to the caller.
|
|
116
|
+
"""
|
|
117
|
+
async with self._open() as client:
|
|
118
|
+
resp = await client.get("/documents/pipeline_status")
|
|
119
|
+
if resp.status_code in (401, 403):
|
|
120
|
+
return False
|
|
121
|
+
if resp.status_code >= 400:
|
|
122
|
+
raise LightRagServerAPIError(
|
|
123
|
+
f"LightRAG server returned {resp.status_code}: {resp.text[:300]}"
|
|
124
|
+
)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sources_from_references(references: Any) -> list[dict[str, Any]]:
|
|
129
|
+
"""Map a LightRAG ``references`` list into DeepTutor's ``sources`` shape."""
|
|
130
|
+
if not isinstance(references, list):
|
|
131
|
+
return []
|
|
132
|
+
sources: list[dict[str, Any]] = []
|
|
133
|
+
for ref in references:
|
|
134
|
+
if not isinstance(ref, dict):
|
|
135
|
+
continue
|
|
136
|
+
file_path = str(ref.get("file_path") or "").strip()
|
|
137
|
+
ref_id = str(ref.get("reference_id") or "").strip()
|
|
138
|
+
if not file_path and not ref_id:
|
|
139
|
+
continue
|
|
140
|
+
source: dict[str, Any] = {}
|
|
141
|
+
if ref_id:
|
|
142
|
+
source["id"] = ref_id
|
|
143
|
+
if file_path:
|
|
144
|
+
source["file_path"] = file_path
|
|
145
|
+
sources.append(source)
|
|
146
|
+
return sources
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["LightRagServerClient", "LightRagServerAPIError"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Per-KB connection config and retrieval modes for the LightRAG Server engine.
|
|
2
|
+
|
|
3
|
+
Unlike the hosted PageIndex engine (one global account key shared by every KB),
|
|
4
|
+
each LightRAG Server KB points at its OWN server instance — a standalone LightRAG
|
|
5
|
+
server's ``--workspace`` is fixed at startup, so one server instance = one
|
|
6
|
+
workspace = one knowledge base. The endpoint (base URL + optional API key) is
|
|
7
|
+
therefore stored per-KB in ``kb_config.json`` (the same place the KB's
|
|
8
|
+
``search_mode`` lives), exactly like a ``linked`` KB stores its ``external_path``.
|
|
9
|
+
|
|
10
|
+
This module is the single seam that reads that binding into a typed config; it
|
|
11
|
+
holds no global state and imports no HTTP client (the client lives in
|
|
12
|
+
``client.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# LightRAG's ``/query`` retrieval modes. ``bypass`` is intentionally omitted: it
|
|
21
|
+
# skips retrieval entirely, which is meaningless for a knowledge base. ``mix``
|
|
22
|
+
# (knowledge-graph + vector + chunks) is the server's own default and the safest
|
|
23
|
+
# general choice, matching the shared per-KB ``search_mode`` default.
|
|
24
|
+
SUPPORTED_MODES = ("naive", "local", "global", "hybrid", "mix")
|
|
25
|
+
DEFAULT_MODE = "mix"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LightRagServerNotConfiguredError(RuntimeError):
|
|
29
|
+
"""Raised when a KB has no LightRAG server URL bound to it."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class LightRagServerConfig:
|
|
34
|
+
"""A KB's resolved connection to an external LightRAG server."""
|
|
35
|
+
|
|
36
|
+
base_url: str
|
|
37
|
+
api_key: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_base_url(url: str | None) -> str:
|
|
41
|
+
"""Trim and drop any trailing slash so endpoint paths join cleanly."""
|
|
42
|
+
return (url or "").strip().rstrip("/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def config_from_entry(entry: dict[str, Any]) -> LightRagServerConfig:
|
|
46
|
+
"""Build a :class:`LightRagServerConfig` from a ``kb_config.json`` KB entry.
|
|
47
|
+
|
|
48
|
+
Raises :class:`LightRagServerNotConfiguredError` when no ``server_url`` is
|
|
49
|
+
present, so retrieval fails with a clear message instead of an opaque
|
|
50
|
+
connection error.
|
|
51
|
+
"""
|
|
52
|
+
base_url = normalize_base_url(entry.get("server_url"))
|
|
53
|
+
if not base_url:
|
|
54
|
+
raise LightRagServerNotConfiguredError(
|
|
55
|
+
"This knowledge base is not connected to a LightRAG server "
|
|
56
|
+
"(no server URL configured). Re-create it with a valid server URL."
|
|
57
|
+
)
|
|
58
|
+
return LightRagServerConfig(base_url=base_url, api_key=str(entry.get("api_key") or "").strip())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"SUPPORTED_MODES",
|
|
63
|
+
"DEFAULT_MODE",
|
|
64
|
+
"LightRagServerNotConfiguredError",
|
|
65
|
+
"LightRagServerConfig",
|
|
66
|
+
"normalize_base_url",
|
|
67
|
+
"config_from_entry",
|
|
68
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Retrieval-only pipeline backed by an external LightRAG server.
|
|
2
|
+
|
|
3
|
+
Implements the same contract as the other pipelines (see ``..base.RAGPipeline``)
|
|
4
|
+
but owns no index: a ``lightrag-server`` KB is a connection pointer (``type:
|
|
5
|
+
lightrag_server`` in ``kb_config.json``) to a standalone LightRAG server the user
|
|
6
|
+
runs and indexed themselves. Only :meth:`search` does real work — it reads the
|
|
7
|
+
KB's endpoint, asks the server for grounded context (no server-side generation),
|
|
8
|
+
and shapes the result for the ``rag`` tool. Indexing is offloaded entirely to the
|
|
9
|
+
server, so :meth:`initialize` / :meth:`add_documents` are not part of this
|
|
10
|
+
engine's job and fail with a clear message; :meth:`delete` is a no-op because
|
|
11
|
+
deleting the KB only drops DeepTutor's pointer (handled by the manager) and must
|
|
12
|
+
never touch the user's server.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from deeptutor.runtime.home import get_runtime_data_root
|
|
21
|
+
from deeptutor.services.rag.provider_binding import load_kb_config_entry
|
|
22
|
+
|
|
23
|
+
from ..modes import resolve_kb_mode
|
|
24
|
+
from .config import (
|
|
25
|
+
DEFAULT_MODE,
|
|
26
|
+
SUPPORTED_MODES,
|
|
27
|
+
LightRagServerNotConfiguredError,
|
|
28
|
+
config_from_entry,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
PROVIDER = "lightrag-server"
|
|
34
|
+
DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LightRagServerPipeline:
|
|
38
|
+
"""Query an external LightRAG server on behalf of a connected KB."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, kb_base_dir: Optional[str] = None, *, client_factory=None, **_: Any) -> None:
|
|
41
|
+
self.logger = logging.getLogger(__name__)
|
|
42
|
+
self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
|
|
43
|
+
# Injection seam for tests: (config) -> client. None uses the real client.
|
|
44
|
+
self._client_factory = client_factory
|
|
45
|
+
|
|
46
|
+
# ----- helpers --------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _client(self, config):
|
|
49
|
+
if self._client_factory is not None:
|
|
50
|
+
return self._client_factory(config)
|
|
51
|
+
from .client import LightRagServerClient
|
|
52
|
+
|
|
53
|
+
return LightRagServerClient(config)
|
|
54
|
+
|
|
55
|
+
def _resolve_mode(self, kb_name: str, kwargs: dict[str, Any]) -> str:
|
|
56
|
+
return resolve_kb_mode(
|
|
57
|
+
self.kb_base_dir,
|
|
58
|
+
kb_name,
|
|
59
|
+
PROVIDER,
|
|
60
|
+
explicit=kwargs.get("mode"),
|
|
61
|
+
supported=SUPPORTED_MODES,
|
|
62
|
+
default=DEFAULT_MODE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ----- retrieval ------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async def search(self, query: str, kb_name: str, **kwargs) -> Dict[str, Any]:
|
|
68
|
+
try:
|
|
69
|
+
config = config_from_entry(load_kb_config_entry(self.kb_base_dir, kb_name))
|
|
70
|
+
except LightRagServerNotConfiguredError as exc:
|
|
71
|
+
return self._error_result(query, exc, error_type="not_configured")
|
|
72
|
+
|
|
73
|
+
mode = self._resolve_mode(kb_name, kwargs)
|
|
74
|
+
try:
|
|
75
|
+
result = await self._client(config).query_context(query, mode)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
self.logger.error("LightRAG server search failed for '%s': %s", kb_name, exc)
|
|
78
|
+
return self._error_result(query, exc, error_type="retrieval_error")
|
|
79
|
+
|
|
80
|
+
content = result.get("content") or ""
|
|
81
|
+
return {
|
|
82
|
+
"query": query,
|
|
83
|
+
"answer": content,
|
|
84
|
+
"content": content,
|
|
85
|
+
"sources": result.get("sources") or [],
|
|
86
|
+
"provider": PROVIDER,
|
|
87
|
+
"mode": mode,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _error_result(self, query: str, exc: Exception, *, error_type: str) -> Dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"query": query,
|
|
93
|
+
"answer": str(exc),
|
|
94
|
+
"content": "",
|
|
95
|
+
"sources": [],
|
|
96
|
+
"provider": PROVIDER,
|
|
97
|
+
"error_type": error_type,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ----- indexing (not applicable — owned by the external server) -------
|
|
101
|
+
|
|
102
|
+
async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"LightRAG Server knowledge bases are indexed on the external server; "
|
|
105
|
+
"DeepTutor does not build or store their index. Add documents on the "
|
|
106
|
+
"LightRAG server directly."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
|
|
110
|
+
return await self.initialize(kb_name, file_paths, **kwargs)
|
|
111
|
+
|
|
112
|
+
# ----- lifecycle ------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def delete(self, kb_name: str, **kwargs) -> bool:
|
|
115
|
+
# The KB is only a pointer; the manager removes its config entry. Never
|
|
116
|
+
# touch the user's server. Nothing local to clean up here.
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["LightRagServerPipeline", "PROVIDER"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Probe an external LightRAG server before connecting a KB to it.
|
|
2
|
+
|
|
3
|
+
Connecting is cheap and reversible, but a typo'd URL or a wrong API key should
|
|
4
|
+
fail loudly at connect time rather than silently at every later query. This
|
|
5
|
+
module answers, in one round-trip pair, the questions the UI needs to confirm:
|
|
6
|
+
|
|
7
|
+
1. **Reachable, and is it actually a LightRAG server?** ``GET /auth-status`` is
|
|
8
|
+
whitelisted on the server, so it answers without credentials; its LightRAG-
|
|
9
|
+
specific keys confirm we're talking to the right kind of server.
|
|
10
|
+
2. **Does it require an API key, and is ours accepted?** When auth is enabled we
|
|
11
|
+
validate the key against the read-only ``GET /documents/pipeline_status``.
|
|
12
|
+
|
|
13
|
+
Always returns a :class:`ServerProbe` (never raises); ``ok`` is the single
|
|
14
|
+
boolean the caller gates on, with ``error`` explaining any failure.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from .client import LightRagServerClient
|
|
23
|
+
from .config import LightRagServerConfig, normalize_base_url
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ServerProbe:
|
|
28
|
+
base_url: str
|
|
29
|
+
ok: bool = False
|
|
30
|
+
reachable: bool = False
|
|
31
|
+
auth_required: bool = False
|
|
32
|
+
auth_ok: bool = False
|
|
33
|
+
core_version: Optional[str] = None
|
|
34
|
+
api_version: Optional[str] = None
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def probe_server(
|
|
42
|
+
server_url: str,
|
|
43
|
+
api_key: str = "",
|
|
44
|
+
*,
|
|
45
|
+
client_factory=None,
|
|
46
|
+
) -> ServerProbe:
|
|
47
|
+
"""Inspect ``server_url`` for a reachable, authorised LightRAG server.
|
|
48
|
+
|
|
49
|
+
``client_factory`` (config → client) is an injection seam for tests; in
|
|
50
|
+
production it defaults to a real :class:`LightRagServerClient`.
|
|
51
|
+
"""
|
|
52
|
+
base_url = normalize_base_url(server_url)
|
|
53
|
+
probe = ServerProbe(base_url=base_url)
|
|
54
|
+
if not base_url:
|
|
55
|
+
probe.error = "Server URL is required."
|
|
56
|
+
return probe
|
|
57
|
+
if not (base_url.startswith("http://") or base_url.startswith("https://")):
|
|
58
|
+
probe.error = "Server URL must start with http:// or https://."
|
|
59
|
+
return probe
|
|
60
|
+
|
|
61
|
+
config = LightRagServerConfig(base_url=base_url, api_key=(api_key or "").strip())
|
|
62
|
+
client = client_factory(config) if client_factory else LightRagServerClient(config)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
status = await client.auth_status()
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
probe.error = f"Could not reach a LightRAG server at {base_url}: {exc}"
|
|
68
|
+
return probe
|
|
69
|
+
|
|
70
|
+
# ``auth_configured`` is LightRAG-specific; its absence means we reached
|
|
71
|
+
# something that isn't a LightRAG server.
|
|
72
|
+
if "auth_configured" not in status:
|
|
73
|
+
probe.error = f"{base_url} responded but does not look like a LightRAG server."
|
|
74
|
+
return probe
|
|
75
|
+
|
|
76
|
+
probe.reachable = True
|
|
77
|
+
probe.core_version = _opt_str(status.get("core_version"))
|
|
78
|
+
probe.api_version = _opt_str(status.get("api_version"))
|
|
79
|
+
probe.auth_required = bool(status.get("auth_configured"))
|
|
80
|
+
|
|
81
|
+
if not probe.auth_required:
|
|
82
|
+
# Open server — nothing to validate. A stray key is simply ignored.
|
|
83
|
+
probe.auth_ok = True
|
|
84
|
+
probe.ok = True
|
|
85
|
+
return probe
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
probe.auth_ok = await client.verify_key()
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
probe.error = f"Reached the server but could not validate the API key: {exc}"
|
|
91
|
+
return probe
|
|
92
|
+
|
|
93
|
+
if not probe.auth_ok:
|
|
94
|
+
probe.error = (
|
|
95
|
+
"This server requires an API key and the one provided was rejected."
|
|
96
|
+
if api_key
|
|
97
|
+
else "This server requires an API key. Provide one to connect."
|
|
98
|
+
)
|
|
99
|
+
return probe
|
|
100
|
+
|
|
101
|
+
probe.ok = True
|
|
102
|
+
return probe
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _opt_str(value: Any) -> Optional[str]:
|
|
106
|
+
text = str(value).strip() if value is not None else ""
|
|
107
|
+
return text or None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["ServerProbe", "probe_server"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Configuration helpers for DeepTutor's LlamaIndex RAG pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
VECTOR_PROFILE = "vector"
|
|
10
|
+
HYBRID_PROFILE = "hybrid"
|
|
11
|
+
SUPPORTED_RETRIEVAL_PROFILES = {VECTOR_PROFILE, HYBRID_PROFILE}
|
|
12
|
+
FLAT_VECTOR_INDEX = "flat"
|
|
13
|
+
HNSW_VECTOR_INDEX = "hnsw"
|
|
14
|
+
SUPPORTED_VECTOR_INDEX_TYPES = {FLAT_VECTOR_INDEX, HNSW_VECTOR_INDEX}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def should_show_progress() -> bool:
|
|
18
|
+
"""Whether to emit LlamaIndex ``tqdm`` progress bars.
|
|
19
|
+
|
|
20
|
+
tqdm writes carriage-return progress lines to ``sys.stdout``. When
|
|
21
|
+
DeepTutor runs as a server that stream is a pipe whose read end (the
|
|
22
|
+
launcher's relay thread) can close mid-indexing, and the next tqdm write
|
|
23
|
+
then raises :class:`BrokenPipeError`, killing document indexing. DeepTutor
|
|
24
|
+
reports indexing progress through its own ``ProgressTracker``, so the tqdm
|
|
25
|
+
output is only wanted in an interactive CLI/REPL session.
|
|
26
|
+
"""
|
|
27
|
+
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RetrievalConfig:
|
|
32
|
+
"""Runtime retrieval knobs for the LlamaIndex pipeline."""
|
|
33
|
+
|
|
34
|
+
profile: str = HYBRID_PROFILE
|
|
35
|
+
vector_top_k_multiplier: int = 2
|
|
36
|
+
bm25_top_k_multiplier: int = 2
|
|
37
|
+
fusion_num_queries: int = 1
|
|
38
|
+
reranker_model: str = ""
|
|
39
|
+
rerank_top_k: int = 50
|
|
40
|
+
|
|
41
|
+
def candidate_top_k(self, top_k: int, multiplier: int) -> int:
|
|
42
|
+
"""Return the number of candidates to ask a child retriever for."""
|
|
43
|
+
requested = max(1, int(top_k))
|
|
44
|
+
return max(requested, requested * max(1, int(multiplier)))
|
|
45
|
+
|
|
46
|
+
def rerank_candidate_top_k(self, top_k: int) -> int:
|
|
47
|
+
"""Return the first-stage candidate count before optional reranking."""
|
|
48
|
+
requested = max(1, int(top_k))
|
|
49
|
+
if not self.reranker_model:
|
|
50
|
+
return requested
|
|
51
|
+
return max(requested, min(100, max(1, int(self.rerank_top_k))))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class VectorIndexConfig:
|
|
56
|
+
"""FAISS construction knobs for the next full index build."""
|
|
57
|
+
|
|
58
|
+
type: str = FLAT_VECTOR_INDEX
|
|
59
|
+
hnsw_m: int = 32
|
|
60
|
+
hnsw_ef_construction: int = 200
|
|
61
|
+
hnsw_ef_search: int = 64
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_retrieval_profile(value: str | None) -> str:
|
|
65
|
+
"""Return a supported retrieval profile, defaulting to hybrid."""
|
|
66
|
+
profile = (value or "").strip().lower()
|
|
67
|
+
if profile in SUPPORTED_RETRIEVAL_PROFILES:
|
|
68
|
+
return profile
|
|
69
|
+
return HYBRID_PROFILE
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_reranker_model(value: str | None) -> str:
|
|
73
|
+
"""Return a bounded Hugging Face model identifier (empty disables rerank)."""
|
|
74
|
+
return (value or "").strip()[:200]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_vector_index_type(value: str | None) -> str:
|
|
78
|
+
"""Return a supported vector index type, defaulting to exact flat search."""
|
|
79
|
+
index_type = (value or "").strip().lower()
|
|
80
|
+
if index_type in SUPPORTED_VECTOR_INDEX_TYPES:
|
|
81
|
+
return index_type
|
|
82
|
+
return FLAT_VECTOR_INDEX
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def retrieval_config_from_env() -> RetrievalConfig:
|
|
86
|
+
"""Build retrieval config from environment variables.
|
|
87
|
+
|
|
88
|
+
The default is intentionally ``hybrid``. If the optional LlamaIndex BM25
|
|
89
|
+
integration is not installed, the retriever builder transparently falls
|
|
90
|
+
back to plain vector retrieval.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
return RetrievalConfig(
|
|
94
|
+
profile=normalize_retrieval_profile(
|
|
95
|
+
os.getenv("DEEPTUTOR_RAG_RETRIEVAL_PROFILE") or os.getenv("RAG_RETRIEVAL_PROFILE")
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_runtime_settings() -> dict:
|
|
101
|
+
"""Load the persisted LlamaIndex engine settings (env overrides applied)."""
|
|
102
|
+
from deeptutor.services.config import load_llamaindex_settings
|
|
103
|
+
|
|
104
|
+
return load_llamaindex_settings()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def retrieval_config_from_settings() -> RetrievalConfig:
|
|
108
|
+
"""Build retrieval config from persisted engine settings.
|
|
109
|
+
|
|
110
|
+
Falls back to defaults on any read error so retrieval never breaks because
|
|
111
|
+
of a malformed settings file. ``fusion_num_queries`` stays at the dataclass
|
|
112
|
+
default — query generation needs a real LLM, but the fusion retriever runs
|
|
113
|
+
on a MockLLM, so it is not user-tunable.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
settings = _load_runtime_settings()
|
|
117
|
+
except Exception:
|
|
118
|
+
return RetrievalConfig()
|
|
119
|
+
return RetrievalConfig(
|
|
120
|
+
profile=normalize_retrieval_profile(settings.get("retrieval_profile")),
|
|
121
|
+
vector_top_k_multiplier=int(settings.get("vector_top_k_multiplier", 2) or 2),
|
|
122
|
+
bm25_top_k_multiplier=int(settings.get("bm25_top_k_multiplier", 2) or 2),
|
|
123
|
+
reranker_model=normalize_reranker_model(settings.get("reranker_model")),
|
|
124
|
+
rerank_top_k=int(settings.get("rerank_top_k", 50) or 50),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def vector_index_config_from_settings() -> VectorIndexConfig:
|
|
129
|
+
"""Build FAISS construction settings, retaining the exact flat default."""
|
|
130
|
+
try:
|
|
131
|
+
settings = _load_runtime_settings()
|
|
132
|
+
except Exception:
|
|
133
|
+
return VectorIndexConfig()
|
|
134
|
+
return VectorIndexConfig(
|
|
135
|
+
type=normalize_vector_index_type(settings.get("vector_index_type")),
|
|
136
|
+
hnsw_m=int(settings.get("hnsw_m", 32) or 32),
|
|
137
|
+
hnsw_ef_construction=int(settings.get("hnsw_ef_construction", 200) or 200),
|
|
138
|
+
hnsw_ef_search=int(settings.get("hnsw_ef_search", 64) or 64),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def default_top_k() -> int:
|
|
143
|
+
"""The configured default number of chunks a retrieval returns."""
|
|
144
|
+
try:
|
|
145
|
+
return int(_load_runtime_settings().get("top_k", 5) or 5)
|
|
146
|
+
except Exception:
|
|
147
|
+
return 5
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def chunk_geometry() -> tuple[int, int]:
|
|
151
|
+
"""The configured ``(chunk_size, chunk_overlap)`` for indexing."""
|
|
152
|
+
try:
|
|
153
|
+
settings = _load_runtime_settings()
|
|
154
|
+
chunk_size = settings.get("chunk_size", 512)
|
|
155
|
+
chunk_overlap = settings.get("chunk_overlap", 50)
|
|
156
|
+
return int(chunk_size if chunk_size is not None else 512), int(
|
|
157
|
+
chunk_overlap if chunk_overlap is not None else 50
|
|
158
|
+
)
|
|
159
|
+
except Exception:
|
|
160
|
+
return 512, 50
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def image_description_batch_size() -> int:
|
|
164
|
+
"""Opt-in images per LlamaIndex caption request; one preserves legacy calls."""
|
|
165
|
+
try:
|
|
166
|
+
return min(8, max(1, int(_load_runtime_settings().get("image_description_batch_size", 1))))
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def image_description_limits() -> tuple[int, float]:
|
|
172
|
+
"""Return the configured vision-call concurrency and per-image timeout."""
|
|
173
|
+
try:
|
|
174
|
+
settings = _load_runtime_settings()
|
|
175
|
+
concurrency = int(settings.get("image_description_concurrency", 4) or 4)
|
|
176
|
+
timeout_seconds = float(settings.get("image_description_timeout_seconds", 60) or 60)
|
|
177
|
+
return min(16, max(1, concurrency)), min(600.0, max(5.0, timeout_seconds))
|
|
178
|
+
except Exception:
|
|
179
|
+
return 4, 60.0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"HYBRID_PROFILE",
|
|
184
|
+
"RetrievalConfig",
|
|
185
|
+
"SUPPORTED_RETRIEVAL_PROFILES",
|
|
186
|
+
"SUPPORTED_VECTOR_INDEX_TYPES",
|
|
187
|
+
"VECTOR_PROFILE",
|
|
188
|
+
"FLAT_VECTOR_INDEX",
|
|
189
|
+
"HNSW_VECTOR_INDEX",
|
|
190
|
+
"chunk_geometry",
|
|
191
|
+
"default_top_k",
|
|
192
|
+
"image_description_limits",
|
|
193
|
+
"image_description_batch_size",
|
|
194
|
+
"normalize_retrieval_profile",
|
|
195
|
+
"normalize_reranker_model",
|
|
196
|
+
"retrieval_config_from_env",
|
|
197
|
+
"retrieval_config_from_settings",
|
|
198
|
+
"vector_index_config_from_settings",
|
|
199
|
+
]
|