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,238 @@
1
+ """Retrieval-only pipeline backed by a Tencent IMA knowledge base.
2
+
3
+ Implements the same contract as the other pipelines (see ``..base.RAGPipeline``)
4
+ but owns no index: an ``ima`` KB is a connection pointer (``type: ima`` in
5
+ ``kb_config.json``) to a library the user keeps in IMA and curates there. Only
6
+ :meth:`search` does real work — it resolves the KB's credentials, asks IMA for
7
+ matching passages, tops the thin ones up with real source text, and shapes them
8
+ for the ``rag`` tool. Documents are added in IMA (or through the IMA capability's
9
+ own tools), so :meth:`initialize` / :meth:`add_documents` are not part of this
10
+ engine's job and fail with a clear message; :meth:`delete` is a no-op because
11
+ deleting the KB only drops DeepTutor's pointer (handled by the manager) and must
12
+ never touch the user's IMA library.
13
+
14
+ The retrieval *policy* — which matches deserve a full-text fetch — lives in
15
+ :mod:`.sources`; this module only orchestrates.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ import httpx
25
+
26
+ from deeptutor.runtime.home import get_runtime_data_root
27
+ from deeptutor.services.rag.provider_binding import load_kb_config_entry
28
+
29
+ from . import media as media_ops
30
+ from . import sources as source_policy
31
+ from .config import ImaNotConfiguredError, resolve_kb_config
32
+ from .envelope import ImaAuthError, ImaRateLimitError
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ PROVIDER = "ima"
37
+ DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
38
+
39
+ # How many matched passages one retrieval feeds into the prompt. IMA returns a
40
+ # highlight snippet per item rather than whole documents, so this is a passage
41
+ # budget, not a document budget.
42
+ _DEFAULT_TOP_K = 10
43
+ _MAX_TOP_K = 50
44
+
45
+
46
+ class ImaPipeline:
47
+ """Query a Tencent IMA knowledge base on behalf of a connected KB."""
48
+
49
+ def __init__(self, kb_base_dir: Optional[str] = None, *, client_factory=None, **_: Any) -> None:
50
+ self.logger = logging.getLogger(__name__)
51
+ self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
52
+ # Injection seam for tests: (config) -> client. None uses the real client.
53
+ self._client_factory = client_factory
54
+
55
+ # ----- helpers --------------------------------------------------------
56
+
57
+ def _client(self, config):
58
+ if self._client_factory is not None:
59
+ return self._client_factory(config)
60
+ from .client import ImaClient
61
+
62
+ return ImaClient(config)
63
+
64
+ @staticmethod
65
+ def _top_k(kwargs: dict[str, Any]) -> int:
66
+ try:
67
+ requested = int(kwargs.get("top_k") or _DEFAULT_TOP_K)
68
+ except (TypeError, ValueError):
69
+ return _DEFAULT_TOP_K
70
+ return max(1, min(requested, _MAX_TOP_K))
71
+
72
+ # ----- retrieval ------------------------------------------------------
73
+
74
+ async def search(self, query: str, kb_name: str, **kwargs) -> Dict[str, Any]:
75
+ try:
76
+ config = resolve_kb_config(load_kb_config_entry(self.kb_base_dir, kb_name))
77
+ except ImaNotConfiguredError as exc:
78
+ return self._error_result(query, exc, error_type="not_configured")
79
+
80
+ try:
81
+ client = self._client(config)
82
+ page = await client.search_knowledge(query, limit=self._top_k(kwargs))
83
+ except Exception as exc:
84
+ self.logger.error("IMA search failed for '%s' (%s)", kb_name, type(exc).__name__)
85
+ return self._error_result(query, exc, error_type="retrieval_error")
86
+
87
+ matched = source_policy.documents_to_sources(page.documents)
88
+ hydration_failures = await self._hydrate(client, matched)
89
+ # A title match without a snippet or readable media is useful for
90
+ # hydration, but it is not evidence that the rag tool may cite (#1500).
91
+ sources = [source for source in matched if str(source.get("content") or "").strip()]
92
+ diagnostics = {
93
+ "knowledge_base": kb_name,
94
+ "matched_documents": len(matched),
95
+ "readable_documents": len(sources),
96
+ "unreadable_documents": len(matched) - len(sources),
97
+ "unverified_documents": page.unverified_documents + len(page.documents) - len(matched),
98
+ "hydration_failures": hydration_failures,
99
+ }
100
+ if diagnostics["unverified_documents"] and not matched:
101
+ return {
102
+ "query": query,
103
+ "answer": "Tencent IMA returned matches without usable source identifiers. No source passage could be verified.",
104
+ "content": "",
105
+ "sources": [],
106
+ "provider": PROVIDER,
107
+ "error_type": "unverified_sources",
108
+ "retrieval_status": "unverified_sources",
109
+ "diagnostics": diagnostics,
110
+ }
111
+ if matched and not sources:
112
+ return {
113
+ "query": query,
114
+ "answer": (
115
+ "Tencent IMA matched document titles, but returned no readable text. "
116
+ "Check access to the matched media or try again."
117
+ ),
118
+ "content": "",
119
+ "sources": [],
120
+ "provider": PROVIDER,
121
+ "error_type": "content_unavailable",
122
+ "retrieval_status": "content_unavailable",
123
+ "diagnostics": diagnostics,
124
+ }
125
+ content = source_policy.render_context(sources)
126
+ status = "ready"
127
+ if not matched:
128
+ status = "no_matches"
129
+ content = f"Tencent IMA returned no matching passages for this query in '{kb_name}'."
130
+ elif (
131
+ len(sources) < len(matched) or hydration_failures or diagnostics["unverified_documents"]
132
+ ):
133
+ status = "partial"
134
+ content += (
135
+ "\n\nSome IMA matches could not be read in full. Use only the returned "
136
+ "passages as evidence; do not invent missing source text or locations."
137
+ )
138
+ return {
139
+ "query": query,
140
+ "answer": content,
141
+ "content": content,
142
+ "sources": sources,
143
+ "provider": PROVIDER,
144
+ "retrieval_status": status,
145
+ "diagnostics": diagnostics,
146
+ }
147
+
148
+ async def _hydrate(self, client, sources: list[dict[str, Any]]) -> list[dict[str, str]]:
149
+ """Replace thin or missing snippets with real source text, concurrently.
150
+
151
+ Each fetch is independent, so they run together — a search that needs
152
+ four documents costs one round-trip's latency, not four. A document that
153
+ cannot be loaded keeps its snippet (or stays a title-only reference):
154
+ one unavailable file must never discard the other matches.
155
+ """
156
+ targets = source_policy.hydration_targets(sources)
157
+ if not targets:
158
+ return []
159
+ results = await asyncio.gather(
160
+ *(self._fetch_text(client, sources[index]) for index in targets),
161
+ return_exceptions=True,
162
+ )
163
+ failures = []
164
+ for index, result in zip(targets, results):
165
+ if isinstance(result, BaseException):
166
+ # HTTP errors may embed a signed COS URL; log only the error
167
+ # class so short-lived download credentials never reach logs.
168
+ self.logger.warning(
169
+ "Could not load IMA media '%s' (%s)",
170
+ sources[index]["chunk_id"],
171
+ type(result).__name__,
172
+ )
173
+ failures.append(
174
+ {"source_id": sources[index]["chunk_id"], "error_type": type(result).__name__}
175
+ )
176
+ elif result:
177
+ sources[index]["content"] = result
178
+ else:
179
+ failures.append(
180
+ {"source_id": sources[index]["chunk_id"], "error_type": "content_unavailable"}
181
+ )
182
+ return failures
183
+
184
+ @staticmethod
185
+ async def _fetch_text(client, source: dict[str, Any]) -> str:
186
+ media = await client.get_media_content(source["chunk_id"])
187
+ return await media_ops.extract_text(
188
+ media,
189
+ source["title"],
190
+ max_chars=source_policy.MAX_FULLTEXT_CHARS,
191
+ )
192
+
193
+ def _error_result(self, query: str, exc: Exception, *, error_type: str) -> Dict[str, Any]:
194
+ # #1500: preserve observable failure classes without exposing signed
195
+ # media URLs or credentials carried by transport exceptions.
196
+ message = str(exc)
197
+ if isinstance(exc, ImaAuthError):
198
+ error_type = "authentication_error"
199
+ message = "Tencent IMA rejected the credentials or library access. Check this account's IMA connection and permissions."
200
+ elif isinstance(exc, ImaRateLimitError):
201
+ error_type = "rate_limited"
202
+ message = "Tencent IMA rate limit reached. Wait before retrying this search."
203
+ elif isinstance(exc, httpx.TimeoutException):
204
+ error_type = "timeout"
205
+ message = "Tencent IMA did not respond in time. Retry the search."
206
+ elif isinstance(exc, httpx.TransportError):
207
+ error_type = "network_error"
208
+ message = "Tencent IMA could not be reached. Check the connection and retry."
209
+ return {
210
+ "query": query,
211
+ "answer": message,
212
+ "content": "",
213
+ "sources": [],
214
+ "provider": PROVIDER,
215
+ "error_type": error_type,
216
+ "retrieval_status": "failed",
217
+ }
218
+
219
+ # ----- indexing (not applicable — owned by IMA) ------------------------
220
+
221
+ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
222
+ raise RuntimeError(
223
+ "Tencent IMA knowledge bases are indexed by IMA; DeepTutor does not "
224
+ "build or store their index. Add documents in IMA directly."
225
+ )
226
+
227
+ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
228
+ return await self.initialize(kb_name, file_paths, **kwargs)
229
+
230
+ # ----- lifecycle ------------------------------------------------------
231
+
232
+ async def delete(self, kb_name: str, **kwargs) -> bool:
233
+ # The KB is only a pointer; the manager removes its config entry. Never
234
+ # touch the user's IMA library. Nothing local to clean up here.
235
+ return True
236
+
237
+
238
+ __all__ = ["ImaPipeline", "PROVIDER"]
@@ -0,0 +1,105 @@
1
+ """Probe a Tencent IMA knowledge base before connecting a KB to it.
2
+
3
+ Connecting is cheap and reversible, but wrong credentials or a mistyped
4
+ knowledge base id should fail loudly at connect time rather than silently at
5
+ every later query. One ``get_knowledge_base`` round-trip answers both questions
6
+ the UI needs to confirm: are the credentials accepted, and does the id resolve to
7
+ a library we can name back to the user?
8
+
9
+ Always returns an :class:`ImaProbe` (never raises); ``ok`` is the single boolean
10
+ the caller gates on, with ``error`` explaining any failure.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass
16
+ from typing import Any, Optional
17
+
18
+ from .client import ImaClient
19
+ from .config import ImaConfig
20
+ from .envelope import ImaAuthError
21
+
22
+
23
+ @dataclass
24
+ class ImaProbe:
25
+ knowledge_base_id: str
26
+ ok: bool = False
27
+ credentials_ok: bool = False
28
+ knowledge_base_name: Optional[str] = None
29
+ description: Optional[str] = None
30
+ error: Optional[str] = None
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return asdict(self)
34
+
35
+
36
+ async def probe_knowledge_base(
37
+ client_id: str,
38
+ api_key: str,
39
+ knowledge_base_id: str,
40
+ *,
41
+ client_factory=None,
42
+ ) -> ImaProbe:
43
+ """Check that *knowledge_base_id* is reachable with the given credentials.
44
+
45
+ ``client_factory`` (config → client) is an injection seam for tests; in
46
+ production it defaults to a real :class:`ImaClient`.
47
+ """
48
+ client_id = (client_id or "").strip()
49
+ api_key = (api_key or "").strip()
50
+ knowledge_base_id = (knowledge_base_id or "").strip()
51
+
52
+ probe = ImaProbe(knowledge_base_id=knowledge_base_id)
53
+ missing = [
54
+ label
55
+ for label, value in (
56
+ ("Client ID", client_id),
57
+ ("API key", api_key),
58
+ ("knowledge base ID", knowledge_base_id),
59
+ )
60
+ if not value
61
+ ]
62
+ if missing:
63
+ probe.error = f"{', '.join(missing)} is required."
64
+ return probe
65
+
66
+ config = ImaConfig(
67
+ client_id=client_id,
68
+ api_key=api_key,
69
+ knowledge_base_id=knowledge_base_id,
70
+ )
71
+ client = client_factory(config) if client_factory else ImaClient(config)
72
+
73
+ try:
74
+ info = await client.get_knowledge_base()
75
+ except ImaAuthError:
76
+ probe.error = "IMA rejected these credentials. Check them and try again."
77
+ return probe
78
+ except Exception:
79
+ # Upstream exception strings are not returned because they may contain
80
+ # request details. The UI only needs a stable, actionable verdict.
81
+ probe.error = "Could not reach Tencent IMA. Try again shortly."
82
+ return probe
83
+
84
+ # The call is credential-gated, so reaching this point means they were
85
+ # accepted — an unknown id just yields no entry.
86
+ probe.credentials_ok = True
87
+ if not info:
88
+ probe.error = (
89
+ "The credentials work, but no knowledge base matches this ID. "
90
+ "Check the ID in IMA and try again."
91
+ )
92
+ return probe
93
+
94
+ probe.knowledge_base_name = _opt_str(info.get("name"))
95
+ probe.description = _opt_str(info.get("description"))
96
+ probe.ok = True
97
+ return probe
98
+
99
+
100
+ def _opt_str(value: Any) -> Optional[str]:
101
+ text = str(value).strip() if value is not None else ""
102
+ return text or None
103
+
104
+
105
+ __all__ = ["ImaProbe", "probe_knowledge_base"]
@@ -0,0 +1,105 @@
1
+ """Shaping IMA search results into the grounded context the ``rag`` tool expects.
2
+
3
+ IMA answers a search with one *highlight snippet* per matched item — often a
4
+ single sentence, and nothing at all when the match was on the title. Handing
5
+ those straight to the model is what made a connected IMA library feel thin
6
+ compared to an indexed KB, whose chunks are whole passages.
7
+
8
+ So retrieval has two stages, and this module owns the policy for the second:
9
+
10
+ 1. map matched documents into DeepTutor's ``sources`` shape;
11
+ 2. decide which of them are worth spending a full-text fetch on
12
+ (:func:`hydration_targets`) — items with *no* snippet first, then items whose
13
+ snippet is too thin to reason from, best-ranked first, bounded by a budget so
14
+ one search cannot turn into a dozen downloads.
15
+
16
+ Kept separate from :mod:`.pipeline` (which orchestrates the calls) and
17
+ :mod:`.client` (which makes them) so the retrieval policy can be reasoned about
18
+ and tested without a transport.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Iterable, Sequence
24
+
25
+ from .models import ImaDocument
26
+
27
+ # A snippet shorter than this is treated as a hint that a document matched, not
28
+ # as usable evidence, so it is a candidate for full-text hydration.
29
+ MIN_USEFUL_SNIPPET_CHARS = 240
30
+
31
+ # How many documents one retrieval may fetch in full. Bounds both the network
32
+ # cost and the prompt footprint even when ``top_k`` is large.
33
+ DEFAULT_HYDRATION_BUDGET = 4
34
+
35
+ # Per-document character cap for hydrated full text.
36
+ MAX_FULLTEXT_CHARS = 12_000
37
+
38
+
39
+ def documents_to_sources(documents: Iterable[ImaDocument]) -> list[dict[str, Any]]:
40
+ """Map matched IMA documents into DeepTutor's ``sources`` shape.
41
+
42
+ Items whose match was on the title alone carry no snippet and are still
43
+ listed, so the model can see the document exists even before (or without)
44
+ hydration.
45
+ """
46
+ sources: list[dict[str, Any]] = []
47
+ for document in documents:
48
+ title = document.title or document.media_id
49
+ if not title:
50
+ continue
51
+ sources.append(
52
+ {
53
+ "title": title,
54
+ "content": document.highlight,
55
+ "source": title,
56
+ "chunk_id": document.media_id,
57
+ }
58
+ )
59
+ return sources
60
+
61
+
62
+ def hydration_targets(
63
+ sources: Sequence[dict[str, Any]],
64
+ *,
65
+ budget: int = DEFAULT_HYDRATION_BUDGET,
66
+ min_chars: int = MIN_USEFUL_SNIPPET_CHARS,
67
+ ) -> list[int]:
68
+ """Indices of the sources worth fetching in full, in priority order.
69
+
70
+ Snippet-less matches come first (they carry no evidence at all), then thin
71
+ snippets in rank order. Sources without an item id cannot be fetched and are
72
+ never returned.
73
+ """
74
+ if budget <= 0:
75
+ return []
76
+ empty: list[int] = []
77
+ thin: list[int] = []
78
+ for index, source in enumerate(sources):
79
+ if not source.get("chunk_id"):
80
+ continue
81
+ content = str(source.get("content") or "").strip()
82
+ if not content:
83
+ empty.append(index)
84
+ elif len(content) < min_chars:
85
+ thin.append(index)
86
+ return (empty + thin)[:budget]
87
+
88
+
89
+ def render_context(sources: Sequence[dict[str, Any]]) -> str:
90
+ """Flatten retrieved snippets into the grounded context block."""
91
+ blocks = [
92
+ f"[{index}] {source.get('title') or ''}\n{source.get('content') or ''}".rstrip()
93
+ for index, source in enumerate(sources, start=1)
94
+ ]
95
+ return "\n\n".join(blocks)
96
+
97
+
98
+ __all__ = [
99
+ "DEFAULT_HYDRATION_BUDGET",
100
+ "MAX_FULLTEXT_CHARS",
101
+ "MIN_USEFUL_SNIPPET_CHARS",
102
+ "documents_to_sources",
103
+ "hydration_targets",
104
+ "render_context",
105
+ ]
@@ -0,0 +1,130 @@
1
+ """HTTP transport shared by every Tencent IMA OpenAPI call.
2
+
3
+ All IMA methods are ``POST https://ima.qq.com/<prefix>/<method>`` with a JSON
4
+ body and two credential headers, so the wire mechanics are identical across the
5
+ knowledge-base (``wiki``) and notes modules. Concentrating them here leaves the
6
+ client classes as nothing but method tables, and leaves exactly one place that
7
+ attaches credentials.
8
+
9
+ A fresh ``httpx`` client per call (mirroring ``LightRagServerClient``) keeps the
10
+ object safe to construct once and reuse from anywhere, and the injectable
11
+ ``transport`` lets tests stub the wire without a live server.
12
+
13
+ Both an async and a blocking flavour of the same call are exposed. The async one
14
+ serves retrieval and the tool layer; the blocking one exists for the knowledge-base
15
+ *inventory*, which is read from the deliberately synchronous manifest layer (see
16
+ :mod:`deeptutor.knowledge.manifest`) — giving it a blocking call here is far less
17
+ invasive than making that whole path async, and both share this module's URL,
18
+ header and envelope handling.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Optional
24
+
25
+ import httpx
26
+
27
+ from .config import ImaConfig
28
+ from .envelope import ImaAPIError, ImaAuthError, ImaRateLimitError, unwrap
29
+
30
+ API_BASE_URL = "https://ima.qq.com"
31
+ WIKI_PREFIX = "/openapi/wiki/v1"
32
+ NOTE_PREFIX = "/openapi/note/v1"
33
+
34
+ DEFAULT_TIMEOUT = 30.0
35
+
36
+
37
+ def build_headers(config: ImaConfig) -> dict[str, str]:
38
+ """The credential headers every IMA request carries."""
39
+ return {
40
+ "Content-Type": "application/json",
41
+ "ima-openapi-clientid": config.client_id,
42
+ "ima-openapi-apikey": config.api_key,
43
+ }
44
+
45
+
46
+ class ImaTransport:
47
+ """POST one IMA method and return its unwrapped ``data`` object."""
48
+
49
+ def __init__(
50
+ self,
51
+ config: ImaConfig,
52
+ *,
53
+ timeout: float = DEFAULT_TIMEOUT,
54
+ transport: Optional[httpx.AsyncBaseTransport] = None,
55
+ ) -> None:
56
+ self.config = config
57
+ self.timeout = timeout
58
+ self.transport = transport
59
+
60
+ async def post(
61
+ self,
62
+ method: str,
63
+ body: dict[str, Any],
64
+ *,
65
+ prefix: str = WIKI_PREFIX,
66
+ ) -> dict[str, Any]:
67
+ async with httpx.AsyncClient(
68
+ base_url=API_BASE_URL,
69
+ headers=build_headers(self.config),
70
+ timeout=self.timeout,
71
+ transport=self.transport,
72
+ ) as client:
73
+ response = await client.post(f"{prefix}/{method}", json=body)
74
+ return self._unwrap(response)
75
+
76
+ def post_sync(
77
+ self,
78
+ method: str,
79
+ body: dict[str, Any],
80
+ *,
81
+ prefix: str = WIKI_PREFIX,
82
+ ) -> dict[str, Any]:
83
+ with httpx.Client(
84
+ base_url=API_BASE_URL,
85
+ headers=build_headers(self.config),
86
+ timeout=self.timeout,
87
+ transport=_sync_transport(self.transport),
88
+ ) as client:
89
+ response = client.post(f"{prefix}/{method}", json=body)
90
+ return self._unwrap(response)
91
+
92
+ @staticmethod
93
+ def _unwrap(response: httpx.Response) -> dict[str, Any]:
94
+ if response.status_code in {401, 403}:
95
+ raise ImaAuthError("IMA rejected the credentials or knowledge-base permissions.")
96
+ # A transport-level 429 never reaches the envelope, so it is mapped here
97
+ # to the same error the envelope's rate-limit codes raise.
98
+ if response.status_code == 429:
99
+ raise ImaRateLimitError("IMA rate limit reached. Try again shortly.")
100
+ if not response.is_success:
101
+ # #1500: a failed gateway response must not become a successful
102
+ # empty retrieval merely because its JSON contains code=0.
103
+ raise ImaAPIError(
104
+ f"IMA returned HTTP {response.status_code}. Retry or check the connection."
105
+ )
106
+ try:
107
+ payload = response.json()
108
+ except Exception:
109
+ payload = None
110
+ return unwrap(payload, status_code=response.status_code)
111
+
112
+
113
+ def _sync_transport(transport: Optional[httpx.AsyncBaseTransport]) -> Optional[httpx.BaseTransport]:
114
+ """Reuse an injected test transport for blocking calls when it supports them.
115
+
116
+ ``httpx.MockTransport`` implements both the sync and async protocols, so a
117
+ test's stub works for either flavour; a genuinely async-only transport is
118
+ ignored rather than passed to a blocking client that cannot drive it.
119
+ """
120
+ return transport if isinstance(transport, httpx.BaseTransport) else None
121
+
122
+
123
+ __all__ = [
124
+ "API_BASE_URL",
125
+ "DEFAULT_TIMEOUT",
126
+ "NOTE_PREFIX",
127
+ "WIKI_PREFIX",
128
+ "ImaTransport",
129
+ "build_headers",
130
+ ]
@@ -0,0 +1 @@
1
+ """Read-only retrieval from ZIM archives already served by kiwix-serve."""