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,8 @@
|
|
|
1
|
+
"""codegraph-voyage — hybrid semantic retrieval sidecar for CodeGraph.
|
|
2
|
+
|
|
3
|
+
A self-contained CLI tool that builds symbol-level documents from a CodeGraph
|
|
4
|
+
index, generates voyage-code-4 embeddings, stores them in a sidecar SQLite DB,
|
|
5
|
+
and fuses lexical + vector retrieval with weighted reciprocal-rank fusion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
codegraph_voyage/cli.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""CLI entry point for codegraph-voyage.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
codegraph-voyage index [options]
|
|
5
|
+
codegraph-voyage search <query> [options]
|
|
6
|
+
codegraph-voyage status [options]
|
|
7
|
+
codegraph-voyage explore <query> [options]
|
|
8
|
+
|
|
9
|
+
The API key is read from the VOYAGE_API_KEY environment variable only;
|
|
10
|
+
no CLI flag accepts a secret value.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import textwrap
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .document import (
|
|
25
|
+
build_documents_from_db,
|
|
26
|
+
build_document_for_node_id,
|
|
27
|
+
compute_content_hash,
|
|
28
|
+
)
|
|
29
|
+
from .explore import codegraph_explore
|
|
30
|
+
from .providers import (
|
|
31
|
+
EmbeddingProvider,
|
|
32
|
+
FakeEmbeddingProvider,
|
|
33
|
+
VoyageEmbeddingProvider,
|
|
34
|
+
create_provider,
|
|
35
|
+
)
|
|
36
|
+
from .ranking import (
|
|
37
|
+
RankingResult,
|
|
38
|
+
find_pinned_candidates,
|
|
39
|
+
hybrid_search,
|
|
40
|
+
)
|
|
41
|
+
from .sanitize import sanitize_content, is_sensitive_path
|
|
42
|
+
from .sidecar import SidecarDB, SidecarError
|
|
43
|
+
|
|
44
|
+
DEFAULT_CODEGRAPH_DIR = ".codegraph"
|
|
45
|
+
DEFAULT_SIDECAR_NAME = "codegraph-voyage.db"
|
|
46
|
+
DEFAULT_PROVIDER = "fake"
|
|
47
|
+
DEFAULT_MODEL = "voyage-code-4"
|
|
48
|
+
DEFAULT_DIMENSIONS = 512
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_project_root(path: str | Path | None = None) -> Path:
|
|
52
|
+
"""Resolve project root — the directory containing .codegraph/."""
|
|
53
|
+
start = Path(path or os.getcwd()).resolve()
|
|
54
|
+
# Walk up looking for .codegraph/
|
|
55
|
+
d = start
|
|
56
|
+
while d != d.parent:
|
|
57
|
+
if (d / DEFAULT_CODEGRAPH_DIR).is_dir():
|
|
58
|
+
return d
|
|
59
|
+
d = d.parent
|
|
60
|
+
# If not found, use start
|
|
61
|
+
return start
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _codegraph_db_path(root: Path) -> Path:
|
|
65
|
+
return root / DEFAULT_CODEGRAPH_DIR / "codegraph.db"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sidecar_db_path(root: Path) -> Path:
|
|
69
|
+
return root / DEFAULT_CODEGRAPH_DIR / DEFAULT_SIDECAR_NAME
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _make_provider(args: argparse.Namespace) -> EmbeddingProvider:
|
|
73
|
+
"""Create an embedding provider from CLI args.
|
|
74
|
+
|
|
75
|
+
The API key is read from the VOYAGE_API_KEY environment variable only;
|
|
76
|
+
no CLI flag accepts a secret value.
|
|
77
|
+
"""
|
|
78
|
+
api_key = os.environ.get("VOYAGE_API_KEY", "")
|
|
79
|
+
if args.provider == "voyage" and not api_key:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"VOYAGE_API_KEY is required for voyage provider; "
|
|
82
|
+
"set the VOYAGE_API_KEY environment variable"
|
|
83
|
+
)
|
|
84
|
+
return create_provider(
|
|
85
|
+
args.provider,
|
|
86
|
+
api_key=api_key,
|
|
87
|
+
model=args.model,
|
|
88
|
+
dimensions=args.dimensions,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _make_provider_or_report(args: argparse.Namespace) -> EmbeddingProvider | None:
|
|
93
|
+
"""Build a provider and turn configuration failures into actionable CLI errors."""
|
|
94
|
+
try:
|
|
95
|
+
return _make_provider(args)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sidecar_model_mismatch(
|
|
102
|
+
status: dict[str, Any], provider: EmbeddingProvider
|
|
103
|
+
) -> str | None:
|
|
104
|
+
"""Return an actionable message when stored vectors are incompatible."""
|
|
105
|
+
groups = status.get("model_groups", [])
|
|
106
|
+
if not groups:
|
|
107
|
+
return None
|
|
108
|
+
if any(
|
|
109
|
+
group[0] == provider.model_name and int(group[1]) == provider.dimensions
|
|
110
|
+
for group in groups
|
|
111
|
+
):
|
|
112
|
+
return None
|
|
113
|
+
stored = ", ".join(f"{group[0]} dims={group[1]}" for group in groups)
|
|
114
|
+
return (
|
|
115
|
+
f"Sidecar contains {stored}, but selected provider is "
|
|
116
|
+
f"{provider.model_name} dims={provider.dimensions}; pass the matching "
|
|
117
|
+
"--provider/--model/--dimensions options or clear and rebuild the index"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
122
|
+
"""Build documents from CodeGraph and store embeddings in sidecar."""
|
|
123
|
+
root = _resolve_project_root(args.project)
|
|
124
|
+
cg_db = _codegraph_db_path(root)
|
|
125
|
+
sidecar_db = _sidecar_db_path(root)
|
|
126
|
+
|
|
127
|
+
if not cg_db.is_file():
|
|
128
|
+
print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
|
|
129
|
+
print("Run `codegraph init` or `codegraph sync` first.", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
provider = _make_provider_or_report(args)
|
|
133
|
+
if provider is None:
|
|
134
|
+
return 2
|
|
135
|
+
print(f"Provider: {provider.model_name} (dimensions={provider.dimensions})")
|
|
136
|
+
|
|
137
|
+
# Build documents
|
|
138
|
+
print("Building documents from CodeGraph DB...")
|
|
139
|
+
docs = build_documents_from_db(
|
|
140
|
+
cg_db,
|
|
141
|
+
root,
|
|
142
|
+
include_source=not args.no_source,
|
|
143
|
+
max_source_lines=args.max_source_lines,
|
|
144
|
+
node_kinds=tuple(args.kind.split(",")) if args.kind else None,
|
|
145
|
+
file_filter=args.file_filter,
|
|
146
|
+
)
|
|
147
|
+
print(f" Total documents: {len(docs)}")
|
|
148
|
+
|
|
149
|
+
# Sanitization is mandatory before any remote call. There is deliberately
|
|
150
|
+
# no CLI bypass: exclusions are a security boundary, not a tuning option.
|
|
151
|
+
# Hash the exact sanitized payload used for embedding.
|
|
152
|
+
print("Applying path/content sanitization...")
|
|
153
|
+
for doc in docs:
|
|
154
|
+
doc["document"] = sanitize_content(
|
|
155
|
+
doc["document"], doc.get("file_path", "")
|
|
156
|
+
)
|
|
157
|
+
doc["content_hash"] = compute_content_hash(doc["document"])
|
|
158
|
+
|
|
159
|
+
# Open sidecar
|
|
160
|
+
sidecar = SidecarDB(sidecar_db)
|
|
161
|
+
sidecar.open()
|
|
162
|
+
try:
|
|
163
|
+
# Find changed nodes
|
|
164
|
+
print("Finding changed nodes for incremental indexing...")
|
|
165
|
+
changed = sidecar.find_changed_nodes(docs, provider)
|
|
166
|
+
print(f" Changed/new: {len(changed)} / {len(docs)}")
|
|
167
|
+
|
|
168
|
+
if not changed:
|
|
169
|
+
print("No changes to index.")
|
|
170
|
+
# Still remove stale records
|
|
171
|
+
current_ids = {d["node_id"] for d in docs}
|
|
172
|
+
removed = sidecar.remove_stale_records(current_ids, provider)
|
|
173
|
+
if removed:
|
|
174
|
+
print(f" Removed stale records: {removed}")
|
|
175
|
+
status = sidecar.get_status()
|
|
176
|
+
print(f" Total embeddings: {status.get('total_embeddings', 0)}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
# Generate embeddings
|
|
180
|
+
print(f"Generating embeddings for {len(changed)} documents...")
|
|
181
|
+
texts = [d["document"] for d in changed]
|
|
182
|
+
try:
|
|
183
|
+
embeddings = provider.embed_documents(texts, input_type="document")
|
|
184
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
185
|
+
print(f"Embedding failed; sidecar left unchanged: {exc}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
if len(embeddings) != len(changed):
|
|
188
|
+
print(
|
|
189
|
+
"Embedding failed; sidecar left unchanged: provider returned "
|
|
190
|
+
f"{len(embeddings)} vectors for {len(changed)} documents",
|
|
191
|
+
file=sys.stderr,
|
|
192
|
+
)
|
|
193
|
+
return 2
|
|
194
|
+
print(f" Generated {len(embeddings)} embeddings")
|
|
195
|
+
|
|
196
|
+
# Store embeddings
|
|
197
|
+
records = []
|
|
198
|
+
for doc, emb in zip(changed, embeddings):
|
|
199
|
+
records.append({
|
|
200
|
+
"node_id": doc["node_id"],
|
|
201
|
+
"content_hash": doc["content_hash"],
|
|
202
|
+
"embedding": emb,
|
|
203
|
+
"node_kind": doc["node_kind"],
|
|
204
|
+
"name": doc["name"],
|
|
205
|
+
"qualified_name": doc["qualified_name"],
|
|
206
|
+
"file_path": doc["file_path"],
|
|
207
|
+
"language": doc["language"],
|
|
208
|
+
"start_line": doc["start_line"],
|
|
209
|
+
"end_line": doc["end_line"],
|
|
210
|
+
"document_text": doc["document"],
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
stored = sidecar.store_embeddings(records, provider)
|
|
215
|
+
except (SidecarError, ValueError, OSError) as exc:
|
|
216
|
+
print(f"Embedding store failed; sidecar left unchanged: {exc}", file=sys.stderr)
|
|
217
|
+
return 2
|
|
218
|
+
print(f" Stored embeddings: {stored}")
|
|
219
|
+
|
|
220
|
+
# Remove stale records
|
|
221
|
+
current_ids = {d["node_id"] for d in docs}
|
|
222
|
+
removed = sidecar.remove_stale_records(current_ids, provider)
|
|
223
|
+
if removed:
|
|
224
|
+
print(f" Removed stale records: {removed}")
|
|
225
|
+
|
|
226
|
+
status = sidecar.get_status()
|
|
227
|
+
print(f" Total embeddings: {status.get('total_embeddings', 0)}")
|
|
228
|
+
finally:
|
|
229
|
+
sidecar.close()
|
|
230
|
+
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
235
|
+
"""Hybrid semantic search (semantic_candidates)."""
|
|
236
|
+
root = _resolve_project_root(args.project)
|
|
237
|
+
cg_db = _codegraph_db_path(root)
|
|
238
|
+
sidecar_db = _sidecar_db_path(root)
|
|
239
|
+
query = args.query
|
|
240
|
+
|
|
241
|
+
if not cg_db.is_file():
|
|
242
|
+
print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
|
|
243
|
+
return 1
|
|
244
|
+
if not sidecar_db.is_file():
|
|
245
|
+
print(
|
|
246
|
+
f"Sidecar DB not found at {sidecar_db}. Run 'index' first.",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
provider = _make_provider_or_report(args)
|
|
252
|
+
if provider is None:
|
|
253
|
+
return 2
|
|
254
|
+
|
|
255
|
+
# Build lexical candidates (all docs)
|
|
256
|
+
if not args.json:
|
|
257
|
+
print(f"Building documents for query: {query}")
|
|
258
|
+
docs = build_documents_from_db(
|
|
259
|
+
cg_db,
|
|
260
|
+
root,
|
|
261
|
+
include_source=not args.no_source,
|
|
262
|
+
max_source_lines=args.max_source_lines,
|
|
263
|
+
node_kinds=tuple(args.kind.split(",")) if args.kind else None,
|
|
264
|
+
file_filter=args.file_filter,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Load sidecar embeddings
|
|
268
|
+
sidecar = SidecarDB(sidecar_db)
|
|
269
|
+
sidecar.open()
|
|
270
|
+
try:
|
|
271
|
+
mismatch = _sidecar_model_mismatch(sidecar.get_status(), provider)
|
|
272
|
+
if mismatch:
|
|
273
|
+
print(f"Error: {mismatch}", file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
emb_candidates = sidecar.get_all_embeddings(provider)
|
|
276
|
+
finally:
|
|
277
|
+
sidecar.close()
|
|
278
|
+
|
|
279
|
+
# Match docs with embeddings
|
|
280
|
+
emb_map = {c["node_id"]: c for c in emb_candidates}
|
|
281
|
+
vector_candidates: list[dict[str, Any]] = []
|
|
282
|
+
for d in docs:
|
|
283
|
+
if d["node_id"] in emb_map:
|
|
284
|
+
vector_candidates.append({
|
|
285
|
+
"node_id": d["node_id"],
|
|
286
|
+
"name": d["name"],
|
|
287
|
+
"qualified_name": d["qualified_name"],
|
|
288
|
+
"file_path": d["file_path"],
|
|
289
|
+
"node_kind": d["node_kind"],
|
|
290
|
+
"language": d["language"],
|
|
291
|
+
"start_line": d["start_line"],
|
|
292
|
+
"end_line": d["end_line"],
|
|
293
|
+
"embedding": emb_map[d["node_id"]]["embedding"],
|
|
294
|
+
"document_text": d["document"],
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
# Lexical candidates
|
|
298
|
+
lexical_candidates: list[dict[str, Any]] = [
|
|
299
|
+
{
|
|
300
|
+
"node_id": d["node_id"],
|
|
301
|
+
"name": d["name"],
|
|
302
|
+
"qualified_name": d["qualified_name"],
|
|
303
|
+
"file_path": d["file_path"],
|
|
304
|
+
"node_kind": d["node_kind"],
|
|
305
|
+
"language": d["language"],
|
|
306
|
+
"start_line": d["start_line"],
|
|
307
|
+
"end_line": d["end_line"],
|
|
308
|
+
"document_text": d["document"],
|
|
309
|
+
}
|
|
310
|
+
for d in docs
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
# Generate query embedding; network/API failure degrades to lexical-only.
|
|
314
|
+
if not args.json:
|
|
315
|
+
print("Generating query embedding...")
|
|
316
|
+
try:
|
|
317
|
+
query_vector = provider.embed_query(query, input_type="query")
|
|
318
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
319
|
+
print(f"Vector lookup unavailable; continuing lexical-only: {exc}", file=sys.stderr)
|
|
320
|
+
query_vector = None
|
|
321
|
+
|
|
322
|
+
# Find pinned candidates
|
|
323
|
+
if not args.json:
|
|
324
|
+
print("Identifying pinned candidates...")
|
|
325
|
+
pinned = find_pinned_candidates(
|
|
326
|
+
query, lexical_candidates + vector_candidates
|
|
327
|
+
)
|
|
328
|
+
if not args.json:
|
|
329
|
+
print(f" Pinned: {len(pinned)}")
|
|
330
|
+
|
|
331
|
+
# Hybrid search
|
|
332
|
+
if not args.json:
|
|
333
|
+
print("Running hybrid search (weighted RRF)...")
|
|
334
|
+
results = hybrid_search(
|
|
335
|
+
query,
|
|
336
|
+
query_vector,
|
|
337
|
+
vector_candidates,
|
|
338
|
+
lexical_candidates,
|
|
339
|
+
pinned_candidates=pinned if pinned else None,
|
|
340
|
+
top_k=args.top_k,
|
|
341
|
+
lexical_weight=args.lexical_weight,
|
|
342
|
+
vector_weight=args.vector_weight,
|
|
343
|
+
rrf_k=args.rrf_k,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Output
|
|
347
|
+
if args.json:
|
|
348
|
+
output = [r.to_dict() for r in results]
|
|
349
|
+
print(json.dumps(output, indent=2))
|
|
350
|
+
else:
|
|
351
|
+
print(f"\nTop {len(results)} results:\n")
|
|
352
|
+
for i, r in enumerate(results, start=1):
|
|
353
|
+
provenance = r.provenance or "fused"
|
|
354
|
+
print(
|
|
355
|
+
f" {i:2d}. [{r.node_kind:12s}] {r.name:40s} "
|
|
356
|
+
f"score={r.score:.4f} "
|
|
357
|
+
f"{'[PINNED]' if r.is_pinned else ''} "
|
|
358
|
+
f"provenance={provenance}"
|
|
359
|
+
)
|
|
360
|
+
if r.qualified_name:
|
|
361
|
+
print(f" Qualified: {r.qualified_name}")
|
|
362
|
+
print(f" File: {r.file_path}:{r.start_line or ''}")
|
|
363
|
+
print()
|
|
364
|
+
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
369
|
+
"""Show sidecar status."""
|
|
370
|
+
root = _resolve_project_root(args.project)
|
|
371
|
+
cg_db = _codegraph_db_path(root)
|
|
372
|
+
sidecar_db = _sidecar_db_path(root)
|
|
373
|
+
|
|
374
|
+
print(f"Project root: {root}")
|
|
375
|
+
print(f"CodeGraph DB: {cg_db}")
|
|
376
|
+
print(f"Sidecar DB: {sidecar_db}")
|
|
377
|
+
|
|
378
|
+
# CodeGraph status
|
|
379
|
+
if cg_db.is_file():
|
|
380
|
+
import sqlite3
|
|
381
|
+
conn = sqlite3.connect(f"file://{cg_db.resolve()}?mode=ro", uri=True)
|
|
382
|
+
try:
|
|
383
|
+
total_nodes = conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
|
|
384
|
+
total_files = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
385
|
+
total_edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
|
386
|
+
print(f"\nCodeGraph:")
|
|
387
|
+
print(f" Nodes: {total_nodes}")
|
|
388
|
+
print(f" Files: {total_files}")
|
|
389
|
+
print(f" Edges: {total_edges}")
|
|
390
|
+
finally:
|
|
391
|
+
conn.close()
|
|
392
|
+
else:
|
|
393
|
+
print(f"\nCodeGraph: NOT FOUND")
|
|
394
|
+
|
|
395
|
+
# Sidecar status
|
|
396
|
+
if sidecar_db.is_file():
|
|
397
|
+
sidecar = SidecarDB(sidecar_db)
|
|
398
|
+
sidecar.open()
|
|
399
|
+
try:
|
|
400
|
+
status = sidecar.get_status()
|
|
401
|
+
print(f"\nSidecar:")
|
|
402
|
+
print(f" Connected: {status.get('connected')}")
|
|
403
|
+
print(f" Total embeddings: {status.get('total_embeddings', 0)}")
|
|
404
|
+
for group in status.get("model_groups", []):
|
|
405
|
+
print(f" Group: model={group[0]} dims={group[1]} dtype={group[2]} count={group[3]}")
|
|
406
|
+
print(f" Schema version: {status.get('schema_version')}")
|
|
407
|
+
finally:
|
|
408
|
+
sidecar.close()
|
|
409
|
+
else:
|
|
410
|
+
print(f"\nSidecar: NOT FOUND (run 'index' first)")
|
|
411
|
+
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cmd_explore(args: argparse.Namespace) -> int:
|
|
416
|
+
"""Integration: hybrid search then codegraph explore with candidates."""
|
|
417
|
+
root = _resolve_project_root(args.project)
|
|
418
|
+
cg_db = _codegraph_db_path(root)
|
|
419
|
+
sidecar_db = _sidecar_db_path(root)
|
|
420
|
+
query = args.query
|
|
421
|
+
|
|
422
|
+
if not cg_db.is_file():
|
|
423
|
+
print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
|
|
424
|
+
return 1
|
|
425
|
+
|
|
426
|
+
provider = _make_provider_or_report(args)
|
|
427
|
+
if provider is None:
|
|
428
|
+
return 2
|
|
429
|
+
sidecar_available = sidecar_db.is_file()
|
|
430
|
+
|
|
431
|
+
# Build docs
|
|
432
|
+
docs = build_documents_from_db(
|
|
433
|
+
cg_db, root,
|
|
434
|
+
include_source=not args.no_source,
|
|
435
|
+
max_source_lines=args.max_source_lines,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
lexical_candidates: list[dict[str, Any]] = [
|
|
439
|
+
{
|
|
440
|
+
"node_id": d["node_id"],
|
|
441
|
+
"name": d["name"],
|
|
442
|
+
"qualified_name": d["qualified_name"],
|
|
443
|
+
"file_path": d["file_path"],
|
|
444
|
+
"node_kind": d["node_kind"],
|
|
445
|
+
"language": d["language"],
|
|
446
|
+
"start_line": d["start_line"],
|
|
447
|
+
"end_line": d["end_line"],
|
|
448
|
+
"document_text": d["document"],
|
|
449
|
+
}
|
|
450
|
+
for d in docs
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
# Query embedding
|
|
454
|
+
query_vector = None
|
|
455
|
+
if sidecar_available:
|
|
456
|
+
sidecar = SidecarDB(sidecar_db)
|
|
457
|
+
sidecar.open()
|
|
458
|
+
try:
|
|
459
|
+
mismatch = _sidecar_model_mismatch(sidecar.get_status(), provider)
|
|
460
|
+
if mismatch:
|
|
461
|
+
print(f"Error: {mismatch}", file=sys.stderr)
|
|
462
|
+
return 2
|
|
463
|
+
emb_candidates = sidecar.get_all_embeddings(provider)
|
|
464
|
+
emb_map = {c["node_id"]: c for c in emb_candidates}
|
|
465
|
+
vector_candidates = [
|
|
466
|
+
{**d, "embedding": emb_map[d["node_id"]]["embedding"]}
|
|
467
|
+
for d in docs if d["node_id"] in emb_map
|
|
468
|
+
]
|
|
469
|
+
try:
|
|
470
|
+
query_vector = provider.embed_query(query, input_type="query")
|
|
471
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
472
|
+
print(f"Vector lookup unavailable; continuing lexical-only: {exc}", file=sys.stderr)
|
|
473
|
+
query_vector = None
|
|
474
|
+
finally:
|
|
475
|
+
sidecar.close()
|
|
476
|
+
else:
|
|
477
|
+
vector_candidates = []
|
|
478
|
+
|
|
479
|
+
# Pinned
|
|
480
|
+
pinned = find_pinned_candidates(query, lexical_candidates + vector_candidates)
|
|
481
|
+
|
|
482
|
+
# Hybrid search
|
|
483
|
+
results = hybrid_search(
|
|
484
|
+
query,
|
|
485
|
+
query_vector,
|
|
486
|
+
vector_candidates,
|
|
487
|
+
lexical_candidates,
|
|
488
|
+
pinned_candidates=pinned if pinned else None,
|
|
489
|
+
top_k=args.top_k,
|
|
490
|
+
lexical_weight=args.lexical_weight,
|
|
491
|
+
vector_weight=args.vector_weight,
|
|
492
|
+
rrf_k=args.rrf_k,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if args.dry_run:
|
|
496
|
+
from .explore import build_explore_query
|
|
497
|
+
q = build_explore_query(results, max_symbols=20)
|
|
498
|
+
print(f"Explore query ({len(results)} candidates → {len(q.split())} symbols):")
|
|
499
|
+
print(f" {q}")
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
# Run codegraph explore
|
|
503
|
+
result = codegraph_explore(
|
|
504
|
+
results,
|
|
505
|
+
project_path=root,
|
|
506
|
+
max_files=args.max_files,
|
|
507
|
+
codegraph_bin=args.codegraph_bin,
|
|
508
|
+
timeout=args.timeout,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if result.get("error"):
|
|
512
|
+
print(f"codegraph explore error: {result['error']}", file=sys.stderr)
|
|
513
|
+
return 1
|
|
514
|
+
|
|
515
|
+
print(result.get("stdout", ""))
|
|
516
|
+
if result.get("stderr"):
|
|
517
|
+
print(result["stderr"], file=sys.stderr)
|
|
518
|
+
|
|
519
|
+
return result.get("returncode", 0)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
523
|
+
ap = argparse.ArgumentParser(
|
|
524
|
+
description="codegraph-voyage: hybrid semantic retrieval sidecar for CodeGraph",
|
|
525
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
526
|
+
epilog=textwrap.dedent("""\
|
|
527
|
+
Examples:
|
|
528
|
+
# Index with fake provider (no API key needed)
|
|
529
|
+
codegraph-voyage index
|
|
530
|
+
|
|
531
|
+
# Index with voyage-code-4
|
|
532
|
+
VOYAGE_API_KEY=... codegraph-voyage index --provider voyage
|
|
533
|
+
|
|
534
|
+
# Search (hybrid lexical + vector)
|
|
535
|
+
codegraph-voyage search "AuthService" --top-k 10
|
|
536
|
+
|
|
537
|
+
# Explore with semantic candidates
|
|
538
|
+
codegraph-voyage explore "UserManager" --max-files 8
|
|
539
|
+
|
|
540
|
+
# Status
|
|
541
|
+
codegraph-voyage status
|
|
542
|
+
"""),
|
|
543
|
+
)
|
|
544
|
+
ap.add_argument("--version", action="version", version=f"codegraph-voyage {__version__}")
|
|
545
|
+
|
|
546
|
+
# Common options
|
|
547
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
548
|
+
common.add_argument(
|
|
549
|
+
"-p", "--project",
|
|
550
|
+
default=None,
|
|
551
|
+
help="Project root path (default: current dir, walks up for .codegraph/)",
|
|
552
|
+
)
|
|
553
|
+
common.add_argument(
|
|
554
|
+
"--provider",
|
|
555
|
+
default=DEFAULT_PROVIDER,
|
|
556
|
+
choices=["fake", "voyage"],
|
|
557
|
+
help=f"Embedding provider (default: {DEFAULT_PROVIDER})",
|
|
558
|
+
)
|
|
559
|
+
common.add_argument(
|
|
560
|
+
"--model",
|
|
561
|
+
default=DEFAULT_MODEL,
|
|
562
|
+
help=f"Embedding model name (default: {DEFAULT_MODEL})",
|
|
563
|
+
)
|
|
564
|
+
common.add_argument(
|
|
565
|
+
"--dimensions",
|
|
566
|
+
type=int,
|
|
567
|
+
default=DEFAULT_DIMENSIONS,
|
|
568
|
+
help=f"Embedding dimensions (default: {DEFAULT_DIMENSIONS})",
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
572
|
+
|
|
573
|
+
# index
|
|
574
|
+
p_index = sub.add_parser("index", help="Build documents and store embeddings", parents=[common])
|
|
575
|
+
p_index.add_argument(
|
|
576
|
+
"--no-source", action="store_true",
|
|
577
|
+
help="Exclude source lines from documents",
|
|
578
|
+
)
|
|
579
|
+
p_index.add_argument(
|
|
580
|
+
"--max-source-lines", type=int, default=200,
|
|
581
|
+
help="Max source lines per document (default: 200)",
|
|
582
|
+
)
|
|
583
|
+
p_index.add_argument(
|
|
584
|
+
"--kind", default=None,
|
|
585
|
+
help="Comma-separated node kinds to index (e.g. 'function,class')",
|
|
586
|
+
)
|
|
587
|
+
p_index.add_argument(
|
|
588
|
+
"--file-filter", default=None,
|
|
589
|
+
help="Optional file path filter (SQL LIKE pattern)",
|
|
590
|
+
)
|
|
591
|
+
p_index.set_defaults(func=cmd_index)
|
|
592
|
+
|
|
593
|
+
# search
|
|
594
|
+
p_search = sub.add_parser("search", aliases=["semantic_candidates"],
|
|
595
|
+
help="Hybrid semantic search", parents=[common])
|
|
596
|
+
p_search.add_argument("query", help="Search query")
|
|
597
|
+
p_search.add_argument(
|
|
598
|
+
"--top-k", type=int, default=20,
|
|
599
|
+
help="Max results (default: 20)",
|
|
600
|
+
)
|
|
601
|
+
p_search.add_argument(
|
|
602
|
+
"--json", action="store_true",
|
|
603
|
+
help="Output as JSON",
|
|
604
|
+
)
|
|
605
|
+
p_search.add_argument(
|
|
606
|
+
"--no-source", action="store_true",
|
|
607
|
+
)
|
|
608
|
+
p_search.add_argument(
|
|
609
|
+
"--max-source-lines", type=int, default=200,
|
|
610
|
+
)
|
|
611
|
+
p_search.add_argument(
|
|
612
|
+
"--kind", default=None,
|
|
613
|
+
help="Comma-separated node kinds to filter",
|
|
614
|
+
)
|
|
615
|
+
p_search.add_argument(
|
|
616
|
+
"--file-filter", default=None,
|
|
617
|
+
)
|
|
618
|
+
p_search.add_argument(
|
|
619
|
+
"--lexical-weight", type=float, default=0.5,
|
|
620
|
+
help="Weight for lexical RRF contribution (default: 0.5)",
|
|
621
|
+
)
|
|
622
|
+
p_search.add_argument(
|
|
623
|
+
"--vector-weight", type=float, default=0.5,
|
|
624
|
+
help="Weight for vector RRF contribution (default: 0.5)",
|
|
625
|
+
)
|
|
626
|
+
p_search.add_argument(
|
|
627
|
+
"--rrf-k", type=int, default=60,
|
|
628
|
+
help="RRF constant k (default: 60)",
|
|
629
|
+
)
|
|
630
|
+
p_search.set_defaults(func=cmd_search)
|
|
631
|
+
|
|
632
|
+
# status
|
|
633
|
+
p_status = sub.add_parser("status", help="Show sidecar status", parents=[common])
|
|
634
|
+
p_status.set_defaults(func=cmd_status)
|
|
635
|
+
|
|
636
|
+
# explore
|
|
637
|
+
p_explore = sub.add_parser("explore",
|
|
638
|
+
help="Hybrid search + codegraph explore",
|
|
639
|
+
parents=[common])
|
|
640
|
+
p_explore.add_argument("query", help="Search query")
|
|
641
|
+
p_explore.add_argument(
|
|
642
|
+
"--top-k", type=int, default=15,
|
|
643
|
+
help="Max candidates to pass to explore (default: 15)",
|
|
644
|
+
)
|
|
645
|
+
p_explore.add_argument(
|
|
646
|
+
"--max-files", type=int, default=12,
|
|
647
|
+
help="Max files for codegraph explore (default: 12)",
|
|
648
|
+
)
|
|
649
|
+
p_explore.add_argument(
|
|
650
|
+
"--codegraph-bin", default="codegraph",
|
|
651
|
+
help="Path to codegraph binary (default: codegraph)",
|
|
652
|
+
)
|
|
653
|
+
p_explore.add_argument(
|
|
654
|
+
"--timeout", type=int, default=60,
|
|
655
|
+
help="Timeout for codegraph explore (default: 60s)",
|
|
656
|
+
)
|
|
657
|
+
p_explore.add_argument(
|
|
658
|
+
"--dry-run", action="store_true",
|
|
659
|
+
help="Show the explore query without running it",
|
|
660
|
+
)
|
|
661
|
+
p_explore.add_argument(
|
|
662
|
+
"--no-source", action="store_true",
|
|
663
|
+
)
|
|
664
|
+
p_explore.add_argument(
|
|
665
|
+
"--max-source-lines", type=int, default=200,
|
|
666
|
+
)
|
|
667
|
+
p_explore.add_argument(
|
|
668
|
+
"--lexical-weight", type=float, default=0.5,
|
|
669
|
+
)
|
|
670
|
+
p_explore.add_argument(
|
|
671
|
+
"--vector-weight", type=float, default=0.5,
|
|
672
|
+
)
|
|
673
|
+
p_explore.add_argument(
|
|
674
|
+
"--rrf-k", type=int, default=60,
|
|
675
|
+
)
|
|
676
|
+
p_explore.set_defaults(func=cmd_explore)
|
|
677
|
+
|
|
678
|
+
return ap
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def main(argv: list[str] | None = None) -> int:
|
|
682
|
+
ap = _build_parser()
|
|
683
|
+
args = ap.parse_args(argv)
|
|
684
|
+
if hasattr(args, "func"):
|
|
685
|
+
return args.func(args)
|
|
686
|
+
ap.print_help()
|
|
687
|
+
return 1
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
if __name__ == "__main__":
|
|
691
|
+
raise SystemExit(main())
|