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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_init.py
|
|
3
|
+
|
|
4
|
+
CLI command for initializing PyCodeKG in a repository:
|
|
5
|
+
|
|
6
|
+
init — download model, build graph, install hooks, capture snapshot
|
|
7
|
+
|
|
8
|
+
Author: Eric G. Suchanek, PhD
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
import tomllib
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from pycode_kg.cli.main import cli
|
|
22
|
+
from pycode_kg.cli.options import (
|
|
23
|
+
exclude_option,
|
|
24
|
+
include_option,
|
|
25
|
+
model_option,
|
|
26
|
+
repo_option,
|
|
27
|
+
)
|
|
28
|
+
from pycode_kg.index import _local_model_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_pycodekg_config(repo_root: Path) -> bool:
|
|
32
|
+
"""Check whether pyproject.toml already has a [tool.pycodekg] section."""
|
|
33
|
+
pyproject = repo_root / "pyproject.toml"
|
|
34
|
+
if not pyproject.exists():
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
with open(pyproject, "rb") as f:
|
|
38
|
+
data = tomllib.load(f)
|
|
39
|
+
return "pycodekg" in data.get("tool", {})
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _scaffold_pycodekg_config(repo_root: Path) -> bool:
|
|
45
|
+
"""Append a minimal [tool.pycodekg] section to pyproject.toml if missing.
|
|
46
|
+
|
|
47
|
+
Detects the most likely source directory (``src``, ``lib``, or the
|
|
48
|
+
project name) and sets it as the include list.
|
|
49
|
+
|
|
50
|
+
:param repo_root: Repository root directory.
|
|
51
|
+
:return: True if the section was added, False if skipped.
|
|
52
|
+
"""
|
|
53
|
+
pyproject = repo_root / "pyproject.toml"
|
|
54
|
+
if not pyproject.exists():
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
# Guess the best include directory
|
|
58
|
+
candidates = ["src", "lib"]
|
|
59
|
+
include_dirs: list[str] = [d for d in candidates if (repo_root / d).is_dir()]
|
|
60
|
+
|
|
61
|
+
if not include_dirs:
|
|
62
|
+
# Fall back to the repo name itself if it's a directory
|
|
63
|
+
repo_name = repo_root.name.replace("-", "_")
|
|
64
|
+
if (repo_root / repo_name).is_dir():
|
|
65
|
+
include_dirs = [repo_name]
|
|
66
|
+
|
|
67
|
+
lines = [
|
|
68
|
+
"",
|
|
69
|
+
"[tool.pycodekg]",
|
|
70
|
+
"# Directories to include in the knowledge graph build and analysis.",
|
|
71
|
+
"# When unset, all directories are indexed.",
|
|
72
|
+
]
|
|
73
|
+
if include_dirs:
|
|
74
|
+
include_str = ", ".join(f'"{d}"' for d in include_dirs)
|
|
75
|
+
lines.append(f"include = [{include_str}]")
|
|
76
|
+
else:
|
|
77
|
+
lines.append("# include = []")
|
|
78
|
+
|
|
79
|
+
pyproject.open("a").write("\n".join(lines) + "\n")
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command("init")
|
|
84
|
+
@repo_option
|
|
85
|
+
@model_option
|
|
86
|
+
@include_option
|
|
87
|
+
@exclude_option
|
|
88
|
+
@click.option("--skip-hooks", is_flag=True, help="Don't install the pre-commit git hook.")
|
|
89
|
+
@click.option("--skip-snapshot", is_flag=True, help="Don't capture an initial snapshot.")
|
|
90
|
+
@click.option("--force", is_flag=True, help="Overwrite existing graph data and hook.")
|
|
91
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show progress details.")
|
|
92
|
+
def init(
|
|
93
|
+
repo: str,
|
|
94
|
+
model: str,
|
|
95
|
+
include_dir: tuple[str, ...],
|
|
96
|
+
exclude_dir: tuple[str, ...],
|
|
97
|
+
skip_hooks: bool,
|
|
98
|
+
skip_snapshot: bool,
|
|
99
|
+
force: bool,
|
|
100
|
+
verbose: bool,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Initialize PyCodeKG in a repository.
|
|
103
|
+
|
|
104
|
+
Downloads the embedding model, builds the knowledge graph (SQLite +
|
|
105
|
+
LanceDB), optionally installs the pre-commit hook, and captures an
|
|
106
|
+
initial snapshot. Designed to be idempotent — safe to run more than once.
|
|
107
|
+
|
|
108
|
+
Example::
|
|
109
|
+
|
|
110
|
+
pycodekg init --repo .
|
|
111
|
+
"""
|
|
112
|
+
from pycode_kg.cli.cmd_build_full import (
|
|
113
|
+
_run_pipeline, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
114
|
+
)
|
|
115
|
+
from pycode_kg.cli.cmd_hooks import (
|
|
116
|
+
_PRE_COMMIT_HOOK, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
repo_root = Path(repo).resolve()
|
|
120
|
+
t_total = time.monotonic()
|
|
121
|
+
|
|
122
|
+
click.echo()
|
|
123
|
+
click.echo(" PyCodeKG Init")
|
|
124
|
+
click.echo(f" repo {repo_root}")
|
|
125
|
+
click.echo()
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Step 0: Scaffold [tool.pycodekg] in pyproject.toml if missing
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
if not _has_pycodekg_config(repo_root):
|
|
131
|
+
if _scaffold_pycodekg_config(repo_root):
|
|
132
|
+
click.echo(" [0/4] Added [tool.pycodekg] section to pyproject.toml")
|
|
133
|
+
else:
|
|
134
|
+
click.echo(" [0/4] No pyproject.toml found — skipping config scaffold")
|
|
135
|
+
else:
|
|
136
|
+
click.echo(" [0/4] [tool.pycodekg] config already present")
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Step 1: Download the embedding model
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
click.echo()
|
|
142
|
+
local_path = _local_model_path(model)
|
|
143
|
+
|
|
144
|
+
if local_path.exists() and not force:
|
|
145
|
+
click.echo(f" [1/4] Model already cached at {local_path}")
|
|
146
|
+
else:
|
|
147
|
+
click.echo(f" [1/4] Downloading embedding model '{model}'...")
|
|
148
|
+
from sentence_transformers import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
149
|
+
SentenceTransformer,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
st_model = SentenceTransformer(model)
|
|
153
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
st_model.save(str(local_path))
|
|
155
|
+
click.echo(f" OK: model saved to {local_path}")
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
# Step 2: Build the knowledge graph (full wipe)
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
click.echo()
|
|
161
|
+
click.echo(" [2/4] Building knowledge graph...")
|
|
162
|
+
_run_pipeline(
|
|
163
|
+
repo=repo,
|
|
164
|
+
db=None,
|
|
165
|
+
lancedb=None,
|
|
166
|
+
table="pycodekg_nodes",
|
|
167
|
+
model=model,
|
|
168
|
+
verbose=verbose,
|
|
169
|
+
kinds="module,class,function,method",
|
|
170
|
+
batch=256,
|
|
171
|
+
include_dir=include_dir,
|
|
172
|
+
exclude_dir=exclude_dir,
|
|
173
|
+
wipe=True,
|
|
174
|
+
label="Init Build",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Step 3: Install pre-commit hook
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
click.echo()
|
|
181
|
+
if skip_hooks:
|
|
182
|
+
click.echo(" [3/4] Skipping hook installation (--skip-hooks)")
|
|
183
|
+
else:
|
|
184
|
+
git_dir = repo_root / ".git"
|
|
185
|
+
if not git_dir.is_dir():
|
|
186
|
+
click.echo(" [3/4] Not a git repository — skipping hook installation")
|
|
187
|
+
else:
|
|
188
|
+
hooks_dir = git_dir / "hooks"
|
|
189
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
190
|
+
hook_path = hooks_dir / "pre-commit"
|
|
191
|
+
|
|
192
|
+
if hook_path.exists() and not force:
|
|
193
|
+
click.echo(f" [3/4] Hook already exists: {hook_path}")
|
|
194
|
+
click.echo(" Use --force to overwrite.")
|
|
195
|
+
else:
|
|
196
|
+
hook_path.write_text(_PRE_COMMIT_HOOK)
|
|
197
|
+
mode = hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
198
|
+
hook_path.chmod(mode)
|
|
199
|
+
click.echo(f" [3/4] OK: installed pre-commit hook at {hook_path}")
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# Step 4: Capture initial snapshot
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
click.echo()
|
|
205
|
+
if skip_snapshot:
|
|
206
|
+
click.echo(" [4/4] Skipping initial snapshot (--skip-snapshot)")
|
|
207
|
+
else:
|
|
208
|
+
try:
|
|
209
|
+
tree_hash = (
|
|
210
|
+
subprocess.check_output(
|
|
211
|
+
["git", "rev-parse", "HEAD"],
|
|
212
|
+
cwd=str(repo_root),
|
|
213
|
+
stderr=subprocess.DEVNULL,
|
|
214
|
+
)
|
|
215
|
+
.decode()
|
|
216
|
+
.strip()
|
|
217
|
+
)
|
|
218
|
+
branch = (
|
|
219
|
+
subprocess.check_output(
|
|
220
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
221
|
+
cwd=str(repo_root),
|
|
222
|
+
stderr=subprocess.DEVNULL,
|
|
223
|
+
)
|
|
224
|
+
.decode()
|
|
225
|
+
.strip()
|
|
226
|
+
)
|
|
227
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
228
|
+
tree_hash = ""
|
|
229
|
+
branch = "unknown"
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
import importlib.metadata # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
233
|
+
|
|
234
|
+
from pycode_kg.kg import (
|
|
235
|
+
PyCodeKG, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
236
|
+
)
|
|
237
|
+
from pycode_kg.pycodekg_thorough_analysis import (
|
|
238
|
+
PyCodeKGAnalyzer, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
239
|
+
)
|
|
240
|
+
from pycode_kg.snapshots import (
|
|
241
|
+
SnapshotManager, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
242
|
+
)
|
|
243
|
+
from pycode_kg.store import (
|
|
244
|
+
GraphStore, # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
db_path = repo_root / ".pycodekg" / "graph.sqlite"
|
|
248
|
+
snapshots_path = repo_root / ".pycodekg" / "snapshots"
|
|
249
|
+
lancedb_dir = repo_root / ".pycodekg" / "lancedb"
|
|
250
|
+
|
|
251
|
+
store = GraphStore(db_path)
|
|
252
|
+
try:
|
|
253
|
+
stats = store.stats()
|
|
254
|
+
finally:
|
|
255
|
+
store.close()
|
|
256
|
+
|
|
257
|
+
kg = PyCodeKG(
|
|
258
|
+
repo_root=repo_root,
|
|
259
|
+
db_path=db_path,
|
|
260
|
+
lancedb_dir=lancedb_dir,
|
|
261
|
+
model=model,
|
|
262
|
+
)
|
|
263
|
+
snap_mgr = SnapshotManager(snapshots_path, db_path=db_path)
|
|
264
|
+
try:
|
|
265
|
+
analyzer = PyCodeKGAnalyzer(kg, snapshot_mgr=snap_mgr)
|
|
266
|
+
analysis = analyzer.run_analysis()
|
|
267
|
+
|
|
268
|
+
docstring_cov = analysis.get("docstring_coverage", {})
|
|
269
|
+
coverage = docstring_cov.get("coverage_pct", 0.0) / 100.0 if docstring_cov else 0.0
|
|
270
|
+
critical_issues = len(analysis.get("issues", []))
|
|
271
|
+
|
|
272
|
+
fn_metrics = analysis.get("function_metrics", {})
|
|
273
|
+
hotspots = [
|
|
274
|
+
{
|
|
275
|
+
"name": name,
|
|
276
|
+
"callers": m.get("fan_in", 0),
|
|
277
|
+
"callees": m.get("fan_out", 0),
|
|
278
|
+
"risk_level": m.get("risk_level", "low"),
|
|
279
|
+
}
|
|
280
|
+
for name, m in list(fn_metrics.items())[:10]
|
|
281
|
+
]
|
|
282
|
+
fan_ins = [m.get("fan_in", 0) for m in fn_metrics.values()]
|
|
283
|
+
complexity_median = sorted(fan_ins)[len(fan_ins) // 2] if fan_ins else 0.0
|
|
284
|
+
finally:
|
|
285
|
+
kg.close()
|
|
286
|
+
|
|
287
|
+
version = importlib.metadata.version("pycode-kg")
|
|
288
|
+
snapshot_obj = snap_mgr.capture(
|
|
289
|
+
version=version,
|
|
290
|
+
branch=branch,
|
|
291
|
+
graph_stats_dict=stats,
|
|
292
|
+
coverage=coverage,
|
|
293
|
+
critical_issues=critical_issues,
|
|
294
|
+
complexity_median=complexity_median,
|
|
295
|
+
hotspots=hotspots,
|
|
296
|
+
issues=analysis.get("issues", []),
|
|
297
|
+
tree_hash=tree_hash,
|
|
298
|
+
)
|
|
299
|
+
snap_mgr.save_snapshot(snapshot_obj)
|
|
300
|
+
click.echo(f" [4/4] OK: snapshot saved (key={snapshot_obj.key[:12]})")
|
|
301
|
+
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
302
|
+
click.echo(f" [4/4] Snapshot skipped: {exc}", err=True)
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
# Done
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
elapsed = time.monotonic() - t_total
|
|
308
|
+
click.echo()
|
|
309
|
+
click.echo(f" Done ({elapsed:.1f}s)")
|
|
310
|
+
click.echo()
|
|
311
|
+
click.echo(" Run 'pycodekg query \"your question\"' to search the codebase.")
|
|
312
|
+
click.echo()
|
pycode_kg/cli/cmd_mcp.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_mcp.py
|
|
3
|
+
|
|
4
|
+
Click subcommand for starting the PyCodeKG MCP server:
|
|
5
|
+
|
|
6
|
+
mcp — start the MCP server (thin wrapper around mcp_server.main)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from pycode_kg.cli.main import cli
|
|
14
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli.command("mcp")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--repo",
|
|
20
|
+
default=".",
|
|
21
|
+
type=click.Path(exists=True, file_okay=False),
|
|
22
|
+
help="Repository root directory.",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--db",
|
|
26
|
+
default=".pycodekg/graph.sqlite",
|
|
27
|
+
type=click.Path(),
|
|
28
|
+
help="SQLite database path.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--lancedb",
|
|
32
|
+
default=".pycodekg/lancedb",
|
|
33
|
+
type=click.Path(),
|
|
34
|
+
help="LanceDB directory path.",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--model",
|
|
38
|
+
default=DEFAULT_MODEL,
|
|
39
|
+
help="Sentence-transformer model name.",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--transport",
|
|
43
|
+
type=click.Choice(["stdio", "sse"]),
|
|
44
|
+
default="stdio",
|
|
45
|
+
help="MCP transport protocol.",
|
|
46
|
+
)
|
|
47
|
+
def mcp(repo: str, db: str, lancedb: str, model: str, transport: str) -> None:
|
|
48
|
+
"""Start the PyCodeKG MCP server."""
|
|
49
|
+
try:
|
|
50
|
+
from mcp.server.fastmcp import ( # pylint: disable=import-outside-toplevel
|
|
51
|
+
FastMCP, # noqa: F401
|
|
52
|
+
)
|
|
53
|
+
except ImportError:
|
|
54
|
+
raise click.ClickException("'mcp' package not found. Install with: pip install mcp")
|
|
55
|
+
|
|
56
|
+
argv = [
|
|
57
|
+
"--repo",
|
|
58
|
+
repo,
|
|
59
|
+
"--db",
|
|
60
|
+
db,
|
|
61
|
+
"--lancedb",
|
|
62
|
+
lancedb,
|
|
63
|
+
"--model",
|
|
64
|
+
model,
|
|
65
|
+
"--transport",
|
|
66
|
+
transport,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
from pycode_kg.mcp_server import main # pylint: disable=import-outside-toplevel
|
|
70
|
+
|
|
71
|
+
main(argv=argv)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_model.py
|
|
3
|
+
|
|
4
|
+
CLI command for managing the PyCodeKG embedding model cache.
|
|
5
|
+
|
|
6
|
+
download-model — download and cache the sentence-transformer model for offline use
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from pycode_kg.cli.main import cli
|
|
14
|
+
from pycode_kg.index import _local_model_path
|
|
15
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cli.command("download-model")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--model",
|
|
21
|
+
default=DEFAULT_MODEL,
|
|
22
|
+
show_default=True,
|
|
23
|
+
help="SentenceTransformer model name to download.",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--force",
|
|
27
|
+
is_flag=True,
|
|
28
|
+
help="Re-download even if a local copy already exists.",
|
|
29
|
+
)
|
|
30
|
+
def download_model(model: str, force: bool) -> None:
|
|
31
|
+
"""Download and cache the embedding model for offline use.
|
|
32
|
+
|
|
33
|
+
The model is saved to ``.pycodekg/models/<model>/`` in the current working
|
|
34
|
+
directory (or the path set by the ``PYCODEKG_MODEL_DIR`` environment
|
|
35
|
+
variable). Once cached, ``pycodekg build-lancedb`` and ``pycodekg query``
|
|
36
|
+
will use this local copy without any network access.
|
|
37
|
+
"""
|
|
38
|
+
from sentence_transformers import ( # pylint: disable=import-outside-toplevel
|
|
39
|
+
SentenceTransformer,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
local_path = _local_model_path(model)
|
|
43
|
+
|
|
44
|
+
if local_path.exists() and not force:
|
|
45
|
+
click.echo(f"Model already cached at {local_path}")
|
|
46
|
+
click.echo("Use --force to re-download.")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
click.echo(f"Downloading model '{model}'...")
|
|
50
|
+
st_model = SentenceTransformer(model)
|
|
51
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
st_model.save(str(local_path))
|
|
53
|
+
click.echo(f"OK: model saved to {local_path}")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_query.py
|
|
3
|
+
|
|
4
|
+
Click subcommands for querying and packing snippets from the PyCodeKG:
|
|
5
|
+
|
|
6
|
+
query — hybrid semantic + graph query, prints a ranked result summary
|
|
7
|
+
pack — hybrid query + source-grounded snippet packing, outputs markdown or JSON
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from pycode_kg.cli.main import cli
|
|
17
|
+
from pycode_kg.cli.options import lancedb_option, model_option, sqlite_option
|
|
18
|
+
from pycode_kg.kg import PyCodeKG
|
|
19
|
+
from pycode_kg.store import DEFAULT_RELS
|
|
20
|
+
|
|
21
|
+
_DEFAULT_RELS_STR = ",".join(DEFAULT_RELS)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cli.command("query")
|
|
25
|
+
@click.argument("query_text", metavar="QUERY")
|
|
26
|
+
@sqlite_option
|
|
27
|
+
@lancedb_option
|
|
28
|
+
@click.option(
|
|
29
|
+
"--table",
|
|
30
|
+
default="pycodekg_nodes",
|
|
31
|
+
show_default=True,
|
|
32
|
+
help="LanceDB table name.",
|
|
33
|
+
)
|
|
34
|
+
@model_option
|
|
35
|
+
@click.option("--k", type=int, default=8, show_default=True, help="Top-k semantic hits.")
|
|
36
|
+
@click.option("--hop", type=int, default=1, show_default=True, help="Graph expansion hops.")
|
|
37
|
+
@click.option(
|
|
38
|
+
"--rels",
|
|
39
|
+
default=_DEFAULT_RELS_STR,
|
|
40
|
+
show_default=True,
|
|
41
|
+
help="Comma-separated edge types to expand.",
|
|
42
|
+
)
|
|
43
|
+
@click.option("--include-symbols", is_flag=True, help="Include symbol nodes in output.")
|
|
44
|
+
def query(
|
|
45
|
+
query_text: str,
|
|
46
|
+
sqlite: str,
|
|
47
|
+
lancedb: str,
|
|
48
|
+
table: str,
|
|
49
|
+
model: str,
|
|
50
|
+
k: int,
|
|
51
|
+
hop: int,
|
|
52
|
+
rels: str,
|
|
53
|
+
include_symbols: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Run a hybrid semantic + graph query and print a ranked result summary.
|
|
56
|
+
|
|
57
|
+
Performs vector-similarity seeding followed by graph expansion to find the
|
|
58
|
+
most relevant nodes (modules, classes, functions, methods) in the knowledge
|
|
59
|
+
graph, then prints a ranked summary table to stdout.
|
|
60
|
+
|
|
61
|
+
:param query_text: Natural-language query, e.g. ``"database connection setup"``.
|
|
62
|
+
:param sqlite: Path to the SQLite graph database.
|
|
63
|
+
:param lancedb: Directory for the LanceDB vector index.
|
|
64
|
+
:param table: LanceDB table name (default ``pycodekg_nodes``).
|
|
65
|
+
:param model: Sentence-transformer embedding model name.
|
|
66
|
+
:param k: Number of semantic seed nodes (default 8).
|
|
67
|
+
:param hop: Graph expansion hops from each seed (default 1).
|
|
68
|
+
:param rels: Comma-separated edge types to follow during expansion.
|
|
69
|
+
:param include_symbols: When set, include low-level symbol nodes in results.
|
|
70
|
+
"""
|
|
71
|
+
rels_tuple = tuple(r.strip() for r in rels.split(",") if r.strip())
|
|
72
|
+
|
|
73
|
+
# query-only does not need repo_root for source reading; use sqlite parent as placeholder
|
|
74
|
+
repo_root = Path(sqlite).parent
|
|
75
|
+
|
|
76
|
+
kg = PyCodeKG(
|
|
77
|
+
repo_root=repo_root,
|
|
78
|
+
db_path=Path(sqlite),
|
|
79
|
+
lancedb_dir=Path(lancedb),
|
|
80
|
+
model=model,
|
|
81
|
+
table=table,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result = kg.query(
|
|
85
|
+
query_text,
|
|
86
|
+
k=k,
|
|
87
|
+
hop=hop,
|
|
88
|
+
rels=rels_tuple,
|
|
89
|
+
include_symbols=include_symbols,
|
|
90
|
+
)
|
|
91
|
+
result.print_summary()
|
|
92
|
+
kg.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command("pack")
|
|
96
|
+
@click.argument("query_text", metavar="QUERY")
|
|
97
|
+
@click.option(
|
|
98
|
+
"--repo-root",
|
|
99
|
+
default=".",
|
|
100
|
+
type=click.Path(),
|
|
101
|
+
show_default=True,
|
|
102
|
+
help="Repository root directory.",
|
|
103
|
+
)
|
|
104
|
+
@sqlite_option
|
|
105
|
+
@lancedb_option
|
|
106
|
+
@click.option(
|
|
107
|
+
"--table",
|
|
108
|
+
default="pycodekg_nodes",
|
|
109
|
+
show_default=True,
|
|
110
|
+
help="LanceDB table name.",
|
|
111
|
+
)
|
|
112
|
+
@model_option
|
|
113
|
+
@click.option("--k", type=int, default=8, show_default=True, help="Top-k semantic hits.")
|
|
114
|
+
@click.option("--hop", type=int, default=1, show_default=True, help="Graph expansion hops.")
|
|
115
|
+
@click.option(
|
|
116
|
+
"--rels",
|
|
117
|
+
default=_DEFAULT_RELS_STR,
|
|
118
|
+
show_default=True,
|
|
119
|
+
help="Comma-separated edge types to expand.",
|
|
120
|
+
)
|
|
121
|
+
@click.option("--include-symbols", is_flag=True, help="Include symbol nodes.")
|
|
122
|
+
@click.option(
|
|
123
|
+
"--context",
|
|
124
|
+
type=int,
|
|
125
|
+
default=5,
|
|
126
|
+
show_default=True,
|
|
127
|
+
help="Extra context lines before/after each definition span.",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--max-lines",
|
|
131
|
+
type=int,
|
|
132
|
+
default=160,
|
|
133
|
+
show_default=True,
|
|
134
|
+
help="Max lines per snippet block.",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--max-nodes",
|
|
138
|
+
type=int,
|
|
139
|
+
default=None,
|
|
140
|
+
help="Max nodes returned in pack (default: no limit).",
|
|
141
|
+
)
|
|
142
|
+
@click.option(
|
|
143
|
+
"--out",
|
|
144
|
+
type=click.Path(),
|
|
145
|
+
default=None,
|
|
146
|
+
help="Output file path (default: stdout).",
|
|
147
|
+
)
|
|
148
|
+
def pack(
|
|
149
|
+
query_text: str,
|
|
150
|
+
repo_root: str,
|
|
151
|
+
sqlite: str,
|
|
152
|
+
lancedb: str,
|
|
153
|
+
table: str,
|
|
154
|
+
model: str,
|
|
155
|
+
k: int,
|
|
156
|
+
hop: int,
|
|
157
|
+
rels: str,
|
|
158
|
+
include_symbols: bool,
|
|
159
|
+
context: int,
|
|
160
|
+
max_lines: int,
|
|
161
|
+
max_nodes: int | None,
|
|
162
|
+
out: str | None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Run a hybrid query and emit source-grounded snippet packs.
|
|
165
|
+
|
|
166
|
+
Performs vector-similarity seeding, graph expansion, and source snippet
|
|
167
|
+
extraction, then outputs a Markdown context pack with ranked, deduplicated
|
|
168
|
+
code snippets and line numbers — ready for direct LLM ingestion.
|
|
169
|
+
|
|
170
|
+
:param query_text: Natural-language query, e.g. ``"configuration loading"``.
|
|
171
|
+
:param repo_root: Repository root directory used for source file lookup.
|
|
172
|
+
:param sqlite: Path to the SQLite graph database.
|
|
173
|
+
:param lancedb: Directory for the LanceDB vector index.
|
|
174
|
+
:param table: LanceDB table name (default ``pycodekg_nodes``).
|
|
175
|
+
:param model: Sentence-transformer embedding model name.
|
|
176
|
+
:param k: Number of semantic seed nodes (default 8).
|
|
177
|
+
:param hop: Graph expansion hops from each seed (default 1).
|
|
178
|
+
:param rels: Comma-separated edge types to follow during expansion.
|
|
179
|
+
:param include_symbols: When set, include low-level symbol nodes.
|
|
180
|
+
:param context: Extra context lines before/after each definition span.
|
|
181
|
+
:param max_lines: Maximum lines per snippet block (default 160).
|
|
182
|
+
:param max_nodes: Maximum nodes to include in the pack (``None`` for no limit).
|
|
183
|
+
:param out: Output file path; when omitted, writes to stdout.
|
|
184
|
+
"""
|
|
185
|
+
rels_tuple = tuple(r.strip() for r in rels.split(",") if r.strip())
|
|
186
|
+
|
|
187
|
+
kg = PyCodeKG(
|
|
188
|
+
repo_root=Path(repo_root),
|
|
189
|
+
db_path=Path(sqlite),
|
|
190
|
+
lancedb_dir=Path(lancedb),
|
|
191
|
+
model=model,
|
|
192
|
+
table=table,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
snippet_pack = kg.pack(
|
|
196
|
+
query_text,
|
|
197
|
+
k=k,
|
|
198
|
+
hop=hop,
|
|
199
|
+
rels=rels_tuple,
|
|
200
|
+
include_symbols=include_symbols,
|
|
201
|
+
context=context,
|
|
202
|
+
max_lines=max_lines,
|
|
203
|
+
max_nodes=max_nodes,
|
|
204
|
+
)
|
|
205
|
+
kg.close()
|
|
206
|
+
|
|
207
|
+
if out:
|
|
208
|
+
snippet_pack.save(out)
|
|
209
|
+
print(f"OK: wrote pack to {out}")
|
|
210
|
+
else:
|
|
211
|
+
print(snippet_pack.to_markdown())
|