mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #459 from Hawksight-AI/visualization
fix(visualization): Accept KnowledgeGraph objects in all `visualize_*` methods
This commit is contained in:
@@ -118,9 +118,39 @@ class KGVisualizer:
|
||||
"Install with: pip install plotly"
|
||||
)
|
||||
|
||||
def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize graph input to the expected dict format.
|
||||
|
||||
Accepts either:
|
||||
- A dict with "entities" and "relationships" keys (canonical format)
|
||||
- Any object that exposes .entities and .relationships attributes
|
||||
(e.g. a KnowledgeGraph dataclass returned by GraphBuilder.build())
|
||||
|
||||
Returns:
|
||||
Dict with "entities", "relationships", and "metadata" keys.
|
||||
"""
|
||||
if isinstance(graph, dict):
|
||||
return graph
|
||||
|
||||
# Duck-type: accept any object with .entities / .relationships
|
||||
entities = getattr(graph, "entities", None)
|
||||
relationships = getattr(graph, "relationships", None)
|
||||
if entities is None and relationships is None:
|
||||
raise ProcessingError(
|
||||
f"Cannot visualize object of type '{type(graph).__name__}': "
|
||||
"expected a dict with 'entities'/'relationships' keys, or an object "
|
||||
"with .entities and .relationships attributes."
|
||||
)
|
||||
return {
|
||||
"entities": list(entities) if entities is not None else [],
|
||||
"relationships": list(relationships) if relationships is not None else [],
|
||||
"metadata": dict(getattr(graph, "metadata", None) or {}),
|
||||
}
|
||||
|
||||
def visualize_network(
|
||||
self,
|
||||
graph: Dict[str, Any],
|
||||
graph: Union[Dict[str, Any], Any],
|
||||
output: str = "interactive",
|
||||
file_path: Optional[Union[str, Path]] = None,
|
||||
node_color_by: str = "type",
|
||||
@@ -139,7 +169,9 @@ class KGVisualizer:
|
||||
5. Interaction: rich hover data and zoom capabilities
|
||||
|
||||
Args:
|
||||
graph: Knowledge graph dictionary with entities and relationships
|
||||
graph: Knowledge graph — either a dict with "entities"/"relationships"
|
||||
keys, or any object exposing .entities and .relationships attributes
|
||||
(e.g. the result of GraphBuilder.build())
|
||||
output: Output type ("interactive", "html", "png", "svg")
|
||||
file_path: Output file path (required for non-interactive)
|
||||
node_color_by: Property to map to node color (default: "type")
|
||||
@@ -151,6 +183,7 @@ class KGVisualizer:
|
||||
Plotly figure (if interactive) or None
|
||||
"""
|
||||
self._check_dependencies()
|
||||
graph = self._normalize_graph(graph)
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="visualization",
|
||||
submodule="KGVisualizer",
|
||||
@@ -237,6 +270,7 @@ class KGVisualizer:
|
||||
Visualization figure or None
|
||||
"""
|
||||
self._check_dependencies()
|
||||
graph = self._normalize_graph(graph)
|
||||
self.logger.info("Visualizing knowledge graph communities")
|
||||
|
||||
entities = graph.get("entities", [])
|
||||
@@ -296,6 +330,7 @@ class KGVisualizer:
|
||||
Visualization figure or None
|
||||
"""
|
||||
self._check_dependencies()
|
||||
graph = self._normalize_graph(graph)
|
||||
self.logger.info(
|
||||
f"Visualizing knowledge graph with {centrality_type} centrality"
|
||||
)
|
||||
@@ -350,6 +385,7 @@ class KGVisualizer:
|
||||
Visualization figure or None
|
||||
"""
|
||||
self._check_dependencies()
|
||||
graph = self._normalize_graph(graph)
|
||||
self.logger.info("Visualizing entity type distribution")
|
||||
|
||||
entities = graph.get("entities", [])
|
||||
@@ -395,6 +431,7 @@ class KGVisualizer:
|
||||
Visualization figure or None
|
||||
"""
|
||||
self._check_dependencies()
|
||||
graph = self._normalize_graph(graph)
|
||||
self.logger.info("Visualizing relationship matrix")
|
||||
|
||||
entities = graph.get("entities", [])
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Tests for KGVisualizer._normalize_graph() and the fix for issue #458:
|
||||
"KGVisualizer.visualize_network() does not accept a KnowledgeGraph object"
|
||||
|
||||
All public visualize_* methods must accept either:
|
||||
- a plain dict {"entities": [...], "relationships": [...]}
|
||||
- any object exposing .entities / .relationships attributes
|
||||
and must raise a clear ProcessingError for anything else.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import sys
|
||||
import unittest
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub out heavy optional deps before importing the module under test
|
||||
# ---------------------------------------------------------------------------
|
||||
sys.modules.setdefault("matplotlib", MagicMock())
|
||||
sys.modules.setdefault("matplotlib.pyplot", MagicMock())
|
||||
sys.modules.setdefault("matplotlib.patches", MagicMock())
|
||||
sys.modules.setdefault("plotly", MagicMock())
|
||||
sys.modules.setdefault("plotly.express", MagicMock())
|
||||
sys.modules.setdefault("plotly.graph_objects", MagicMock())
|
||||
sys.modules.setdefault("plotly.subplots", MagicMock())
|
||||
sys.modules.setdefault("graphviz", MagicMock())
|
||||
sys.modules.setdefault("seaborn", MagicMock())
|
||||
|
||||
from semantica.utils.exceptions import ProcessingError # noqa: E402
|
||||
from semantica.visualization.kg_visualizer import KGVisualizer # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
ENTITIES = [
|
||||
{"id": "e1", "text": "Alice", "type": "Person"},
|
||||
{"id": "e2", "text": "Bob", "type": "Person"},
|
||||
]
|
||||
RELATIONSHIPS = [
|
||||
{"source": "e1", "target": "e2", "type": "KNOWS"},
|
||||
]
|
||||
GRAPH_DICT = {"entities": ENTITIES, "relationships": RELATIONSHIPS}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimpleKG:
|
||||
"""Minimal KnowledgeGraph-like dataclass (mimics GraphBuilder output)."""
|
||||
entities: List[dict] = field(default_factory=list)
|
||||
relationships: List[dict] = field(default_factory=list)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class NamespaceKG:
|
||||
"""Object-with-attributes variant (no dataclass decorator)."""
|
||||
def __init__(self, entities, relationships, metadata=None):
|
||||
self.entities = entities
|
||||
self.relationships = relationships
|
||||
self.metadata = metadata or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build a KGVisualizer with all heavy internals mocked out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_viz():
|
||||
mock_logger = MagicMock()
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.enabled = True
|
||||
mock_tracker.start_tracking.return_value = "tid"
|
||||
|
||||
patches = [
|
||||
patch("semantica.visualization.kg_visualizer.get_logger", return_value=mock_logger),
|
||||
patch("semantica.visualization.kg_visualizer.get_progress_tracker", return_value=mock_tracker),
|
||||
patch("semantica.visualization.kg_visualizer.ForceDirectedLayout", MagicMock()),
|
||||
patch("semantica.visualization.kg_visualizer.HierarchicalLayout", MagicMock()),
|
||||
patch("semantica.visualization.kg_visualizer.CircularLayout", MagicMock()),
|
||||
]
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
viz = KGVisualizer(layout="force")
|
||||
|
||||
viz.logger = mock_logger
|
||||
viz.progress_tracker = mock_tracker
|
||||
return viz
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for _normalize_graph directly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeGraph(unittest.TestCase):
|
||||
"""Unit tests for _normalize_graph — no Plotly calls needed."""
|
||||
|
||||
def setUp(self):
|
||||
self.viz = _make_viz()
|
||||
|
||||
# --- dict input ---
|
||||
|
||||
def test_dict_passthrough(self):
|
||||
result = self.viz._normalize_graph(GRAPH_DICT)
|
||||
self.assertIs(result, GRAPH_DICT, "_normalize_graph should return the same dict unchanged")
|
||||
|
||||
def test_dict_missing_keys_passthrough(self):
|
||||
"""A dict without entities/relationships is still passed through; callers handle emptiness."""
|
||||
result = self.viz._normalize_graph({})
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
# --- object-with-attributes input ---
|
||||
|
||||
def test_dataclass_kg(self):
|
||||
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
result = self.viz._normalize_graph(kg)
|
||||
self.assertEqual(result["entities"], ENTITIES)
|
||||
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
||||
|
||||
def test_namespace_kg(self):
|
||||
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
result = self.viz._normalize_graph(kg)
|
||||
self.assertEqual(result["entities"], ENTITIES)
|
||||
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
||||
|
||||
def test_object_with_only_entities(self):
|
||||
"""An object with only .entities (no .relationships) should still work."""
|
||||
class EntitiesOnly:
|
||||
entities = ENTITIES
|
||||
result = self.viz._normalize_graph(EntitiesOnly())
|
||||
self.assertEqual(result["entities"], ENTITIES)
|
||||
self.assertEqual(result["relationships"], [])
|
||||
|
||||
def test_object_with_only_relationships(self):
|
||||
"""An object with only .relationships (no .entities) should still work."""
|
||||
class RelsOnly:
|
||||
relationships = RELATIONSHIPS
|
||||
result = self.viz._normalize_graph(RelsOnly())
|
||||
self.assertEqual(result["entities"], [])
|
||||
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
||||
|
||||
def test_metadata_propagated(self):
|
||||
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS, metadata={"version": "1"})
|
||||
result = self.viz._normalize_graph(kg)
|
||||
self.assertEqual(result["metadata"], {"version": "1"})
|
||||
|
||||
def test_metadata_defaults_to_empty_dict(self):
|
||||
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
kg.metadata = None
|
||||
result = self.viz._normalize_graph(kg)
|
||||
self.assertEqual(result["metadata"], {})
|
||||
|
||||
# --- unsupported types ---
|
||||
|
||||
def test_raises_for_string(self):
|
||||
with self.assertRaises(ProcessingError) as ctx:
|
||||
self.viz._normalize_graph("not a graph")
|
||||
self.assertIn("str", str(ctx.exception))
|
||||
|
||||
def test_raises_for_integer(self):
|
||||
with self.assertRaises(ProcessingError):
|
||||
self.viz._normalize_graph(42)
|
||||
|
||||
def test_raises_for_list(self):
|
||||
with self.assertRaises(ProcessingError):
|
||||
self.viz._normalize_graph([{"id": "e1"}])
|
||||
|
||||
def test_raises_for_none(self):
|
||||
with self.assertRaises((ProcessingError, AttributeError)):
|
||||
self.viz._normalize_graph(None)
|
||||
|
||||
def test_error_message_names_type(self):
|
||||
class WeirdThing:
|
||||
pass
|
||||
with self.assertRaises(ProcessingError) as ctx:
|
||||
self.viz._normalize_graph(WeirdThing())
|
||||
self.assertIn("WeirdThing", str(ctx.exception))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: visualize_network accepts KG objects end-to-end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVisualizeNetworkAcceptsKGObject(unittest.TestCase):
|
||||
"""
|
||||
Regression tests for issue #458.
|
||||
|
||||
visualize_network() must produce the same result whether it receives a
|
||||
dict or an equivalent KG object.
|
||||
"""
|
||||
|
||||
def _run_visualize_network(self, graph_arg):
|
||||
"""Run visualize_network with all Plotly internals mocked."""
|
||||
mock_fig = MagicMock()
|
||||
mock_go = sys.modules["plotly.graph_objects"]
|
||||
mock_go.Figure.return_value = mock_fig
|
||||
mock_go.Scatter.return_value = MagicMock()
|
||||
mock_go.Layout.return_value = MagicMock()
|
||||
|
||||
viz = _make_viz()
|
||||
|
||||
# Mock layout to return deterministic positions
|
||||
fake_pos = {"e1": (0.0, 0.0), "e2": (1.0, 1.0)}
|
||||
viz.force_layout = MagicMock()
|
||||
viz.force_layout.compute_layout.return_value = fake_pos
|
||||
viz.hierarchical_layout = MagicMock()
|
||||
viz.circular_layout = MagicMock()
|
||||
|
||||
# ColorPalette helpers
|
||||
with (
|
||||
patch(
|
||||
"semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
|
||||
return_value={"Person": "#ff0000"},
|
||||
),
|
||||
patch(
|
||||
"semantica.visualization.kg_visualizer.ColorPalette.get_colors",
|
||||
return_value=["#ff0000"],
|
||||
),
|
||||
):
|
||||
return viz.visualize_network(graph_arg, output="interactive")
|
||||
|
||||
def test_dict_input_returns_figure(self):
|
||||
fig = self._run_visualize_network(GRAPH_DICT)
|
||||
self.assertIsNotNone(fig)
|
||||
|
||||
def test_dataclass_kg_returns_figure(self):
|
||||
"""Issue #458: passing a KnowledgeGraph dataclass must not be a silent no-op."""
|
||||
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
fig = self._run_visualize_network(kg)
|
||||
self.assertIsNotNone(fig)
|
||||
|
||||
def test_namespace_kg_returns_figure(self):
|
||||
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
fig = self._run_visualize_network(kg)
|
||||
self.assertIsNotNone(fig)
|
||||
|
||||
def test_unsupported_type_raises_processing_error(self):
|
||||
viz = _make_viz()
|
||||
with self.assertRaises(ProcessingError):
|
||||
viz.visualize_network("not a graph")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: all other visualize_* methods also accept KG objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
|
||||
"""Each public visualize_* method must call _normalize_graph."""
|
||||
|
||||
def setUp(self):
|
||||
self.viz = _make_viz()
|
||||
self.kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
||||
|
||||
def test_visualize_communities_accepts_kg_object(self):
|
||||
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
||||
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
|
||||
communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
|
||||
with patch(
|
||||
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
|
||||
return_value=["#ff0000", "#00ff00"],
|
||||
):
|
||||
self.viz.visualize_communities(self.kg, communities=communities)
|
||||
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
||||
|
||||
def test_visualize_centrality_accepts_kg_object(self):
|
||||
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
||||
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
|
||||
self.viz.visualize_centrality(self.kg, centrality={"centrality": {}})
|
||||
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
||||
|
||||
def test_visualize_entity_types_accepts_kg_object(self):
|
||||
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
||||
mock_px = sys.modules["plotly.express"]
|
||||
mock_px.bar.return_value = MagicMock()
|
||||
self.viz.visualize_entity_types(self.kg)
|
||||
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
||||
|
||||
def test_visualize_relationship_matrix_accepts_kg_object(self):
|
||||
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
||||
mock_go = sys.modules["plotly.graph_objects"]
|
||||
mock_go.Figure.return_value = MagicMock()
|
||||
mock_go.Heatmap.return_value = MagicMock()
|
||||
self.viz.visualize_relationship_matrix(self.kg)
|
||||
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user