kgmodule-utils 0.6.2__tar.gz → 0.7.0__tar.gz

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 (35) hide show
  1. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/PKG-INFO +7 -5
  2. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/README.md +4 -4
  3. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/pyproject.toml +6 -1
  4. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/__init__.py +5 -1
  5. kgmodule_utils-0.7.0/src/kg_utils/analysis/__init__.py +19 -0
  6. kgmodule_utils-0.7.0/src/kg_utils/analysis/scores.py +329 -0
  7. kgmodule_utils-0.7.0/src/kg_utils/viz/__init__.py +33 -0
  8. kgmodule_utils-0.7.0/src/kg_utils/viz/graph_html.py +412 -0
  9. kgmodule_utils-0.7.0/src/kg_utils/viz/theme.py +135 -0
  10. kgmodule_utils-0.7.0/src/kg_utils/viz/tooltip.py +119 -0
  11. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/LICENSE +0 -0
  12. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/corpus_embedder.py +0 -0
  13. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/embed.py +0 -0
  14. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/embedder.py +0 -0
  15. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/extractor.py +0 -0
  16. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/module.py +0 -0
  17. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/pipeline.py +0 -0
  18. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/py.typed +0 -0
  19. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/retrieval/__init__.py +0 -0
  20. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/retrieval/hits.py +0 -0
  21. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/semantic.py +0 -0
  22. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/__init__.py +0 -0
  23. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/manager.py +0 -0
  24. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/models.py +0 -0
  25. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/specs.py +0 -0
  26. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/store.py +0 -0
  27. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/__init__.py +0 -0
  28. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_config.py +0 -0
  29. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_image.py +0 -0
  30. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_text.py +0 -0
  31. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/factory.py +0 -0
  32. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/vector_backend.py +0 -0
  33. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/worker/__init__.py +0 -0
  34. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/worker/client.py +0 -0
  35. {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/worker/ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kgmodule-utils
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Shared types, graph store, semantic index, and pipeline base for the KGModule SDK
5
5
  License: Elastic-2.0
6
6
  License-File: LICENSE
@@ -17,6 +17,7 @@ Provides-Extra: semantic
17
17
  Provides-Extra: sqlite-vec
18
18
  Provides-Extra: synthesis
19
19
  Provides-Extra: synthesis-mflux
20
+ Provides-Extra: viz
20
21
  Requires-Dist: httpx (>=0.27.0) ; extra == "synthesis"
21
22
  Requires-Dist: httpx (>=0.27.0) ; extra == "synthesis-mflux"
22
23
  Requires-Dist: lancedb (>=0.19.0) ; extra == "semantic"
@@ -26,6 +27,7 @@ Requires-Dist: openai (>=1.30.0) ; extra == "synthesis"
26
27
  Requires-Dist: openai (>=1.30.0) ; extra == "synthesis-mflux"
27
28
  Requires-Dist: pillow (>=10.0.0) ; extra == "synthesis"
28
29
  Requires-Dist: pillow (>=10.0.0) ; extra == "synthesis-mflux"
30
+ Requires-Dist: pyvis (>=0.3.2) ; extra == "viz"
29
31
  Requires-Dist: rich (>=13.0.0) ; extra == "semantic"
30
32
  Requires-Dist: sentence-transformers (>=5.4.1) ; extra == "semantic"
31
33
  Requires-Dist: sqlite-vec (==0.1.9) ; extra == "sqlite-vec"
@@ -40,7 +42,7 @@ Description-Content-Type: text/markdown
40
42
  [![Version](https://img.shields.io/badge/version-0.6.2-blue.svg)](https://github.com/Flux-Frontiers/KG_utils/releases)
41
43
  [![CI](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml)
42
44
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
43
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21364005.svg)](https://doi.org/10.5281/zenodo.21364005)
45
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21387077.svg)](https://doi.org/10.5281/zenodo.21387077)
44
46
 
45
47
  # kgmodule-utils
46
48
 
@@ -319,11 +321,11 @@ poetry run pytest
319
321
 
320
322
  If you use kgmodule-utils in research or a project, please cite it:
321
323
 
322
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21364005.svg)](https://doi.org/10.5281/zenodo.21364005)
324
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21387077.svg)](https://doi.org/10.5281/zenodo.21387077)
323
325
 
324
326
  **APA**
325
327
 
326
- > Suchanek, E. G. (2026). *kgmodule-utils: Shared SDK for the KGModule Knowledge-Graph Ecosystem* (Version 0.6.2) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.21364005
328
+ > Suchanek, E. G. (2026). *kgmodule-utils: Shared SDK for the KGModule Knowledge-Graph Ecosystem* (Version 0.6.2) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.21387077
327
329
 
328
330
  **BibTeX**
329
331
 
@@ -335,7 +337,7 @@ If you use kgmodule-utils in research or a project, please cite it:
335
337
  year = {2026},
336
338
  publisher = {Flux-Frontiers},
337
339
  url = {https://github.com/Flux-Frontiers/KG_utils},
338
- doi = {10.5281/zenodo.21364005},
340
+ doi = {10.5281/zenodo.21387077},
339
341
  }
340
342
  ```
341
343
 
@@ -4,7 +4,7 @@
4
4
  [![Version](https://img.shields.io/badge/version-0.6.2-blue.svg)](https://github.com/Flux-Frontiers/KG_utils/releases)
5
5
  [![CI](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml)
6
6
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
7
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21364005.svg)](https://doi.org/10.5281/zenodo.21364005)
7
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21387077.svg)](https://doi.org/10.5281/zenodo.21387077)
8
8
 
9
9
  # kgmodule-utils
10
10
 
@@ -283,11 +283,11 @@ poetry run pytest
283
283
 
284
284
  If you use kgmodule-utils in research or a project, please cite it:
285
285
 
286
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21364005.svg)](https://doi.org/10.5281/zenodo.21364005)
286
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21387077.svg)](https://doi.org/10.5281/zenodo.21387077)
287
287
 
288
288
  **APA**
289
289
 
290
- > Suchanek, E. G. (2026). *kgmodule-utils: Shared SDK for the KGModule Knowledge-Graph Ecosystem* (Version 0.6.2) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.21364005
290
+ > Suchanek, E. G. (2026). *kgmodule-utils: Shared SDK for the KGModule Knowledge-Graph Ecosystem* (Version 0.6.2) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.21387077
291
291
 
292
292
  **BibTeX**
293
293
 
@@ -299,7 +299,7 @@ If you use kgmodule-utils in research or a project, please cite it:
299
299
  year = {2026},
300
300
  publisher = {Flux-Frontiers},
301
301
  url = {https://github.com/Flux-Frontiers/KG_utils},
302
- doi = {10.5281/zenodo.21364005},
302
+ doi = {10.5281/zenodo.21387077},
303
303
  }
304
304
  ```
305
305
 
@@ -10,7 +10,7 @@ build-backend = "poetry.core.masonry.api"
10
10
 
11
11
  [project]
12
12
  name = "kgmodule-utils"
13
- version = "0.6.2"
13
+ version = "0.7.0"
14
14
  description = "Shared types, graph store, semantic index, and pipeline base for the KGModule SDK"
15
15
  readme = "README.md"
16
16
  license = { text = "Elastic-2.0" }
@@ -37,6 +37,11 @@ semantic = [
37
37
  "torch>=2.5.1",
38
38
  "transformers>=4.40.0,<4.57",
39
39
  ]
40
+ # Shared graph rendering (kg_utils.viz). Kept out of the core so the
41
+ # zero-dependency install stays zero-dependency.
42
+ viz = [
43
+ "pyvis>=0.3.2",
44
+ ]
40
45
  # Exact pin: sqlite-vec is pre-1.0, breaking minors are possible. Prebuilt
41
46
  # wheels exist for macOS arm64 + Linux x86_64 (no compile step).
42
47
  sqlite-vec = [
@@ -18,6 +18,10 @@ Sub-packages / modules:
18
18
  mflux-local | mflux-serve | openai (image).
19
19
  kg_utils.worker — RunPod worker protocol helpers and WorkerClient for /runsync calls.
20
20
  kg_utils.retrieval — Shared retrieval helpers: hit_to_dict, attach_content_by_sqlite.
21
+ kg_utils.analysis — ScoreSet, load_scores, available_metrics: persisted
22
+ centrality read back for ranking and visual encoding.
23
+ kg_utils.viz — GraphTheme, TooltipSpec, build_graph_html, select_nodes:
24
+ shared interactive graph rendering (needs the 'viz' extra).
21
25
 
22
26
  Optional extras
23
27
  ---------------
@@ -26,4 +30,4 @@ Optional extras
26
30
  pip install 'kgmodule-utils[synthesis-mflux]' # + mflux (Apple Silicon local gen)
27
31
  """
28
32
 
29
- __version__ = "0.6.2"
33
+ __version__ = "0.7.0"
@@ -0,0 +1,19 @@
1
+ """Domain-agnostic analysis helpers shared by KG modules."""
2
+
3
+ from kg_utils.analysis.scores import (
4
+ METRIC_TABLES,
5
+ MetricRef,
6
+ Scaler,
7
+ ScoreSet,
8
+ available_metrics,
9
+ load_scores,
10
+ )
11
+
12
+ __all__ = [
13
+ "METRIC_TABLES",
14
+ "MetricRef",
15
+ "Scaler",
16
+ "ScoreSet",
17
+ "available_metrics",
18
+ "load_scores",
19
+ ]
@@ -0,0 +1,329 @@
1
+ """
2
+ Read persisted node-level metrics so renderers can encode them visually.
3
+
4
+ KG modules that compute structural importance persist it to one of two tables:
5
+ ``centrality_scores`` (which also records the parameters used) or
6
+ ``node_metrics``. Renderers read it back through this module to drive node size
7
+ and opacity, so a graph shows what matters rather than only what kind each node
8
+ is.
9
+
10
+ This module closes that gap. Both tables are already long-format and keyed by
11
+ ``(node_id, metric)``, which is exactly the shape needed to offer a metric
12
+ selector, so no schema change is required.
13
+
14
+ Neither table is part of the base graph schema — each is created lazily by its
15
+ writer — so every function here treats a missing table, a missing database, or
16
+ an unknown metric as "no data" rather than an error. Renderers degrade to
17
+ uniform sizing.
18
+
19
+ Typical use::
20
+
21
+ metrics = available_metrics("graph.sqlite")
22
+ scores = load_scores("graph.sqlite", metrics[0].metric)
23
+ if scores:
24
+ radius = scores.scaled(node_id, 0.4, 2.0, default=0.7)
25
+ alpha = 0.35 + 0.65 * scores.percentile(node_id)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ import sqlite3
32
+ from collections.abc import Mapping
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Final, Literal
36
+
37
+ __all__ = [
38
+ "METRIC_TABLES",
39
+ "MetricRef",
40
+ "ScoreSet",
41
+ "Scaler",
42
+ "available_metrics",
43
+ "load_scores",
44
+ ]
45
+
46
+ #: Tables scanned for metrics, in preference order. ``centrality_scores`` is
47
+ #: first because its writer also records the parameters used.
48
+ METRIC_TABLES: Final[tuple[str, ...]] = ("centrality_scores", "node_metrics")
49
+
50
+ #: How a raw score is mapped onto a visual range.
51
+ Scaler = Literal["log", "linear", "rank"]
52
+
53
+ #: Guards ``log(0)`` when a metric's minimum equals one of its values.
54
+ _LOG_EPSILON: Final[float] = 1e-9
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class MetricRef:
59
+ """A metric that exists in the database and can be loaded.
60
+
61
+ :param metric: Metric name as stored, e.g. ``"sir_pagerank"``.
62
+ :param table: Table the metric was found in.
63
+ :param count: Number of nodes carrying a score for this metric.
64
+ """
65
+
66
+ metric: str
67
+ table: str
68
+ count: int
69
+
70
+ @property
71
+ def label(self) -> str:
72
+ """Human-readable label for a selector widget.
73
+
74
+ :return: The metric name with underscores replaced by spaces.
75
+ """
76
+ return self.metric.replace("_", " ")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ScoreSet:
81
+ """Scores for one metric, with rank and percentile derived on load.
82
+
83
+ Ranks are computed from the loaded scores rather than read from the
84
+ ``rank`` column of ``centrality_scores``. The stored rank reflects whatever
85
+ set the writer ranked, which may have been filtered or truncated; deriving
86
+ it here guarantees dense, consistent ranks over exactly the rows loaded, and
87
+ means ``node_metrics`` (which has no rank column) behaves identically.
88
+
89
+ :param metric: Metric name.
90
+ :param table: Table the scores came from.
91
+ :param scores: Mapping of node ID to raw score.
92
+ :param ranks: Mapping of node ID to 1-based rank, 1 being most central.
93
+ """
94
+
95
+ metric: str
96
+ table: str
97
+ scores: Mapping[str, float]
98
+ ranks: Mapping[str, int]
99
+
100
+ def __len__(self) -> int:
101
+ """Number of scored nodes.
102
+
103
+ :return: Count of nodes carrying a score.
104
+ """
105
+ return len(self.scores)
106
+
107
+ def __contains__(self, node_id: str) -> bool:
108
+ """Whether *node_id* carries a score.
109
+
110
+ :param node_id: Node ID to test.
111
+ :return: ``True`` if scored.
112
+ """
113
+ return node_id in self.scores
114
+
115
+ def score(self, node_id: str, default: float = 0.0) -> float:
116
+ """Raw score for *node_id*.
117
+
118
+ :param node_id: Node ID to look up.
119
+ :param default: Returned when the node is unscored.
120
+ :return: The stored score, or *default*.
121
+ """
122
+ return self.scores.get(node_id, default)
123
+
124
+ def rank(self, node_id: str) -> int | None:
125
+ """1-based rank for *node_id*, 1 being most central.
126
+
127
+ :param node_id: Node ID to look up.
128
+ :return: The rank, or ``None`` when the node is unscored.
129
+ """
130
+ return self.ranks.get(node_id)
131
+
132
+ def percentile(self, node_id: str, default: float = 0.0) -> float:
133
+ """Rank percentile in ``[0, 1]``, where ``1.0`` is the most central node.
134
+
135
+ Percentile is preferred over the raw score for opacity because
136
+ PageRank-style scores are heavily skewed — a linear map on raw values
137
+ leaves almost every node at the bottom of the range.
138
+
139
+ :param node_id: Node ID to look up.
140
+ :param default: Returned when the node is unscored.
141
+ :return: Percentile in ``[0, 1]``, or *default*.
142
+ """
143
+ rank = self.ranks.get(node_id)
144
+ if rank is None:
145
+ return default
146
+ n = len(self.ranks)
147
+ if n <= 1:
148
+ return 1.0
149
+ return (n - rank) / (n - 1)
150
+
151
+ def scaled(
152
+ self,
153
+ node_id: str,
154
+ lo: float,
155
+ hi: float,
156
+ *,
157
+ scaler: Scaler = "rank",
158
+ default: float | None = None,
159
+ ) -> float:
160
+ """Map *node_id*'s score onto the range ``[lo, hi]``.
161
+
162
+ ``"rank"`` is the default because it is the only option that uses the
163
+ full output range on real data. Centrality scores are extremely
164
+ top-heavy — on a real code graph the median score was 1.2x the
165
+ minimum while the maximum was 58x it — and both alternatives handle that
166
+ badly. ``"linear"`` collapses three quarters of the nodes onto the
167
+ floor; ``"log"`` overcorrects, because the single smallest value
168
+ stretches the bottom of the range far enough to push the bulk to ~0.73
169
+ of it. Measured interquartile spread over an 8-42 px range was 1 px for
170
+ ``"linear"``, 4 px for ``"log"`` and 8 px for ``"rank"``.
171
+
172
+ The trade is that ``"rank"`` discards magnitude, implying visual
173
+ difference between scores that are nearly identical. That is acceptable
174
+ for a node-link diagram, where size is a navigational affordance rather
175
+ than a measurement — callers wanting faithful magnitude should pass
176
+ ``"linear"`` and read exact values from the score itself.
177
+
178
+ :param node_id: Node ID to look up.
179
+ :param lo: Lower bound of the output range.
180
+ :param hi: Upper bound of the output range.
181
+ :param scaler: One of ``"rank"`` (default), ``"log"`` or ``"linear"``.
182
+ :param default: Returned when the node is unscored. When ``None``, the
183
+ midpoint of ``[lo, hi]`` is used.
184
+ :return: A value within ``[lo, hi]``.
185
+ """
186
+ if node_id not in self.scores:
187
+ return (lo + hi) / 2.0 if default is None else default
188
+ return lo + self._fraction(node_id, scaler) * (hi - lo)
189
+
190
+ def _fraction(self, node_id: str, scaler: Scaler) -> float:
191
+ """Position of *node_id* within the metric's spread, in ``[0, 1]``.
192
+
193
+ :param node_id: Node ID to look up.
194
+ :param scaler: Scaling strategy.
195
+ :return: Fraction in ``[0, 1]``.
196
+ """
197
+ if scaler == "rank":
198
+ return self.percentile(node_id)
199
+
200
+ values = self.scores.values()
201
+ lo_val, hi_val = min(values), max(values)
202
+ if math.isclose(lo_val, hi_val):
203
+ # A degenerate metric carries no information; sit at the midpoint
204
+ # rather than pinning every node to one end of the range.
205
+ return 0.5
206
+
207
+ value = self.scores[node_id]
208
+ if scaler == "log":
209
+ shift = _LOG_EPSILON - lo_val
210
+ span = math.log(hi_val + shift) - math.log(lo_val + shift)
211
+ if span <= 0.0:
212
+ return 0.5
213
+ return (math.log(value + shift) - math.log(lo_val + shift)) / span
214
+ return (value - lo_val) / (hi_val - lo_val)
215
+
216
+
217
+ def _table_exists(con: sqlite3.Connection, table: str) -> bool:
218
+ """Whether *table* exists in the connected database.
219
+
220
+ :param con: Open SQLite connection.
221
+ :param table: Table name.
222
+ :return: ``True`` when the table is present.
223
+ """
224
+ row = con.execute(
225
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
226
+ ).fetchone()
227
+ return row is not None
228
+
229
+
230
+ def _connect(db_path: str | Path) -> sqlite3.Connection | None:
231
+ """Open *db_path* read-only, or return ``None`` when it does not exist.
232
+
233
+ A plain :func:`sqlite3.connect` would *create* a missing database, which
234
+ would silently produce an empty graph rather than a clear "not built yet".
235
+
236
+ :param db_path: Path to the graph database.
237
+ :return: An open connection, or ``None``.
238
+ """
239
+ path = Path(db_path)
240
+ if not path.is_file():
241
+ return None
242
+ try:
243
+ return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
244
+ except sqlite3.Error:
245
+ return None
246
+
247
+
248
+ def available_metrics(db_path: str | Path) -> list[MetricRef]:
249
+ """List every metric present in the database.
250
+
251
+ Metrics are returned in table preference order (``centrality_scores`` before
252
+ ``node_metrics``), then by descending coverage. A metric name appearing in
253
+ both tables yields two entries, distinguished by :attr:`MetricRef.table`.
254
+
255
+ :param db_path: Path to the graph database.
256
+ :return: Available metrics; empty when the database or both tables are
257
+ absent.
258
+ """
259
+ con = _connect(db_path)
260
+ if con is None:
261
+ return []
262
+
263
+ found: list[MetricRef] = []
264
+ try:
265
+ for table in METRIC_TABLES:
266
+ if not _table_exists(con, table):
267
+ continue
268
+ rows = con.execute(
269
+ f"SELECT metric, COUNT(*) FROM {table} GROUP BY metric" # noqa: S608
270
+ ).fetchall()
271
+ found.extend(
272
+ MetricRef(metric=metric, table=table, count=count)
273
+ for metric, count in rows
274
+ if count
275
+ )
276
+ except sqlite3.Error:
277
+ return []
278
+ finally:
279
+ con.close()
280
+
281
+ order = {table: i for i, table in enumerate(METRIC_TABLES)}
282
+ found.sort(key=lambda ref: (order.get(ref.table, 99), -ref.count, ref.metric))
283
+ return found
284
+
285
+
286
+ def load_scores(
287
+ db_path: str | Path,
288
+ metric: str,
289
+ *,
290
+ table: str | None = None,
291
+ ) -> ScoreSet | None:
292
+ """Load all scores for *metric*.
293
+
294
+ :param db_path: Path to the graph database.
295
+ :param metric: Metric name to load.
296
+ :param table: Restrict the lookup to one table. When ``None``, the tables
297
+ in :data:`METRIC_TABLES` are tried in order and the first with rows for
298
+ *metric* wins.
299
+ :return: A :class:`ScoreSet`, or ``None`` when the metric is not present.
300
+ """
301
+ con = _connect(db_path)
302
+ if con is None:
303
+ return None
304
+
305
+ tables = (table,) if table else METRIC_TABLES
306
+ try:
307
+ for name in tables:
308
+ if name is None or not _table_exists(con, name):
309
+ continue
310
+ rows = con.execute(
311
+ f"SELECT node_id, score FROM {name} WHERE metric = ?", # noqa: S608
312
+ (metric,),
313
+ ).fetchall()
314
+ if not rows:
315
+ continue
316
+ scores = {node_id: float(score) for node_id, score in rows}
317
+ ranks = {
318
+ node_id: i
319
+ for i, (node_id, _) in enumerate(
320
+ sorted(scores.items(), key=lambda kv: (-kv[1], kv[0])), start=1
321
+ )
322
+ }
323
+ return ScoreSet(metric=metric, table=name, scores=scores, ranks=ranks)
324
+ except sqlite3.Error:
325
+ return None
326
+ finally:
327
+ con.close()
328
+
329
+ return None
@@ -0,0 +1,33 @@
1
+ """Shared graph visualisation for KG modules.
2
+
3
+ Requires the ``viz`` extra::
4
+
5
+ pip install 'kgmodule-utils[viz]'
6
+
7
+ Domain differences are supplied as data — a :class:`GraphTheme` naming the
8
+ domain's kinds and relations, and a :class:`TooltipSpec` naming the fields worth
9
+ showing — so every module shares one rendering implementation.
10
+ """
11
+
12
+ from kg_utils.viz.graph_html import (
13
+ CENTRALITY_MIN_OPACITY,
14
+ CENTRALITY_SIZE_RANGE,
15
+ SEED_FRACTION,
16
+ build_graph_html,
17
+ select_nodes,
18
+ )
19
+ from kg_utils.viz.theme import GraphTheme, KindStyle, with_alpha
20
+ from kg_utils.viz.tooltip import TooltipRow, TooltipSpec
21
+
22
+ __all__ = [
23
+ "CENTRALITY_MIN_OPACITY",
24
+ "CENTRALITY_SIZE_RANGE",
25
+ "SEED_FRACTION",
26
+ "GraphTheme",
27
+ "KindStyle",
28
+ "TooltipRow",
29
+ "TooltipSpec",
30
+ "build_graph_html",
31
+ "select_nodes",
32
+ "with_alpha",
33
+ ]
@@ -0,0 +1,412 @@
1
+ """
2
+ Render a knowledge graph as a self-contained interactive HTML page.
3
+
4
+ One implementation shared by every KG module. Domain differences arrive as
5
+ data: a :class:`~kg_utils.viz.theme.GraphTheme` naming the domain's kinds and
6
+ relations, and a :class:`~kg_utils.viz.tooltip.TooltipSpec` naming the fields
7
+ worth showing. A plain callable can replace the spec when a domain needs markup
8
+ the spec cannot express.
9
+
10
+ The output inlines vis-network, so the page opens from ``file://`` and survives
11
+ being embedded in a ``srcdoc`` iframe — both of which pyvis's default
12
+ ``cdn_resources="local"`` breaks, silently, by emitting relative asset paths.
13
+
14
+ Requires the ``viz`` extra::
15
+
16
+ pip install 'kgmodule-utils[viz]'
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from collections.abc import Callable, Mapping, Sequence
25
+ from pathlib import Path
26
+ from typing import Any, Final
27
+
28
+ from kg_utils.viz.theme import GraphTheme, with_alpha
29
+ from kg_utils.viz.tooltip import TooltipSpec
30
+
31
+ __all__ = [
32
+ "CENTRALITY_MIN_OPACITY",
33
+ "CENTRALITY_SIZE_RANGE",
34
+ "SEED_FRACTION",
35
+ "build_graph_html",
36
+ "select_nodes",
37
+ ]
38
+
39
+ #: Node diameter range in pixels when a metric drives sizing.
40
+ CENTRALITY_SIZE_RANGE: Final[tuple[int, int]] = (8, 42)
41
+
42
+ #: Opacity floor for the least central node. Never 0 — an invisible node is
43
+ #: indistinguishable from a missing one.
44
+ CENTRALITY_MIN_OPACITY: Final[float] = 0.35
45
+
46
+ #: Share of the node budget spent on seeds; the rest goes to their neighbours.
47
+ SEED_FRACTION: Final[int] = 4
48
+
49
+ #: Border colour for nodes the caller asked to highlight.
50
+ _HIGHLIGHT_COLOR: Final[str] = "#FFD700"
51
+
52
+ _MAX_LABEL = 28
53
+
54
+ _PHYSICS_OPTIONS: Final[dict[str, Any]] = {
55
+ "barnesHut": {
56
+ "gravitationalConstant": -8000,
57
+ "centralGravity": 0.3,
58
+ "springLength": 120,
59
+ "springConstant": 0.04,
60
+ "damping": 0.09,
61
+ },
62
+ "stabilization": {"iterations": 150},
63
+ }
64
+
65
+
66
+ def _describe(scores: Any, kept: Sequence[str], suffix: str) -> str:
67
+ """Summarise a selection for display above the graph.
68
+
69
+ Reporting how many kept nodes carry no score matters once neighbours are
70
+ pulled in by expansion: those are frequently unscored, and a reader should
71
+ not assume every node on screen was ranked.
72
+
73
+ :param scores: The active ``ScoreSet``.
74
+ :param kept: Node IDs that were kept.
75
+ :param suffix: Extra text describing the strategy.
76
+ :return: Human-readable description.
77
+ """
78
+ unscored = sum(1 for i in kept if scores.rank(i) is None)
79
+ tail = f" ({unscored} unscored)" if unscored else ""
80
+ return f"most central by {scores.metric}{suffix}{tail}"
81
+
82
+
83
+ def select_nodes(
84
+ nodes: list[dict],
85
+ limit: int,
86
+ scores: Any | None,
87
+ mode: str = "central",
88
+ expand: Callable[[set[str], int], set[str]] | None = None,
89
+ ) -> tuple[list[dict], str]:
90
+ """Reduce *nodes* to at most *limit*, choosing which ones to keep.
91
+
92
+ A graph can only draw a few hundred nodes, so which ones survive the cap
93
+ decides what the reader actually sees. Two strategies:
94
+
95
+ * ``"path"`` keeps the store's natural order. Because that order is
96
+ contiguous by source, the result is well connected — but it is an
97
+ arbitrary slice, typically alphabetical.
98
+ * ``"central"`` seeds on the most central nodes and pulls in their graph
99
+ neighbours until the budget is spent.
100
+
101
+ Seeding *and expanding* rather than taking the top N is the important
102
+ detail. The most central nodes are scattered, so keeping only them strands
103
+ most of them: measured on a 10k-node code graph at a cap of 150,
104
+ top-N-by-centrality left 47 nodes with no edge at all and halved the edge
105
+ count, producing a field of dots rather than a graph.
106
+
107
+ :param nodes: Candidate nodes, already filtered.
108
+ :param limit: Maximum number to keep.
109
+ :param scores: Active ``ScoreSet``, or ``None``.
110
+ :param mode: ``"central"`` or ``"path"``.
111
+ :param expand: Callable taking ``(seed_ids, hop)`` and returning the node IDs
112
+ reachable within that many hops. Without it, ``"central"`` degrades to
113
+ top-N by centrality.
114
+ :return: The kept nodes and a short description of how they were chosen.
115
+ """
116
+ if len(nodes) <= limit:
117
+ return nodes, "all matching nodes"
118
+ if mode != "central" or scores is None:
119
+ return nodes[:limit], "first in store order"
120
+
121
+ by_id = {n["id"]: n for n in nodes}
122
+ ranked = sorted(by_id, key=lambda i: (scores.rank(i) is None, scores.rank(i) or 0, i))
123
+
124
+ if expand is None:
125
+ top = ranked[:limit]
126
+ return [by_id[i] for i in top], _describe(scores, top, "")
127
+
128
+ seed_count = max(1, limit // SEED_FRACTION)
129
+ seeds = set(ranked[:seed_count])
130
+ reachable = expand(seeds, 1) & by_id.keys()
131
+
132
+ kept: list[str] = list(ranked[:seed_count])
133
+ seen = set(kept)
134
+ for node_id in sorted(
135
+ reachable, key=lambda i: (scores.rank(i) is None, scores.rank(i) or 0, i)
136
+ ):
137
+ if len(kept) >= limit:
138
+ break
139
+ if node_id not in seen:
140
+ kept.append(node_id)
141
+ seen.add(node_id)
142
+ for node_id in ranked:
143
+ if len(kept) >= limit:
144
+ break
145
+ if node_id not in seen:
146
+ kept.append(node_id)
147
+ seen.add(node_id)
148
+
149
+ return [by_id[i] for i in kept], _describe(scores, kept, ", plus neighbours")
150
+
151
+
152
+ def build_graph_html(
153
+ nodes: Sequence[Mapping[str, Any]],
154
+ edges: Sequence[Mapping[str, Any]],
155
+ *,
156
+ theme: GraphTheme,
157
+ tooltip: TooltipSpec | Callable[[Mapping[str, Any], str], str] | None = None,
158
+ scores: Any | None = None,
159
+ height: str = "620px",
160
+ physics: bool = True,
161
+ highlight_ids: set[str] | None = None,
162
+ label_field: str = "name",
163
+ ) -> str:
164
+ """Render *nodes* and *edges* as a self-contained interactive HTML page.
165
+
166
+ When *scores* is supplied, node diameter and opacity both encode the
167
+ metric's rank percentile. Without it, diameter comes from the theme's
168
+ per-kind size and every node is fully opaque.
169
+
170
+ :param nodes: Node attribute mappings; each needs at least an ``id``.
171
+ :param edges: Edge mappings with ``src``, ``dst`` and ``rel`` keys.
172
+ :param theme: Visual vocabulary for this domain.
173
+ :param tooltip: A :class:`TooltipSpec`, or a callable taking
174
+ ``(node, color)`` and returning HTML. ``None`` shows kind and name only.
175
+ :param scores: ``ScoreSet`` driving size and opacity, or ``None``.
176
+ :param height: CSS height for the canvas, e.g. ``"620px"``.
177
+ :param physics: Whether to run the Barnes-Hut simulation.
178
+ :param highlight_ids: Node IDs to mark with a gold border — query seeds,
179
+ search hits, whatever the caller wants to point at.
180
+ :param label_field: Node key used for the on-canvas label.
181
+ :return: A self-contained HTML document.
182
+ """
183
+ from pyvis.network import Network # noqa: PLC0415 — keeps the viz extra optional
184
+
185
+ net = Network(
186
+ height=height,
187
+ width="100%",
188
+ bgcolor="#0e1117",
189
+ font_color="#e0e0e0",
190
+ directed=True,
191
+ notebook=False,
192
+ # pyvis defaults to cdn_resources="local", which emits *relative* asset
193
+ # paths plus a cdnjs fallback, and writes a lib/ directory into the
194
+ # working directory. Relative paths cannot resolve inside a srcdoc
195
+ # iframe, so the graph renders only when cdnjs is reachable and fails
196
+ # silently with "vis is not defined" otherwise. "in_line" inlines
197
+ # vis-network, making the page genuinely self-contained.
198
+ cdn_resources="in_line",
199
+ )
200
+ net.set_options(
201
+ json.dumps(
202
+ {
203
+ "physics": {"enabled": physics, **_PHYSICS_OPTIONS},
204
+ "edges": {
205
+ "smooth": {"type": "dynamic"},
206
+ "arrows": {"to": {"enabled": True, "scaleFactor": 0.6}},
207
+ "font": {"size": 10, "color": "#aaaaaa"},
208
+ },
209
+ "interaction": {
210
+ "hover": True,
211
+ "tooltipDelay": 80,
212
+ "navigationButtons": True,
213
+ "keyboard": True,
214
+ },
215
+ }
216
+ )
217
+ )
218
+
219
+ highlight_ids = highlight_ids or set()
220
+ lo_size, hi_size = CENTRALITY_SIZE_RANGE
221
+ panel_data: dict[str, dict[str, Any]] = {}
222
+
223
+ for node in nodes:
224
+ node_id = node["id"]
225
+ style = theme.style_of(node)
226
+ color = style.color
227
+
228
+ label = str(node.get(label_field) or node_id)
229
+ if len(label) > _MAX_LABEL:
230
+ label = label[: _MAX_LABEL - 3] + "…"
231
+
232
+ if tooltip is None:
233
+ title = f"{node.get('kind', '')}: {label}"
234
+ elif isinstance(tooltip, TooltipSpec):
235
+ title = tooltip.render(node, color)
236
+ else:
237
+ title = tooltip(node, color)
238
+
239
+ if scores is None:
240
+ size: float = style.size
241
+ background = color
242
+ else:
243
+ size = scores.scaled(node_id, lo_size, hi_size, default=lo_size)
244
+ alpha = CENTRALITY_MIN_OPACITY + (1.0 - CENTRALITY_MIN_OPACITY) * scores.percentile(
245
+ node_id
246
+ )
247
+ background = with_alpha(color, alpha)
248
+
249
+ highlighted = node_id in highlight_ids
250
+ net.add_node(
251
+ node_id,
252
+ label=label,
253
+ title=title,
254
+ color={
255
+ "background": background,
256
+ "border": _HIGHLIGHT_COLOR if highlighted else color,
257
+ "highlight": {"background": color, "border": "#FFFFFF"},
258
+ },
259
+ shape=style.shape,
260
+ size=size,
261
+ borderWidth=3 if highlighted else 1,
262
+ font={"size": 11},
263
+ )
264
+ panel_data[node_id] = _panel_entry(node, color, tooltip, scores)
265
+
266
+ for edge in edges:
267
+ rel = str(edge.get("rel", ""))
268
+ net.add_edge(
269
+ edge["src"],
270
+ edge["dst"],
271
+ label=rel,
272
+ color=theme.relation_color(rel),
273
+ width=1.5,
274
+ title=rel,
275
+ )
276
+
277
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as handle:
278
+ tmp_path = handle.name
279
+ try:
280
+ net.save_graph(tmp_path)
281
+ document = Path(tmp_path).read_text(encoding="utf-8")
282
+ finally:
283
+ os.unlink(tmp_path)
284
+
285
+ return document.replace("</body>", _panel_markup(panel_data) + "</body>")
286
+
287
+
288
+ def _panel_entry(
289
+ node: Mapping[str, Any],
290
+ color: str,
291
+ tooltip: TooltipSpec | Callable[..., str] | None,
292
+ scores: Any | None,
293
+ ) -> dict[str, Any]:
294
+ """Build the click-panel payload for one node.
295
+
296
+ Driven by the same spec as the hover tooltip so the two never disagree.
297
+
298
+ :param node: Node attribute mapping.
299
+ :param color: Accent colour.
300
+ :param tooltip: The active tooltip spec, if it is a spec.
301
+ :param scores: Active ``ScoreSet``, used to include rank.
302
+ :return: JSON-serialisable panel entry.
303
+ """
304
+ spec = tooltip if isinstance(tooltip, TooltipSpec) else None
305
+ if spec is None:
306
+ title = str(node.get("name") or node["id"])
307
+ meta: list[str] = []
308
+ body = ""
309
+ else:
310
+ title = (
311
+ str(node.get(spec.title) or node.get("name") or node["id"])
312
+ if isinstance(spec.title, str)
313
+ else spec.title(node)
314
+ )
315
+ meta = [text for text in (row.render(node) for row in spec.rows) if text]
316
+ body = str(node.get(spec.body) or "").strip() if spec.body else ""
317
+
318
+ rank = scores.rank(node["id"]) if scores is not None else None
319
+ return {
320
+ "id": node["id"],
321
+ "kind": str(node.get("kind", "")),
322
+ "color": color,
323
+ "title": title,
324
+ "meta": meta,
325
+ "body": body,
326
+ "rank": rank,
327
+ "metric": getattr(scores, "metric", None) if scores is not None else None,
328
+ }
329
+
330
+
331
+ def _panel_markup(panel_data: Mapping[str, Mapping[str, Any]]) -> str:
332
+ """Return the CSS, markup and script for the floating click-detail panel.
333
+
334
+ :param panel_data: Per-node payload keyed by node ID.
335
+ :return: HTML to inject before ``</body>``.
336
+ """
337
+ # The payload is embedded in a <script> block, so any "</script>" inside a
338
+ # node's text would terminate the block during HTML parsing and inject
339
+ # whatever followed. Escaping the three characters that can start such a
340
+ # sequence keeps the JSON valid while making breakout impossible.
341
+ payload = (
342
+ json.dumps(panel_data, ensure_ascii=False)
343
+ .replace("<", "\\u003c")
344
+ .replace(">", "\\u003e")
345
+ .replace("&", "\\u0026")
346
+ )
347
+ return """
348
+ <style>
349
+ #kgviz-panel {
350
+ display: none; position: fixed; top: 12px; right: 12px; width: 340px;
351
+ max-height: 88vh; overflow-y: auto; background: #1e1e2e; border-radius: 10px;
352
+ box-shadow: 0 4px 24px rgba(0,0,0,0.6); z-index: 9999;
353
+ font-family: sans-serif; font-size: 13px; color: #e0e0e0;
354
+ }
355
+ #kgviz-panel-inner { padding: 14px 16px 16px 16px; }
356
+ #kgviz-panel-close {
357
+ position: absolute; top: 8px; right: 10px; cursor: pointer; font-size: 18px;
358
+ color: #888; line-height: 1; background: none; border: none;
359
+ }
360
+ #kgviz-panel-close:hover { color: #fff; }
361
+ #kgviz-panel-body {
362
+ background: #12121f; border: 1px solid #2a2a3e; border-radius: 6px;
363
+ padding: 8px 10px; font-family: monospace; font-size: 12px; color: #c9d1d9;
364
+ white-space: pre-wrap; word-break: break-word; margin-top: 8px;
365
+ max-height: 300px; overflow-y: auto;
366
+ }
367
+ </style>
368
+ <div id="kgviz-panel">
369
+ <button id="kgviz-panel-close"
370
+ onclick="document.getElementById('kgviz-panel').style.display='none'">&#10005;</button>
371
+ <div id="kgviz-panel-inner">
372
+ <div id="kgviz-panel-badge"></div>
373
+ <div id="kgviz-panel-title" style="font-size:15px;font-weight:bold;margin:6px 0 2px 0;"></div>
374
+ <div id="kgviz-panel-meta" style="color:#888;font-size:11px;font-family:monospace;"></div>
375
+ <div id="kgviz-panel-id"
376
+ style="color:#444;font-size:10px;font-family:monospace;margin-top:2px;"></div>
377
+ <div id="kgviz-panel-body"></div>
378
+ </div>
379
+ </div>
380
+ <script>
381
+ (function () {
382
+ var NODE_DATA = __KGVIZ_PAYLOAD__;
383
+ function esc(s) {
384
+ return String(s == null ? "" : s)
385
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
386
+ }
387
+ function show(nodeId) {
388
+ var n = NODE_DATA[nodeId];
389
+ if (!n) { return; }
390
+ document.getElementById("kgviz-panel-badge").innerHTML =
391
+ "<span style='background:" + n.color + ";color:#fff;border-radius:4px;" +
392
+ "padding:1px 7px;font-size:11px;font-weight:bold;'>" + esc(n.kind) + "</span>";
393
+ document.getElementById("kgviz-panel-title").textContent = n.title;
394
+ var meta = (n.meta || []).slice();
395
+ if (n.rank) { meta.push(n.metric + " rank " + n.rank); }
396
+ document.getElementById("kgviz-panel-meta").textContent = meta.join(" · ");
397
+ document.getElementById("kgviz-panel-id").textContent = n.id;
398
+ var body = document.getElementById("kgviz-panel-body");
399
+ body.textContent = n.body || "";
400
+ body.style.display = n.body ? "block" : "none";
401
+ document.getElementById("kgviz-panel").style.display = "block";
402
+ }
403
+ function attach() {
404
+ if (typeof network === "undefined") { window.setTimeout(attach, 120); return; }
405
+ network.on("click", function (params) {
406
+ if (params.nodes && params.nodes.length) { show(params.nodes[0]); }
407
+ });
408
+ }
409
+ attach();
410
+ })();
411
+ </script>
412
+ """.replace("__KGVIZ_PAYLOAD__", payload)
@@ -0,0 +1,135 @@
1
+ """
2
+ Declarative visual vocabulary for graph renderers.
3
+
4
+ A :class:`GraphTheme` says how one domain's node kinds and edge relations should
5
+ look. It is data, not code: each KG module constructs one and hands it to the
6
+ renderer, so all modules share a single rendering implementation while naming
7
+ their own kinds. ``code`` has modules and functions, ``doc`` has sections and
8
+ chunks, ``meta`` has compounds and reactions — only ``id``, ``kind`` and
9
+ ``name`` are common to all of them.
10
+
11
+ Typical use::
12
+
13
+ THEME = GraphTheme(
14
+ kinds={
15
+ "document": KindStyle("#4A90D9", shape="box", size=18),
16
+ "chunk": KindStyle("#27AE60", shape="ellipse"),
17
+ },
18
+ fallback=KindStyle("#95A5A6", shape="triangle"),
19
+ relations={"CONTAINS": "#BDC3C7", "SIMILAR_TO": "#E74C3C"},
20
+ )
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable, Mapping
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Final
28
+
29
+ __all__ = ["GraphTheme", "KindStyle", "with_alpha"]
30
+
31
+ #: Used when a theme names no relation colour and declares no fallback.
32
+ DEFAULT_RELATION_COLOR: Final[str] = "#888888"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class KindStyle:
37
+ """How one node kind is drawn.
38
+
39
+ :param color: Fill colour as ``#RRGGBB``. The renderer converts this to
40
+ ``rgba()`` when a centrality metric drives opacity, so six-digit hex is
41
+ required for fading to work.
42
+ :param shape: vis.js node shape — ``dot``, ``box``, ``ellipse``,
43
+ ``diamond``, ``triangle``, ``star``, and so on.
44
+ :param size: Node diameter in pixels when no metric is driving size.
45
+ :param radius: Node radius in world units for 3-D renderers. Ignored by the
46
+ HTML renderer; carried here so one theme can serve both.
47
+ """
48
+
49
+ color: str
50
+ shape: str = "dot"
51
+ size: int = 12
52
+ radius: float = 0.7
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GraphTheme:
57
+ """The full visual vocabulary for one knowledge-graph domain.
58
+
59
+ :param kinds: Node kind to :class:`KindStyle`.
60
+ :param fallback: Style for kinds absent from *kinds*. Every graph
61
+ eventually meets a kind nobody planned for, and silently dropping those
62
+ nodes is worse than drawing them grey.
63
+ :param relations: Edge relation to colour.
64
+ :param relation_fallback: Colour for relations absent from *relations*.
65
+ :param resolve_kind: Optional hook mapping a node to a *render* kind that
66
+ need not exist in the store — for example drawing underscore-prefixed
67
+ functions differently. Receives the whole node dict. Must return a key
68
+ present in *kinds*, or anything else to get *fallback*.
69
+ """
70
+
71
+ kinds: Mapping[str, KindStyle]
72
+ fallback: KindStyle
73
+ relations: Mapping[str, str] = field(default_factory=dict)
74
+ relation_fallback: str = DEFAULT_RELATION_COLOR
75
+ resolve_kind: Callable[[Mapping[str, Any]], str] | None = None
76
+
77
+ def kind_of(self, node: Mapping[str, Any]) -> str:
78
+ """Return the render kind for *node*.
79
+
80
+ :param node: Node attribute mapping.
81
+ :return: A kind name, which may be a render-only kind from
82
+ :attr:`resolve_kind` rather than one the store knows about.
83
+ """
84
+ if self.resolve_kind is not None:
85
+ return self.resolve_kind(node)
86
+ return str(node.get("kind", ""))
87
+
88
+ def style_of(self, node: Mapping[str, Any]) -> KindStyle:
89
+ """Return the style for *node*, falling back rather than raising.
90
+
91
+ :param node: Node attribute mapping.
92
+ :return: The matching :class:`KindStyle`, or :attr:`fallback`.
93
+ """
94
+ return self.kinds.get(self.kind_of(node), self.fallback)
95
+
96
+ def style_for_kind(self, kind: str) -> KindStyle:
97
+ """Return the style for a kind name.
98
+
99
+ :param kind: Kind name.
100
+ :return: The matching :class:`KindStyle`, or :attr:`fallback`.
101
+ """
102
+ return self.kinds.get(kind, self.fallback)
103
+
104
+ def relation_color(self, relation: str) -> str:
105
+ """Return the colour for an edge relation.
106
+
107
+ :param relation: Relation name.
108
+ :return: Hex colour string.
109
+ """
110
+ return self.relations.get(relation, self.relation_fallback)
111
+
112
+
113
+ def with_alpha(hex_color: str, alpha: float) -> str:
114
+ """Convert ``#RRGGBB`` to an ``rgba()`` string at the given opacity.
115
+
116
+ vis.js accepts CSS colour strings for node backgrounds, so opacity is
117
+ expressed by converting the palette hex rather than by compositing against
118
+ the canvas background.
119
+
120
+ :param hex_color: Colour in ``#RRGGBB`` form, with or without the leading
121
+ ``#``.
122
+ :param alpha: Opacity in ``[0, 1]``; values outside the range are clamped.
123
+ :return: An ``rgba(r, g, b, a)`` string, or *hex_color* unchanged when it is
124
+ not a six-digit hex colour — a CSS keyword should degrade to an opaque
125
+ colour rather than crash a render.
126
+ """
127
+ raw = hex_color.lstrip("#")
128
+ if len(raw) != 6:
129
+ return hex_color
130
+ try:
131
+ r, g, b = (int(raw[i : i + 2], 16) for i in (0, 2, 4))
132
+ except ValueError:
133
+ return hex_color
134
+ a = min(1.0, max(0.0, alpha))
135
+ return f"rgba({r}, {g}, {b}, {a:.3f})"
@@ -0,0 +1,119 @@
1
+ """
2
+ Declarative hover-tooltip specification for graph renderers.
3
+
4
+ Node schemas diverge sharply between KG domains — code nodes carry
5
+ ``qualname``/``module_path``/``lineno``, document nodes carry
6
+ ``title``/``file_path``/``char_start``, metabolic nodes carry
7
+ ``formula``/``ec_number``. Only ``id``, ``kind`` and ``name`` are universal.
8
+
9
+ A :class:`TooltipSpec` lets each domain name its own fields while the renderer
10
+ keeps one implementation of the markup, so the viewers stay visually consistent
11
+ without every module writing its own HTML. When a domain needs markup the spec
12
+ cannot express, the renderer also accepts a plain callable instead — see
13
+ ``build_graph_html``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html
19
+ from collections.abc import Callable, Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ __all__ = ["TooltipRow", "TooltipSpec"]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TooltipRow:
28
+ """One metadata line beneath the tooltip's title.
29
+
30
+ :param value: Either a node-dict key, or a callable taking the whole node
31
+ and returning display text. The callable form exists for values that
32
+ span several fields, such as a line range built from ``lineno`` and
33
+ ``end_lineno``.
34
+ :param prefix: Short text or emoji placed before the value.
35
+ :param skip_if_empty: Drop the row when the value is empty or ``None``.
36
+ Almost always what you want — a tooltip full of blank labels is worse
37
+ than a short one.
38
+ """
39
+
40
+ value: str | Callable[[Mapping[str, Any]], str]
41
+ prefix: str = ""
42
+ skip_if_empty: bool = True
43
+
44
+ def render(self, node: Mapping[str, Any]) -> str:
45
+ """Resolve this row against *node*.
46
+
47
+ :param node: Node attribute mapping.
48
+ :return: Display text, empty when the row should be dropped.
49
+ """
50
+ if isinstance(self.value, str):
51
+ raw = node.get(self.value)
52
+ text = "" if raw is None else str(raw)
53
+ else:
54
+ text = self.value(node) or ""
55
+ text = text.strip()
56
+ if not text and self.skip_if_empty:
57
+ return ""
58
+ return f"{self.prefix}{text}" if self.prefix else text
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class TooltipSpec:
63
+ """How to build a node's hover tooltip from its fields.
64
+
65
+ :param title: Node key holding the bold heading, or a callable.
66
+ :param rows: Metadata lines shown under the heading, joined with separators.
67
+ :param body: Node key holding free text — a docstring, chunk text or
68
+ description — rendered in a monospaced block below a rule.
69
+ :param body_lines: Maximum body lines before truncation.
70
+ :param max_width: Tooltip width in pixels.
71
+ """
72
+
73
+ title: str | Callable[[Mapping[str, Any]], str] = "name"
74
+ rows: Sequence[TooltipRow] = field(default_factory=tuple)
75
+ body: str | None = None
76
+ body_lines: int = 8
77
+ max_width: int = 400
78
+
79
+ def render(self, node: Mapping[str, Any], color: str) -> str:
80
+ """Build the tooltip HTML for *node*.
81
+
82
+ :param node: Node attribute mapping.
83
+ :param color: Accent colour for the kind badge and left border.
84
+ :return: An HTML string suitable for a pyvis node ``title``.
85
+ """
86
+ kind = html.escape(str(node.get("kind", "")))
87
+ if isinstance(self.title, str):
88
+ title_text = str(node.get(self.title) or node.get("name") or node.get("id", ""))
89
+ else:
90
+ title_text = self.title(node)
91
+ title = html.escape(title_text)
92
+
93
+ rendered = [r.render(node) for r in self.rows]
94
+ meta = " &nbsp;·&nbsp; ".join(html.escape(r) for r in rendered if r)
95
+ meta_html = f"<br><span style='color:#888;font-size:11px;'>{meta}</span>" if meta else ""
96
+
97
+ body_html = ""
98
+ if self.body:
99
+ raw = str(node.get(self.body) or "").strip()
100
+ if raw:
101
+ lines = raw.splitlines()
102
+ shown = [html.escape(line) for line in lines[: self.body_lines]]
103
+ ellipsis = "…" if len(lines) > self.body_lines else ""
104
+ body_html = (
105
+ "<hr style='border:0;border-top:1px solid #444;margin:6px 0;'>"
106
+ "<div style='font-family:monospace;font-size:11px;color:#ccc;"
107
+ "white-space:pre-wrap;'>" + "\n".join(shown) + ellipsis + "</div>"
108
+ )
109
+
110
+ return (
111
+ f"<div style='font-family:sans-serif;font-size:12px;"
112
+ f"background:#1e1e2e;color:#e0e0e0;padding:10px 14px;"
113
+ f"border-radius:8px;border-left:4px solid {color};"
114
+ f"max-width:{self.max_width}px;'>"
115
+ f"<span style='background:{color};color:#fff;border-radius:4px;"
116
+ f"padding:1px 7px;font-size:11px;font-weight:bold;'>{kind}</span>"
117
+ f"&nbsp;&nbsp;<b style='font-size:13px;'>{title}</b>"
118
+ f"{meta_html}{body_html}</div>"
119
+ )
File without changes