codegraph-voyage 0.1.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.
- codegraph_voyage/__init__.py +8 -0
- codegraph_voyage/__main__.py +5 -0
- codegraph_voyage/cli.py +691 -0
- codegraph_voyage/document.py +238 -0
- codegraph_voyage/explore.py +148 -0
- codegraph_voyage/mcp_server.py +78 -0
- codegraph_voyage/providers.py +275 -0
- codegraph_voyage/ranking.py +448 -0
- codegraph_voyage/sanitize.py +116 -0
- codegraph_voyage/sidecar.py +325 -0
- codegraph_voyage/tests/__init__.py +1 -0
- codegraph_voyage/tests/benchmark.py +278 -0
- codegraph_voyage/tests/test_all.py +1114 -0
- codegraph_voyage-0.1.0.dist-info/METADATA +196 -0
- codegraph_voyage-0.1.0.dist-info/RECORD +17 -0
- codegraph_voyage-0.1.0.dist-info/WHEEL +4 -0
- codegraph_voyage-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Embedding provider strategy — abstract base and concrete implementations.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- VoyageEmbeddingProvider: Real voyage-code-4 API via urllib.request (stdlib).
|
|
5
|
+
- FakeEmbeddingProvider: Deterministic fake for tests.
|
|
6
|
+
|
|
7
|
+
Each provider produces embeddings as lists of floats with a configurable
|
|
8
|
+
dimension.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import struct
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EmbeddingProvider(ABC):
|
|
23
|
+
"""Abstract base for embedding providers."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def embed_documents(self, texts: list[str], *, input_type: str = "document") -> list[list[float]]:
|
|
27
|
+
"""Embed a list of document texts.
|
|
28
|
+
|
|
29
|
+
Returns a list of embedding vectors (list of floats).
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
|
|
35
|
+
"""Embed a single query string.
|
|
36
|
+
|
|
37
|
+
Returns an embedding vector (list of floats).
|
|
38
|
+
"""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def model_name(self) -> str:
|
|
44
|
+
"""Return the model identifier (e.g. 'voyage-code-4')."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def dimensions(self) -> int:
|
|
50
|
+
"""Return the embedding dimension."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def raw_bytes(self, vector: list[float]) -> bytes:
|
|
54
|
+
"""Serialize a float vector to raw bytes (float32 little-endian)."""
|
|
55
|
+
return struct.pack(f"<{len(vector)}f", *vector)
|
|
56
|
+
|
|
57
|
+
def from_bytes(self, data: bytes) -> list[float]:
|
|
58
|
+
"""Deserialize raw bytes back to a float vector."""
|
|
59
|
+
n = len(data) // 4
|
|
60
|
+
return list(struct.unpack(f"<{n}f", data))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VoyageEmbeddingProvider(EmbeddingProvider):
|
|
64
|
+
"""Real voyage-code-4 embedding provider via the Voyage API.
|
|
65
|
+
|
|
66
|
+
Uses the official Voyage AI embeddings API:
|
|
67
|
+
POST https://api.voyageai.com/v1/embeddings
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
API_URL = "https://api.voyageai.com/v1/embeddings"
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
model: str | None = None,
|
|
76
|
+
dimensions: int = 512,
|
|
77
|
+
timeout: int = 60,
|
|
78
|
+
batch_size: int = 128,
|
|
79
|
+
):
|
|
80
|
+
if not api_key:
|
|
81
|
+
api_key = os.environ.get("VOYAGE_API_KEY", "")
|
|
82
|
+
if not api_key:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"VOYAGE_API_KEY is required. Set the environment variable or pass api_key."
|
|
85
|
+
)
|
|
86
|
+
self._api_key = api_key
|
|
87
|
+
self._model = model or os.environ.get("VOYAGE_MODEL", "voyage-code-4")
|
|
88
|
+
self._dimensions = dimensions
|
|
89
|
+
self._timeout = timeout
|
|
90
|
+
if not 1 <= batch_size <= 128:
|
|
91
|
+
raise ValueError("batch_size must be between 1 and 128")
|
|
92
|
+
self._batch_size = batch_size
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def model_name(self) -> str:
|
|
96
|
+
return self._model
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def dimensions(self) -> int:
|
|
100
|
+
return self._dimensions
|
|
101
|
+
|
|
102
|
+
def embed_documents(
|
|
103
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
104
|
+
) -> list[list[float]]:
|
|
105
|
+
"""Embed documents in Voyage batches while preserving input order."""
|
|
106
|
+
result: list[list[float]] = []
|
|
107
|
+
for offset in range(0, len(texts), self._batch_size):
|
|
108
|
+
batch = texts[offset:offset + self._batch_size]
|
|
109
|
+
try:
|
|
110
|
+
result.extend(self._call_api(batch, input_type=input_type))
|
|
111
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
112
|
+
batch_number = offset // self._batch_size + 1
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"Voyage embedding batch {batch_number} failed: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
|
|
119
|
+
"""Embed a query using voyage-code-4 with input_type='query'."""
|
|
120
|
+
result = self._call_api([text], input_type=input_type)
|
|
121
|
+
return result[0]
|
|
122
|
+
|
|
123
|
+
def _call_api(
|
|
124
|
+
self, texts: list[str], *, input_type: str
|
|
125
|
+
) -> list[list[float]]:
|
|
126
|
+
"""Make the actual API call to Voyage AI via stdlib urllib.request.
|
|
127
|
+
|
|
128
|
+
Empty texts are filtered out before the API call; the returned list
|
|
129
|
+
is mapped back to the original text order (empty lists for skipped
|
|
130
|
+
texts).
|
|
131
|
+
"""
|
|
132
|
+
# Track which original indices are non-empty
|
|
133
|
+
valid_indices: list[int] = []
|
|
134
|
+
valid_texts: list[str] = []
|
|
135
|
+
for i, t in enumerate(texts):
|
|
136
|
+
if t.strip():
|
|
137
|
+
valid_indices.append(i)
|
|
138
|
+
valid_texts.append(t)
|
|
139
|
+
|
|
140
|
+
if not valid_texts:
|
|
141
|
+
return [[] for _ in texts]
|
|
142
|
+
|
|
143
|
+
payload: dict[str, Any] = {
|
|
144
|
+
"model": self._model,
|
|
145
|
+
"input": valid_texts,
|
|
146
|
+
"input_type": input_type,
|
|
147
|
+
}
|
|
148
|
+
if self._dimensions:
|
|
149
|
+
payload["output_dimension"] = self._dimensions
|
|
150
|
+
|
|
151
|
+
body = json.dumps(payload).encode("utf-8")
|
|
152
|
+
req = urllib.request.Request(
|
|
153
|
+
self.API_URL,
|
|
154
|
+
data=body,
|
|
155
|
+
headers={
|
|
156
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
},
|
|
159
|
+
method="POST",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
164
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
165
|
+
except urllib.error.HTTPError as exc:
|
|
166
|
+
# Read minimal error info — never include response body (may
|
|
167
|
+
# echo sensitive content).
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"Voyage API error {exc.code}: {exc.reason}"
|
|
170
|
+
) from exc
|
|
171
|
+
except urllib.error.URLError as exc:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"Voyage API connection error: {exc.reason}"
|
|
174
|
+
) from exc
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"Voyage API request failed: {exc}"
|
|
178
|
+
) from exc
|
|
179
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
180
|
+
raise RuntimeError("Voyage API returned malformed JSON") from exc
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
items = data["data"]
|
|
184
|
+
if not isinstance(items, list) or len(items) != len(valid_texts):
|
|
185
|
+
raise ValueError("embedding count mismatch")
|
|
186
|
+
by_index: dict[int, list[float]] = {}
|
|
187
|
+
for item in items:
|
|
188
|
+
if not isinstance(item, dict):
|
|
189
|
+
raise ValueError("malformed embedding item")
|
|
190
|
+
index = item.get("index")
|
|
191
|
+
embedding = item.get("embedding")
|
|
192
|
+
if not isinstance(index, int) or isinstance(index, bool):
|
|
193
|
+
raise ValueError("invalid embedding index")
|
|
194
|
+
if index < 0 or index >= len(valid_texts) or index in by_index:
|
|
195
|
+
raise ValueError("duplicate or out-of-range embedding index")
|
|
196
|
+
if not isinstance(embedding, list) or len(embedding) != self._dimensions:
|
|
197
|
+
raise ValueError("embedding dimension mismatch")
|
|
198
|
+
if not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in embedding):
|
|
199
|
+
raise ValueError("malformed embedding vector")
|
|
200
|
+
by_index[index] = [float(value) for value in embedding]
|
|
201
|
+
valid_embeddings = [by_index[index] for index in range(len(valid_texts))]
|
|
202
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
203
|
+
raise RuntimeError(f"Voyage API response validation failed: {exc}") from exc
|
|
204
|
+
|
|
205
|
+
# Map back to original text order
|
|
206
|
+
result: list[list[float] | None] = [None] * len(texts)
|
|
207
|
+
for orig_idx, emb in zip(valid_indices, valid_embeddings):
|
|
208
|
+
result[orig_idx] = emb
|
|
209
|
+
# Fill in empty lists for skipped texts
|
|
210
|
+
return [r if r is not None else [] for r in result]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class FakeEmbeddingProvider(EmbeddingProvider):
|
|
214
|
+
"""Deterministic fake embedding provider for testing.
|
|
215
|
+
|
|
216
|
+
Produces embedding vectors where each dimension is a deterministic
|
|
217
|
+
function of the text content and dimension index.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, dimensions: int = 512, model: str = "fake-embedding-v1"):
|
|
221
|
+
self._dimensions = dimensions
|
|
222
|
+
self._model = model
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def model_name(self) -> str:
|
|
226
|
+
return self._model
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def dimensions(self) -> int:
|
|
230
|
+
return self._dimensions
|
|
231
|
+
|
|
232
|
+
def _derive(self, text: str, seed: int = 0) -> list[float]:
|
|
233
|
+
"""Deterministic embedding from text content."""
|
|
234
|
+
import hashlib
|
|
235
|
+
|
|
236
|
+
h = hashlib.sha256(f"{text}:{seed}".encode("utf-8")).hexdigest()
|
|
237
|
+
# Use the hash to seed a deterministic pseudo-random sequence
|
|
238
|
+
vals: list[float] = []
|
|
239
|
+
for i in range(self._dimensions):
|
|
240
|
+
# Mix hash bytes with position
|
|
241
|
+
h2 = hashlib.sha256(f"{h}:{i}:{seed}".encode("utf-8")).hexdigest()
|
|
242
|
+
# Convert first 8 hex chars to a float in [0, 1)
|
|
243
|
+
chunk = int(h2[:8], 16) / 0xFFFFFFFF
|
|
244
|
+
vals.append(chunk)
|
|
245
|
+
return vals
|
|
246
|
+
|
|
247
|
+
def embed_documents(
|
|
248
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
249
|
+
) -> list[list[float]]:
|
|
250
|
+
seed = 1 if input_type == "document" else 2
|
|
251
|
+
return [self._derive(t, seed) for t in texts]
|
|
252
|
+
|
|
253
|
+
def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
|
|
254
|
+
return self._derive(text, 2 if input_type == "query" else 1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def create_provider(
|
|
258
|
+
provider_name: str = "fake",
|
|
259
|
+
*,
|
|
260
|
+
api_key: str | None = None,
|
|
261
|
+
model: str | None = None,
|
|
262
|
+
dimensions: int = 512,
|
|
263
|
+
) -> EmbeddingProvider:
|
|
264
|
+
"""Factory: create an embedding provider by name.
|
|
265
|
+
|
|
266
|
+
Supported names: 'voyage', 'fake'.
|
|
267
|
+
"""
|
|
268
|
+
if provider_name == "voyage":
|
|
269
|
+
return VoyageEmbeddingProvider(
|
|
270
|
+
api_key=api_key, model=model, dimensions=dimensions
|
|
271
|
+
)
|
|
272
|
+
elif provider_name == "fake":
|
|
273
|
+
return FakeEmbeddingProvider(dimensions=dimensions)
|
|
274
|
+
else:
|
|
275
|
+
raise ValueError(f"Unknown provider: {provider_name}. Use 'voyage' or 'fake'.")
|