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,640 @@
1
+ """Durable scheduling state for web-source synchronization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ import contextlib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ import sqlite3
10
+ import time
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class WebSourceSyncJob:
16
+ """One durable row per account-scoped knowledge-base source."""
17
+
18
+ owner_id: str
19
+ kb_name: str
20
+ source_id: str
21
+ next_run_at_ms: int
22
+ updated_at_ms: int
23
+ state: str = "pending"
24
+ last_run_at_ms: int | None = None
25
+ attempt: int = 0
26
+ error: str | None = None
27
+ cancel_requested: bool = False
28
+ runner_id: str = ""
29
+ lease_until_ms: int | None = None
30
+
31
+ @classmethod
32
+ def from_row(cls, row: sqlite3.Row) -> "WebSourceSyncJob":
33
+ return cls(
34
+ owner_id=str(row["owner_id"]),
35
+ kb_name=str(row["kb_name"]),
36
+ source_id=str(row["source_id"]),
37
+ state=str(row["state"]),
38
+ next_run_at_ms=int(row["next_run_at_ms"]),
39
+ last_run_at_ms=(
40
+ int(row["last_run_at_ms"]) if row["last_run_at_ms"] is not None else None
41
+ ),
42
+ attempt=int(row["attempt"]),
43
+ error=str(row["error"]) if row["error"] is not None else None,
44
+ cancel_requested=bool(row["cancel_requested"]),
45
+ runner_id=str(row["runner_id"]),
46
+ lease_until_ms=(
47
+ int(row["lease_until_ms"]) if row["lease_until_ms"] is not None else None
48
+ ),
49
+ updated_at_ms=int(row["updated_at_ms"]),
50
+ )
51
+
52
+ def public_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "owner_id": self.owner_id,
55
+ "kb_name": self.kb_name,
56
+ "source_id": self.source_id,
57
+ "state": self.state,
58
+ "next_run_at": self.next_run_at_ms,
59
+ "last_run_at": self.last_run_at_ms,
60
+ "attempt": self.attempt,
61
+ "error": self.error,
62
+ "cancel_requested": self.cancel_requested,
63
+ }
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class WebSourceBilingualPairing:
68
+ """One durable bilingual pairing between two crawled pages."""
69
+
70
+ owner_id: str
71
+ kb_name: str
72
+ source_id: str
73
+ pairing_id: str
74
+ source_url: str
75
+ target_url: str
76
+ source_file: str
77
+ target_file: str
78
+ source_lang: str
79
+ target_lang: str
80
+ pairing_method: str
81
+ updated_at_ms: int
82
+
83
+ @classmethod
84
+ def from_row(cls, row: sqlite3.Row) -> "WebSourceBilingualPairing":
85
+ return cls(
86
+ owner_id=str(row["owner_id"]),
87
+ kb_name=str(row["kb_name"]),
88
+ source_id=str(row["source_id"]),
89
+ pairing_id=str(row["pairing_id"]),
90
+ source_url=str(row["source_url"]),
91
+ target_url=str(row["target_url"]),
92
+ source_file=str(row["source_file"]),
93
+ target_file=str(row["target_file"]),
94
+ source_lang=str(row["source_lang"]),
95
+ target_lang=str(row["target_lang"]),
96
+ pairing_method=str(row["pairing_method"]),
97
+ updated_at_ms=int(row["updated_at_ms"]),
98
+ )
99
+
100
+ def public_dict(self) -> dict[str, Any]:
101
+ return {
102
+ "pairing_id": self.pairing_id,
103
+ "source_url": self.source_url,
104
+ "target_url": self.target_url,
105
+ "source_file": self.source_file,
106
+ "target_file": self.target_file,
107
+ "source_lang": self.source_lang,
108
+ "target_lang": self.target_lang,
109
+ "pairing_method": self.pairing_method,
110
+ "updated_at": self.updated_at_ms,
111
+ }
112
+
113
+
114
+ class SQLiteWebSourceSyncRepository:
115
+ """WAL-backed job state shared by API requests and the scheduler."""
116
+
117
+ def __init__(self, path: Path) -> None:
118
+ self.path = Path(path).expanduser().resolve()
119
+ self.path.parent.mkdir(parents=True, exist_ok=True)
120
+ self._initialize()
121
+
122
+ @contextlib.contextmanager
123
+ def _connect(self) -> Iterator[sqlite3.Connection]:
124
+ """Preserve transaction handling and close without relying on garbage collection."""
125
+ connection = sqlite3.connect(self.path, timeout=30, isolation_level=None)
126
+ try:
127
+ connection.execute("PRAGMA journal_mode=WAL")
128
+ connection.execute("PRAGMA busy_timeout=30000")
129
+ connection.execute("PRAGMA synchronous=NORMAL")
130
+ connection.row_factory = sqlite3.Row
131
+ with connection:
132
+ yield connection
133
+ finally:
134
+ connection.close()
135
+
136
+ def _initialize(self) -> None:
137
+ with self._connect() as connection:
138
+ connection.execute(
139
+ """
140
+ CREATE TABLE IF NOT EXISTS web_source_sync_jobs (
141
+ owner_id TEXT NOT NULL,
142
+ kb_name TEXT NOT NULL,
143
+ source_id TEXT NOT NULL,
144
+ state TEXT NOT NULL,
145
+ next_run_at_ms INTEGER NOT NULL,
146
+ last_run_at_ms INTEGER,
147
+ attempt INTEGER NOT NULL DEFAULT 0,
148
+ error TEXT,
149
+ cancel_requested INTEGER NOT NULL DEFAULT 0,
150
+ runner_id TEXT NOT NULL DEFAULT '',
151
+ lease_until_ms INTEGER,
152
+ updated_at_ms INTEGER NOT NULL,
153
+ PRIMARY KEY (owner_id, kb_name, source_id)
154
+ )
155
+ """
156
+ )
157
+ connection.execute(
158
+ "CREATE INDEX IF NOT EXISTS idx_web_source_sync_due "
159
+ "ON web_source_sync_jobs(next_run_at_ms, state)"
160
+ )
161
+ connection.execute(
162
+ """
163
+ CREATE TABLE IF NOT EXISTS web_source_bilingual_pairings (
164
+ owner_id TEXT NOT NULL,
165
+ kb_name TEXT NOT NULL,
166
+ source_id TEXT NOT NULL,
167
+ pairing_id TEXT NOT NULL,
168
+ source_url TEXT NOT NULL,
169
+ target_url TEXT NOT NULL,
170
+ source_file TEXT NOT NULL,
171
+ target_file TEXT NOT NULL,
172
+ source_lang TEXT NOT NULL,
173
+ target_lang TEXT NOT NULL,
174
+ pairing_method TEXT NOT NULL,
175
+ updated_at_ms INTEGER NOT NULL,
176
+ PRIMARY KEY (owner_id, kb_name, source_id, pairing_id)
177
+ )
178
+ """
179
+ )
180
+ connection.execute(
181
+ "CREATE INDEX IF NOT EXISTS idx_web_source_pairings "
182
+ "ON web_source_bilingual_pairings(owner_id, kb_name, source_id)"
183
+ )
184
+
185
+ @staticmethod
186
+ def key(job: WebSourceSyncJob) -> tuple[str, str, str]:
187
+ return job.owner_id, job.kb_name, job.source_id
188
+
189
+ def _now_ms(self) -> int:
190
+ return int(time.time() * 1000)
191
+
192
+ def reconcile_sources(
193
+ self,
194
+ source_keys: set[tuple[str, str, str]],
195
+ *,
196
+ scanned_owner_ids: set[str] | None = None,
197
+ ) -> None:
198
+ """Reconcile only owners whose source inventory was read successfully."""
199
+ now = self._now_ms()
200
+ with self._connect() as connection:
201
+ connection.execute("BEGIN IMMEDIATE")
202
+ try:
203
+ rows = connection.execute(
204
+ "SELECT owner_id, kb_name, source_id FROM web_source_sync_jobs"
205
+ ).fetchall()
206
+ existing = {
207
+ (str(row["owner_id"]), str(row["kb_name"]), str(row["source_id"]))
208
+ for row in rows
209
+ }
210
+ for owner_id, kb_name, source_id in source_keys - existing:
211
+ connection.execute(
212
+ """
213
+ INSERT OR IGNORE INTO web_source_sync_jobs
214
+ (owner_id, kb_name, source_id, state, next_run_at_ms,
215
+ updated_at_ms)
216
+ VALUES (?, ?, ?, 'pending', ?, ?)
217
+ """,
218
+ (owner_id, kb_name, source_id, now, now),
219
+ )
220
+ for owner_id, kb_name, source_id in existing - source_keys:
221
+ if scanned_owner_ids is not None and owner_id not in scanned_owner_ids:
222
+ continue
223
+ connection.execute(
224
+ """
225
+ DELETE FROM web_source_sync_jobs
226
+ WHERE owner_id=? AND kb_name=? AND source_id=?
227
+ """,
228
+ (owner_id, kb_name, source_id),
229
+ )
230
+ connection.execute(
231
+ """
232
+ DELETE FROM web_source_bilingual_pairings
233
+ WHERE owner_id=? AND kb_name=? AND source_id=?
234
+ """,
235
+ (owner_id, kb_name, source_id),
236
+ )
237
+ connection.execute("COMMIT")
238
+ except Exception:
239
+ connection.execute("ROLLBACK")
240
+ raise
241
+
242
+ def ensure_source(self, source_key: tuple[str, str, str]) -> WebSourceSyncJob:
243
+ """Create a due job for one source without touching unrelated rows."""
244
+ now = self._now_ms()
245
+ with self._connect() as connection:
246
+ connection.execute(
247
+ """
248
+ INSERT OR IGNORE INTO web_source_sync_jobs
249
+ (owner_id, kb_name, source_id, state, next_run_at_ms,
250
+ updated_at_ms)
251
+ VALUES (?, ?, ?, 'pending', ?, ?)
252
+ """,
253
+ (*source_key, now, now),
254
+ )
255
+ job = self.get(source_key)
256
+ if job is None:
257
+ raise RuntimeError("Web source synchronization job could not be created")
258
+ return job
259
+
260
+ def recover_interrupted(self, runner_id: str) -> None:
261
+ """Mark jobs left running by a prior process or expired lease as retryable."""
262
+ now = self._now_ms()
263
+ with self._connect() as connection:
264
+ connection.execute(
265
+ """
266
+ UPDATE web_source_sync_jobs
267
+ SET state='interrupted',
268
+ error='Synchronization was interrupted by a restart',
269
+ runner_id='',
270
+ lease_until_ms=NULL,
271
+ next_run_at_ms=?,
272
+ updated_at_ms=?
273
+ WHERE state='running' AND runner_id<>?
274
+ AND (lease_until_ms IS NULL OR lease_until_ms<=?)
275
+ """,
276
+ (now, now, runner_id, now),
277
+ )
278
+
279
+ def list_jobs(self, owner_id: str, kb_name: str) -> list[WebSourceSyncJob]:
280
+ with self._connect() as connection:
281
+ rows = connection.execute(
282
+ """
283
+ SELECT * FROM web_source_sync_jobs
284
+ WHERE owner_id=? AND kb_name=?
285
+ ORDER BY next_run_at_ms, source_id
286
+ """,
287
+ (owner_id, kb_name),
288
+ ).fetchall()
289
+ return [WebSourceSyncJob.from_row(row) for row in rows]
290
+
291
+ def due_jobs(self, now_ms: int | None = None) -> list[WebSourceSyncJob]:
292
+ now = self._now_ms() if now_ms is None else now_ms
293
+ with self._connect() as connection:
294
+ rows = connection.execute(
295
+ """
296
+ SELECT * FROM web_source_sync_jobs
297
+ WHERE state IN ('pending', 'interrupted', 'error')
298
+ AND next_run_at_ms<=?
299
+ ORDER BY next_run_at_ms, owner_id, kb_name, source_id
300
+ """,
301
+ (now,),
302
+ ).fetchall()
303
+ return [WebSourceSyncJob.from_row(row) for row in rows]
304
+
305
+ def claim(
306
+ self,
307
+ job: WebSourceSyncJob,
308
+ *,
309
+ runner_id: str,
310
+ lease_until_ms: int,
311
+ ) -> WebSourceSyncJob | None:
312
+ """Claim one job only when its durable state still matches."""
313
+ now = self._now_ms()
314
+ with self._connect() as connection:
315
+ connection.execute("BEGIN IMMEDIATE")
316
+ try:
317
+ row = connection.execute(
318
+ """
319
+ SELECT * FROM web_source_sync_jobs
320
+ WHERE owner_id=? AND kb_name=? AND source_id=?
321
+ """,
322
+ self.key(job),
323
+ ).fetchone()
324
+ if row is None:
325
+ connection.execute("COMMIT")
326
+ return None
327
+ current = WebSourceSyncJob.from_row(row)
328
+ runnable = current.state in {"pending", "interrupted", "cancelled", "error"}
329
+ lease_expired = (
330
+ current.state == "running"
331
+ and current.lease_until_ms is not None
332
+ and current.lease_until_ms <= now
333
+ )
334
+ if not (runnable or lease_expired):
335
+ connection.execute("COMMIT")
336
+ return None
337
+ connection.execute(
338
+ """
339
+ UPDATE web_source_sync_jobs
340
+ SET state='running', runner_id=?, lease_until_ms=?,
341
+ cancel_requested=0, updated_at_ms=?
342
+ WHERE owner_id=? AND kb_name=? AND source_id=?
343
+ """,
344
+ (runner_id, lease_until_ms, now, *self.key(job)),
345
+ )
346
+ connection.execute("COMMIT")
347
+ current.state = "running"
348
+ current.runner_id = runner_id
349
+ current.lease_until_ms = lease_until_ms
350
+ current.cancel_requested = False
351
+ return current
352
+ except Exception:
353
+ connection.execute("ROLLBACK")
354
+ raise
355
+
356
+ def renew_lease(self, job: WebSourceSyncJob, lease_until_ms: int) -> bool:
357
+ """Extend only the still-owned, unexpired claim."""
358
+ now = self._now_ms()
359
+ with self._connect() as connection:
360
+ cursor = connection.execute(
361
+ """
362
+ UPDATE web_source_sync_jobs
363
+ SET lease_until_ms=?, updated_at_ms=?
364
+ WHERE owner_id=? AND kb_name=? AND source_id=?
365
+ AND state='running' AND runner_id=? AND lease_until_ms>?
366
+ """,
367
+ (lease_until_ms, now, *self.key(job), job.runner_id, now),
368
+ )
369
+ return int(cursor.rowcount or 0) == 1
370
+
371
+ def _update(
372
+ self,
373
+ job: WebSourceSyncJob,
374
+ **fields: Any,
375
+ ) -> bool:
376
+ assignments = list(fields)
377
+ if not assignments:
378
+ return False
379
+ fields["updated_at_ms"] = self._now_ms()
380
+ assignments.append("updated_at_ms")
381
+ values = list(fields.values())
382
+ set_sql = ", ".join(f"{name}=?" for name in assignments)
383
+ with self._connect() as connection:
384
+ cursor = connection.execute(
385
+ f"""
386
+ UPDATE web_source_sync_jobs
387
+ SET {set_sql}
388
+ WHERE owner_id=? AND kb_name=? AND source_id=?
389
+ AND state='running' AND runner_id=? AND lease_until_ms>?
390
+ """, # nosec B608 - SET names come from _update's internal keyword callers; every value is bound
391
+ (*values, *self.key(job), job.runner_id, self._now_ms()),
392
+ )
393
+ return int(cursor.rowcount or 0) == 1
394
+
395
+ def mark_success(self, job: WebSourceSyncJob, next_run_at_ms: int) -> None:
396
+ self._update(
397
+ job,
398
+ state="pending",
399
+ next_run_at_ms=next_run_at_ms,
400
+ last_run_at_ms=self._now_ms(),
401
+ attempt=0,
402
+ error=None,
403
+ cancel_requested=False,
404
+ runner_id="",
405
+ lease_until_ms=None,
406
+ )
407
+
408
+ def mark_failure(
409
+ self,
410
+ job: WebSourceSyncJob,
411
+ *,
412
+ error: str,
413
+ next_run_at_ms: int,
414
+ ) -> None:
415
+ self._update(
416
+ job,
417
+ state="error",
418
+ next_run_at_ms=next_run_at_ms,
419
+ last_run_at_ms=self._now_ms(),
420
+ attempt=job.attempt + 1,
421
+ error=error[:2000],
422
+ cancel_requested=False,
423
+ runner_id="",
424
+ lease_until_ms=None,
425
+ )
426
+
427
+ def mark_interrupted(self, job: WebSourceSyncJob) -> None:
428
+ self._update(
429
+ job,
430
+ state="interrupted",
431
+ next_run_at_ms=self._now_ms(),
432
+ error="Synchronization was interrupted",
433
+ cancel_requested=False,
434
+ runner_id="",
435
+ lease_until_ms=None,
436
+ )
437
+
438
+ def mark_cancelled(self, job: WebSourceSyncJob, next_run_at_ms: int | None = None) -> None:
439
+ now = self._now_ms()
440
+ with self._connect() as connection:
441
+ connection.execute(
442
+ """
443
+ UPDATE web_source_sync_jobs
444
+ SET state='cancelled', cancel_requested=0, runner_id='',
445
+ lease_until_ms=NULL, last_run_at_ms=?, updated_at_ms=?,
446
+ next_run_at_ms=COALESCE(?, next_run_at_ms)
447
+ WHERE owner_id=? AND kb_name=? AND source_id=?
448
+ AND state='running' AND runner_id=? AND lease_until_ms>?
449
+ """,
450
+ (now, now, next_run_at_ms, *self.key(job), job.runner_id, now),
451
+ )
452
+
453
+ def request_cancel(self, job_key: tuple[str, str, str]) -> bool:
454
+ now = self._now_ms()
455
+ with self._connect() as connection:
456
+ connection.execute("BEGIN IMMEDIATE")
457
+ try:
458
+ row = connection.execute(
459
+ """
460
+ SELECT state FROM web_source_sync_jobs
461
+ WHERE owner_id=? AND kb_name=? AND source_id=?
462
+ """,
463
+ job_key,
464
+ ).fetchone()
465
+ if row is None:
466
+ connection.execute("COMMIT")
467
+ return False
468
+ state = str(row["state"])
469
+ if state not in {"pending", "running", "interrupted", "error"}:
470
+ connection.execute("COMMIT")
471
+ return False
472
+ cancel_requested = 1 if state == "running" else 0
473
+ if state == "pending":
474
+ connection.execute(
475
+ """
476
+ UPDATE web_source_sync_jobs
477
+ SET state='cancelled', cancel_requested=0, runner_id='',
478
+ lease_until_ms=NULL, updated_at_ms=?
479
+ WHERE owner_id=? AND kb_name=? AND source_id=?
480
+ """,
481
+ (now, *job_key),
482
+ )
483
+ else:
484
+ connection.execute(
485
+ """
486
+ UPDATE web_source_sync_jobs
487
+ SET cancel_requested=?, updated_at_ms=?
488
+ WHERE owner_id=? AND kb_name=? AND source_id=?
489
+ """,
490
+ (cancel_requested, now, *job_key),
491
+ )
492
+ connection.execute("COMMIT")
493
+ return True
494
+ except Exception:
495
+ connection.execute("ROLLBACK")
496
+ raise
497
+
498
+ def delete(self, job_key: tuple[str, str, str]) -> bool:
499
+ """Remove a job after its source has been deleted."""
500
+ with self._connect() as connection:
501
+ cursor = connection.execute(
502
+ """
503
+ DELETE FROM web_source_sync_jobs
504
+ WHERE owner_id=? AND kb_name=? AND source_id=?
505
+ """,
506
+ job_key,
507
+ )
508
+ connection.execute(
509
+ """
510
+ DELETE FROM web_source_bilingual_pairings
511
+ WHERE owner_id=? AND kb_name=? AND source_id=?
512
+ """,
513
+ job_key,
514
+ )
515
+ return int(cursor.rowcount or 0) > 0
516
+
517
+ def retry(self, job_key: tuple[str, str, str]) -> WebSourceSyncJob | None:
518
+ now = self._now_ms()
519
+ with self._connect() as connection:
520
+ cursor = connection.execute(
521
+ """
522
+ UPDATE web_source_sync_jobs
523
+ SET state='pending', next_run_at_ms=?, attempt=0, error=NULL,
524
+ cancel_requested=0, runner_id='', lease_until_ms=NULL,
525
+ updated_at_ms=?
526
+ WHERE owner_id=? AND kb_name=? AND source_id=?
527
+ AND state IN ('error', 'interrupted', 'cancelled')
528
+ """,
529
+ (now, now, *job_key),
530
+ )
531
+ changed = int(cursor.rowcount or 0) > 0
532
+ if not changed:
533
+ return None
534
+ return self.get(job_key)
535
+
536
+ def get(self, job_key: tuple[str, str, str]) -> WebSourceSyncJob | None:
537
+ with self._connect() as connection:
538
+ row = connection.execute(
539
+ """
540
+ SELECT * FROM web_source_sync_jobs
541
+ WHERE owner_id=? AND kb_name=? AND source_id=?
542
+ """,
543
+ job_key,
544
+ ).fetchone()
545
+ return WebSourceSyncJob.from_row(row) if row else None
546
+
547
+ def record_pairings(
548
+ self,
549
+ owner_id: str,
550
+ kb_name: str,
551
+ source_id: str,
552
+ pairings: list[dict[str, Any]],
553
+ ) -> list[WebSourceBilingualPairing]:
554
+ """Atomically replace bilingual pairings for one web source."""
555
+ now = self._now_ms()
556
+ with self._connect() as connection:
557
+ connection.execute("BEGIN IMMEDIATE")
558
+ try:
559
+ connection.execute(
560
+ """
561
+ DELETE FROM web_source_bilingual_pairings
562
+ WHERE owner_id=? AND kb_name=? AND source_id=?
563
+ """,
564
+ (owner_id, kb_name, source_id),
565
+ )
566
+ for item in pairings:
567
+ connection.execute(
568
+ """
569
+ INSERT INTO web_source_bilingual_pairings
570
+ (owner_id, kb_name, source_id, pairing_id, source_url,
571
+ target_url, source_file, target_file, source_lang,
572
+ target_lang, pairing_method, updated_at_ms)
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
574
+ """,
575
+ (
576
+ owner_id,
577
+ kb_name,
578
+ source_id,
579
+ str(item["pairing_id"]),
580
+ str(item["source_url"]),
581
+ str(item["target_url"]),
582
+ str(item.get("source_file", "")),
583
+ str(item.get("target_file", "")),
584
+ str(item.get("source_lang", "")),
585
+ str(item.get("target_lang", "")),
586
+ str(item.get("pairing_method", "hreflang")),
587
+ now,
588
+ ),
589
+ )
590
+ connection.execute("COMMIT")
591
+ except Exception:
592
+ connection.execute("ROLLBACK")
593
+ raise
594
+ return self.list_pairings(owner_id, kb_name, source_id)
595
+
596
+ def list_pairings(
597
+ self,
598
+ owner_id: str,
599
+ kb_name: str,
600
+ source_id: str | None = None,
601
+ ) -> list[WebSourceBilingualPairing]:
602
+ with self._connect() as connection:
603
+ if source_id is not None:
604
+ rows = connection.execute(
605
+ """
606
+ SELECT * FROM web_source_bilingual_pairings
607
+ WHERE owner_id=? AND kb_name=? AND source_id=?
608
+ ORDER BY source_url, target_url
609
+ """,
610
+ (owner_id, kb_name, source_id),
611
+ ).fetchall()
612
+ else:
613
+ rows = connection.execute(
614
+ """
615
+ SELECT * FROM web_source_bilingual_pairings
616
+ WHERE owner_id=? AND kb_name=?
617
+ ORDER BY source_id, source_url, target_url
618
+ """,
619
+ (owner_id, kb_name),
620
+ ).fetchall()
621
+ return [WebSourceBilingualPairing.from_row(row) for row in rows]
622
+
623
+ def delete_pairings(
624
+ self,
625
+ owner_id: str,
626
+ kb_name: str,
627
+ source_id: str,
628
+ ) -> int:
629
+ with self._connect() as connection:
630
+ cursor = connection.execute(
631
+ """
632
+ DELETE FROM web_source_bilingual_pairings
633
+ WHERE owner_id=? AND kb_name=? AND source_id=?
634
+ """,
635
+ (owner_id, kb_name, source_id),
636
+ )
637
+ return int(cursor.rowcount or 0)
638
+
639
+
640
+ __all__ = ["SQLiteWebSourceSyncRepository", "WebSourceBilingualPairing", "WebSourceSyncJob"]