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,462 @@
1
+ """Gemini embeddings via the native batch API with legacy compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import replace
7
+ import json
8
+ import logging
9
+ import math
10
+ from typing import Any, Dict
11
+ from urllib.parse import parse_qsl, unquote, urlparse
12
+
13
+ import httpx
14
+
15
+ from deeptutor.services.config.embedding_endpoint import (
16
+ GEMINI_API_HOST,
17
+ SENSITIVE_ENDPOINT_QUERY_KEYS,
18
+ redact_embedding_endpoint_for_display,
19
+ )
20
+ from deeptutor.services.llm.openai_http_client import disable_ssl_verify_enabled
21
+
22
+ from .base import EmbeddingProviderError, EmbeddingRequest, EmbeddingResponse
23
+ from .openai_compatible import OpenAICompatibleEmbeddingAdapter
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class GeminiEmbeddingAdapter(OpenAICompatibleEmbeddingAdapter):
29
+ """Use Gemini's native API while preserving saved OpenAI-compatible setups."""
30
+
31
+ SUPPORTS_INPUT_TYPE = True
32
+
33
+ # ``multimodal`` states what the model maps into its embedding space, and
34
+ # is the single place that decides it: ``get_model_info`` reports it to
35
+ # Settings, and ``_embed_native`` refuses ``contents`` for a model that
36
+ # says False rather than posting parts the endpoint will reject (#814).
37
+ MODELS_INFO: dict[str, object] = {
38
+ "gemini-embedding-2": {
39
+ "default": 3072,
40
+ "dimensions": [128, 256, 512, 768, 1536, 3072],
41
+ "multimodal": True,
42
+ },
43
+ "gemini-embedding-2-preview": {
44
+ "default": 3072,
45
+ "dimensions": [128, 256, 512, 768, 1536, 3072],
46
+ "multimodal": True,
47
+ },
48
+ "gemini-embedding-001": {
49
+ "default": 3072,
50
+ "dimensions": [128, 256, 512, 768, 1536, 3072],
51
+ "multimodal": False,
52
+ },
53
+ }
54
+
55
+ _QUERY_PREFIX = "task: search result | query: "
56
+ _DOCUMENT_PREFIX = "title: none | text: "
57
+ _NATIVE_SUFFIX = ":batchEmbedContents"
58
+ _SENSITIVE_QUERY_KEYS = SENSITIVE_ENDPOINT_QUERY_KEYS
59
+
60
+ @staticmethod
61
+ def _model_id(model_name: str | None) -> str:
62
+ normalized = str(model_name or "").strip()
63
+ return normalized.removeprefix("models/")
64
+
65
+ @classmethod
66
+ def _is_embedding2(cls, model_name: str | None) -> bool:
67
+ """Return whether a model uses Gemini Embedding 2 prompt instructions."""
68
+ return cls._model_id(model_name).lower().startswith("gemini-embedding-2")
69
+
70
+ @classmethod
71
+ def _format_retrieval_texts(
72
+ cls,
73
+ texts: list[str],
74
+ input_type: str | None,
75
+ ) -> list[str]:
76
+ """Return new strings using Gemini 2's asymmetric retrieval format."""
77
+ if input_type == "search_query":
78
+ return [f"{cls._QUERY_PREFIX}{text}" for text in texts]
79
+ if input_type == "search_document":
80
+ return [f"{cls._DOCUMENT_PREFIX}{text}" for text in texts]
81
+ return list(texts)
82
+
83
+ @classmethod
84
+ def _native_endpoint_model(cls, endpoint: str | None) -> str | None:
85
+ path = urlparse(str(endpoint or "")).path.rstrip("/")
86
+ if not path.endswith(cls._NATIVE_SUFFIX) or "/models/" not in path:
87
+ return None
88
+ model_segment = path.rsplit("/models/", 1)[1]
89
+ return unquote(model_segment.removesuffix(cls._NATIVE_SUFFIX))
90
+
91
+ def _should_send_dimensions(self, model_name: str | None) -> bool:
92
+ """Only send OpenAI-style dimensions when a legacy user opted in."""
93
+ del model_name
94
+ return self.send_dimensions is True
95
+
96
+ @staticmethod
97
+ def _task_type(input_type: str | None) -> str | None:
98
+ return {
99
+ "search_query": "RETRIEVAL_QUERY",
100
+ "search_document": "RETRIEVAL_DOCUMENT",
101
+ }.get(input_type)
102
+
103
+ @staticmethod
104
+ def _normalize_vectors(vectors: list[list[float]]) -> list[list[float]]:
105
+ """L2-normalize vectors without mutating the provider response."""
106
+ normalized: list[list[float]] = []
107
+ for vector in vectors:
108
+ norm = math.sqrt(sum(value * value for value in vector))
109
+ normalized.append([value / norm for value in vector] if norm else list(vector))
110
+ return normalized
111
+
112
+ @classmethod
113
+ def _redacted_url(cls, url: str) -> str:
114
+ del cls
115
+ return redact_embedding_endpoint_for_display(url)
116
+
117
+ def _redacted_body(
118
+ self,
119
+ body: str,
120
+ *,
121
+ api_key: str,
122
+ url: str,
123
+ sensitive_texts: list[str],
124
+ ) -> str:
125
+ secrets = [api_key, *sensitive_texts]
126
+ secrets.extend(
127
+ value
128
+ for key, value in parse_qsl(urlparse(url).query, keep_blank_values=True)
129
+ if key.lower() in self._SENSITIVE_QUERY_KEYS
130
+ )
131
+ for key, value in self.extra_headers.items():
132
+ if str(key).lower() not in {"authorization", "x-goog-api-key"}:
133
+ continue
134
+ secret = str(value)
135
+ secrets.extend([secret, secret.encode("unicode_escape").decode("ascii")])
136
+ if secret.lower().startswith("bearer "):
137
+ token = secret[7:].strip()
138
+ secrets.extend([token, token.encode("unicode_escape").decode("ascii")])
139
+ redacted = body
140
+ for secret in secrets:
141
+ if secret:
142
+ redacted = redacted.replace(secret, "[REDACTED]")
143
+ return redacted[:500]
144
+
145
+ @classmethod
146
+ def _model_info(cls, model_name: str | None) -> dict[str, Any] | None:
147
+ """The MODELS_INFO row for a model, falling back across the 2.x line."""
148
+ model_id = cls._model_id(model_name)
149
+ info = cls.MODELS_INFO.get(model_id)
150
+ if info is None and cls._is_embedding2(model_id):
151
+ info = cls.MODELS_INFO["gemini-embedding-2"]
152
+ return info if isinstance(info, dict) else None
153
+
154
+ @classmethod
155
+ def _supports_multimodal(cls, model_name: str | None) -> bool:
156
+ info = cls._model_info(model_name)
157
+ return bool(info.get("multimodal", False)) if info else False
158
+
159
+ @staticmethod
160
+ def _inline_data(value: str, kind: str) -> dict[str, Any]:
161
+ """Turn one ``data:`` URI into Gemini's ``inlineData`` part.
162
+
163
+ Only ``data:`` URIs are accepted. `batchEmbedContents` has no remote-URL
164
+ part — the alternative to inline bytes is a File API upload — so an
165
+ http(s) value could only be honoured by fetching it here, and this
166
+ repository has already settled that question against doing so (see
167
+ ``services/llm/multimodal._resolve_local_attachment_url``: sync network
168
+ IO on an async path, and an SSRF footgun). Callers hand us bytes.
169
+ """
170
+ text = str(value or "").strip()
171
+ if not text.startswith("data:"):
172
+ raise ValueError(
173
+ f"Gemini embeddings need inline bytes for '{kind}' content, but got "
174
+ f"{'an http(s) URL' if text[:4].lower() == 'http' else 'an unsupported value'}. "
175
+ "Pass a data: URI (base64) instead."
176
+ )
177
+ header, _, payload = text.partition(",")
178
+ if not payload:
179
+ raise ValueError(f"Malformed data: URI for '{kind}' content — no base64 payload.")
180
+ mime_type = header[len("data:") :].split(";", 1)[0].strip()
181
+ if not mime_type:
182
+ raise ValueError(f"Malformed data: URI for '{kind}' content — no MIME type.")
183
+ return {"inlineData": {"mimeType": mime_type, "data": payload}}
184
+
185
+ @classmethod
186
+ def _content_parts(cls, item: dict[str, Any]) -> list[dict[str, Any]]:
187
+ """Map one ``{"text"|"image"|"video"|"audio": value}`` item to parts."""
188
+ parts: list[dict[str, Any]] = []
189
+ for kind, value in item.items():
190
+ if kind == "text":
191
+ parts.append({"text": str(value or "")})
192
+ elif kind in {"image", "video", "audio", "document"}:
193
+ parts.append(cls._inline_data(str(value or ""), kind))
194
+ else:
195
+ raise ValueError(f"Gemini embeddings do not support content type '{kind}'.")
196
+ return parts
197
+
198
+ def _native_multimodal_payload(
199
+ self,
200
+ request: EmbeddingRequest,
201
+ model: str,
202
+ ) -> dict[str, Any]:
203
+ """Build ``batchEmbedContents`` requests from provider-agnostic contents.
204
+
205
+ One vector per content item, matching the Cohere adapter's reading of
206
+ the same contract — except that Gemini maps every modality into one
207
+ shared space, so ``enable_fusion`` genuinely works here: it folds every
208
+ item's parts into a single content and returns one vector for the lot.
209
+ """
210
+ model_id = self._model_id(model)
211
+ items = [item for item in (request.contents or []) if isinstance(item, dict)]
212
+ dimension = request.dimensions or self.dimensions
213
+
214
+ def _request(parts: list[dict[str, Any]]) -> dict[str, Any]:
215
+ entry: dict[str, Any] = {
216
+ "model": f"models/{model_id}",
217
+ "content": {"parts": parts},
218
+ }
219
+ if dimension and self.send_dimensions is not False:
220
+ entry["outputDimensionality"] = dimension
221
+ return entry
222
+
223
+ if request.enable_fusion:
224
+ fused = [part for item in items for part in self._content_parts(item)]
225
+ return {"requests": [_request(fused)] if fused else []}
226
+ return {"requests": [_request(self._content_parts(item)) for item in items]}
227
+
228
+ def _native_payload(
229
+ self,
230
+ request: EmbeddingRequest,
231
+ model: str,
232
+ ) -> dict[str, Any]:
233
+ model_id = self._model_id(model)
234
+ texts = (
235
+ self._format_retrieval_texts(request.texts, request.input_type)
236
+ if self._is_embedding2(model_id)
237
+ else list(request.texts)
238
+ )
239
+ dimension = request.dimensions or self.dimensions
240
+ task_type = self._task_type(request.input_type)
241
+ native_requests: list[dict[str, Any]] = []
242
+ for text in texts:
243
+ item: dict[str, Any] = {
244
+ "model": f"models/{model_id}",
245
+ "content": {"parts": [{"text": text}]},
246
+ }
247
+ if dimension and self.send_dimensions is not False:
248
+ item["outputDimensionality"] = dimension
249
+ # Gemini Embedding 2 removed task_type in favour of prompt
250
+ # instructions. The text-only 001 model still supports it.
251
+ if task_type and not self._is_embedding2(model_id):
252
+ item["taskType"] = task_type
253
+ native_requests.append(item)
254
+ return {"requests": native_requests}
255
+
256
+ async def _embed_native(
257
+ self,
258
+ request: EmbeddingRequest,
259
+ model: str,
260
+ ) -> EmbeddingResponse:
261
+ endpoint_model = self._native_endpoint_model(self.base_url)
262
+ model_id = self._model_id(model)
263
+ if endpoint_model != model_id:
264
+ raise ValueError(
265
+ f"Gemini endpoint model '{endpoint_model}' does not match selected "
266
+ f"model '{model_id}'. Update the visible embedding Endpoint URL."
267
+ )
268
+ if request.contents and not self._supports_multimodal(model_id):
269
+ raise ValueError(
270
+ f"Gemini model '{model_id}' is text-only and cannot embed multimodal "
271
+ "`contents`. Select gemini-embedding-2, which maps text, images, "
272
+ "video, audio and documents into one space."
273
+ )
274
+
275
+ payload = (
276
+ self._native_multimodal_payload(request, model_id)
277
+ if request.contents
278
+ else self._native_payload(request, model_id)
279
+ )
280
+ headers = {"Content-Type": "application/json"}
281
+ api_key = self._auth_api_key()
282
+ explicit_auth = {str(key).lower() for key in self.extra_headers} & {
283
+ "authorization",
284
+ "x-goog-api-key",
285
+ }
286
+ if api_key and not explicit_auth:
287
+ if urlparse(str(self.base_url)).hostname == GEMINI_API_HOST:
288
+ headers["x-goog-api-key"] = api_key
289
+ else:
290
+ headers["Authorization"] = f"Bearer {api_key}"
291
+ headers.update({str(key): str(value) for key, value in self.extra_headers.items()})
292
+ url = str(self.base_url)
293
+ safe_url = self._redacted_url(url)
294
+ timeout = httpx.Timeout(
295
+ connect=10.0,
296
+ read=max(self.request_timeout, 60),
297
+ write=10.0,
298
+ pool=10.0,
299
+ )
300
+
301
+ data: Any = None
302
+ last_error: Exception | None = None
303
+ for attempt in range(1 + self._MAX_RETRIES):
304
+ try:
305
+ async with httpx.AsyncClient(
306
+ timeout=timeout,
307
+ verify=not disable_ssl_verify_enabled(),
308
+ ) as client:
309
+ response = await client.post(url, json=payload, headers=headers)
310
+ if response.status_code == 429:
311
+ try:
312
+ retry_after = float(response.headers.get("Retry-After", 0))
313
+ except (TypeError, ValueError):
314
+ retry_after = 0
315
+ wait = max(
316
+ retry_after,
317
+ self._RATE_LIMIT_BACKOFF * (2**attempt),
318
+ )
319
+ logger.warning(
320
+ "Gemini embedding rate limited on attempt %s/%s; retrying in %.1fs",
321
+ attempt + 1,
322
+ 1 + self._MAX_RETRIES,
323
+ wait,
324
+ )
325
+ await asyncio.sleep(wait)
326
+ last_error = EmbeddingProviderError(
327
+ "Gemini embedding provider returned HTTP 429",
328
+ status=429,
329
+ model=model_id,
330
+ url=safe_url,
331
+ provider="gemini",
332
+ )
333
+ continue
334
+ if response.status_code >= 400:
335
+ raise EmbeddingProviderError(
336
+ f"Gemini embedding provider returned HTTP {response.status_code}",
337
+ status=response.status_code,
338
+ body=self._redacted_body(
339
+ response.text,
340
+ api_key=api_key,
341
+ url=url,
342
+ sensitive_texts=request.texts,
343
+ ),
344
+ model=model_id,
345
+ url=safe_url,
346
+ provider="gemini",
347
+ )
348
+ try:
349
+ data = response.json()
350
+ except (json.JSONDecodeError, ValueError) as exc:
351
+ raise EmbeddingProviderError(
352
+ f"Gemini embedding provider returned non-JSON response: {exc}",
353
+ status=response.status_code,
354
+ body=self._redacted_body(
355
+ response.text,
356
+ api_key=api_key,
357
+ url=url,
358
+ sensitive_texts=request.texts,
359
+ ),
360
+ model=model_id,
361
+ url=safe_url,
362
+ provider="gemini",
363
+ ) from exc
364
+ break
365
+ except httpx.TransportError as exc:
366
+ last_error = exc
367
+ if attempt >= self._MAX_RETRIES:
368
+ safe_error = self._redacted_body(
369
+ str(exc),
370
+ api_key=api_key,
371
+ url=url,
372
+ sensitive_texts=request.texts,
373
+ )
374
+ raise EmbeddingProviderError(
375
+ f"Gemini embedding transport error: {safe_error}",
376
+ model=model_id,
377
+ url=safe_url,
378
+ provider="gemini",
379
+ ) from None
380
+ wait = self._RETRY_BACKOFF * (2**attempt)
381
+ logger.warning(
382
+ "Gemini embedding transport error on attempt %s/%s; retrying in %.1fs",
383
+ attempt + 1,
384
+ 1 + self._MAX_RETRIES,
385
+ wait,
386
+ )
387
+ await asyncio.sleep(wait)
388
+ else:
389
+ if last_error is not None:
390
+ raise last_error
391
+
392
+ raw_embeddings = data.get("embeddings") if isinstance(data, dict) else None
393
+ if not isinstance(raw_embeddings, list):
394
+ raise ValueError("Gemini native embedding response is missing the `embeddings` list.")
395
+ embeddings = [item.get("values") or [] for item in raw_embeddings if isinstance(item, dict)]
396
+ if not embeddings or any(
397
+ not isinstance(vector, list) or not vector for vector in embeddings
398
+ ):
399
+ raise ValueError("Gemini native embedding response contains no usable vectors.")
400
+ # Count against what was actually posted, not against `request.texts`:
401
+ # a multimodal turn carries no texts at all, and a fused one collapses
402
+ # many items into a single vector.
403
+ expected = len(payload.get("requests", []))
404
+ if len(embeddings) != expected:
405
+ raise ValueError(
406
+ "Gemini native embedding response count does not match the request: "
407
+ f"expected {expected}, got {len(embeddings)}."
408
+ )
409
+
410
+ requested_dimension = request.dimensions or self.dimensions
411
+ if (
412
+ request.normalized
413
+ and model_id == "gemini-embedding-001"
414
+ and requested_dimension
415
+ and requested_dimension != 3072
416
+ ):
417
+ embeddings = self._normalize_vectors(embeddings)
418
+
419
+ actual_dimension = len(embeddings[0])
420
+ return EmbeddingResponse(
421
+ embeddings=embeddings,
422
+ model=model_id,
423
+ dimensions=actual_dimension,
424
+ usage=(data.get("usageMetadata", {}) if isinstance(data, dict) else {}),
425
+ )
426
+
427
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
428
+ """Embed text through native Gemini or a saved legacy-compatible URL."""
429
+ model = self._model_id(request.model or self.model)
430
+ if self._native_endpoint_model(self.base_url) is not None:
431
+ return await self._embed_native(request, model)
432
+
433
+ prepared = request
434
+ if not request.contents and self._is_embedding2(model):
435
+ prepared = replace(
436
+ request,
437
+ model=model,
438
+ texts=self._format_retrieval_texts(request.texts, request.input_type),
439
+ )
440
+ return await super().embed(prepared)
441
+
442
+ def get_model_info(self) -> Dict[str, Any]:
443
+ """Return Gemini embedding dimensions exposed to Settings diagnostics."""
444
+ model_name = self._model_id(self.model)
445
+ model_info = self._model_info(model_name)
446
+ if model_info is None:
447
+ return {
448
+ "model": model_name,
449
+ "dimensions": self.dimensions,
450
+ "supported_dimensions": [],
451
+ "supports_variable_dimensions": False,
452
+ "multimodal": False,
453
+ "provider": "gemini",
454
+ }
455
+ return {
456
+ "model": model_name,
457
+ "dimensions": model_info["default"],
458
+ "supported_dimensions": list(model_info["dimensions"]),
459
+ "supports_variable_dimensions": True,
460
+ "multimodal": bool(model_info.get("multimodal", False)),
461
+ "provider": "gemini",
462
+ }
@@ -0,0 +1,161 @@
1
+ """Jina AI embedding adapter with task-aware embeddings and late chunking."""
2
+
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+ import httpx
7
+
8
+ from deeptutor.services.embedding.request_options import should_send_embedding_dimensions
9
+ from deeptutor.services.llm.openai_http_client import disable_ssl_verify_enabled
10
+
11
+ from .base import BaseEmbeddingAdapter, EmbeddingRequest, EmbeddingResponse
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class JinaEmbeddingAdapter(BaseEmbeddingAdapter):
17
+ # Jina ignores `input_type` unless explicitly opted in. Enabling it changes
18
+ # vectors for both documents and queries, so the embedding signature must
19
+ # distinguish pre-role Jina indexes.
20
+ SUPPORTS_INPUT_TYPE = True
21
+
22
+ MODELS_INFO = {
23
+ "jina-embeddings-v3": {
24
+ "default": 1024,
25
+ "dimensions": [32, 64, 128, 256, 512, 768, 1024],
26
+ "multimodal": False,
27
+ },
28
+ "jina-embeddings-v4": {
29
+ "default": 1024,
30
+ "dimensions": [32, 64, 128, 256, 512, 768, 1024],
31
+ "multimodal": True,
32
+ },
33
+ }
34
+
35
+ INPUT_TYPE_TO_TASK = {
36
+ "search_document": "retrieval.passage",
37
+ "search_query": "retrieval.query",
38
+ "classification": "classification",
39
+ "clustering": "separation",
40
+ "text-matching": "text-matching",
41
+ }
42
+
43
+ def _should_send_dimensions(self, model_name: str | None, dim: int) -> bool:
44
+ """Decide whether to attach `dimensions` (Matryoshka truncation)."""
45
+ decision = should_send_embedding_dimensions(
46
+ binding="jina",
47
+ model=model_name,
48
+ dimension=dim,
49
+ send_dimensions=self.send_dimensions,
50
+ )
51
+ if self.send_dimensions is not None or decision:
52
+ return decision
53
+ info = self.MODELS_INFO.get(model_name or "", {})
54
+ supported = info.get("dimensions") if isinstance(info, dict) else None
55
+ if isinstance(supported, list):
56
+ logger.warning(
57
+ f"Jina model '{model_name}' supports dims {supported} but {dim} requested; "
58
+ "dropping `dimensions` from payload."
59
+ )
60
+ return decision
61
+
62
+ def _supports_multimodal(self, model_name: str | None) -> bool:
63
+ info = self.MODELS_INFO.get(model_name or "")
64
+ return bool(isinstance(info, dict) and info.get("multimodal", False))
65
+
66
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
67
+ headers = {
68
+ "Authorization": f"Bearer {self.api_key}",
69
+ "Content-Type": "application/json",
70
+ }
71
+ headers.update({str(k): str(v) for k, v in self.extra_headers.items()})
72
+
73
+ # Jina v4 accepts mixed `["text", "https://image.url", "data:..."]`
74
+ # arrays in `input`; v3 is text-only. Treat `contents` as advisory:
75
+ # if set, flatten each {"text"|"image"|"video": value} to its value.
76
+ if request.contents:
77
+ if not self._supports_multimodal(request.model or self.model):
78
+ raise ValueError(
79
+ f"Jina model '{request.model or self.model}' does not support "
80
+ "multimodal `contents`."
81
+ )
82
+ input_payload = [
83
+ next(iter(item.values())) for item in request.contents if isinstance(item, dict)
84
+ ]
85
+ else:
86
+ input_payload = request.texts
87
+
88
+ payload = {
89
+ "input": input_payload,
90
+ "model": request.model or self.model,
91
+ }
92
+
93
+ # `dimensions` opt-in: tri-state send_dimensions wins; otherwise only
94
+ # send when the configured model is in MODELS_INFO and exposes a
95
+ # supported list (Matryoshka). Avoids HTTP 400 on models that reject
96
+ # the param.
97
+ dim_value = request.dimensions or self.dimensions
98
+ if dim_value and self._should_send_dimensions(request.model or self.model, dim_value):
99
+ payload["dimensions"] = dim_value
100
+
101
+ if request.input_type:
102
+ task = self.INPUT_TYPE_TO_TASK.get(request.input_type, request.input_type)
103
+ payload["task"] = task
104
+ logger.debug(f"Using Jina task: {task}")
105
+
106
+ if request.normalized is not None:
107
+ payload["normalized"] = request.normalized
108
+
109
+ if request.late_chunking:
110
+ payload["late_chunking"] = True
111
+
112
+ url = self.base_url
113
+
114
+ logger.debug(f"Sending embedding request to {url} with {len(request.texts)} texts")
115
+
116
+ async with httpx.AsyncClient(
117
+ timeout=self.request_timeout, verify=not disable_ssl_verify_enabled()
118
+ ) as client:
119
+ response = await client.post(url, json=payload, headers=headers)
120
+
121
+ if response.status_code >= 400:
122
+ logger.error(f"HTTP {response.status_code} response body: {response.text}")
123
+
124
+ response.raise_for_status()
125
+ data = response.json()
126
+
127
+ embeddings = [item["embedding"] for item in data["data"]]
128
+ actual_dims = len(embeddings[0]) if embeddings else 0
129
+
130
+ logger.info(
131
+ f"Successfully generated {len(embeddings)} embeddings "
132
+ f"(model: {data['model']}, dimensions: {actual_dims})"
133
+ )
134
+
135
+ return EmbeddingResponse(
136
+ embeddings=embeddings,
137
+ model=data["model"],
138
+ dimensions=actual_dims,
139
+ usage=data.get("usage", {}),
140
+ )
141
+
142
+ def get_model_info(self) -> Dict[str, Any]:
143
+ model_info = self.MODELS_INFO.get(self.model, self.dimensions)
144
+
145
+ if isinstance(model_info, dict):
146
+ return {
147
+ "model": self.model,
148
+ "dimensions": model_info.get("default", self.dimensions),
149
+ "supported_dimensions": model_info.get("dimensions", []),
150
+ "supports_variable_dimensions": True,
151
+ "multimodal": bool(model_info.get("multimodal", False)),
152
+ "provider": "jina",
153
+ }
154
+ else:
155
+ return {
156
+ "model": self.model,
157
+ "dimensions": model_info or self.dimensions,
158
+ "supports_variable_dimensions": False,
159
+ "multimodal": False,
160
+ "provider": "jina",
161
+ }