deeptutor-knowledge 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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
|
+
]
|