From e78ad7f819fba0e78ba7c9031f47f6c8396b1e53 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 14 Apr 2026 11:04:25 +0530 Subject: [PATCH] fix(visualization): accept KnowledgeGraph objects in all visualize_* methods (closes #458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KGVisualizer.visualize_network() (and sibling methods) only accepted a raw dict. Passing a KnowledgeGraph object — the natural output of GraphBuilder.build() — silently returned without rendering. Added _normalize_graph() which duck-types the input: dicts pass through unchanged; any object exposing .entities / .relationships attributes is converted to the canonical dict form; anything else raises a clear ProcessingError naming the offending type. _normalize_graph() is called as the first statement in visualize_network(), visualize_communities(), visualize_centrality(), visualize_entity_types(), and visualize_relationship_matrix(). Also adds 21 tests in tests/visualization/test_kg_visualizer_normalize_graph.py covering the helper directly, the end-to-end regression for #458, and a guard that every public method routes through _normalize_graph. Co-Authored-By: Claude Sonnet 4.6 --- semantica/visualization/kg_visualizer.py | 41 ++- .../test_kg_visualizer_normalize_graph.py | 287 ++++++++++++++++++ 2 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 tests/visualization/test_kg_visualizer_normalize_graph.py diff --git a/semantica/visualization/kg_visualizer.py b/semantica/visualization/kg_visualizer.py index a6187f7a..e5890638 100644 --- a/semantica/visualization/kg_visualizer.py +++ b/semantica/visualization/kg_visualizer.py @@ -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", []) diff --git a/tests/visualization/test_kg_visualizer_normalize_graph.py b/tests/visualization/test_kg_visualizer_normalize_graph.py new file mode 100644 index 00000000..781fd491 --- /dev/null +++ b/tests/visualization/test_kg_visualizer_normalize_graph.py @@ -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()