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.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. 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()
@@ -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())