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,182 @@
1
+ """Per-crawl robots rules and host pacing for documentation sync (#1429)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from fnmatch import translate
8
+ import math
9
+ import re
10
+ import time
11
+ from urllib.parse import quote, urljoin, urlsplit
12
+
13
+ import httpx
14
+
15
+ from deeptutor.tools.web_fetch import (
16
+ DEFAULT_USER_AGENT,
17
+ MAX_RESPONSE_BYTES,
18
+ _bounded_read,
19
+ _is_disallowed_host,
20
+ )
21
+
22
+ DEFAULT_REQUEST_INTERVAL_S = 0.125
23
+ #: A hostile robots.txt can otherwise park a crawl (and the shared per-host
24
+ #: pace lock) on an arbitrarily long sleep; delays beyond a minute are treated
25
+ #: as "no directive" and the default interval applies instead.
26
+ MAX_HONORED_CRAWL_DELAY_S = 60.0
27
+ _UNRESERVED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
28
+
29
+
30
+ def _normalise_path(value: str) -> str:
31
+ # Compare UTF-8 octets while preserving escaped reserved characters.
32
+ value = quote(value, safe="/%:@!$&'()*+,;=-._~?")
33
+ return re.sub(
34
+ r"%([0-9a-fA-F]{2})",
35
+ lambda match: (
36
+ chr(int(match[1], 16)) if chr(int(match[1], 16)) in _UNRESERVED else match[0].upper()
37
+ ),
38
+ value,
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RobotsPolicy:
44
+ rules: tuple[tuple[str, bool], ...] = ()
45
+ crawl_delay_s: float = 0.0
46
+ available: bool = True
47
+
48
+ def permits(self, url: str) -> bool:
49
+ if not self.available:
50
+ return False
51
+ parsed = urlsplit(url)
52
+ path = _normalise_path((parsed.path or "/") + ("?" + parsed.query if parsed.query else ""))
53
+ if parsed.path == "/robots.txt":
54
+ return True
55
+ matches: list[tuple[int, bool]] = []
56
+ for rule, allowed in self.rules:
57
+ anchored = rule.endswith("$")
58
+ pattern = rule[:-1] if anchored else rule
59
+ # fnmatch's atomic wildcard matching avoids exponential regex
60
+ # backtracking on an untrusted rule with many stars. Robots only
61
+ # gives '*' wildcard meaning; brackets and '?' remain literal.
62
+ glob = pattern.replace("[", "[[]").replace("?", "[?]")
63
+ expression = translate(glob + ("" if anchored else "*"))
64
+ if re.match(expression, path):
65
+ matches.append((len(rule.encode("utf-8")), allowed))
66
+ # Longest match wins; Allow wins ties (RFC 9309).
67
+ return max(matches, default=(0, True))[1]
68
+
69
+
70
+ def parse_robots_txt(raw: str) -> RobotsPolicy:
71
+ groups: list[tuple[list[str], list[tuple[str, bool]], list[float]]] = []
72
+ agents: list[str] = []
73
+ rules: list[tuple[str, bool]] = []
74
+ delays: list[float] = []
75
+ has_directive = False
76
+ for line in raw.splitlines():
77
+ key, sep, value = line.split("#", 1)[0].partition(":")
78
+ if not sep:
79
+ continue
80
+ key, value = key.strip().lower(), value.strip()
81
+ if key == "user-agent":
82
+ if has_directive:
83
+ groups.append((agents, rules, delays))
84
+ agents, rules, delays = [], [], []
85
+ has_directive = False
86
+ if value:
87
+ agents.append(value.lower())
88
+ elif agents:
89
+ has_directive = True
90
+ if key in {"allow", "disallow"} and value.startswith("/"):
91
+ rules.append((_normalise_path(value), key == "allow"))
92
+ elif key == "crawl-delay":
93
+ try:
94
+ delay = float(value)
95
+ except ValueError:
96
+ continue
97
+ # Ignore absurd delays instead of honoring them: a hostile
98
+ # robots.txt could otherwise park every request to this host
99
+ # on one unbounded sleep (RFC 9309 lets crawlers cap
100
+ # unreasonable values).
101
+ if math.isfinite(delay) and 0 <= delay <= MAX_HONORED_CRAWL_DELAY_S:
102
+ delays.append(delay)
103
+ groups.append((agents, rules, delays))
104
+ product = DEFAULT_USER_AGENT.split("/", 1)[0].lower()
105
+ selected = [group for group in groups if product in group[0]]
106
+ selected = selected or [group for group in groups if "*" in group[0]]
107
+ return RobotsPolicy(
108
+ rules=tuple(rule for _, rules, _ in selected for rule in rules),
109
+ crawl_delay_s=max((delay for _, _, delays in selected for delay in delays), default=0.0),
110
+ )
111
+
112
+
113
+ class CrawlAccess:
114
+ """Cache by origin; serialize request starts per host, including redirects."""
115
+
116
+ def __init__(self, client: httpx.AsyncClient) -> None:
117
+ self.client = client
118
+ self._policies: dict[str, RobotsPolicy] = {}
119
+ self._policy_locks: dict[str, asyncio.Lock] = {}
120
+ self._host_locks: dict[str, asyncio.Lock] = {}
121
+ self._last_request: dict[str, float] = {}
122
+ self._intervals: dict[str, float] = {}
123
+
124
+ async def _pace(self, url: str, delay: float = 0.0) -> None:
125
+ host = urlsplit(url).hostname or ""
126
+ self._intervals[host] = max(self._intervals.get(host, DEFAULT_REQUEST_INTERVAL_S), delay)
127
+ async with self._host_locks.setdefault(host, asyncio.Lock()):
128
+ last = self._last_request.get(host)
129
+ if last is not None:
130
+ wait = self._intervals[host] - (time.monotonic() - last)
131
+ if wait > 0:
132
+ await asyncio.sleep(wait)
133
+ self._last_request[host] = time.monotonic()
134
+
135
+ async def _fetch_policy(self, url: str) -> RobotsPolicy:
136
+ # robots redirects do not recursively request another robots file, but
137
+ # every hop retains the same SSRF checks as page requests.
138
+ for _ in range(6):
139
+ parsed = urlsplit(url)
140
+ if (
141
+ parsed.scheme not in {"http", "https"}
142
+ or not parsed.hostname
143
+ or _is_disallowed_host(parsed.hostname)
144
+ ):
145
+ return RobotsPolicy(available=False)
146
+ await self._pace(url)
147
+ try:
148
+ async with self.client.stream(
149
+ "GET",
150
+ url,
151
+ headers={"User-Agent": DEFAULT_USER_AGENT, "Accept": "text/plain,*/*"},
152
+ follow_redirects=False,
153
+ ) as response:
154
+ if response.status_code in {301, 302, 303, 307, 308}:
155
+ location = response.headers.get("location", "").strip()
156
+ if not location:
157
+ return RobotsPolicy(available=False)
158
+ url = urljoin(url, location)
159
+ continue
160
+ if response.status_code >= 500 or response.status_code == 429:
161
+ return RobotsPolicy(available=False)
162
+ if response.status_code >= 400:
163
+ return RobotsPolicy()
164
+ return parse_robots_txt(await _bounded_read(response, MAX_RESPONSE_BYTES))
165
+ except httpx.HTTPError:
166
+ return RobotsPolicy(available=False)
167
+ return RobotsPolicy(available=False)
168
+
169
+ async def policy_for(self, url: str) -> RobotsPolicy:
170
+ parsed = urlsplit(url)
171
+ origin = f"{parsed.scheme}://{parsed.netloc}"
172
+ async with self._policy_locks.setdefault(origin, asyncio.Lock()):
173
+ if origin not in self._policies:
174
+ self._policies[origin] = await self._fetch_policy(origin + "/robots.txt")
175
+ return self._policies[origin]
176
+
177
+ async def permits_request(self, url: str) -> bool:
178
+ policy = await self.policy_for(url)
179
+ if not policy.permits(url):
180
+ return False
181
+ await self._pace(url, policy.crawl_delay_s)
182
+ return True
@@ -0,0 +1,336 @@
1
+ """Durable background scheduler for account-scoped web sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+ import uuid
11
+
12
+ from deeptutor.services.web_source.repository import (
13
+ SQLiteWebSourceSyncRepository,
14
+ WebSourceSyncJob,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ WEB_SYNC_MIN_INTERVAL_HOURS = 1
20
+ WEB_SYNC_MAX_INTERVAL_HOURS = 168
21
+ WEB_SYNC_INTERVAL_HOURS = 24
22
+ WEB_SYNC_CHECK_SECONDS = 15
23
+ WEB_SYNC_LEASE_SECONDS = 60
24
+ WEB_SYNC_LEASE_RENEW_SECONDS = 20
25
+ WEB_SYNC_MAX_CONCURRENCY = 2
26
+
27
+
28
+ def _now_ms() -> int:
29
+ return int(time.time() * 1000)
30
+
31
+
32
+ def _hours_ms(hours: int) -> int:
33
+ return hours * 60 * 60 * 1000
34
+
35
+
36
+ def default_repository() -> SQLiteWebSourceSyncRepository:
37
+ from deeptutor.multi_user.paths import SYSTEM_ROOT
38
+
39
+ return SQLiteWebSourceSyncRepository(SYSTEM_ROOT / "web-source-sync.sqlite")
40
+
41
+
42
+ def normalize_sync_interval(value: Any) -> int:
43
+ try:
44
+ interval = int(value)
45
+ except (TypeError, ValueError):
46
+ return WEB_SYNC_INTERVAL_HOURS
47
+ return max(WEB_SYNC_MIN_INTERVAL_HOURS, min(WEB_SYNC_MAX_INTERVAL_HOURS, interval))
48
+
49
+
50
+ class WebSourceSyncScheduler:
51
+ """Run due web-source jobs while preserving state across restarts."""
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ repository: SQLiteWebSourceSyncRepository | None = None,
57
+ manager_factory: Callable[[], Any] | None = None,
58
+ check_interval_s: float = WEB_SYNC_CHECK_SECONDS,
59
+ max_concurrency: int = WEB_SYNC_MAX_CONCURRENCY,
60
+ ) -> None:
61
+ self._repository = repository
62
+ self._manager_factory = manager_factory
63
+ self._check_interval_s = check_interval_s
64
+ self._max_concurrency = max_concurrency
65
+ self._runner_id = f"web-sync-{uuid.uuid4().hex}"
66
+ self._task: asyncio.Task[None] | None = None
67
+ self._running = False
68
+ self._run_tasks: dict[tuple[str, str, str], asyncio.Task[None]] = {}
69
+ self._sources: dict[tuple[str, str, str], dict[str, Any]] = {}
70
+
71
+ @property
72
+ def repo(self) -> SQLiteWebSourceSyncRepository:
73
+ if self._repository is None:
74
+ self._repository = default_repository()
75
+ return self._repository
76
+
77
+ def _make_manager(self) -> Any:
78
+ if self._manager_factory is not None:
79
+ return self._manager_factory()
80
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
81
+ from deeptutor.multi_user.knowledge_access import current_kb_base_dir
82
+
83
+ return KnowledgeBaseManager(base_dir=str(current_kb_base_dir()))
84
+
85
+ async def start(self) -> None:
86
+ if self._running:
87
+ return
88
+ self._running = True
89
+ self.repo.recover_interrupted(self._runner_id)
90
+ self._task = asyncio.create_task(self._loop(), name="web-source-sync:scheduler")
91
+ logger.info("Web source sync scheduler started")
92
+
93
+ async def stop(self) -> None:
94
+ self._running = False
95
+ if self._task and not self._task.done():
96
+ self._task.cancel()
97
+ try:
98
+ await self._task
99
+ except asyncio.CancelledError:
100
+ pass
101
+ self._task = None
102
+ if self._run_tasks:
103
+ for task in self._run_tasks.values():
104
+ task.cancel()
105
+ await asyncio.gather(*self._run_tasks.values(), return_exceptions=True)
106
+ self._run_tasks.clear()
107
+
108
+ async def _loop(self) -> None:
109
+ while self._running:
110
+ try:
111
+ await self._synchronize_sources()
112
+ await asyncio.to_thread(self.repo.recover_interrupted, self._runner_id)
113
+ self._start_due_jobs()
114
+ except Exception:
115
+ logger.exception("Web source scheduler cycle failed")
116
+ await asyncio.sleep(self._check_interval_s)
117
+
118
+ def _manager_for_owner(self, owner_id: str) -> Any:
119
+ from deeptutor.multi_user.context import reset_current_user, set_current_user
120
+ from deeptutor.multi_user.models import CurrentUser
121
+ from deeptutor.multi_user.paths import (
122
+ LOCAL_ADMIN_ID,
123
+ ensure_scope_workspace,
124
+ local_admin_user,
125
+ scope_for_user,
126
+ )
127
+
128
+ user = (
129
+ local_admin_user()
130
+ if owner_id == LOCAL_ADMIN_ID
131
+ else CurrentUser(
132
+ id=owner_id,
133
+ username=owner_id,
134
+ role="user",
135
+ scope=scope_for_user(owner_id, is_admin=False),
136
+ )
137
+ )
138
+ if user.scope.kind == "user":
139
+ ensure_scope_workspace(user.scope)
140
+ token = set_current_user(user)
141
+ try:
142
+ return self._make_manager()
143
+ finally:
144
+ reset_current_user(token)
145
+
146
+ async def _synchronize_sources(self) -> None:
147
+ from deeptutor.multi_user.paths import LOCAL_ADMIN_ID, USERS_ROOT
148
+
149
+ manager = self._manager_for_owner(LOCAL_ADMIN_ID)
150
+ owner_ids = [LOCAL_ADMIN_ID, *(path.name for path in USERS_ROOT.glob("*") if path.is_dir())]
151
+ # The first manager call above also validates the configured admin path.
152
+ del manager
153
+ source_keys: set[tuple[str, str, str]] = set()
154
+ sources: dict[tuple[str, str, str], dict[str, Any]] = {}
155
+ scanned_owner_ids: set[str] = set()
156
+ for owner_id in owner_ids:
157
+ owner_sources: dict[tuple[str, str, str], dict[str, Any]] = {}
158
+ try:
159
+ owner_manager = await asyncio.to_thread(self._manager_for_owner, owner_id)
160
+ for kb_name, source in owner_manager.get_all_web_sources():
161
+ if not source.get("enabled", True) or not source.get("auto_sync_enabled", True):
162
+ continue
163
+ source_id = str(source.get("id") or "")
164
+ kb_key = str(kb_name)
165
+ if source_id and kb_key:
166
+ owner_sources[owner_id, kb_key, source_id] = dict(source)
167
+ except Exception:
168
+ logger.exception("Failed to enumerate web sources for owner %s", owner_id)
169
+ continue
170
+ scanned_owner_ids.add(owner_id)
171
+ sources.update(owner_sources)
172
+ source_keys.update(owner_sources)
173
+ self._sources = sources
174
+ await asyncio.to_thread(
175
+ self.repo.reconcile_sources, source_keys, scanned_owner_ids=scanned_owner_ids
176
+ )
177
+
178
+ def _start_due_jobs(self) -> None:
179
+ for job in self.repo.due_jobs():
180
+ key = self.repo.key(job)
181
+ if key in self._run_tasks or key not in self._sources:
182
+ continue
183
+ if len(self._run_tasks) >= self._max_concurrency:
184
+ return
185
+ task = asyncio.create_task(self._run_job(job), name=f"web-source-sync:{key[2]}")
186
+ self._run_tasks[key] = task
187
+ task.add_done_callback(lambda _task, item=key: self._run_tasks.pop(item, None)) # type: ignore[misc]
188
+
189
+ async def _run_job(self, scheduled: WebSourceSyncJob) -> None:
190
+ from deeptutor.multi_user.context import reset_current_user, set_current_user
191
+ from deeptutor.multi_user.models import CurrentUser
192
+ from deeptutor.multi_user.paths import (
193
+ LOCAL_ADMIN_ID,
194
+ ensure_scope_workspace,
195
+ local_admin_user,
196
+ scope_for_user,
197
+ )
198
+ from deeptutor.services.web_source.sync import sync_source
199
+
200
+ key = self.repo.key(scheduled)
201
+ lease_until = _now_ms() + WEB_SYNC_LEASE_SECONDS * 1000
202
+ claimed = await asyncio.to_thread(
203
+ self.repo.claim,
204
+ scheduled,
205
+ runner_id=f"{self._runner_id}:{uuid.uuid4().hex}",
206
+ lease_until_ms=lease_until,
207
+ )
208
+ if claimed is None:
209
+ return
210
+ source = self._sources.get(key)
211
+ if source is None:
212
+ # A scan may have failed after this job was queued. Keep the job
213
+ # retryable until its owner's inventory is read successfully.
214
+ self.repo.mark_interrupted(claimed)
215
+ return
216
+
217
+ user = (
218
+ local_admin_user()
219
+ if claimed.owner_id == LOCAL_ADMIN_ID
220
+ else CurrentUser(
221
+ id=claimed.owner_id,
222
+ username=claimed.owner_id,
223
+ role="user",
224
+ scope=scope_for_user(claimed.owner_id, is_admin=False),
225
+ )
226
+ )
227
+ if user.scope.kind == "user":
228
+ ensure_scope_workspace(user.scope)
229
+ token = set_current_user(user)
230
+ owner_task = asyncio.current_task()
231
+ assert owner_task is not None
232
+ lease_task = asyncio.create_task(
233
+ self._renew_lease(claimed, owner_task), name=f"web-source-sync:lease:{key[2]}"
234
+ )
235
+ try:
236
+ current = await asyncio.to_thread(self.repo.get, key)
237
+ if current is not None and current.cancel_requested:
238
+ self.repo.mark_cancelled(current)
239
+ return
240
+ result = await sync_source(
241
+ kb_name=claimed.kb_name,
242
+ source=source,
243
+ base_dir=str(self._make_manager().base_dir),
244
+ max_depth=source.get("max_depth"),
245
+ max_pages=source.get("max_pages"),
246
+ )
247
+ current = await asyncio.to_thread(self.repo.get, key)
248
+ if current is not None and current.cancel_requested:
249
+ self.repo.mark_cancelled(current)
250
+ return
251
+ if result.ok:
252
+ interval = normalize_sync_interval(source.get("sync_interval_hours"))
253
+ self.repo.mark_success(claimed, _now_ms() + _hours_ms(interval))
254
+ else:
255
+ self._mark_failure(claimed, source, result.error or "Web synchronization failed")
256
+ except asyncio.CancelledError:
257
+ current = self.repo.get(key)
258
+ if current is not None and current.cancel_requested:
259
+ self.repo.mark_cancelled(claimed, _now_ms())
260
+ else:
261
+ self.repo.mark_interrupted(claimed)
262
+ raise
263
+ except Exception as exc:
264
+ logger.exception("Scheduled web source sync failed for %s", source.get("url"))
265
+ self._mark_failure(claimed, source, str(exc))
266
+ finally:
267
+ lease_task.cancel()
268
+ await asyncio.gather(lease_task, return_exceptions=True)
269
+ reset_current_user(token)
270
+
271
+ async def _renew_lease(self, job: WebSourceSyncJob, owner_task: asyncio.Task[None]) -> None:
272
+ while True:
273
+ await asyncio.sleep(WEB_SYNC_LEASE_RENEW_SECONDS)
274
+ renewed = await asyncio.to_thread(
275
+ self.repo.renew_lease, job, _now_ms() + WEB_SYNC_LEASE_SECONDS * 1000
276
+ )
277
+ if not renewed:
278
+ owner_task.cancel()
279
+ return
280
+
281
+ def _mark_failure(
282
+ self,
283
+ job: WebSourceSyncJob,
284
+ source: dict[str, Any],
285
+ error: str,
286
+ ) -> None:
287
+ interval = normalize_sync_interval(source.get("sync_interval_hours"))
288
+ backoff = min(interval, WEB_SYNC_MIN_INTERVAL_HOURS * max(1, job.attempt + 1))
289
+ self.repo.mark_failure(
290
+ job,
291
+ error=error,
292
+ next_run_at_ms=_now_ms() + _hours_ms(backoff),
293
+ )
294
+
295
+ async def request_cancel(self, owner_id: str, kb_name: str, source_id: str) -> bool:
296
+ key = owner_id, kb_name, source_id
297
+ if not await asyncio.to_thread(self.repo.request_cancel, key):
298
+ return False
299
+ task = self._run_tasks.get(key)
300
+ if task is not None and not task.done():
301
+ task.cancel()
302
+ return True
303
+
304
+ async def retry(self, owner_id: str, kb_name: str, source_id: str) -> bool:
305
+ job = await asyncio.to_thread(self.repo.retry, (owner_id, kb_name, source_id))
306
+ return job is not None
307
+
308
+
309
+ _scheduler: WebSourceSyncScheduler | None = None
310
+
311
+
312
+ def get_web_source_sync_scheduler() -> WebSourceSyncScheduler:
313
+ global _scheduler
314
+ if _scheduler is None:
315
+ _scheduler = WebSourceSyncScheduler()
316
+ return _scheduler
317
+
318
+
319
+ async def start_web_source_sync_scheduler() -> None:
320
+ await get_web_source_sync_scheduler().start()
321
+
322
+
323
+ async def stop_web_source_sync_scheduler() -> None:
324
+ await get_web_source_sync_scheduler().stop()
325
+
326
+
327
+ __all__ = [
328
+ "WEB_SYNC_INTERVAL_HOURS",
329
+ "WEB_SYNC_MAX_INTERVAL_HOURS",
330
+ "WEB_SYNC_MIN_INTERVAL_HOURS",
331
+ "WebSourceSyncScheduler",
332
+ "get_web_source_sync_scheduler",
333
+ "normalize_sync_interval",
334
+ "start_web_source_sync_scheduler",
335
+ "stop_web_source_sync_scheduler",
336
+ ]
@@ -0,0 +1,171 @@
1
+ """Safe, local image assets for captured web Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ import hashlib
9
+ import logging
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import urljoin, urlsplit
13
+
14
+ import httpx
15
+
16
+ from deeptutor.tools.web_fetch import (
17
+ ALLOWED_SCHEMES,
18
+ DEFAULT_TIMEOUT_S,
19
+ DEFAULT_USER_AGENT,
20
+ MAX_REDIRECTS,
21
+ _is_disallowed_host,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ MAX_SNAPSHOT_IMAGES = 24
27
+ MAX_SNAPSHOT_IMAGE_BYTES = 8 * 1024 * 1024
28
+ _IMAGE = re.compile(
29
+ r"!\[([^\]]*)\]\(\s*(https?://[^\s)]+)(?:\s+[\"'][^)]*[\"'])?\s*\)",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SnapshotAsset:
36
+ data: bytes
37
+ mime: str
38
+ extension: str
39
+
40
+
41
+ ImageFetcher = Callable[[str], Awaitable[SnapshotAsset | None]]
42
+
43
+
44
+ async def localize_snapshot_images(
45
+ markdown: str,
46
+ material_id: str,
47
+ *,
48
+ fetcher: ImageFetcher | None = None,
49
+ ) -> tuple[str, dict[str, bytes]]:
50
+ """Cache bounded raster images and replace hotlinks with local API URLs."""
51
+ matches = list(_IMAGE.finditer(markdown))
52
+ urls = list(dict.fromkeys(match.group(2) for match in matches))[:MAX_SNAPSHOT_IMAGES]
53
+ if not urls:
54
+ return markdown, {}
55
+
56
+ resolved_fetcher = fetcher or fetch_snapshot_image
57
+ semaphore = asyncio.Semaphore(4)
58
+
59
+ async def load(url: str) -> tuple[str, SnapshotAsset | None]:
60
+ try:
61
+ async with semaphore:
62
+ return url, await resolved_fetcher(url)
63
+ except Exception:
64
+ logger.info("Snapshot image could not be localized: %s", url, exc_info=True)
65
+ return url, None
66
+
67
+ fetched = dict(await asyncio.gather(*(load(url) for url in urls)))
68
+ assets: dict[str, bytes] = {}
69
+ replacements: dict[str, str | None] = {}
70
+ for url, asset in fetched.items():
71
+ if asset is None:
72
+ replacements[url] = None
73
+ continue
74
+ digest = hashlib.sha256(url.encode("utf-8") + asset.data).hexdigest()[:20]
75
+ name = f"{digest}.{asset.extension}"
76
+ assets[name] = asset.data
77
+ replacements[url] = f"/api/reading/materials/{material_id}/assets/{name}"
78
+
79
+ def replace(match: re.Match[str]) -> str:
80
+ alt, url = match.group(1), match.group(2)
81
+ local = replacements.get(url)
82
+ if local:
83
+ return f"![{alt}]({local})"
84
+ label = alt.strip() or "image"
85
+ return f"*Image unavailable: {label}*"
86
+
87
+ return _IMAGE.sub(replace, markdown), assets
88
+
89
+
90
+ async def fetch_snapshot_image(
91
+ url: str,
92
+ *,
93
+ timeout_s: float = DEFAULT_TIMEOUT_S,
94
+ client_factory: Any = None,
95
+ host_validator: Any = None,
96
+ ) -> SnapshotAsset | None:
97
+ """Fetch one public raster image with redirect, MIME, and size guards."""
98
+ current_url = str(url or "").strip()
99
+ validator = host_validator or _is_disallowed_host
100
+ factory = client_factory or _default_client_factory
101
+ try:
102
+ async with factory(timeout=timeout_s) as client:
103
+ for hop in range(MAX_REDIRECTS + 1):
104
+ parsed = urlsplit(current_url)
105
+ host = (parsed.hostname or "").strip()
106
+ if parsed.scheme.lower() not in ALLOWED_SCHEMES or not host:
107
+ return None
108
+ if validator(host):
109
+ return None
110
+ async with client.stream(
111
+ "GET",
112
+ current_url,
113
+ headers={
114
+ "User-Agent": DEFAULT_USER_AGENT,
115
+ "Accept": "image/png,image/jpeg,image/gif,image/webp;q=0.9",
116
+ },
117
+ follow_redirects=False,
118
+ ) as response:
119
+ location = response.headers.get("location", "")
120
+ if response.status_code in {301, 302, 303, 307, 308} and location:
121
+ if hop >= MAX_REDIRECTS:
122
+ return None
123
+ current_url = urljoin(current_url, location)
124
+ continue
125
+ if response.status_code >= 400:
126
+ return None
127
+ data = await _bounded_image_read(response)
128
+ return _sniff_raster(data)
129
+ except httpx.HTTPError:
130
+ return None
131
+ return None
132
+
133
+
134
+ def _default_client_factory(*, timeout: float) -> httpx.AsyncClient:
135
+ return httpx.AsyncClient(timeout=timeout, max_redirects=MAX_REDIRECTS)
136
+
137
+
138
+ async def _bounded_image_read(response: httpx.Response) -> bytes:
139
+ data = bytearray()
140
+ async for chunk in response.aiter_bytes():
141
+ data.extend(chunk)
142
+ if len(data) > MAX_SNAPSHOT_IMAGE_BYTES:
143
+ return b""
144
+ return bytes(data)
145
+
146
+
147
+ def _sniff_raster(data: bytes) -> SnapshotAsset | None:
148
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
149
+ return SnapshotAsset(data, "image/png", "png")
150
+ if data.startswith(b"\xff\xd8\xff"):
151
+ return SnapshotAsset(data, "image/jpeg", "jpg")
152
+ if data.startswith((b"GIF87a", b"GIF89a")):
153
+ return SnapshotAsset(data, "image/gif", "gif")
154
+ if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
155
+ return SnapshotAsset(data, "image/webp", "webp")
156
+ return None
157
+
158
+
159
+ def snapshot_asset_mime(data: bytes) -> str | None:
160
+ asset = _sniff_raster(data)
161
+ return asset.mime if asset else None
162
+
163
+
164
+ __all__ = [
165
+ "MAX_SNAPSHOT_IMAGE_BYTES",
166
+ "MAX_SNAPSHOT_IMAGES",
167
+ "SnapshotAsset",
168
+ "fetch_snapshot_image",
169
+ "localize_snapshot_images",
170
+ "snapshot_asset_mime",
171
+ ]