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,209 @@
|
|
|
1
|
+
"""What a connected IMA library actually contains — its document inventory.
|
|
2
|
+
|
|
3
|
+
Retrieval answers "what does the material say"; it cannot answer "what is in
|
|
4
|
+
here" or "did I add X". For an indexed KB the answer is a directory walk. For an
|
|
5
|
+
IMA library the equivalent is ``get_knowledge_list``, which browses the library's
|
|
6
|
+
folder tree, and this module turns that into the same flat list of relative paths
|
|
7
|
+
:mod:`deeptutor.knowledge.manifest` produces for a local KB — so "list what's in
|
|
8
|
+
this knowledge base" works the same whether the documents sit on disk or in IMA.
|
|
9
|
+
|
|
10
|
+
Two properties make it usable from the manifest layer:
|
|
11
|
+
|
|
12
|
+
* **Blocking.** The manifest is deliberately synchronous (it is called from a
|
|
13
|
+
worker thread, and from the chat pipeline's prompt assembly), so the traversal
|
|
14
|
+
uses the transport's blocking flavour instead of forcing that whole path async.
|
|
15
|
+
* **Cached.** A manifest is rebuilt on every turn's system prompt; without a
|
|
16
|
+
cache that would be a fresh IMA round-trip per turn. Entries expire after
|
|
17
|
+
:data:`CACHE_TTL_SECONDS`, which is short enough that a document added in IMA
|
|
18
|
+
shows up almost immediately and long enough that a burst of turns costs one
|
|
19
|
+
traversal.
|
|
20
|
+
|
|
21
|
+
The traversal is bounded on purpose (:data:`MAX_REQUESTS`): a huge library must
|
|
22
|
+
not stall prompt assembly. When the budget runs out the result is marked
|
|
23
|
+
incomplete and the count it reports is a lower bound, never a confident total.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections import deque
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
import logging
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Mapping, Optional
|
|
34
|
+
|
|
35
|
+
from .config import ImaNotConfiguredError, resolve_kb_config
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Requests one traversal may spend. With IMA's 50-item pages this covers the
|
|
40
|
+
# first few hundred documents, including a level or two of folders.
|
|
41
|
+
MAX_REQUESTS = 8
|
|
42
|
+
|
|
43
|
+
PAGE_SIZE = 50
|
|
44
|
+
|
|
45
|
+
CACHE_TTL_SECONDS = 60.0
|
|
46
|
+
|
|
47
|
+
# Failures are cached too, briefly. Without this, an unreachable library would be
|
|
48
|
+
# retried on every single turn's prompt assembly — paying the timeout each time.
|
|
49
|
+
FAILURE_TTL_SECONDS = 30.0
|
|
50
|
+
|
|
51
|
+
# This runs while a turn's system prompt is being assembled, so it must never be
|
|
52
|
+
# what makes a turn feel slow: a stalled IMA gives up quickly rather than holding
|
|
53
|
+
# the default 30 s.
|
|
54
|
+
TIMEOUT_SECONDS = 6.0
|
|
55
|
+
|
|
56
|
+
# Folder nesting to descend. Deep trees are truncated rather than walked
|
|
57
|
+
# exhaustively — the inventory is an overview, not a mirror.
|
|
58
|
+
MAX_DEPTH = 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ImaInventory:
|
|
63
|
+
"""A connected IMA library's documents, as folder-relative paths."""
|
|
64
|
+
|
|
65
|
+
documents: tuple[str, ...] = ()
|
|
66
|
+
complete: bool = True
|
|
67
|
+
"""False when the request budget ran out — ``documents`` is then a prefix."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# key -> (expires_at, inventory or None). ``None`` is a cached *failure*.
|
|
71
|
+
_CACHE: dict[str, tuple[float, Optional[ImaInventory]]] = {}
|
|
72
|
+
_CACHE_LOCK = threading.Lock()
|
|
73
|
+
|
|
74
|
+
# Distinguishes "nothing cached" from "a cached failure", which is itself a
|
|
75
|
+
# ``None`` result worth honouring.
|
|
76
|
+
_MISS = object()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_inventory(
|
|
80
|
+
entry: Mapping[str, Any],
|
|
81
|
+
*,
|
|
82
|
+
client_factory=None,
|
|
83
|
+
use_cache: bool = True,
|
|
84
|
+
) -> Optional[ImaInventory]:
|
|
85
|
+
"""Return the inventory of the IMA library *entry* points at.
|
|
86
|
+
|
|
87
|
+
``None`` means "cannot be determined" (missing credentials, or IMA
|
|
88
|
+
unreachable) — distinct from an empty library, which is an empty inventory.
|
|
89
|
+
``client_factory`` (config → client) is the test seam.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
config = resolve_kb_config(dict(entry))
|
|
93
|
+
except ImaNotConfiguredError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
cache_key = f"{config.client_id}:{config.knowledge_base_id}"
|
|
97
|
+
if use_cache:
|
|
98
|
+
cached = _cached(cache_key)
|
|
99
|
+
if cached is not _MISS:
|
|
100
|
+
return cached # type: ignore[return-value]
|
|
101
|
+
|
|
102
|
+
if client_factory is not None:
|
|
103
|
+
client = client_factory(config)
|
|
104
|
+
else:
|
|
105
|
+
from .client import ImaClient
|
|
106
|
+
|
|
107
|
+
client = ImaClient(config, timeout=TIMEOUT_SECONDS)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
inventory = _traverse(client, config.knowledge_base_id)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Could not read the IMA document inventory for '%s' (%s)",
|
|
114
|
+
config.knowledge_base_id,
|
|
115
|
+
type(exc).__name__,
|
|
116
|
+
)
|
|
117
|
+
if use_cache:
|
|
118
|
+
_store(cache_key, None, ttl=FAILURE_TTL_SECONDS)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if use_cache:
|
|
122
|
+
_store(cache_key, inventory, ttl=CACHE_TTL_SECONDS)
|
|
123
|
+
return inventory
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def clear_cache() -> None:
|
|
127
|
+
"""Drop every cached inventory (used by tests and after a KB is re-bound)."""
|
|
128
|
+
with _CACHE_LOCK:
|
|
129
|
+
_CACHE.clear()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _traverse(client, root_id: str) -> ImaInventory:
|
|
133
|
+
"""Breadth-first walk of the library, folder paths flattened like a local KB.
|
|
134
|
+
|
|
135
|
+
``truncated`` records whether anything was left unvisited — a folder still
|
|
136
|
+
queued, a page still to fetch, or a subtree below :data:`MAX_DEPTH`. It is
|
|
137
|
+
tracked explicitly rather than inferred from the request count so a traversal
|
|
138
|
+
that happens to finish on its last allowed request is still reported as
|
|
139
|
+
complete.
|
|
140
|
+
"""
|
|
141
|
+
documents: list[str] = []
|
|
142
|
+
seen_folders: set[str] = set()
|
|
143
|
+
# (folder_id, path prefix, depth); the root folder's id is the library id.
|
|
144
|
+
queue: deque[tuple[str, str, int]] = deque([(root_id, "", 0)])
|
|
145
|
+
requests = 0
|
|
146
|
+
truncated = False
|
|
147
|
+
|
|
148
|
+
while queue:
|
|
149
|
+
folder_id, prefix, depth = queue.popleft()
|
|
150
|
+
cursor = ""
|
|
151
|
+
while True:
|
|
152
|
+
if requests >= MAX_REQUESTS:
|
|
153
|
+
truncated = True
|
|
154
|
+
break
|
|
155
|
+
page = client.get_knowledge_list_sync(
|
|
156
|
+
folder_id=folder_id,
|
|
157
|
+
cursor=cursor,
|
|
158
|
+
limit=PAGE_SIZE,
|
|
159
|
+
)
|
|
160
|
+
requests += 1
|
|
161
|
+
for document in page.documents:
|
|
162
|
+
documents.append(f"{prefix}{document.title}" if prefix else document.title)
|
|
163
|
+
for folder in page.folders:
|
|
164
|
+
if folder.folder_id in seen_folders or folder.folder_id == folder_id:
|
|
165
|
+
continue
|
|
166
|
+
seen_folders.add(folder.folder_id)
|
|
167
|
+
if depth >= MAX_DEPTH:
|
|
168
|
+
truncated = True
|
|
169
|
+
continue
|
|
170
|
+
queue.append((folder.folder_id, f"{prefix}{folder.name}/", depth + 1))
|
|
171
|
+
cursor = page.next_cursor
|
|
172
|
+
if page.is_end or not cursor:
|
|
173
|
+
break
|
|
174
|
+
if truncated and requests >= MAX_REQUESTS:
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
return ImaInventory(documents=tuple(documents), complete=not truncated and not queue)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _cached(key: str) -> Any:
|
|
181
|
+
"""The cached value, or :data:`_MISS` when absent or expired."""
|
|
182
|
+
now = time.monotonic()
|
|
183
|
+
with _CACHE_LOCK:
|
|
184
|
+
hit = _CACHE.get(key)
|
|
185
|
+
if hit is None:
|
|
186
|
+
return _MISS
|
|
187
|
+
expires_at, inventory = hit
|
|
188
|
+
if now >= expires_at:
|
|
189
|
+
_CACHE.pop(key, None)
|
|
190
|
+
return _MISS
|
|
191
|
+
return inventory
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _store(key: str, inventory: Optional[ImaInventory], *, ttl: float) -> None:
|
|
195
|
+
with _CACHE_LOCK:
|
|
196
|
+
_CACHE[key] = (time.monotonic() + ttl, inventory)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = [
|
|
200
|
+
"CACHE_TTL_SECONDS",
|
|
201
|
+
"FAILURE_TTL_SECONDS",
|
|
202
|
+
"MAX_DEPTH",
|
|
203
|
+
"MAX_REQUESTS",
|
|
204
|
+
"PAGE_SIZE",
|
|
205
|
+
"TIMEOUT_SECONDS",
|
|
206
|
+
"ImaInventory",
|
|
207
|
+
"clear_cache",
|
|
208
|
+
"read_inventory",
|
|
209
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Fetching and reading one IMA item's actual content.
|
|
2
|
+
|
|
3
|
+
An IMA knowledge item is either a *note* (whose text the notes API returns
|
|
4
|
+
directly) or a *file* (which IMA hands over as a short-lived Tencent COS
|
|
5
|
+
download URL). This module owns the file half — the download and the text
|
|
6
|
+
extraction — so both retrieval (``pipeline``) and the capability's ``ima_read``
|
|
7
|
+
tool read documents through exactly one implementation.
|
|
8
|
+
|
|
9
|
+
Security boundary
|
|
10
|
+
-----------------
|
|
11
|
+
The download URL arrives inside an API response, so it is treated as untrusted
|
|
12
|
+
input: it must be HTTPS and served from a trusted Tencent domain (Tencent COS
|
|
13
|
+
or IMA's own resource host, :data:`_ALLOWED_MEDIA_ROOT_DOMAINS`), which
|
|
14
|
+
prevents a tampered or buggy response from turning retrieval into an SSRF
|
|
15
|
+
primitive. Redirects are not followed, IMA's own credentials are never attached
|
|
16
|
+
to this separate client, and hop-by-hop / identity headers are stripped from
|
|
17
|
+
whatever header set the response asked us to send. Size is capped
|
|
18
|
+
(:data:`MAX_MEDIA_BYTES`) both by the advertised ``content-length`` and while
|
|
19
|
+
streaming, so a mis-sized response cannot exhaust memory.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import PurePath, PurePosixPath
|
|
27
|
+
from typing import Optional
|
|
28
|
+
from urllib.parse import unquote, urlparse
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from deeptutor.utils.document_extractor import (
|
|
33
|
+
SUPPORTED_DOC_EXTENSIONS,
|
|
34
|
+
extract_text_from_bytes,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .envelope import ImaAPIError
|
|
38
|
+
|
|
39
|
+
# Official IMA media links are short-lived Tencent COS download URLs, and
|
|
40
|
+
# note/file content is served from IMA's exact resource host
|
|
41
|
+
# (res-pkb.ima.qq.com). Do not trust every sibling under ima.qq.com: a broad
|
|
42
|
+
# suffix allowlist would let an unrelated or later-compromised service become
|
|
43
|
+
# an SSRF relay.
|
|
44
|
+
_ALLOWED_MEDIA_ROOT_DOMAINS = ("myqcloud.com",)
|
|
45
|
+
_ALLOWED_MEDIA_HOSTS = frozenset({"res-pkb.ima.qq.com"})
|
|
46
|
+
|
|
47
|
+
# Headers we never forward, whatever the API response asks for: hop-by-hop
|
|
48
|
+
# fields and anything that would carry identity to COS.
|
|
49
|
+
_FORBIDDEN_MEDIA_HEADERS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"connection",
|
|
52
|
+
"content-length",
|
|
53
|
+
"cookie",
|
|
54
|
+
"host",
|
|
55
|
+
"proxy-authorization",
|
|
56
|
+
"transfer-encoding",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
MAX_MEDIA_BYTES = 20 * 1024 * 1024
|
|
61
|
+
|
|
62
|
+
_CONTENT_TYPE_EXTENSIONS: dict[str, str] = {
|
|
63
|
+
"application/pdf": ".pdf",
|
|
64
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
65
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
66
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
67
|
+
"application/json": ".json",
|
|
68
|
+
"text/csv": ".csv",
|
|
69
|
+
"text/html": ".html",
|
|
70
|
+
"text/markdown": ".md",
|
|
71
|
+
"text/plain": ".txt",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ImaMediaContent:
|
|
77
|
+
"""One IMA item's content, as either note text or downloaded file bytes."""
|
|
78
|
+
|
|
79
|
+
text: str = ""
|
|
80
|
+
data: bytes = b""
|
|
81
|
+
filename: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def download_media(
|
|
85
|
+
url: str,
|
|
86
|
+
*,
|
|
87
|
+
headers: dict[str, str],
|
|
88
|
+
timeout: float,
|
|
89
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
90
|
+
) -> ImaMediaContent:
|
|
91
|
+
"""Stream a COS media URL into memory, bounded by :data:`MAX_MEDIA_BYTES`."""
|
|
92
|
+
validate_media_url(url)
|
|
93
|
+
async with httpx.AsyncClient(
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
transport=transport,
|
|
96
|
+
follow_redirects=False,
|
|
97
|
+
) as client:
|
|
98
|
+
async with client.stream("GET", url, headers=headers) as response:
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
length = response.headers.get("content-length")
|
|
101
|
+
if length and length.isdigit() and int(length) > MAX_MEDIA_BYTES:
|
|
102
|
+
raise ImaAPIError("IMA media exceeds the 20 MB retrieval limit.")
|
|
103
|
+
body = bytearray()
|
|
104
|
+
async for chunk in response.aiter_bytes():
|
|
105
|
+
body.extend(chunk)
|
|
106
|
+
if len(body) > MAX_MEDIA_BYTES:
|
|
107
|
+
raise ImaAPIError("IMA media exceeds the 20 MB retrieval limit.")
|
|
108
|
+
filename = media_filename(url, response.headers.get("content-type"))
|
|
109
|
+
return ImaMediaContent(data=bytes(body), filename=filename)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def extract_text(
|
|
113
|
+
media: ImaMediaContent | None,
|
|
114
|
+
*title_candidates: str,
|
|
115
|
+
max_chars: int,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Plain text for *media*, truncated to *max_chars* (``""`` when unreadable).
|
|
118
|
+
|
|
119
|
+
Note text is already plain. File bytes are decoded by
|
|
120
|
+
:func:`~deeptutor.utils.document_extractor.extract_text_from_bytes`, which
|
|
121
|
+
needs a filename to pick a decoder — the download's own filename or the
|
|
122
|
+
item title, whichever carries a supported extension. Extraction is CPU-bound
|
|
123
|
+
(PDF / Office parsing), so it runs in a worker thread.
|
|
124
|
+
"""
|
|
125
|
+
if media is None:
|
|
126
|
+
return ""
|
|
127
|
+
if media.text:
|
|
128
|
+
return media.text[:max_chars].strip()
|
|
129
|
+
if not media.data:
|
|
130
|
+
return ""
|
|
131
|
+
filename = _extractable_filename(media.filename, *title_candidates)
|
|
132
|
+
if not filename:
|
|
133
|
+
return ""
|
|
134
|
+
return await asyncio.to_thread(
|
|
135
|
+
extract_text_from_bytes,
|
|
136
|
+
filename,
|
|
137
|
+
media.data,
|
|
138
|
+
max_bytes=MAX_MEDIA_BYTES,
|
|
139
|
+
max_chars=max_chars,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_media_url(url: str) -> None:
|
|
144
|
+
parsed = urlparse(url)
|
|
145
|
+
hostname = (parsed.hostname or "").rstrip(".").lower()
|
|
146
|
+
if parsed.scheme != "https" or not hostname:
|
|
147
|
+
raise ImaAPIError("IMA media URL must use HTTPS.")
|
|
148
|
+
trusted_root = any(
|
|
149
|
+
hostname == root or hostname.endswith(f".{root}") for root in _ALLOWED_MEDIA_ROOT_DOMAINS
|
|
150
|
+
)
|
|
151
|
+
if hostname not in _ALLOWED_MEDIA_HOSTS and not trusted_root:
|
|
152
|
+
raise ImaAPIError("IMA media URL is outside the trusted Tencent media hosts.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def media_headers(raw: object) -> dict[str, str]:
|
|
156
|
+
"""The subset of a response's suggested download headers we will forward."""
|
|
157
|
+
if not isinstance(raw, dict):
|
|
158
|
+
return {}
|
|
159
|
+
return {
|
|
160
|
+
str(key): str(value)
|
|
161
|
+
for key, value in raw.items()
|
|
162
|
+
if str(key).lower() not in _FORBIDDEN_MEDIA_HEADERS and isinstance(value, (str, int, float))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def media_filename(url: str, content_type: str | None) -> str:
|
|
167
|
+
"""A filename for the download, inferring an extension when the path lacks one."""
|
|
168
|
+
name = unquote(PurePosixPath(urlparse(url).path).name).strip()
|
|
169
|
+
if "." in name:
|
|
170
|
+
return name
|
|
171
|
+
media_type = str(content_type or "").partition(";")[0].strip().lower()
|
|
172
|
+
return f"{name or 'ima-document'}{_CONTENT_TYPE_EXTENSIONS.get(media_type, '')}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extractable_filename(*candidates: str) -> str:
|
|
176
|
+
for candidate in candidates:
|
|
177
|
+
if candidate and PurePath(candidate).suffix.lower() in SUPPORTED_DOC_EXTENSIONS:
|
|
178
|
+
return candidate
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"MAX_MEDIA_BYTES",
|
|
184
|
+
"ImaMediaContent",
|
|
185
|
+
"download_media",
|
|
186
|
+
"extract_text",
|
|
187
|
+
"media_filename",
|
|
188
|
+
"media_headers",
|
|
189
|
+
"validate_media_url",
|
|
190
|
+
]
|