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,364 @@
1
+ """Client for Tencent IMA's knowledge-base OpenAPI (``/openapi/wiki/v1``).
2
+
3
+ A method table over the calls DeepTutor needs, with the wire mechanics delegated
4
+ to :class:`~deeptutor.services.rag.pipelines.ima.transport.ImaTransport`, the
5
+ payload decoding to :mod:`.models`, and file downloads to :mod:`.media`:
6
+
7
+ Reading
8
+ * ``search_knowledge`` — retrieval inside one knowledge base; returns matched
9
+ items with a ``highlight_content`` snippet, cursor-paginated.
10
+ * ``get_knowledge_list`` — *browse* a knowledge base (or one of its folders)
11
+ and see what it actually holds. This is the inventory call: retrieval can
12
+ only report what a query matched, never what a library contains.
13
+ * ``search_knowledge_base`` / ``get_knowledge_base`` — which libraries these
14
+ credentials can reach, and one library's name / description (which doubles
15
+ as the connect-time credential check).
16
+ * ``get_media_info`` (+ the notes module's ``get_doc_content``) — full source
17
+ text for one item.
18
+
19
+ Writing
20
+ * ``import_urls`` — add web pages / WeChat articles to a library.
21
+ * the notes module (see :class:`ImaNotesClient`) — create or append notes.
22
+
23
+ Uploading local *files* is deliberately still out of scope: IMA's upload is a
24
+ four-step transaction ending in a signed direct-to-COS PUT, which needs its own
25
+ credential handling and size/type preflight. Deleting anything in IMA is out of
26
+ scope permanently — a connected KB never destroys the user's own library.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Any, Optional
32
+
33
+ import httpx
34
+
35
+ from . import media as media_ops
36
+ from .config import ImaConfig
37
+ from .envelope import ImaAPIError, ImaAuthError, ImaRateLimitError
38
+ from .media import MAX_MEDIA_BYTES, ImaMediaContent
39
+ from .models import (
40
+ ImaImportedUrl,
41
+ ImaKnowledgePage,
42
+ parse_imported_urls,
43
+ parse_knowledge_bases,
44
+ parse_knowledge_page,
45
+ )
46
+ from .notes import ImaNotesClient
47
+ from .transport import API_BASE_URL, DEFAULT_TIMEOUT, ImaTransport
48
+
49
+ # IMA's documented page bounds for every ``limit``-taking knowledge-base call.
50
+ MAX_PAGE_LIMIT = 50
51
+
52
+ # ``get_knowledge_base`` takes ids in batches, capped by IMA at 20.
53
+ MAX_DETAIL_IDS = 20
54
+
55
+ # ``import_urls`` accepts 1-10 URLs per call.
56
+ MAX_IMPORT_URLS = 10
57
+
58
+ # ``search_knowledge`` is cursor-paginated. Retrieval feeds an LLM prompt, so a
59
+ # couple of pages is plenty — this bounds the calls a single search can make.
60
+ _MAX_SEARCH_PAGES = 3
61
+
62
+ # Media type IMA assigns to a note linked into a knowledge base. Its text comes
63
+ # from the notes module rather than a file download.
64
+ _NOTE_MEDIA_TYPE = 11
65
+
66
+
67
+ class ImaClient:
68
+ """Stateless wrapper over the IMA knowledge-base OpenAPI."""
69
+
70
+ def __init__(
71
+ self,
72
+ config: ImaConfig,
73
+ *,
74
+ timeout: float = DEFAULT_TIMEOUT,
75
+ transport: Optional[httpx.AsyncBaseTransport] = None,
76
+ ) -> None:
77
+ self._config = config
78
+ self._timeout = timeout
79
+ self._transport = transport
80
+ self._wire = ImaTransport(config, timeout=timeout, transport=transport)
81
+ self._notes = ImaNotesClient(config, timeout=timeout, transport=transport)
82
+
83
+ @property
84
+ def notes(self) -> ImaNotesClient:
85
+ """The notes-module client sharing this client's credentials."""
86
+ return self._notes
87
+
88
+ @property
89
+ def knowledge_base_id(self) -> str:
90
+ return self._config.knowledge_base_id
91
+
92
+ # ----- retrieval ------------------------------------------------------
93
+
94
+ async def search_knowledge(self, query: str, *, limit: int) -> ImaKnowledgePage:
95
+ """Return up to *limit* matching documents from the bound knowledge base.
96
+
97
+ Pages are followed until the result is full, IMA reports the end of the
98
+ list, or the page budget runs out. Matched *folders* are kept separate
99
+ from matched documents (IMA returns both) so retrieval never treats a
100
+ folder as a source.
101
+ """
102
+ documents = []
103
+ folders = []
104
+ cursor = ""
105
+ is_end = False
106
+ unverified = 0
107
+ for _ in range(_MAX_SEARCH_PAGES):
108
+ page = parse_knowledge_page(
109
+ await self._wire.post(
110
+ "search_knowledge",
111
+ {
112
+ "query": query,
113
+ "cursor": cursor,
114
+ "knowledge_base_id": self._config.knowledge_base_id,
115
+ },
116
+ )
117
+ )
118
+ documents.extend(page.documents)
119
+ folders.extend(page.folders)
120
+ unverified += page.unverified_documents
121
+ cursor = page.next_cursor
122
+ is_end = page.is_end
123
+ if len(documents) >= limit or page.is_end or not cursor:
124
+ break
125
+ return ImaKnowledgePage(
126
+ documents=tuple(documents[:limit]),
127
+ folders=tuple(folders),
128
+ next_cursor=cursor,
129
+ is_end=is_end,
130
+ unverified_documents=unverified,
131
+ )
132
+
133
+ async def get_knowledge_list(
134
+ self,
135
+ *,
136
+ folder_id: str = "",
137
+ cursor: str = "",
138
+ limit: int = MAX_PAGE_LIMIT,
139
+ ) -> ImaKnowledgePage:
140
+ """Return one page of the bound knowledge base's contents.
141
+
142
+ Omitting *folder_id* lists the library root; passing one descends into
143
+ that folder. The response carries documents, subfolders and a breadcrumb.
144
+ """
145
+ return parse_knowledge_page(
146
+ await self._wire.post("get_knowledge_list", self._list_body(folder_id, cursor, limit))
147
+ )
148
+
149
+ def get_knowledge_list_sync(
150
+ self,
151
+ *,
152
+ folder_id: str = "",
153
+ cursor: str = "",
154
+ limit: int = MAX_PAGE_LIMIT,
155
+ ) -> ImaKnowledgePage:
156
+ """Blocking :meth:`get_knowledge_list`, for the synchronous manifest layer."""
157
+ return parse_knowledge_page(
158
+ self._wire.post_sync("get_knowledge_list", self._list_body(folder_id, cursor, limit))
159
+ )
160
+
161
+ def _list_body(self, folder_id: str, cursor: str, limit: int) -> dict[str, Any]:
162
+ body: dict[str, Any] = {
163
+ "knowledge_base_id": self._config.knowledge_base_id,
164
+ "cursor": str(cursor or ""),
165
+ "limit": page_limit(limit),
166
+ }
167
+ folder = str(folder_id or "").strip()
168
+ # The root folder id equals the knowledge base id; sending it is
169
+ # equivalent to omitting the field, so it is dropped for clarity.
170
+ if folder and folder != self._config.knowledge_base_id:
171
+ body["folder_id"] = folder
172
+ return body
173
+
174
+ async def get_media_info(self, media_id: str) -> dict[str, Any]:
175
+ """Return one item's metadata (media type, note link, download URL)."""
176
+ normalized = str(media_id or "").strip()
177
+ if not normalized:
178
+ return {}
179
+ return await self._wire.post("get_media_info", {"media_id": normalized})
180
+
181
+ async def get_media_content(self, media_id: str) -> ImaMediaContent | None:
182
+ """Fetch full content for one knowledge item.
183
+
184
+ IMA notes are returned as plain text by the notes module. File media is
185
+ streamed from the short-lived COS URL with a hard byte limit. Missing or
186
+ inaccessible media returns ``None`` so a caller can degrade to the item's
187
+ title instead of failing outright.
188
+ """
189
+ info = await self.get_media_info(media_id)
190
+ if not info:
191
+ return None
192
+
193
+ note_id = _linked_note_id(info)
194
+ if note_id:
195
+ content = await self._notes.get_note_content(note_id)
196
+ return ImaMediaContent(text=content) if content else None
197
+
198
+ url_info = info.get("url_info")
199
+ if not isinstance(url_info, dict):
200
+ return None
201
+ url = str(url_info.get("url") or "").strip()
202
+ if not url:
203
+ return None
204
+ return await media_ops.download_media(
205
+ url,
206
+ headers=media_ops.media_headers(url_info.get("headers")),
207
+ timeout=self._timeout,
208
+ transport=self._transport,
209
+ )
210
+
211
+ # ----- writing --------------------------------------------------------
212
+
213
+ async def import_urls(self, urls: list[str], *, folder_id: str = "") -> list[ImaImportedUrl]:
214
+ """Add up to :data:`MAX_IMPORT_URLS` web pages to the bound library.
215
+
216
+ IMA reports a per-URL verdict rather than failing the batch, so partial
217
+ success is normal and is returned as-is for the caller to report.
218
+ """
219
+ cleaned: list[str] = []
220
+ for raw in urls:
221
+ url = str(raw or "").strip()
222
+ if url and url not in cleaned:
223
+ cleaned.append(url)
224
+ if not cleaned:
225
+ raise ValueError("At least one URL is required.")
226
+ if len(cleaned) > MAX_IMPORT_URLS:
227
+ raise ValueError(f"IMA accepts at most {MAX_IMPORT_URLS} URLs per call.")
228
+
229
+ # ``folder_id`` is required here, and the root folder's id is the
230
+ # knowledge base id itself.
231
+ target = str(folder_id or "").strip() or self._config.knowledge_base_id
232
+ data = await self._wire.post(
233
+ "import_urls",
234
+ {
235
+ "urls": cleaned,
236
+ "knowledge_base_id": self._config.knowledge_base_id,
237
+ "folder_id": target,
238
+ },
239
+ )
240
+ results = parse_imported_urls(data)
241
+ if results:
242
+ return results
243
+ # Some responses acknowledge the batch without echoing per-URL rows.
244
+ return [ImaImportedUrl(url=url, ok=True) for url in cleaned]
245
+
246
+ # ----- probing --------------------------------------------------------
247
+
248
+ async def search_knowledge_bases(
249
+ self,
250
+ query: str = "",
251
+ *,
252
+ cursor: str = "",
253
+ limit: int = MAX_DETAIL_IDS,
254
+ ) -> dict[str, Any]:
255
+ """Return one page of knowledge bases available to these credentials.
256
+
257
+ A single batch details request enriches descriptions when possible; that
258
+ optional request never prevents a usable name list from being returned.
259
+ Since IMA caps a details batch at :data:`MAX_DETAIL_IDS` while a listing
260
+ page can hold more, libraries past that point simply come back without a
261
+ description — every one is still listed and selectable.
262
+ The shape is the one the connect flow's API contract expects.
263
+ """
264
+ bases, cursor_state = parse_knowledge_bases(
265
+ await self._wire.post(
266
+ "search_knowledge_base",
267
+ {
268
+ "query": str(query or "").strip(),
269
+ "cursor": str(cursor or "").strip(),
270
+ "limit": page_limit(limit),
271
+ },
272
+ )
273
+ )
274
+
275
+ details: dict[str, dict[str, Any]] = {}
276
+ if bases:
277
+ try:
278
+ details = await self.get_knowledge_bases(
279
+ [base.id for base in bases[:MAX_DETAIL_IDS]]
280
+ )
281
+ except Exception:
282
+ # Names from search_knowledge_base are sufficient for selection;
283
+ # description lookup is intentionally best-effort.
284
+ details = {}
285
+
286
+ knowledge_bases = []
287
+ for base in bases:
288
+ raw_description = details.get(base.id, {}).get("description")
289
+ description = str(raw_description).strip() if raw_description is not None else ""
290
+ knowledge_bases.append(
291
+ {
292
+ "id": base.id,
293
+ "name": base.name,
294
+ "description": description or None,
295
+ }
296
+ )
297
+ return {
298
+ "knowledge_bases": knowledge_bases,
299
+ "next_cursor": cursor_state.next_cursor,
300
+ "is_end": cursor_state.is_end,
301
+ }
302
+
303
+ async def get_knowledge_bases(self, ids: list[str]) -> dict[str, dict[str, Any]]:
304
+ """Return details for at most :data:`MAX_DETAIL_IDS` knowledge base ids."""
305
+ normalized: list[str] = []
306
+ for item in ids:
307
+ kb_id = str(item or "").strip()
308
+ if kb_id and kb_id not in normalized:
309
+ normalized.append(kb_id)
310
+ if not normalized:
311
+ return {}
312
+ if len(normalized) > MAX_DETAIL_IDS:
313
+ raise ValueError(
314
+ f"IMA accepts at most {MAX_DETAIL_IDS} knowledge base IDs per request."
315
+ )
316
+
317
+ data = await self._wire.post("get_knowledge_base", {"ids": normalized})
318
+ infos = data.get("infos")
319
+ if not isinstance(infos, dict):
320
+ return {}
321
+ return {str(kb_id): info for kb_id, info in infos.items() if isinstance(info, dict)}
322
+
323
+ async def get_knowledge_base(self) -> dict[str, Any]:
324
+ """Return the bound knowledge base's info, or ``{}`` when unknown.
325
+
326
+ Doubles as the credential check: bad credentials raise
327
+ :class:`ImaAuthError`, while a well-formed but unknown id simply yields
328
+ no entry for it.
329
+ """
330
+ kb_id = self._config.knowledge_base_id
331
+ return (await self.get_knowledge_bases([kb_id])).get(kb_id, {})
332
+
333
+
334
+ def page_limit(limit: Any, *, maximum: int = MAX_PAGE_LIMIT) -> int:
335
+ """Clamp a caller's page size into IMA's documented ``1..maximum`` range."""
336
+ try:
337
+ value = int(limit)
338
+ except (TypeError, ValueError):
339
+ return maximum
340
+ return max(1, min(value, maximum))
341
+
342
+
343
+ def _linked_note_id(info: dict[str, Any]) -> str:
344
+ """The note id behind a note-type knowledge item, if this item is one."""
345
+ note_info = info.get("notebook_ext_info")
346
+ if info.get("media_type") != _NOTE_MEDIA_TYPE or not isinstance(note_info, dict):
347
+ return ""
348
+ return str(note_info.get("notebook_id") or note_info.get("doc_id") or "").strip()
349
+
350
+
351
+ __all__ = [
352
+ "API_BASE_URL",
353
+ "MAX_DETAIL_IDS",
354
+ "MAX_IMPORT_URLS",
355
+ "MAX_MEDIA_BYTES",
356
+ "MAX_PAGE_LIMIT",
357
+ "ImaAPIError",
358
+ "ImaAuthError",
359
+ "ImaClient",
360
+ "ImaMediaContent",
361
+ "ImaNotesClient",
362
+ "ImaRateLimitError",
363
+ "page_limit",
364
+ ]
@@ -0,0 +1,140 @@
1
+ """Connection config for the Tencent IMA engine.
2
+
3
+ IMA credentials (``client_id`` + ``api_key``, issued at
4
+ https://ima.qq.com/agent-interface) identify an *account*, and a knowledge base
5
+ id identifies one of that account's libraries. The credentials therefore resolve
6
+ at two levels:
7
+
8
+ * **account level** — ``settings/ima.json`` (managed by
9
+ ``RuntimeSettingsService``), edited under Knowledge → the IMA engine page.
10
+ One pair is shared by every ``ima`` KB, the way PageIndex's key is;
11
+ * **per KB** — the same two fields on the KB's ``kb_config.json`` entry. Present
12
+ on knowledge bases connected before the engine page existed, and still the way
13
+ to point one KB at a *different* IMA account.
14
+
15
+ The per-KB pair wins when it is complete, so an existing binding keeps working
16
+ untouched and rotating the account key updates every KB that relies on it.
17
+
18
+ This module is the single seam that reads that binding into a typed config; it
19
+ holds no global state and imports no HTTP client (the client lives in
20
+ ``client.py``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Any, Optional
27
+
28
+ # IMA exposes exactly one retrieval call (``search_knowledge``) with no mode
29
+ # knob, so a KB bound to this engine has no per-KB search mode to pick. The
30
+ # empty tuple keeps the shared provider-mode plumbing happy while telling the
31
+ # UI there is nothing to offer.
32
+ SUPPORTED_MODES: tuple[str, ...] = ()
33
+ DEFAULT_MODE = ""
34
+
35
+
36
+ class ImaNotConfiguredError(RuntimeError):
37
+ """Raised when a KB is missing the credentials or the knowledge base id."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ImaCredentials:
42
+ """One IMA account's credential pair."""
43
+
44
+ client_id: str = ""
45
+ api_key: str = ""
46
+
47
+ @property
48
+ def complete(self) -> bool:
49
+ return bool(self.client_id and self.api_key)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ImaConfig:
54
+ """A KB's resolved connection to one Tencent IMA knowledge base."""
55
+
56
+ client_id: str
57
+ api_key: str
58
+ knowledge_base_id: str
59
+
60
+
61
+ def get_account_credentials() -> ImaCredentials:
62
+ """Load the account-level credential pair, or an empty one.
63
+
64
+ Never raises: an unreadable settings file only means "not configured", which
65
+ the callers already handle.
66
+ """
67
+ try:
68
+ from deeptutor.services.config import get_runtime_settings_service
69
+
70
+ settings = get_runtime_settings_service().load_ima()
71
+ except Exception:
72
+ return ImaCredentials()
73
+ return ImaCredentials(
74
+ client_id=str(settings.get("client_id") or "").strip(),
75
+ api_key=str(settings.get("api_key") or "").strip(),
76
+ )
77
+
78
+
79
+ def is_ima_configured() -> bool:
80
+ """Whether account-level credentials are set (flags the engine as ready)."""
81
+ return get_account_credentials().complete
82
+
83
+
84
+ def config_from_entry(
85
+ entry: dict[str, Any],
86
+ *,
87
+ fallback: Optional[ImaCredentials] = None,
88
+ ) -> ImaConfig:
89
+ """Build an :class:`ImaConfig` from a ``kb_config.json`` KB entry.
90
+
91
+ The entry's own credentials win; *fallback* (normally the account-level
92
+ pair) fills in what it omits. Raises :class:`ImaNotConfiguredError` when any
93
+ of the three required fields is still missing, so retrieval fails with a
94
+ clear message instead of an opaque HTTP error from IMA.
95
+ """
96
+ client_id = str(entry.get("client_id") or "").strip()
97
+ api_key = str(entry.get("api_key") or "").strip()
98
+ knowledge_base_id = str(entry.get("knowledge_base_id") or "").strip()
99
+ if fallback is not None:
100
+ client_id = client_id or fallback.client_id
101
+ api_key = api_key or fallback.api_key
102
+ missing = [
103
+ label
104
+ for label, value in (
105
+ ("client ID", client_id),
106
+ ("API key", api_key),
107
+ ("knowledge base ID", knowledge_base_id),
108
+ )
109
+ if not value
110
+ ]
111
+ if missing:
112
+ raise ImaNotConfiguredError(
113
+ "This knowledge base is not fully connected to Tencent IMA "
114
+ f"(missing {', '.join(missing)}). Add the IMA credentials on the "
115
+ "engine page under Knowledge, or re-create the knowledge base with "
116
+ "complete credentials."
117
+ )
118
+ return ImaConfig(
119
+ client_id=client_id,
120
+ api_key=api_key,
121
+ knowledge_base_id=knowledge_base_id,
122
+ )
123
+
124
+
125
+ def resolve_kb_config(entry: dict[str, Any]) -> ImaConfig:
126
+ """``config_from_entry`` with the account-level credentials as fallback."""
127
+ return config_from_entry(entry, fallback=get_account_credentials())
128
+
129
+
130
+ __all__ = [
131
+ "SUPPORTED_MODES",
132
+ "DEFAULT_MODE",
133
+ "ImaNotConfiguredError",
134
+ "ImaCredentials",
135
+ "ImaConfig",
136
+ "config_from_entry",
137
+ "get_account_credentials",
138
+ "is_ima_configured",
139
+ "resolve_kb_config",
140
+ ]
@@ -0,0 +1,109 @@
1
+ """The response envelope every Tencent IMA OpenAPI call answers with.
2
+
3
+ Both IMA modules (``/openapi/wiki/v1`` and ``/openapi/note/v1``) wrap their
4
+ payload in a status envelope and return HTTP 200 even for business failures, so
5
+ unwrapping it is where *all* IMA error handling happens. This module is the one
6
+ place that knows its shape, and the exception classes it raises live here too —
7
+ the envelope's status code is what decides which one a caller sees.
8
+
9
+ Field names
10
+ -----------
11
+ IMA's own reference documents the envelope as ``{"retcode", "errmsg", "data"}``
12
+ while live responses have also been observed using ``{"code", "msg", "data"}``.
13
+ Both spellings are accepted (:data:`_CODE_KEYS` / :data:`_MESSAGE_KEYS`): a
14
+ mismatch here would not degrade gracefully — reading only one spelling turns
15
+ every successful call into "request failed with code None", which is exactly the
16
+ kind of failure a wire-format assumption should never be able to cause.
17
+
18
+ Status codes worth naming are the two classes a caller reacts to differently
19
+ from a generic failure: rejected credentials and rate limiting. Everything else
20
+ surfaces IMA's own message, which the API documents as safe to show the user.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ # Accepted spellings of the envelope's status fields, in priority order.
28
+ _CODE_KEYS: tuple[str, ...] = ("retcode", "code")
29
+ _MESSAGE_KEYS: tuple[str, ...] = ("errmsg", "msg")
30
+
31
+ # Credential rejection: the client id / API key pair was refused.
32
+ _CREDENTIAL_CODES = frozenset({20004, 200002})
33
+
34
+ # Rate limiting: the key exceeded its call frequency. Both modules' codes.
35
+ _RATE_LIMIT_CODES = frozenset({20002, 110021})
36
+
37
+ # Transient upstream failures IMA documents as retryable. Mapped to a plain
38
+ # API error, but with a message that says so.
39
+ _RETRYABLE_CODES = frozenset({110010, 100003})
40
+
41
+
42
+ class ImaAPIError(RuntimeError):
43
+ """Raised when IMA returns an error envelope or an unexpected payload."""
44
+
45
+
46
+ class ImaAuthError(ImaAPIError):
47
+ """Raised when IMA rejects the client id / API key pair."""
48
+
49
+
50
+ class ImaRateLimitError(ImaAPIError):
51
+ """Raised when IMA rate-limits the request."""
52
+
53
+
54
+ def unwrap(payload: Any, *, status_code: int) -> dict[str, Any]:
55
+ """Return the envelope's ``data`` object, or raise the mapped error.
56
+
57
+ ``status_code`` is the HTTP status the payload arrived with; it only matters
58
+ when the body is unusable, so the raised message can name it.
59
+ """
60
+ if not isinstance(payload, dict):
61
+ raise ImaAPIError(f"IMA returned an unexpected payload with status {status_code}.")
62
+
63
+ code = _first_present(payload, _CODE_KEYS)
64
+ message = _message_of(payload)
65
+ if _is_success(code):
66
+ data = payload.get("data")
67
+ return data if isinstance(data, dict) else {}
68
+ if code in _CREDENTIAL_CODES:
69
+ raise ImaAuthError(message or "IMA rejected the client ID / API key.")
70
+ if code in _RATE_LIMIT_CODES:
71
+ raise ImaRateLimitError(message or "IMA rate limit reached.")
72
+ if code in _RETRYABLE_CODES:
73
+ raise ImaAPIError(message or f"IMA is temporarily unavailable (code {code}). Try again.")
74
+ if code is None:
75
+ # Neither spelling present: the body is not an IMA envelope at all.
76
+ raise ImaAPIError(f"IMA returned an unrecognized response with status {status_code}.")
77
+ raise ImaAPIError(message or f"IMA request failed with code {code}.")
78
+
79
+
80
+ def _first_present(payload: dict[str, Any], keys: tuple[str, ...]) -> int | None:
81
+ """The first key's value coerced to int, or ``None`` when none is usable."""
82
+ for key in keys:
83
+ if key not in payload:
84
+ continue
85
+ try:
86
+ return int(payload[key])
87
+ except (TypeError, ValueError):
88
+ continue
89
+ return None
90
+
91
+
92
+ def _message_of(payload: dict[str, Any]) -> str:
93
+ for key in _MESSAGE_KEYS:
94
+ value = payload.get(key)
95
+ if value:
96
+ return str(value).strip()
97
+ return ""
98
+
99
+
100
+ def _is_success(code: int | None) -> bool:
101
+ return code == 0
102
+
103
+
104
+ __all__ = [
105
+ "ImaAPIError",
106
+ "ImaAuthError",
107
+ "ImaRateLimitError",
108
+ "unwrap",
109
+ ]