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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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
|
+
}
|