pycode-kg 0.16.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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/index.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
index.py
|
|
4
|
+
|
|
5
|
+
SemanticIndex — LanceDB vector index for the Code Knowledge Graph.
|
|
6
|
+
|
|
7
|
+
Derived from SQLite; disposable and rebuildable at any time.
|
|
8
|
+
SQLite (GraphStore) remains the authoritative source of truth.
|
|
9
|
+
|
|
10
|
+
Author: Eric G. Suchanek, PhD
|
|
11
|
+
Last Revision: 2026-03-13 22:53:21
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Local model cache
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _local_model_path(model_name: str) -> Path:
|
|
34
|
+
"""Return the local cache path for *model_name*.
|
|
35
|
+
|
|
36
|
+
Defaults to ``.pycodekg/models/<model>`` under the current working directory
|
|
37
|
+
so the cache lives alongside the rest of the PyCodeKG artefacts.
|
|
38
|
+
Override via the ``PYCODEKG_MODEL_DIR`` environment variable.
|
|
39
|
+
|
|
40
|
+
Slashes in the model name (e.g. ``org/model``) are replaced with ``--``
|
|
41
|
+
so the path is always a single directory level under the cache root.
|
|
42
|
+
|
|
43
|
+
:param model_name: HuggingFace model identifier or short name.
|
|
44
|
+
:return: Absolute :class:`~pathlib.Path` to the cached model directory.
|
|
45
|
+
"""
|
|
46
|
+
import os # pylint: disable=import-outside-toplevel
|
|
47
|
+
|
|
48
|
+
default = str(Path.cwd() / ".pycodekg" / "models")
|
|
49
|
+
cache_root = Path(os.environ.get("PYCODEKG_MODEL_DIR", default))
|
|
50
|
+
safe_name = model_name.replace("/", "--")
|
|
51
|
+
return cache_root / safe_name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from pycode_kg.store import GraphStore
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Logging / progress suppression
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def suppress_ingestion_logging() -> None:
|
|
63
|
+
"""Suppress verbose progress output during model loading and ingestion.
|
|
64
|
+
|
|
65
|
+
Suppresses Python logging, transformers/HuggingFace verbosity, and all
|
|
66
|
+
tqdm progress bars (including sentence_transformers' internal "Batches:"
|
|
67
|
+
bars) for the lifetime of the process.
|
|
68
|
+
"""
|
|
69
|
+
for name in (
|
|
70
|
+
"sentence_transformers",
|
|
71
|
+
"transformers",
|
|
72
|
+
"huggingface_hub",
|
|
73
|
+
"lancedb",
|
|
74
|
+
"pylance",
|
|
75
|
+
):
|
|
76
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import transformers # pylint: disable=import-outside-toplevel
|
|
80
|
+
|
|
81
|
+
transformers.logging.set_verbosity_error()
|
|
82
|
+
except (ImportError, AttributeError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# tqdm checks TQDM_DISABLE at instantiation time, so setting it here
|
|
86
|
+
# silences all subclasses regardless of when they were imported.
|
|
87
|
+
os.environ["TQDM_DISABLE"] = "1"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Embedder interface (pluggable)
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Embedder:
|
|
96
|
+
"""
|
|
97
|
+
Abstract embedding backend.
|
|
98
|
+
|
|
99
|
+
Subclass and implement :meth:`embed_texts` to plug in any model.
|
|
100
|
+
|
|
101
|
+
:param dim: Embedding dimension (must be set by subclass ``__init__``).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
dim: int
|
|
105
|
+
|
|
106
|
+
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
|
107
|
+
"""
|
|
108
|
+
Embed a list of strings.
|
|
109
|
+
|
|
110
|
+
:param texts: Input strings.
|
|
111
|
+
:return: List of float32 vectors, one per input.
|
|
112
|
+
"""
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
|
|
115
|
+
def embed_query(self, query: str) -> list[float]:
|
|
116
|
+
"""
|
|
117
|
+
Embed a single query string.
|
|
118
|
+
|
|
119
|
+
Default implementation calls :meth:`embed_texts` with a one-element list.
|
|
120
|
+
|
|
121
|
+
:param query: Query string.
|
|
122
|
+
:return: Float32 vector.
|
|
123
|
+
"""
|
|
124
|
+
return self.embed_texts([query])[0]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SentenceTransformerEmbedder(Embedder):
|
|
128
|
+
"""
|
|
129
|
+
Local embedding via ``sentence-transformers``.
|
|
130
|
+
|
|
131
|
+
:param model_name: HuggingFace model name or local path.
|
|
132
|
+
Defaults to :data:`~pycode_kg.pycodekg.DEFAULT_MODEL`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
|
|
136
|
+
"""Load the sentence-transformer model.
|
|
137
|
+
|
|
138
|
+
:param model_name: HuggingFace model name or local path.
|
|
139
|
+
"""
|
|
140
|
+
import os # pylint: disable=import-outside-toplevel
|
|
141
|
+
|
|
142
|
+
from sentence_transformers import ( # pylint: disable=import-outside-toplevel
|
|
143
|
+
SentenceTransformer,
|
|
144
|
+
)
|
|
145
|
+
from transformers import logging as hf_logging # pylint: disable=import-outside-toplevel
|
|
146
|
+
|
|
147
|
+
hf_logging.set_verbosity_error()
|
|
148
|
+
|
|
149
|
+
local_path = _local_model_path(model_name)
|
|
150
|
+
_prev_tqdm = os.environ.get("TQDM_DISABLE")
|
|
151
|
+
os.environ["TQDM_DISABLE"] = "1"
|
|
152
|
+
try:
|
|
153
|
+
if local_path.exists():
|
|
154
|
+
self.model = SentenceTransformer(str(local_path), trust_remote_code=True)
|
|
155
|
+
else:
|
|
156
|
+
try:
|
|
157
|
+
self.model = SentenceTransformer(
|
|
158
|
+
model_name, local_files_only=True, trust_remote_code=True
|
|
159
|
+
)
|
|
160
|
+
except OSError:
|
|
161
|
+
self.model = SentenceTransformer(model_name, trust_remote_code=True)
|
|
162
|
+
finally:
|
|
163
|
+
if _prev_tqdm is None:
|
|
164
|
+
os.environ.pop("TQDM_DISABLE", None)
|
|
165
|
+
else:
|
|
166
|
+
os.environ["TQDM_DISABLE"] = _prev_tqdm
|
|
167
|
+
self.model_name = model_name
|
|
168
|
+
self.dim: int = self.model.get_embedding_dimension() or 384
|
|
169
|
+
# Detect task-prompt support (e.g. nomic-embed-text-v1.5).
|
|
170
|
+
# sentence-transformers exposes model.prompts as a dict of name→prefix.
|
|
171
|
+
_prompts: dict = getattr(self.model, "prompts", {}) or {}
|
|
172
|
+
self._query_prompt: str | None = "search_query" if "search_query" in _prompts else None
|
|
173
|
+
self._doc_prompt: str | None = "search_document" if "search_document" in _prompts else None
|
|
174
|
+
|
|
175
|
+
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
|
176
|
+
"""Embed a list of strings into float32 vectors.
|
|
177
|
+
|
|
178
|
+
Uses ``search_document`` task prompt when the model supports it
|
|
179
|
+
(e.g. ``nomic-ai/nomic-embed-text-v1.5``).
|
|
180
|
+
|
|
181
|
+
:param texts: Input strings to embed.
|
|
182
|
+
:return: List of float32 vectors, one per input string.
|
|
183
|
+
"""
|
|
184
|
+
kwargs: dict = {"normalize_embeddings": True, "show_progress_bar": False}
|
|
185
|
+
if self._doc_prompt:
|
|
186
|
+
kwargs["prompt_name"] = self._doc_prompt
|
|
187
|
+
vecs = self.model.encode(texts, **kwargs)
|
|
188
|
+
return [np.asarray(v, dtype="float32").tolist() for v in vecs]
|
|
189
|
+
|
|
190
|
+
def embed_query(self, query: str) -> list[float]:
|
|
191
|
+
"""Embed a single query string into a float32 vector.
|
|
192
|
+
|
|
193
|
+
Uses ``search_query`` task prompt when the model supports it
|
|
194
|
+
(e.g. ``nomic-ai/nomic-embed-text-v1.5``).
|
|
195
|
+
|
|
196
|
+
:param query: Query string to embed.
|
|
197
|
+
:return: Float32 vector representation of the query.
|
|
198
|
+
"""
|
|
199
|
+
kwargs: dict = {"normalize_embeddings": True}
|
|
200
|
+
if self._query_prompt:
|
|
201
|
+
kwargs["prompt_name"] = self._query_prompt
|
|
202
|
+
vec = self.model.encode([query], **kwargs)[0]
|
|
203
|
+
return np.asarray(vec, dtype="float32").tolist()
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
"""Return a developer-readable representation of this embedder.
|
|
207
|
+
|
|
208
|
+
:return: String of the form ``SentenceTransformerEmbedder(model=..., dim=...)``.
|
|
209
|
+
"""
|
|
210
|
+
return f"SentenceTransformerEmbedder(model={self.model_name!r}, dim={self.dim})"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Seed hit returned by SemanticIndex.search()
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class SeedHit:
|
|
220
|
+
"""
|
|
221
|
+
A single result from a semantic vector search.
|
|
222
|
+
|
|
223
|
+
:param id: Node ID.
|
|
224
|
+
:param kind: Node kind (``module``, ``class``, ``function``, ``method``).
|
|
225
|
+
:param name: Short name.
|
|
226
|
+
:param qualname: Qualified name.
|
|
227
|
+
:param module_path: Repo-relative module path.
|
|
228
|
+
:param distance: Vector distance (lower = more similar).
|
|
229
|
+
:param rank: Zero-based rank in the result list.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
id: str
|
|
233
|
+
kind: str
|
|
234
|
+
name: str
|
|
235
|
+
qualname: str
|
|
236
|
+
module_path: str
|
|
237
|
+
distance: float
|
|
238
|
+
rank: int
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# SemanticIndex
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
_DEFAULT_TABLE = "pycodekg_nodes"
|
|
246
|
+
_DEFAULT_KINDS = ("module", "class", "function", "method")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class SemanticIndex:
|
|
250
|
+
"""
|
|
251
|
+
LanceDB-backed semantic vector index for the Code Knowledge Graph.
|
|
252
|
+
|
|
253
|
+
Reads nodes from a :class:`~pycode_kg.store.GraphStore` (via its SQLite
|
|
254
|
+
database), embeds them, and stores the vectors in LanceDB. The index
|
|
255
|
+
is **derived and disposable** — it can be rebuilt from SQLite at any
|
|
256
|
+
time without data loss.
|
|
257
|
+
|
|
258
|
+
Example::
|
|
259
|
+
|
|
260
|
+
embedder = SentenceTransformerEmbedder()
|
|
261
|
+
idx = SemanticIndex("./lancedb", embedder=embedder)
|
|
262
|
+
idx.build(store, wipe=True)
|
|
263
|
+
|
|
264
|
+
hits = idx.search("database connection setup", k=8)
|
|
265
|
+
for h in hits:
|
|
266
|
+
print(h.id, h.distance)
|
|
267
|
+
|
|
268
|
+
:param lancedb_dir: Directory for the LanceDB database.
|
|
269
|
+
:param embedder: Embedding backend. Defaults to
|
|
270
|
+
:class:`SentenceTransformerEmbedder` with
|
|
271
|
+
:data:`~pycode_kg.pycodekg.DEFAULT_MODEL`.
|
|
272
|
+
:param table: LanceDB table name. Defaults to ``"pycodekg_nodes"``.
|
|
273
|
+
:param index_kinds: Node kinds to embed.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
lancedb_dir: str | Path,
|
|
279
|
+
*,
|
|
280
|
+
embedder: Embedder | None = None,
|
|
281
|
+
table: str = _DEFAULT_TABLE,
|
|
282
|
+
index_kinds: Sequence[str] = _DEFAULT_KINDS,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Initialise the semantic index.
|
|
285
|
+
|
|
286
|
+
:param lancedb_dir: Directory for the LanceDB database.
|
|
287
|
+
:param embedder: Embedding backend. Defaults to :class:`SentenceTransformerEmbedder`.
|
|
288
|
+
:param table: LanceDB table name. Defaults to ``"pycodekg_nodes"``.
|
|
289
|
+
:param index_kinds: Node kinds to include in the index.
|
|
290
|
+
"""
|
|
291
|
+
self.lancedb_dir = Path(lancedb_dir)
|
|
292
|
+
self.embedder: Embedder = embedder or SentenceTransformerEmbedder()
|
|
293
|
+
self.table_name = table
|
|
294
|
+
self.index_kinds = tuple(index_kinds)
|
|
295
|
+
self._tbl = None # lazy LanceDB table handle
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
# Build
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def build(
|
|
302
|
+
self,
|
|
303
|
+
store: GraphStore,
|
|
304
|
+
*,
|
|
305
|
+
wipe: bool = False,
|
|
306
|
+
batch_size: int = 256,
|
|
307
|
+
quiet: bool = True,
|
|
308
|
+
) -> dict:
|
|
309
|
+
"""
|
|
310
|
+
Build (or rebuild) the vector index from *store*.
|
|
311
|
+
|
|
312
|
+
:param store: Authoritative :class:`~pycode_kg.store.GraphStore`.
|
|
313
|
+
:param wipe: If ``True``, delete all existing vectors first.
|
|
314
|
+
:param batch_size: Number of nodes to embed per batch.
|
|
315
|
+
:param quiet: If ``True`` (default), suppress progress output from LanceDB and
|
|
316
|
+
sentence-transformers during ingestion.
|
|
317
|
+
:return: Stats dict with ``indexed_rows``, ``dim``, ``table``,
|
|
318
|
+
``lancedb_dir``, ``kinds``.
|
|
319
|
+
"""
|
|
320
|
+
if quiet:
|
|
321
|
+
suppress_ingestion_logging()
|
|
322
|
+
|
|
323
|
+
nodes = self._read_nodes(store)
|
|
324
|
+
tbl = self._open_table(wipe=wipe)
|
|
325
|
+
|
|
326
|
+
indexed = 0
|
|
327
|
+
for i in range(0, len(nodes), batch_size):
|
|
328
|
+
chunk = nodes[i : i + batch_size]
|
|
329
|
+
texts = [_build_index_text(n) for n in chunk]
|
|
330
|
+
vecs = self.embedder.embed_texts(texts)
|
|
331
|
+
|
|
332
|
+
# upsert: delete existing IDs then add fresh rows
|
|
333
|
+
ids = [n["id"] for n in chunk]
|
|
334
|
+
if ids:
|
|
335
|
+
pred = " OR ".join([f"id = '{_escape(nid)}'" for nid in ids])
|
|
336
|
+
tbl.delete(pred)
|
|
337
|
+
|
|
338
|
+
rows = [
|
|
339
|
+
{
|
|
340
|
+
"id": n["id"],
|
|
341
|
+
"kind": n["kind"],
|
|
342
|
+
"name": n["name"],
|
|
343
|
+
"qualname": n["qualname"] or "",
|
|
344
|
+
"module_path": n["module_path"] or "",
|
|
345
|
+
"text": text,
|
|
346
|
+
"vector": vec,
|
|
347
|
+
}
|
|
348
|
+
for n, text, vec in zip(chunk, texts, vecs)
|
|
349
|
+
]
|
|
350
|
+
tbl.add(rows)
|
|
351
|
+
indexed += len(rows)
|
|
352
|
+
|
|
353
|
+
self._tbl = tbl
|
|
354
|
+
return {
|
|
355
|
+
"indexed_rows": indexed,
|
|
356
|
+
"dim": self.embedder.dim,
|
|
357
|
+
"table": self.table_name,
|
|
358
|
+
"lancedb_dir": str(self.lancedb_dir),
|
|
359
|
+
"kinds": list(self.index_kinds),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
# Search
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def search(self, query: str, k: int = 8) -> list[SeedHit]:
|
|
367
|
+
"""
|
|
368
|
+
Semantic vector search.
|
|
369
|
+
|
|
370
|
+
:param query: Natural-language query string.
|
|
371
|
+
:param k: Number of results to return.
|
|
372
|
+
:return: List of :class:`SeedHit` ordered by ascending distance.
|
|
373
|
+
"""
|
|
374
|
+
tbl = self._get_table()
|
|
375
|
+
qvec = self.embedder.embed_query(query)
|
|
376
|
+
raw = tbl.search(qvec).limit(k).to_list()
|
|
377
|
+
|
|
378
|
+
hits: list[SeedHit] = []
|
|
379
|
+
for rank, row in enumerate(raw):
|
|
380
|
+
dist = _extract_distance(row, rank)
|
|
381
|
+
hits.append(
|
|
382
|
+
SeedHit(
|
|
383
|
+
id=row["id"],
|
|
384
|
+
kind=row.get("kind", ""),
|
|
385
|
+
name=row.get("name", ""),
|
|
386
|
+
qualname=row.get("qualname", ""),
|
|
387
|
+
module_path=row.get("module_path", ""),
|
|
388
|
+
distance=dist,
|
|
389
|
+
rank=rank,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return hits
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# Internal helpers
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def _read_nodes(self, store: GraphStore) -> list[dict]:
|
|
399
|
+
"""Read indexable nodes from the store filtered by ``index_kinds``.
|
|
400
|
+
|
|
401
|
+
:param store: Authoritative :class:`~pycode_kg.store.GraphStore` to query.
|
|
402
|
+
:return: List of node dicts for kinds in :attr:`index_kinds`.
|
|
403
|
+
"""
|
|
404
|
+
return store.query_nodes(kinds=list(self.index_kinds))
|
|
405
|
+
|
|
406
|
+
def _open_table(self, *, wipe: bool = False):
|
|
407
|
+
"""Open the LanceDB table, creating it with the correct schema if absent.
|
|
408
|
+
|
|
409
|
+
:param wipe: If ``True``, delete all existing rows after opening.
|
|
410
|
+
:return: LanceDB table handle.
|
|
411
|
+
"""
|
|
412
|
+
import lancedb # pylint: disable=import-outside-toplevel
|
|
413
|
+
|
|
414
|
+
self.lancedb_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
db = lancedb.connect(str(self.lancedb_dir)) # type: ignore[attr-defined]
|
|
416
|
+
|
|
417
|
+
if self.table_name in db.list_tables().tables:
|
|
418
|
+
if wipe:
|
|
419
|
+
db.drop_table(self.table_name)
|
|
420
|
+
else:
|
|
421
|
+
try:
|
|
422
|
+
return db.open_table(self.table_name)
|
|
423
|
+
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
424
|
+
# Table directory exists on disk but is corrupt/incomplete
|
|
425
|
+
# (e.g. from a previously interrupted build — empty _versions
|
|
426
|
+
# or data directories). Drop and recreate it cleanly.
|
|
427
|
+
logging.getLogger(__name__).warning(
|
|
428
|
+
"LanceDB table %r appears corrupt (%s); dropping and recreating.",
|
|
429
|
+
self.table_name,
|
|
430
|
+
exc,
|
|
431
|
+
)
|
|
432
|
+
db.drop_table(self.table_name)
|
|
433
|
+
|
|
434
|
+
# Create with a dummy row to establish schema, then remove it
|
|
435
|
+
dummy = {
|
|
436
|
+
"id": "__dummy__",
|
|
437
|
+
"kind": "dummy",
|
|
438
|
+
"name": "__dummy__",
|
|
439
|
+
"qualname": "",
|
|
440
|
+
"module_path": "",
|
|
441
|
+
"text": "__dummy__",
|
|
442
|
+
"vector": np.zeros((self.embedder.dim,), dtype="float32").tolist(),
|
|
443
|
+
}
|
|
444
|
+
tbl = db.create_table(self.table_name, data=[dummy])
|
|
445
|
+
tbl.delete("id = '__dummy__'")
|
|
446
|
+
return tbl
|
|
447
|
+
|
|
448
|
+
def _get_table(self):
|
|
449
|
+
"""Return the cached LanceDB table handle, opening it if not yet loaded.
|
|
450
|
+
|
|
451
|
+
:return: LanceDB table handle.
|
|
452
|
+
"""
|
|
453
|
+
if self._tbl is None:
|
|
454
|
+
import lancedb # pylint: disable=import-outside-toplevel
|
|
455
|
+
|
|
456
|
+
db = lancedb.connect(str(self.lancedb_dir)) # type: ignore[attr-defined]
|
|
457
|
+
self._tbl = db.open_table(self.table_name)
|
|
458
|
+
return self._tbl
|
|
459
|
+
|
|
460
|
+
def __repr__(self) -> str:
|
|
461
|
+
"""Return a developer-readable representation of this SemanticIndex.
|
|
462
|
+
|
|
463
|
+
:return: String including lancedb_dir, table name, and embedder details.
|
|
464
|
+
"""
|
|
465
|
+
return (
|
|
466
|
+
f"SemanticIndex(lancedb_dir={self.lancedb_dir!r}, "
|
|
467
|
+
f"table={self.table_name!r}, embedder={self.embedder!r})"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Internal utilities
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _build_index_text(n: dict) -> str:
|
|
477
|
+
"""Build the canonical text document used for embedding a node.
|
|
478
|
+
|
|
479
|
+
**Format version 2** — includes a KEYWORDS section of de-duplicated word
|
|
480
|
+
tokens extracted from the name, qualname, and module path. This improves
|
|
481
|
+
recall for abstract queries (for example "error handling strategy",
|
|
482
|
+
"logging approach", "entry points", and "configuration") that do not
|
|
483
|
+
match on names alone. Rebuilding the LanceDB index is required after this
|
|
484
|
+
change (``pycodekg build-lancedb --wipe``).
|
|
485
|
+
|
|
486
|
+
Retrieval quality depends heavily on vocabulary in node docstrings.
|
|
487
|
+
Include meaningful terms for behavior, parameters, fallback semantics,
|
|
488
|
+
and operational concerns so both semantic search and lexical reranking
|
|
489
|
+
can surface the right nodes.
|
|
490
|
+
|
|
491
|
+
:param n: Node dict with keys ``kind``, ``name``, ``qualname``, ``module_path``,
|
|
492
|
+
``lineno``, and optionally ``docstring``.
|
|
493
|
+
:return: Newline-joined string suitable for embedding.
|
|
494
|
+
"""
|
|
495
|
+
parts = [f"KIND: {n['kind']}", f"NAME: {n['name']}"]
|
|
496
|
+
if n.get("qualname"):
|
|
497
|
+
parts.append(f"QUALNAME: {n['qualname']}")
|
|
498
|
+
if n.get("module_path"):
|
|
499
|
+
parts.append(f"MODULE: {n['module_path']}")
|
|
500
|
+
if n.get("lineno") is not None:
|
|
501
|
+
parts.append(f"LINE: {n['lineno']}")
|
|
502
|
+
if n.get("docstring"):
|
|
503
|
+
parts.append("DOCSTRING:\n" + n["docstring"].strip())
|
|
504
|
+
|
|
505
|
+
# Augment with word-token keywords from name/qualname/module for abstract query matching.
|
|
506
|
+
# Splitting snake_case and path components gives the model more signal when the docstring
|
|
507
|
+
# is absent or brief (e.g. "error" + "handler" from "_error_handler" matches "error handling").
|
|
508
|
+
raw = " ".join(filter(None, [n.get("name"), n.get("qualname"), n.get("module_path")]))
|
|
509
|
+
tokens = [w.lower() for w in re.findall(r"[a-zA-Z]+", raw) if len(w) > 2]
|
|
510
|
+
seen_in_doc = set(re.findall(r"[a-zA-Z]+", (n.get("docstring") or "").lower()))
|
|
511
|
+
extra = [t for t in dict.fromkeys(tokens) if t not in seen_in_doc]
|
|
512
|
+
if extra:
|
|
513
|
+
parts.append("KEYWORDS: " + " ".join(extra))
|
|
514
|
+
|
|
515
|
+
return "\n".join(parts)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _extract_distance(row: dict, fallback_rank: int) -> float:
|
|
519
|
+
"""Extract a distance value from a LanceDB result row.
|
|
520
|
+
|
|
521
|
+
Tries ``_distance``, then ``distance``, then inverts ``score``.
|
|
522
|
+
Falls back to the row's rank if no distance field is found.
|
|
523
|
+
|
|
524
|
+
:param row: Raw result dict from LanceDB.
|
|
525
|
+
:param fallback_rank: Zero-based rank to use when no distance field is present.
|
|
526
|
+
:return: Float distance value (lower = more similar).
|
|
527
|
+
"""
|
|
528
|
+
for key in ("_distance", "distance"):
|
|
529
|
+
if key in row and row[key] is not None:
|
|
530
|
+
return float(row[key])
|
|
531
|
+
if "score" in row and row["score"] is not None:
|
|
532
|
+
return 1.0 / (1.0 + float(row["score"]))
|
|
533
|
+
return float(fallback_rank)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _escape(s: str) -> str:
|
|
537
|
+
"""Escape single quotes in a string for use in LanceDB delete predicates.
|
|
538
|
+
|
|
539
|
+
:param s: String to escape.
|
|
540
|
+
:return: String with single quotes doubled.
|
|
541
|
+
"""
|
|
542
|
+
return s.replace("'", "''")
|