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,369 @@
|
|
|
1
|
+
"""Bounded client for kiwix-serve's public search and raw-content APIs.
|
|
2
|
+
|
|
3
|
+
One connected KB points to one ZIM name on one server. Search uses the ZIM's
|
|
4
|
+
existing full-text index; only matched articles are read, so no archive is
|
|
5
|
+
uploaded, unpacked, or indexed again by DeepTutor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from html import unescape
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
import re
|
|
14
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
from defusedxml import ElementTree
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
MAX_SEARCH_BYTES = 512_000
|
|
20
|
+
MAX_CATALOG_BYTES = 1_000_000
|
|
21
|
+
MAX_ARTICLE_BYTES = 2_000_000
|
|
22
|
+
MAX_ARTICLE_CHARS = 12_000
|
|
23
|
+
MAX_TITLE_BYTES = 32_000
|
|
24
|
+
_ZIM_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
25
|
+
_BLOCKS = frozenset(
|
|
26
|
+
{"article", "br", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "section", "tr"}
|
|
27
|
+
)
|
|
28
|
+
_SKIP = frozenset({"script", "style", "nav", "footer", "header", "noscript"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KiwixError(ValueError):
|
|
32
|
+
"""The configured server or selected archive cannot satisfy a request."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_base_url(value: str) -> str:
|
|
36
|
+
raw = str(value or "").strip()
|
|
37
|
+
if len(raw) > 2048 or any(ord(char) < 32 for char in raw):
|
|
38
|
+
raise KiwixError("Enter a valid kiwix-serve base URL.")
|
|
39
|
+
parsed = urlsplit(raw)
|
|
40
|
+
try:
|
|
41
|
+
port = parsed.port
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise KiwixError("Enter a valid kiwix-serve host and port.") from exc
|
|
44
|
+
if (
|
|
45
|
+
parsed.scheme not in {"http", "https"}
|
|
46
|
+
or not parsed.hostname
|
|
47
|
+
or (port is not None and not 1 <= port <= 65535)
|
|
48
|
+
or parsed.username
|
|
49
|
+
or parsed.password
|
|
50
|
+
or parsed.query
|
|
51
|
+
or parsed.fragment
|
|
52
|
+
or any(part in {".", ".."} for part in unquote(parsed.path).split("/"))
|
|
53
|
+
):
|
|
54
|
+
raise KiwixError(
|
|
55
|
+
"Enter a kiwix-serve HTTP(S) base URL without credentials or query parameters."
|
|
56
|
+
)
|
|
57
|
+
return parsed.geturl().rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_zim_name(value: str) -> str:
|
|
61
|
+
name = str(value or "").strip()
|
|
62
|
+
if not _ZIM_NAME.fullmatch(name):
|
|
63
|
+
raise KiwixError("Enter one ZIM name from the Kiwix library (letters, numbers, _, - or .).")
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_article_path(value: str) -> str:
|
|
68
|
+
path = unquote(str(value or "").strip()).lstrip("/")
|
|
69
|
+
if (
|
|
70
|
+
not path
|
|
71
|
+
or len(path) > 2048
|
|
72
|
+
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
73
|
+
or any(ord(char) < 32 for char in path)
|
|
74
|
+
or "\\" in path
|
|
75
|
+
):
|
|
76
|
+
raise KiwixError("Invalid ZIM article path.")
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _PlainText(HTMLParser):
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
super().__init__(convert_charrefs=True)
|
|
83
|
+
self.parts: list[str] = []
|
|
84
|
+
self.skip_depth = 0
|
|
85
|
+
|
|
86
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
87
|
+
if tag in _SKIP:
|
|
88
|
+
self.skip_depth += 1
|
|
89
|
+
elif not self.skip_depth and tag in _BLOCKS:
|
|
90
|
+
self.parts.append("\n")
|
|
91
|
+
|
|
92
|
+
def handle_endtag(self, tag: str) -> None:
|
|
93
|
+
if tag in _SKIP and self.skip_depth:
|
|
94
|
+
self.skip_depth -= 1
|
|
95
|
+
elif not self.skip_depth and tag in _BLOCKS:
|
|
96
|
+
self.parts.append("\n")
|
|
97
|
+
|
|
98
|
+
def handle_data(self, data: str) -> None:
|
|
99
|
+
if not self.skip_depth:
|
|
100
|
+
self.parts.append(data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def html_to_text(raw: str, *, max_chars: int = MAX_ARTICLE_CHARS) -> str:
|
|
104
|
+
parser = _PlainText()
|
|
105
|
+
parser.feed(raw)
|
|
106
|
+
text = unescape("".join(parser.parts))
|
|
107
|
+
return re.sub(
|
|
108
|
+
r"\n{3,}", "\n\n", "\n".join(" ".join(line.split()) for line in text.splitlines())
|
|
109
|
+
).strip()[:max_chars]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class KiwixHit:
|
|
114
|
+
title: str
|
|
115
|
+
article_path: str
|
|
116
|
+
snippet: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class KiwixArchive:
|
|
121
|
+
zim_name: str
|
|
122
|
+
title: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _local_name(tag: str) -> str:
|
|
126
|
+
return tag.rsplit("}", 1)[-1]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_catalog_xml(data: bytes, *, base_url: str) -> list[KiwixArchive]:
|
|
130
|
+
"""Read exact ZIM identifiers from a bounded legacy OPDS acquisition feed."""
|
|
131
|
+
try:
|
|
132
|
+
root = ElementTree.fromstring(data)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise KiwixError("kiwix-serve returned invalid OPDS catalog XML.") from exc
|
|
135
|
+
if _local_name(root.tag) != "feed":
|
|
136
|
+
raise KiwixError("kiwix-serve returned an unexpected OPDS catalog format.")
|
|
137
|
+
base = urlsplit(base_url)
|
|
138
|
+
prefix = f"{base.path.rstrip('/')}/content/"
|
|
139
|
+
archives: list[KiwixArchive] = []
|
|
140
|
+
seen: set[str] = set()
|
|
141
|
+
for entry in root:
|
|
142
|
+
if _local_name(entry.tag) != "entry":
|
|
143
|
+
continue
|
|
144
|
+
fields = {_local_name(child.tag): child for child in entry}
|
|
145
|
+
name = ""
|
|
146
|
+
# OPDS <name> may be a date-free alias. The same-server content link
|
|
147
|
+
# identifies the exact loaded archive/version selected by the user.
|
|
148
|
+
for link in entry:
|
|
149
|
+
if (
|
|
150
|
+
_local_name(link.tag) != "link"
|
|
151
|
+
or link.attrib.get("type", "").split(";", 1)[0].lower() != "text/html"
|
|
152
|
+
):
|
|
153
|
+
continue
|
|
154
|
+
url = urlsplit(link.attrib.get("href") or "")
|
|
155
|
+
if url.scheme and (url.scheme, url.netloc) != (base.scheme, base.netloc):
|
|
156
|
+
continue
|
|
157
|
+
if url.netloc and not url.scheme:
|
|
158
|
+
continue
|
|
159
|
+
if url.query or not url.path.startswith(prefix):
|
|
160
|
+
continue
|
|
161
|
+
name = unquote(url.path[len(prefix) :].split("/", 1)[0])
|
|
162
|
+
break
|
|
163
|
+
if not name:
|
|
164
|
+
name = (fields.get("name").text or "").strip() if fields.get("name") is not None else ""
|
|
165
|
+
try:
|
|
166
|
+
name = validate_zim_name(name)
|
|
167
|
+
except KiwixError:
|
|
168
|
+
continue
|
|
169
|
+
if name in seen:
|
|
170
|
+
continue
|
|
171
|
+
seen.add(name)
|
|
172
|
+
title = (
|
|
173
|
+
" ".join((fields.get("title").text or "").split())
|
|
174
|
+
if fields.get("title") is not None
|
|
175
|
+
else ""
|
|
176
|
+
)
|
|
177
|
+
archives.append(KiwixArchive(zim_name=name, title=title[:300] or name))
|
|
178
|
+
if len(archives) >= 50:
|
|
179
|
+
break
|
|
180
|
+
return archives
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_search_xml(data: bytes, *, base_url: str, zim_name: str) -> list[KiwixHit]:
|
|
184
|
+
"""Accept only RSS items linked to the selected archive on this server."""
|
|
185
|
+
try:
|
|
186
|
+
root = ElementTree.fromstring(data)
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
raise KiwixError("kiwix-serve returned invalid search XML.") from exc
|
|
189
|
+
if root.tag != "rss":
|
|
190
|
+
raise KiwixError("kiwix-serve returned an unexpected search format.")
|
|
191
|
+
base = urlsplit(base_url)
|
|
192
|
+
prefix = f"{base.path.rstrip('/')}/content/{quote(zim_name, safe='')}/"
|
|
193
|
+
hits: list[KiwixHit] = []
|
|
194
|
+
for item in root.findall("./channel/item"):
|
|
195
|
+
link = (item.findtext("link") or "").strip()
|
|
196
|
+
parsed = urlsplit(link)
|
|
197
|
+
if parsed.scheme and (parsed.scheme, parsed.netloc) != (base.scheme, base.netloc):
|
|
198
|
+
continue
|
|
199
|
+
if parsed.query or not parsed.path.startswith(prefix):
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
article_path = validate_article_path(parsed.path[len(prefix) :])
|
|
203
|
+
except KiwixError:
|
|
204
|
+
continue
|
|
205
|
+
title = " ".join((item.findtext("title") or "").split())[:300]
|
|
206
|
+
description = item.find("description")
|
|
207
|
+
snippet = (
|
|
208
|
+
" ".join("".join(description.itertext()).split())[:1000]
|
|
209
|
+
if description is not None
|
|
210
|
+
else ""
|
|
211
|
+
)
|
|
212
|
+
hits.append(
|
|
213
|
+
KiwixHit(title=title or article_path, article_path=article_path, snippet=snippet)
|
|
214
|
+
)
|
|
215
|
+
return hits
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class KiwixClient:
|
|
219
|
+
def __init__(
|
|
220
|
+
self, base_url: str, zim_name: str, *, transport: httpx.AsyncBaseTransport | None = None
|
|
221
|
+
) -> None:
|
|
222
|
+
self.base_url = normalize_base_url(base_url)
|
|
223
|
+
self.zim_name = validate_zim_name(zim_name)
|
|
224
|
+
self.transport = transport
|
|
225
|
+
|
|
226
|
+
def _client(self) -> httpx.AsyncClient:
|
|
227
|
+
return httpx.AsyncClient(
|
|
228
|
+
transport=self.transport,
|
|
229
|
+
timeout=httpx.Timeout(10.0),
|
|
230
|
+
follow_redirects=False,
|
|
231
|
+
trust_env=False,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
async def list_archives(
|
|
236
|
+
cls, base_url: str, query: str = "", *, transport: httpx.AsyncBaseTransport | None = None
|
|
237
|
+
) -> list[KiwixArchive]:
|
|
238
|
+
"""Browse a server's loaded books via legacy OPDS, whose v2 feed can omit them."""
|
|
239
|
+
client = cls(base_url, "catalog", transport=transport)
|
|
240
|
+
params: dict[str, str | int] = {"count": 50, "start": 0}
|
|
241
|
+
if query.strip():
|
|
242
|
+
params["q"] = query.strip()[:100]
|
|
243
|
+
async with client._client() as http:
|
|
244
|
+
raw, _ = await client._get(
|
|
245
|
+
http, "catalog/search", params=params, limit=MAX_CATALOG_BYTES
|
|
246
|
+
)
|
|
247
|
+
return parse_catalog_xml(raw, base_url=client.base_url)
|
|
248
|
+
|
|
249
|
+
async def _get(
|
|
250
|
+
self,
|
|
251
|
+
client: httpx.AsyncClient,
|
|
252
|
+
path: str,
|
|
253
|
+
*,
|
|
254
|
+
params: dict[str, str | int] | None = None,
|
|
255
|
+
limit: int,
|
|
256
|
+
) -> tuple[bytes, str]:
|
|
257
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
258
|
+
try:
|
|
259
|
+
async with client.stream("GET", url, params=params) as response:
|
|
260
|
+
if response.status_code != 200:
|
|
261
|
+
raise KiwixError(f"kiwix-serve returned HTTP {response.status_code}.")
|
|
262
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
|
263
|
+
body = bytearray()
|
|
264
|
+
async for chunk in response.aiter_bytes():
|
|
265
|
+
body.extend(chunk)
|
|
266
|
+
if len(body) > limit:
|
|
267
|
+
raise KiwixError("kiwix-serve response exceeded the article size limit.")
|
|
268
|
+
return bytes(body), content_type
|
|
269
|
+
except httpx.HTTPError as exc:
|
|
270
|
+
raise KiwixError("Could not reach kiwix-serve.") from exc
|
|
271
|
+
|
|
272
|
+
async def probe(self) -> str:
|
|
273
|
+
"""Check metadata and a real one-result search without scanning the ZIM."""
|
|
274
|
+
async with self._client() as client:
|
|
275
|
+
raw, _ = await self._get(
|
|
276
|
+
client,
|
|
277
|
+
f"raw/{quote(self.zim_name, safe='')}/meta/Title",
|
|
278
|
+
limit=MAX_TITLE_BYTES,
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
xml, _ = await self._get(
|
|
282
|
+
client,
|
|
283
|
+
"search",
|
|
284
|
+
params={
|
|
285
|
+
"books.name": self.zim_name,
|
|
286
|
+
"pattern": "a",
|
|
287
|
+
"format": "xml",
|
|
288
|
+
"pageLength": 1,
|
|
289
|
+
},
|
|
290
|
+
limit=MAX_SEARCH_BYTES,
|
|
291
|
+
)
|
|
292
|
+
parse_search_xml(xml, base_url=self.base_url, zim_name=self.zim_name)
|
|
293
|
+
except KiwixError as exc:
|
|
294
|
+
raise KiwixError(
|
|
295
|
+
"The archive is readable, but kiwix-serve full-text search is unavailable. "
|
|
296
|
+
"Book and Guided Learning need a searchable ZIM."
|
|
297
|
+
) from exc
|
|
298
|
+
title = raw.decode("utf-8", errors="replace").strip()
|
|
299
|
+
if not title:
|
|
300
|
+
raise KiwixError("The selected ZIM archive has no readable title metadata.")
|
|
301
|
+
return title[:300]
|
|
302
|
+
|
|
303
|
+
async def search(self, query: str, *, top_k: int = 5) -> list[tuple[KiwixHit, str]]:
|
|
304
|
+
count = max(1, min(int(top_k), 10))
|
|
305
|
+
async with self._client() as client:
|
|
306
|
+
xml, _ = await self._get(
|
|
307
|
+
client,
|
|
308
|
+
"search",
|
|
309
|
+
params={
|
|
310
|
+
"books.name": self.zim_name,
|
|
311
|
+
"pattern": query[:500],
|
|
312
|
+
"format": "xml",
|
|
313
|
+
"pageLength": count,
|
|
314
|
+
},
|
|
315
|
+
limit=MAX_SEARCH_BYTES,
|
|
316
|
+
)
|
|
317
|
+
hits = parse_search_xml(xml, base_url=self.base_url, zim_name=self.zim_name)[:count]
|
|
318
|
+
# Hydrate only a few hits. The Kiwix search snippet remains useful
|
|
319
|
+
# evidence for the rest, and an unreadable title alone is never cited.
|
|
320
|
+
result: list[tuple[KiwixHit, str]] = []
|
|
321
|
+
for hit in hits:
|
|
322
|
+
text = ""
|
|
323
|
+
if len(result) < 4:
|
|
324
|
+
try:
|
|
325
|
+
text = await self._read_article(client, hit.article_path)
|
|
326
|
+
except KiwixError:
|
|
327
|
+
pass
|
|
328
|
+
if text or hit.snippet:
|
|
329
|
+
# The first article characters can be unrelated to a hit
|
|
330
|
+
# thousands of words later. Book clips source text to 1200
|
|
331
|
+
# chars, so lead with Kiwix's matched search passage.
|
|
332
|
+
context = (
|
|
333
|
+
f"Search match: {hit.snippet}\n\nArticle opening: {text}"
|
|
334
|
+
if hit.snippet and text
|
|
335
|
+
else f"Search match: {hit.snippet}"
|
|
336
|
+
if hit.snippet
|
|
337
|
+
else text
|
|
338
|
+
)
|
|
339
|
+
result.append((hit, context))
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
async def _read_article(
|
|
343
|
+
self,
|
|
344
|
+
client: httpx.AsyncClient,
|
|
345
|
+
article_path: str,
|
|
346
|
+
*,
|
|
347
|
+
max_chars: int = MAX_ARTICLE_CHARS,
|
|
348
|
+
) -> str:
|
|
349
|
+
path = validate_article_path(article_path)
|
|
350
|
+
raw, mime = await self._get(
|
|
351
|
+
client,
|
|
352
|
+
f"raw/{quote(self.zim_name, safe='')}/content/{quote(path, safe='/')}",
|
|
353
|
+
limit=MAX_ARTICLE_BYTES,
|
|
354
|
+
)
|
|
355
|
+
if mime not in {"text/html", "application/xhtml+xml", "text/plain"}:
|
|
356
|
+
raise KiwixError("The selected ZIM entry is not a readable article.")
|
|
357
|
+
decoded = raw.decode("utf-8", errors="replace")
|
|
358
|
+
return (
|
|
359
|
+
html_to_text(decoded, max_chars=max_chars)
|
|
360
|
+
if mime != "text/plain"
|
|
361
|
+
else decoded.strip()[:max_chars]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
async def read_article(self, article_path: str) -> str:
|
|
365
|
+
async with self._client() as client:
|
|
366
|
+
text = await self._read_article(client, article_path, max_chars=500_000)
|
|
367
|
+
if not text:
|
|
368
|
+
raise KiwixError("The selected ZIM article has no readable text.")
|
|
369
|
+
return text
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Retrieval-only RAG pipeline over an existing kiwix-serve ZIM archive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from deeptutor.runtime.home import get_runtime_data_root
|
|
8
|
+
from deeptutor.services.rag.provider_binding import load_kb_config_entry
|
|
9
|
+
|
|
10
|
+
from .client import KiwixClient, KiwixError
|
|
11
|
+
|
|
12
|
+
PROVIDER = "kiwix"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KiwixPipeline:
|
|
16
|
+
def __init__(self, kb_base_dir: str | None = None, *, client_factory=None, **_: Any) -> None:
|
|
17
|
+
self.kb_base_dir = kb_base_dir or str(get_runtime_data_root() / "knowledge_bases")
|
|
18
|
+
self._client_factory = client_factory or KiwixClient
|
|
19
|
+
|
|
20
|
+
async def search(self, query: str, kb_name: str, **kwargs: Any) -> dict[str, Any]:
|
|
21
|
+
entry = load_kb_config_entry(self.kb_base_dir, kb_name)
|
|
22
|
+
if entry.get("type") != PROVIDER:
|
|
23
|
+
return self._error(
|
|
24
|
+
query, "This knowledge base is not connected to Kiwix.", "not_configured"
|
|
25
|
+
)
|
|
26
|
+
try:
|
|
27
|
+
client = self._client_factory(entry["server_url"], entry["zim_name"])
|
|
28
|
+
hits = await client.search(query, top_k=kwargs.get("top_k") or 5)
|
|
29
|
+
except (KiwixError, KeyError, TypeError, ValueError) as exc:
|
|
30
|
+
return self._error(query, str(exc), "retrieval_error")
|
|
31
|
+
sources = [
|
|
32
|
+
{
|
|
33
|
+
"title": hit.title,
|
|
34
|
+
"content": content,
|
|
35
|
+
"source": f"{entry.get('zim_title') or entry['zim_name']} / {hit.title}",
|
|
36
|
+
"id": f"{entry['zim_name']}:{hit.article_path}",
|
|
37
|
+
"chunk_id": f"{entry['zim_name']}:{hit.article_path}",
|
|
38
|
+
"article_path": hit.article_path,
|
|
39
|
+
"archive": entry["zim_name"],
|
|
40
|
+
}
|
|
41
|
+
for hit, content in hits
|
|
42
|
+
if content.strip()
|
|
43
|
+
]
|
|
44
|
+
context = "\n\n".join(
|
|
45
|
+
f"[{index}] {source['source']}\n{source['content']}"
|
|
46
|
+
for index, source in enumerate(sources, start=1)
|
|
47
|
+
)
|
|
48
|
+
return {
|
|
49
|
+
"query": query,
|
|
50
|
+
"answer": context,
|
|
51
|
+
"content": context,
|
|
52
|
+
"sources": sources,
|
|
53
|
+
"provider": PROVIDER,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _error(query: str, message: str, error_type: str) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"query": query,
|
|
60
|
+
"answer": message,
|
|
61
|
+
"content": "",
|
|
62
|
+
"sources": [],
|
|
63
|
+
"provider": PROVIDER,
|
|
64
|
+
"error_type": error_type,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async def initialize(self, kb_name: str, file_paths: list[str], **kwargs: Any) -> bool:
|
|
68
|
+
raise RuntimeError("Kiwix owns this ZIM archive and its search index. Add content there.")
|
|
69
|
+
|
|
70
|
+
async def add_documents(self, kb_name: str, file_paths: list[str], **kwargs: Any) -> bool:
|
|
71
|
+
return await self.initialize(kb_name, file_paths, **kwargs)
|
|
72
|
+
|
|
73
|
+
async def delete(self, kb_name: str, **kwargs: Any) -> bool:
|
|
74
|
+
# The manager deletes only its pointer; never delete a user's archive.
|
|
75
|
+
return True
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# LightRAG role models
|
|
2
|
+
|
|
3
|
+
In Knowledge Center, open the native LightRAG engine and choose a base model.
|
|
4
|
+
The saved base is independent of the chat model and the embedding model.
|
|
5
|
+
Shared engine settings require administrator access when authentication is enabled.
|
|
6
|
+
|
|
7
|
+
| Role | Used for | Available sources |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| EXTRACT | Entity/relation extraction, structured tables and equations | Base or explicit model |
|
|
10
|
+
| KEYWORD | Query keyword extraction | Base or explicit model |
|
|
11
|
+
| QUERY | Answer generation | Base or explicit model |
|
|
12
|
+
| VLM | Requested image analysis | Disabled, base or explicit vision model |
|
|
13
|
+
|
|
14
|
+
Fresh settings are distinguished from historical settings. Choose and save a
|
|
15
|
+
LightRAG base in Settings before querying or creating a knowledge base.
|
|
16
|
+
New role configurations disable VLM by default. Inheriting the base for VLM
|
|
17
|
+
requires a model that supports image inputs. Disabling VLM preserves text,
|
|
18
|
+
table and equation processing. An explicit image-analysis request on a disabled
|
|
19
|
+
index fails with full-rebuild guidance.
|
|
20
|
+
|
|
21
|
+
Each enabled role can override supported reasoning effort. `Auto` leaves reasoning
|
|
22
|
+
controls to the provider; `none` explicitly disables reasoning where supported.
|
|
23
|
+
An inherited role follows the base reasoning unless it has its own override.
|
|
24
|
+
Restoring an older pinned index retains its recorded reasoning semantics.
|
|
25
|
+
Unsupported choices are rejected without substituting another model or reasoning
|
|
26
|
+
level. Concurrency and timeout limits apply per role to
|
|
27
|
+
subsequent tasks and do not require rebuilding. Accepted tasks keep their effective
|
|
28
|
+
models, reasoning and limits while queued and running.
|
|
29
|
+
|
|
30
|
+
DeepTutor forwards LightRAG structured-output requests, including the JSON
|
|
31
|
+
object contracts used by EXTRACT, KEYWORD and VLM, through its provider
|
|
32
|
+
capability filter. Provider reasoning is controlled by the saved role effort.
|
|
33
|
+
DeepTutor intentionally omits provider reasoning traces from the returned answer
|
|
34
|
+
even when LightRAG requests its COT output format.
|
|
35
|
+
|
|
36
|
+
## Creating, appending and rebuilding
|
|
37
|
+
|
|
38
|
+
Configure role models in Settings. Creation and full rebuild do not offer per-index
|
|
39
|
+
role overrides, but retain per-knowledge-base embedding selection. An idle,
|
|
40
|
+
unpublished empty knowledge base follows current valid role defaults until its
|
|
41
|
+
first accepted indexing task freezes the configuration, retaining its embedding binding.
|
|
42
|
+
|
|
43
|
+
Before rebuilding, review the selected embedding model/dimension and current default
|
|
44
|
+
EXTRACT/VLM models and reasoning. The existing embedding binding is retained unless
|
|
45
|
+
another model is selected. If the selected configuration changes before submission, review
|
|
46
|
+
and confirm the refreshed configuration. Accepted tasks retain the same embedding
|
|
47
|
+
and role settings through execution and version publication. Failed-task retry
|
|
48
|
+
actions also use this confirmation flow.
|
|
49
|
+
|
|
50
|
+
After publication, uploads and folder/GitHub sync use the index's pinned EXTRACT
|
|
51
|
+
and VLM identities. Changing engine defaults does not change an existing index.
|
|
52
|
+
Enabling, disabling or replacing VLM, or replacing EXTRACT or their reasoning
|
|
53
|
+
configuration, requires a full rebuild. If a valid VLM was pinned before the first
|
|
54
|
+
image, later image uploads can use it without another rebuild.
|
|
55
|
+
|
|
56
|
+
Index versions show their version identifier, state, timestamp and actual embedding,
|
|
57
|
+
EXTRACT and VLM configuration. The display distinguishes provider-default reasoning,
|
|
58
|
+
explicit `none` and disabled VLM. Returning from Settings refreshes default-role
|
|
59
|
+
summaries. Unavailable pinned indexing models require restoring access or rebuilding
|
|
60
|
+
with new defaults, but do not unconditionally block existing text retrieval.
|
|
61
|
+
A failed or cancelled rebuild keeps the previous published index. A queued
|
|
62
|
+
operation whose target changed fails before writing;
|
|
63
|
+
resubmit it against the current index. Concurrent writers for one knowledge base
|
|
64
|
+
are rejected, so wait for the active operation to finish before resubmitting.
|
|
65
|
+
|
|
66
|
+
## Existing settings and indexes
|
|
67
|
+
|
|
68
|
+
Legacy engine settings are prefilled from an accessible existing selection.
|
|
69
|
+
Saving detaches the LightRAG base from chat. Missing or inaccessible selections
|
|
70
|
+
must be replaced explicitly.
|
|
71
|
+
|
|
72
|
+
Verifiable legacy single-model indexes retain their original model fingerprint
|
|
73
|
+
and recorded vision state without a format-only rebuild. Unknown historical
|
|
74
|
+
identity remains queryable but blocks incremental writes until a full rebuild.
|
|
75
|
+
Malformed policies and models that have been deleted, changed identity or lost
|
|
76
|
+
access cannot silently acquire current defaults. Credential rotation alone does
|
|
77
|
+
not change the pinned identity; credentials are resolved privately for execution
|
|
78
|
+
and are excluded from persisted index policy and public provenance.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Native LightRAG knowledge-base engine.
|
|
2
|
+
|
|
3
|
+
A graph-based RAG provider built directly on HKUDS/LightRAG. It consumes
|
|
4
|
+
DeepTutor's shared parse layer for document parsing and exposes LightRAG's
|
|
5
|
+
native query modes (naive/local/global/hybrid/mix) through the per-KB
|
|
6
|
+
``search_mode``.
|
|
7
|
+
|
|
8
|
+
Modules:
|
|
9
|
+
|
|
10
|
+
* ``block_policy`` — versioned MinerU block classification before indexing.
|
|
11
|
+
* ``config`` — availability + mode helpers + the LLM/vision/embedding adapters.
|
|
12
|
+
* ``storage`` — per-KB version-dir layout + readiness marker.
|
|
13
|
+
* ``engine`` — constructs the exact supported LightRAG SDK version.
|
|
14
|
+
* ``pipeline`` — :class:`LightRagPipeline` implementing the ``RAGPipeline`` contract.
|
|
15
|
+
"""
|