pycode-kg 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/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("&", "&").replace("<", "<").replace(">", ">") 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" <b style='font-size:13px;'>{qualname}</b>"
|
|
232
|
+
+ (
|
|
233
|
+
f"<br><span style='color:#888;font-size:11px;'>"
|
|
234
|
+
f"📄 {module}" + (f" · {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
|
+
|
|
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 "—"} · {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("<", "<").replace(">", ">")}
|
|
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()
|