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,72 @@
1
+ """Background service that periodically syncs GitHub sources into KBs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from deeptutor.multi_user.context import reset_current_user, set_current_user
9
+ from deeptutor.multi_user.paths import local_admin_user
10
+ from deeptutor.services.base_sync import BaseSourceSyncService, default_base_dir, is_stale
11
+ from deeptutor.services.github_source.sync import SYNC_INTERVAL_HOURS, redact_sync_error
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GitHubSourceSyncService(BaseSourceSyncService):
17
+ """Periodically syncs all GitHub sources."""
18
+
19
+ def __init__(self, *, base_dir=None, client=None, check_interval_s=3600, owner=None):
20
+ if owner is None and base_dir is not None:
21
+ if Path(base_dir).resolve() != Path(default_base_dir()).resolve():
22
+ raise ValueError("A custom GitHub sync base_dir requires an explicit owner.")
23
+ super().__init__(base_dir=base_dir, check_interval_s=check_interval_s)
24
+ self._client = client
25
+ self._owner = owner or local_admin_user()
26
+
27
+ @property
28
+ def task_name(self) -> str:
29
+ return "github-source-sync"
30
+
31
+ async def _sync_one_cycle(self) -> None:
32
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
33
+ from deeptutor.services.github_source.sync import sync_source
34
+
35
+ token = set_current_user(self._owner)
36
+ try:
37
+ base_dir = self.effective_base_dir
38
+ manager = KnowledgeBaseManager(base_dir=base_dir)
39
+
40
+ all_sources = manager.get_all_github_sources()
41
+ for kb_name, source in all_sources:
42
+ if not source.get("enabled", True):
43
+ continue
44
+ if not is_stale(source, stale_hours=SYNC_INTERVAL_HOURS):
45
+ continue
46
+ sid = source.get("id", "?")
47
+ try:
48
+ await sync_source(
49
+ kb_name=kb_name,
50
+ source=source,
51
+ base_dir=base_dir,
52
+ client=self._client,
53
+ )
54
+ except Exception as exc:
55
+ manager.update_github_source_state(
56
+ kb_name=kb_name,
57
+ source_id=sid,
58
+ last_sync_status="error",
59
+ last_sync_error=redact_sync_error(exc),
60
+ )
61
+ finally:
62
+ reset_current_user(token)
63
+
64
+
65
+ _sync_service = None
66
+
67
+
68
+ def get_sync_service():
69
+ global _sync_service
70
+ if _sync_service is None:
71
+ _sync_service = GitHubSourceSyncService()
72
+ return _sync_service
@@ -0,0 +1,21 @@
1
+ """RAG service exports."""
2
+
3
+ from .factory import (
4
+ DEFAULT_PROVIDER,
5
+ get_pipeline,
6
+ list_pipelines,
7
+ normalize_provider_name,
8
+ )
9
+ from .file_routing import DocumentType, FileClassification, FileTypeRouter
10
+ from .service import RAGService
11
+
12
+ __all__ = [
13
+ "RAGService",
14
+ "FileTypeRouter",
15
+ "FileClassification",
16
+ "DocumentType",
17
+ "get_pipeline",
18
+ "list_pipelines",
19
+ "normalize_provider_name",
20
+ "DEFAULT_PROVIDER",
21
+ ]
@@ -0,0 +1,331 @@
1
+ """Per-knowledge-base embedding references and compatible legacy migration.
2
+
3
+ Persist catalog IDs and index identity only. Credentials are always resolved
4
+ from the current catalog; changing the global default never changes a binding.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import wraps
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from deeptutor.services.embedding.config import EmbeddingConfig
15
+
16
+ from .embedding_signature import signature_from_config
17
+
18
+ EMBEDDING_PROVIDERS = {"llamaindex", "graphrag", "lightrag"}
19
+
20
+
21
+ def get_embedding_config(selection=None, *, catalog=None):
22
+ # Config imports the RAG factory during package initialization.
23
+ from deeptutor.services.embedding.config import get_embedding_config as resolve
24
+
25
+ return resolve(selection, catalog=catalog)
26
+
27
+
28
+ def uses_bound_embedding(entry: dict) -> bool:
29
+ from deeptutor.knowledge.kb_types import is_connected_kb
30
+
31
+ return (
32
+ not is_connected_kb(entry)
33
+ and entry.get("rag_provider", "llamaindex") in EMBEDDING_PROVIDERS
34
+ )
35
+
36
+
37
+ def load_catalog() -> dict:
38
+ from deeptutor.services.config import get_model_catalog_service
39
+
40
+ return get_model_catalog_service().load()
41
+
42
+
43
+ def default_selection(catalog: dict | None = None) -> dict | None:
44
+ service = (
45
+ (catalog if catalog is not None else load_catalog())
46
+ .get("services", {})
47
+ .get("embedding", {})
48
+ )
49
+ if service.get("active_profile_id") and service.get("active_model_id"):
50
+ return {"profile_id": service["active_profile_id"], "model_id": service["active_model_id"]}
51
+ return None
52
+
53
+
54
+ def binding_fields(selection: dict, config) -> dict:
55
+ return {
56
+ "embedding_selection": {
57
+ "profile_id": selection["profile_id"],
58
+ "model_id": selection["model_id"],
59
+ },
60
+ "embedding_model": config.model,
61
+ "embedding_dim": config.dim,
62
+ "embedding_signature": signature_from_config(config).hash(),
63
+ "embedding_status": "ready",
64
+ "embedding_mismatch": False,
65
+ }
66
+
67
+
68
+ def migrate_binding(entry: dict, kb_dir: Path, catalog: dict | None = None) -> bool:
69
+ """Pin only when existing evidence identifies a compatible catalog model.
70
+
71
+ Older indexes without enough evidence retain their legacy read path. A
72
+ successful full rebuild will pin the model that produced the new vectors.
73
+ """
74
+ if entry.get("embedding_selection") or not uses_bound_embedding(entry):
75
+ return False
76
+ from .index_versioning import list_kb_versions
77
+
78
+ catalog = catalog if catalog is not None else load_catalog()
79
+ versions = list_kb_versions(kb_dir)
80
+ signatures = {v.get("embedding_signature") or v.get("signature") for v in versions}
81
+ stored_signature = entry.get("embedding_signature")
82
+ if stored_signature in EMBEDDING_PROVIDERS | {"legacy"}:
83
+ stored_signature = None
84
+ matches = []
85
+ for profile in catalog.get("services", {}).get("embedding", {}).get("profiles", []):
86
+ for model in profile.get("models", []):
87
+ selection = {"profile_id": profile.get("id"), "model_id": model.get("id")}
88
+ try:
89
+ config = get_embedding_config(selection, catalog=catalog)
90
+ except ValueError:
91
+ continue
92
+ signature = signature_from_config(config).hash()
93
+ # Prefer the recorded index identity, not whichever version happens
94
+ # to match today's global default.
95
+ if stored_signature:
96
+ matches_index = signature == stored_signature
97
+ elif entry.get("embedding_model"):
98
+ matches_index = (
99
+ config.model == entry["embedding_model"]
100
+ and (not entry.get("embedding_dim") or config.dim == entry["embedding_dim"])
101
+ and (
102
+ not signatures
103
+ or signature in signatures
104
+ or all(s in EMBEDDING_PROVIDERS | {"legacy"} or not s for s in signatures)
105
+ )
106
+ )
107
+ else:
108
+ matches_index = signature in signatures
109
+ if matches_index:
110
+ matches.append((selection, config))
111
+ if not matches:
112
+ return False
113
+ # Duplicate catalog entries for the same vector space are equivalent. If
114
+ # there are different identities, leave the legacy binding unresolved.
115
+ if len({signature_from_config(config).hash() for _, config in matches}) != 1:
116
+ return False
117
+ active = default_selection(catalog)
118
+ selection, config = next((item for item in matches if item[0] == active), matches[0])
119
+ entry.update(binding_fields(selection, config))
120
+ return True
121
+
122
+
123
+ def binding_status(
124
+ entry: dict, *, catalog: dict | None = None
125
+ ) -> tuple[str, EmbeddingConfig | None]:
126
+ selection = entry.get("embedding_selection")
127
+ if not selection:
128
+ return "legacy", None
129
+ if not isinstance(selection, dict):
130
+ return "missing", None
131
+ catalog = catalog if catalog is not None else load_catalog()
132
+ service = catalog.get("services", {}).get("embedding", {})
133
+ profile = next(
134
+ (p for p in service.get("profiles", []) if p.get("id") == selection.get("profile_id")), None
135
+ )
136
+ if not any(m.get("id") == selection.get("model_id") for m in (profile or {}).get("models", [])):
137
+ return "missing", None
138
+ try:
139
+ config = get_embedding_config(selection, catalog=catalog)
140
+ except ValueError:
141
+ return "unconfigured", None
142
+ if (
143
+ entry.get("embedding_signature")
144
+ and entry["embedding_signature"] != signature_from_config(config).hash()
145
+ ):
146
+ return "changed", config
147
+ return "ready", config
148
+
149
+
150
+ def entry_signature(entry: dict):
151
+ """Resolve a bound signature, falling back only for unbound legacy KBs."""
152
+ from .embedding_signature import signature_from_embedding_config
153
+
154
+ if not entry.get("embedding_selection"):
155
+ return signature_from_embedding_config()
156
+ status, config = binding_status(entry)
157
+ return signature_from_config(config) if config is not None else None
158
+
159
+
160
+ def bound_graph_storage_root(kb_dir: Path, provider: str, fallback: Path | None) -> Path | None:
161
+ """Keep graph reads on a compatible version if publishing a new binding failed."""
162
+ from deeptutor.services.embedding.config import scoped_embedding_config
163
+
164
+ from .index_probe import inspect_provider_version
165
+ from .index_versioning import list_kb_versions
166
+
167
+ config = scoped_embedding_config()
168
+ if config is None or fallback is None:
169
+ return fallback
170
+ expected = signature_from_config(config).hash()
171
+ versions = list_kb_versions(kb_dir)
172
+ for version in versions:
173
+ if version.get("embedding_signature") != expected or not version.get("ready"):
174
+ continue
175
+ root = Path(version["storage_path"])
176
+ if provider == "lightrag":
177
+ from .pipelines.lightrag.storage import meta_is_native_published
178
+
179
+ if not meta_is_native_published(root):
180
+ continue
181
+ if inspect_provider_version(version, provider).ready:
182
+ return root
183
+ fallback_entry = next((v for v in versions if Path(v["storage_path"]) == fallback), {})
184
+ if not fallback_entry.get("embedding_signature"):
185
+ return fallback # Older graph indexes did not record embedding identity.
186
+ raise ValueError(
187
+ "No index version matches the bound embedding model. Re-index this knowledge base."
188
+ )
189
+
190
+
191
+ def reconcile_bindings(entries: dict, base_dir: Path) -> bool:
192
+ """Refresh binding health without touching files or changing index identity."""
193
+ changed = False
194
+ try:
195
+ catalog = load_catalog()
196
+ except (ValueError, OSError):
197
+ return False
198
+ for name, entry in entries.items():
199
+ if not isinstance(entry, dict) or not uses_bound_embedding(entry):
200
+ continue
201
+ before = dict(entry)
202
+ migrate_binding(entry, base_dir / name, catalog)
203
+ if entry.get("embedding_selection"):
204
+ state, _ = binding_status(entry, catalog=catalog)
205
+ entry["embedding_status"] = state
206
+ had_mismatch = bool(before.get("embedding_mismatch"))
207
+ if state == "changed":
208
+ entry["embedding_mismatch"] = True
209
+ entry["needs_reindex"] = True
210
+ elif state == "ready":
211
+ entry.pop("embedding_mismatch", None)
212
+ if had_mismatch:
213
+ entry["needs_reindex"] = False
214
+ # Keep the legacy index picker supplied with the same version list.
215
+ from .index_probe import inspect_kb_versions
216
+
217
+ entry["index_versions"] = inspect_kb_versions(
218
+ base_dir / name, entry.get("rag_provider")
219
+ )
220
+ changed = changed or before != entry
221
+ return changed
222
+
223
+
224
+ def _service(base_dir):
225
+ from deeptutor.services.config.knowledge_base_config import KnowledgeBaseConfigService
226
+
227
+ return KnowledgeBaseConfigService(Path(base_dir) / "kb_config.json")
228
+
229
+
230
+ def persist_binding(base_dir, kb_name: str, selection: dict, config) -> None:
231
+ _service(base_dir).set_kb_config(
232
+ kb_name, {**binding_fields(selection, config), "needs_reindex": False}
233
+ )
234
+
235
+
236
+ def with_kb_embedding(method):
237
+ """Resolve and isolate the model for the entire async RAG operation."""
238
+
239
+ @wraps(method)
240
+ async def wrapped(self, *args, **kwargs):
241
+ # search(query, kb_name); initialize/add_documents(kb_name, files).
242
+ kb_name = kwargs.get("kb_name") or args[1 if method.__name__ == "search" else 0]
243
+ service = _service(self.kb_base_dir)
244
+ entry = service.get_kb_config(kb_name)
245
+ entry["rag_provider"] = self._resolve_provider(kb_name)
246
+ if not uses_bound_embedding(entry):
247
+ return await method(self, *args, **kwargs)
248
+ selection = kwargs.pop("embedding_selection", None)
249
+ frozen_config = kwargs.pop("embedding_config", None)
250
+ if frozen_config is None and entry["rag_provider"] == "lightrag":
251
+ snapshot = kwargs.get("indexing_snapshot") or kwargs.get("accepted_indexing_snapshot")
252
+ if snapshot is not None:
253
+ frozen_config = snapshot.embedding_config
254
+ explicit = selection is not None
255
+ if not explicit:
256
+ if migrate_binding(entry, Path(self.kb_base_dir) / kb_name):
257
+ service.set_kb_config(
258
+ kb_name, {k: v for k, v in entry.items() if k.startswith("embedding_")}
259
+ )
260
+ selection = entry.get("embedding_selection")
261
+ if not selection and method.__name__ != "initialize":
262
+ from .index_versioning import list_kb_versions
263
+
264
+ if list_kb_versions(Path(self.kb_base_dir) / kb_name):
265
+ # In particular, old GraphRAG indexes use the model in
266
+ # their saved settings.yaml. Do not overwrite that runtime
267
+ # with today's default when migration could not identify it.
268
+ return await method(self, *args, **kwargs)
269
+ try:
270
+ if selection:
271
+ status, config = binding_status({**entry, "embedding_selection": selection})
272
+ if status == "missing":
273
+ raise ValueError(
274
+ "The embedding model bound to this knowledge base was deleted. Select another embedding model to re-index it."
275
+ )
276
+ if status == "unconfigured":
277
+ raise ValueError(
278
+ "The bound embedding model is not configured. Check its provider settings."
279
+ )
280
+ if status == "changed" and not explicit:
281
+ raise ValueError(
282
+ "The bound embedding model configuration changed. Re-index this knowledge base before using it."
283
+ )
284
+ if frozen_config is not None:
285
+ config = frozen_config
286
+ else:
287
+ selection = default_selection()
288
+ if selection is None:
289
+ # Preserve legacy callers and installations without a
290
+ # configured catalog. The pipeline retains its own checks.
291
+ return await method(self, *args, **kwargs)
292
+ config = get_embedding_config(selection)
293
+ except ValueError as exc:
294
+ if method.__name__ != "search":
295
+ raise
296
+ return {
297
+ "query": kwargs.get("query", args[0] if args else ""),
298
+ "answer": str(exc),
299
+ "content": "",
300
+ "error_type": "embedding_binding_unavailable",
301
+ }
302
+ from deeptutor.services.embedding.config import embedding_config_scope
303
+
304
+ locked_publication = entry["rag_provider"] == "lightrag" and method.__name__ != "search"
305
+ if locked_publication:
306
+ expected_binding = (entry.get("embedding_selection"), entry.get("embedding_signature"))
307
+
308
+ def validate_binding():
309
+ current = service.get_kb_config(kb_name)
310
+ if (
311
+ current.get("embedding_selection"),
312
+ current.get("embedding_signature"),
313
+ ) != expected_binding:
314
+ raise ValueError(
315
+ "The knowledge-base embedding binding changed before indexing started; resubmit the operation."
316
+ )
317
+
318
+ def publish_binding():
319
+ persist_binding(self.kb_base_dir, kb_name, selection, config)
320
+
321
+ # LightRAG invokes both callbacks under its existing write ownership.
322
+ kwargs["validate_embedding_binding"] = validate_binding
323
+ kwargs["publish_embedding_binding"] = publish_binding
324
+
325
+ with embedding_config_scope(config):
326
+ result = await method(self, *args, **kwargs)
327
+ if result and method.__name__ != "search" and selection and not locked_publication:
328
+ persist_binding(self.kb_base_dir, kb_name, selection, config)
329
+ return result
330
+
331
+ return wrapped
@@ -0,0 +1,63 @@
1
+ """Embedding-signature helpers for RAG index version selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from deeptutor.services.rag.index_versioning import EmbeddingSignature
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def signature_from_config(config: Any) -> EmbeddingSignature:
14
+ """Build a stable RAG index signature from an embedding config object."""
15
+ binding = (getattr(config, "binding", "") or "").strip().lower()
16
+ # Role support is a vector-space change. Jina previously sent no task, so
17
+ # its role-aware indexes need a different signature. Providers that were
18
+ # already role-aware before signatures gained this field keep the blank
19
+ # value to avoid invalidating compatible indexes.
20
+ role_semantics = "jina-task" if binding == "jina" else ""
21
+ return EmbeddingSignature(
22
+ binding=binding,
23
+ model=(getattr(config, "model", "") or "").strip(),
24
+ dimension=int(getattr(config, "dim", 0) or 0),
25
+ base_url=(
26
+ getattr(config, "effective_url", None) or getattr(config, "base_url", None) or ""
27
+ ).strip(),
28
+ api_version=(getattr(config, "api_version", "") or "").strip(),
29
+ role_semantics=role_semantics,
30
+ )
31
+
32
+
33
+ def signature_from_embedding_config() -> EmbeddingSignature | None:
34
+ """Compute the signature for the currently-active embedding config."""
35
+ try:
36
+ from deeptutor.services.embedding import get_embedding_config
37
+ except Exception: # pragma: no cover - import error
38
+ return None
39
+
40
+ try:
41
+ return signature_from_config(get_embedding_config())
42
+ except Exception as exc:
43
+ logger.debug(f"Cannot resolve embedding signature: {exc}")
44
+ return None
45
+
46
+
47
+ def embedding_meta_fields() -> dict[str, Any]:
48
+ """Embedding identity fields to stamp into a version's ``meta.json``.
49
+
50
+ LlamaIndex versions already record the full signature; the graph engines
51
+ (GraphRAG/LightRAG) use a synthetic provider signature, so they stamp these
52
+ extra fields at build time. The probe used when *linking* an external index
53
+ reads them to verify the index was built with a compatible embedding model
54
+ — without which graph engines fail retrieval silently on a mismatch.
55
+ """
56
+ signature = signature_from_embedding_config()
57
+ if signature is None:
58
+ return {}
59
+ return {
60
+ "embedding_signature": signature.hash(),
61
+ "embedding_model": signature.model,
62
+ "embedding_dim": signature.dimension,
63
+ }
@@ -0,0 +1,92 @@
1
+ """Retrieval-quality evaluation for knowledge bases.
2
+
3
+ ``deeptutor kb eval`` scores a knowledge base against a QA set: each case pairs a
4
+ query with the gold passages an ideal retrieval should return, and the run reports
5
+ Recall@k, Precision@k, nDCG@k, MRR, MAP and Hit@k. The metrics are pure functions
6
+ over the ranking — no model judges the output — so a score is reproducible, cheap
7
+ to recompute, and comparable across engines, embedding models and retrieval
8
+ settings. That is what makes the set usable as a regression gate: record a
9
+ baseline with ``--save``, change a setting, and diff the metrics.
10
+
11
+ Nothing here writes to a knowledge base or mutates its index; evaluation only
12
+ reads through the same search path a chat turn uses.
13
+
14
+ Example:
15
+ >>> from deeptutor.services.rag.eval import EvalDataset, evaluate_dataset
16
+ >>> dataset = EvalDataset.from_cases(
17
+ ... [{"query": "What is attention?", "gold": ["Attention weighs each token."]}]
18
+ ... )
19
+ >>> report = await evaluate_dataset(dataset, kb_name="ml-notes", k=5)
20
+ >>> report.aggregate()["recall@5"]
21
+ """
22
+
23
+ from .dataset import EvalCase, EvalDataset, EvalDatasetError, load_dataset
24
+ from .diff import CaseDelta, EvalDiff, MetricDelta, diff_reports
25
+ from .matching import (
26
+ DEFAULT_MIN_RATIO,
27
+ DEFAULT_MIN_TOKENS,
28
+ MatchPolicy,
29
+ MatchResult,
30
+ RelevanceMatcher,
31
+ normalize_text,
32
+ source_text,
33
+ )
34
+ from .metrics import (
35
+ QueryMetrics,
36
+ aggregate_query_metrics,
37
+ average_precision,
38
+ evaluate_hits,
39
+ hit_at_k,
40
+ hit_flags,
41
+ metric_keys,
42
+ ndcg_at_k,
43
+ precision_at_k,
44
+ recall_at_k,
45
+ reciprocal_rank,
46
+ )
47
+ from .report import EvalReport, format_metric
48
+ from .runner import (
49
+ SOURCE_MODE_CITATIONS,
50
+ SOURCE_MODE_CONTEXT,
51
+ ProgressFn,
52
+ QueryEvaluation,
53
+ RetrievalEvaluator,
54
+ evaluate_dataset,
55
+ )
56
+
57
+ __all__ = [
58
+ "CaseDelta",
59
+ "DEFAULT_MIN_RATIO",
60
+ "DEFAULT_MIN_TOKENS",
61
+ "EvalCase",
62
+ "EvalDataset",
63
+ "EvalDatasetError",
64
+ "EvalDiff",
65
+ "EvalReport",
66
+ "MatchPolicy",
67
+ "MatchResult",
68
+ "MetricDelta",
69
+ "ProgressFn",
70
+ "QueryEvaluation",
71
+ "QueryMetrics",
72
+ "RelevanceMatcher",
73
+ "RetrievalEvaluator",
74
+ "SOURCE_MODE_CITATIONS",
75
+ "SOURCE_MODE_CONTEXT",
76
+ "aggregate_query_metrics",
77
+ "average_precision",
78
+ "diff_reports",
79
+ "evaluate_dataset",
80
+ "evaluate_hits",
81
+ "format_metric",
82
+ "hit_at_k",
83
+ "hit_flags",
84
+ "load_dataset",
85
+ "metric_keys",
86
+ "ndcg_at_k",
87
+ "normalize_text",
88
+ "precision_at_k",
89
+ "recall_at_k",
90
+ "reciprocal_rank",
91
+ "source_text",
92
+ ]