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