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,556 @@
1
+ """LlamaIndex-backed RAG pipeline orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextvars import copy_context
7
+ import json
8
+ import logging
9
+ from pathlib import Path
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from typing import Any, Callable, Dict, List, Optional
14
+
15
+ from deeptutor.runtime.home import get_runtime_data_root
16
+ from deeptutor.services.embedding import get_embedding_config
17
+ from deeptutor.services.rag.embedding_signature import signature_from_embedding_config
18
+ from deeptutor.services.rag.index_versioning import (
19
+ EmbeddingSignature,
20
+ resolve_storage_dir_for_read,
21
+ resolve_storage_dir_for_rebuild,
22
+ write_version_meta,
23
+ )
24
+ from deeptutor.services.rag.kb_paths import resolve_kb_dir
25
+ from deeptutor.services.rag.visual_assets import VisualAssetStore
26
+
27
+ from . import storage
28
+ from .config import default_top_k, should_show_progress
29
+ from .document_loader import LlamaIndexDocumentLoader
30
+ from .embedding_adapter import (
31
+ configure_llamaindex_settings,
32
+ set_progress_callback,
33
+ verify_embedding_connectivity,
34
+ )
35
+ from .errors import search_error_result
36
+
37
+ DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
38
+
39
+ # How long an indexing step may run without reporting any progress before it
40
+ # is treated as stalled (see _run_with_stall_guard).
41
+ _INDEX_STALL_TIMEOUT_SECONDS = 600.0
42
+ # How often the stall guard checks the progress heartbeat.
43
+ _INDEX_STALL_POLL_SECONDS = 5.0
44
+ _INDEX_WORKERS: dict[str, threading.Event] = {}
45
+ _INDEX_WORKERS_LOCK = threading.Lock()
46
+
47
+ SignatureProvider = Callable[[], EmbeddingSignature | None]
48
+
49
+
50
+ class IndexingStallError(RuntimeError):
51
+ """Raised when an indexing operation makes no progress for too long."""
52
+
53
+
54
+ def _worker_busy_error() -> IndexingStallError:
55
+ return IndexingStallError(
56
+ "A previous indexing worker for this knowledge base is still running. "
57
+ "Wait for it to finish or restart DeepTutor before retrying."
58
+ )
59
+
60
+
61
+ def _ensure_index_worker_available(key: str) -> None:
62
+ with _INDEX_WORKERS_LOCK:
63
+ marker = _INDEX_WORKERS.get(key)
64
+ if marker is not None and not marker.is_set():
65
+ raise _worker_busy_error()
66
+
67
+
68
+ def _claim_index_worker(key: str | None) -> threading.Event | None:
69
+ if key is None:
70
+ return None
71
+ with _INDEX_WORKERS_LOCK:
72
+ previous = _INDEX_WORKERS.get(key)
73
+ if previous is not None and not previous.is_set():
74
+ raise _worker_busy_error()
75
+ marker = threading.Event()
76
+ _INDEX_WORKERS[key] = marker
77
+ return marker
78
+
79
+
80
+ def _release_index_worker(key: str | None, marker: threading.Event | None) -> None:
81
+ if key is None or marker is None:
82
+ return
83
+ marker.set()
84
+ with _INDEX_WORKERS_LOCK:
85
+ if _INDEX_WORKERS.get(key) is marker:
86
+ _INDEX_WORKERS.pop(key, None)
87
+
88
+
89
+ async def _run_with_stall_guard(
90
+ fn: Callable[[], Any],
91
+ *,
92
+ progress_callback: Optional[Callable[..., Any]] = None,
93
+ stall_timeout: Optional[float] = None,
94
+ worker_key: str | None = None,
95
+ ) -> Any:
96
+ """Run a synchronous indexing step in the executor, failing if it stalls.
97
+
98
+ Indexing steps (chunking + embedding) run in a worker thread via
99
+ ``run_in_executor``. A provider that accepts a request but never
100
+ completes it (e.g. a blackholed keep-alive connection) can block that
101
+ thread indefinitely: per-request HTTP timeouts only bound a single
102
+ attempt, and provider retries extend the wait far beyond any reasonable
103
+ budget. Instead of hanging forever, watch the embedding progress
104
+ heartbeat and fail with a clear error once no progress has been reported
105
+ for ``stall_timeout`` seconds.
106
+
107
+ The sync function keeps running in its thread after a stall is raised —
108
+ Python cannot interrupt arbitrary synchronous code. Its callback becomes
109
+ silent and a same-KB retry is rejected until that thread actually exits.
110
+ """
111
+ if stall_timeout is None:
112
+ stall_timeout = _INDEX_STALL_TIMEOUT_SECONDS
113
+
114
+ worker_marker = _claim_index_worker(worker_key)
115
+ progress_live = threading.Event()
116
+ progress_live.set()
117
+ last_progress = {"at": time.monotonic()}
118
+
119
+ def _heartbeat(*args: Any, **kwargs: Any) -> None:
120
+ if not progress_live.is_set():
121
+ return
122
+ last_progress["at"] = time.monotonic()
123
+ if progress_callback is not None:
124
+ progress_callback(*args, **kwargs)
125
+
126
+ def _work() -> Any:
127
+ try:
128
+ return fn()
129
+ finally:
130
+ _release_index_worker(worker_key, worker_marker)
131
+
132
+ set_progress_callback(_heartbeat)
133
+ try:
134
+ future = asyncio.get_running_loop().run_in_executor(None, copy_context().run, _work)
135
+ except BaseException:
136
+ progress_live.clear()
137
+ _release_index_worker(worker_key, worker_marker)
138
+ raise
139
+
140
+ def _consume_terminal_exception(fut: "asyncio.Future[Any]") -> None:
141
+ # The stalled thread may finish after we raise; retrieve its exception
142
+ # so it is not reported as "exception was never retrieved".
143
+ if not fut.cancelled():
144
+ fut.exception()
145
+
146
+ try:
147
+ while True:
148
+ done, _ = await asyncio.wait({future}, timeout=_INDEX_STALL_POLL_SECONDS)
149
+ if done:
150
+ return future.result()
151
+ stalled_for = time.monotonic() - last_progress["at"]
152
+ if stalled_for > stall_timeout:
153
+ raise IndexingStallError(
154
+ f"Indexing made no progress for {stalled_for:.0f}s while "
155
+ "embedding documents. Check the embedding endpoint; wait "
156
+ "for the worker to finish or restart DeepTutor before retrying."
157
+ )
158
+ finally:
159
+ progress_live.clear()
160
+ if not future.done():
161
+ future.add_done_callback(_consume_terminal_exception)
162
+
163
+
164
+ class LlamaIndexPipeline:
165
+ """Pipeline that indexes and retrieves KB content via LlamaIndex."""
166
+
167
+ def __init__(
168
+ self,
169
+ kb_base_dir: Optional[str] = None,
170
+ *,
171
+ signature_provider: SignatureProvider | None = None,
172
+ document_loader: LlamaIndexDocumentLoader | None = None,
173
+ ):
174
+ self.logger = logging.getLogger(__name__)
175
+ self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
176
+ self._signature_provider = signature_provider or signature_from_embedding_config
177
+ self.document_loader = document_loader or LlamaIndexDocumentLoader(self.logger)
178
+
179
+ def _configure_settings(self) -> None:
180
+ configure_llamaindex_settings(self.logger)
181
+
182
+ async def _verify_embedding_connectivity(self) -> None:
183
+ await verify_embedding_connectivity(self.logger)
184
+
185
+ def _current_signature(self) -> EmbeddingSignature | None:
186
+ return self._signature_provider()
187
+
188
+ def _cleanup_failed_version_dir(self, storage_dir: Path, signature: Optional[Any]) -> None:
189
+ _ = signature
190
+ try:
191
+ if storage.cleanup_failed_version_dir(storage_dir):
192
+ self.logger.info(
193
+ f"Removed empty version dir after failed pipeline run: {storage_dir}"
194
+ )
195
+ except Exception as cleanup_exc: # pragma: no cover - best-effort
196
+ self.logger.warning(
197
+ f"Could not clean up failed version dir for {storage_dir}: {cleanup_exc}"
198
+ )
199
+
200
+ @staticmethod
201
+ def _embedding_source_records(run: Any) -> dict[str, storage.EmbeddingSourceRecord]:
202
+ return {
203
+ key: storage.EmbeddingSourceRecord(key=key, path=path, source_hash=source_hash)
204
+ for key, (path, source_hash) in run.source_receipts().items()
205
+ }
206
+
207
+ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
208
+ from deeptutor.knowledge.indexing_run import current_run
209
+
210
+ progress_callback = kwargs.get("progress_callback")
211
+ image_progress_callback = kwargs.get("image_progress_callback")
212
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
213
+ _ensure_index_worker_available(str(kb_dir.resolve()))
214
+ self._configure_settings()
215
+
216
+ self.logger.info(
217
+ f"Initializing KB '{kb_name}' with {len(file_paths)} files using LlamaIndex"
218
+ )
219
+
220
+ signature = self._current_signature()
221
+ run = current_run()
222
+ source_records = self._embedding_source_records(run) if run is not None else {}
223
+ storage_dir = storage.resolve_resumable_rebuild_dir(
224
+ kb_dir,
225
+ signature,
226
+ previous_run=run.previous_run if run is not None else None,
227
+ source_records=source_records,
228
+ )
229
+ resumed = storage_dir is not None
230
+ if storage_dir is None:
231
+ storage_dir = resolve_storage_dir_for_rebuild(kb_dir, signature, publication_guard=True)
232
+ if run is not None:
233
+ run.set_candidate_storage(storage_dir)
234
+
235
+ try:
236
+ await self._verify_embedding_connectivity()
237
+ visual_candidates = []
238
+ documents = await self.document_loader.load(
239
+ file_paths,
240
+ image_progress_callback=image_progress_callback,
241
+ kb_dir=kb_dir,
242
+ visual_candidates=visual_candidates,
243
+ )
244
+ if not documents:
245
+ self.logger.error("No valid documents found")
246
+ return False
247
+
248
+ self.logger.info(
249
+ f"Creating VectorStoreIndex with {len(documents)} documents "
250
+ f"(chunking + embedding)..."
251
+ )
252
+
253
+ # #1802: validate and publish pixels before persisting nodes that
254
+ # reference them. Failed indexing can leave unused immutable assets;
255
+ # failed asset publication must not leave a partially accepted index.
256
+ VisualAssetStore(kb_dir).publish(visual_candidates, prune_missing=True)
257
+
258
+ def checkpoint_document(path: Path, embedding_reused: bool) -> None:
259
+ if run is not None:
260
+ run.document(
261
+ path,
262
+ "completed",
263
+ embedding_checkpoint=True,
264
+ embedding_reused=embedding_reused,
265
+ )
266
+
267
+ await _run_with_stall_guard(
268
+ lambda: storage.create_resumable_index(
269
+ documents,
270
+ storage_dir,
271
+ source_records=source_records,
272
+ signature=signature,
273
+ show_progress=should_show_progress(),
274
+ checkpoint_callback=checkpoint_document if run is not None else None,
275
+ ),
276
+ progress_callback=progress_callback,
277
+ worker_key=str(kb_dir.resolve()),
278
+ )
279
+
280
+ self.logger.info(f"Index persisted to {storage_dir}")
281
+ if resumed:
282
+ self.logger.info(f"Resumed interrupted LlamaIndex candidate at {storage_dir}")
283
+ await self._publish_verified(kb_dir, storage_dir, signature, documents)
284
+
285
+ indexed_file_callback = kwargs.get("indexed_file_callback")
286
+ if indexed_file_callback is not None:
287
+ indexed_paths: set[str] = set()
288
+ for document in documents:
289
+ metadata = getattr(document, "metadata", None)
290
+ if isinstance(metadata, dict):
291
+ path = metadata.get("file_path")
292
+ if isinstance(path, str) and path:
293
+ indexed_paths.add(path)
294
+ indexed_file_callback(sorted(indexed_paths))
295
+
296
+ self.logger.info(f"KB '{kb_name}' initialized successfully with LlamaIndex")
297
+ return True
298
+
299
+ except Exception as exc:
300
+ self.logger.error(f"Failed to initialize KB: {exc}")
301
+ self.logger.error(traceback.format_exc())
302
+ # A timed-out executor can still be writing to this directory.
303
+ if not isinstance(exc, IndexingStallError):
304
+ self._cleanup_failed_version_dir(storage_dir, signature)
305
+ raise
306
+ finally:
307
+ set_progress_callback(None)
308
+
309
+ async def search(
310
+ self,
311
+ query: str,
312
+ kb_name: str,
313
+ **kwargs,
314
+ ) -> Dict[str, Any]:
315
+ kwargs.pop("mode", None)
316
+ # Numbered exercises require structural lookup: tokenization discards
317
+ # chapter numbers, while top-k fragments omit tables and continuation.
318
+ # This uses completed parses only and leaves ordinary semantic queries
319
+ # on the existing embedding/retrieval path.
320
+ from .exercise_lookup import lookup_exercises, requested_exercises
321
+
322
+ if requested_exercises(query)[0]:
323
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
324
+ try:
325
+ exact = await asyncio.to_thread(
326
+ lookup_exercises, query, kb_dir, Path(self.kb_base_dir).parent / "parse_cache"
327
+ )
328
+ except Exception:
329
+ self.logger.exception("Exercise lookup unavailable; using normal retrieval")
330
+ else:
331
+ if exact is not None:
332
+ return exact
333
+ self._configure_settings()
334
+ self.logger.info(f"Searching KB '{kb_name}' with query: {query[:50]}...")
335
+
336
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
337
+ signature = self._current_signature()
338
+ storage_dir = resolve_storage_dir_for_read(kb_dir, signature)
339
+
340
+ if storage_dir is None or not (storage_dir / "docstore.json").exists():
341
+ self.logger.warning(
342
+ f"No matching index found for KB '{kb_name}' at signature "
343
+ f"{signature.hash() if signature else 'n/a'}"
344
+ )
345
+ return {
346
+ "query": query,
347
+ "answer": (
348
+ "This knowledge base has no index for its selected embedding "
349
+ "model. Re-index it (or switch back to a previously-used "
350
+ "embedding model) before querying."
351
+ ),
352
+ "content": "",
353
+ "provider": "llamaindex",
354
+ "needs_reindex": True,
355
+ }
356
+
357
+ embedding_mismatch_warning = self._embedding_mismatch_warning(kb_name)
358
+
359
+ try:
360
+ loop = asyncio.get_running_loop()
361
+ top_k = kwargs.get("top_k") or default_top_k()
362
+ nodes = await loop.run_in_executor(
363
+ None,
364
+ copy_context().run,
365
+ lambda: storage.retrieve_nodes(
366
+ storage_dir, query, top_k=top_k, document_keys=kwargs.get("document_keys")
367
+ ),
368
+ )
369
+
370
+ result = self._nodes_to_result(query, nodes)
371
+ if embedding_mismatch_warning:
372
+ result["warning"] = embedding_mismatch_warning
373
+ return result
374
+
375
+ except Exception as exc:
376
+ result = search_error_result(query, exc)
377
+ if result.get("error_type"):
378
+ log_message = result.get("log_message") or str(exc)
379
+ self.logger.warning(f"Search failed ({result['error_type']}): {log_message}")
380
+ else:
381
+ self.logger.error(f"Search failed: {exc}")
382
+ self.logger.error(traceback.format_exc())
383
+ return result
384
+
385
+ def _embedding_mismatch_warning(self, kb_name: str) -> str:
386
+ try:
387
+ cfg_path = Path(self.kb_base_dir) / "kb_config.json"
388
+ if not cfg_path.exists():
389
+ return ""
390
+ with open(cfg_path, encoding="utf-8") as handle:
391
+ kb_entry = json.load(handle).get("knowledge_bases", {}).get(kb_name, {})
392
+ if not kb_entry.get("embedding_mismatch"):
393
+ return ""
394
+ stored = kb_entry.get("embedding_model", "unknown")
395
+ current = get_embedding_config().model
396
+ warning = (
397
+ f"Warning: KB '{kb_name}' was indexed with '{stored}' "
398
+ f"but current model is '{current}'. Re-index recommended."
399
+ )
400
+ self.logger.warning(warning)
401
+ return warning
402
+ except Exception:
403
+ return ""
404
+
405
+ def _nodes_to_result(self, query: str, nodes: list[Any]) -> Dict[str, Any]:
406
+ context_parts: list[str] = []
407
+ sources: list[dict[str, Any]] = []
408
+ for i, node in enumerate(nodes):
409
+ context_parts.append(node.node.text)
410
+ meta = node.node.metadata or {}
411
+ sources.append(
412
+ {
413
+ "title": meta.get("file_name", meta.get("title", f"Document {i + 1}")),
414
+ "content": node.node.text[:200],
415
+ "source": meta.get("file_path", meta.get("file_name", "")),
416
+ "page": meta.get("page_label", meta.get("page", "")),
417
+ "chunk_id": node.node.node_id or str(i),
418
+ "score": round(node.score, 4) if node.score is not None else "",
419
+ **(
420
+ {
421
+ "document_key": meta["document_key"],
422
+ "source_digest": meta.get("source_digest", ""),
423
+ "source_schema_version": meta.get("source_schema_version", 0),
424
+ "page_index": meta.get("page_index"),
425
+ "page_label": meta.get("page_label", ""),
426
+ "parser_signature": meta.get("parser_signature", ""),
427
+ "page_status": meta.get("page_status", "unlocated"),
428
+ "visual_status": meta.get("visual_status", "unknown"),
429
+ "evidence_text": node.node.text[:6000],
430
+ }
431
+ if meta.get("document_key")
432
+ else {}
433
+ ),
434
+ **(
435
+ {
436
+ "visual_asset_id": meta["visual_asset_id"],
437
+ "source_document_id": meta.get("source_document_id", ""),
438
+ "bbox": meta.get("bbox"),
439
+ "caption": meta.get("caption", ""),
440
+ "source_locator": meta.get("source_locator", ""),
441
+ }
442
+ if meta.get("visual_asset_id")
443
+ else {}
444
+ ),
445
+ }
446
+ )
447
+
448
+ content = "\n\n".join(context_parts) if context_parts else ""
449
+ return {
450
+ "query": query,
451
+ "answer": content,
452
+ "content": content,
453
+ "sources": sources,
454
+ "provider": "llamaindex",
455
+ }
456
+
457
+ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
458
+ progress_callback = kwargs.get("progress_callback")
459
+ image_progress_callback = kwargs.get("image_progress_callback")
460
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
461
+ _ensure_index_worker_available(str(kb_dir.resolve()))
462
+ self._configure_settings()
463
+
464
+ self.logger.info(f"Adding {len(file_paths)} documents to KB '{kb_name}' using LlamaIndex")
465
+
466
+ signature = self._current_signature()
467
+ plan = storage.resolve_add_storage_plan(kb_dir, signature)
468
+
469
+ try:
470
+ await self._verify_embedding_connectivity()
471
+
472
+ visual_candidates = []
473
+ documents = await self.document_loader.load(
474
+ file_paths,
475
+ image_progress_callback=image_progress_callback,
476
+ kb_dir=kb_dir,
477
+ visual_candidates=visual_candidates,
478
+ )
479
+ if not documents:
480
+ self.logger.warning("No valid documents to add")
481
+ return False
482
+
483
+ VisualAssetStore(kb_dir).publish(visual_candidates)
484
+ if plan.existing_storage is not None:
485
+ self.logger.info(f"Loading existing index from {plan.existing_storage}...")
486
+ num_added = await _run_with_stall_guard(
487
+ lambda: storage.insert_documents(
488
+ plan.existing_storage, plan.storage_dir, documents
489
+ ),
490
+ progress_callback=progress_callback,
491
+ worker_key=str(kb_dir.resolve()),
492
+ )
493
+ self.logger.info(f"Added {num_added} documents to existing index")
494
+
495
+ else:
496
+ self.logger.info(f"Creating new index with {len(documents)} documents...")
497
+ plan.storage_dir.mkdir(parents=True, exist_ok=True)
498
+ num_added = await _run_with_stall_guard(
499
+ lambda: storage.create_index(
500
+ documents, plan.storage_dir, show_progress=should_show_progress()
501
+ ),
502
+ progress_callback=progress_callback,
503
+ worker_key=str(kb_dir.resolve()),
504
+ )
505
+ self.logger.info(f"Created new index with {num_added} documents")
506
+
507
+ await self._publish_verified(kb_dir, plan.storage_dir, signature, documents)
508
+ self.logger.info(f"Successfully added documents to KB '{kb_name}'")
509
+ return True
510
+
511
+ except Exception as exc:
512
+ self.logger.error(f"Failed to add documents: {exc}")
513
+ self.logger.error(traceback.format_exc())
514
+ if not isinstance(exc, IndexingStallError) and (
515
+ plan.existing_storage is None or plan.storage_dir != plan.existing_storage
516
+ ):
517
+ self._cleanup_failed_version_dir(plan.storage_dir, signature)
518
+ raise
519
+ finally:
520
+ set_progress_callback(None)
521
+
522
+ async def _publish_verified(self, kb_dir, storage_dir, signature, documents):
523
+ from deeptutor.knowledge.indexing_run import current_run
524
+ from deeptutor.services.file_io import atomic_write_json
525
+
526
+ run = current_run()
527
+ if run:
528
+ run.phase("verifying")
529
+ await asyncio.to_thread(storage.verify_persisted_index, storage_dir)
530
+ if run:
531
+ await asyncio.to_thread(run.verify_sources)
532
+ if signature is not None:
533
+ write_version_meta(kb_dir, signature, storage_dir=storage_dir)
534
+ else:
535
+ atomic_write_json(
536
+ storage_dir / "meta.json",
537
+ {
538
+ "version": storage_dir.name,
539
+ "provider": "llamaindex",
540
+ "state": "published",
541
+ "readiness_verified": True,
542
+ },
543
+ )
544
+ if run:
545
+ run.data["usable_version"] = storage_dir.name
546
+ for document in documents:
547
+ path = (getattr(document, "metadata", None) or {}).get("file_path")
548
+ if path:
549
+ run.document(Path(path), "completed")
550
+
551
+ async def delete(self, kb_name: str) -> bool:
552
+ kb_dir = resolve_kb_dir(self.kb_base_dir, kb_name)
553
+ deleted = storage.delete_kb_dir(kb_dir)
554
+ if deleted:
555
+ self.logger.info(f"Deleted KB '{kb_name}'")
556
+ return deleted
@@ -0,0 +1,130 @@
1
+ """Optional cross-encoder reranking for the LlamaIndex pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ import logging
7
+ import math
8
+ from threading import Lock
9
+ from typing import Any, Callable
10
+
11
+ from llama_index.core.schema import MetadataMode, NodeWithScore
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _RERANKER_CACHE: "OrderedDict[str, Any]" = OrderedDict()
16
+ _RERANKER_CACHE_LOCK = Lock()
17
+ _RERANKER_CACHE_MAXSIZE = 2
18
+
19
+
20
+ def clear_reranker_cache() -> None:
21
+ """Drop cached reranker models (used by tests and settings changes)."""
22
+ with _RERANKER_CACHE_LOCK:
23
+ _RERANKER_CACHE.clear()
24
+
25
+
26
+ def _load_cross_encoder(model_name: str) -> Any:
27
+ """Load a SentenceTransformers cross-encoder lazily."""
28
+ from sentence_transformers import CrossEncoder
29
+
30
+ return CrossEncoder(model_name)
31
+
32
+
33
+ def _cross_encoder(
34
+ model_name: str,
35
+ loader: Callable[[str], Any] | None = None,
36
+ ) -> Any | None:
37
+ with _RERANKER_CACHE_LOCK:
38
+ cached = _RERANKER_CACHE.get(model_name)
39
+ if cached is not None:
40
+ _RERANKER_CACHE.move_to_end(model_name)
41
+ return cached
42
+
43
+ try:
44
+ model = (loader or _load_cross_encoder)(model_name)
45
+ except ImportError:
46
+ logger.warning(
47
+ "Reranker model %r is configured, but sentence-transformers is not installed; "
48
+ "using embedding retrieval unchanged.",
49
+ model_name,
50
+ )
51
+ return None
52
+ except Exception as exc:
53
+ logger.warning(
54
+ "Failed to load reranker model %r; using embedding retrieval unchanged: %s",
55
+ model_name,
56
+ exc,
57
+ )
58
+ return None
59
+
60
+ with _RERANKER_CACHE_LOCK:
61
+ _RERANKER_CACHE[model_name] = model
62
+ _RERANKER_CACHE.move_to_end(model_name)
63
+ while len(_RERANKER_CACHE) > _RERANKER_CACHE_MAXSIZE:
64
+ _RERANKER_CACHE.popitem(last=False)
65
+ return model
66
+
67
+
68
+ def _sigmoid(value: float) -> float:
69
+ """Convert cross-encoder logits to a stable 0..1 source score."""
70
+ try:
71
+ if value >= 0:
72
+ return 1.0 / (1.0 + math.exp(-value))
73
+ exp_value = math.exp(value)
74
+ return exp_value / (1.0 + exp_value)
75
+ except (OverflowError, ValueError):
76
+ return 1.0 if value > 0 else 0.0
77
+
78
+
79
+ def _identity_logits(scores: Any) -> Any:
80
+ """Request raw cross-encoder logits from SentenceTransformers."""
81
+ return scores
82
+
83
+
84
+ def rerank_nodes(
85
+ query: str,
86
+ nodes: list[Any],
87
+ *,
88
+ top_k: int,
89
+ model_name: str,
90
+ loader: Callable[[str], Any] | None = None,
91
+ ) -> list[Any]:
92
+ """Rerank LlamaIndex results and return at most ``top_k`` nodes.
93
+
94
+ Missing optional dependencies and model-load failures are non-fatal: the
95
+ first-stage ordering is returned so saved knowledge remains searchable.
96
+ """
97
+ requested = max(1, int(top_k))
98
+ if not query or not nodes or not model_name:
99
+ return nodes[:requested]
100
+
101
+ model = _cross_encoder(model_name, loader)
102
+ if model is None:
103
+ return nodes[:requested]
104
+
105
+ pairs = [(query, result.node.get_content(metadata_mode=MetadataMode.LLM)) for result in nodes]
106
+ try:
107
+ raw_scores = model.predict(pairs, activation_fct=_identity_logits)
108
+ ranked = sorted(
109
+ (
110
+ (index, float(score))
111
+ for index, score in enumerate(raw_scores)
112
+ if math.isfinite(float(score))
113
+ ),
114
+ key=lambda item: item[1],
115
+ reverse=True,
116
+ )[:requested]
117
+ except Exception as exc:
118
+ logger.warning(
119
+ "Reranker model %r failed while scoring %d candidates; "
120
+ "using embedding retrieval unchanged: %s",
121
+ model_name,
122
+ len(nodes),
123
+ exc,
124
+ )
125
+ return nodes[:requested]
126
+
127
+ return [NodeWithScore(node=nodes[index].node, score=_sigmoid(score)) for index, score in ranked]
128
+
129
+
130
+ __all__ = ["clear_reranker_cache", "rerank_nodes"]