descry-codegraph 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.
- descry/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
descry/embeddings.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Optional semantic search with embeddings for descry.
|
|
3
|
+
|
|
4
|
+
Uses sentence-transformers for embedding generation and numpy for similarity.
|
|
5
|
+
Falls back to TF-IDF search if dependencies are not available.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Check if available
|
|
9
|
+
if embeddings_available():
|
|
10
|
+
searcher = SemanticSearcher(graph_path)
|
|
11
|
+
results = searcher.search("authentication logic", limit=10)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import fcntl as _fcntl # Unix only
|
|
27
|
+
except ImportError:
|
|
28
|
+
_fcntl = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextlib.contextmanager
|
|
32
|
+
def _file_lock(lock_path: Path, timeout: float = 600.0):
|
|
33
|
+
"""Cross-process advisory lock.
|
|
34
|
+
|
|
35
|
+
Uses fcntl.flock on Unix; falls back to atomic-create sentinel on
|
|
36
|
+
platforms without fcntl (Windows).
|
|
37
|
+
"""
|
|
38
|
+
if _fcntl is not None:
|
|
39
|
+
with open(lock_path, "w") as f:
|
|
40
|
+
_fcntl.flock(f, _fcntl.LOCK_EX)
|
|
41
|
+
try:
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
_fcntl.flock(f, _fcntl.LOCK_UN)
|
|
45
|
+
try:
|
|
46
|
+
lock_path.unlink()
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Windows fallback: exclusive-create a sentinel file.
|
|
52
|
+
start = time.monotonic()
|
|
53
|
+
while True:
|
|
54
|
+
try:
|
|
55
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
56
|
+
os.close(fd)
|
|
57
|
+
break
|
|
58
|
+
except FileExistsError:
|
|
59
|
+
if time.monotonic() - start > timeout:
|
|
60
|
+
raise TimeoutError(
|
|
61
|
+
f"Timed out waiting for embeddings lock at {lock_path}"
|
|
62
|
+
)
|
|
63
|
+
time.sleep(0.1)
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
try:
|
|
68
|
+
lock_path.unlink()
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
# Try to import embedding dependencies
|
|
76
|
+
try:
|
|
77
|
+
import numpy as np
|
|
78
|
+
from sentence_transformers import SentenceTransformer
|
|
79
|
+
|
|
80
|
+
EMBEDDINGS_AVAILABLE = True
|
|
81
|
+
except ImportError:
|
|
82
|
+
EMBEDDINGS_AVAILABLE = False
|
|
83
|
+
np = None
|
|
84
|
+
SentenceTransformer = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def embeddings_available() -> bool:
|
|
88
|
+
"""Check if embedding dependencies are available."""
|
|
89
|
+
return EMBEDDINGS_AVAILABLE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_sentence_transformer(
|
|
93
|
+
model_name: str,
|
|
94
|
+
revision: str | None = None,
|
|
95
|
+
trust_remote_code: bool | None = None,
|
|
96
|
+
):
|
|
97
|
+
"""Construct a SentenceTransformer with explicit trust/revision semantics.
|
|
98
|
+
|
|
99
|
+
The default model (Jina code embeddings) requires remote-code loading; its
|
|
100
|
+
revision is pinned for supply-chain integrity. User-supplied models (from
|
|
101
|
+
`.descry.toml` [embeddings] model) default to trust_remote_code=False.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
model_name: Model repo id or local path.
|
|
105
|
+
revision: Pinned git sha for HF downloads; defaults to the pinned
|
|
106
|
+
DEFAULT_MODEL_REVISION when model_name matches MODEL_NAME.
|
|
107
|
+
trust_remote_code: Whether to allow model-provided Python code.
|
|
108
|
+
Defaults to True only for the bundled default model; False for
|
|
109
|
+
all user-supplied models.
|
|
110
|
+
"""
|
|
111
|
+
if trust_remote_code is None:
|
|
112
|
+
trust_remote_code = model_name == SemanticSearcher.MODEL_NAME
|
|
113
|
+
if revision is None and model_name == SemanticSearcher.MODEL_NAME:
|
|
114
|
+
revision = SemanticSearcher.DEFAULT_MODEL_REVISION
|
|
115
|
+
kwargs: dict = {"trust_remote_code": trust_remote_code}
|
|
116
|
+
if revision:
|
|
117
|
+
kwargs["revision"] = revision
|
|
118
|
+
return SentenceTransformer(model_name, **kwargs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SemanticSearcher:
|
|
122
|
+
"""Semantic search using sentence embeddings.
|
|
123
|
+
|
|
124
|
+
Generates embeddings for node names and docstrings, then uses
|
|
125
|
+
cosine similarity for semantic search.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
# Code-optimized embedding model (896-dim, 494M params)
|
|
129
|
+
# Significantly better code search quality than general-purpose models.
|
|
130
|
+
# This model requires `trust_remote_code=True`; revision is pinned for
|
|
131
|
+
# supply-chain integrity (A.3 Option B).
|
|
132
|
+
MODEL_NAME = "jinaai/jina-code-embeddings-0.5b"
|
|
133
|
+
# Pinned HF revision (git sha) for the default model. Update intentionally
|
|
134
|
+
# when upgrading; cache auto-invalidates via model-name hash in cache key.
|
|
135
|
+
DEFAULT_MODEL_REVISION = "4db235132dafbe56a8b9c5f59b59795ecf58a4a7"
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
graph_path: str,
|
|
140
|
+
cache_dir: Optional[str] = None,
|
|
141
|
+
force_rebuild: bool = False,
|
|
142
|
+
model_name: str | None = None,
|
|
143
|
+
):
|
|
144
|
+
"""Initialize the semantic searcher.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
graph_path: Path to codebase_graph.json
|
|
148
|
+
cache_dir: Optional directory for caching embeddings
|
|
149
|
+
force_rebuild: Force regeneration of embeddings even if cache exists
|
|
150
|
+
"""
|
|
151
|
+
self.model_name = model_name or self.MODEL_NAME
|
|
152
|
+
|
|
153
|
+
if not EMBEDDINGS_AVAILABLE:
|
|
154
|
+
raise ImportError(
|
|
155
|
+
"Embeddings require sentence-transformers and numpy. "
|
|
156
|
+
"Install with: pip install descry-codegraph[embeddings]"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Resolve to absolute path to avoid nested directory issues when CWD is inside cache
|
|
160
|
+
self.graph_path = Path(graph_path).resolve()
|
|
161
|
+
if cache_dir:
|
|
162
|
+
self.cache_dir = Path(cache_dir).resolve()
|
|
163
|
+
elif self.graph_path.parent.name == ".descry_cache":
|
|
164
|
+
# Graph is already in cache dir, use it directly
|
|
165
|
+
self.cache_dir = self.graph_path.parent
|
|
166
|
+
else:
|
|
167
|
+
self.cache_dir = self.graph_path.parent / ".descry_cache"
|
|
168
|
+
|
|
169
|
+
# Load graph (B.6: schema-checked)
|
|
170
|
+
from descry._graph import load_graph_with_schema
|
|
171
|
+
|
|
172
|
+
self.data = load_graph_with_schema(self.graph_path)
|
|
173
|
+
self.nodes = self.data["nodes"]
|
|
174
|
+
|
|
175
|
+
# Load or create embeddings
|
|
176
|
+
self.model = None
|
|
177
|
+
self.embeddings = None
|
|
178
|
+
self.node_texts = None
|
|
179
|
+
self._load_or_create_embeddings(force_rebuild=force_rebuild)
|
|
180
|
+
|
|
181
|
+
def _cache_key(self) -> str:
|
|
182
|
+
"""Compute content-addressed cache key.
|
|
183
|
+
|
|
184
|
+
Composition: int(mtime) + sha256[:16] of graph bytes + sha256[:8] of
|
|
185
|
+
model name. Model-hash component ensures A.3 model swap auto-invalidates
|
|
186
|
+
old caches.
|
|
187
|
+
"""
|
|
188
|
+
graph_mtime = int(self.graph_path.stat().st_mtime)
|
|
189
|
+
graph_hash = hashlib.sha256(self.graph_path.read_bytes()).hexdigest()[:16]
|
|
190
|
+
model_hash = hashlib.sha256(self.model_name.encode("utf-8")).hexdigest()[:8]
|
|
191
|
+
return f"{graph_mtime}_{graph_hash}_{model_hash}"
|
|
192
|
+
|
|
193
|
+
def _cache_paths(self) -> tuple[Path, Path]:
|
|
194
|
+
"""Return (npz_path, json_sidecar_path) for the current cache key."""
|
|
195
|
+
key = self._cache_key()
|
|
196
|
+
return (
|
|
197
|
+
self.cache_dir / f"embeddings_{key}.npz",
|
|
198
|
+
self.cache_dir / f"embeddings_{key}.json",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _cleanup_old_embeddings(self, keep: set[Path]):
|
|
202
|
+
"""Remove embedding cache files not in the keep set."""
|
|
203
|
+
try:
|
|
204
|
+
old_files = [
|
|
205
|
+
f for f in self.cache_dir.glob("embeddings_*.npz") if f not in keep
|
|
206
|
+
] + [f for f in self.cache_dir.glob("embeddings_*.json") if f not in keep]
|
|
207
|
+
for old_file in old_files:
|
|
208
|
+
logger.info(f"Removing stale embedding cache: {old_file.name}")
|
|
209
|
+
old_file.unlink()
|
|
210
|
+
if old_files:
|
|
211
|
+
logger.info(f"Cleaned up {len(old_files)} old embedding file(s)")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning(f"Failed to cleanup old embeddings: {e}")
|
|
214
|
+
|
|
215
|
+
def _atomic_save(self, npz_path: Path, json_path: Path) -> None:
|
|
216
|
+
"""Write .npz + JSON sidecar atomically (tmp then rename).
|
|
217
|
+
|
|
218
|
+
Note: np.savez appends '.npz' to paths that don't end in '.npz'.
|
|
219
|
+
We write to '<final>.tmp.npz' (which numpy preserves because the
|
|
220
|
+
suffix is already .npz) and then rename to the final path.
|
|
221
|
+
"""
|
|
222
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
npz_tmp = npz_path.with_suffix(".tmp.npz")
|
|
224
|
+
json_tmp = json_path.with_suffix(".tmp.json")
|
|
225
|
+
cleanup = [npz_tmp, json_tmp]
|
|
226
|
+
try:
|
|
227
|
+
np.savez(npz_tmp, embeddings=self.embeddings)
|
|
228
|
+
with open(json_tmp, "w") as f:
|
|
229
|
+
json.dump({"texts": self.node_texts}, f)
|
|
230
|
+
os.replace(npz_tmp, npz_path)
|
|
231
|
+
os.replace(json_tmp, json_path)
|
|
232
|
+
finally:
|
|
233
|
+
for p in cleanup:
|
|
234
|
+
if p.exists():
|
|
235
|
+
try:
|
|
236
|
+
p.unlink()
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def _load_or_create_embeddings(self, force_rebuild: bool = False):
|
|
241
|
+
"""Load embeddings from cache or create new ones.
|
|
242
|
+
|
|
243
|
+
Serialized via a cache-dir lockfile so concurrent processes (e.g.
|
|
244
|
+
descry-mcp + descry-web both starting on a cold cache) don't each
|
|
245
|
+
load the model and write to the same files.
|
|
246
|
+
"""
|
|
247
|
+
npz_path, json_path = self._cache_paths()
|
|
248
|
+
|
|
249
|
+
# Fast path: cache is hot, no lock needed.
|
|
250
|
+
if (
|
|
251
|
+
not force_rebuild
|
|
252
|
+
and npz_path.exists()
|
|
253
|
+
and json_path.exists()
|
|
254
|
+
and self._try_load_cache(npz_path, json_path)
|
|
255
|
+
):
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Cold path: acquire a cross-process lock, recheck, then generate.
|
|
259
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
lock_path = self.cache_dir / "embeddings.lock"
|
|
261
|
+
with _file_lock(lock_path):
|
|
262
|
+
if (
|
|
263
|
+
not force_rebuild
|
|
264
|
+
and npz_path.exists()
|
|
265
|
+
and json_path.exists()
|
|
266
|
+
and self._try_load_cache(npz_path, json_path)
|
|
267
|
+
):
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
self._generate_embeddings()
|
|
271
|
+
self._atomic_save(npz_path, json_path)
|
|
272
|
+
logger.info(f"Cached {len(self.node_texts)} embeddings to {npz_path}")
|
|
273
|
+
self._cleanup_old_embeddings(keep={npz_path, json_path})
|
|
274
|
+
|
|
275
|
+
def _try_load_cache(self, npz_path: Path, json_path: Path) -> bool:
|
|
276
|
+
"""Attempt to load embeddings from cache. Returns True on success."""
|
|
277
|
+
try:
|
|
278
|
+
data = np.load(npz_path, allow_pickle=False)
|
|
279
|
+
embeddings = data["embeddings"]
|
|
280
|
+
with open(json_path) as f:
|
|
281
|
+
sidecar = json.load(f)
|
|
282
|
+
texts = sidecar["texts"]
|
|
283
|
+
# Consistency check: an interrupted _atomic_save (or two racing
|
|
284
|
+
# writers) could leave a mismatched pair on disk. Refuse to load
|
|
285
|
+
# it — the cache will regenerate and overwrite with a consistent
|
|
286
|
+
# pair. Cheaper than silently serving wrong embeddings.
|
|
287
|
+
if len(texts) != embeddings.shape[0]:
|
|
288
|
+
logger.warning(
|
|
289
|
+
"Embedding cache mismatch (%d texts vs %d embeddings); regenerating",
|
|
290
|
+
len(texts),
|
|
291
|
+
embeddings.shape[0],
|
|
292
|
+
)
|
|
293
|
+
return False
|
|
294
|
+
self.embeddings = embeddings
|
|
295
|
+
self.node_texts = texts
|
|
296
|
+
logger.info(f"Loaded {len(self.node_texts)} embeddings from cache")
|
|
297
|
+
self._cleanup_old_embeddings(keep={npz_path, json_path})
|
|
298
|
+
return True
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.warning(f"Cache load failed: {e}, regenerating...")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
def _generate_embeddings(self):
|
|
304
|
+
"""Generate weighted composite embeddings for all nodes.
|
|
305
|
+
|
|
306
|
+
Uses separate embeddings for name, signature, and docstring with
|
|
307
|
+
weighted combination. This improves semantic search by:
|
|
308
|
+
- Prioritizing docstring content for meaning-based queries
|
|
309
|
+
- Still matching on symbol names and signatures
|
|
310
|
+
- Including caller context for disambiguation
|
|
311
|
+
|
|
312
|
+
Weights: name=0.2, signature=0.3, docstring=0.5
|
|
313
|
+
"""
|
|
314
|
+
logger.info("Loading embedding model...")
|
|
315
|
+
self.model = _load_sentence_transformer(self.model_name)
|
|
316
|
+
|
|
317
|
+
# Collect texts for each component
|
|
318
|
+
names = []
|
|
319
|
+
signatures = []
|
|
320
|
+
docstrings = []
|
|
321
|
+
self.node_texts = [] # Combined text for cache identification
|
|
322
|
+
|
|
323
|
+
for node in self.nodes:
|
|
324
|
+
meta = node.get("metadata", {})
|
|
325
|
+
|
|
326
|
+
name = meta.get("name", "") or node["id"].split("::")[-1]
|
|
327
|
+
sig = meta.get("signature", "")
|
|
328
|
+
doc = meta.get("docstring", "")[:500]
|
|
329
|
+
|
|
330
|
+
names.append(name)
|
|
331
|
+
signatures.append(sig if sig else name) # Fall back to name if no signature
|
|
332
|
+
docstrings.append(doc if doc else name) # Fall back to name if no docstring
|
|
333
|
+
|
|
334
|
+
# Combined text for cache identification
|
|
335
|
+
combined = f"{name} {sig} {doc}".strip()
|
|
336
|
+
self.node_texts.append(combined if combined else node["id"])
|
|
337
|
+
|
|
338
|
+
logger.info(
|
|
339
|
+
f"Generating weighted embeddings for {len(self.node_texts)} nodes..."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Generate embeddings for each component
|
|
343
|
+
# batch_size keeps memory usage reasonable
|
|
344
|
+
logger.info(" Encoding names...")
|
|
345
|
+
name_embeddings = self.model.encode(
|
|
346
|
+
names, show_progress_bar=False, convert_to_numpy=True
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
logger.info(" Encoding signatures...")
|
|
350
|
+
sig_embeddings = self.model.encode(
|
|
351
|
+
signatures, show_progress_bar=False, convert_to_numpy=True
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
logger.info(" Encoding docstrings...")
|
|
355
|
+
doc_embeddings = self.model.encode(
|
|
356
|
+
docstrings, show_progress_bar=False, convert_to_numpy=True
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Weighted combination: name=0.2, signature=0.3, docstring=0.5
|
|
360
|
+
# Docstring gets highest weight for semantic/meaning-based queries
|
|
361
|
+
logger.info(" Computing weighted composites...")
|
|
362
|
+
self.embeddings = (
|
|
363
|
+
0.2 * name_embeddings + 0.3 * sig_embeddings + 0.5 * doc_embeddings
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Normalize the combined embeddings for consistent cosine similarity
|
|
367
|
+
norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
|
|
368
|
+
norms = np.where(norms == 0, 1, norms) # Avoid division by zero
|
|
369
|
+
self.embeddings = self.embeddings / norms
|
|
370
|
+
|
|
371
|
+
logger.info("Weighted embeddings generated successfully")
|
|
372
|
+
|
|
373
|
+
def search(self, query: str, limit: int = 10, min_score: float = 0.3) -> list:
|
|
374
|
+
"""Search for nodes semantically similar to the query.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
query: Natural language search query
|
|
378
|
+
limit: Maximum number of results
|
|
379
|
+
min_score: Minimum cosine similarity score (0-1)
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
List of (node, score) tuples sorted by relevance
|
|
383
|
+
|
|
384
|
+
Scoring includes:
|
|
385
|
+
- Cosine similarity (primary)
|
|
386
|
+
- In-degree boost (symbols with more callers ranked higher)
|
|
387
|
+
- Type preference (Functions/Classes over Constants)
|
|
388
|
+
"""
|
|
389
|
+
import math
|
|
390
|
+
|
|
391
|
+
if self.model is None:
|
|
392
|
+
self.model = _load_sentence_transformer(self.model_name)
|
|
393
|
+
|
|
394
|
+
# Encode query
|
|
395
|
+
query_embedding = self.model.encode([query], convert_to_numpy=True)[0]
|
|
396
|
+
|
|
397
|
+
# Compute cosine similarities
|
|
398
|
+
similarities = np.dot(self.embeddings, query_embedding) / (
|
|
399
|
+
np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_embedding)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Get candidates above threshold
|
|
403
|
+
top_indices = np.argsort(similarities)[::-1]
|
|
404
|
+
candidates = []
|
|
405
|
+
for idx in top_indices:
|
|
406
|
+
base_score = float(similarities[idx])
|
|
407
|
+
if base_score < min_score:
|
|
408
|
+
break
|
|
409
|
+
# Collect more candidates than needed for re-ranking
|
|
410
|
+
if len(candidates) >= limit * 3:
|
|
411
|
+
break
|
|
412
|
+
candidates.append((idx, base_score))
|
|
413
|
+
|
|
414
|
+
# Re-rank with in-degree boost and type preference
|
|
415
|
+
results = []
|
|
416
|
+
for idx, base_score in candidates:
|
|
417
|
+
node = self.nodes[idx]
|
|
418
|
+
meta = node.get("metadata", {})
|
|
419
|
+
|
|
420
|
+
# In-degree boost: log scaling to prevent domination by very popular symbols
|
|
421
|
+
in_degree = meta.get("in_degree", 0)
|
|
422
|
+
in_degree_boost = 0.0
|
|
423
|
+
if in_degree > 0:
|
|
424
|
+
# Boost ranges from 0 to ~0.15 for nodes with 1-100+ callers
|
|
425
|
+
in_degree_boost = min(0.15, math.log(1 + in_degree) * 0.03)
|
|
426
|
+
|
|
427
|
+
# Type preference: small tie-breaker
|
|
428
|
+
node_type = node.get("type", "")
|
|
429
|
+
type_boost = {
|
|
430
|
+
"Function": 0.02,
|
|
431
|
+
"Method": 0.02,
|
|
432
|
+
"Class": 0.015,
|
|
433
|
+
"Constant": 0.005,
|
|
434
|
+
"File": 0.0,
|
|
435
|
+
}.get(node_type, 0.01)
|
|
436
|
+
|
|
437
|
+
final_score = base_score + in_degree_boost + type_boost
|
|
438
|
+
results.append((node, final_score))
|
|
439
|
+
|
|
440
|
+
# Sort by final score and return top results
|
|
441
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
442
|
+
return results[:limit]
|
|
443
|
+
|
|
444
|
+
def _find_similar(self, node_id: str, limit: int = 5) -> list:
|
|
445
|
+
"""Find nodes semantically similar to a given node.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
node_id: The node ID to find similar nodes for
|
|
449
|
+
limit: Maximum number of results
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
List of (node, score) tuples sorted by similarity
|
|
453
|
+
"""
|
|
454
|
+
# Find the node index
|
|
455
|
+
node_idx = None
|
|
456
|
+
for i, node in enumerate(self.nodes):
|
|
457
|
+
if node["id"] == node_id:
|
|
458
|
+
node_idx = i
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
if node_idx is None:
|
|
462
|
+
return []
|
|
463
|
+
|
|
464
|
+
# Use that node's embedding as query
|
|
465
|
+
node_embedding = self.embeddings[node_idx]
|
|
466
|
+
|
|
467
|
+
# Compute similarities
|
|
468
|
+
similarities = np.dot(self.embeddings, node_embedding) / (
|
|
469
|
+
np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(node_embedding)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Get top results (excluding the query node itself)
|
|
473
|
+
top_indices = np.argsort(similarities)[::-1]
|
|
474
|
+
results = []
|
|
475
|
+
for idx in top_indices:
|
|
476
|
+
if idx == node_idx:
|
|
477
|
+
continue
|
|
478
|
+
score = float(similarities[idx])
|
|
479
|
+
results.append((self.nodes[idx], score))
|
|
480
|
+
if len(results) >= limit:
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
return results
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# Convenience function for one-off searches (with thread safety)
|
|
487
|
+
_cached_searcher = None
|
|
488
|
+
_searcher_lock = threading.Lock()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _semantic_search(
|
|
492
|
+
query: str,
|
|
493
|
+
graph_path: str = ".descry_cache/codebase_graph.json",
|
|
494
|
+
limit: int = 10,
|
|
495
|
+
) -> list:
|
|
496
|
+
"""Convenience function for semantic search (thread-safe).
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
query: Natural language search query
|
|
500
|
+
graph_path: Path to graph file
|
|
501
|
+
limit: Maximum results
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
List of (node, score) tuples
|
|
505
|
+
"""
|
|
506
|
+
global _cached_searcher
|
|
507
|
+
|
|
508
|
+
if not EMBEDDINGS_AVAILABLE:
|
|
509
|
+
logger.warning("Embeddings not available, falling back to keyword search")
|
|
510
|
+
return []
|
|
511
|
+
|
|
512
|
+
with _searcher_lock:
|
|
513
|
+
if _cached_searcher is None or str(_cached_searcher.graph_path) != graph_path:
|
|
514
|
+
_cached_searcher = SemanticSearcher(graph_path)
|
|
515
|
+
searcher = _cached_searcher
|
|
516
|
+
|
|
517
|
+
return searcher.search(query, limit=limit)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def get_embeddings_status(
|
|
521
|
+
graph_path: str = ".descry_cache/codebase_graph.json",
|
|
522
|
+
) -> dict:
|
|
523
|
+
"""Get embeddings status for diagnostics.
|
|
524
|
+
|
|
525
|
+
Reports whether a cache exists for the current graph and counts node
|
|
526
|
+
texts via the JSON sidecar (no np.load of user-controlled .npz).
|
|
527
|
+
"""
|
|
528
|
+
graph_path = Path(graph_path).resolve()
|
|
529
|
+
cache_dir = graph_path.parent
|
|
530
|
+
|
|
531
|
+
status = {
|
|
532
|
+
"available": EMBEDDINGS_AVAILABLE,
|
|
533
|
+
"cached": False,
|
|
534
|
+
"node_count": 0,
|
|
535
|
+
"stale": False,
|
|
536
|
+
"cache_path": None,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if not EMBEDDINGS_AVAILABLE or not graph_path.exists():
|
|
540
|
+
return status
|
|
541
|
+
|
|
542
|
+
# Any embedding file (npz + matching json sidecar) counts; report the
|
|
543
|
+
# most recent pair. No np.load — just read the JSON sidecar for count.
|
|
544
|
+
npz_files = sorted(
|
|
545
|
+
cache_dir.glob("embeddings_*.npz"),
|
|
546
|
+
key=lambda p: p.stat().st_mtime,
|
|
547
|
+
reverse=True,
|
|
548
|
+
)
|
|
549
|
+
for npz_file in npz_files:
|
|
550
|
+
sidecar = npz_file.with_suffix(".json")
|
|
551
|
+
if sidecar.exists():
|
|
552
|
+
status["cached"] = True
|
|
553
|
+
status["cache_path"] = str(npz_file)
|
|
554
|
+
try:
|
|
555
|
+
with open(sidecar) as f:
|
|
556
|
+
texts = json.load(f).get("texts", [])
|
|
557
|
+
status["node_count"] = len(texts)
|
|
558
|
+
except Exception:
|
|
559
|
+
pass
|
|
560
|
+
return status
|
|
561
|
+
|
|
562
|
+
# Fallback: orphan npz (no sidecar) is stale
|
|
563
|
+
if npz_files:
|
|
564
|
+
status["stale"] = True
|
|
565
|
+
status["cache_path"] = str(npz_files[0])
|
|
566
|
+
|
|
567
|
+
return status
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
if __name__ == "__main__":
|
|
571
|
+
# Test the embeddings module
|
|
572
|
+
import sys
|
|
573
|
+
|
|
574
|
+
if not EMBEDDINGS_AVAILABLE:
|
|
575
|
+
print("Embeddings not available. Install with:")
|
|
576
|
+
print(" pip install descry-codegraph[embeddings]")
|
|
577
|
+
sys.exit(1)
|
|
578
|
+
|
|
579
|
+
if len(sys.argv) < 2:
|
|
580
|
+
print("Usage: python embeddings.py <query>")
|
|
581
|
+
sys.exit(1)
|
|
582
|
+
|
|
583
|
+
query = " ".join(sys.argv[1:])
|
|
584
|
+
results = _semantic_search(query)
|
|
585
|
+
|
|
586
|
+
print(f"\nSemantic search results for: '{query}'")
|
|
587
|
+
print("-" * 50)
|
|
588
|
+
for node, score in results:
|
|
589
|
+
meta = node.get("metadata", {})
|
|
590
|
+
print(f"[{score:.3f}] [{node['type'][:3]}] {meta.get('name', node['id'])}")
|
|
591
|
+
if meta.get("docstring"):
|
|
592
|
+
doc = meta["docstring"].split("\n")[0][:60]
|
|
593
|
+
print(f" {doc}...")
|