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