Merge pull request #77 from Hawksight-AI/ontology

Comprehensive Testing and Bug Fixes for Ontology Module
This commit is contained in:
Mohd Kaif
2025-12-11 18:16:31 +05:30
committed by GitHub
8 changed files with 514 additions and 11 deletions
+1 -1
View File
@@ -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)
+4
View File
@@ -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:
+8 -4
View File
@@ -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
+13 -2
View File
@@ -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
+5
View File
@@ -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 = []
+12 -4
View File
@@ -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
+209
View File
@@ -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()
@@ -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()