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,432 @@
1
+ """Durable per-KB indexing state and ownership at reliable boundaries (#1612).
2
+
3
+ Parsing caches remain the reusable work store. This journal records what was
4
+ accepted, parsed, failed, published, or unknown after interruption; it is not a
5
+ queue or a second parsing cache.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from hashlib import sha256
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import sys
16
+ import threading
17
+ import time
18
+ from typing import Any
19
+ from uuid import uuid4
20
+
21
+ try:
22
+ import psutil
23
+ except ImportError: # CLI-only installs need no process-monitor dependency.
24
+ psutil = None
25
+
26
+ from deeptutor.services.file_io import atomic_write_json, read_text
27
+ from deeptutor.services.parsing.progress import (
28
+ _CURRENT,
29
+ )
30
+ from deeptutor.services.parsing.progress import (
31
+ IndexingCancelled as IndexingCancelled,
32
+ )
33
+ from deeptutor.services.parsing.progress import (
34
+ _redact as _redact,
35
+ )
36
+ from deeptutor.services.parsing.progress import (
37
+ current_run as current_run,
38
+ )
39
+ from deeptutor.services.parsing.progress import (
40
+ failure_code as failure_code,
41
+ )
42
+
43
+ _TERMINAL = {"completed", "partial", "failed", "cancelled", "interrupted"}
44
+ _RECOVERY_DESCRIPTION = (
45
+ "Retry reuses validated parser outputs and resumable LlamaIndex document "
46
+ "checkpoints. Uncheckpointed chunking and embedding work restarts."
47
+ )
48
+
49
+
50
+ class IndexingBusyError(RuntimeError):
51
+ pass
52
+
53
+
54
+ def load_run(kb_dir: Path) -> dict[str, Any] | None:
55
+ path = Path(kb_dir) / ".indexing-run.json"
56
+ try:
57
+ value = json.loads(read_text(path))
58
+ except FileNotFoundError:
59
+ return None
60
+ except (OSError, ValueError) as exc:
61
+ raise OSError("Indexing journal is unreadable; original file preserved.") from exc
62
+ if (
63
+ not isinstance(value, dict)
64
+ or value.get("schema") != 1
65
+ or not isinstance(value.get("documents"), dict)
66
+ ):
67
+ raise OSError("Indexing journal is invalid; original file preserved.")
68
+ try:
69
+ cancelled = json.loads(read_text(Path(kb_dir) / ".indexing-cancel.json"))
70
+ except FileNotFoundError:
71
+ cancelled = {}
72
+ except (OSError, ValueError) as exc:
73
+ raise OSError(
74
+ "Indexing cancellation state is unreadable; original file preserved."
75
+ ) from exc
76
+ value["cancel_requested"] = bool(
77
+ isinstance(cancelled, dict) and cancelled.get("task_id") == value.get("task_id")
78
+ )
79
+ return value
80
+
81
+
82
+ def _lease_is_held(kb_dir: Path) -> bool:
83
+ try:
84
+ with (Path(kb_dir) / ".indexing.lock").open("r+b") as lease:
85
+ if sys.platform == "win32":
86
+ import msvcrt
87
+
88
+ msvcrt.locking(lease.fileno(), msvcrt.LK_NBLCK, 1)
89
+ msvcrt.locking(lease.fileno(), msvcrt.LK_UNLCK, 1)
90
+ else:
91
+ import fcntl
92
+
93
+ fcntl.flock(lease.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
94
+ fcntl.flock(lease.fileno(), fcntl.LOCK_UN)
95
+ return False
96
+ except FileNotFoundError:
97
+ return False
98
+ except OSError:
99
+ return True
100
+
101
+
102
+ def visible_run(kb_dir: Path) -> dict[str, Any] | None:
103
+ """Pure projection: a crashed owner's in-flight work becomes unknown."""
104
+ value = load_run(kb_dir)
105
+ if value is None or value.get("state") in _TERMINAL:
106
+ return value
107
+ try:
108
+ alive = (
109
+ psutil.Process(int(value["pid"])).create_time() == value["process_started_at"]
110
+ if psutil is not None
111
+ else _lease_is_held(kb_dir)
112
+ )
113
+ except Exception:
114
+ alive = False
115
+ if alive:
116
+ return value
117
+ documents = {
118
+ key: {
119
+ **doc,
120
+ "status": "unknown"
121
+ if doc.get("status") in {"pending", "parsing", "embedding"}
122
+ else doc.get("status"),
123
+ }
124
+ for key, doc in value["documents"].items()
125
+ }
126
+ return {
127
+ **value,
128
+ "state": "interrupted",
129
+ "documents": documents,
130
+ "recovery": _RECOVERY_DESCRIPTION,
131
+ }
132
+
133
+
134
+ class IndexingRun:
135
+ def __init__(
136
+ self,
137
+ kb_dir: Path,
138
+ sources: list[str],
139
+ *,
140
+ task_id: str = "",
141
+ action: str = "rebuild",
142
+ provider: str = "",
143
+ reporter=None,
144
+ ):
145
+ self.kb_dir = Path(kb_dir)
146
+ self.path = self.kb_dir / ".indexing-run.json"
147
+ self.task_id = task_id or f"index_{uuid4().hex}"
148
+ self.action = action
149
+ self.provider = provider
150
+ self.sources = sources
151
+ self.reporter = reporter
152
+ self._mutex = threading.RLock()
153
+ self.active = False
154
+ self.data: dict[str, Any] = {}
155
+ self._lock = None
156
+ self._token = None
157
+ self._source_paths: dict[str, Path] = {}
158
+ self.previous_run: dict[str, Any] | None = None
159
+
160
+ def __enter__(self):
161
+ return self._start()
162
+
163
+ async def __aenter__(self):
164
+ future = asyncio.create_task(asyncio.to_thread(self._start, False))
165
+ try:
166
+ await asyncio.shield(future)
167
+ except asyncio.CancelledError:
168
+ await future
169
+ self.finish("cancelled")
170
+ self.active = False
171
+ self._release()
172
+ raise
173
+ self._token = _CURRENT.set(self)
174
+ return self
175
+
176
+ async def __aexit__(self, kind, exc, traceback):
177
+ self.__exit__(kind, exc, traceback)
178
+
179
+ def _start(self, bind_context=True):
180
+ self.kb_dir.mkdir(parents=True, exist_ok=True)
181
+ self._lock = (self.kb_dir / ".indexing.lock").open("a+b")
182
+ try:
183
+ if sys.platform == "win32":
184
+ import msvcrt
185
+
186
+ self._lock.seek(0)
187
+ if not self._lock.read(1):
188
+ self._lock.write(b"\0")
189
+ self._lock.flush()
190
+ self._lock.seek(0)
191
+ msvcrt.locking(self._lock.fileno(), msvcrt.LK_NBLCK, 1)
192
+ else:
193
+ import fcntl
194
+
195
+ fcntl.flock(self._lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
196
+ except OSError as exc:
197
+ self._lock.close()
198
+ raise IndexingBusyError(
199
+ "An indexing worker still owns this knowledge base. Wait for it to settle before retrying."
200
+ ) from exc
201
+ try:
202
+ previous = visible_run(self.kb_dir)
203
+ now = time.time()
204
+ self.data = {
205
+ "schema": 1,
206
+ "task_id": self.task_id,
207
+ "action": self.action,
208
+ "provider": self.provider,
209
+ "pid": os.getpid(),
210
+ "process_started_at": psutil.Process().create_time()
211
+ if psutil is not None
212
+ else None,
213
+ "state": "running",
214
+ "phase": "preflight",
215
+ "started_at": now,
216
+ "last_activity_at": now,
217
+ "last_progress_at": now,
218
+ "documents": {},
219
+ "previous_task_id": previous.get("task_id") if previous else None,
220
+ "recovery": _RECOVERY_DESCRIPTION,
221
+ "cancel_requested": False,
222
+ "usable_version": None,
223
+ "candidate_storage": None,
224
+ }
225
+ from deeptutor.utils.file_types import FileTypeRouter
226
+
227
+ candidates = list(dict.fromkeys(self.sources))
228
+ raw = self.kb_dir / "raw"
229
+ if self.action != "upload" and raw.is_dir():
230
+ candidates.extend(
231
+ str(path)
232
+ for path in raw.rglob("*")
233
+ if path.is_file() and not path.is_symlink() and str(path) not in candidates
234
+ )
235
+ classification = FileTypeRouter.classify_files(candidates)
236
+ unsupported = set(classification.unsupported)
237
+ for name in candidates:
238
+ source = Path(name)
239
+ key = self.source_key(source)
240
+ self._source_paths[key] = source
241
+ try:
242
+ digest = self._hash(source)
243
+ reason = "unsupported_input" if name in unsupported else ""
244
+ status = "excluded" if reason else "pending"
245
+ except OSError as exc:
246
+ digest, reason, status = "", _redact(str(exc)), "failed"
247
+ self.data["documents"][key] = {
248
+ "status": status,
249
+ "reason": reason,
250
+ "source_hash": digest,
251
+ "parse_reused": False,
252
+ }
253
+ self.active = True
254
+ self.previous_run = previous
255
+ if bind_context:
256
+ self._token = _CURRENT.set(self)
257
+ self._save()
258
+ return self
259
+ except BaseException:
260
+ self.active = False
261
+ if self._token is not None:
262
+ _CURRENT.reset(self._token)
263
+ self._release()
264
+ raise
265
+
266
+ def __exit__(self, kind, exc, traceback):
267
+ try:
268
+ if exc is not None:
269
+ self.finish(
270
+ "partial"
271
+ if self.data.get("usable_version")
272
+ else "cancelled"
273
+ if isinstance(exc, IndexingCancelled)
274
+ or (load_run(self.kb_dir) or {}).get("cancel_requested")
275
+ else "failed",
276
+ version=self.data.get("usable_version"),
277
+ error=exc,
278
+ )
279
+ elif self.data.get("state") not in _TERMINAL:
280
+ self.finish("failed", error=RuntimeError("Index readiness was not verified."))
281
+ finally:
282
+ self.active = False
283
+ if self._token is not None:
284
+ _CURRENT.reset(self._token)
285
+ self._release()
286
+
287
+ def _release(self):
288
+ if self._lock is not None and not self._lock.closed:
289
+ if sys.platform == "win32":
290
+ import msvcrt
291
+
292
+ self._lock.seek(0)
293
+ msvcrt.locking(self._lock.fileno(), msvcrt.LK_UNLCK, 1)
294
+ else:
295
+ import fcntl
296
+
297
+ fcntl.flock(self._lock.fileno(), fcntl.LOCK_UN)
298
+ self._lock.close()
299
+
300
+ def set_candidate_storage(self, storage_dir: Path) -> None:
301
+ """Record the unpublished output that a retry may resume."""
302
+ self.data["candidate_storage"] = str(storage_dir)
303
+ self._save()
304
+
305
+ def source_key(self, source: Path) -> str:
306
+ try:
307
+ return source.resolve().relative_to((self.kb_dir / "raw").resolve()).as_posix()
308
+ except ValueError:
309
+ return (
310
+ f"external-{sha256(str(source.resolve()).encode()).hexdigest()[:12]}/{source.name}"
311
+ )
312
+
313
+ def source_receipts(self) -> dict[str, tuple[Path, str]]:
314
+ """Return the current run's source path and hash by durable key."""
315
+ return {
316
+ key: (self._source_paths[key], str(doc.get("source_hash", "")))
317
+ for key, doc in self.data["documents"].items()
318
+ if key in self._source_paths and doc.get("source_hash")
319
+ }
320
+
321
+ @staticmethod
322
+ def _hash(source: Path) -> str:
323
+ digest = sha256()
324
+ with source.open("rb") as stream:
325
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
326
+ digest.update(chunk)
327
+ return digest.hexdigest()
328
+
329
+ def check(self):
330
+ if not self.active:
331
+ raise IndexingCancelled("This indexing worker no longer owns the run.")
332
+ stored = load_run(self.kb_dir)
333
+ if stored and stored.get("task_id") != self.task_id:
334
+ raise IndexingCancelled("A newer run owns the indexing journal.")
335
+ if stored and stored.get("cancel_requested"):
336
+ self.data["cancel_requested"] = True
337
+ raise IndexingCancelled(
338
+ "Cancellation reached a safe boundary; validated completed parsing remains reusable."
339
+ )
340
+
341
+ def phase(
342
+ self,
343
+ phase: str,
344
+ *,
345
+ source: Path | None = None,
346
+ current: int | None = None,
347
+ total: int | None = None,
348
+ activity: str = "",
349
+ ):
350
+ with self._mutex:
351
+ self.check()
352
+ now = time.time()
353
+ changed = self.data.get("phase") != phase or (
354
+ current is not None and current != self.data.get("phase_current")
355
+ )
356
+ self.data.update(
357
+ {
358
+ "phase": phase,
359
+ "last_activity_at": now,
360
+ "current_document": self.source_key(source)
361
+ if source
362
+ else self.data.get("current_document"),
363
+ "phase_current": current,
364
+ "phase_total": total,
365
+ "activity": _redact(activity),
366
+ }
367
+ )
368
+ if changed:
369
+ self.data["last_progress_at"] = now
370
+ self._save()
371
+
372
+ def document(self, source: Path, status: str, **details):
373
+ with self._mutex:
374
+ if not self.active:
375
+ return
376
+ stored = load_run(self.kb_dir)
377
+ if stored and stored.get("task_id") != self.task_id:
378
+ return
379
+ key = self.source_key(source)
380
+ doc = self.data["documents"].setdefault(key, {})
381
+ doc.update({"status": status, "updated_at": time.time(), **details})
382
+ self.data["last_progress_at"] = time.time()
383
+ self._save()
384
+
385
+ def verify_sources(self):
386
+ self.check()
387
+ for key, doc in self.data["documents"].items():
388
+ if doc.get("status") in {"parsed", "embedding", "completed"}:
389
+ if self._hash(self._source_paths[key]) != doc.get("source_hash"):
390
+ raise RuntimeError(
391
+ f"Source changed during indexing: {key}. Retry with the current source."
392
+ )
393
+
394
+ def finish(self, state: str, *, version: str | None = None, error: BaseException | None = None):
395
+ with self._mutex:
396
+ if not self.active:
397
+ return
398
+ stored = load_run(self.kb_dir)
399
+ if stored and stored.get("task_id") != self.task_id:
400
+ return
401
+ for doc in self.data["documents"].values():
402
+ if doc.get("status") in {"pending", "parsing", "embedding", "parsed"}:
403
+ doc["status"] = "unknown"
404
+ self.data.update(
405
+ {
406
+ "state": state,
407
+ "finished_at": time.time(),
408
+ "usable_version": version,
409
+ "error_code": failure_code(error) if error else None,
410
+ "error": _redact(str(error)) if error else None,
411
+ }
412
+ )
413
+ self._save()
414
+
415
+ def _save(self):
416
+ atomic_write_json(self.path, self.data)
417
+ if self.reporter is not None:
418
+ try:
419
+ self.reporter(dict(self.data))
420
+ except Exception:
421
+ # Notification delivery cannot invalidate the durable checkpoint.
422
+ pass
423
+
424
+
425
+ def request_cancel(kb_dir: Path, task_id: str) -> bool:
426
+ value = load_run(kb_dir)
427
+ if value is None or value.get("task_id") != task_id or value.get("state") in _TERMINAL:
428
+ return False
429
+ atomic_write_json(
430
+ Path(kb_dir) / ".indexing-cancel.json", {"task_id": task_id, "requested_at": time.time()}
431
+ )
432
+ return True