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
pycode_kg/app.py ADDED
@@ -0,0 +1,1335 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ app.py — PyCodeKG Streamlit Visualizer
4
+
5
+ Interactive knowledge-graph explorer with:
6
+ • Sidebar: configure repo/db paths and query parameters
7
+ • Graph tab: pyvis interactive graph of the full KG or query results
8
+ • Query tab: hybrid semantic+structural query with ranked node results
9
+ • Snippets tab: source-grounded snippet pack viewer
10
+
11
+ Run with:
12
+ poetry run pycodekg-viz
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ import streamlit as st
23
+ from pyvis.network import Network
24
+
25
+ from pycode_kg.store import DEFAULT_RELS, GraphStore
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Constants — colours and shapes per node kind
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _KIND_COLOR: dict[str, str] = {
32
+ "module": "#4A90D9", # blue
33
+ "class": "#E67E22", # orange
34
+ "function": "#27AE60", # green
35
+ "method": "#8E44AD", # purple
36
+ "symbol": "#95A5A6", # grey
37
+ }
38
+
39
+ _KIND_SHAPE: dict[str, str] = {
40
+ "module": "box",
41
+ "class": "diamond",
42
+ "function": "ellipse",
43
+ "method": "dot",
44
+ "symbol": "triangle",
45
+ }
46
+
47
+ _REL_COLOR: dict[str, str] = {
48
+ "CONTAINS": "#BDC3C7",
49
+ "CALLS": "#E74C3C",
50
+ "IMPORTS": "#3498DB",
51
+ "INHERITS": "#F39C12",
52
+ }
53
+
54
+ # Honour the PYCODEKG_DB env var so the Docker image (which mounts
55
+ # persistent data at /data) works out of the box without the user
56
+ # having to change the sidebar path manually.
57
+ import os as _os # noqa: E402
58
+
59
+ _DEFAULT_DB = _os.environ.get("PYCODEKG_DB", ".pycodekg/graph.sqlite")
60
+ _DEFAULT_LANCEDB = _os.environ.get("PYCODEKG_LANCEDB", ".pycodekg/lancedb")
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Page config (must be first Streamlit call)
64
+ # ---------------------------------------------------------------------------
65
+
66
+ st.set_page_config(
67
+ page_title="PyCodeKG Explorer",
68
+ page_icon="🕸️",
69
+ layout="wide",
70
+ initial_sidebar_state="expanded",
71
+ )
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Minimal CSS tweaks
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Session-state initialisation
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def _init_state() -> None:
84
+ """
85
+ Initialize Streamlit session state with default values.
86
+
87
+ Sets keys for database path, store, query/pack results, graph data,
88
+ PyCodeKG instance, and selected node if they are not already present.
89
+ """
90
+ defaults = {
91
+ "db_path": _DEFAULT_DB,
92
+ "store": None,
93
+ "store_loaded_path": None,
94
+ "query_result": None,
95
+ "pack_result": None,
96
+ "graph_nodes": None,
97
+ "graph_edges": None,
98
+ "kg": None,
99
+ "kg_loaded_path": None,
100
+ "selected_node_id": None,
101
+ }
102
+ for k, v in defaults.items():
103
+ if k not in st.session_state:
104
+ st.session_state[k] = v
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Store helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @st.cache_resource(show_spinner="Opening SQLite store…")
113
+ def _load_store(db_path: str) -> GraphStore | None:
114
+ """
115
+ Load and cache a GraphStore from the given SQLite database path.
116
+
117
+ Returns ``None`` if the file does not exist.
118
+
119
+ :param db_path: Filesystem path to the SQLite database file.
120
+ :return: A connected ``GraphStore`` instance, or ``None`` if the file is absent.
121
+ """
122
+ p = Path(db_path)
123
+ if not p.exists():
124
+ return None
125
+ return GraphStore(db_path)
126
+
127
+
128
+ def _get_store() -> GraphStore | None:
129
+ """
130
+ Retrieve the current GraphStore, loading it if the database path has changed.
131
+
132
+ Compares the cached loaded path against ``st.session_state.db_path`` and
133
+ calls ``_load_store`` only when the path differs.
134
+
135
+ :return: The active ``GraphStore`` instance, or ``None`` if no database is available.
136
+ """
137
+ db = st.session_state.db_path
138
+ if st.session_state.store_loaded_path != db:
139
+ st.session_state.store = _load_store(db)
140
+ st.session_state.store_loaded_path = db
141
+ return st.session_state.store
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # PyCodeKG helper (lazy, cached per (db_path, repo_root, model))
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ @st.cache_resource(show_spinner="Loading PyCodeKG (embedder may take a moment)…")
150
+ def _load_kg(repo_root: str, db_path: str, lancedb_dir: str, model: str):
151
+ """
152
+ Load and cache a ``PyCodeKG`` instance for the given configuration.
153
+
154
+ Keyed on all four parameters so that changing any one triggers a fresh load.
155
+ This is the main entry point for query/snippet execution in the UI and the
156
+ configuration boundary between Streamlit controls and runtime PyCodeKG state.
157
+
158
+ :param repo_root: Root directory of the Python repository to analyse.
159
+ :param db_path: Path to the SQLite graph database.
160
+ :param lancedb_dir: Directory for the LanceDB vector index.
161
+ :param model: Name of the sentence-transformer embedding model to use.
162
+ :return: An initialised ``PyCodeKG`` instance.
163
+ """
164
+ from pycode_kg import PyCodeKG # pylint: disable=import-outside-toplevel
165
+
166
+ return PyCodeKG(
167
+ repo_root=repo_root,
168
+ db_path=db_path,
169
+ lancedb_dir=lancedb_dir,
170
+ model=model,
171
+ )
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # pyvis graph builder
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def _build_node_tooltip(n: dict, color: str) -> str:
180
+ """
181
+ Build a rich HTML tooltip for a pyvis node.
182
+
183
+ Shows: kind badge · qualname · module path · line range · full docstring.
184
+ Rendered inside the pyvis hover popup (supports basic HTML).
185
+
186
+ :param n: Node attribute dictionary containing keys such as ``kind``,
187
+ ``qualname``, ``module_path``, ``lineno``, ``end_lineno``, and
188
+ ``docstring``.
189
+ :param color: Hex colour string used for the kind badge and left border.
190
+ :return: An HTML string suitable for use as a pyvis node ``title``.
191
+ """
192
+ kind = n.get("kind", "symbol")
193
+ qualname = n.get("qualname") or n.get("name", "")
194
+ module = n.get("module_path") or ""
195
+ lineno = n.get("lineno")
196
+ end_lineno = n.get("end_lineno")
197
+ docstring = (n.get("docstring") or "").strip()
198
+
199
+ # Line range string
200
+ if lineno and end_lineno and end_lineno != lineno:
201
+ line_str = f"lines {lineno}–{end_lineno}"
202
+ elif lineno:
203
+ line_str = f"line {lineno}"
204
+ else:
205
+ line_str = ""
206
+
207
+ # Docstring — show up to 8 lines, wrap long lines
208
+ doc_html = ""
209
+ if docstring:
210
+ doc_lines = docstring.splitlines()
211
+ shown = doc_lines[:8]
212
+ escaped = [
213
+ line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") for line in shown
214
+ ]
215
+ doc_html = (
216
+ "<hr style='border:0;border-top:1px solid #444;margin:6px 0;'>"
217
+ "<div style='font-family:monospace;font-size:11px;color:#ccc;"
218
+ "white-space:pre-wrap;max-width:380px;'>"
219
+ + "<br>".join(escaped)
220
+ + ("…" if len(doc_lines) > 8 else "")
221
+ + "</div>"
222
+ )
223
+
224
+ tooltip = (
225
+ f"<div style='font-family:sans-serif;font-size:12px;"
226
+ f"background:#1e1e2e;color:#e0e0e0;padding:10px 14px;"
227
+ f"border-radius:8px;border-left:4px solid {color};"
228
+ f"max-width:400px;'>"
229
+ f"<span style='background:{color};color:#fff;border-radius:4px;"
230
+ f"padding:1px 7px;font-size:11px;font-weight:bold;'>{kind}</span>"
231
+ f"&nbsp;&nbsp;<b style='font-size:13px;'>{qualname}</b>"
232
+ + (
233
+ f"<br><span style='color:#888;font-size:11px;'>"
234
+ f"📄 {module}" + (f" &nbsp;·&nbsp; {line_str}" if line_str else "") + "</span>"
235
+ if module
236
+ else ""
237
+ )
238
+ + doc_html
239
+ + "</div>"
240
+ )
241
+ return tooltip
242
+
243
+
244
+ def _build_pyvis(
245
+ nodes: list[dict],
246
+ edges: list[dict],
247
+ *,
248
+ height: str = "620px",
249
+ seed_ids: set[str] | None = None,
250
+ physics: bool = True,
251
+ ) -> str:
252
+ """
253
+ Build a pyvis Network from node/edge dicts and return the HTML string.
254
+
255
+ Seed nodes (from semantic search) are rendered with a gold border.
256
+ Hovering shows a rich tooltip; clicking a node opens a floating detail
257
+ panel inside the graph iframe with the full docstring and metadata.
258
+
259
+ :param nodes: List of node attribute dicts, each containing at minimum
260
+ an ``id`` key plus optional ``kind``, ``name``, ``qualname``,
261
+ ``module_path``, ``lineno``, ``end_lineno``, and ``docstring``.
262
+ :param edges: List of edge dicts with ``src``, ``dst``, and ``rel`` keys.
263
+ :param height: CSS height string for the iframe (e.g. ``"620px"``).
264
+ :param seed_ids: Set of node IDs that originated from the semantic seed
265
+ query; these are highlighted with a gold border.
266
+ :param physics: Whether to enable the Barnes-Hut physics simulation.
267
+ :return: A self-contained HTML string that renders the interactive graph.
268
+ """
269
+ net = Network(
270
+ height=height,
271
+ width="100%",
272
+ bgcolor="#0e1117",
273
+ font_color="#e0e0e0",
274
+ directed=True,
275
+ notebook=False,
276
+ )
277
+ net.set_options(
278
+ json.dumps(
279
+ {
280
+ "physics": {
281
+ "enabled": physics,
282
+ "barnesHut": {
283
+ "gravitationalConstant": -8000,
284
+ "centralGravity": 0.3,
285
+ "springLength": 120,
286
+ "springConstant": 0.04,
287
+ "damping": 0.09,
288
+ },
289
+ "stabilization": {"iterations": 150},
290
+ },
291
+ "edges": {
292
+ "smooth": {"type": "dynamic"},
293
+ "arrows": {"to": {"enabled": True, "scaleFactor": 0.6}},
294
+ "font": {"size": 10, "color": "#aaaaaa"},
295
+ },
296
+ "interaction": {
297
+ "hover": True,
298
+ "tooltipDelay": 80,
299
+ "navigationButtons": True,
300
+ "keyboard": True,
301
+ },
302
+ }
303
+ )
304
+ )
305
+
306
+ seed_ids = seed_ids or set()
307
+
308
+ # Build a JS-safe node data map for the click panel
309
+ node_data_js: dict[str, dict] = {}
310
+
311
+ for n in nodes:
312
+ kind = n.get("kind", "symbol")
313
+ color = _KIND_COLOR.get(kind, "#95A5A6")
314
+ shape = _KIND_SHAPE.get(kind, "dot")
315
+ label = n.get("name", n["id"])
316
+ if len(label) > 28:
317
+ label = label[:25] + "…"
318
+ border_color = "#FFD700" if n["id"] in seed_ids else color
319
+ tooltip = _build_node_tooltip(n, color)
320
+ net.add_node(
321
+ n["id"],
322
+ label=label,
323
+ title=tooltip,
324
+ color={
325
+ "background": color,
326
+ "border": border_color,
327
+ "highlight": {"background": color, "border": "#FFFFFF"},
328
+ },
329
+ shape=shape,
330
+ size=18 if kind in ("class", "module") else 12,
331
+ borderWidth=3 if n["id"] in seed_ids else 1,
332
+ font={"size": 11},
333
+ )
334
+ node_data_js[n["id"]] = {
335
+ "id": n["id"],
336
+ "kind": kind,
337
+ "color": color,
338
+ "qualname": n.get("qualname") or n.get("name", ""),
339
+ "module": n.get("module_path") or "",
340
+ "lineno": n.get("lineno"),
341
+ "end_lineno": n.get("end_lineno"),
342
+ "docstring": (n.get("docstring") or "").strip(),
343
+ }
344
+
345
+ for e in edges:
346
+ rel = e.get("rel", "")
347
+ ecolor = _REL_COLOR.get(rel, "#888888")
348
+ net.add_edge(
349
+ e["src"],
350
+ e["dst"],
351
+ label=rel,
352
+ color=ecolor,
353
+ width=1.5,
354
+ title=rel,
355
+ )
356
+
357
+ # Write to a temp file and read back as HTML string
358
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as f:
359
+ tmp_path = f.name
360
+ net.save_graph(tmp_path)
361
+ html = Path(tmp_path).read_text(encoding="utf-8")
362
+ os.unlink(tmp_path)
363
+
364
+ # Inject: floating click-detail panel + node data map
365
+ node_data_json = json.dumps(node_data_js, ensure_ascii=False)
366
+
367
+ panel_css = """
368
+ <style>
369
+ #pycodekg-panel {
370
+ display: none;
371
+ position: fixed;
372
+ top: 12px;
373
+ right: 12px;
374
+ width: 340px;
375
+ max-height: 88vh;
376
+ overflow-y: auto;
377
+ background: #1e1e2e;
378
+ border-radius: 10px;
379
+ box-shadow: 0 4px 24px rgba(0,0,0,0.6);
380
+ z-index: 9999;
381
+ font-family: sans-serif;
382
+ font-size: 13px;
383
+ color: #e0e0e0;
384
+ }
385
+ #pycodekg-panel-inner { padding: 14px 16px 16px 16px; }
386
+ #pycodekg-panel-close {
387
+ position: absolute;
388
+ top: 8px; right: 10px;
389
+ cursor: pointer;
390
+ font-size: 18px;
391
+ color: #888;
392
+ line-height: 1;
393
+ background: none;
394
+ border: none;
395
+ }
396
+ #pycodekg-panel-close:hover { color: #fff; }
397
+ #pycodekg-panel-docstring {
398
+ background: #12121f;
399
+ border: 1px solid #2a2a3e;
400
+ border-radius: 6px;
401
+ padding: 8px 10px;
402
+ font-family: monospace;
403
+ font-size: 12px;
404
+ color: #c9d1d9;
405
+ white-space: pre-wrap;
406
+ word-break: break-word;
407
+ margin-top: 8px;
408
+ max-height: 300px;
409
+ overflow-y: auto;
410
+ }
411
+ </style>
412
+ """
413
+
414
+ panel_html = """
415
+ <div id="pycodekg-panel">
416
+ <button id="pycodekg-panel-close" onclick="document.getElementById('pycodekg-panel').style.display='none'">✕</button>
417
+ <div id="pycodekg-panel-inner">
418
+ <div id="pycodekg-panel-badge"></div>
419
+ <div id="pycodekg-panel-qualname" style="font-size:15px;font-weight:bold;margin:6px 0 2px 0;"></div>
420
+ <div id="pycodekg-panel-meta" style="color:#888;font-size:11px;font-family:monospace;"></div>
421
+ <div id="pycodekg-panel-id" style="color:#444;font-size:10px;font-family:monospace;margin-top:2px;"></div>
422
+ <div id="pycodekg-panel-docstring"></div>
423
+ </div>
424
+ </div>
425
+ """
426
+
427
+ panel_js = f"""
428
+ <script>
429
+ (function() {{
430
+ var NODE_DATA = {node_data_json};
431
+
432
+ function showPanel(nodeId) {{
433
+ var n = NODE_DATA[nodeId];
434
+ if (!n) return;
435
+
436
+ var panel = document.getElementById('pycodekg-panel');
437
+ var badge = document.getElementById('pycodekg-panel-badge');
438
+ var qname = document.getElementById('pycodekg-panel-qualname');
439
+ var meta = document.getElementById('pycodekg-panel-meta');
440
+ var nid = document.getElementById('pycodekg-panel-id');
441
+ var doc = document.getElementById('pycodekg-panel-docstring');
442
+
443
+ badge.innerHTML = '<span style="background:' + n.color + ';color:#fff;border-radius:4px;' +
444
+ 'padding:2px 8px;font-size:11px;font-weight:bold;font-family:monospace;">' +
445
+ n.kind + '</span>';
446
+
447
+ qname.textContent = n.qualname;
448
+ qname.style.color = '#f0f0f0';
449
+
450
+ var lineStr = '';
451
+ if (n.lineno && n.end_lineno && n.end_lineno !== n.lineno) {{
452
+ lineStr = ' · lines ' + n.lineno + '–' + n.end_lineno;
453
+ }} else if (n.lineno) {{
454
+ lineStr = ' · line ' + n.lineno;
455
+ }}
456
+ meta.textContent = (n.module || '—') + lineStr;
457
+
458
+ nid.textContent = 'id: ' + n.id;
459
+
460
+ if (n.docstring) {{
461
+ doc.style.display = 'block';
462
+ doc.textContent = n.docstring;
463
+ }} else {{
464
+ doc.style.display = 'block';
465
+ doc.textContent = '(no docstring)';
466
+ doc.style.color = '#555';
467
+ }}
468
+
469
+ panel.style.borderLeft = '5px solid ' + n.color;
470
+ panel.style.display = 'block';
471
+ }}
472
+
473
+ function fixHtmlTitles() {{
474
+ // vis-network renders string titles as plain text; swap in DOM elements
475
+ // so that the rich HTML tooltips display correctly.
476
+ var ids = network.body.data.nodes.getIds();
477
+ ids.forEach(function(id) {{
478
+ var node = network.body.data.nodes.get(id);
479
+ if (node && typeof node.title === 'string' && node.title.trim().charAt(0) === '<') {{
480
+ var div = document.createElement('div');
481
+ div.innerHTML = node.title;
482
+ network.body.data.nodes.update({{id: id, title: div}});
483
+ }}
484
+ }});
485
+ }}
486
+
487
+ function waitForNetwork() {{
488
+ if (typeof network === 'undefined') {{
489
+ setTimeout(waitForNetwork, 200);
490
+ return;
491
+ }}
492
+ fixHtmlTitles();
493
+ network.on('click', function(params) {{
494
+ if (params.nodes && params.nodes.length > 0) {{
495
+ showPanel(String(params.nodes[0]));
496
+ }} else {{
497
+ // click on empty space — hide panel
498
+ document.getElementById('pycodekg-panel').style.display = 'none';
499
+ }}
500
+ }});
501
+ }}
502
+ waitForNetwork();
503
+ }})();
504
+ </script>
505
+ """
506
+
507
+ html = html.replace("</head>", panel_css + "\n</head>")
508
+ html = html.replace("</body>", panel_html + panel_js + "\n</body>")
509
+ return html
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Node detail panel
514
+ # ---------------------------------------------------------------------------
515
+
516
+
517
+ def _render_node_detail(node: dict, store: GraphStore | None = None) -> None:
518
+ """
519
+ Render a rich detail card for a single node.
520
+
521
+ Shows: kind badge, qualname, module + line range, full docstring,
522
+ and (if store is provided) the node's immediate edges.
523
+
524
+ :param node: Node attribute dictionary containing at minimum an ``id``
525
+ key plus optional ``kind``, ``qualname``, ``name``, ``module_path``,
526
+ ``lineno``, ``end_lineno``, and ``docstring``.
527
+ :param store: An optional ``GraphStore`` used to look up adjacent edges.
528
+ When ``None``, the edges section is omitted.
529
+ """
530
+ kind = node.get("kind", "symbol")
531
+ color = _KIND_COLOR.get(kind, "#95A5A6")
532
+ qualname = node.get("qualname") or node.get("name", "")
533
+ module = node.get("module_path") or ""
534
+ lineno = node.get("lineno")
535
+ end_lineno = node.get("end_lineno")
536
+ docstring = (node.get("docstring") or "").strip()
537
+ node_id = node.get("id", "")
538
+
539
+ # Line range
540
+ if lineno and end_lineno and end_lineno != lineno:
541
+ line_str = f"lines {lineno}-{end_lineno}"
542
+ elif lineno:
543
+ line_str = f"line {lineno}"
544
+ else:
545
+ line_str = "-"
546
+
547
+ st.markdown(
548
+ f"""
549
+ <div style="background:#1e1e2e;border-left:5px solid {color};
550
+ border-radius:8px;padding:14px 18px;margin-bottom:8px;">
551
+ <span style="background:{color};color:#fff;border-radius:4px;
552
+ padding:2px 9px;font-size:12px;font-weight:bold;
553
+ font-family:monospace;">{kind}</span>
554
+ &nbsp;
555
+ <span style="font-size:17px;font-weight:bold;color:#f0f0f0;">
556
+ {qualname}
557
+ </span>
558
+ <br>
559
+ <span style="color:#888;font-size:12px;font-family:monospace;">
560
+ 📄 {module or "—"} &nbsp;·&nbsp; {line_str}
561
+ </span>
562
+ <br>
563
+ <span style="color:#555;font-size:10px;font-family:monospace;">
564
+ id: {node_id}
565
+ </span>
566
+ </div>
567
+ """,
568
+ unsafe_allow_html=True,
569
+ )
570
+
571
+ if docstring:
572
+ st.markdown("**📝 Docstring**")
573
+ st.markdown(
574
+ f"""
575
+ <div style="background:#12121f;border-radius:6px;padding:10px 14px;
576
+ font-family:monospace;font-size:13px;color:#c9d1d9;
577
+ white-space:pre-wrap;border:1px solid #2a2a3e;">
578
+ {docstring.replace("<", "&lt;").replace(">", "&gt;")}
579
+ </div>
580
+ """,
581
+ unsafe_allow_html=True,
582
+ )
583
+ else:
584
+ st.caption("*No docstring.*")
585
+
586
+ # Immediate edges from the store
587
+ if store and node_id:
588
+ st.markdown("**🔗 Edges**")
589
+ try:
590
+ rows = store.con.execute(
591
+ "SELECT src, rel, dst FROM edges WHERE src = ? OR dst = ? LIMIT 60",
592
+ (node_id, node_id),
593
+ ).fetchall()
594
+ if rows:
595
+ import pandas as pd # pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
596
+
597
+ edf = pd.DataFrame([{"src": r[0], "rel": r[1], "dst": r[2]} for r in rows])
598
+ st.dataframe(edf, use_container_width=True, hide_index=True)
599
+ else:
600
+ st.caption("*No edges.*")
601
+ except (AttributeError, ValueError, RuntimeError, KeyError):
602
+ pass
603
+
604
+
605
+ def _node_detail_section(
606
+ nodes: list[dict],
607
+ store: GraphStore | None,
608
+ *,
609
+ key_prefix: str = "detail",
610
+ ) -> None:
611
+ """
612
+ Render a searchable node-detail section below a graph.
613
+
614
+ Users pick a node from a selectbox (or type a name) and see the full
615
+ detail card. This is the Streamlit-native complement to the pyvis
616
+ hover tooltip — it persists on screen and shows the complete docstring
617
+ plus edge table.
618
+
619
+ :param nodes: List of node attribute dicts to populate the selectbox.
620
+ :param store: An optional ``GraphStore`` passed through to
621
+ ``_render_node_detail`` for edge lookups.
622
+ :param key_prefix: String prefix for Streamlit widget keys, used to
623
+ avoid key collisions when multiple instances are rendered on the
624
+ same page.
625
+ """
626
+ if not nodes:
627
+ return
628
+
629
+ st.markdown("---")
630
+ st.subheader("🔎 Node Detail")
631
+
632
+ # Build label → node mapping (qualname preferred, fall back to name)
633
+ label_map: dict[str, dict] = {}
634
+ for n in nodes:
635
+ lbl = n.get("qualname") or n.get("name") or n["id"]
636
+ # Disambiguate duplicates
637
+ if lbl in label_map:
638
+ lbl = f"{lbl} [{n['id']}]"
639
+ label_map[lbl] = n
640
+
641
+ options = ["— select a node —"] + sorted(label_map.keys())
642
+ chosen = st.selectbox(
643
+ "Select node to inspect",
644
+ options=options,
645
+ index=0,
646
+ key=f"{key_prefix}_node_select",
647
+ help="Pick any node to see its full docstring and edges.",
648
+ )
649
+
650
+ if chosen and chosen != "— select a node —":
651
+ node = label_map.get(chosen)
652
+ if node:
653
+ _render_node_detail(node, store=store)
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Legend widget
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ def _render_legend() -> None:
662
+ """
663
+ Render the graph legend showing node-kind colours and edge-relation colours.
664
+
665
+ Displays colour swatches for each entry in ``_KIND_COLOR`` and
666
+ ``_REL_COLOR`` as inline Streamlit markdown columns.
667
+ """
668
+ st.markdown("**Node kinds**")
669
+ cols = st.columns(len(_KIND_COLOR))
670
+ for col, (kind, color) in zip(cols, _KIND_COLOR.items()):
671
+ col.markdown(
672
+ f'<span style="display:inline-block;width:12px;height:12px;'
673
+ f'background:{color};border-radius:50%;margin-right:4px;"></span>'
674
+ f"`{kind}`",
675
+ unsafe_allow_html=True,
676
+ )
677
+ st.markdown("**Edge relations**")
678
+ cols2 = st.columns(len(_REL_COLOR))
679
+ for col, (rel, color) in zip(cols2, _REL_COLOR.items()):
680
+ col.markdown(
681
+ f'<span style="display:inline-block;width:20px;height:3px;'
682
+ f'background:{color};margin-right:4px;vertical-align:middle;"></span>'
683
+ f"`{rel}`",
684
+ unsafe_allow_html=True,
685
+ )
686
+
687
+
688
+ # ---------------------------------------------------------------------------
689
+ # Sidebar
690
+ # ---------------------------------------------------------------------------
691
+
692
+
693
+ def _render_sidebar() -> dict:
694
+ """
695
+ Render the sidebar controls and return a configuration dictionary.
696
+
697
+ Exposes controls for the SQLite path, repo root, LanceDB directory,
698
+ embedding model, query parameters (k, hops, relations, include_symbols),
699
+ graph display options (max nodes, physics, height), and build buttons
700
+ for the graph and semantic index.
701
+
702
+ :return: A dict with keys ``db_path``, ``repo_root``, ``lancedb_dir``,
703
+ ``model``, ``k``, ``hop``, ``rels``, ``include_symbols``,
704
+ ``max_graph_nodes``, ``physics_on``, ``graph_height``, and ``store``.
705
+ """
706
+ st.sidebar.title("PyCodeKG Explorer")
707
+ st.sidebar.markdown("---")
708
+
709
+ st.sidebar.subheader("Database")
710
+ db_path = st.sidebar.text_input(
711
+ "SQLite path",
712
+ value=st.session_state.db_path,
713
+ help="Path to .pycodekg/graph.sqlite (relative or absolute)",
714
+ )
715
+ st.session_state.db_path = db_path
716
+
717
+ store = _get_store()
718
+ if store is None:
719
+ st.sidebar.warning(
720
+ f"[WARNING] `{db_path}` not found.\n\n"
721
+ "Set **Repo root** below and click **Build Graph** to create it."
722
+ )
723
+ else:
724
+ s = store.stats()
725
+ st.sidebar.success(f"{s['total_nodes']} nodes · {s['total_edges']} edges")
726
+ with st.sidebar.expander("Node counts"):
727
+ for k, v in sorted(s["node_counts"].items()):
728
+ st.write(f"`{k}`: {v}")
729
+ with st.sidebar.expander("Edge counts"):
730
+ for k, v in sorted(s["edge_counts"].items()):
731
+ st.write(f"`{k}`: {v}")
732
+
733
+ st.sidebar.markdown("---")
734
+ st.sidebar.subheader("Paths & model")
735
+
736
+ repo_root = st.sidebar.text_input(
737
+ "Repo root",
738
+ value=str(Path.cwd()),
739
+ help="Root directory of the Python repository to analyse",
740
+ )
741
+ lancedb_dir = st.sidebar.text_input(
742
+ "LanceDB dir",
743
+ value=_DEFAULT_LANCEDB,
744
+ help="Directory for the LanceDB vector index",
745
+ )
746
+ model = st.sidebar.selectbox(
747
+ "Embedding model",
748
+ [
749
+ "BAAI/bge-small-en-v1.5",
750
+ "all-mpnet-base-v2",
751
+ "all-MiniLM-L6-v2",
752
+ "jinaai/jina-embeddings-v3",
753
+ "paraphrase-MiniLM-L3-v2",
754
+ ],
755
+ index=0,
756
+ )
757
+ k = st.sidebar.slider("Top-K seeds (k)", min_value=1, max_value=30, value=8)
758
+ hop = st.sidebar.slider("Graph hops", min_value=0, max_value=4, value=1)
759
+
760
+ all_rels = list(DEFAULT_RELS)
761
+ chosen_rels = st.sidebar.multiselect(
762
+ "Edge relations",
763
+ options=all_rels,
764
+ default=all_rels,
765
+ )
766
+
767
+ include_symbols = st.sidebar.checkbox("Include symbol nodes", value=False)
768
+
769
+ st.sidebar.markdown("---")
770
+ st.sidebar.subheader("🗺️ Graph display")
771
+ max_graph_nodes = st.sidebar.slider(
772
+ "Max nodes in graph view", min_value=20, max_value=500, value=150, step=10
773
+ )
774
+ physics_on = st.sidebar.checkbox("Physics simulation", value=True)
775
+ graph_height = st.sidebar.select_slider(
776
+ "Graph height",
777
+ options=["400px", "500px", "620px", "750px", "900px"],
778
+ value="620px",
779
+ )
780
+
781
+ st.sidebar.markdown("---")
782
+ st.sidebar.subheader("🔨 Build pipeline")
783
+
784
+ build_col1, build_col2 = st.sidebar.columns(2)
785
+ build_graph_btn = build_col1.button(
786
+ "🔨 Build Graph",
787
+ help="Run AST extraction → SQLite (fast, no embeddings)",
788
+ use_container_width=True,
789
+ )
790
+ build_index_btn = build_col2.button(
791
+ "🧠 Build Index",
792
+ help="Embed nodes → LanceDB (requires graph to exist)",
793
+ use_container_width=True,
794
+ )
795
+ build_all_btn = st.sidebar.button(
796
+ "⚡ Build All (graph + index)",
797
+ help="Full pipeline: AST → SQLite → LanceDB",
798
+ use_container_width=True,
799
+ type="primary",
800
+ )
801
+
802
+ if build_graph_btn or build_all_btn:
803
+ with st.sidebar:
804
+ with st.spinner("Building graph (AST → SQLite)…"):
805
+ try:
806
+ from pycode_kg import ( # pylint: disable=import-outside-toplevel
807
+ PyCodeKG,
808
+ )
809
+
810
+ kg = PyCodeKG(
811
+ repo_root=repo_root,
812
+ db_path=db_path,
813
+ lancedb_dir=lancedb_dir,
814
+ model=model,
815
+ )
816
+ if build_all_btn:
817
+ stats = kg.build(wipe=True)
818
+ st.success(
819
+ f"✅ Built: {stats.total_nodes} nodes, "
820
+ f"{stats.total_edges} edges, "
821
+ f"{stats.indexed_rows} vectors"
822
+ )
823
+ else:
824
+ stats = kg.build_graph(wipe=True)
825
+ st.success(
826
+ f"✅ Graph: {stats.total_nodes} nodes, {stats.total_edges} edges"
827
+ )
828
+ # Invalidate cached store so sidebar refreshes
829
+ st.session_state.store_loaded_path = None
830
+ st.session_state.graph_nodes = None
831
+ _load_store.clear() # type: ignore[attr-defined]
832
+ st.rerun()
833
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
834
+ st.error(f"Build failed: {exc}")
835
+
836
+ if build_index_btn and not build_all_btn:
837
+ with st.sidebar:
838
+ with st.spinner("Building semantic index (SQLite → LanceDB)…"):
839
+ try:
840
+ from pycode_kg import ( # pylint: disable=import-outside-toplevel
841
+ PyCodeKG,
842
+ )
843
+
844
+ kg = PyCodeKG(
845
+ repo_root=repo_root,
846
+ db_path=db_path,
847
+ lancedb_dir=lancedb_dir,
848
+ model=model,
849
+ )
850
+ stats = kg.build_index(wipe=True)
851
+ st.success(f"✅ Index: {stats.indexed_rows} vectors (dim={stats.index_dim})")
852
+ _load_kg.clear() # type: ignore[attr-defined]
853
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
854
+ st.error(f"Index build failed: {exc}")
855
+
856
+ return {
857
+ "db_path": db_path,
858
+ "repo_root": repo_root,
859
+ "lancedb_dir": lancedb_dir,
860
+ "model": model,
861
+ "k": k,
862
+ "hop": hop,
863
+ "rels": tuple(chosen_rels) if chosen_rels else DEFAULT_RELS,
864
+ "include_symbols": include_symbols,
865
+ "max_graph_nodes": max_graph_nodes,
866
+ "physics_on": physics_on,
867
+ "graph_height": graph_height,
868
+ "store": store,
869
+ }
870
+
871
+
872
+ # ---------------------------------------------------------------------------
873
+ # Tab 1 — Full graph browser
874
+ # ---------------------------------------------------------------------------
875
+
876
+
877
+ def _tab_graph(cfg: dict) -> None:
878
+ """
879
+ Render the Graph Browser tab.
880
+
881
+ Loads nodes and edges from the store according to the sidebar filters,
882
+ displays the interactive pyvis graph, a node table expander, and the
883
+ node-detail section.
884
+
885
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
886
+ providing keys such as ``store``, ``max_graph_nodes``,
887
+ ``graph_height``, and ``physics_on``.
888
+ """
889
+ st.header("🗺️ Knowledge Graph Browser")
890
+ store: GraphStore | None = cfg["store"]
891
+ if store is None:
892
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
893
+ return
894
+
895
+ col1, col2, col3 = st.columns([2, 2, 1])
896
+ with col1:
897
+ kind_filter = st.multiselect(
898
+ "Filter node kinds",
899
+ options=list(_KIND_COLOR.keys()),
900
+ default=["module", "class", "function", "method"],
901
+ key="graph_kind_filter",
902
+ )
903
+ with col2:
904
+ module_filter = st.text_input(
905
+ "Filter by module path (substring)",
906
+ value="",
907
+ key="graph_module_filter",
908
+ )
909
+ with col3:
910
+ st.write("")
911
+ st.write("")
912
+ load_btn = st.button("🔄 Load / Refresh", key="graph_load_btn", type="primary")
913
+
914
+ if load_btn or st.session_state.graph_nodes is None:
915
+ with st.spinner("Loading nodes and edges…"):
916
+ nodes = store.query_nodes(kinds=kind_filter if kind_filter else None)
917
+ if module_filter.strip():
918
+ nodes = [n for n in nodes if module_filter.strip() in (n.get("module_path") or "")]
919
+ max_n = cfg["max_graph_nodes"]
920
+ if len(nodes) > max_n:
921
+ st.info(f"Showing first {max_n} of {len(nodes)} nodes (increase limit in sidebar).")
922
+ nodes = nodes[:max_n]
923
+ node_ids = {n["id"] for n in nodes}
924
+ edges = store.edges_within(node_ids)
925
+ st.session_state.graph_nodes = nodes
926
+ st.session_state.graph_edges = edges
927
+
928
+ nodes = st.session_state.graph_nodes or []
929
+ edges = st.session_state.graph_edges or []
930
+
931
+ if not nodes:
932
+ st.info("No nodes match the current filters.")
933
+ return
934
+
935
+ st.caption(f"Showing **{len(nodes)}** nodes · **{len(edges)}** edges")
936
+ _render_legend()
937
+ st.markdown("---")
938
+
939
+ html = _build_pyvis(
940
+ nodes,
941
+ edges,
942
+ height=cfg["graph_height"],
943
+ physics=cfg["physics_on"],
944
+ )
945
+ st.iframe(html, height=int(cfg["graph_height"].replace("px", "")))
946
+
947
+ with st.expander("📋 Node table"):
948
+ import pandas as pd # pylint: disable=import-outside-toplevel
949
+
950
+ df = pd.DataFrame(
951
+ [
952
+ {
953
+ "id": n["id"],
954
+ "kind": n["kind"],
955
+ "name": n["name"],
956
+ "qualname": n.get("qualname", ""),
957
+ "module": n.get("module_path", ""),
958
+ "line": n.get("lineno", ""),
959
+ }
960
+ for n in nodes
961
+ ]
962
+ )
963
+ st.dataframe(df, use_container_width=True, hide_index=True)
964
+
965
+ # Node detail panel — hover tooltip complement
966
+ _node_detail_section(nodes, store, key_prefix="graph")
967
+
968
+
969
+ # ---------------------------------------------------------------------------
970
+ # Tab 2 — Hybrid query
971
+ # ---------------------------------------------------------------------------
972
+
973
+
974
+ def _tab_query(cfg: dict) -> None:
975
+ """
976
+ Render the Hybrid Query tab.
977
+
978
+ Accepts a natural-language query, runs it through the PyCodeKG hybrid
979
+ semantic+structural search, and displays the results as a graph,
980
+ node table, edge table, and raw JSON (with a download button).
981
+
982
+ Error handling strategy: query execution failures are caught and surfaced
983
+ as inline Streamlit errors so the app remains interactive.
984
+
985
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
986
+ providing keys such as ``store``, ``repo_root``, ``db_path``,
987
+ ``lancedb_dir``, ``model``, ``k``, ``hop``, ``rels``,
988
+ ``include_symbols``, ``graph_height``, and ``physics_on``.
989
+ """
990
+ st.header("🔍 Hybrid Query")
991
+ store: GraphStore | None = cfg["store"]
992
+ if store is None:
993
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
994
+ return
995
+
996
+ query_text = st.text_input(
997
+ "Natural-language query",
998
+ placeholder="e.g. database connection setup",
999
+ key="query_input",
1000
+ )
1001
+
1002
+ run_btn = st.button("▶ Run Query", type="primary", key="run_query_btn")
1003
+
1004
+ if run_btn and query_text.strip():
1005
+ with st.spinner("Running hybrid query…"):
1006
+ try:
1007
+ kg = _load_kg(
1008
+ cfg["repo_root"],
1009
+ cfg["db_path"],
1010
+ cfg["lancedb_dir"],
1011
+ cfg["model"],
1012
+ )
1013
+ result = kg.query(
1014
+ query_text.strip(),
1015
+ k=cfg["k"],
1016
+ hop=cfg["hop"],
1017
+ rels=cfg["rels"],
1018
+ include_symbols=cfg["include_symbols"],
1019
+ )
1020
+ st.session_state.query_result = result
1021
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
1022
+ st.error(f"Query failed: {exc}")
1023
+ return
1024
+
1025
+ result = st.session_state.query_result
1026
+ if result is None:
1027
+ st.info("Enter a query above and click **Run Query**.")
1028
+ return
1029
+
1030
+ # Summary metrics
1031
+ c1, c2, c3, c4 = st.columns(4)
1032
+ c1.metric("Seeds", result.seeds)
1033
+ c2.metric("Expanded", result.expanded_nodes)
1034
+ c3.metric("Returned", result.returned_nodes)
1035
+ c4.metric("Edges", len(result.edges))
1036
+
1037
+ tab_graph, tab_table, tab_edges, tab_json = st.tabs(
1038
+ ["🗺️ Graph", "📋 Nodes", "🔗 Edges", "{ } JSON"]
1039
+ )
1040
+
1041
+ with tab_graph:
1042
+ if result.nodes:
1043
+ _render_legend()
1044
+ html = _build_pyvis(
1045
+ result.nodes,
1046
+ result.edges,
1047
+ height=cfg["graph_height"],
1048
+ physics=cfg["physics_on"],
1049
+ )
1050
+ st.iframe(html, height=int(cfg["graph_height"].replace("px", "")))
1051
+ else:
1052
+ st.info("No nodes to display.")
1053
+
1054
+ with tab_table:
1055
+ import pandas as pd # pylint: disable=import-outside-toplevel
1056
+
1057
+ df = pd.DataFrame(
1058
+ [
1059
+ {
1060
+ "kind": n["kind"],
1061
+ "name": n["name"],
1062
+ "qualname": n.get("qualname", ""),
1063
+ "module": n.get("module_path", ""),
1064
+ "line": n.get("lineno", ""),
1065
+ "docstring": (
1066
+ (n.get("docstring") or "").strip().splitlines()[0][:80]
1067
+ if n.get("docstring")
1068
+ else ""
1069
+ ),
1070
+ }
1071
+ for n in result.nodes
1072
+ ]
1073
+ )
1074
+ st.dataframe(df, use_container_width=True, hide_index=True)
1075
+
1076
+ with tab_edges:
1077
+ if result.edges:
1078
+ import pandas as pd # pylint: disable=import-outside-toplevel
1079
+
1080
+ edf = pd.DataFrame(
1081
+ [
1082
+ {"src": e["src"], "rel": e["rel"], "dst": e["dst"]}
1083
+ for e in sorted(result.edges, key=lambda x: (x["rel"], x["src"]))
1084
+ ]
1085
+ )
1086
+ st.dataframe(edf, use_container_width=True, hide_index=True)
1087
+ else:
1088
+ st.info("No edges in result set.")
1089
+
1090
+ with tab_json:
1091
+ st.download_button(
1092
+ "⬇ Download JSON",
1093
+ data=result.to_json(),
1094
+ file_name="query_result.json",
1095
+ mime="application/json",
1096
+ )
1097
+ st.code(result.to_json(), language="json")
1098
+
1099
+ # Node detail panel — below the sub-tabs
1100
+ _node_detail_section(result.nodes, store, key_prefix="query")
1101
+
1102
+
1103
+ # ---------------------------------------------------------------------------
1104
+ # Tab 3 — Snippet pack
1105
+ # ---------------------------------------------------------------------------
1106
+
1107
+
1108
+ def _tab_snippets(cfg: dict) -> None:
1109
+ """
1110
+ Render the Snippet Pack tab.
1111
+
1112
+ Accepts a query, builds a source-grounded snippet pack via PyCodeKG,
1113
+ and displays metrics, download buttons, a pack graph, per-node
1114
+ expandable code snippets, and an edges table.
1115
+
1116
+ Configuration defaults come from sidebar controls (model, graph/index paths,
1117
+ and traversal parameters). Runtime failures are reported inline to preserve
1118
+ the interactive debugging loop.
1119
+
1120
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
1121
+ providing keys such as ``store``, ``repo_root``, ``db_path``,
1122
+ ``lancedb_dir``, ``model``, ``k``, ``hop``, ``rels``,
1123
+ ``include_symbols``, and ``physics_on``.
1124
+ """
1125
+ st.header("📦 Snippet Pack")
1126
+ store: GraphStore | None = cfg["store"]
1127
+ if store is None:
1128
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
1129
+ return
1130
+
1131
+ col_q, col_ctx, col_ml, col_mn = st.columns([3, 1, 1, 1])
1132
+ with col_q:
1133
+ pack_query = st.text_input(
1134
+ "Query for snippet pack",
1135
+ placeholder="e.g. configuration loading",
1136
+ key="pack_query_input",
1137
+ )
1138
+ with col_ctx:
1139
+ context_lines = st.number_input("Context lines", min_value=0, max_value=20, value=5)
1140
+ with col_ml:
1141
+ max_lines = st.number_input(
1142
+ "Max lines/snippet", min_value=20, max_value=400, value=160, step=20
1143
+ )
1144
+ with col_mn:
1145
+ max_nodes = st.number_input("Max nodes", min_value=5, max_value=100, value=50, step=5)
1146
+
1147
+ pack_btn = st.button("📦 Build Pack", type="primary", key="pack_btn")
1148
+
1149
+ if pack_btn and pack_query.strip():
1150
+ with st.spinner("Building snippet pack…"):
1151
+ try:
1152
+ kg = _load_kg(
1153
+ cfg["repo_root"],
1154
+ cfg["db_path"],
1155
+ cfg["lancedb_dir"],
1156
+ cfg["model"],
1157
+ )
1158
+ pack = kg.pack(
1159
+ pack_query.strip(),
1160
+ k=cfg["k"],
1161
+ hop=cfg["hop"],
1162
+ rels=cfg["rels"],
1163
+ include_symbols=cfg["include_symbols"],
1164
+ context=int(context_lines),
1165
+ max_lines=int(max_lines),
1166
+ max_nodes=int(max_nodes),
1167
+ )
1168
+ st.session_state.pack_result = pack
1169
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
1170
+ st.error(f"Pack failed: {exc}")
1171
+ return
1172
+
1173
+ pack = st.session_state.pack_result
1174
+ if pack is None:
1175
+ st.info("Enter a query above and click **Build Pack**.")
1176
+ return
1177
+
1178
+ # Summary
1179
+ c1, c2, c3, c4 = st.columns(4)
1180
+ c1.metric("Seeds", pack.seeds)
1181
+ c2.metric("Expanded", pack.expanded_nodes)
1182
+ c3.metric("Returned", pack.returned_nodes)
1183
+ c4.metric("Model", pack.model)
1184
+
1185
+ # Download buttons
1186
+ dl1, dl2 = st.columns(2)
1187
+ dl1.download_button(
1188
+ "⬇ Download Markdown",
1189
+ data=pack.to_markdown(),
1190
+ file_name="snippet_pack.md",
1191
+ mime="text/markdown",
1192
+ )
1193
+ dl2.download_button(
1194
+ "⬇ Download JSON",
1195
+ data=pack.to_json(),
1196
+ file_name="snippet_pack.json",
1197
+ mime="application/json",
1198
+ )
1199
+
1200
+ st.markdown("---")
1201
+
1202
+ # Graph of pack nodes
1203
+ with st.expander("🗺️ Pack graph", expanded=False):
1204
+ _render_legend()
1205
+ html = _build_pyvis(
1206
+ pack.nodes,
1207
+ pack.edges,
1208
+ height="500px",
1209
+ physics=cfg["physics_on"],
1210
+ )
1211
+ st.iframe(html, height=500)
1212
+
1213
+ # Node cards with snippets
1214
+ st.subheader(f"Nodes ({len(pack.nodes)})")
1215
+ for n in pack.nodes:
1216
+ kind = n.get("kind", "?")
1217
+ color = _KIND_COLOR.get(kind, "#95A5A6")
1218
+ qualname = n.get("qualname") or n.get("name", "")
1219
+ module = n.get("module_path") or ""
1220
+ lineno = n.get("lineno")
1221
+ doc = (n.get("docstring") or "").strip()
1222
+ doc0 = doc.splitlines()[0][:120] if doc else ""
1223
+ snippet = n.get("snippet")
1224
+
1225
+ header = f"**`{kind}`** — `{qualname}`"
1226
+ if module:
1227
+ header += f" · `{module}`"
1228
+ if lineno:
1229
+ header += f" line {lineno}"
1230
+
1231
+ with st.expander(header, expanded=bool(snippet)):
1232
+ st.markdown(
1233
+ f'<div style="border-left:4px solid {color};padding-left:10px;">'
1234
+ f'<code style="color:{color}">{kind}</code> '
1235
+ f"<b>{qualname}</b><br>"
1236
+ f'<small style="color:#888">{module}'
1237
+ + (f" · line {lineno}" if lineno else "")
1238
+ + "</small>"
1239
+ + (f"<br><i>{doc0}</i>" if doc0 else "")
1240
+ + "</div>",
1241
+ unsafe_allow_html=True,
1242
+ )
1243
+ if snippet:
1244
+ st.code(snippet["text"], language="python")
1245
+ st.caption(f"`{snippet['path']}` lines {snippet['start']}–{snippet['end']}")
1246
+ elif doc:
1247
+ st.markdown(f"*{doc[:300]}*")
1248
+
1249
+ # Edges table
1250
+ if pack.edges:
1251
+ with st.expander(f"🔗 Edges ({len(pack.edges)})"):
1252
+ import pandas as pd # pylint: disable=import-outside-toplevel
1253
+
1254
+ edf = pd.DataFrame(
1255
+ [
1256
+ {"src": e["src"], "rel": e["rel"], "dst": e["dst"]}
1257
+ for e in sorted(pack.edges, key=lambda x: (x["rel"], x["src"]))
1258
+ ]
1259
+ )
1260
+ st.dataframe(edf, use_container_width=True, hide_index=True)
1261
+
1262
+
1263
+ # ---------------------------------------------------------------------------
1264
+ # Main
1265
+ # ---------------------------------------------------------------------------
1266
+
1267
+
1268
+ def _inject_css() -> None:
1269
+ """Inject CSS that matches Streamlit's active theme (light or dark)."""
1270
+ is_dark = st.get_option("theme.base") == "dark"
1271
+ card_bg = "rgba(255,255,255,0.06)" if is_dark else "rgba(0,0,0,0.04)"
1272
+ card_color = "#e0e0e0" if is_dark else "#111"
1273
+ small_color = "#aaa" if is_dark else "#555"
1274
+ edge_color = "#aaa" if is_dark else "#444"
1275
+ st.markdown(
1276
+ f"""
1277
+ <style>
1278
+ .stTabs [data-baseweb="tab-list"] {{ gap: 12px; }}
1279
+ .stTabs [data-baseweb="tab"] {{ font-size: 1rem; padding: 6px 18px; }}
1280
+ .node-card {{
1281
+ background: {card_bg};
1282
+ color: {card_color};
1283
+ border-left: 4px solid #4A90D9;
1284
+ border-radius: 6px;
1285
+ padding: 10px 14px;
1286
+ margin-bottom: 8px;
1287
+ font-family: monospace;
1288
+ font-size: 0.85rem;
1289
+ }}
1290
+ .node-card small {{ color: {small_color}; }}
1291
+ .edge-row {{ font-family: monospace; font-size: 0.82rem; color: {edge_color}; }}
1292
+ </style>
1293
+ """,
1294
+ unsafe_allow_html=True,
1295
+ )
1296
+
1297
+
1298
+ def main() -> None:
1299
+ """
1300
+ Application entry point for the PyCodeKG Streamlit visualizer.
1301
+
1302
+ Initialises session state, renders the sidebar, and dispatches to the
1303
+ three tab renderers: Graph Browser, Hybrid Query, and Snippet Pack.
1304
+ """
1305
+ _init_state()
1306
+ _inject_css()
1307
+ cfg = _render_sidebar()
1308
+
1309
+ st.title("🕸️ PyCodeKG Explorer")
1310
+ st.caption(
1311
+ "Interactive knowledge-graph browser for Python codebases. "
1312
+ "Built with [PyCodeKG](https://github.com/suchanek/pycode_kg) · "
1313
+ "Powered by Streamlit + pyvis."
1314
+ )
1315
+
1316
+ tab1, tab2, tab3 = st.tabs(
1317
+ [
1318
+ "🗺️ Graph Browser",
1319
+ "🔍 Hybrid Query",
1320
+ "📦 Snippet Pack",
1321
+ ]
1322
+ )
1323
+
1324
+ with tab1:
1325
+ _tab_graph(cfg)
1326
+
1327
+ with tab2:
1328
+ _tab_query(cfg)
1329
+
1330
+ with tab3:
1331
+ _tab_snippets(cfg)
1332
+
1333
+
1334
+ if __name__ == "__main__":
1335
+ main()