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,696 @@
1
+ """Immutable model identity for native LightRAG indexing writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field, replace
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlsplit, urlunsplit
12
+
13
+ from deeptutor.multi_user.context import get_current_user, get_current_user_or_none
14
+ from deeptutor.multi_user.model_access import (
15
+ OWNER_BOUND_BINDINGS,
16
+ apply_allowed_llm_selection,
17
+ )
18
+ from deeptutor.multi_user.models import CurrentUser
19
+ from deeptutor.services.llm.capabilities import supports_vision
20
+ from deeptutor.services.llm.config import LLMConfig
21
+ from deeptutor.services.llm.exceptions import LLMConfigError
22
+ from deeptutor.services.model_selection.llm import LLMSelection
23
+ from deeptutor.services.model_selection.runtime import resolve_llm_config_for_selection
24
+
25
+ POLICY_PINNED = "pinned"
26
+ POLICY_PENDING = "pending_pinned"
27
+ POLICY_LEGACY = "legacy_unpinned"
28
+
29
+
30
+ class IndexingPolicyError(RuntimeError):
31
+ """A pinned model can no longer be used without changing index identity."""
32
+
33
+ code = "indexing_model_unavailable"
34
+ retryable = False
35
+
36
+
37
+ class IndexingModelChangedError(IndexingPolicyError):
38
+ """The selected catalog entry no longer matches the published fingerprint."""
39
+
40
+ code = "reindex_required"
41
+
42
+
43
+ class EmbeddingMismatchError(IndexingPolicyError):
44
+ """The selected embedding does not match the published vector space."""
45
+
46
+ code = "lightrag_embedding_incompatible"
47
+
48
+
49
+ def _endpoint_identity(value: str | None) -> str:
50
+ if not value:
51
+ return ""
52
+ parsed = urlsplit(value)
53
+ host = parsed.hostname or ""
54
+ if parsed.port is not None:
55
+ host = f"{host}:{parsed.port}"
56
+ return urlunsplit((parsed.scheme.lower(), host.lower(), parsed.path.rstrip("/"), "", ""))
57
+
58
+
59
+ def _canonical_identity(
60
+ selection: LLMSelection | None,
61
+ config: LLMConfig,
62
+ *,
63
+ vision_available: bool | None = None,
64
+ ) -> dict[str, Any]:
65
+ identity = {
66
+ "binding": config.binding,
67
+ "provider_mode": config.provider_mode,
68
+ "endpoint": _endpoint_identity(config.effective_url or config.base_url),
69
+ "api_version": config.api_version or None,
70
+ "model": config.model,
71
+ "reasoning_effort": config.reasoning_effort,
72
+ }
73
+ if selection is not None:
74
+ identity["profile_id"] = selection.profile_id
75
+ identity["model_id"] = selection.model_id
76
+ if vision_available is not None:
77
+ identity["vision_available"] = vision_available
78
+ return identity
79
+
80
+
81
+ def _fingerprint(identity: dict[str, Any]) -> str:
82
+ encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
83
+ return hashlib.sha256(encoded).hexdigest()
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class IndexingLLMSnapshot:
88
+ config: LLMConfig
89
+ owner: CurrentUser
90
+ policy: str
91
+ selection: LLMSelection | None
92
+ descriptor: dict[str, Any]
93
+ fingerprint: str | None
94
+ vision_available: bool
95
+
96
+ def persisted_policy(self) -> dict[str, Any]:
97
+ payload: dict[str, Any] = {
98
+ "policy": self.policy,
99
+ "descriptor": self.descriptor,
100
+ "fingerprint": self.fingerprint,
101
+ "vision_available": self.vision_available,
102
+ }
103
+ if self.selection is not None:
104
+ payload["selection"] = self.selection.to_dict()
105
+ return payload
106
+
107
+
108
+ def freeze_snapshot(
109
+ selection_value: Any = None,
110
+ *,
111
+ policy: str | None = None,
112
+ require_explicit_user: bool = True,
113
+ ) -> IndexingLLMSnapshot:
114
+ """Resolve and freeze one access-checked indexing model in the caller scope."""
115
+ selection = LLMSelection.from_payload(selection_value)
116
+ explicit_user = get_current_user_or_none()
117
+ if selection is not None and explicit_user is None and require_explicit_user:
118
+ raise IndexingPolicyError(
119
+ "Pinned indexing models require an explicit initiating user scope."
120
+ )
121
+ try:
122
+ if selection is not None:
123
+ apply_allowed_llm_selection(selection.to_dict())
124
+ config = resolve_llm_config_for_selection(selection)
125
+ except (LLMConfigError, PermissionError, ValueError) as exc:
126
+ raise IndexingPolicyError(str(exc)) from exc
127
+
128
+ if config.binding in OWNER_BOUND_BINDINGS and explicit_user is None:
129
+ raise IndexingPolicyError(
130
+ "Owner-bound indexing models require an explicit initiating user scope."
131
+ )
132
+ owner = explicit_user or get_current_user()
133
+ resolved_policy = policy or (POLICY_PINNED if selection is not None else POLICY_LEGACY)
134
+ vision_available = supports_vision(config.binding, config.model)
135
+ if resolved_policy == POLICY_LEGACY:
136
+ descriptor = {
137
+ "model": config.model,
138
+ "binding": config.binding,
139
+ "reasoning_effort": config.reasoning_effort,
140
+ }
141
+ fingerprint = None
142
+ else:
143
+ identity = _canonical_identity(
144
+ selection,
145
+ config,
146
+ vision_available=vision_available,
147
+ )
148
+ descriptor = identity
149
+ fingerprint = _fingerprint(identity)
150
+ return IndexingLLMSnapshot(
151
+ config=config,
152
+ owner=owner,
153
+ policy=resolved_policy,
154
+ selection=None if resolved_policy == POLICY_LEGACY else selection,
155
+ descriptor=descriptor,
156
+ fingerprint=fingerprint,
157
+ vision_available=vision_available,
158
+ )
159
+
160
+
161
+ def pending_policy_for_selection(selection_value: Any) -> dict[str, Any]:
162
+ return freeze_roles(selection_value, policy=POLICY_PENDING).persisted_policy()
163
+
164
+
165
+ def _active_catalog_selection() -> dict[str, str] | None:
166
+ from deeptutor.services.config import get_model_catalog_service
167
+
168
+ catalog = get_model_catalog_service().load()
169
+ service = catalog.get("services", {}).get("llm", {})
170
+ profile_id = str(service.get("active_profile_id") or "").strip()
171
+ model_id = str(service.get("active_model_id") or "").strip()
172
+ if not profile_id or not model_id:
173
+ return None
174
+ return {"profile_id": profile_id, "model_id": model_id}
175
+
176
+
177
+ def _freeze_legacy_default_snapshot() -> IndexingLLMSnapshot:
178
+ """Freeze the released LightRAG model, or the active model when it is unset."""
179
+ from .config import lightrag_indexing_selection_from_settings
180
+
181
+ try:
182
+ selection = lightrag_indexing_selection_from_settings()
183
+ except Exception as exc:
184
+ raise IndexingPolicyError(
185
+ "The LightRAG indexing-model setting could not be resolved."
186
+ ) from exc
187
+ if selection is None:
188
+ selection = _active_catalog_selection()
189
+ return freeze_snapshot(
190
+ selection,
191
+ policy=POLICY_PINNED,
192
+ require_explicit_user=False,
193
+ )
194
+
195
+
196
+ def _single_snapshot_from_persisted(policy: dict[str, Any]) -> IndexingLLMSnapshot:
197
+ if type(policy.get("vision_available")) is not bool:
198
+ raise IndexingModelChangedError(
199
+ "The historical vision capability is unknown; run a full re-index."
200
+ )
201
+ selection = policy.get("selection")
202
+ if not isinstance(selection, dict):
203
+ raise IndexingPolicyError("Pinned LightRAG metadata has an invalid model selection.")
204
+ snapshot = freeze_snapshot(selection, policy=POLICY_PINNED)
205
+ expected = str(policy.get("fingerprint") or "")
206
+ if not expected or snapshot.fingerprint != expected:
207
+ raise IndexingModelChangedError(
208
+ "The pinned LightRAG indexing model changed; run a full re-index to publish "
209
+ "the new model identity."
210
+ )
211
+ return snapshot
212
+
213
+
214
+ @dataclass(frozen=True, slots=True)
215
+ class IndexingPolicySnapshot:
216
+ """Freeze role identities, runtime limits, and accepted indexing target state."""
217
+
218
+ extract: IndexingLLMSnapshot
219
+ vlm: IndexingLLMSnapshot | None
220
+ policy: str = POLICY_PINNED
221
+ limits: dict[str, dict[str, int]] = field(default_factory=dict)
222
+ legacy_policy: dict[str, Any] | None = None
223
+ target_version: str | None = None
224
+ target_bound: bool = False
225
+ target_policy: str | None = None
226
+ image_analysis: bool | None = None
227
+ target_revision: str | None = None
228
+ embedding_config: Any | None = field(default=None, repr=False, compare=False)
229
+
230
+ @property
231
+ def vision_available(self) -> bool:
232
+ """Return whether the pinned vision model supports image inputs."""
233
+ return self.vlm is not None and self.vlm.vision_available
234
+
235
+ def persisted_policy(self) -> dict[str, Any]:
236
+ """Serialize role identities while preserving an existing legacy policy."""
237
+ if self.legacy_policy is not None:
238
+ # Appending must not rewrite the old fingerprint as a v2 identity.
239
+ return dict(self.legacy_policy)
240
+ return {
241
+ "schema_version": 2,
242
+ "policy": self.policy,
243
+ "extract": self.extract.persisted_policy(),
244
+ "vlm": {
245
+ "mode": "enabled" if self.vlm else "disabled",
246
+ **({"snapshot": self.vlm.persisted_policy()} if self.vlm else {}),
247
+ },
248
+ }
249
+
250
+
251
+ def _role_identity(snapshot: IndexingLLMSnapshot, *, vision: bool) -> dict[str, Any]:
252
+ config = snapshot.config
253
+ return {
254
+ **_canonical_identity(
255
+ snapshot.selection,
256
+ config,
257
+ vision_available=snapshot.vision_available if vision else None,
258
+ ),
259
+ "wire_api": config.wire_api,
260
+ "api_format": config.api_format,
261
+ "context_window": config.context_window,
262
+ }
263
+
264
+
265
+ def _with_role_identity(snapshot: IndexingLLMSnapshot, *, vision: bool) -> IndexingLLMSnapshot:
266
+ snapshot = replace(
267
+ snapshot,
268
+ selection=replace(snapshot.selection, reasoning_effort=snapshot.config.reasoning_effort),
269
+ )
270
+ identity = _role_identity(snapshot, vision=vision)
271
+ return replace(snapshot, descriptor=identity, fingerprint=_fingerprint(identity))
272
+
273
+
274
+ def _freeze_role(
275
+ selection_value: Any, *, vision: bool = False, provider_default: bool = True
276
+ ) -> IndexingLLMSnapshot:
277
+ from deeptutor.services.config.lightrag_roles import LightRagModelSelection
278
+
279
+ from .roles import resolve_selection
280
+
281
+ if isinstance(selection_value, LLMSelection):
282
+ selection_value = selection_value.to_dict()
283
+ selected = LightRagModelSelection.model_validate(selection_value)
284
+ selection = LLMSelection(**selected.model_dump())
285
+ try:
286
+ config = resolve_selection(selection, vision=vision, provider_default=provider_default)
287
+ except (ValueError, PermissionError, LLMConfigError) as exc:
288
+ raise IndexingPolicyError(str(exc)) from exc
289
+ explicit_user = get_current_user_or_none()
290
+ if config.binding in OWNER_BOUND_BINDINGS and explicit_user is None:
291
+ raise IndexingPolicyError(
292
+ "Owner-bound indexing models require an explicit initiating user scope."
293
+ )
294
+ snapshot = IndexingLLMSnapshot(
295
+ config=config,
296
+ owner=explicit_user or get_current_user(),
297
+ policy=POLICY_PINNED,
298
+ selection=replace(selection, reasoning_effort=config.reasoning_effort),
299
+ descriptor={},
300
+ fingerprint=None,
301
+ vision_available=supports_vision(config.binding, config.model),
302
+ )
303
+ return _with_role_identity(snapshot, vision=vision)
304
+
305
+
306
+ def freeze_roles(
307
+ selection_value: Any = None, *, policy: str = POLICY_PINNED
308
+ ) -> IndexingPolicySnapshot:
309
+ """Freeze create/rebuild choices; old request bodies still mean one model."""
310
+ from deeptutor.services.config import load_lightrag_settings
311
+ from deeptutor.services.config.lightrag_roles import LightRagIndexingSelection
312
+
313
+ from .roles import runtime_limits, settings_models
314
+
315
+ settings = load_lightrag_settings()
316
+ models = settings_models(settings)
317
+ limits = runtime_limits(settings)
318
+ if selection_value is None and models is None and settings.get("version") == 2:
319
+ raise IndexingPolicyError("Choose a LightRAG base model or explicit indexing models first.")
320
+ if selection_value is None and models is not None:
321
+ extract_selection = models.selection_for("extract").model_dump()
322
+ vlm_selection = models.selection_for("vlm")
323
+ value = {
324
+ "extract": extract_selection,
325
+ "vlm": {"mode": "enabled", "selection": vlm_selection.model_dump()}
326
+ if vlm_selection
327
+ else {"mode": "disabled"},
328
+ }
329
+ elif (
330
+ selection_value is not None
331
+ and isinstance(selection_value, dict)
332
+ and "extract" in selection_value
333
+ ):
334
+ value = selection_value
335
+ else:
336
+ # Supported v1 settings and form producer: freeze the one known model,
337
+ # including its known vision capability, before constructing v2 roles.
338
+ single = (
339
+ freeze_snapshot(selection_value, policy=POLICY_PINNED)
340
+ if selection_value is not None
341
+ else _freeze_legacy_default_snapshot()
342
+ )
343
+ if single.selection is None:
344
+ raise IndexingPolicyError("Choose a catalog model before creating a LightRAG index.")
345
+ selected = single.selection.to_dict()
346
+ value = {
347
+ "extract": selected,
348
+ "vlm": {"mode": "enabled", "selection": selected}
349
+ if single.vision_available and settings.get("version") != 2
350
+ else {"mode": "disabled"},
351
+ }
352
+ parsed = LightRagIndexingSelection.model_validate(value)
353
+ extract = _freeze_role(parsed.extract.model_dump())
354
+ vlm = (
355
+ _freeze_role(parsed.vlm.selection.model_dump(), vision=True)
356
+ if parsed.vlm.selection
357
+ else None
358
+ )
359
+ return IndexingPolicySnapshot(extract, vlm, policy=policy, limits=limits)
360
+
361
+
362
+ def freeze_default_snapshot() -> IndexingPolicySnapshot:
363
+ return freeze_roles()
364
+
365
+
366
+ def snapshot_from_persisted(policy: dict[str, Any]) -> IndexingPolicySnapshot:
367
+ from deeptutor.services.config import load_lightrag_settings
368
+
369
+ from .roles import runtime_limits
370
+
371
+ if not isinstance(policy, dict) or policy.get("policy") not in {POLICY_PINNED, POLICY_PENDING}:
372
+ raise IndexingModelChangedError(
373
+ "The index has no verified model policy; run a full re-index."
374
+ )
375
+ limits = runtime_limits(load_lightrag_settings())
376
+ if "schema_version" not in policy:
377
+ single = _single_snapshot_from_persisted(policy)
378
+ if policy["policy"] == POLICY_PENDING:
379
+ return IndexingPolicySnapshot(
380
+ _with_role_identity(single, vision=False),
381
+ _with_role_identity(single, vision=True) if single.vision_available else None,
382
+ policy=POLICY_PENDING,
383
+ limits=limits,
384
+ )
385
+ return IndexingPolicySnapshot(
386
+ single,
387
+ single if single.vision_available else None,
388
+ policy=policy["policy"],
389
+ limits=limits,
390
+ legacy_policy=dict(policy),
391
+ )
392
+ if type(policy["schema_version"]) is not int or policy["schema_version"] != 2:
393
+ raise IndexingPolicyError("Unsupported LightRAG indexing-policy version.")
394
+ vision = policy.get("vlm")
395
+ if not isinstance(vision, dict) or vision.get("mode") not in {"disabled", "enabled"}:
396
+ raise IndexingPolicyError("Invalid LightRAG vision policy.")
397
+ if vision["mode"] == "disabled" and "snapshot" in vision:
398
+ raise IndexingPolicyError("A disabled VLM cannot have an indexing snapshot.")
399
+
400
+ def restore(value: Any, *, vision: bool = False) -> IndexingLLMSnapshot:
401
+ if not isinstance(value, dict) or not isinstance(value.get("selection"), dict):
402
+ raise IndexingPolicyError("Invalid LightRAG role snapshot.")
403
+ descriptor = value.get("descriptor")
404
+ result = _freeze_role(
405
+ value["selection"],
406
+ vision=vision,
407
+ provider_default=isinstance(descriptor, dict)
408
+ and descriptor.get("reasoning_effort") == "",
409
+ )
410
+ if not value.get("fingerprint") or result.fingerprint != value["fingerprint"]:
411
+ raise IndexingModelChangedError("The pinned role model changed; run a full re-index.")
412
+ return result
413
+
414
+ return IndexingPolicySnapshot(
415
+ restore(policy.get("extract")),
416
+ restore(vision.get("snapshot"), vision=True) if vision["mode"] == "enabled" else None,
417
+ policy=policy["policy"],
418
+ limits=limits,
419
+ )
420
+
421
+
422
+ def revalidate_snapshot(snapshot: IndexingPolicySnapshot) -> IndexingPolicySnapshot:
423
+ """Refresh credentials and permissions without changing frozen task settings."""
424
+ from deeptutor.multi_user.paths import user_context
425
+
426
+ with user_context(snapshot.extract.owner):
427
+ fresh = snapshot_from_persisted(snapshot.persisted_policy())
428
+ return replace(
429
+ fresh,
430
+ limits=snapshot.limits,
431
+ target_version=snapshot.target_version,
432
+ target_bound=snapshot.target_bound,
433
+ target_policy=snapshot.target_policy,
434
+ image_analysis=snapshot.image_analysis,
435
+ target_revision=snapshot.target_revision,
436
+ embedding_config=snapshot.embedding_config,
437
+ )
438
+
439
+
440
+ def _bound_published_root(kb_dir: Path) -> Path | None:
441
+ """Use the published version selected by the KB's existing embedding binding."""
442
+ from deeptutor.services.config.knowledge_base_config import KnowledgeBaseConfigService
443
+
444
+ from .storage import published_root_for_embedding
445
+
446
+ entry = KnowledgeBaseConfigService(kb_dir.parent / "kb_config.json").get_kb_config(kb_dir.name)
447
+ signature = entry.get("embedding_signature") if entry.get("embedding_selection") else None
448
+ return published_root_for_embedding(kb_dir, signature)
449
+
450
+
451
+ def _target_policy_key(kb_dir: Path) -> str | None:
452
+ policy = effective_policy(kb_dir, base_dir=str(kb_dir.parent), kb_name=kb_dir.name)
453
+ return (
454
+ _fingerprint({key: value for key, value in policy.items() if key != "vlm_used"})
455
+ if policy is not None
456
+ else None
457
+ )
458
+
459
+
460
+ def _content_revision(root: Path | None) -> str:
461
+ # Native document-status flushes are atomic. Track inode and nanosecond
462
+ # timestamps as well as size so a same-version append invalidates a queued rebuild.
463
+ from .storage import _store_root
464
+
465
+ revisions = {}
466
+ if root is not None:
467
+ paths = {
468
+ "meta.json": root / "meta.json",
469
+ "kv_store_doc_status.json": _store_root(root) / "kv_store_doc_status.json",
470
+ }
471
+ for name, path in paths.items():
472
+ try:
473
+ stat = path.stat()
474
+ revisions[name] = [stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns]
475
+ except FileNotFoundError:
476
+ revisions[name] = None
477
+ return _fingerprint(revisions)
478
+
479
+
480
+ def bind_target(
481
+ snapshot: IndexingPolicySnapshot, kb_dir: Path, *, protect_contents: bool = False
482
+ ) -> IndexingPolicySnapshot:
483
+ """Bind a snapshot to the current index and optionally its content revision."""
484
+ target = _bound_published_root(kb_dir)
485
+ snapshot = with_embedding(snapshot, kb_dir=kb_dir)
486
+ return replace(
487
+ snapshot,
488
+ target_version=str(target) if target else None,
489
+ target_bound=True,
490
+ target_policy=_target_policy_key(kb_dir),
491
+ target_revision=_content_revision(target) if protect_contents else None,
492
+ )
493
+
494
+
495
+ def validate_target(snapshot: IndexingPolicySnapshot, kb_dir: Path) -> None:
496
+ """Reject a queued write if its bound index, policy, or revision changed."""
497
+ if not snapshot.target_bound:
498
+ return
499
+ target = _bound_published_root(kb_dir)
500
+ if (
501
+ snapshot.target_version != (str(target) if target else None)
502
+ or snapshot.target_policy != _target_policy_key(kb_dir)
503
+ or (
504
+ snapshot.target_revision is not None
505
+ and snapshot.target_revision != _content_revision(target)
506
+ )
507
+ ):
508
+ raise IndexingPolicyError(
509
+ "The target index or pending policy changed while queued; resubmit this operation."
510
+ )
511
+
512
+
513
+ def with_image_analysis(
514
+ snapshot: IndexingPolicySnapshot, requested: bool | None
515
+ ) -> IndexingPolicySnapshot:
516
+ """Record the requested image mode, rejecting unavailable pinned vision."""
517
+ if requested is True and not snapshot.vision_available:
518
+ raise IndexingModelChangedError(
519
+ "Image analysis is disabled for this index; run a full re-index with a VLM."
520
+ )
521
+ return replace(snapshot, image_analysis=requested)
522
+
523
+
524
+ def effective_policy(kb_dir: Path, *, base_dir: str, kb_name: str) -> dict[str, Any] | None:
525
+ """Return published policy first, then pre-publication pending policy."""
526
+ from .storage import read_published_policy
527
+
528
+ root = _bound_published_root(kb_dir)
529
+ published = read_published_policy(root)
530
+ if published is not None:
531
+ return published
532
+
533
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
534
+
535
+ manager = KnowledgeBaseManager(base_dir=base_dir)
536
+ entry = manager.config.get("knowledge_bases", {}).get(kb_name) or {}
537
+ pending = entry.get("pending_indexing_policy")
538
+ if pending is not None and not isinstance(pending, dict):
539
+ raise IndexingPolicyError("Invalid pending LightRAG indexing policy.")
540
+ return pending
541
+
542
+
543
+ def resolve_write_snapshot(
544
+ kb_dir: Path,
545
+ *,
546
+ base_dir: str,
547
+ kb_name: str,
548
+ explicit: IndexingPolicySnapshot | None = None,
549
+ ) -> IndexingPolicySnapshot:
550
+ if explicit is not None:
551
+ return explicit
552
+ # Empty KBs (including old creation-time pending policies) follow defaults.
553
+ # Published/partial versions still require verified historical identities.
554
+ from deeptutor.services.rag.index_versioning import list_kb_versions
555
+
556
+ from .storage import latest_published_root
557
+
558
+ if latest_published_root(kb_dir) is None and not list_kb_versions(kb_dir):
559
+ return freeze_default_snapshot()
560
+ policy = effective_policy(kb_dir, base_dir=base_dir, kb_name=kb_name)
561
+ if policy is None or policy.get("policy") == POLICY_LEGACY:
562
+ raise IndexingModelChangedError(
563
+ "This LightRAG index has no verified indexing model; run a full re-index "
564
+ "before appending documents."
565
+ )
566
+ return snapshot_from_persisted(policy)
567
+
568
+
569
+ def with_embedding(
570
+ snapshot: IndexingPolicySnapshot, *, kb_dir: Path | None = None
571
+ ) -> IndexingPolicySnapshot:
572
+ """Freeze embedding configuration once, before accepting an indexing task."""
573
+ if snapshot.embedding_config is not None:
574
+ return snapshot
575
+ from deeptutor.services.embedding import get_embedding_config
576
+
577
+ config = None
578
+ if kb_dir is not None:
579
+ from deeptutor.services.config.knowledge_base_config import KnowledgeBaseConfigService
580
+ from deeptutor.services.rag.embedding_binding import binding_status
581
+
582
+ entry = KnowledgeBaseConfigService(kb_dir.parent / "kb_config.json").get_kb_config(
583
+ kb_dir.name
584
+ )
585
+ state, config = binding_status(entry)
586
+ if state in {"missing", "unconfigured", "changed"}:
587
+ raise IndexingPolicyError(
588
+ "The bound embedding model is unavailable or changed. Restore its configuration or rebuild."
589
+ )
590
+ config = deepcopy(config if config is not None else get_embedding_config())
591
+ if not config.dim:
592
+ raise IndexingPolicyError(
593
+ "Configure an embedding model with a known dimension in Settings."
594
+ )
595
+ return replace(snapshot, embedding_config=config)
596
+
597
+
598
+ def public_rebuild_config(
599
+ snapshot: IndexingPolicySnapshot, embedding_selection: dict | None = None
600
+ ) -> dict[str, Any]:
601
+ """Bind the confirmation to resolved identities without exposing credentials."""
602
+ from deeptutor.services.rag.embedding_signature import signature_from_config
603
+
604
+ snapshot = with_embedding(snapshot)
605
+ signature = signature_from_config(snapshot.embedding_config)
606
+ policy = snapshot.persisted_policy()
607
+ return {
608
+ "fingerprint": _fingerprint(
609
+ {
610
+ "indexing_policy": policy,
611
+ "embedding": signature.hash(),
612
+ "embedding_selection": embedding_selection,
613
+ }
614
+ ),
615
+ "indexing_policy": public_policy(policy),
616
+ "embedding": {"model": signature.model, "dimension": signature.dimension},
617
+ "embedding_selection": embedding_selection,
618
+ }
619
+
620
+
621
+ def cache_identity(snapshot: IndexingLLMSnapshot) -> str:
622
+ if snapshot.fingerprint:
623
+ return snapshot.fingerprint
624
+ identity = {
625
+ "binding": snapshot.config.binding,
626
+ "provider_mode": snapshot.config.provider_mode,
627
+ "endpoint": _endpoint_identity(snapshot.config.effective_url or snapshot.config.base_url),
628
+ "model": snapshot.config.model,
629
+ "reasoning_effort": snapshot.config.reasoning_effort,
630
+ }
631
+ return _fingerprint(identity)
632
+
633
+
634
+ def cache_identity_for_config(config: LLMConfig) -> str:
635
+ """Return a credential-free cache identity for a resolved query model."""
636
+ return _fingerprint(
637
+ {
638
+ **_canonical_identity(None, config),
639
+ "wire_api": config.wire_api,
640
+ "api_format": config.api_format,
641
+ "context_window": config.context_window,
642
+ }
643
+ )
644
+
645
+
646
+ def public_policy(policy: dict[str, Any]) -> dict[str, Any]:
647
+ """Expose provenance without private endpoint or owner metadata."""
648
+ result = {
649
+ key: policy[key]
650
+ for key in ("schema_version", "policy", "fingerprint", "vision_available", "vlm_used")
651
+ if key in policy
652
+ }
653
+ selection = policy.get("selection")
654
+ if isinstance(selection, dict):
655
+ result["selection"] = {
656
+ key: selection[key]
657
+ for key in ("profile_id", "model_id", "reasoning_effort")
658
+ if key in selection
659
+ }
660
+ descriptor = policy.get("descriptor")
661
+ if isinstance(descriptor, dict):
662
+ result["descriptor"] = {
663
+ key: descriptor[key]
664
+ for key in ("model", "binding", "provider_mode", "reasoning_effort", "vision_available")
665
+ if key in descriptor
666
+ }
667
+ if isinstance(policy.get("extract"), dict):
668
+ result["extract"] = public_policy(policy["extract"])
669
+ vlm = policy.get("vlm")
670
+ if isinstance(vlm, dict):
671
+ result["vlm"] = {"mode": vlm.get("mode")}
672
+ if isinstance(vlm.get("snapshot"), dict):
673
+ result["vlm"]["snapshot"] = public_policy(vlm["snapshot"])
674
+ return result
675
+
676
+
677
+ __all__ = [
678
+ "IndexingLLMSnapshot",
679
+ "IndexingPolicySnapshot",
680
+ "freeze_roles",
681
+ "bind_target",
682
+ "revalidate_snapshot",
683
+ "IndexingModelChangedError",
684
+ "IndexingPolicyError",
685
+ "POLICY_LEGACY",
686
+ "POLICY_PENDING",
687
+ "POLICY_PINNED",
688
+ "cache_identity",
689
+ "cache_identity_for_config",
690
+ "effective_policy",
691
+ "freeze_default_snapshot",
692
+ "freeze_snapshot",
693
+ "pending_policy_for_selection",
694
+ "resolve_write_snapshot",
695
+ "snapshot_from_persisted",
696
+ ]