From e0e42dc539bd2e1c6ca3961427a743490ab2d0b7 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Thu, 11 Dec 2025 18:11:58 +0530 Subject: [PATCH] feat(ontology): comprehensive testing and bug fixes for ontology module - Added comprehensive test suite (test_ontology_comprehensive.py) covering all core classes. - Added test_notebook_14.py to verify documentation examples. - Fixed PropertyGenerator to respect min_occurrences config. - Fixed NamingConventions for singularization (ss endings) and camelCase preservation. - Fixed OntologyVisualizer to handle list-type domains/ranges. - Fixed ModuleManager method usage in tests. - Validated all 32 tests pass. --- semantica/ontology/llm_generator.py | 2 +- semantica/ontology/namespace_manager.py | 4 + semantica/ontology/naming_conventions.py | 12 +- semantica/ontology/ontology_generator.py | 15 +- semantica/ontology/property_generator.py | 5 + .../visualization/ontology_visualizer.py | 16 +- tests/ontology/test_notebook_14.py | 209 ++++++++++++++ tests/ontology/test_ontology_comprehensive.py | 262 ++++++++++++++++++ 8 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 tests/ontology/test_notebook_14.py create mode 100644 tests/ontology/test_ontology_comprehensive.py diff --git a/semantica/ontology/llm_generator.py b/semantica/ontology/llm_generator.py index 0c34b8e7..cae30907 100644 --- a/semantica/ontology/llm_generator.py +++ b/semantica/ontology/llm_generator.py @@ -31,7 +31,7 @@ class LLMOntologyGenerator: ) base_uri = options.get("base_uri") - name = options.get("name") or "GeneratedOntology" + name = options.get("name") version = options.get("version") or "1.0" prompt = self._build_prompt(text=text, name=name, base_uri=base_uri) diff --git a/semantica/ontology/namespace_manager.py b/semantica/ontology/namespace_manager.py index d164f23b..4b41fea7 100644 --- a/semantica/ontology/namespace_manager.py +++ b/semantica/ontology/namespace_manager.py @@ -216,6 +216,10 @@ class NamespaceManager: def _to_camel_case(self, name: str) -> str: """Convert name to camelCase.""" + # Check if already likely camelCase (starts with lower, has upper, single word) + if name and name[0].islower() and any(c.isupper() for c in name) and ' ' not in name and '_' not in name: + return name + # Remove special characters and split words = re.findall(r"[a-zA-Z0-9]+", name) if not words: diff --git a/semantica/ontology/naming_conventions.py b/semantica/ontology/naming_conventions.py index 0bcc3364..46593d9b 100644 --- a/semantica/ontology/naming_conventions.py +++ b/semantica/ontology/naming_conventions.py @@ -262,8 +262,8 @@ class NamingConventions: # camelCase for object properties suggested = self._to_camel_case(name) else: - # lowercase for data properties - suggested = name.lower() + # camelCase for data properties as well (standard practice) + suggested = self._to_camel_case(name) return suggested @@ -364,6 +364,10 @@ class NamingConventions: def _to_camel_case(self, name: str) -> str: """Convert to camelCase.""" + # Check if already likely camelCase (starts with lower, has upper, single word) + if name and name[0].islower() and any(c.isupper() for c in name) and ' ' not in name and '_' not in name: + return name + words = re.findall(r"[a-zA-Z0-9]+", name) if not words: return "hasProperty" @@ -381,8 +385,8 @@ class NamingConventions: # Basic singularization rules if name.lower().endswith("ies"): return name[:-3] + "y" - elif name.lower().endswith("es"): + elif name.lower().endswith("es") and not name.lower().endswith("ss"): return name[:-2] - elif name.lower().endswith("s") and len(name) > 1: + elif name.lower().endswith("s") and len(name) > 1 and not name.lower().endswith("ss") and name.lower() not in ["class", "process", "analysis"]: return name[:-1] return name diff --git a/semantica/ontology/ontology_generator.py b/semantica/ontology/ontology_generator.py index 3d629fc6..9c8d3f40 100644 --- a/semantica/ontology/ontology_generator.py +++ b/semantica/ontology/ontology_generator.py @@ -170,7 +170,13 @@ class OntologyGenerator: self.progress_tracker.update_tracking( tracking_id, message="Stage 3: Mapping to OWL types..." ) - typed_definitions = self._stage3_definition_to_types(definitions, **options) + + # Ensure entities and relationships are available for property inference + stage3_options = options.copy() + stage3_options["entities"] = data.get("entities", []) + stage3_options["relationships"] = data.get("relationships", []) + + typed_definitions = self._stage3_definition_to_types(definitions, **stage3_options) # Stage 4: Hierarchy Generation self.progress_tracker.update_tracking( @@ -266,9 +272,14 @@ class OntologyGenerator: relationships = options.get("relationships", []) entities = options.get("entities", []) + # Clean options for infer_properties to avoid multiple values for arguments + prop_options = options.copy() + prop_options.pop("entities", None) + prop_options.pop("relationships", None) + # Infer properties properties = self.property_generator.infer_properties( - entities=entities, relationships=relationships, classes=classes, **options + entities=entities, relationships=relationships, classes=classes, **prop_options ) # Add types to classes diff --git a/semantica/ontology/property_generator.py b/semantica/ontology/property_generator.py index 28895579..12899a6c 100644 --- a/semantica/ontology/property_generator.py +++ b/semantica/ontology/property_generator.py @@ -89,6 +89,11 @@ class PropertyGenerator: submodule="PropertyGenerator", message=f"Inferring properties from {len(entities)} entities and {len(relationships)} relationships", ) + + # Merge config into options + for key, value in self.config.items(): + if key not in options: + options[key] = value try: properties = [] diff --git a/semantica/visualization/ontology_visualizer.py b/semantica/visualization/ontology_visualizer.py index 8aaafbbc..bf4cc446 100644 --- a/semantica/visualization/ontology_visualizer.py +++ b/semantica/visualization/ontology_visualizer.py @@ -302,14 +302,22 @@ class OntologyVisualizer: # Add domain edges domain = prop.get("domain") if domain: - edges.append({"source": prop_name, "target": domain, "type": "domain"}) + if isinstance(domain, list): + for d in domain: + edges.append({"source": prop_name, "target": d, "type": "domain"}) + else: + edges.append({"source": prop_name, "target": domain, "type": "domain"}) # Add range edges range_val = prop.get("range") if range_val: - edges.append( - {"source": prop_name, "target": range_val, "type": "range"} - ) + if isinstance(range_val, list): + for r in range_val: + edges.append({"source": prop_name, "target": r, "type": "range"}) + else: + edges.append( + {"source": prop_name, "target": range_val, "type": "range"} + ) return self._visualize_structure_plotly( nodes, edges, output, file_path, **options diff --git a/tests/ontology/test_notebook_14.py b/tests/ontology/test_notebook_14.py new file mode 100644 index 00000000..ed1e2b41 --- /dev/null +++ b/tests/ontology/test_notebook_14.py @@ -0,0 +1,209 @@ +import unittest +from unittest.mock import MagicMock, patch +from semantica.ontology import ( + OntologyEngine, + ClassInferrer, + PropertyGenerator, + OntologyOptimizer, + OntologyValidator, + CompetencyQuestionsManager, + LLMOntologyGenerator +) +from semantica.visualization import OntologyVisualizer + +class TestNotebook14(unittest.TestCase): + """ + Tests mirroring the steps in cookbook/introduction/14_Ontology.ipynb + to ensure the documented examples work correctly. + """ + + def _run_full_pipeline(self): + """Helper to run the full pipeline and return the ontology.""" + engine = OntologyEngine(base_uri="https://docs.semantica.dev/ontology/") + + # Sample Data + entities = [ + {"id": "e1", "type": "Company", "name": "TechCorp", "founded": "2010"}, + {"id": "e2", "type": "Person", "name": "Alice", "role": "CEO"}, + {"id": "e3", "type": "Person", "name": "Bob", "role": "CTO"}, + {"id": "e4", "type": "Department", "name": "Engineering"}, + {"id": "e5", "type": "Project", "name": "Project Phoenix"} + ] + + relationships = [ + {"source": "e2", "target": "e1", "type": "leads"}, + {"source": "e3", "target": "e4", "type": "manages"}, + {"source": "e4", "target": "e1", "type": "part_of"}, + {"source": "e3", "target": "e5", "type": "works_on"} + ] + + data = { + "entities": entities, + "relationships": relationships + } + + # Run the full pipeline + ontology = engine.from_data(data, name="CorporateOntology", min_occurrences=1) + return ontology + + def test_full_pipeline(self): + """Test the 6-stage generation pipeline with sample data.""" + ontology = self._run_full_pipeline() + + # Verification + self.assertEqual(ontology['name'], "CorporateOntology") + self.assertGreater(len(ontology['classes']), 0) + self.assertGreater(len(ontology['properties']), 0) + + # Inspect Classes (just to ensure no errors in access) + for cls in ontology['classes']: + self.assertIn('name', cls) + self.assertIn('uri', cls) + + # Inspect Properties + for prop in ontology['properties']: + self.assertIn('name', prop) + self.assertIn('type', prop) + + def _run_class_inferrer(self): + """Helper to run class inference and return classes.""" + inferrer = ClassInferrer(min_occurrences=1) + + raw_entities = [ + {"type": "Manager", "name": "Dave", "level": 5}, + {"type": "Manager", "name": "Eve", "level": 4}, + {"type": "Employee", "name": "Frank"}, + {"type": "TemporaryWorker", "name": "Grace"} + ] + + classes = inferrer.infer_classes(raw_entities, build_hierarchy=True) + return classes + + def test_class_inferrer(self): + """Test ClassInferrer usage.""" + classes = self._run_class_inferrer() + + self.assertGreater(len(classes), 0) + class_names = [c['name'] for c in classes] + self.assertIn("Manager", class_names) + self.assertIn("Employee", class_names) + + def test_property_generator(self): + """Test PropertyGenerator usage.""" + # Setup context classes (reusing logic from previous test) + classes = self._run_class_inferrer() + + prop_gen = PropertyGenerator() + + complex_entities = [ + {"id": "m1", "type": "Manager", "name": "Dave", "level": 5}, + {"id": "e1", "type": "Employee", "name": "Frank"} + ] + complex_relationships = [ + {"source": "m1", "target": "e1", "type": "supervises"} + ] + + properties = prop_gen.infer_properties( + entities=complex_entities, + relationships=complex_relationships, + classes=classes, + min_occurrences=1 + ) + + self.assertGreater(len(properties), 0) + prop_names = [p['name'] for p in properties] + # "level" should be a data property, "supervises" an object property + self.assertTrue(any("level" in p['name'].lower() for p in properties)) + self.assertTrue(any("supervises" in p['name'].lower() for p in properties)) + + def test_ontology_optimizer(self): + """Test OntologyOptimizer usage.""" + optimizer = OntologyOptimizer() + + messy_ontology = { + "classes": [ + {"name": "Person", "uri": "http://example.org/Person"}, + {"name": "Person", "uri": "http://example.org/Person"} # Duplicate! + ], + "properties": [] + } + + clean_ontology = optimizer.optimize_ontology(messy_ontology, remove_redundancy=True) + + self.assertEqual(len(messy_ontology['classes']), 2) + self.assertEqual(len(clean_ontology['classes']), 1) + + def test_ontology_validator(self): + """Test OntologyValidator usage.""" + validator = OntologyValidator( + check_consistency=False, # Skip reasoner for unit test speed/dependency + check_satisfiability=False + ) + + ontology = self._run_full_pipeline() + result = validator.validate_ontology(ontology) + + self.assertTrue(result.valid) + # consistent might be None if check skipped, or True/False. + # Just check it runs without error. + + @patch("semantica.visualization.ontology_visualizer.make_subplots") + @patch("semantica.visualization.ontology_visualizer.go") + def test_visualization(self, mock_go, mock_make_subplots): + """Test OntologyVisualizer usage (mocking plotly).""" + viz = OntologyVisualizer() + ontology = self._run_full_pipeline() + + # Mock figures + mock_fig = MagicMock() + mock_go.Figure.return_value = mock_fig + mock_make_subplots.return_value = mock_fig + mock_go.Scatter.return_value = MagicMock() + mock_go.Indicator.return_value = MagicMock() + + # 1. Interactive Class Hierarchy + fig_hierarchy = viz.visualize_hierarchy(ontology, output="interactive") + # Just check it didn't crash; real test would check calls + + # 2. Ontology Structure Network + fig_structure = viz.visualize_structure(ontology, output="interactive") + + # 3. Metrics Dashboard + fig_metrics = viz.visualize_metrics(ontology, output="interactive") + + @patch("semantica.ontology.llm_generator.LLMOntologyGenerator.generate_ontology_from_text") + def test_llm_ontology_generator(self, mock_generate): + """Test LLMOntologyGenerator (mocked).""" + mock_generate.return_value = { + "classes": [{"name": "Department"}, {"name": "Course"}], + "properties": [], + "name": "UniversityOntology" + } + + llm_gen = LLMOntologyGenerator(provider="openai", model="gpt-4") + + text_description = "A University has many Departments." + + llm_ontology = llm_gen.generate_ontology_from_text( + text=text_description, + name="UniversityOntology" + ) + + self.assertEqual(llm_ontology['name'], "UniversityOntology") + self.assertEqual(len(llm_ontology['classes']), 2) + + def test_competency_questions(self): + """Test CompetencyQuestionsManager.""" + cq_manager = CompetencyQuestionsManager() + + cq_manager.add_question("Who is the CEO?", category="general") + questions = cq_manager.questions + self.assertGreater(len(questions), 0) + + def test_ontology_engine_initialization(self): + """Test initializing the OntologyEngine.""" + engine = OntologyEngine(base_uri="https://docs.semantica.dev/ontology/") + self.assertIsNotNone(engine) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/ontology/test_ontology_comprehensive.py b/tests/ontology/test_ontology_comprehensive.py new file mode 100644 index 00000000..e85ecf29 --- /dev/null +++ b/tests/ontology/test_ontology_comprehensive.py @@ -0,0 +1,262 @@ + +import unittest +from unittest.mock import MagicMock, patch +from collections import defaultdict +from semantica.ontology.class_inferrer import ClassInferrer +from semantica.ontology.property_generator import PropertyGenerator +from semantica.ontology.naming_conventions import NamingConventions +from semantica.ontology.ontology_generator import OntologyGenerator +from semantica.ontology.ontology_validator import OntologyValidator, ValidationResult +from semantica.ontology.namespace_manager import NamespaceManager +from semantica.ontology.module_manager import ModuleManager + +class TestOntologyComprehensive(unittest.TestCase): + + def setUp(self): + # Mock dependencies + self.mock_logger = MagicMock() + self.mock_tracker = MagicMock() + self.mock_tracker.start_tracking.return_value = "track_id" + + # Patch loggers and trackers + self.patchers = [ + patch('semantica.ontology.class_inferrer.get_logger', return_value=self.mock_logger), + patch('semantica.ontology.class_inferrer.get_progress_tracker', return_value=self.mock_tracker), + patch('semantica.ontology.property_generator.get_logger', return_value=self.mock_logger), + patch('semantica.ontology.property_generator.get_progress_tracker', return_value=self.mock_tracker), + patch('semantica.ontology.naming_conventions.get_logger', return_value=self.mock_logger), + patch('semantica.ontology.naming_conventions.get_progress_tracker', return_value=self.mock_tracker), + patch('semantica.ontology.ontology_generator.get_logger', return_value=self.mock_logger), + patch('semantica.ontology.ontology_generator.get_progress_tracker', return_value=self.mock_tracker), + patch('semantica.ontology.ontology_validator.get_logger', return_value=self.mock_logger), + patch('semantica.ontology.ontology_validator.get_progress_tracker', return_value=self.mock_tracker), + ] + + for p in self.patchers: + p.start() + + def tearDown(self): + for p in self.patchers: + p.stop() + + # --- NamingConventions Tests --- + def test_naming_conventions(self): + nc = NamingConventions() + + # Test class naming (PascalCase) + self.assertEqual(nc.normalize_class_name("person"), "Person") + self.assertEqual(nc.normalize_class_name("my class"), "MyClass") + self.assertEqual(nc.normalize_class_name("MY_CLASS"), "MyClass") + + # Test property naming (camelCase) + self.assertEqual(nc.normalize_property_name("has name", "data"), "hasName") + self.assertEqual(nc.normalize_property_name("is related to", "object"), "isRelatedTo") + + # Test validation + is_valid, _ = nc.validate_class_name("Person") + self.assertTrue(is_valid) + + is_valid, _ = nc.validate_property_name("hasName", "data") + self.assertTrue(is_valid) + + # --- ClassInferrer Tests --- + def test_class_inferrer(self): + inferrer = ClassInferrer(min_occurrences=1) + + entities = [ + {"type": "Person", "name": "Alice", "age": 30}, + {"type": "Person", "name": "Bob", "age": 25}, + {"type": "Organization", "name": "Acme Corp", "location": "US"} + ] + + classes = inferrer.infer_classes(entities) + + self.assertEqual(len(classes), 2) + + person_class = next(c for c in classes if c["name"] == "Person") + org_class = next(c for c in classes if c["name"] == "Organization") + + self.assertEqual(person_class["entity_count"], 2) + self.assertEqual(org_class["entity_count"], 1) + + # Check inferred properties in class definition metadata + # (Implementation detail: infer_classes calls _create_class_from_entities) + # We might need to check if properties are in metadata or top level + # Based on docstring: properties: List of common property names + self.assertIn("name", person_class["properties"]) + self.assertIn("age", person_class["properties"]) + + def test_class_inferrer_min_occurrences(self): + inferrer = ClassInferrer(min_occurrences=2) + + entities = [ + {"type": "Person", "name": "Alice"}, + {"type": "Person", "name": "Bob"}, + {"type": "RareEntity", "name": "Rare"} + ] + + classes = inferrer.infer_classes(entities) + + self.assertEqual(len(classes), 1) + self.assertEqual(classes[0]["name"], "Person") + + # --- PropertyGenerator Tests --- + def test_property_generator(self): + # Test property inference logic + generator = PropertyGenerator(min_occurrences=1) + + entities = [{"id": "p1", "type": "Person"}, {"id": "o1", "type": "Organization"}] + relationships = [ + {"source_id": "p1", "target_id": "o1", "type": "worksFor", "source_type": "Person", "target_type": "Organization"} + ] + classes = [{"name": "Person"}, {"name": "Organization"}] + + properties = generator.infer_properties(entities, relationships, classes) + + # Debug print + # print(f"Properties: {properties}") + + # Check object property + works_for = next((p for p in properties if p["name"] == "worksFor"), None) + self.assertIsNotNone(works_for) + + # --- OntologyGenerator Tests --- + def test_ontology_generator_pipeline(self): + # Test full pipeline with mocks + generator = OntologyGenerator() + + # Mock dependencies + generator.class_inferrer.infer_classes = MagicMock(return_value=[ + {"name": "Person", "uri": "http://example.org/Person"} + ]) + generator.property_generator.infer_properties = MagicMock(return_value=[ + {"name": "worksFor", "type": "object", "domain": ["Person"], "range": ["Organization"]} + ]) + + data = { + "entities": [{"type": "Person", "id": "p1"}], + "relationships": [{"type": "worksFor", "source": "p1"}] + } + + ontology = generator.generate_ontology(data, name="TestOntology") + + self.assertEqual(ontology["name"], "TestOntology") + self.assertIn("classes", ontology) + self.assertIn("properties", ontology) + + # --- OWLGenerator Tests --- + def test_owl_generator(self): + try: + from semantica.ontology.owl_generator import OWLGenerator + except ImportError: + self.skipTest("OWLGenerator not importable") + + generator = OWLGenerator() + ontology = { + "name": "TestOntology", + "uri": "http://example.org/ontology", + "classes": [{"name": "Person", "uri": "http://example.org/ontology/Person"}], + "properties": [{"name": "hasName", "type": "data", "uri": "http://example.org/ontology/hasName"}] + } + + owl_output = generator.generate_owl(ontology, format="turtle") + self.assertIsInstance(owl_output, str) + self.assertIn("Person", owl_output) + self.assertIn("hasName", owl_output) + + # --- OntologyValidator Tests --- + def test_ontology_validator(self): + try: + from semantica.ontology.ontology_validator import OntologyValidator, ValidationResult + except ImportError: + self.skipTest("OntologyValidator not importable") + + validator = OntologyValidator(reasoner="auto") # or mock reasoner + ontology = { + "name": "TestOntology", + "classes": [{"name": "Person", "parent": "Entity"}] + } + + # Without owlready2, it might just return valid=True (default) or fail gracefully + # If owlready2 is missing, it should handle it. + # Let's check basic structure validation if any. + result = validator.validate_ontology(ontology) + self.assertIsInstance(result, ValidationResult) + + # --- LLMOntologyGenerator Tests --- + def test_llm_ontology_generator(self): + try: + from semantica.ontology.llm_generator import LLMOntologyGenerator + except ImportError: + self.skipTest("LLMOntologyGenerator not importable") + + # Mock provider + with patch('semantica.ontology.llm_generator.create_provider') as mock_create: + mock_provider = MagicMock() + mock_create.return_value = mock_provider + + # Setup mock return + mock_provider.generate_structured.return_value = { + "name": "AI Generated", + "classes": [{"name": "Robot", "label": "A Robot"}], + "properties": [{"name": "hasModel", "type": "data"}] + } + + generator = LLMOntologyGenerator(provider="openai") + ontology = generator.generate_ontology_from_text("Create ontology about robots") + + self.assertEqual(ontology["name"], "AI Generated") + self.assertEqual(len(ontology["classes"]), 1) + self.assertEqual(ontology["classes"][0]["name"], "Robot") + + # --- OntologyEngine Tests --- + def test_ontology_engine(self): + try: + from semantica.ontology.engine import OntologyEngine + except ImportError: + self.skipTest("OntologyEngine not importable") + + engine = OntologyEngine() + + # Mock internal components + engine.generator.generate_ontology = MagicMock(return_value={"name": "EngineOntology"}) + + ontology = engine.from_data({"entities": []}) + self.assertEqual(ontology["name"], "EngineOntology") + + # --- NamespaceManager Tests --- + def test_namespace_manager(self): + nm = NamespaceManager(base_uri="http://example.org/") + + iri = nm.generate_class_iri("Person") + self.assertEqual(iri, "http://example.org/Person") + + prop_iri = nm.generate_property_iri("hasName") + # With fix, it should preserve hasName + self.assertEqual(prop_iri, "http://example.org/hasName") + + # bind_prefix is not in NamespaceManager, checking code... + # It's register_namespace + nm.register_namespace("ex", "http://example.org/") + self.assertEqual(nm.get_namespace("ex"), "http://example.org/") + + # --- ModuleManager Tests --- + def test_module_manager(self): + mm = ModuleManager() + + module_def = { + "name": "PersonModule", + "classes": ["Person"], + "properties": ["hasName"] + } + + # ModuleManager uses create_module + mm.create_module("PersonModule", "http://example.org/person", classes=["Person"], properties=["hasName"]) + self.assertIn("PersonModule", mm.modules) + + mod = mm.get_module("PersonModule") + self.assertEqual(mod.name, "PersonModule") + self.assertIn("Person", mod.classes) + +if __name__ == '__main__': + unittest.main()