deeptutor-knowledge 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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
+ """