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,150 @@
1
+ """Legacy embedding adapter using AsyncOpenAI.
2
+
3
+ Public Settings providers use exact endpoint URLs and raw HTTP adapters so the
4
+ URL shown in Settings is the URL sent on the wire. This SDK adapter is retained
5
+ for old configs/tests that intentionally depend on AsyncOpenAI semantics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict
12
+
13
+ from openai import APIConnectionError, APIError, APIStatusError, AsyncOpenAI
14
+
15
+ from deeptutor.services.embedding.request_options import should_send_embedding_dimensions
16
+ from deeptutor.services.llm.openai_http_client import openai_client_kwargs
17
+
18
+ from .base import (
19
+ BaseEmbeddingAdapter,
20
+ EmbeddingProviderError,
21
+ EmbeddingRequest,
22
+ EmbeddingResponse,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class OpenAISDKEmbeddingAdapter(BaseEmbeddingAdapter):
29
+ """Embedding adapter using the official ``AsyncOpenAI`` client."""
30
+
31
+ def _should_send_dimensions(self, model_name: str | None) -> bool:
32
+ """Mirror of the heuristic in :mod:`openai_compatible`.
33
+
34
+ Tri-state ``self.send_dimensions``: ``True`` always send, ``False``
35
+ never send, ``None`` auto by model family.
36
+ """
37
+ return should_send_embedding_dimensions(
38
+ binding=None,
39
+ model=model_name,
40
+ dimension=self.dimensions or 1,
41
+ send_dimensions=self.send_dimensions,
42
+ )
43
+
44
+ def _build_client(self) -> AsyncOpenAI:
45
+ # OpenRouter / custom gateways often don't validate the key, but the
46
+ # SDK refuses to construct without one. Use a placeholder when empty.
47
+ return AsyncOpenAI(
48
+ api_key=self.api_key or "sk-no-key-required",
49
+ base_url=self.base_url,
50
+ timeout=max(self.request_timeout, 60),
51
+ default_headers=(
52
+ {str(k): str(v) for k, v in self.extra_headers.items()}
53
+ if self.extra_headers
54
+ else None
55
+ ),
56
+ max_retries=2,
57
+ **openai_client_kwargs(timeout=max(self.request_timeout, 60)),
58
+ )
59
+
60
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
61
+ if request.contents:
62
+ raise ValueError(
63
+ "openai_sdk adapter does not support multimodal `contents`. "
64
+ "Pick a multimodal-capable provider (cohere, aliyun)."
65
+ )
66
+
67
+ model = request.model or self.model
68
+ kwargs: Dict[str, Any] = {
69
+ "model": model,
70
+ "input": request.texts,
71
+ # Unlike the gateway adapter (which omits `encoding_format` to avoid
72
+ # HTTP 400s), the official OpenAI/Azure API accepts it and callers
73
+ # expect float vectors, so pin "float" when none is set explicitly.
74
+ "encoding_format": request.encoding_format or "float",
75
+ }
76
+ dim_value = request.dimensions or self.dimensions
77
+ if dim_value and self._should_send_dimensions(model):
78
+ kwargs["dimensions"] = dim_value
79
+
80
+ client = self._build_client()
81
+ try:
82
+ response = await client.embeddings.create(**kwargs)
83
+ except APIStatusError as exc:
84
+ try:
85
+ body = exc.response.text
86
+ except Exception:
87
+ body = str(exc)
88
+ raise EmbeddingProviderError(
89
+ f"OpenAI SDK request failed: {exc}",
90
+ status=getattr(exc, "status_code", None),
91
+ body=body,
92
+ model=model,
93
+ url=self.base_url,
94
+ provider="openai_sdk",
95
+ ) from exc
96
+ except APIConnectionError as exc:
97
+ raise EmbeddingProviderError(
98
+ f"OpenAI SDK connection error: {exc}",
99
+ model=model,
100
+ url=self.base_url,
101
+ provider="openai_sdk",
102
+ ) from exc
103
+ except APIError as exc:
104
+ raise EmbeddingProviderError(
105
+ f"OpenAI SDK API error: {exc}",
106
+ model=model,
107
+ url=self.base_url,
108
+ provider="openai_sdk",
109
+ ) from exc
110
+ finally:
111
+ try:
112
+ await client.close()
113
+ except Exception:
114
+ pass
115
+
116
+ embeddings = [list(item.embedding) for item in response.data]
117
+ if not embeddings:
118
+ raise ValueError("openai_sdk returned an empty data list.")
119
+
120
+ actual_dims = len(embeddings[0])
121
+ usage_obj = getattr(response, "usage", None)
122
+ if usage_obj is None:
123
+ usage: Dict[str, Any] = {}
124
+ elif hasattr(usage_obj, "model_dump"):
125
+ usage = usage_obj.model_dump()
126
+ elif isinstance(usage_obj, dict):
127
+ usage = usage_obj
128
+ else:
129
+ usage = {}
130
+
131
+ logger.info(
132
+ f"Generated {len(embeddings)} embeddings via openai SDK "
133
+ f"(model={model}, dim={actual_dims}, base_url={self.base_url})"
134
+ )
135
+
136
+ return EmbeddingResponse(
137
+ embeddings=embeddings,
138
+ model=getattr(response, "model", None) or model,
139
+ dimensions=actual_dims,
140
+ usage=usage,
141
+ )
142
+
143
+ def get_model_info(self) -> Dict[str, Any]:
144
+ return {
145
+ "model": self.model,
146
+ "dimensions": self.dimensions,
147
+ "supports_variable_dimensions": False,
148
+ "multimodal": False,
149
+ "provider": "openai_sdk",
150
+ }
@@ -0,0 +1,323 @@
1
+ """Unified embedding client backed by normalized provider runtime config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ import logging
7
+ import threading
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from deeptutor.services.config.embedding_endpoint import (
11
+ redact_embedding_endpoint_for_display,
12
+ )
13
+ from deeptutor.services.config.provider_runtime import (
14
+ EMBEDDING_PROVIDERS,
15
+ embedding_endpoint_validation_error,
16
+ )
17
+
18
+ from .adapters import ADAPTER_BACKENDS, BaseEmbeddingAdapter, EmbeddingRequest
19
+ from .config import EmbeddingConfig, get_embedding_config
20
+ from .validation import validate_embedding_batch
21
+
22
+
23
+ def _resolve_adapter_class(binding: str) -> type[BaseEmbeddingAdapter]:
24
+ provider = (binding or "").strip().lower()
25
+ spec = EMBEDDING_PROVIDERS.get(provider)
26
+ if spec is None:
27
+ supported = sorted(EMBEDDING_PROVIDERS.keys())
28
+ raise ValueError(
29
+ f"Unknown embedding binding: '{binding}'. Supported: {', '.join(supported)}"
30
+ )
31
+ cls = ADAPTER_BACKENDS.get(spec.adapter)
32
+ if cls is None:
33
+ raise ValueError(
34
+ f"No adapter registered for backend '{spec.adapter}' (binding='{binding}')"
35
+ )
36
+ return cls
37
+
38
+
39
+ class EmbeddingClient:
40
+ """Unified embedding client for RAG and retrieval services."""
41
+
42
+ # 全局发帖节流:KB reindex 时 LlamaIndex 用线程池并发调 embedding,
43
+ # 每个线程经 _run_in_new_loop 跑独立 event loop——asyncio.Lock 绑定
44
+ # 创建时的 loop,跨 loop 既不互斥还会挂死。必须用线程级锁。
45
+ _spacing_lock: Any = None
46
+ _last_request_monotonic: float = 0.0
47
+ _thread_guard = threading.Lock()
48
+
49
+ @classmethod
50
+ def _global_spacing_lock(cls):
51
+ with cls._thread_guard:
52
+ if cls._spacing_lock is None:
53
+ cls._spacing_lock = threading.Lock()
54
+ return cls._spacing_lock
55
+
56
+ @staticmethod
57
+ @asynccontextmanager
58
+ async def _hold_spacing_lock():
59
+ """Acquire the cross-thread lock without blocking the current loop."""
60
+ import asyncio
61
+
62
+ lock = EmbeddingClient._global_spacing_lock()
63
+ while not lock.acquire(blocking=False):
64
+ await asyncio.sleep(0.05)
65
+ try:
66
+ yield
67
+ finally:
68
+ lock.release()
69
+
70
+ async def _wait_for_request_slot(self) -> None:
71
+ """Space request starts across threads/loops without serializing HTTP.
72
+
73
+ Text and multimodal calls share the same clock. No delay means no
74
+ throttling; adapters still enforce their own concurrency/retry policy.
75
+ """
76
+ import asyncio
77
+ from time import monotonic
78
+
79
+ delay = self.config.batch_delay
80
+ if delay <= 0:
81
+ return
82
+ async with EmbeddingClient._hold_spacing_lock():
83
+ remaining = delay - (monotonic() - EmbeddingClient._last_request_monotonic)
84
+ if remaining > 0:
85
+ await asyncio.sleep(remaining)
86
+ EmbeddingClient._last_request_monotonic = monotonic()
87
+
88
+ def __init__(self, config: Optional[EmbeddingConfig] = None):
89
+ self.config = config or get_embedding_config()
90
+ self.logger = logging.getLogger(__name__)
91
+ endpoint = self.config.effective_url or self.config.base_url
92
+ problem = embedding_endpoint_validation_error(self.config.binding, endpoint)
93
+ if problem:
94
+ displayed_endpoint = redact_embedding_endpoint_for_display(endpoint)
95
+ raise ValueError(
96
+ f"{problem} Current Settings endpoint is {displayed_endpoint!r}. "
97
+ "DeepTutor sends embedding requests to the Settings URL exactly; "
98
+ "update the visible Endpoint URL instead of relying on hidden path appending."
99
+ )
100
+ adapter_class = _resolve_adapter_class(self.config.binding)
101
+ self.adapter = adapter_class(
102
+ {
103
+ "api_key": self.config.api_key,
104
+ "base_url": self.config.effective_url or self.config.base_url,
105
+ "api_version": self.config.api_version,
106
+ "model": self.config.model,
107
+ "dimensions": self.config.dim,
108
+ "send_dimensions": self.config.send_dimensions,
109
+ "request_timeout": self.config.request_timeout,
110
+ "extra_headers": self.config.extra_headers or {},
111
+ }
112
+ )
113
+ self.logger.info(
114
+ f"Initialized embedding client with {self.config.binding} adapter "
115
+ f"(model: {self.config.model}, dimensions: {self.config.dim})"
116
+ )
117
+
118
+ async def embed(
119
+ self,
120
+ texts: List[str],
121
+ progress_callback=None,
122
+ *,
123
+ input_type: str | None = None,
124
+ ) -> List[List[float]]:
125
+ """Embed text batches, optionally identifying their retrieval role."""
126
+ if not texts:
127
+ return []
128
+
129
+ # Only adapters that opted in receive the role. Forwarding it to every
130
+ # backend would change the request Jina has always sent (no `task`) and
131
+ # silently invalidate the indexes built from it.
132
+ role = input_type if getattr(self.adapter, "SUPPORTS_INPUT_TYPE", False) else None
133
+
134
+ # Clamp configured batch size against the provider's per-request item
135
+ # cap. SiliconFlow Qwen3 family caps at 32; DashScope at 20; others
136
+ # have generous defaults. Without this clamp, indexing a doc with many
137
+ # chunks fails on the second batch even when "Test connection" passes.
138
+ spec = EMBEDDING_PROVIDERS.get(self.config.binding)
139
+ provider_max = spec.max_batch_items if spec else 256
140
+ batch_size = max(1, min(self.config.batch_size, provider_max))
141
+ if batch_size < self.config.batch_size:
142
+ self.logger.info(
143
+ f"Clamped batch_size {self.config.batch_size} -> {batch_size} "
144
+ f"(provider '{self.config.binding}' max={provider_max})"
145
+ )
146
+ all_embeddings: List[List[float]] = []
147
+ expected_dim: int | None = None
148
+
149
+ total_batches = (len(texts) + batch_size - 1) // batch_size
150
+ for i, start in enumerate(range(0, len(texts), batch_size)):
151
+ batch = texts[start : start + batch_size]
152
+ request = EmbeddingRequest(
153
+ texts=batch,
154
+ model=self.config.model,
155
+ dimensions=self.config.dim or None,
156
+ input_type=role,
157
+ )
158
+ try:
159
+ await self._wait_for_request_slot()
160
+ response = await self.adapter.embed(request)
161
+ except Exception as exc:
162
+ # Capture batch context so the task log stream / KB diagnostics
163
+ # show actionable info instead of a bare exception string.
164
+ import traceback
165
+
166
+ first_chunk_chars = len(batch[0]) if batch else 0
167
+ longest_chunk_chars = max((len(t) for t in batch), default=0)
168
+ self.logger.error(
169
+ f"Embedding batch failed "
170
+ f"(binding={self.config.binding}, model={self.config.model}, "
171
+ f"batch_index={i + 1}/{total_batches}, batch_items={len(batch)}, "
172
+ f"first_chunk_chars={first_chunk_chars}, "
173
+ f"longest_chunk_chars={longest_chunk_chars}): {exc}\n"
174
+ f"{traceback.format_exc()}"
175
+ )
176
+ raise
177
+ validated = validate_embedding_batch(
178
+ response.embeddings,
179
+ expected_count=len(batch),
180
+ binding=self.config.binding,
181
+ model=self.config.model,
182
+ batch_index=i + 1,
183
+ total_batches=total_batches,
184
+ start_index=start,
185
+ )
186
+ batch_dim = len(validated[0]) if validated else 0
187
+ if expected_dim is None:
188
+ expected_dim = batch_dim
189
+ elif batch_dim != expected_dim:
190
+ raise ValueError(
191
+ "Embedding provider returned inconsistent vector dimensions "
192
+ f"across batches (binding={self.config.binding}, "
193
+ f"model={self.config.model}): expected {expected_dim}, "
194
+ f"got {batch_dim} in batch {i + 1}/{total_batches}. "
195
+ "Use a single embedding model/dimension and re-index the knowledge base."
196
+ )
197
+
198
+ all_embeddings.extend(validated)
199
+
200
+ # Report progress after each batch
201
+ if progress_callback:
202
+ try:
203
+ progress_callback(i + 1, total_batches)
204
+ except Exception as exc:
205
+ # Progress feedback is best-effort: never fail the embedding
206
+ # run, but never lose the failure silently either — index
207
+ # progress would read as stalled with no trace why.
208
+ self.logger.warning(
209
+ f"Embedding progress callback failed "
210
+ f"(batch {i + 1}/{total_batches}): {exc}",
211
+ exc_info=True,
212
+ )
213
+
214
+ self.logger.debug(
215
+ f"Generated {len(all_embeddings)} embeddings using "
216
+ f"{self.config.binding} (batch_size={batch_size})"
217
+ )
218
+ return all_embeddings
219
+
220
+ def supports_multimodal_contents(self) -> bool:
221
+ """Return whether the configured adapter/model accepts multimodal contents."""
222
+ try:
223
+ info = self.adapter.get_model_info()
224
+ if "multimodal" in info:
225
+ return bool(info.get("multimodal"))
226
+ except Exception:
227
+ pass
228
+
229
+ spec = EMBEDDING_PROVIDERS.get(self.config.binding)
230
+ return bool(spec and spec.multimodal)
231
+
232
+ async def embed_contents(
233
+ self,
234
+ contents: List[Dict[str, Any]],
235
+ *,
236
+ progress_callback=None,
237
+ ) -> List[List[float]]:
238
+ """Embed provider-agnostic multimodal content items.
239
+
240
+ ``contents`` uses the same simple contract as ``EmbeddingRequest``:
241
+ ``[{"text": "..."}, {"image": "data:...|url"}, {"video": "..."}]``.
242
+ """
243
+ if not contents:
244
+ return []
245
+ if not self.supports_multimodal_contents():
246
+ raise ValueError(
247
+ "Configured embedding provider/model does not support multimodal contents."
248
+ )
249
+
250
+ spec = EMBEDDING_PROVIDERS.get(self.config.binding)
251
+ provider_max = spec.max_batch_items if spec else 256
252
+ batch_size = max(1, min(self.config.batch_size, provider_max))
253
+ all_embeddings: List[List[float]] = []
254
+ total_batches = (len(contents) + batch_size - 1) // batch_size
255
+
256
+ for i, start in enumerate(range(0, len(contents), batch_size)):
257
+ batch = contents[start : start + batch_size]
258
+ request = EmbeddingRequest(
259
+ texts=[],
260
+ model=self.config.model,
261
+ dimensions=self.config.dim or None,
262
+ contents=batch,
263
+ enable_fusion=False,
264
+ )
265
+ await self._wait_for_request_slot()
266
+ response = await self.adapter.embed(request)
267
+ validated = validate_embedding_batch(
268
+ response.embeddings,
269
+ expected_count=len(batch),
270
+ binding=self.config.binding,
271
+ model=self.config.model,
272
+ batch_index=i + 1,
273
+ total_batches=total_batches,
274
+ start_index=start,
275
+ )
276
+ all_embeddings.extend(validated)
277
+
278
+ if progress_callback:
279
+ try:
280
+ progress_callback(i + 1, total_batches)
281
+ except Exception as exc:
282
+ # Same contract as embed(): log and continue, so a broken
283
+ # progress sink cannot stall multimodal indexing invisibly.
284
+ self.logger.warning(
285
+ f"Embedding progress callback failed "
286
+ f"(batch {i + 1}/{total_batches}): {exc}",
287
+ exc_info=True,
288
+ )
289
+
290
+ return all_embeddings
291
+
292
+ def embed_sync(self, texts: List[str]) -> List[List[float]]:
293
+ import asyncio
294
+
295
+ try:
296
+ asyncio.get_running_loop()
297
+ except RuntimeError:
298
+ return asyncio.run(self.embed(texts))
299
+
300
+ import concurrent.futures
301
+
302
+ with concurrent.futures.ThreadPoolExecutor() as executor:
303
+ future = executor.submit(asyncio.run, self.embed(texts))
304
+ return future.result()
305
+
306
+
307
+ _client: Optional[EmbeddingClient] = None
308
+
309
+
310
+ def get_embedding_client(config: Optional[EmbeddingConfig] = None) -> EmbeddingClient:
311
+ global _client
312
+ resolved_config = config or get_embedding_config()
313
+ client = _client
314
+ if client is None or client.config != resolved_config:
315
+ client = EmbeddingClient(resolved_config)
316
+ _client = client
317
+ # Each caller retains its own client even if another worker changes the cache.
318
+ return client
319
+
320
+
321
+ def reset_embedding_client() -> None:
322
+ global _client
323
+ _client = None
@@ -0,0 +1,114 @@
1
+ """Normalized embedding configuration resolved from the model catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+
10
+ from deeptutor.services.config import resolve_embedding_runtime_config
11
+
12
+
13
+ @dataclass
14
+ class EmbeddingConfig:
15
+ """Embedding runtime configuration."""
16
+
17
+ model: str
18
+ api_key: str | list[str]
19
+ base_url: str | None = None
20
+ effective_url: str | None = None
21
+ binding: str = "openai"
22
+ provider_name: str = "openai"
23
+ provider_mode: str = "standard"
24
+ api_version: str | None = None
25
+ extra_headers: dict[str, str] | None = None
26
+ dim: int = 0
27
+ send_dimensions: bool | None = None
28
+ request_timeout: int = 60
29
+ batch_size: int = 10
30
+ batch_delay: float = 0.0
31
+
32
+
33
+ _scoped_config: ContextVar[EmbeddingConfig | None] = ContextVar("embedding_config", default=None)
34
+
35
+
36
+ def scoped_embedding_config() -> EmbeddingConfig | None:
37
+ return _scoped_config.get()
38
+
39
+
40
+ @contextmanager
41
+ def embedding_config_scope(config: EmbeddingConfig):
42
+ """Bind one operation, including its worker threads, to a resolved model."""
43
+ token = _scoped_config.set(config)
44
+ try:
45
+ yield
46
+ finally:
47
+ _scoped_config.reset(token)
48
+
49
+
50
+ def get_embedding_config(
51
+ selection: dict | None = None, *, catalog: dict | None = None
52
+ ) -> EmbeddingConfig:
53
+ """Load embedding config from provider runtime resolver."""
54
+ if selection is None and catalog is None and _scoped_config.get() is not None:
55
+ return _scoped_config.get()
56
+ if selection is not None:
57
+ from deeptutor.services.config import get_model_catalog_service
58
+
59
+ catalog = deepcopy(catalog if catalog is not None else get_model_catalog_service().load())
60
+ service = catalog.get("services", {}).get("embedding", {})
61
+ profile = next(
62
+ (p for p in service.get("profiles", []) if p.get("id") == selection.get("profile_id")),
63
+ None,
64
+ )
65
+ model = next(
66
+ (
67
+ m
68
+ for m in (profile or {}).get("models", [])
69
+ if m.get("id") == selection.get("model_id")
70
+ ),
71
+ None,
72
+ )
73
+ if profile is None or model is None:
74
+ raise ValueError(
75
+ "The embedding model bound to this knowledge base was deleted. Select another embedding model to re-index it."
76
+ )
77
+ service["active_profile_id"] = profile["id"]
78
+ service["active_model_id"] = model["id"]
79
+ resolved = (
80
+ resolve_embedding_runtime_config(catalog=catalog)
81
+ if catalog is not None
82
+ else resolve_embedding_runtime_config()
83
+ )
84
+
85
+ if not resolved.model:
86
+ raise ValueError("Embedding model not set. Configure it in Settings > Embedding models.")
87
+
88
+ if not resolved.effective_url:
89
+ raise ValueError(
90
+ "No effective embedding endpoint resolved. Please configure base_url/host for the active profile."
91
+ )
92
+
93
+ if resolved.provider_mode != "local" and not resolved.api_key:
94
+ raise ValueError(
95
+ "Embedding API key not set. Configure the active profile in "
96
+ "Settings > Embedding models."
97
+ )
98
+
99
+ return EmbeddingConfig(
100
+ model=resolved.model,
101
+ api_key=resolved.api_key,
102
+ base_url=resolved.base_url,
103
+ effective_url=resolved.effective_url,
104
+ binding=resolved.binding,
105
+ provider_name=resolved.provider_name,
106
+ provider_mode=resolved.provider_mode,
107
+ api_version=resolved.api_version,
108
+ extra_headers=resolved.extra_headers,
109
+ dim=resolved.dimension,
110
+ send_dimensions=resolved.send_dimensions,
111
+ request_timeout=max(1, resolved.request_timeout),
112
+ batch_size=max(1, resolved.batch_size),
113
+ batch_delay=max(0.0, resolved.batch_delay),
114
+ )
@@ -0,0 +1,46 @@
1
+ """Shared request-option decisions for embedding transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from deeptutor.services.config.embedding_endpoint import canonical_embedding_provider_name
6
+
7
+ _JINA_VARIABLE_DIMENSIONS: dict[str, frozenset[int]] = {
8
+ "jina-embeddings-v3": frozenset({32, 64, 128, 256, 512, 768, 1024}),
9
+ "jina-embeddings-v4": frozenset({32, 64, 128, 256, 512, 768, 1024}),
10
+ }
11
+
12
+
13
+ def should_send_embedding_dimensions(
14
+ *,
15
+ binding: str | None,
16
+ model: str | None,
17
+ dimension: int | None,
18
+ send_dimensions: bool | None,
19
+ ) -> bool:
20
+ """Apply DeepTutor's tri-state ``dimensions`` request policy.
21
+
22
+ Explicit user choices always win. In automatic mode, Jina uses its known
23
+ Matryoshka dimensions while OpenAI-compatible transports use the model
24
+ families already supported by DeepTutor's regular embedding adapters.
25
+ """
26
+ if not dimension:
27
+ return False
28
+ if send_dimensions is True:
29
+ return True
30
+ if send_dimensions is False:
31
+ return False
32
+
33
+ provider = canonical_embedding_provider_name(binding)
34
+ model_name = str(model or "").strip()
35
+ if provider == "jina":
36
+ return dimension in _JINA_VARIABLE_DIMENSIONS.get(model_name, frozenset())
37
+
38
+ lowered = model_name.lower()
39
+ return (
40
+ lowered.startswith("text-embedding-3")
41
+ or "qwen3-embedding" in lowered
42
+ or "qwen3-vl-embedding" in lowered
43
+ )
44
+
45
+
46
+ __all__ = ["should_send_embedding_dimensions"]