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,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
+ ]