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.
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/PKG-INFO +7 -5
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/README.md +4 -4
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/pyproject.toml +6 -1
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/__init__.py +5 -1
- kgmodule_utils-0.7.0/src/kg_utils/analysis/__init__.py +19 -0
- kgmodule_utils-0.7.0/src/kg_utils/analysis/scores.py +329 -0
- kgmodule_utils-0.7.0/src/kg_utils/viz/__init__.py +33 -0
- kgmodule_utils-0.7.0/src/kg_utils/viz/graph_html.py +412 -0
- kgmodule_utils-0.7.0/src/kg_utils/viz/theme.py +135 -0
- kgmodule_utils-0.7.0/src/kg_utils/viz/tooltip.py +119 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/LICENSE +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/corpus_embedder.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/embed.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/embedder.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/extractor.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/module.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/pipeline.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/py.typed +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/retrieval/__init__.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/retrieval/hits.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/semantic.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/__init__.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/manager.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/snapshots/models.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/specs.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/store.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/__init__.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_config.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_image.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/_text.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/synthesis/factory.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/vector_backend.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/worker/__init__.py +0 -0
- {kgmodule_utils-0.6.2 → kgmodule_utils-0.7.0}/src/kg_utils/worker/client.py +0 -0
- {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.
|
|
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
|
[](https://github.com/Flux-Frontiers/KG_utils/releases)
|
|
41
43
|
[](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml)
|
|
42
44
|
[](https://python-poetry.org/)
|
|
43
|
-
[](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
|
-
[](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.
|
|
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.
|
|
340
|
+
doi = {10.5281/zenodo.21387077},
|
|
339
341
|
}
|
|
340
342
|
```
|
|
341
343
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/Flux-Frontiers/KG_utils/releases)
|
|
5
5
|
[](https://github.com/Flux-Frontiers/KG_utils/actions/workflows/ci.yml)
|
|
6
6
|
[](https://python-poetry.org/)
|
|
7
|
-
[](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
|
-
[](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.
|
|
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.
|
|
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.
|
|
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.
|
|
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'">✕</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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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 = " · ".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" <b style='font-size:13px;'>{title}</b>"
|
|
118
|
+
f"{meta_html}{body_html}</div>"
|
|
119
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|