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,133 @@
1
+ """On-disk manifest for a PageIndex-backed knowledge base.
2
+
3
+ PageIndex has no embeddings, so there is nothing to vectorise locally. The only
4
+ local state is a lightweight manifest mapping each ingested file to its hosted
5
+ ``doc_id``. It is written into the KB's flat ``version-N`` directory (reusing
6
+ ``index_versioning`` with a ``None`` signature) so the Index-versions UI and the
7
+ "is this KB initialised?" checks see a ready version just like LlamaIndex KBs —
8
+ only the file contents differ (a doc-id map instead of a vector store).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timezone
14
+ import json
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from deeptutor.services.file_io import atomic_write_json
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ MANIFEST_FILENAME = "pageindex_docs.json"
24
+ META_FILENAME = "meta.json"
25
+ SDK_STORAGE_DIRNAME = "pageindex"
26
+ CLOUD_PROVIDER = "pageindex"
27
+ OSS_PROVIDER = "pageindex-oss"
28
+
29
+
30
+ def _empty_manifest(provider: str = CLOUD_PROVIDER) -> dict[str, Any]:
31
+ return {"provider": provider, "docs": {}}
32
+
33
+
34
+ def manifest_path(storage_dir: Path) -> Path:
35
+ return Path(storage_dir) / MANIFEST_FILENAME
36
+
37
+
38
+ def read_manifest(
39
+ storage_dir: Path | None,
40
+ *,
41
+ provider: str = CLOUD_PROVIDER,
42
+ ) -> dict[str, Any]:
43
+ if storage_dir is None:
44
+ return _empty_manifest(provider)
45
+ path = manifest_path(storage_dir)
46
+ if not path.exists():
47
+ return _empty_manifest(provider)
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ except Exception as exc:
51
+ logger.warning("Failed to read PageIndex manifest %s: %s", path, exc)
52
+ return _empty_manifest(provider)
53
+ if not isinstance(data, dict):
54
+ return _empty_manifest(provider)
55
+ data.setdefault("provider", provider)
56
+ if not isinstance(data.get("docs"), dict):
57
+ data["docs"] = {}
58
+ return data
59
+
60
+
61
+ def write_manifest(storage_dir: Path, manifest: dict[str, Any]) -> None:
62
+ atomic_write_json(manifest_path(storage_dir), manifest)
63
+
64
+
65
+ def write_meta(storage_dir: Path, *, provider: str = CLOUD_PROVIDER) -> None:
66
+ """Write a flat-layout ``meta.json`` so the version is listed as ready.
67
+
68
+ Mirrors ``index_versioning.write_version_meta`` but carries a synthetic
69
+ ``pageindex`` signature instead of an embedding hash.
70
+ """
71
+ target = Path(storage_dir)
72
+ payload = {
73
+ "version": target.name,
74
+ "signature": provider,
75
+ "provider": provider,
76
+ "layout": "flat",
77
+ "created_at": datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
78
+ }
79
+ atomic_write_json(target / META_FILENAME, payload)
80
+
81
+
82
+ def doc_entries(manifest: dict[str, Any]) -> dict[str, Any]:
83
+ docs = manifest.get("docs")
84
+ return docs if isinstance(docs, dict) else {}
85
+
86
+
87
+ def doc_ids(manifest: dict[str, Any]) -> list[str]:
88
+ return [
89
+ str(entry["doc_id"])
90
+ for entry in doc_entries(manifest).values()
91
+ if isinstance(entry, dict) and entry.get("doc_id")
92
+ ]
93
+
94
+
95
+ def upsert_doc(
96
+ manifest: dict[str, Any],
97
+ file_name: str,
98
+ doc_id: str,
99
+ *,
100
+ size: int | None = None,
101
+ ) -> None:
102
+ docs = manifest.setdefault("docs", {})
103
+ docs[file_name] = {
104
+ "doc_id": doc_id,
105
+ "size": size,
106
+ "submitted_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
107
+ }
108
+
109
+
110
+ def remove_doc(manifest: dict[str, Any], file_name: str) -> dict[str, Any] | None:
111
+ entry = doc_entries(manifest).pop(file_name, None)
112
+ return entry if isinstance(entry, dict) else None
113
+
114
+
115
+ def sdk_storage_path(storage_dir: Path) -> Path:
116
+ return Path(storage_dir) / SDK_STORAGE_DIRNAME
117
+
118
+
119
+ __all__ = [
120
+ "MANIFEST_FILENAME",
121
+ "CLOUD_PROVIDER",
122
+ "OSS_PROVIDER",
123
+ "SDK_STORAGE_DIRNAME",
124
+ "manifest_path",
125
+ "read_manifest",
126
+ "write_manifest",
127
+ "write_meta",
128
+ "doc_entries",
129
+ "doc_ids",
130
+ "upsert_doc",
131
+ "remove_doc",
132
+ "sdk_storage_path",
133
+ ]
@@ -0,0 +1,221 @@
1
+ """Turn-scoped DeepTutor wrappers for PageIndex Cloud and OSS SDK tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ import json
9
+ from typing import Any
10
+
11
+ from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolLookup, ToolResult
12
+ from deeptutor.runtime.registry.scoped_registry import ScopedToolRegistry
13
+
14
+ from .pipeline import PageIndexPipeline
15
+ from .storage import CLOUD_PROVIDER, OSS_PROVIDER
16
+
17
+ TOOL_PREFIXES = {
18
+ CLOUD_PROVIDER: "pageindex_cloud_",
19
+ OSS_PROVIDER: "pageindex_oss_",
20
+ }
21
+
22
+
23
+ def pageindex_sources_from_text(
24
+ text: str,
25
+ *,
26
+ provider: str,
27
+ kb_name: str = "",
28
+ doc_ids: dict[str, str] | None = None,
29
+ ) -> list[dict[str, Any]]:
30
+ """Normalize real PageIndex page reads into DeepTutor source rows."""
31
+ try:
32
+ payload = json.loads(text)
33
+ except (TypeError, ValueError):
34
+ return []
35
+ if not isinstance(payload, dict) or payload.get("success") is False or payload.get("errorCode"):
36
+ return []
37
+ document_name = str(payload.get("doc_name") or "").strip()
38
+ content = payload.get("content")
39
+ if not document_name or not isinstance(content, list):
40
+ return []
41
+
42
+ rows: list[dict[str, Any]] = []
43
+ seen: set[tuple[str, str]] = set()
44
+ for item in content:
45
+ if not isinstance(item, dict) or item.get("page") in (None, ""):
46
+ continue
47
+ page = item["page"]
48
+ block_id = str(item.get("block_id") or "").strip()
49
+ key = (str(page), block_id)
50
+ if key in seen:
51
+ continue
52
+ seen.add(key)
53
+ row: dict[str, Any] = {
54
+ "type": "pageindex",
55
+ "provider": provider,
56
+ "document_name": document_name,
57
+ "source": document_name,
58
+ "page": page,
59
+ }
60
+ if kb_name:
61
+ row["kb_name"] = kb_name
62
+ if doc_ids and document_name in doc_ids:
63
+ row["doc_id"] = doc_ids[document_name]
64
+ if block_id:
65
+ row["block_id"] = block_id
66
+ rows.append(row)
67
+ return rows
68
+
69
+
70
+ class PageIndexSDKTool(BaseTool):
71
+ deferred = True
72
+ provider_kind = "pageindex"
73
+
74
+ def __init__(
75
+ self,
76
+ sdk_tool: Any,
77
+ *,
78
+ provider: str,
79
+ kb_name: str,
80
+ doc_ids: dict[str, str],
81
+ ) -> None:
82
+ self._sdk_tool = sdk_tool
83
+ self.provider_id = provider
84
+ self._kb_name = kb_name
85
+ self._doc_ids = doc_ids
86
+
87
+ def get_definition(self) -> ToolDefinition:
88
+ label = "Cloud" if self.provider_id == CLOUD_PROVIDER else "OSS"
89
+ return ToolDefinition(
90
+ name=f"{TOOL_PREFIXES[self.provider_id]}{self._sdk_tool.name}",
91
+ description=f"[PageIndex {label}: {self._kb_name}] {self._sdk_tool.description}",
92
+ raw_parameters=dict(self._sdk_tool.params_json_schema),
93
+ )
94
+
95
+ async def execute(self, **kwargs: Any) -> ToolResult:
96
+ kwargs.pop("event_sink", None)
97
+ text = await self._sdk_tool.on_invoke_tool(
98
+ None,
99
+ json.dumps(kwargs, ensure_ascii=False),
100
+ )
101
+ try:
102
+ payload = json.loads(text)
103
+ except (TypeError, ValueError):
104
+ payload = {}
105
+ success = not (
106
+ isinstance(payload, dict)
107
+ and (payload.get("success") is False or payload.get("errorCode"))
108
+ )
109
+ sources = pageindex_sources_from_text(
110
+ str(text),
111
+ provider=self.provider_id,
112
+ kb_name=self._kb_name,
113
+ doc_ids=self._doc_ids,
114
+ )
115
+ return ToolResult(
116
+ content=str(text),
117
+ sources=sources,
118
+ metadata={
119
+ "pageindex_provider": self.provider_id,
120
+ "kb_name": self._kb_name,
121
+ "sources": sources,
122
+ },
123
+ success=success,
124
+ )
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class PageIndexSDKToolBundle:
129
+ provider: str
130
+ tools: tuple[PageIndexSDKTool, ...]
131
+ instructions: str
132
+ documents: dict[str, str]
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class PageIndexToolContext:
137
+ provider: str
138
+ registry: ToolLookup
139
+ tools: tuple[BaseTool, ...]
140
+ instructions: str
141
+ documents: dict[str, str]
142
+
143
+
144
+ @lru_cache(maxsize=1)
145
+ def _cloud_read_tools(client: Any) -> tuple[Any, ...]:
146
+ """Cache live Cloud schemas on the SDK client that owns their MCP session."""
147
+ return tuple(client.as_openai_tools(include_management=False))
148
+
149
+
150
+ async def build_sdk_tool_bundle(
151
+ kb_name: str,
152
+ kb_base_dir: str,
153
+ *,
154
+ provider: str,
155
+ ) -> PageIndexSDKToolBundle:
156
+ """Build one KB's read-only SDK tools without exposing their transport."""
157
+
158
+ def build() -> PageIndexSDKToolBundle:
159
+ pipeline = PageIndexPipeline(kb_base_dir=kb_base_dir, provider=provider)
160
+ documents = pipeline.document_map(kb_name)
161
+ client = pipeline.sdk_client_for_read(kb_name)
162
+ sdk_tools = (
163
+ client.as_openai_tools(include_management=False)
164
+ if provider == OSS_PROVIDER
165
+ else _cloud_read_tools(client)
166
+ )
167
+ instructions = client.agent_instructions()
168
+ return PageIndexSDKToolBundle(
169
+ provider=provider,
170
+ tools=tuple(
171
+ PageIndexSDKTool(
172
+ tool,
173
+ provider=provider,
174
+ kb_name=kb_name,
175
+ doc_ids=documents,
176
+ )
177
+ for tool in sdk_tools
178
+ ),
179
+ instructions=str(instructions or ""),
180
+ documents=documents,
181
+ )
182
+
183
+ return await asyncio.to_thread(build)
184
+
185
+
186
+ async def build_pageindex_tool_context(
187
+ kb_name: str | None,
188
+ *,
189
+ base_registry: ToolLookup,
190
+ ) -> PageIndexToolContext | None:
191
+ """Resolve one PageIndex KB into tools for an existing workflow loop."""
192
+ if not kb_name:
193
+ return None
194
+ from deeptutor.multi_user.knowledge_access import resolve_kb
195
+ from deeptutor.services.rag.provider_binding import resolve_bound_provider
196
+
197
+ resource = resolve_kb(kb_name, require_write=False)
198
+ base_dir = str(resource.base_dir)
199
+ provider = resolve_bound_provider(base_dir, resource.name)
200
+ if provider in {CLOUD_PROVIDER, OSS_PROVIDER}:
201
+ bundle = await build_sdk_tool_bundle(resource.name, base_dir, provider=provider)
202
+ registry = ScopedToolRegistry(base=base_registry, overlay=bundle.tools)
203
+ return PageIndexToolContext(
204
+ provider=provider,
205
+ registry=registry,
206
+ tools=bundle.tools,
207
+ instructions=bundle.instructions,
208
+ documents=bundle.documents,
209
+ )
210
+ return None
211
+
212
+
213
+ __all__ = [
214
+ "PageIndexSDKTool",
215
+ "PageIndexSDKToolBundle",
216
+ "PageIndexToolContext",
217
+ "TOOL_PREFIXES",
218
+ "build_sdk_tool_bundle",
219
+ "build_pageindex_tool_context",
220
+ "pageindex_sources_from_text",
221
+ ]
@@ -0,0 +1,13 @@
1
+ """Retrieval-only integration with an external Tencent WeKnora server."""
2
+
3
+ from .client import WeKnoraAPIError, WeKnoraClient
4
+ from .config import WeKnoraConfig, config_from_entry
5
+ from .pipeline import WeKnoraPipeline
6
+
7
+ __all__ = [
8
+ "WeKnoraAPIError",
9
+ "WeKnoraClient",
10
+ "WeKnoraConfig",
11
+ "WeKnoraPipeline",
12
+ "config_from_entry",
13
+ ]
@@ -0,0 +1,106 @@
1
+ """Thin HTTP client for Tencent WeKnora's documented REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+ from .config import WeKnoraConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # An external service is not allowed to make the DeepTutor process buffer an
16
+ # unbounded body. Four MiB is ample for a page of KB summaries or retrieval
17
+ # chunks while keeping a compromised/misconfigured server cheap to reject.
18
+ MAX_RESPONSE_BYTES = 4 * 1024 * 1024
19
+
20
+
21
+ class WeKnoraAPIError(RuntimeError):
22
+ """Raised when WeKnora returns an error or unexpected payload."""
23
+
24
+
25
+ class WeKnoraClient:
26
+ def __init__(
27
+ self,
28
+ config: WeKnoraConfig,
29
+ *,
30
+ timeout: float = 60.0,
31
+ transport: Optional[httpx.AsyncBaseTransport] = None,
32
+ ) -> None:
33
+ self._config = config
34
+ self._timeout = timeout
35
+ self._transport = transport
36
+
37
+ def _open(self) -> httpx.AsyncClient:
38
+ return httpx.AsyncClient(
39
+ base_url=self._config.base_url,
40
+ headers={
41
+ "Accept": "application/json",
42
+ "X-API-Key": self._config.api_key,
43
+ },
44
+ timeout=self._timeout,
45
+ transport=self._transport,
46
+ )
47
+
48
+ async def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
49
+ # Re-resolve on every request. Checking only when the connection is
50
+ # saved would leave retrieval vulnerable to DNS rebinding later.
51
+ from deeptutor.services.mcp.network import validate_mcp_url_async
52
+
53
+ ok, error = await validate_mcp_url_async(self._config.base_url)
54
+ if not ok:
55
+ raise WeKnoraAPIError(f"Unsafe WeKnora server URL: {error}")
56
+
57
+ async with self._open() as client:
58
+ async with client.stream(method, path, **kwargs) as resp:
59
+ declared = resp.headers.get("content-length")
60
+ if declared:
61
+ try:
62
+ if int(declared) > MAX_RESPONSE_BYTES:
63
+ raise WeKnoraAPIError("WeKnora response exceeds the 4 MiB limit.")
64
+ except ValueError:
65
+ pass
66
+ body = bytearray()
67
+ async for chunk in resp.aiter_bytes():
68
+ body.extend(chunk)
69
+ if len(body) > MAX_RESPONSE_BYTES:
70
+ raise WeKnoraAPIError("WeKnora response exceeds the 4 MiB limit.")
71
+
72
+ if resp.status_code >= 400:
73
+ preview = bytes(body[:300]).decode("utf-8", errors="replace")
74
+ raise WeKnoraAPIError(f"WeKnora returned {resp.status_code}: {preview}")
75
+ try:
76
+ data = json.loads(body)
77
+ except Exception as exc:
78
+ raise WeKnoraAPIError(f"WeKnora returned a non-JSON response: {exc}") from exc
79
+ if not isinstance(data, dict):
80
+ raise WeKnoraAPIError(f"WeKnora returned unexpected payload: {data!r}")
81
+ return data
82
+
83
+ async def list_knowledge_bases(self) -> list[dict[str, Any]]:
84
+ data = (await self._request_json("GET", "/api/v1/knowledge-bases")).get("data")
85
+ if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
86
+ raise WeKnoraAPIError("WeKnora returned an unexpected knowledge-base list.")
87
+ return data
88
+
89
+ async def search(self, query: str) -> list[dict[str, Any]]:
90
+ data = (
91
+ await self._request_json(
92
+ "POST",
93
+ "/api/v1/knowledge-search",
94
+ params={"resource_urls": "handle"},
95
+ json={
96
+ "query": query,
97
+ "knowledge_base_id": self._config.knowledge_base_id,
98
+ },
99
+ )
100
+ ).get("data")
101
+ if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
102
+ raise WeKnoraAPIError("WeKnora returned unexpected search results.")
103
+ return data
104
+
105
+
106
+ __all__ = ["MAX_RESPONSE_BYTES", "WeKnoraAPIError", "WeKnoraClient"]
@@ -0,0 +1,56 @@
1
+ """Per-KB connection configuration for Tencent WeKnora."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+
10
+ class WeKnoraNotConfiguredError(RuntimeError):
11
+ """Raised when a KB lacks the fields needed to reach WeKnora."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class WeKnoraConfig:
16
+ base_url: str
17
+ api_key: str
18
+ knowledge_base_id: str
19
+
20
+
21
+ def normalize_base_url(url: str | None) -> str:
22
+ normalized = (url or "").strip().rstrip("/")
23
+ if not normalized:
24
+ return ""
25
+ parsed = urlparse(normalized)
26
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
27
+ return ""
28
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
29
+ return ""
30
+ return normalized
31
+
32
+
33
+ def config_from_entry(entry: dict[str, Any]) -> WeKnoraConfig:
34
+ base_url = normalize_base_url(entry.get("server_url"))
35
+ api_key = str(entry.get("api_key") or "").strip()
36
+ knowledge_base_id = str(entry.get("knowledge_base_id") or "").strip()
37
+ if not base_url or not knowledge_base_id:
38
+ raise WeKnoraNotConfiguredError(
39
+ "This knowledge base is not connected to WeKnora "
40
+ "(server URL and knowledge base ID are required)."
41
+ )
42
+ if not api_key:
43
+ raise WeKnoraNotConfiguredError("A WeKnora API key is required for retrieval.")
44
+ return WeKnoraConfig(
45
+ base_url=base_url,
46
+ api_key=api_key,
47
+ knowledge_base_id=knowledge_base_id,
48
+ )
49
+
50
+
51
+ __all__ = [
52
+ "WeKnoraConfig",
53
+ "WeKnoraNotConfiguredError",
54
+ "config_from_entry",
55
+ "normalize_base_url",
56
+ ]
@@ -0,0 +1,74 @@
1
+ """Retrieval-only RAG pipeline backed by an external WeKnora knowledge base."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from deeptutor.runtime.home import get_runtime_data_root
9
+ from deeptutor.services.rag.provider_binding import load_kb_config_entry
10
+
11
+ from .config import WeKnoraNotConfiguredError, config_from_entry
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ PROVIDER = "weknora"
16
+ DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
17
+
18
+
19
+ class WeKnoraPipeline:
20
+ def __init__(self, kb_base_dir: Optional[str] = None, *, client_factory=None, **_: Any) -> None:
21
+ self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
22
+ self._client_factory = client_factory
23
+
24
+ def _client(self, config):
25
+ if self._client_factory is not None:
26
+ return self._client_factory(config)
27
+ from .client import WeKnoraClient
28
+
29
+ return WeKnoraClient(config)
30
+
31
+ async def search(self, query: str, kb_name: str, **kwargs) -> Dict[str, Any]:
32
+ try:
33
+ config = config_from_entry(load_kb_config_entry(self.kb_base_dir, kb_name))
34
+ except WeKnoraNotConfiguredError as exc:
35
+ return self._error_result(query, exc, error_type="not_configured")
36
+
37
+ try:
38
+ chunks = await self._client(config).search(query)
39
+ except Exception as exc:
40
+ logger.error("WeKnora search failed for '%s': %s", kb_name, exc)
41
+ return self._error_result(query, exc, error_type="retrieval_error")
42
+
43
+ return {
44
+ "query": query,
45
+ "answer": "\n\n---\n\n".join(str(item.get("content") or "") for item in chunks),
46
+ "content": "\n\n---\n\n".join(str(item.get("content") or "") for item in chunks),
47
+ "sources": chunks,
48
+ "provider": PROVIDER,
49
+ }
50
+
51
+ def _error_result(self, query: str, exc: Exception, *, error_type: str) -> Dict[str, Any]:
52
+ return {
53
+ "query": query,
54
+ "answer": str(exc),
55
+ "content": "",
56
+ "sources": [],
57
+ "provider": PROVIDER,
58
+ "error_type": error_type,
59
+ }
60
+
61
+ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
62
+ raise RuntimeError(
63
+ "WeKnora knowledge bases are managed in WeKnora; DeepTutor does not "
64
+ "upload or index their documents."
65
+ )
66
+
67
+ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
68
+ return await self.initialize(kb_name, file_paths, **kwargs)
69
+
70
+ async def delete(self, kb_name: str, **kwargs) -> bool:
71
+ return True
72
+
73
+
74
+ __all__ = ["WeKnoraPipeline", "PROVIDER"]
@@ -0,0 +1,80 @@
1
+ """Validate a WeKnora endpoint, API key, and knowledge-base binding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Any, Optional
7
+
8
+ from .client import WeKnoraClient
9
+ from .config import WeKnoraConfig, normalize_base_url
10
+
11
+
12
+ @dataclass
13
+ class WeKnoraProbe:
14
+ base_url: str
15
+ knowledge_base_id: str
16
+ ok: bool = False
17
+ reachable: bool = False
18
+ credentials_ok: bool = False
19
+ knowledge_base_found: bool = False
20
+ knowledge_base_name: Optional[str] = None
21
+ error: Optional[str] = None
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return asdict(self)
25
+
26
+
27
+ async def probe_weknora(
28
+ server_url: str,
29
+ api_key: str,
30
+ knowledge_base_id: str,
31
+ *,
32
+ client_factory=None,
33
+ ) -> WeKnoraProbe:
34
+ base_url = normalize_base_url(server_url)
35
+ knowledge_base_id = (knowledge_base_id or "").strip()
36
+ api_key = (api_key or "").strip()
37
+ probe = WeKnoraProbe(base_url=base_url, knowledge_base_id=knowledge_base_id)
38
+
39
+ if not base_url or not knowledge_base_id or not api_key:
40
+ probe.error = "Server URL, API key, and knowledge base ID are required."
41
+ return probe
42
+
43
+ # WeKnora is administrator-configured and often lives on the deployment's
44
+ # private network, so loopback/LAN remain valid. Link-local, metadata and
45
+ # malformed targets are still forbidden, and the client repeats this check
46
+ # at every retrieval request to narrow DNS-rebinding exposure.
47
+ from deeptutor.services.mcp.network import validate_mcp_url_async
48
+
49
+ safe, error = await validate_mcp_url_async(base_url)
50
+ if not safe:
51
+ probe.error = f"Unsafe WeKnora server URL: {error}"
52
+ return probe
53
+
54
+ config = WeKnoraConfig(
55
+ base_url=base_url,
56
+ api_key=api_key,
57
+ knowledge_base_id=knowledge_base_id,
58
+ )
59
+ client = client_factory(config) if client_factory else WeKnoraClient(config)
60
+ try:
61
+ knowledge_bases = await client.list_knowledge_bases()
62
+ except Exception as exc:
63
+ probe.error = f"Could not validate the WeKnora connection: {exc}"
64
+ return probe
65
+
66
+ probe.reachable = True
67
+ probe.credentials_ok = True
68
+ matches = [item for item in knowledge_bases if str(item.get("id") or "") == knowledge_base_id]
69
+ if not matches:
70
+ probe.error = f"Knowledge base {knowledge_base_id} is not visible to this WeKnora API key."
71
+ return probe
72
+
73
+ probe.knowledge_base_found = True
74
+ name = str(matches[0].get("name") or "").strip()
75
+ probe.knowledge_base_name = name or None
76
+ probe.ok = True
77
+ return probe
78
+
79
+
80
+ __all__ = ["WeKnoraProbe", "probe_weknora"]