Merge pull request #459 from Hawksight-AI/visualization

fix(visualization): Accept KnowledgeGraph objects in all `visualize_*` methods
This commit is contained in:
Mohd Kaif
2026-04-14 11:18:41 +05:30
committed by GitHub
2 changed files with 326 additions and 2 deletions
+39 -2
View File
@@ -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()