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,1114 @@
|
|
|
1
|
+
"""Tests for codegraph-voyage — document construction, sanitization, sidecar,
|
|
2
|
+
ranking, providers, and CLI integration."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import io
|
|
8
|
+
import email.message
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from unittest import mock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =========================================================================
|
|
21
|
+
# Document construction
|
|
22
|
+
# =========================================================================
|
|
23
|
+
|
|
24
|
+
class TestDocumentConstruction(unittest.TestCase):
|
|
25
|
+
"""build_document, build_documents_from_db, build_document_for_node_id."""
|
|
26
|
+
|
|
27
|
+
def setUp(self):
|
|
28
|
+
self.root = Path(tempfile.mkdtemp())
|
|
29
|
+
# Create a source file
|
|
30
|
+
self.src = self.root / "src" / "example.py"
|
|
31
|
+
self.src.parent.mkdir(parents=True)
|
|
32
|
+
self.src.write_text("""def hello():
|
|
33
|
+
\"\"\"Say hello.\"\"\"
|
|
34
|
+
return "hello"
|
|
35
|
+
|
|
36
|
+
class Greeter:
|
|
37
|
+
\"\"\"A greeter class.\"\"\"
|
|
38
|
+
def greet(self, name: str) -> str:
|
|
39
|
+
return f"Hello {name}"
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
def tearDown(self):
|
|
43
|
+
# Clean up temp dir
|
|
44
|
+
import shutil
|
|
45
|
+
shutil.rmtree(str(self.root), ignore_errors=True)
|
|
46
|
+
|
|
47
|
+
def _make_node(self, **overrides: Any) -> dict[str, Any]:
|
|
48
|
+
node = {
|
|
49
|
+
"name": "hello",
|
|
50
|
+
"qualified_name": "example.hello",
|
|
51
|
+
"kind": "function",
|
|
52
|
+
"file_path": "src/example.py",
|
|
53
|
+
"language": "python",
|
|
54
|
+
"start_line": 1,
|
|
55
|
+
"end_line": 3,
|
|
56
|
+
"docstring": "Say hello.",
|
|
57
|
+
"signature": "def hello()",
|
|
58
|
+
"visibility": "public",
|
|
59
|
+
"return_type": "str",
|
|
60
|
+
}
|
|
61
|
+
node.update(overrides)
|
|
62
|
+
return node
|
|
63
|
+
|
|
64
|
+
def test_build_document_basic(self):
|
|
65
|
+
from tools.codegraph_voyage.document import build_document
|
|
66
|
+
doc = build_document(self._make_node(), self.root)
|
|
67
|
+
self.assertIn("Symbol: hello", doc)
|
|
68
|
+
self.assertIn("Qualified Name: example.hello", doc)
|
|
69
|
+
self.assertIn("Kind: function", doc)
|
|
70
|
+
self.assertIn("Say hello.", doc)
|
|
71
|
+
self.assertIn('def hello():', doc)
|
|
72
|
+
self.assertIn('return "hello"', doc)
|
|
73
|
+
|
|
74
|
+
def test_build_document_no_source(self):
|
|
75
|
+
from tools.codegraph_voyage.document import build_document
|
|
76
|
+
doc = build_document(self._make_node(), self.root, include_source=False)
|
|
77
|
+
self.assertIn("Symbol: hello", doc)
|
|
78
|
+
self.assertNotIn('def hello():', doc)
|
|
79
|
+
|
|
80
|
+
def test_build_document_max_lines(self):
|
|
81
|
+
from tools.codegraph_voyage.document import build_document
|
|
82
|
+
doc = build_document(self._make_node(), self.root, max_source_lines=1)
|
|
83
|
+
self.assertIn("truncated at 1 lines", doc)
|
|
84
|
+
|
|
85
|
+
def test_build_document_missing_file(self):
|
|
86
|
+
from tools.codegraph_voyage.document import build_document
|
|
87
|
+
node = self._make_node(file_path="nonexistent.py")
|
|
88
|
+
doc = build_document(node, self.root)
|
|
89
|
+
self.assertIn("Symbol: hello", doc)
|
|
90
|
+
# No source lines, no error
|
|
91
|
+
self.assertNotIn("Source:", doc)
|
|
92
|
+
|
|
93
|
+
def test_source_path_cannot_escape_root(self):
|
|
94
|
+
from tools.codegraph_voyage.document import build_document
|
|
95
|
+
outside = self.root.parent / "outside-codegraph-voyage.txt"
|
|
96
|
+
outside.write_text("must-not-be-embedded")
|
|
97
|
+
try:
|
|
98
|
+
node = self._make_node(file_path="../outside-codegraph-voyage.txt")
|
|
99
|
+
doc = build_document(node, self.root)
|
|
100
|
+
self.assertNotIn("must-not-be-embedded", doc)
|
|
101
|
+
self.assertNotIn("Source:", doc)
|
|
102
|
+
finally:
|
|
103
|
+
outside.unlink(missing_ok=True)
|
|
104
|
+
|
|
105
|
+
def test_compute_content_hash(self):
|
|
106
|
+
from tools.codegraph_voyage.document import compute_content_hash
|
|
107
|
+
h1 = compute_content_hash("hello world")
|
|
108
|
+
h2 = compute_content_hash("hello world")
|
|
109
|
+
h3 = compute_content_hash("hello world!")
|
|
110
|
+
self.assertEqual(h1, h2)
|
|
111
|
+
self.assertNotEqual(h1, h3)
|
|
112
|
+
|
|
113
|
+
def test_build_documents_from_db_empty(self):
|
|
114
|
+
"""Table doesn't exist yet — should raise or handle gracefully."""
|
|
115
|
+
from tools.codegraph_voyage.document import build_documents_from_db, DocumentConstructionError
|
|
116
|
+
fake_db = self.root / "codegraph.db"
|
|
117
|
+
# Not a valid SQLite DB
|
|
118
|
+
fake_db.write_text("not a database")
|
|
119
|
+
with self.assertRaises((sqlite3.DatabaseError, DocumentConstructionError)):
|
|
120
|
+
build_documents_from_db(fake_db, self.root)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# =========================================================================
|
|
124
|
+
# Sanitization
|
|
125
|
+
# =========================================================================
|
|
126
|
+
|
|
127
|
+
class TestSanitization(unittest.TestCase):
|
|
128
|
+
"""is_sensitive_path and sanitize_content."""
|
|
129
|
+
|
|
130
|
+
def test_sensitive_paths(self):
|
|
131
|
+
from tools.codegraph_voyage.sanitize import is_sensitive_path
|
|
132
|
+
self.assertTrue(is_sensitive_path(".env"))
|
|
133
|
+
self.assertTrue(is_sensitive_path("config/.env.production"))
|
|
134
|
+
self.assertTrue(is_sensitive_path("credentials/aws.json"))
|
|
135
|
+
self.assertTrue(is_sensitive_path("secrets/keys.yaml"))
|
|
136
|
+
self.assertTrue(is_sensitive_path(".git/config"))
|
|
137
|
+
self.assertTrue(is_sensitive_path("src/__pycache__/foo.pyc"))
|
|
138
|
+
self.assertTrue(is_sensitive_path("node_modules/bar.js"))
|
|
139
|
+
self.assertTrue(is_sensitive_path(r"home\.aws\credentials"))
|
|
140
|
+
self.assertTrue(is_sensitive_path(".ssh/id_ed25519"))
|
|
141
|
+
self.assertTrue(is_sensitive_path(".docker/config.json"))
|
|
142
|
+
self.assertTrue(is_sensitive_path("config/service-account-prod.json"))
|
|
143
|
+
self.assertTrue(is_sensitive_path(".git-credentials"))
|
|
144
|
+
self.assertTrue(is_sensitive_path("deploy_id_rsa"))
|
|
145
|
+
self.assertFalse(is_sensitive_path("pyproject.toml"))
|
|
146
|
+
self.assertFalse(is_sensitive_path("src/main.py"))
|
|
147
|
+
self.assertFalse(is_sensitive_path("README.md"))
|
|
148
|
+
|
|
149
|
+
def test_sanitize_content(self):
|
|
150
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
151
|
+
content = "public code\npassword = 'supersecret'\nmore public code"
|
|
152
|
+
result = sanitize_content(content, "src/main.py")
|
|
153
|
+
self.assertIn("public code", result)
|
|
154
|
+
self.assertIn("[redacted", result)
|
|
155
|
+
self.assertIn("more public code", result)
|
|
156
|
+
self.assertNotIn("supersecret", result)
|
|
157
|
+
|
|
158
|
+
def test_sanitize_content_sensitive_path(self):
|
|
159
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
160
|
+
result = sanitize_content("anything", ".env")
|
|
161
|
+
self.assertEqual(result, "[content excluded: sensitive path]")
|
|
162
|
+
|
|
163
|
+
def test_sanitize_content_all_redacted(self):
|
|
164
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
165
|
+
# Use a value >= 8 chars to match the {8,} pattern
|
|
166
|
+
result = sanitize_content("password = 'hunter2!!'", "src/main.py")
|
|
167
|
+
# The line is replaced with a redacted marker, not removed
|
|
168
|
+
self.assertIn("[redacted", result)
|
|
169
|
+
|
|
170
|
+
def test_structured_secret_assignments_are_redacted(self):
|
|
171
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
172
|
+
secret = "live-secret-938475"
|
|
173
|
+
content = "\n".join([
|
|
174
|
+
f'{{"password": "{secret}"}}',
|
|
175
|
+
f"client_secret: {secret}",
|
|
176
|
+
f'api_key = "{secret}"',
|
|
177
|
+
f"auth_token={secret}",
|
|
178
|
+
f"aws_secret_access_key = {secret}",
|
|
179
|
+
])
|
|
180
|
+
result = sanitize_content(content, "config/settings.toml")
|
|
181
|
+
self.assertNotIn(secret, result)
|
|
182
|
+
self.assertEqual(result.count("[redacted"), 5)
|
|
183
|
+
|
|
184
|
+
def test_placeholders_are_not_redacted(self):
|
|
185
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
186
|
+
content = 'password = "placeholder"\ntoken: ${TOKEN}'
|
|
187
|
+
self.assertEqual(sanitize_content(content, "config/example.yaml"), content)
|
|
188
|
+
|
|
189
|
+
def test_private_key_block_is_redacted(self):
|
|
190
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
191
|
+
begin_marker = "-----BEGIN " + "OPENSSH PRIVATE KEY" + "-----"
|
|
192
|
+
end_marker = "-----END " + "OPENSSH PRIVATE KEY" + "-----"
|
|
193
|
+
secret = "SECRETKEYDATA"
|
|
194
|
+
content = f"{begin_marker}\n{secret}\n{end_marker}"
|
|
195
|
+
result = sanitize_content(content, "src/example.txt")
|
|
196
|
+
self.assertNotIn(secret, result)
|
|
197
|
+
|
|
198
|
+
def test_sanitized_outbound_payload_has_no_secret(self):
|
|
199
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
200
|
+
from tools.codegraph_voyage.sanitize import sanitize_content
|
|
201
|
+
secret = "voyage-must-never-see-this"
|
|
202
|
+
sanitized = sanitize_content(f'{{"api_key": "{secret}"}}', "config.json")
|
|
203
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
|
|
204
|
+
response = json.dumps({"data": [{"index": 0, "embedding": [0.1, 0.2]}]}).encode()
|
|
205
|
+
with mock.patch("urllib.request.urlopen") as mocked:
|
|
206
|
+
mocked.return_value.__enter__.return_value.read.return_value = response
|
|
207
|
+
provider.embed_documents([sanitized])
|
|
208
|
+
payload = mocked.call_args[0][0].data.decode()
|
|
209
|
+
self.assertNotIn(secret, payload)
|
|
210
|
+
self.assertIn("redacted", payload)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# =========================================================================
|
|
214
|
+
# Embedding providers
|
|
215
|
+
# =========================================================================
|
|
216
|
+
|
|
217
|
+
class TestProviders(unittest.TestCase):
|
|
218
|
+
"""FakeEmbeddingProvider determinism, VoyageEmbeddingProvider request
|
|
219
|
+
construction via mocked urllib."""
|
|
220
|
+
|
|
221
|
+
def test_fake_determinism(self):
|
|
222
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
223
|
+
p = FakeEmbeddingProvider(dimensions=8)
|
|
224
|
+
texts = ["hello", "world"]
|
|
225
|
+
emb1 = p.embed_documents(texts)
|
|
226
|
+
emb2 = p.embed_documents(texts)
|
|
227
|
+
self.assertEqual(emb1, emb2)
|
|
228
|
+
self.assertEqual(len(emb1), 2)
|
|
229
|
+
self.assertEqual(len(emb1[0]), 8)
|
|
230
|
+
self.assertEqual(len(emb1[1]), 8)
|
|
231
|
+
# Different texts → different embeddings
|
|
232
|
+
emb3 = p.embed_documents(["hello", "goodbye"])
|
|
233
|
+
self.assertEqual(emb1[0], emb3[0]) # same text, same seed → same
|
|
234
|
+
|
|
235
|
+
def test_fake_query_vs_document(self):
|
|
236
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
237
|
+
p = FakeEmbeddingProvider(dimensions=4)
|
|
238
|
+
q = p.embed_query("test")
|
|
239
|
+
d = p.embed_documents(["test"])[0]
|
|
240
|
+
# Different input_type → different seed → different embedding
|
|
241
|
+
self.assertNotEqual(q, d)
|
|
242
|
+
|
|
243
|
+
def test_fake_properties(self):
|
|
244
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
245
|
+
p = FakeEmbeddingProvider(dimensions=256)
|
|
246
|
+
self.assertEqual(p.model_name, "fake-embedding-v1")
|
|
247
|
+
self.assertEqual(p.dimensions, 256)
|
|
248
|
+
|
|
249
|
+
def test_raw_bytes_roundtrip(self):
|
|
250
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
251
|
+
p = FakeEmbeddingProvider(dimensions=8)
|
|
252
|
+
vec = p._derive("test")
|
|
253
|
+
blob = p.raw_bytes(vec)
|
|
254
|
+
restored = p.from_bytes(blob)
|
|
255
|
+
self.assertAlmostEqual(vec[0], restored[0], places=6)
|
|
256
|
+
self.assertEqual(len(vec), len(restored))
|
|
257
|
+
|
|
258
|
+
def test_voyage_requires_key(self):
|
|
259
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
260
|
+
with mock.patch.dict("os.environ", {"VOYAGE_API_KEY": ""}):
|
|
261
|
+
with self.assertRaises(ValueError):
|
|
262
|
+
VoyageEmbeddingProvider(api_key="")
|
|
263
|
+
|
|
264
|
+
def test_voyage_mocked_request(self):
|
|
265
|
+
"""Mock urllib.request to verify input_type payload."""
|
|
266
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
267
|
+
|
|
268
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", model="voyage-code-4", dimensions=512)
|
|
269
|
+
|
|
270
|
+
fake_response = json.dumps({
|
|
271
|
+
"data": [
|
|
272
|
+
{"index": 0, "embedding": [0.1] * 512},
|
|
273
|
+
{"index": 1, "embedding": [0.2] * 512},
|
|
274
|
+
]
|
|
275
|
+
}).encode("utf-8")
|
|
276
|
+
|
|
277
|
+
with mock.patch("urllib.request.urlopen") as mock_urlopen:
|
|
278
|
+
mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
|
|
279
|
+
|
|
280
|
+
result = provider.embed_documents(["hello", "world"], input_type="document")
|
|
281
|
+
|
|
282
|
+
# Verify payload was sent correctly
|
|
283
|
+
call_args = mock_urlopen.call_args
|
|
284
|
+
req: urllib.request.Request = call_args[0][0]
|
|
285
|
+
self.assertIsInstance(req, urllib.request.Request)
|
|
286
|
+
payload = json.loads(req.data)
|
|
287
|
+
self.assertEqual(payload["input_type"], "document")
|
|
288
|
+
self.assertEqual(payload["model"], "voyage-code-4")
|
|
289
|
+
self.assertEqual(payload["input"], ["hello", "world"])
|
|
290
|
+
self.assertEqual(payload["output_dimension"], 512)
|
|
291
|
+
self.assertEqual(req.headers["Authorization"], "Bearer test-key")
|
|
292
|
+
|
|
293
|
+
# Verify result
|
|
294
|
+
self.assertEqual(len(result), 2)
|
|
295
|
+
self.assertEqual(len(result[0]), 512)
|
|
296
|
+
self.assertEqual(result[0][0], 0.1)
|
|
297
|
+
|
|
298
|
+
def test_voyage_query_uses_query_input_type(self):
|
|
299
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
300
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
|
|
301
|
+
fake_response = json.dumps({
|
|
302
|
+
"data": [{"index": 0, "embedding": [0.4, 0.6]}]
|
|
303
|
+
}).encode("utf-8")
|
|
304
|
+
with mock.patch("urllib.request.urlopen") as mock_urlopen:
|
|
305
|
+
mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
|
|
306
|
+
result = provider.embed_query("find auth", input_type="query")
|
|
307
|
+
payload = json.loads(mock_urlopen.call_args[0][0].data)
|
|
308
|
+
self.assertEqual(payload["input_type"], "query")
|
|
309
|
+
self.assertEqual(payload["input"], ["find auth"])
|
|
310
|
+
self.assertEqual(result, [0.4, 0.6])
|
|
311
|
+
|
|
312
|
+
def test_voyage_empty_texts(self):
|
|
313
|
+
"""Empty texts should return empty lists without API call."""
|
|
314
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
315
|
+
provider = VoyageEmbeddingProvider(api_key="test-key")
|
|
316
|
+
with mock.patch("urllib.request.urlopen") as mock_urlopen:
|
|
317
|
+
result = provider.embed_documents(["", " "])
|
|
318
|
+
mock_urlopen.assert_not_called()
|
|
319
|
+
# Should return empty lists for each
|
|
320
|
+
self.assertEqual(len(result), 2)
|
|
321
|
+
|
|
322
|
+
def test_voyage_mixed_empty_and_valid(self):
|
|
323
|
+
"""Mix of empty and valid texts — only valid ones sent to API."""
|
|
324
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
325
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=4)
|
|
326
|
+
|
|
327
|
+
fake_response = json.dumps({
|
|
328
|
+
"data": [
|
|
329
|
+
{"index": 0, "embedding": [0.1, 0.2, 0.3, 0.4]},
|
|
330
|
+
{"index": 1, "embedding": [0.5, 0.6, 0.7, 0.8]},
|
|
331
|
+
]
|
|
332
|
+
}).encode("utf-8")
|
|
333
|
+
|
|
334
|
+
with mock.patch("urllib.request.urlopen") as mock_urlopen:
|
|
335
|
+
mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
|
|
336
|
+
|
|
337
|
+
texts = ["hello", "", "world"]
|
|
338
|
+
result = provider.embed_documents(texts, input_type="document")
|
|
339
|
+
|
|
340
|
+
# Verify only non-empty texts sent
|
|
341
|
+
payload = json.loads(mock_urlopen.call_args[0][0].data)
|
|
342
|
+
self.assertEqual(payload["input"], ["hello", "world"])
|
|
343
|
+
|
|
344
|
+
# Verify mapping back
|
|
345
|
+
self.assertEqual(len(result), 3)
|
|
346
|
+
self.assertEqual(len(result[0]), 4) # "hello" embedding
|
|
347
|
+
self.assertEqual(result[1], []) # empty text → empty list
|
|
348
|
+
self.assertEqual(len(result[2]), 4) # "world" embedding
|
|
349
|
+
|
|
350
|
+
def test_voyage_http_error_no_body_in_message(self):
|
|
351
|
+
"""HTTP error should not include response body in error message."""
|
|
352
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
353
|
+
provider = VoyageEmbeddingProvider(api_key="test-key")
|
|
354
|
+
|
|
355
|
+
with mock.patch("urllib.request.urlopen") as mock_urlopen:
|
|
356
|
+
mock_urlopen.side_effect = urllib.error.HTTPError(
|
|
357
|
+
url="https://api.voyageai.com/v1/embeddings",
|
|
358
|
+
code=401,
|
|
359
|
+
msg="Unauthorized",
|
|
360
|
+
hdrs={},
|
|
361
|
+
fp=None,
|
|
362
|
+
)
|
|
363
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
364
|
+
provider.embed_documents(["test"], input_type="document")
|
|
365
|
+
self.assertNotIn("sensitive", str(ctx.exception).lower())
|
|
366
|
+
self.assertIn("401", str(ctx.exception))
|
|
367
|
+
|
|
368
|
+
def test_voyage_batches_more_than_128_in_order(self):
|
|
369
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
370
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
|
|
371
|
+
|
|
372
|
+
def response_for(request, timeout=None):
|
|
373
|
+
inputs = json.loads(request.data)["input"]
|
|
374
|
+
response = mock.MagicMock()
|
|
375
|
+
response.__enter__.return_value.read.return_value = json.dumps({
|
|
376
|
+
"data": [
|
|
377
|
+
{"index": i, "embedding": [float(text[1:]), 1.0]}
|
|
378
|
+
for i, text in enumerate(inputs)
|
|
379
|
+
]
|
|
380
|
+
}).encode()
|
|
381
|
+
return response
|
|
382
|
+
|
|
383
|
+
with mock.patch("urllib.request.urlopen", side_effect=response_for) as mocked:
|
|
384
|
+
result = provider.embed_documents([f"t{i}" for i in range(257)])
|
|
385
|
+
self.assertEqual(mocked.call_count, 3)
|
|
386
|
+
self.assertEqual(
|
|
387
|
+
[len(json.loads(call.args[0].data)["input"]) for call in mocked.call_args_list],
|
|
388
|
+
[128, 128, 1],
|
|
389
|
+
)
|
|
390
|
+
self.assertEqual([row[0] for row in result], list(map(float, range(257))))
|
|
391
|
+
|
|
392
|
+
def test_voyage_configurable_batch_size_and_partial_failure(self):
|
|
393
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
394
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=1, batch_size=2)
|
|
395
|
+
good = mock.MagicMock()
|
|
396
|
+
good.__enter__.return_value.read.return_value = json.dumps({
|
|
397
|
+
"data": [{"index": 0, "embedding": [1.0]}, {"index": 1, "embedding": [2.0]}]
|
|
398
|
+
}).encode()
|
|
399
|
+
with mock.patch(
|
|
400
|
+
"urllib.request.urlopen",
|
|
401
|
+
side_effect=[good, urllib.error.URLError("offline")],
|
|
402
|
+
):
|
|
403
|
+
with self.assertRaisesRegex(RuntimeError, "batch 2"):
|
|
404
|
+
provider.embed_documents(["a", "b", "c"])
|
|
405
|
+
|
|
406
|
+
def test_voyage_response_validation(self):
|
|
407
|
+
from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
|
|
408
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
|
|
409
|
+
invalid = [
|
|
410
|
+
{"data": [{"index": 0, "embedding": [0.1, 0.2]}]},
|
|
411
|
+
{"data": [{"index": 0, "embedding": [0.1, 0.2]}, {"index": 0, "embedding": [0.3, 0.4]}]},
|
|
412
|
+
{"data": [{"index": 0, "embedding": [0.1]}, {"index": 1, "embedding": [0.3, 0.4]}]},
|
|
413
|
+
{"unexpected": []},
|
|
414
|
+
]
|
|
415
|
+
for payload in invalid:
|
|
416
|
+
with self.subTest(payload=payload), mock.patch("urllib.request.urlopen") as mocked:
|
|
417
|
+
mocked.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
|
418
|
+
with self.assertRaisesRegex(RuntimeError, "response validation"):
|
|
419
|
+
provider.embed_documents(["a", "b"])
|
|
420
|
+
|
|
421
|
+
def test_create_provider(self):
|
|
422
|
+
from tools.codegraph_voyage.providers import create_provider, FakeEmbeddingProvider, VoyageEmbeddingProvider
|
|
423
|
+
p1 = create_provider("fake")
|
|
424
|
+
self.assertIsInstance(p1, FakeEmbeddingProvider)
|
|
425
|
+
with self.assertRaises(ValueError):
|
|
426
|
+
create_provider("unknown")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# =========================================================================
|
|
430
|
+
# Sidecar DB
|
|
431
|
+
# =========================================================================
|
|
432
|
+
|
|
433
|
+
class TestSidecarDB(unittest.TestCase):
|
|
434
|
+
"""SidecarDB create, store, retrieve, stale removal, incremental indexing."""
|
|
435
|
+
|
|
436
|
+
def setUp(self):
|
|
437
|
+
self.tmpdir = Path(tempfile.mkdtemp())
|
|
438
|
+
self.db_path = self.tmpdir / "sidecar.db"
|
|
439
|
+
|
|
440
|
+
def tearDown(self):
|
|
441
|
+
import shutil
|
|
442
|
+
shutil.rmtree(str(self.tmpdir), ignore_errors=True)
|
|
443
|
+
|
|
444
|
+
def _make_provider(self, name="fake-embedding-v1", dims=512):
|
|
445
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
446
|
+
return FakeEmbeddingProvider(dimensions=dims, model=name)
|
|
447
|
+
|
|
448
|
+
def test_open_and_close(self):
|
|
449
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
450
|
+
db = SidecarDB(self.db_path)
|
|
451
|
+
db.open()
|
|
452
|
+
self.assertIsNotNone(db.conn)
|
|
453
|
+
status = db.get_status()
|
|
454
|
+
self.assertTrue(status["connected"])
|
|
455
|
+
self.assertEqual(status["total_embeddings"], 0)
|
|
456
|
+
pk_columns = [
|
|
457
|
+
row[1]
|
|
458
|
+
for row in sorted(
|
|
459
|
+
(row for row in db.conn.execute("PRAGMA table_info(embeddings)") if row[5]),
|
|
460
|
+
key=lambda row: row[5],
|
|
461
|
+
)
|
|
462
|
+
]
|
|
463
|
+
self.assertEqual(
|
|
464
|
+
pk_columns,
|
|
465
|
+
["node_id", "source_content_hash", "model", "dimensions", "dtype"],
|
|
466
|
+
)
|
|
467
|
+
db.close()
|
|
468
|
+
with self.assertRaises(Exception):
|
|
469
|
+
db.conn
|
|
470
|
+
|
|
471
|
+
def test_store_and_retrieve(self):
|
|
472
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
473
|
+
provider = self._make_provider(dims=3)
|
|
474
|
+
db = SidecarDB(self.db_path)
|
|
475
|
+
db.open()
|
|
476
|
+
try:
|
|
477
|
+
records = [{
|
|
478
|
+
"node_id": "n1",
|
|
479
|
+
"content_hash": "abc",
|
|
480
|
+
"embedding": [0.1, 0.2, 0.3],
|
|
481
|
+
"node_kind": "function",
|
|
482
|
+
"name": "hello",
|
|
483
|
+
"qualified_name": "mod.hello",
|
|
484
|
+
"file_path": "src/main.py",
|
|
485
|
+
"language": "python",
|
|
486
|
+
"start_line": 1,
|
|
487
|
+
"end_line": 5,
|
|
488
|
+
"document_text": "def hello(): pass",
|
|
489
|
+
}]
|
|
490
|
+
stored = db.store_embeddings(records, provider)
|
|
491
|
+
self.assertEqual(stored, 1)
|
|
492
|
+
|
|
493
|
+
# Retrieve
|
|
494
|
+
emb = db.get_embedding("n1", provider)
|
|
495
|
+
self.assertIsNotNone(emb)
|
|
496
|
+
self.assertEqual(len(emb), 3)
|
|
497
|
+
self.assertAlmostEqual(emb[0], 0.1)
|
|
498
|
+
|
|
499
|
+
# All embeddings
|
|
500
|
+
all_emb = db.get_all_embeddings(provider)
|
|
501
|
+
self.assertEqual(len(all_emb), 1)
|
|
502
|
+
self.assertEqual(all_emb[0]["node_id"], "n1")
|
|
503
|
+
finally:
|
|
504
|
+
db.close()
|
|
505
|
+
|
|
506
|
+
def test_replace_existing(self):
|
|
507
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
508
|
+
provider = self._make_provider(dims=3)
|
|
509
|
+
db = SidecarDB(self.db_path)
|
|
510
|
+
db.open()
|
|
511
|
+
try:
|
|
512
|
+
db.store_embeddings([{
|
|
513
|
+
"node_id": "n1",
|
|
514
|
+
"content_hash": "abc",
|
|
515
|
+
"embedding": [0.1, 0.2, 0.3],
|
|
516
|
+
"node_kind": "function",
|
|
517
|
+
"name": "hello",
|
|
518
|
+
"qualified_name": "mod.hello",
|
|
519
|
+
"file_path": "src/main.py",
|
|
520
|
+
"language": "python",
|
|
521
|
+
"start_line": 1,
|
|
522
|
+
"end_line": 5,
|
|
523
|
+
"document_text": "def hello(): pass",
|
|
524
|
+
}], provider)
|
|
525
|
+
db.store_embeddings([{
|
|
526
|
+
"node_id": "n1",
|
|
527
|
+
"content_hash": "def",
|
|
528
|
+
"embedding": [0.9, 0.8, 0.7],
|
|
529
|
+
"node_kind": "function",
|
|
530
|
+
"name": "hello",
|
|
531
|
+
"qualified_name": "mod.hello",
|
|
532
|
+
"file_path": "src/main.py",
|
|
533
|
+
"language": "python",
|
|
534
|
+
"start_line": 1,
|
|
535
|
+
"end_line": 5,
|
|
536
|
+
"document_text": "def hello(): pass",
|
|
537
|
+
}], provider)
|
|
538
|
+
emb = db.get_embedding("n1", provider)
|
|
539
|
+
self.assertAlmostEqual(emb[0], 0.9)
|
|
540
|
+
self.assertEqual(db.get_status()["total_embeddings"], 1)
|
|
541
|
+
finally:
|
|
542
|
+
db.close()
|
|
543
|
+
|
|
544
|
+
def test_find_changed_nodes(self):
|
|
545
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
546
|
+
provider = self._make_provider(dims=2)
|
|
547
|
+
db = SidecarDB(self.db_path)
|
|
548
|
+
db.open()
|
|
549
|
+
try:
|
|
550
|
+
# Store one
|
|
551
|
+
db.store_embeddings([{
|
|
552
|
+
"node_id": "n1",
|
|
553
|
+
"content_hash": "abc",
|
|
554
|
+
"embedding": [0.1, 0.2],
|
|
555
|
+
"node_kind": "function",
|
|
556
|
+
"name": "hello",
|
|
557
|
+
"qualified_name": "",
|
|
558
|
+
"file_path": "src/main.py",
|
|
559
|
+
"language": "python",
|
|
560
|
+
"start_line": 1,
|
|
561
|
+
"end_line": 5,
|
|
562
|
+
"document_text": "def hello(): pass",
|
|
563
|
+
}], provider)
|
|
564
|
+
|
|
565
|
+
# Same hash → unchanged
|
|
566
|
+
docs = [{"node_id": "n1", "content_hash": "abc"}]
|
|
567
|
+
changed = db.find_changed_nodes(docs, provider)
|
|
568
|
+
self.assertEqual(len(changed), 0)
|
|
569
|
+
|
|
570
|
+
# Different hash → changed
|
|
571
|
+
docs2 = [{"node_id": "n1", "content_hash": "xyz"}]
|
|
572
|
+
changed2 = db.find_changed_nodes(docs2, provider)
|
|
573
|
+
self.assertEqual(len(changed2), 1)
|
|
574
|
+
|
|
575
|
+
# New node → changed
|
|
576
|
+
docs3 = [{"node_id": "n2", "content_hash": "new"}]
|
|
577
|
+
changed3 = db.find_changed_nodes(docs3, provider)
|
|
578
|
+
self.assertEqual(len(changed3), 1)
|
|
579
|
+
finally:
|
|
580
|
+
db.close()
|
|
581
|
+
|
|
582
|
+
def test_remove_stale_records(self):
|
|
583
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
584
|
+
provider = self._make_provider(dims=1)
|
|
585
|
+
db = SidecarDB(self.db_path)
|
|
586
|
+
db.open()
|
|
587
|
+
try:
|
|
588
|
+
db.store_embeddings([
|
|
589
|
+
{
|
|
590
|
+
"node_id": "n1",
|
|
591
|
+
"content_hash": "a",
|
|
592
|
+
"embedding": [0.1],
|
|
593
|
+
"node_kind": "f",
|
|
594
|
+
"name": "a",
|
|
595
|
+
"qualified_name": "a",
|
|
596
|
+
"file_path": "a.py",
|
|
597
|
+
"language": "py",
|
|
598
|
+
"start_line": 1,
|
|
599
|
+
"end_line": 2,
|
|
600
|
+
"document_text": "a",
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
"node_id": "n2",
|
|
604
|
+
"content_hash": "b",
|
|
605
|
+
"embedding": [0.2],
|
|
606
|
+
"node_kind": "f",
|
|
607
|
+
"name": "b",
|
|
608
|
+
"qualified_name": "b",
|
|
609
|
+
"file_path": "b.py",
|
|
610
|
+
"language": "py",
|
|
611
|
+
"start_line": 1,
|
|
612
|
+
"end_line": 2,
|
|
613
|
+
"document_text": "b",
|
|
614
|
+
},
|
|
615
|
+
], provider)
|
|
616
|
+
|
|
617
|
+
# Remove n2 (stale)
|
|
618
|
+
removed = db.remove_stale_records({"n1"}, provider)
|
|
619
|
+
self.assertEqual(removed, 1)
|
|
620
|
+
|
|
621
|
+
# n1 should still exist
|
|
622
|
+
self.assertIsNotNone(db.get_embedding("n1", provider))
|
|
623
|
+
self.assertIsNone(db.get_embedding("n2", provider))
|
|
624
|
+
finally:
|
|
625
|
+
db.close()
|
|
626
|
+
|
|
627
|
+
def test_empty_current_set_removes_all_for_provider(self):
|
|
628
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
629
|
+
provider = self._make_provider(dims=1)
|
|
630
|
+
db = SidecarDB(self.db_path)
|
|
631
|
+
db.open()
|
|
632
|
+
try:
|
|
633
|
+
db.store_embeddings([{
|
|
634
|
+
"node_id": "stale", "content_hash": "x", "embedding": [0.1],
|
|
635
|
+
"node_kind": "f", "name": "stale", "qualified_name": "stale",
|
|
636
|
+
"file_path": "stale.py", "language": "python",
|
|
637
|
+
"start_line": 1, "end_line": 1, "document_text": "stale",
|
|
638
|
+
}], provider)
|
|
639
|
+
self.assertEqual(db.remove_stale_records(set(), provider), 1)
|
|
640
|
+
self.assertIsNone(db.get_embedding("stale", provider))
|
|
641
|
+
finally:
|
|
642
|
+
db.close()
|
|
643
|
+
|
|
644
|
+
def test_dimension_mismatch_is_atomic(self):
|
|
645
|
+
from tools.codegraph_voyage.sidecar import SidecarDB, SidecarError
|
|
646
|
+
provider = self._make_provider(dims=2)
|
|
647
|
+
db = SidecarDB(self.db_path)
|
|
648
|
+
db.open()
|
|
649
|
+
try:
|
|
650
|
+
records = [
|
|
651
|
+
{"node_id": "ok", "content_hash": "a", "embedding": [0.1, 0.2]},
|
|
652
|
+
{"node_id": "bad", "content_hash": "b", "embedding": [0.3]},
|
|
653
|
+
]
|
|
654
|
+
with self.assertRaises(SidecarError):
|
|
655
|
+
db.store_embeddings(records, provider)
|
|
656
|
+
self.assertEqual(db.get_status()["total_embeddings"], 0)
|
|
657
|
+
finally:
|
|
658
|
+
db.close()
|
|
659
|
+
|
|
660
|
+
def test_model_incompatibility(self):
|
|
661
|
+
"""Different model/dimensions are segregated."""
|
|
662
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
663
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
664
|
+
provider_a = FakeEmbeddingProvider(dimensions=4, model="fake-model-a")
|
|
665
|
+
provider_b = FakeEmbeddingProvider(dimensions=4, model="fake-model-b")
|
|
666
|
+
db = SidecarDB(self.db_path)
|
|
667
|
+
db.open()
|
|
668
|
+
try:
|
|
669
|
+
db.store_embeddings([{
|
|
670
|
+
"node_id": "n1",
|
|
671
|
+
"content_hash": "a",
|
|
672
|
+
"embedding": [0.1, 0.2, 0.3, 0.4],
|
|
673
|
+
"node_kind": "f",
|
|
674
|
+
"name": "a",
|
|
675
|
+
"qualified_name": "a",
|
|
676
|
+
"file_path": "a.py",
|
|
677
|
+
"language": "py",
|
|
678
|
+
"start_line": 1,
|
|
679
|
+
"end_line": 2,
|
|
680
|
+
"document_text": "a",
|
|
681
|
+
}], provider_a)
|
|
682
|
+
|
|
683
|
+
# Provider B should not see n1 (different model, same dimensions)
|
|
684
|
+
self.assertIsNone(db.get_embedding("n1", provider_b))
|
|
685
|
+
|
|
686
|
+
# Provider A should see it
|
|
687
|
+
self.assertIsNotNone(db.get_embedding("n1", provider_a))
|
|
688
|
+
finally:
|
|
689
|
+
db.close()
|
|
690
|
+
|
|
691
|
+
def test_clear(self):
|
|
692
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
693
|
+
provider = self._make_provider(dims=1)
|
|
694
|
+
db = SidecarDB(self.db_path)
|
|
695
|
+
db.open()
|
|
696
|
+
try:
|
|
697
|
+
db.store_embeddings([{
|
|
698
|
+
"node_id": "n1",
|
|
699
|
+
"content_hash": "a",
|
|
700
|
+
"embedding": [0.1],
|
|
701
|
+
"node_kind": "f",
|
|
702
|
+
"name": "a",
|
|
703
|
+
"qualified_name": "",
|
|
704
|
+
"file_path": "a.py",
|
|
705
|
+
"language": "py",
|
|
706
|
+
"start_line": 1,
|
|
707
|
+
"end_line": 2,
|
|
708
|
+
"document_text": "a",
|
|
709
|
+
}], provider)
|
|
710
|
+
cleared = db.clear(provider)
|
|
711
|
+
self.assertEqual(cleared, 1)
|
|
712
|
+
self.assertEqual(db.get_status()["total_embeddings"], 0)
|
|
713
|
+
finally:
|
|
714
|
+
db.close()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# =========================================================================
|
|
718
|
+
# Ranking
|
|
719
|
+
# =========================================================================
|
|
720
|
+
|
|
721
|
+
class TestRanking(unittest.TestCase):
|
|
722
|
+
"""Pinned candidates, lexical ranking, vector ranking, RRF fusion, hybrid search."""
|
|
723
|
+
|
|
724
|
+
def _make_doc(self, node_id: str, name: str = "", qname: str = "",
|
|
725
|
+
fpath: str = "", kind: str = "function", doc_text: str = "",
|
|
726
|
+
embedding: list[float] | None = None) -> dict[str, Any]:
|
|
727
|
+
return {
|
|
728
|
+
"node_id": node_id,
|
|
729
|
+
"name": name,
|
|
730
|
+
"qualified_name": qname,
|
|
731
|
+
"file_path": fpath,
|
|
732
|
+
"node_kind": kind,
|
|
733
|
+
"language": "python",
|
|
734
|
+
"start_line": 1,
|
|
735
|
+
"end_line": 5,
|
|
736
|
+
"document_text": doc_text or f"def {name}: pass",
|
|
737
|
+
"embedding": embedding or [0.0] * 4,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
def test_pinned_exact_name(self):
|
|
741
|
+
from tools.codegraph_voyage.ranking import find_pinned_candidates
|
|
742
|
+
candidates = [
|
|
743
|
+
self._make_doc("n1", name="AuthService", qname="app.AuthService"),
|
|
744
|
+
self._make_doc("n2", name="UserModel", qname="app.UserModel"),
|
|
745
|
+
]
|
|
746
|
+
pinned = find_pinned_candidates("AuthService", candidates)
|
|
747
|
+
self.assertEqual(len(pinned), 1)
|
|
748
|
+
self.assertEqual(pinned[0].node_id, "n1")
|
|
749
|
+
self.assertTrue(pinned[0].is_pinned)
|
|
750
|
+
self.assertIn("exact_name", pinned[0].provenance)
|
|
751
|
+
|
|
752
|
+
# The same node may arrive from both vector and lexical candidate lists.
|
|
753
|
+
deduped = find_pinned_candidates("AuthService", candidates + candidates)
|
|
754
|
+
self.assertEqual([p.node_id for p in deduped], ["n1"])
|
|
755
|
+
|
|
756
|
+
def test_pinned_path_match(self):
|
|
757
|
+
from tools.codegraph_voyage.ranking import find_pinned_candidates
|
|
758
|
+
candidates = [
|
|
759
|
+
self._make_doc("n1", name="f1", fpath="src/auth/login.py"),
|
|
760
|
+
self._make_doc("n2", name="f2", fpath="src/utils/helper.py"),
|
|
761
|
+
]
|
|
762
|
+
self.assertEqual(find_pinned_candidates("auth", candidates), [])
|
|
763
|
+
pinned = find_pinned_candidates("login.py", candidates)
|
|
764
|
+
self.assertEqual([p.node_id for p in pinned], ["n1"])
|
|
765
|
+
self.assertIn("exact_basename", pinned[0].provenance)
|
|
766
|
+
exact_path = find_pinned_candidates("SRC/AUTH/LOGIN.PY", candidates)
|
|
767
|
+
self.assertEqual([p.node_id for p in exact_path], ["n1"])
|
|
768
|
+
self.assertIn("exact_path", exact_path[0].provenance)
|
|
769
|
+
|
|
770
|
+
def test_pinned_partial_qname(self):
|
|
771
|
+
from tools.codegraph_voyage.ranking import find_pinned_candidates
|
|
772
|
+
candidates = [
|
|
773
|
+
self._make_doc("n1", qname="app.services.auth.AuthService"),
|
|
774
|
+
self._make_doc("n2", qname="app.models.User"),
|
|
775
|
+
]
|
|
776
|
+
self.assertEqual(find_pinned_candidates("auth service", candidates), [])
|
|
777
|
+
|
|
778
|
+
def test_pinning_rejects_empty_and_substring_queries(self):
|
|
779
|
+
from tools.codegraph_voyage.ranking import find_pinned_candidates
|
|
780
|
+
candidates = [self._make_doc("n1", name="Alpha", fpath="src/data.py")]
|
|
781
|
+
self.assertEqual(find_pinned_candidates("", candidates), [])
|
|
782
|
+
self.assertEqual(find_pinned_candidates(" ", candidates), [])
|
|
783
|
+
self.assertEqual(find_pinned_candidates("a", candidates), [])
|
|
784
|
+
|
|
785
|
+
def test_cosine_similarity(self):
|
|
786
|
+
from tools.codegraph_voyage.ranking import cosine_similarity
|
|
787
|
+
self.assertAlmostEqual(cosine_similarity([1, 0], [1, 0]), 1.0)
|
|
788
|
+
self.assertAlmostEqual(cosine_similarity([1, 0], [0, 1]), 0.0)
|
|
789
|
+
self.assertAlmostEqual(cosine_similarity([1, 1], [1, 1]), 1.0)
|
|
790
|
+
# Zero vectors
|
|
791
|
+
self.assertEqual(cosine_similarity([0, 0], [0, 0]), 0.0)
|
|
792
|
+
|
|
793
|
+
def test_rank_by_vector_similarity(self):
|
|
794
|
+
from tools.codegraph_voyage.ranking import rank_by_vector_similarity
|
|
795
|
+
qv = [1.0, 0.0, 0.0, 0.0]
|
|
796
|
+
candidates = [
|
|
797
|
+
self._make_doc("n1", embedding=[0.9, 0.1, 0.0, 0.0]),
|
|
798
|
+
self._make_doc("n2", embedding=[0.0, 0.9, 0.0, 0.0]),
|
|
799
|
+
]
|
|
800
|
+
results = rank_by_vector_similarity(qv, candidates, top_k=5)
|
|
801
|
+
self.assertEqual(len(results), 2)
|
|
802
|
+
# n1 is more similar (closer to [1,0,0,0])
|
|
803
|
+
self.assertEqual(results[0].node_id, "n1")
|
|
804
|
+
self.assertGreater(results[0].vector_score, results[1].vector_score)
|
|
805
|
+
|
|
806
|
+
def test_rank_by_lexical_similarity(self):
|
|
807
|
+
from tools.codegraph_voyage.ranking import rank_by_lexical_similarity
|
|
808
|
+
candidates = [
|
|
809
|
+
self._make_doc("n1", name="a", doc_text="auth service login handler"),
|
|
810
|
+
self._make_doc("n2", name="b", doc_text="user model data access"),
|
|
811
|
+
]
|
|
812
|
+
results = rank_by_lexical_similarity("auth login", candidates, top_k=5)
|
|
813
|
+
self.assertEqual(len(results), 2)
|
|
814
|
+
self.assertEqual(results[0].node_id, "n1")
|
|
815
|
+
|
|
816
|
+
def test_reciprocal_rank_fusion(self):
|
|
817
|
+
from tools.codegraph_voyage.ranking import (
|
|
818
|
+
reciprocal_rank_fusion, RankingResult,
|
|
819
|
+
)
|
|
820
|
+
list_a = [
|
|
821
|
+
RankingResult(node_id="n1", score=1.0, provenance="vector"),
|
|
822
|
+
RankingResult(node_id="n2", score=0.8, provenance="vector"),
|
|
823
|
+
]
|
|
824
|
+
list_b = [
|
|
825
|
+
RankingResult(node_id="n2", score=1.0, provenance="lexical"),
|
|
826
|
+
RankingResult(node_id="n3", score=0.9, provenance="lexical"),
|
|
827
|
+
]
|
|
828
|
+
fused = reciprocal_rank_fusion([list_a, list_b], weights=[0.5, 0.5], k=60)
|
|
829
|
+
self.assertEqual(len(fused), 3)
|
|
830
|
+
# n2 appears in both lists → highest fused score
|
|
831
|
+
self.assertEqual(fused[0].node_id, "n2")
|
|
832
|
+
self.assertIn("vector", fused[0].provenance)
|
|
833
|
+
self.assertIn("lexical", fused[0].provenance)
|
|
834
|
+
|
|
835
|
+
def test_hybrid_search_pinned(self):
|
|
836
|
+
from tools.codegraph_voyage.ranking import hybrid_search
|
|
837
|
+
qv = [1.0, 0.0, 0.0, 0.0]
|
|
838
|
+
vector_candidates = [
|
|
839
|
+
self._make_doc("n1", name="AuthService", embedding=[0.9, 0.0, 0.0, 0.0]),
|
|
840
|
+
self._make_doc("n2", name="Helper", embedding=[0.0, 0.9, 0.0, 0.0]),
|
|
841
|
+
]
|
|
842
|
+
lexical_candidates = list(vector_candidates)
|
|
843
|
+
results = hybrid_search("AuthService", qv, vector_candidates, lexical_candidates, top_k=5)
|
|
844
|
+
self.assertEqual(len(results), 2)
|
|
845
|
+
# AuthService should be pinned to top
|
|
846
|
+
self.assertEqual(results[0].node_id, "n1")
|
|
847
|
+
self.assertTrue(results[0].is_pinned)
|
|
848
|
+
|
|
849
|
+
def test_merge_pinned_into_results(self):
|
|
850
|
+
from tools.codegraph_voyage.ranking import merge_pinned_into_results, RankingResult
|
|
851
|
+
pinned = [
|
|
852
|
+
RankingResult(node_id="n1", score=10.0, is_pinned=True, exact_score=10.0),
|
|
853
|
+
RankingResult(node_id="n2", score=5.0, is_pinned=True, exact_score=5.0),
|
|
854
|
+
]
|
|
855
|
+
fused = [
|
|
856
|
+
RankingResult(node_id="n3", score=0.5),
|
|
857
|
+
RankingResult(node_id="n1", score=0.3, lexical_score=0.7,
|
|
858
|
+
vector_score=0.8, provenance="lexical+vector"),
|
|
859
|
+
]
|
|
860
|
+
merged = merge_pinned_into_results(pinned, fused)
|
|
861
|
+
self.assertEqual(len(merged), 3)
|
|
862
|
+
self.assertEqual(merged[0].node_id, "n1")
|
|
863
|
+
self.assertEqual(merged[1].node_id, "n2")
|
|
864
|
+
self.assertEqual(merged[2].node_id, "n3")
|
|
865
|
+
self.assertEqual(merged[0].lexical_score, 0.7)
|
|
866
|
+
self.assertEqual(merged[0].vector_score, 0.8)
|
|
867
|
+
self.assertIn("lexical", merged[0].provenance)
|
|
868
|
+
self.assertIn("vector", merged[0].provenance)
|
|
869
|
+
|
|
870
|
+
def test_ranking_result_to_dict(self):
|
|
871
|
+
from tools.codegraph_voyage.ranking import RankingResult
|
|
872
|
+
r = RankingResult(
|
|
873
|
+
node_id="n1", score=0.5, is_pinned=True,
|
|
874
|
+
lexical_score=0.3, vector_score=0.2,
|
|
875
|
+
name="hello", provenance="lexical+vector",
|
|
876
|
+
)
|
|
877
|
+
d = r.to_dict()
|
|
878
|
+
self.assertEqual(d["node_id"], "n1")
|
|
879
|
+
self.assertEqual(d["score"], 0.5)
|
|
880
|
+
self.assertTrue(d["is_pinned"])
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# =========================================================================
|
|
884
|
+
# Explore integration
|
|
885
|
+
# =========================================================================
|
|
886
|
+
|
|
887
|
+
class TestExplore(unittest.TestCase):
|
|
888
|
+
"""build_explore_query and codegraph_explore (dry-run)."""
|
|
889
|
+
|
|
890
|
+
def test_build_explore_query(self):
|
|
891
|
+
from tools.codegraph_voyage.ranking import RankingResult
|
|
892
|
+
from tools.codegraph_voyage.explore import build_explore_query
|
|
893
|
+
candidates = [
|
|
894
|
+
RankingResult(node_id="n1", name="hello", qualified_name="mod.hello", file_path="src/a.py"),
|
|
895
|
+
RankingResult(node_id="n2", name="world", qualified_name="mod.world", file_path="src/b.py"),
|
|
896
|
+
]
|
|
897
|
+
q = build_explore_query(candidates, max_symbols=10)
|
|
898
|
+
self.assertIn("mod.hello", q)
|
|
899
|
+
self.assertIn("mod.world", q)
|
|
900
|
+
|
|
901
|
+
def test_codegraph_explore_dry_run(self):
|
|
902
|
+
from tools.codegraph_voyage.ranking import RankingResult
|
|
903
|
+
from tools.codegraph_voyage.explore import codegraph_explore
|
|
904
|
+
candidates = [
|
|
905
|
+
RankingResult(node_id="n1", name="hello", qualified_name="mod.hello"),
|
|
906
|
+
]
|
|
907
|
+
result = codegraph_explore(candidates, project_path="/tmp", dry_run=True)
|
|
908
|
+
self.assertIn("command", result)
|
|
909
|
+
self.assertIn("codegraph explore", result["command"])
|
|
910
|
+
|
|
911
|
+
def test_codegraph_explore_fake_executable(self):
|
|
912
|
+
from tools.codegraph_voyage.ranking import RankingResult
|
|
913
|
+
from tools.codegraph_voyage.explore import codegraph_explore
|
|
914
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
915
|
+
fake = Path(tmp) / "fake-codegraph"
|
|
916
|
+
fake.write_text("#!/bin/sh\nprintf 'FAKE_EXPLORE:%s\\n' \"$*\"\n")
|
|
917
|
+
fake.chmod(0o755)
|
|
918
|
+
candidates = [RankingResult(
|
|
919
|
+
node_id="n1", name="AuthService", qualified_name="app.AuthService"
|
|
920
|
+
)]
|
|
921
|
+
result = codegraph_explore(
|
|
922
|
+
candidates, project_path=tmp, codegraph_bin=str(fake), timeout=5
|
|
923
|
+
)
|
|
924
|
+
self.assertEqual(result["returncode"], 0)
|
|
925
|
+
self.assertIn("FAKE_EXPLORE:explore", result["stdout"])
|
|
926
|
+
self.assertIn("app.AuthService", result["stdout"])
|
|
927
|
+
|
|
928
|
+
def test_codegraph_explore_no_candidates(self):
|
|
929
|
+
from tools.codegraph_voyage.explore import codegraph_explore
|
|
930
|
+
result = codegraph_explore([], project_path="/tmp")
|
|
931
|
+
self.assertEqual(result["returncode"], 1)
|
|
932
|
+
self.assertEqual(result["error"], "No candidates")
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
# =========================================================================
|
|
936
|
+
# CLI
|
|
937
|
+
# =========================================================================
|
|
938
|
+
|
|
939
|
+
class TestCLI(unittest.TestCase):
|
|
940
|
+
"""CLI argument parsing and command dispatch."""
|
|
941
|
+
|
|
942
|
+
def test_missing_voyage_key_fails_actionably(self):
|
|
943
|
+
import argparse
|
|
944
|
+
from tools.codegraph_voyage.cli import _make_provider
|
|
945
|
+
args = argparse.Namespace(provider="voyage", model="voyage-code-4", dimensions=16)
|
|
946
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
947
|
+
with self.assertRaisesRegex(ValueError, "VOYAGE_API_KEY"):
|
|
948
|
+
_make_provider(args)
|
|
949
|
+
|
|
950
|
+
def test_search_rejects_incompatible_sidecar_model(self):
|
|
951
|
+
import argparse
|
|
952
|
+
from tools.codegraph_voyage.cli import cmd_search
|
|
953
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
954
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
955
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
956
|
+
root = Path(tmp)
|
|
957
|
+
graph_dir = root / ".codegraph"
|
|
958
|
+
graph_dir.mkdir()
|
|
959
|
+
(graph_dir / "codegraph.db").touch()
|
|
960
|
+
sidecar = SidecarDB(graph_dir / "codegraph-voyage.db")
|
|
961
|
+
sidecar.open()
|
|
962
|
+
sidecar.store_embeddings([{
|
|
963
|
+
"node_id": "n1", "content_hash": "h1", "embedding": [0.1, 0.2],
|
|
964
|
+
"node_kind": "f", "name": "n1", "qualified_name": "n1",
|
|
965
|
+
"file_path": "n1.py", "language": "python", "start_line": 1,
|
|
966
|
+
"end_line": 1, "document_text": "n1",
|
|
967
|
+
}], FakeEmbeddingProvider(dimensions=2, model="voyage-code-4"))
|
|
968
|
+
sidecar.close()
|
|
969
|
+
args = argparse.Namespace(
|
|
970
|
+
project=str(root), query="n1", provider="fake", model="voyage-code-4",
|
|
971
|
+
dimensions=2, no_source=False, max_source_lines=20, kind=None,
|
|
972
|
+
file_filter=None, top_k=5, lexical_weight=0.5, vector_weight=0.5,
|
|
973
|
+
rrf_k=60, json=False,
|
|
974
|
+
)
|
|
975
|
+
stderr = io.StringIO()
|
|
976
|
+
with mock.patch(
|
|
977
|
+
"tools.codegraph_voyage.cli.build_documents_from_db", return_value=[]
|
|
978
|
+
), mock.patch("sys.stderr", stderr):
|
|
979
|
+
rc = cmd_search(args)
|
|
980
|
+
self.assertEqual(rc, 2)
|
|
981
|
+
self.assertIn("Sidecar contains voyage-code-4 dims=2", stderr.getvalue())
|
|
982
|
+
self.assertIn("clear and rebuild", stderr.getvalue())
|
|
983
|
+
|
|
984
|
+
def test_search_json_stdout_is_parseable_list(self):
|
|
985
|
+
import argparse
|
|
986
|
+
from tools.codegraph_voyage.cli import cmd_search
|
|
987
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
988
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
989
|
+
root = Path(tmp)
|
|
990
|
+
graph_dir = root / ".codegraph"
|
|
991
|
+
graph_dir.mkdir()
|
|
992
|
+
(graph_dir / "codegraph.db").touch()
|
|
993
|
+
sidecar = SidecarDB(graph_dir / "codegraph-voyage.db")
|
|
994
|
+
sidecar.open()
|
|
995
|
+
sidecar.close()
|
|
996
|
+
args = argparse.Namespace(
|
|
997
|
+
project=str(root), query="n1", provider="fake", model="voyage-code-4",
|
|
998
|
+
dimensions=2, no_source=False, max_source_lines=20, kind=None,
|
|
999
|
+
file_filter=None, top_k=5, lexical_weight=0.5, vector_weight=0.5,
|
|
1000
|
+
rrf_k=60, json=True,
|
|
1001
|
+
)
|
|
1002
|
+
stdout = io.StringIO()
|
|
1003
|
+
with mock.patch(
|
|
1004
|
+
"tools.codegraph_voyage.cli.build_documents_from_db", return_value=[]
|
|
1005
|
+
), mock.patch("sys.stdout", stdout):
|
|
1006
|
+
rc = cmd_search(args)
|
|
1007
|
+
self.assertEqual(rc, 0)
|
|
1008
|
+
self.assertIsInstance(json.loads(stdout.getvalue()), list)
|
|
1009
|
+
|
|
1010
|
+
def _assert_failed_index_preserves_sidecar(self, urlopen_side_effect=None, response=None):
|
|
1011
|
+
import argparse
|
|
1012
|
+
from tools.codegraph_voyage.cli import cmd_index
|
|
1013
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider, VoyageEmbeddingProvider
|
|
1014
|
+
from tools.codegraph_voyage.sidecar import SidecarDB
|
|
1015
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1016
|
+
root = Path(tmp)
|
|
1017
|
+
graph_dir = root / ".codegraph"
|
|
1018
|
+
graph_dir.mkdir()
|
|
1019
|
+
(graph_dir / "codegraph.db").touch()
|
|
1020
|
+
db_path = graph_dir / "codegraph-voyage.db"
|
|
1021
|
+
sidecar = SidecarDB(db_path)
|
|
1022
|
+
sidecar.open()
|
|
1023
|
+
sidecar.store_embeddings([{
|
|
1024
|
+
"node_id": "sentinel", "content_hash": "original", "embedding": [0.5],
|
|
1025
|
+
"node_kind": "f", "name": "sentinel", "qualified_name": "sentinel",
|
|
1026
|
+
"file_path": "sentinel.py", "language": "python", "start_line": 1,
|
|
1027
|
+
"end_line": 1, "document_text": "sentinel",
|
|
1028
|
+
}], FakeEmbeddingProvider(dimensions=1, model="sentinel-model"))
|
|
1029
|
+
before = sidecar.conn.execute(
|
|
1030
|
+
"SELECT node_id, source_content_hash, model FROM embeddings ORDER BY node_id"
|
|
1031
|
+
).fetchall()
|
|
1032
|
+
sidecar.close()
|
|
1033
|
+
doc = {
|
|
1034
|
+
"node_id": "new", "document": 'api_key = "do-not-send"',
|
|
1035
|
+
"node_kind": "f", "name": "new", "qualified_name": "new",
|
|
1036
|
+
"file_path": "src/new.py", "language": "python", "start_line": 1,
|
|
1037
|
+
"end_line": 1,
|
|
1038
|
+
}
|
|
1039
|
+
args = argparse.Namespace(
|
|
1040
|
+
project=str(root), provider="voyage", model="voyage-code-4", dimensions=2,
|
|
1041
|
+
no_source=False, max_source_lines=20, kind=None, file_filter=None,
|
|
1042
|
+
)
|
|
1043
|
+
provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
|
|
1044
|
+
stderr = io.StringIO()
|
|
1045
|
+
patcher = mock.patch("urllib.request.urlopen")
|
|
1046
|
+
mocked_urlopen = patcher.start()
|
|
1047
|
+
if urlopen_side_effect is not None:
|
|
1048
|
+
mocked_urlopen.side_effect = urlopen_side_effect
|
|
1049
|
+
else:
|
|
1050
|
+
mocked_urlopen.return_value.__enter__.return_value.read.return_value = response
|
|
1051
|
+
try:
|
|
1052
|
+
with mock.patch(
|
|
1053
|
+
"tools.codegraph_voyage.cli._make_provider_or_report", return_value=provider
|
|
1054
|
+
), mock.patch(
|
|
1055
|
+
"tools.codegraph_voyage.cli.build_documents_from_db", return_value=[doc]
|
|
1056
|
+
), mock.patch("sys.stderr", stderr):
|
|
1057
|
+
rc = cmd_index(args)
|
|
1058
|
+
finally:
|
|
1059
|
+
patcher.stop()
|
|
1060
|
+
sidecar.open()
|
|
1061
|
+
after = sidecar.conn.execute(
|
|
1062
|
+
"SELECT node_id, source_content_hash, model FROM embeddings ORDER BY node_id"
|
|
1063
|
+
).fetchall()
|
|
1064
|
+
sidecar.close()
|
|
1065
|
+
self.assertEqual(rc, 2)
|
|
1066
|
+
self.assertEqual(after, before)
|
|
1067
|
+
self.assertIn("sidecar left unchanged", stderr.getvalue())
|
|
1068
|
+
return stderr.getvalue()
|
|
1069
|
+
|
|
1070
|
+
def test_index_http_failure_is_atomic(self):
|
|
1071
|
+
error = urllib.error.HTTPError(
|
|
1072
|
+
url="https://api.voyageai.com/v1/embeddings", code=503,
|
|
1073
|
+
msg="Unavailable", hdrs=email.message.Message(), fp=None,
|
|
1074
|
+
)
|
|
1075
|
+
stderr = self._assert_failed_index_preserves_sidecar(urlopen_side_effect=error)
|
|
1076
|
+
self.assertIn("503", stderr)
|
|
1077
|
+
|
|
1078
|
+
def test_index_malformed_voyage_response_is_atomic(self):
|
|
1079
|
+
stderr = self._assert_failed_index_preserves_sidecar(
|
|
1080
|
+
response=json.dumps({"data": []}).encode()
|
|
1081
|
+
)
|
|
1082
|
+
self.assertIn("response validation failed", stderr)
|
|
1083
|
+
|
|
1084
|
+
def test_parser_accepts_commands(self):
|
|
1085
|
+
from tools.codegraph_voyage.cli import _build_parser
|
|
1086
|
+
ap = _build_parser()
|
|
1087
|
+
# Check commands are registered (--help exits, so catch SystemExit)
|
|
1088
|
+
for cmd in ["index", "search", "status", "explore"]:
|
|
1089
|
+
sub = ap._subparsers._group_actions[0]
|
|
1090
|
+
self.assertIn(cmd, sub.choices)
|
|
1091
|
+
|
|
1092
|
+
def test_help_prints(self):
|
|
1093
|
+
from tools.codegraph_voyage.cli import main
|
|
1094
|
+
# --help on the main parser exits with code 0; catch SystemExit
|
|
1095
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
1096
|
+
main(["--help"])
|
|
1097
|
+
self.assertEqual(ctx.exception.code, 0)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
# =========================================================================
|
|
1101
|
+
# Sanitize test
|
|
1102
|
+
# =========================================================================
|
|
1103
|
+
|
|
1104
|
+
class TestSanitizeModule(unittest.TestCase):
|
|
1105
|
+
"""Module-level exports."""
|
|
1106
|
+
|
|
1107
|
+
def test_excluded_path_patterns(self):
|
|
1108
|
+
from tools.codegraph_voyage.sanitize import EXCLUDED_PATH_PATTERNS
|
|
1109
|
+
self.assertIsInstance(EXCLUDED_PATH_PATTERNS, list)
|
|
1110
|
+
self.assertTrue(len(EXCLUDED_PATH_PATTERNS) > 0)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
if __name__ == "__main__":
|
|
1114
|
+
unittest.main()
|