diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73c9d1af..68db6389 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Ontology Ingestion Module**:
+ - Implemented `OntologyIngestor` in `semantica.ingest` for parsing RDF/OWL files (Turtle, RDF/XML, JSON-LD, N3) into standardized `OntologyData` objects.
+ - Added `ingest_ontology` convenience function and integrated it into the unified `ingest(source_type="ontology")` interface.
+ - Added recursive directory scanning support for batch ontology ingestion.
+ - Exposed ingestion tools in `semantica.ontology` for better discoverability.
+ - Added `OntologyData` dataclass for consistent metadata handling (source path, format, timestamps).
+- **Documentation**:
+ - **Ontology Usage Guide**: Updated `ontology_usage.md` with comprehensive examples for single-file and directory ingestion.
+ - **API Reference**: Updated `ontology.md` with `OntologyIngestor` class documentation and method details.
+- **Tests**:
+ - **Comprehensive Test Suite**: Added `tests/ingest/test_ontology_ingestor.py` covering all supported formats, error handling, and unified interface integration.
+ - **Demo Script**: Added `examples/demo_ontology_ingest.py` for end-to-end usage demonstration.
+
## [0.2.3] - 2026-01-20
### Fixed
diff --git a/docs/reference/ontology.md b/docs/reference/ontology.md
index 8c6ce8f2..5c46d816 100644
--- a/docs/reference/ontology.md
+++ b/docs/reference/ontology.md
@@ -63,6 +63,29 @@ The module uses several inference algorithms:
---
+## Ontology Ingestion
+
+Ingest existing ontology files directly into usable data structures using `OntologyIngestor`.
+
+**Function:** `ingest_ontology(source, method="file")`
+
+| Argument | Description |
+|----------|-------------|
+| `source` | File path, directory path, or list of paths |
+| `method` | Ingestion method (default: "file") |
+
+**Example:**
+
+```python
+from semantica.ontology import ingest_ontology
+
+# Ingest file
+data = ingest_ontology("ontology.ttl")
+
+# Ingest directory
+dataset = ingest_ontology("ontologies/")
+```
+
## Main Classes
### OntologyEngine
@@ -170,6 +193,17 @@ Manages external dependencies.
| `import_external_ontology(uri, ontology)` | Load and merge external ontology |
| `evaluate_alignment(uri, ontology)` | Assess alignment and compatibility |
+### OntologyIngestor
+
+Handles ingestion of existing ontologies from files and directories.
+
+**Methods:**
+
+| Method | Description |
+|--------|-------------|
+| `ingest_ontology(file_path)` | Ingest a single ontology file |
+| `ingest_directory(directory_path)` | Recursively ingest ontology files from a directory |
+
---
## Unified Engine Examples
diff --git a/examples/demo_ontology_ingest.py b/examples/demo_ontology_ingest.py
new file mode 100644
index 00000000..ec745e06
--- /dev/null
+++ b/examples/demo_ontology_ingest.py
@@ -0,0 +1,68 @@
+import os
+import shutil
+import tempfile
+from pathlib import Path
+from semantica.ingest import ingest, ingest_ontology, OntologyData
+
+def demo_ontology_ingestion():
+ print("=== Ontology Ingestion Demo ===")
+
+ # Create a sample ontology file
+ sample_ttl = """
+ @prefix : .
+ @prefix owl: .
+ @prefix rdf: .
+ @prefix rdfs: .
+
+ rdf:type owl:Ontology ;
+ rdfs:label "Demo Ontology" ;
+ rdfs:comment "A simple ontology for demonstration." .
+
+ :DemoClass rdf:type owl:Class ;
+ rdfs:label "Demo Class" .
+ """
+
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".ttl", mode="w") as tmp:
+ tmp.write(sample_ttl)
+ tmp_path = tmp.name
+
+ print(f"\nCreated temporary ontology file: {tmp_path}")
+
+ try:
+ # 1. Use ingest_ontology convenience function
+ print("\n--- Method 1: ingest_ontology() ---")
+ result = ingest_ontology(tmp_path)
+
+ if isinstance(result, OntologyData):
+ print(f"Success! Ingested ontology: {result.data.get('name')}")
+ print(f"Format: {result.metadata.get('format')}")
+ print(f"Classes found: {len(result.data.get('classes', []))}")
+ for cls in result.data.get('classes', []):
+ print(f" - {cls.get('name')} ({cls.get('uri')})")
+ else:
+ print("Unexpected result type:", type(result))
+
+ # 2. Use unified ingest function
+ print("\n--- Method 2: Unified ingest() ---")
+ # Explicitly setting source_type="ontology" ensures it uses OntologyIngestor
+ unified_result = ingest(tmp_path, source_type="ontology")
+
+ if "ontology" in unified_result:
+ ont_data = unified_result["ontology"]
+ if isinstance(ont_data, OntologyData):
+ print(f"Success! Ingested via unified interface.")
+ print(f"Ontology Name: {ont_data.data.get('name')}")
+ else:
+ print(f"Got 'ontology' key but value is {type(ont_data)}")
+ else:
+ print("Unified ingest result keys:", unified_result.keys())
+
+ except Exception as e:
+ print(f"An error occurred: {e}")
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ print(f"\nCleaned up temporary file.")
+
+if __name__ == "__main__":
+ demo_ontology_ingestion()
diff --git a/semantica/ingest/__init__.py b/semantica/ingest/__init__.py
index 5fe6c23f..04a1f479 100644
--- a/semantica/ingest/__init__.py
+++ b/semantica/ingest/__init__.py
@@ -86,6 +86,7 @@ Main Classes:
- RepoIngestor: Git repository processing
- EmailIngestor: Email protocol handling
- DBIngestor: Database export handling
+ - OntologyIngestor: Ontology file processing
- MethodRegistry: Registry for custom ingestion methods
- IngestConfig: Configuration manager for ingest module
@@ -98,6 +99,7 @@ Convenience Functions:
- ingest_repository: Repository ingestion wrapper
- ingest_email: Email ingestion wrapper
- ingest_database: Database ingestion wrapper
+ - ingest_ontology: Ontology ingestion wrapper
Example Usage:
@@ -134,6 +136,7 @@ from .methods import (
ingest_feed,
ingest_file,
ingest_mcp,
+ ingest_ontology,
ingest_repository,
ingest_stream,
ingest_web,
@@ -166,6 +169,8 @@ from .web_ingestor import (
WebIngestor,
)
+from .ontology_ingestor import OntologyData, OntologyIngestor
+
__all__ = [
# File ingestion
"FileIngestor",
@@ -216,6 +221,9 @@ __all__ = [
"MCPClient",
"MCPResource",
"MCPTool",
+ # Ontology ingestion
+ "OntologyIngestor",
+ "OntologyData",
# Registry and Methods
"MethodRegistry",
"method_registry",
@@ -227,6 +235,7 @@ __all__ = [
"ingest_repository",
"ingest_email",
"ingest_database",
+ "ingest_ontology",
"ingest_mcp",
"get_ingest_method",
"list_available_methods",
diff --git a/semantica/ingest/methods.py b/semantica/ingest/methods.py
index dbb1740d..ecc1a90a 100644
--- a/semantica/ingest/methods.py
+++ b/semantica/ingest/methods.py
@@ -150,6 +150,7 @@ from .email_ingestor import EmailData, EmailIngestor
from .feed_ingestor import FeedData, FeedIngestor
from .file_ingestor import FileIngestor, FileObject
from .mcp_ingestor import MCPData, MCPIngestor
+from .ontology_ingestor import OntologyData, OntologyIngestor
from .registry import method_registry
from .repo_ingestor import RepoIngestor
from .stream_ingestor import StreamIngestor, StreamProcessor
@@ -537,6 +538,66 @@ def ingest_email(
raise
+def ingest_ontology(
+ source: Union[str, Path, List[Union[str, Path]]], method: str = "file", **kwargs
+) -> Union[OntologyData, List[OntologyData]]:
+ """
+ Ingest ontology from source (convenience function).
+
+ This is a user-friendly wrapper that ingests ontologies using the specified method.
+
+ Args:
+ source: Ontology file path, directory path, or list of paths
+ method: Ingestion method (default: "file")
+ - "file": Single file ingestion
+ - "directory": Directory ingestion with recursive scanning
+ **kwargs: Additional options passed to OntologyIngestor
+
+ Returns:
+ OntologyData, List[OntologyData] with ingestion results
+
+ Examples:
+ >>> from semantica.ingest.methods import ingest_ontology
+ >>> ontology = ingest_ontology("ontology.ttl")
+ >>> ontologies = ingest_ontology("./ontologies", method="directory")
+ """
+ # Check for custom method in registry
+ custom_method = method_registry.get("ontology", method)
+ if custom_method and custom_method != ingest_ontology:
+ try:
+ return custom_method(source, **kwargs)
+ except Exception as e:
+ logger.warning(
+ f"Custom method {method} failed: {e}, falling back to default"
+ )
+
+ try:
+ # Get config
+ config = ingest_config.get_method_config("ontology")
+ config.update(kwargs)
+
+ ingestor = OntologyIngestor(**config)
+
+ source_path = str(source) if isinstance(source, (str, Path)) else None
+
+ if method == "file" and source_path:
+ if isinstance(source, list):
+ return [ingestor.ingest_ontology(str(s), **kwargs) for s in source]
+ return ingestor.ingest_ontology(source_path, **kwargs)
+ elif method == "directory" and source_path:
+ recursive = kwargs.get("recursive", ingest_config.get("recursive", True))
+ return ingestor.ingest_directory(source_path, recursive=recursive, **kwargs)
+ else:
+ # Default: try as file
+ if isinstance(source, list):
+ return [ingestor.ingest_ontology(str(s), **kwargs) for s in source]
+ return ingestor.ingest_ontology(str(source), **kwargs)
+
+ except Exception as e:
+ logger.error(f"Failed to ingest ontology: {e}")
+ raise
+
+
def ingest_database(
source: Union[str, Dict[str, Any]], method: Optional[str] = None, **kwargs
) -> Union[TableData, List[TableData], Dict[str, Any]]:
@@ -769,6 +830,7 @@ def ingest(
- "repo": Repository ingestion
- "email": Email ingestion
- "db": Database ingestion
+ - "ontology": Ontology ingestion
method: Optional specific ingestion method
**kwargs: Additional options passed to ingestor
@@ -802,6 +864,8 @@ def ingest(
("git@", "https://github.com", "https://gitlab.com")
):
source_type = "repo"
+ elif source_str.endswith((".ttl", ".owl", ".rdf", ".jsonld", ".n3", ".nt")):
+ source_type = "ontology"
else:
source_type = "file"
else:
@@ -830,6 +894,8 @@ def ingest(
raise ProcessingError("Email ingestion requires configuration dictionary")
elif source_type == "db":
return {"data": ingest_database(sources, method=method, **kwargs)}
+ elif source_type == "ontology":
+ return {"ontology": ingest_ontology(sources, method=method or "file", **kwargs)}
elif source_type == "mcp":
return {"data": ingest_mcp(sources, method=method or "resources", **kwargs)}
else:
@@ -909,5 +975,8 @@ method_registry.register("mcp", "default", ingest_mcp)
method_registry.register("mcp", "resources", ingest_mcp)
method_registry.register("mcp", "tools", ingest_mcp)
method_registry.register("mcp", "all", ingest_mcp)
+method_registry.register("ontology", "default", ingest_ontology)
+method_registry.register("ontology", "file", ingest_ontology)
+method_registry.register("ontology", "directory", ingest_ontology)
method_registry.register("ingest", "default", ingest)
method_registry.register("ingest", "unified", ingest)
diff --git a/semantica/ingest/ontology_ingestor.py b/semantica/ingest/ontology_ingestor.py
new file mode 100644
index 00000000..3b8638f9
--- /dev/null
+++ b/semantica/ingest/ontology_ingestor.py
@@ -0,0 +1,392 @@
+"""
+Ontology Ingestion Module
+
+This module provides capabilities to ingest external ontologies from files (OWL, RDF, TTL, etc.)
+and convert them into Semantica's internal ontology dictionary format.
+
+Supported Formats:
+ - Turtle (.ttl): Terse RDF Triple Language. A concise, human-readable
+ format for representing RDF graphs. Commonly used for writing
+ ontologies by hand.
+ - RDF/XML (.rdf, .owl): The XML serialization of RDF. The standard
+ format for OWL (Web Ontology Language) ontologies and often used
+ for data interchange.
+ - JSON-LD (.jsonld): JSON for Linked Data. A lightweight Linked Data
+ format that is easy for humans to read and for machines to parse
+ and generate. Ideal for web-based applications.
+ - N-Triples (.nt): A line-based, plain text format for encoding an
+ RDF graph. Each line represents a single triple. Very simple to
+ parse but verbose.
+ - Notation3 (.n3): A superset of Turtle that adds features like logic
+ and rules.
+
+Key Features:
+ - Support for multiple RDF formats (Turtle, RDF/XML, JSON-LD, N3, NT)
+ - Automatic parsing using rdflib
+ - Conversion to Semantica ontology structure
+ - Batch processing of ontology files
+ - Extraction of classes, properties, and metadata
+
+Example Usage:
+ >>> from semantica.ingest import OntologyIngestor
+ >>> ingestor = OntologyIngestor()
+ >>> ontology = ingestor.ingest_ontology("my_ontology.ttl")
+"""
+
+import os
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Union
+
+import rdflib
+from rdflib import RDF, RDFS, OWL, Graph
+
+from ..utils.exceptions import ProcessingError, ValidationError
+from ..utils.logging import get_logger
+from ..utils.progress_tracker import get_progress_tracker
+
+
+@dataclass
+class OntologyData:
+ """Ontology data representation."""
+
+ data: Dict[str, Any]
+ source_path: str
+ format: str
+ metadata: Dict[str, Any] = field(default_factory=dict)
+ ingested_at: datetime = field(default_factory=datetime.now)
+
+
+class OntologyIngestor:
+ """
+ Ontology ingestion handler.
+
+ This class parses OWL/RDF files and converts them to Semantica's ontology dictionary format.
+ """
+
+ def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
+ """
+ Initialize ontology ingestor.
+
+ Args:
+ config: Optional configuration dictionary
+ **kwargs: Additional configuration parameters
+ """
+ self.logger = get_logger("ontology_ingestor")
+ self.progress = get_progress_tracker()
+ self.config = config or {}
+ self.config.update(kwargs)
+
+ def ingest_ontology(self, file_path: Union[str, Path], format: Optional[str] = None, **kwargs) -> OntologyData:
+ """
+ Ingest an ontology file.
+
+ Args:
+ file_path: Path to the ontology file (string or Path object)
+ format: Optional format hint (e.g., 'turtle', 'xml'). If None, rdflib guesses.
+ **kwargs: Additional arguments for rdflib parsing
+
+ Returns:
+ OntologyData object containing the parsed ontology and metadata
+ """
+ file_path = Path(file_path)
+
+ # Track file ingestion
+ tracking_id = self.progress.start_tracking(
+ file=str(file_path),
+ module="ingest",
+ submodule="OntologyIngestor",
+ message=f"Ontology: {file_path.name}",
+ )
+
+ try:
+ # Validate file exists
+ if not file_path.exists():
+ raise ValidationError(f"File not found: {file_path}")
+
+ self.progress.update_tracking(tracking_id, message="Parsing RDF graph...")
+ g = Graph()
+
+ # Use provided format or let rdflib guess based on extension
+ parse_kwargs = kwargs.copy()
+ if format:
+ parse_kwargs['format'] = format
+
+ try:
+ g.parse(file_path, **parse_kwargs)
+ except Exception as e:
+ # Fallback: try to guess format from extension if not provided and initial parse failed
+ if not format:
+ ext = os.path.splitext(file_path)[1].lower()
+ fmt_map = {
+ '.ttl': 'turtle',
+ '.owl': 'xml', # OWL is often XML
+ '.rdf': 'xml',
+ '.jsonld': 'json-ld',
+ '.n3': 'n3',
+ '.nt': 'nt'
+ }
+ guessed_fmt = fmt_map.get(ext)
+ if guessed_fmt:
+ self.logger.info(f"Retrying with guessed format: {guessed_fmt}")
+ g.parse(file_path, format=guessed_fmt, **kwargs)
+ else:
+ raise e
+ else:
+ raise e
+
+ self.progress.update_tracking(tracking_id, message="Converting to internal format...")
+
+ # Determine format for metadata
+ used_format = format
+ if not used_format:
+ ext = os.path.splitext(file_path)[1].lower()
+ fmt_map = {
+ '.ttl': 'turtle',
+ '.owl': 'xml',
+ '.rdf': 'xml',
+ '.jsonld': 'json-ld',
+ '.n3': 'n3',
+ '.nt': 'nt'
+ }
+ used_format = fmt_map.get(ext, 'unknown')
+
+ ontology_dict = self._convert_to_dict(g, source_path=str(file_path), format=used_format)
+
+ ontology_data = OntologyData(
+ data=ontology_dict,
+ source_path=str(file_path),
+ format=used_format,
+ metadata=ontology_dict.get("metadata", {}).copy()
+ )
+
+ self.progress.stop_tracking(
+ tracking_id,
+ status="completed",
+ message=f"Successfully ingested ontology from {file_path}",
+ )
+
+ return ontology_data
+
+ except Exception as e:
+ self.logger.error(f"Failed to ingest ontology: {str(e)}")
+ self.progress.stop_tracking(
+ tracking_id, status="failed", message=str(e)
+ )
+ raise ProcessingError(f"Failed to ingest ontology: {str(e)}") from e
+
+ def ingest_directory(self, directory_path: Union[str, Path], recursive: bool = True, **kwargs) -> List[OntologyData]:
+ """
+ Ingest all ontology files in a directory.
+
+ Args:
+ directory_path: Path to the directory (string or Path object)
+ recursive: Whether to search recursively
+ **kwargs: Additional arguments
+
+ Returns:
+ List of OntologyData objects
+ """
+ directory_path = Path(directory_path)
+ ontologies = []
+ extensions = {'.ttl', '.owl', '.rdf', '.jsonld', '.n3', '.nt'}
+
+ # Track directory ingestion
+ tracking_id = self.progress.start_tracking(
+ file=str(directory_path),
+ module="ingest",
+ submodule="OntologyIngestor",
+ message=f"Directory: {directory_path.name}",
+ )
+
+ try:
+ if not directory_path.exists():
+ raise ValidationError(f"Directory not found: {directory_path}")
+
+ if not directory_path.is_dir():
+ raise ValidationError(f"Path is not a directory: {directory_path}")
+
+ files_to_process = []
+ for root, _, files in os.walk(directory_path):
+ for file in files:
+ ext = os.path.splitext(file)[1].lower()
+ if ext in extensions:
+ files_to_process.append(os.path.join(root, file))
+
+ if not recursive:
+ break
+
+ total_files = len(files_to_process)
+ self.progress.update_tracking(
+ tracking_id, message=f"Processing {total_files} ontology files"
+ )
+
+ for idx, file_path in enumerate(files_to_process, 1):
+ try:
+ ont_data = self.ingest_ontology(file_path, **kwargs)
+ ontologies.append(ont_data)
+
+ self.progress.update_progress(
+ tracking_id,
+ processed=idx,
+ total=total_files,
+ message=f"Processing {idx}/{total_files}: {Path(file_path).name}"
+ )
+ except Exception as e:
+ self.logger.warning(f"Skipping {file_path}: {e}")
+
+ self.progress.stop_tracking(
+ tracking_id,
+ status="completed",
+ message=f"Ingested {len(ontologies)} ontologies",
+ )
+ return ontologies
+
+ except Exception as e:
+ self.progress.stop_tracking(
+ tracking_id, status="failed", message=str(e)
+ )
+ raise
+
+ def _convert_to_dict(self, graph: Graph, source_path: str, format: str = "unknown") -> Dict[str, Any]:
+ """
+ Convert rdflib Graph to Semantica ontology dictionary.
+
+ Args:
+ graph: Parsed rdflib Graph
+ source_path: Source file path
+ format: Format of the ontology file
+
+ Returns:
+ Ontology dictionary
+ """
+ ontology = {
+ "uri": "",
+ "name": os.path.basename(source_path),
+ "version": "1.0",
+ "classes": [],
+ "properties": [],
+ "metadata": {
+ "source_path": source_path,
+ "ingested_at": datetime.now().isoformat(),
+ "format": format
+ }
+ }
+
+ # 1. Extract Ontology Metadata
+ for s, p, o in graph.triples((None, RDF.type, OWL.Ontology)):
+ ontology["uri"] = str(s)
+
+ # Try to find label/comment/versionInfo
+ for _, _, label in graph.triples((s, RDFS.label, None)):
+ ontology["name"] = str(label)
+
+ for _, _, comment in graph.triples((s, RDFS.comment, None)):
+ ontology["description"] = str(comment)
+
+ for _, _, version in graph.triples((s, OWL.versionInfo, None)):
+ ontology["version"] = str(version)
+
+ # Break after first ontology definition found (usually only one per file)
+ break
+
+ # 2. Extract Classes
+ classes = {}
+ # Union of owl:Class and rdfs:Class
+ class_types = [OWL.Class, RDFS.Class]
+ for c_type in class_types:
+ for s, p, o in graph.triples((None, RDF.type, c_type)):
+ if isinstance(s, rdflib.BNode):
+ continue # Skip blank nodes for now
+
+ uri = str(s)
+ if uri not in classes:
+ cls_def = {
+ "uri": uri,
+ "name": self._get_local_name(uri),
+ "type": "class"
+ }
+
+ # Add label/comment
+ label = graph.value(s, RDFS.label)
+ if label:
+ cls_def["label"] = str(label)
+ cls_def["name"] = str(label) # Prefer label as name if available? Or keep URI fragment?
+ # Keeping local name from URI is safer for internal IDs, label for display.
+ # But Semantica seems to use "name" for the identifier in some examples.
+ # Let's keep name as local name or label if simple.
+
+ comment = graph.value(s, RDFS.comment)
+ if comment:
+ cls_def["description"] = str(comment)
+
+ # Superclasses
+ parents = []
+ for _, _, parent in graph.triples((s, RDFS.subClassOf, None)):
+ if isinstance(parent, rdflib.URIRef):
+ parents.append(str(parent))
+ if parents:
+ cls_def["parents"] = parents
+
+ classes[uri] = cls_def
+
+ ontology["classes"] = list(classes.values())
+
+ # 3. Extract Properties
+ properties = {}
+ # Object Properties
+ for s, p, o in graph.triples((None, RDF.type, OWL.ObjectProperty)):
+ self._add_property(graph, s, "object", properties)
+
+ # Datatype Properties
+ for s, p, o in graph.triples((None, RDF.type, OWL.DatatypeProperty)):
+ self._add_property(graph, s, "data", properties)
+
+ # RDF Properties (generic)
+ for s, p, o in graph.triples((None, RDF.type, RDF.Property)):
+ if str(s) not in properties: # Don't overwrite if already found as specific type
+ self._add_property(graph, s, "annotation", properties) # Default to annotation or generic
+
+ ontology["properties"] = list(properties.values())
+
+ return ontology
+
+ def _add_property(self, graph: Graph, subject: rdflib.term.Node, prop_type: str, properties_dict: Dict):
+ if isinstance(subject, rdflib.BNode):
+ return
+
+ uri = str(subject)
+ if uri in properties_dict:
+ return
+
+ prop_def = {
+ "uri": uri,
+ "name": self._get_local_name(uri),
+ "type": prop_type
+ }
+
+ label = graph.value(subject, RDFS.label)
+ if label:
+ prop_def["label"] = str(label)
+
+ comment = graph.value(subject, RDFS.comment)
+ if comment:
+ prop_def["description"] = str(comment)
+
+ # Domain and Range
+ domain = graph.value(subject, RDFS.domain)
+ if domain and isinstance(domain, rdflib.URIRef):
+ prop_def["domain"] = str(domain)
+
+ range_val = graph.value(subject, RDFS.range)
+ if range_val and isinstance(range_val, rdflib.URIRef):
+ prop_def["range"] = str(range_val)
+
+ properties_dict[uri] = prop_def
+
+ def _get_local_name(self, uri: str) -> str:
+ """Extract local name from URI."""
+ if '#' in uri:
+ return uri.split('#')[-1]
+ return uri.split('/')[-1]
diff --git a/semantica/ontology/__init__.py b/semantica/ontology/__init__.py
index c9baf840..0ef5b0c1 100644
--- a/semantica/ontology/__init__.py
+++ b/semantica/ontology/__init__.py
@@ -109,12 +109,14 @@ Convenience Functions:
- create_associative_class: Associative class creation wrapper
- get_ontology_method: Get ontology method by name
- list_available_methods: List registered methods
+ - ingest_ontology: Ingest ontology from file or directory
Example Usage:
- >>> from semantica.ontology import generate_ontology, infer_classes, OntologyGenerator
+ >>> from semantica.ontology import generate_ontology, infer_classes, OntologyGenerator, ingest_ontology
>>> # Using convenience functions
>>> ontology = generate_ontology({"entities": [...], "relationships": [...]}, method="default")
>>> classes = infer_classes(entities, method="default")
+ >>> data = ingest_ontology("ontology.ttl")
>>> # Using classes directly
>>> from semantica.ontology import OntologyGenerator, ClassInferrer, PropertyGenerator
>>> generator = OntologyGenerator(base_uri="https://example.org/ontology/")
@@ -155,6 +157,8 @@ from .registry import MethodRegistry, method_registry
from .requirements_spec import RequirementsSpec, RequirementsSpecManager
from .reuse_manager import ReuseDecision, ReuseManager
from .version_manager import OntologyVersion, VersionManager
+from semantica.ingest import OntologyData, OntologyIngestor
+from .methods import ingest_ontology
__all__ = [
# Main generators
@@ -200,4 +204,7 @@ __all__ = [
# Configuration
"OntologyConfig",
"ontology_config",
+ "ingest_ontology",
+ "OntologyData",
+ "OntologyIngestor",
]
diff --git a/semantica/ontology/methods.py b/semantica/ontology/methods.py
index f1dd0115..e0f483e3 100644
--- a/semantica/ontology/methods.py
+++ b/semantica/ontology/methods.py
@@ -111,15 +111,19 @@ Main Functions:
- create_associative_class: Associative class creation wrapper
- get_ontology_method: Get ontology method by name
- list_available_methods: List registered methods
+ - ingest_ontology: Ingest ontology from file or directory (via semantica.ingest)
Example Usage:
- >>> from semantica.ontology.methods import generate_ontology, infer_classes
+ >>> from semantica.ontology.methods import generate_ontology, infer_classes, ingest_ontology
>>> ontology = generate_ontology({"entities": [...], "relationships": [...]}, method="default")
>>> classes = infer_classes(entities, method="default")
+ >>> data = ingest_ontology("ontology.ttl")
"""
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Union
+from pathlib import Path
+from semantica.ingest import ingest_ontology as _ingest_ontology, OntologyData
from .registry import method_registry
@@ -172,4 +176,23 @@ def list_available_methods(task: Optional[str] = None) -> Dict[str, List[str]]:
return method_registry.list_all(task)
-pass
+def ingest_ontology(
+ source: Union[str, Path, List[Union[str, Path]]],
+ method: str = "file",
+ **kwargs
+) -> Union[OntologyData, List[OntologyData]]:
+ """
+ Ingest ontology from source.
+
+ This is a convenience wrapper around semantica.ingest.ingest_ontology.
+
+ Args:
+ source: Ontology file path, directory path, or list of paths
+ method: Ingestion method (default: "file")
+ **kwargs: Additional options
+
+ Returns:
+ OntologyData or List[OntologyData]
+ """
+ return _ingest_ontology(source, method=method, **kwargs)
+
diff --git a/semantica/ontology/ontology_usage.md b/semantica/ontology/ontology_usage.md
index 755172ba..85dc6f04 100644
--- a/semantica/ontology/ontology_usage.md
+++ b/semantica/ontology/ontology_usage.md
@@ -42,6 +42,18 @@ classes = inferrer.infer_classes(entities, build_hierarchy=True)
properties = prop_gen.infer_properties(entities, relationships, classes)
```
+### Ingesting Ontologies
+
+```python
+from semantica.ingest import OntologyIngestor
+
+# Create ingestor
+ingestor = OntologyIngestor()
+
+# Ingest ontology
+ontology_data = ingestor.ingest_ontology("ontology.ttl")
+```
+
## Ontology Generation
### Basic Ontology Generation
@@ -115,6 +127,49 @@ ontology = engine.from_data(
)
```
+## Ontology Ingestion
+
+### Basic Ingestion
+
+Ingest existing ontologies from files (Turtle, RDF/XML, JSON-LD, etc.) into `OntologyData` objects.
+
+```python
+from semantica.ontology import ingest_ontology
+
+# Ingest a single file
+ontology_data = ingest_ontology("path/to/ontology.ttl")
+
+print(f"Source: {ontology_data.source_path}")
+print(f"Format: {ontology_data.format}")
+print(f"Data keys: {ontology_data.data.keys()}")
+```
+
+### Ingesting Directories
+
+Ingest all ontology files in a directory recursively.
+
+```python
+from semantica.ontology import ingest_ontology
+
+# Ingest a directory
+ontologies = ingest_ontology("path/to/ontologies_dir/")
+
+for ont in ontologies:
+ print(f"Ingested: {ont.source_path} ({ont.format})")
+```
+
+### Unified Ingestion Interface
+
+You can also use the unified `semantica.ingest` interface.
+
+```python
+from semantica.ingest import ingest
+
+# Ingest as "ontology" source type
+result = ingest("path/to/ontology.ttl", source_type="ontology")
+ontology_data = result["ontology"]
+```
+
## Class Inference
### Basic Class Inference
diff --git a/semantica/ontology/reuse_manager.py b/semantica/ontology/reuse_manager.py
index 47550e92..2c9b546e 100644
--- a/semantica/ontology/reuse_manager.py
+++ b/semantica/ontology/reuse_manager.py
@@ -363,3 +363,90 @@ class ReuseManager:
def list_known_ontologies(self) -> List[str]:
"""List known ontology URIs."""
return list(self.known_ontologies.keys())
+
+ def merge_ontology_data(
+ self, target: Dict[str, Any], source: Dict[str, Any], **options
+ ) -> Dict[str, Any]:
+ """
+ Merge source ontology data into target ontology.
+
+ Merges classes, properties, and metadata from source to target.
+ Handles deduplication based on URI and name.
+
+ Args:
+ target: Target ontology dictionary (modified in-place)
+ source: Source ontology dictionary
+ **options: Merge options:
+ - overwrite: Whether to overwrite existing elements (default: False)
+ - merge_metadata: Whether to merge metadata (default: True)
+
+ Returns:
+ Merged target ontology
+ """
+ tracking_id = self.progress_tracker.start_tracking(
+ module="ontology",
+ submodule="ReuseManager",
+ message=f"Merging ontology {source.get('name', 'unknown')} into {target.get('name', 'unknown')}",
+ )
+
+ try:
+ overwrite = options.get("overwrite", False)
+
+ # Helper to merge lists of dicts (classes/properties)
+ def merge_lists(target_list, source_list, key_field="uri"):
+ existing_keys = {item.get(key_field): i for i, item in enumerate(target_list) if item.get(key_field)}
+
+ for item in source_list:
+ key = item.get(key_field)
+ if not key:
+ # Fallback to name if URI missing
+ key = item.get("name")
+
+ if key in existing_keys:
+ if overwrite:
+ target_list[existing_keys[key]] = item
+ else:
+ target_list.append(item)
+ if key:
+ existing_keys[key] = len(target_list) - 1
+
+ # Merge Classes
+ if "classes" in source:
+ if "classes" not in target:
+ target["classes"] = []
+ merge_lists(target["classes"], source["classes"])
+
+ # Merge Properties
+ if "properties" in source:
+ if "properties" not in target:
+ target["properties"] = []
+ merge_lists(target["properties"], source["properties"])
+
+ # Merge Metadata
+ if options.get("merge_metadata", True) and "metadata" in source:
+ if "metadata" not in target:
+ target["metadata"] = {}
+ # Update with source metadata, preserving target's specific fields if needed
+ # Here we just update
+ target["metadata"].update(source["metadata"])
+
+ # Merge Imports
+ if "imports" in source:
+ if "imports" not in target:
+ target["imports"] = []
+ for imp in source["imports"]:
+ if imp not in target["imports"]:
+ target["imports"].append(imp)
+
+ self.progress_tracker.stop_tracking(
+ tracking_id,
+ status="completed",
+ message=f"Merged ontology data successfully",
+ )
+ return target
+
+ except Exception as e:
+ self.progress_tracker.stop_tracking(
+ tracking_id, status="failed", message=str(e)
+ )
+ raise
diff --git a/tests/ingest/test_ontology_ingestor.py b/tests/ingest/test_ontology_ingestor.py
new file mode 100644
index 00000000..11cc246f
--- /dev/null
+++ b/tests/ingest/test_ontology_ingestor.py
@@ -0,0 +1,214 @@
+import os
+import shutil
+import tempfile
+import pytest
+from pathlib import Path
+from semantica.ingest import OntologyIngestor, ingest, ingest_ontology, OntologyData
+
+class TestOntologyIngestor:
+ @pytest.fixture
+ def sample_ttl_content(self):
+ return """
+ @prefix : .
+ @prefix owl: .
+ @prefix rdf: .
+ @prefix rdfs: .
+ @prefix xsd: .
+
+ rdf:type owl:Ontology ;
+ rdfs:label "Test Ontology" .
+
+ :Person rdf:type owl:Class ;
+ rdfs:label "Person" .
+
+ :hasName rdf:type owl:DatatypeProperty ;
+ rdfs:domain :Person ;
+ rdfs:range xsd:string .
+ """
+
+ def test_ingest_single_file(self, sample_ttl_content):
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".ttl", mode="w") as tmp:
+ tmp.write(sample_ttl_content)
+ tmp_path = tmp.name
+
+ try:
+ ingestor = OntologyIngestor()
+ result = ingestor.ingest_ontology(tmp_path)
+
+ assert isinstance(result, OntologyData)
+ assert result.data["name"] == "Test Ontology" or result.data["name"] == os.path.basename(tmp_path)
+ assert any(cls["name"] == "Person" for cls in result.data["classes"])
+ assert any(prop["name"] == "hasName" for prop in result.data["properties"])
+ assert result.metadata["format"] == "ttl" or result.metadata["format"] == "turtle"
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ def test_ingest_directory(self, sample_ttl_content):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ # Create two ontology files
+ file1 = os.path.join(tmp_dir, "ont1.ttl")
+ file2 = os.path.join(tmp_dir, "ont2.rdf")
+
+ with open(file1, "w") as f:
+ f.write(sample_ttl_content)
+
+ # Simple RDF/XML content for the second file
+ rdf_content = """
+
+
+
+
+ """
+ with open(file2, "w") as f:
+ f.write(rdf_content)
+
+ ingestor = OntologyIngestor()
+ results = ingestor.ingest_directory(tmp_dir)
+
+ assert len(results) == 2
+ assert all(isinstance(r, OntologyData) for r in results)
+
+ # Verify results contain expected classes
+ classes = [cls["name"] for res in results for cls in res.data["classes"]]
+ assert "Person" in classes
+ assert "Animal" in classes
+
+ def test_unified_ingest_function(self, sample_ttl_content):
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".ttl", mode="w") as tmp:
+ tmp.write(sample_ttl_content)
+ tmp_path = tmp.name
+
+ try:
+ # Test auto-detection via unified ingest
+ result = ingest(tmp_path)
+ assert "ontology" in result
+ assert isinstance(result["ontology"], OntologyData)
+ assert len(result["ontology"].data["classes"]) > 0
+
+ # Test explicit source type
+ result_explicit = ingest(tmp_path, source_type="ontology")
+ assert "ontology" in result_explicit
+ assert result_explicit["ontology"].metadata["source_path"] == tmp_path
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ def test_convenience_function(self, sample_ttl_content):
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".n3", mode="w") as tmp:
+ tmp.write(sample_ttl_content)
+ tmp_path = tmp.name
+
+ try:
+ result = ingest_ontology(tmp_path)
+ assert isinstance(result, OntologyData)
+ assert len(result.data["classes"]) > 0
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ def test_ingest_formats(self):
+ """Test ingestion of all supported formats."""
+ ingestor = OntologyIngestor()
+
+ # 1. JSON-LD
+ jsonld_content = """
+ {
+ "@context": {
+ "owl": "http://www.w3.org/2002/07/owl#",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#"
+ },
+ "@id": "http://example.org/jsonld",
+ "@type": "owl:Ontology",
+ "rdfs:label": "JSON-LD Ontology",
+ "owl:versionInfo": "1.0"
+ }
+ """
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonld", mode="w") as tmp:
+ tmp.write(jsonld_content)
+ tmp_path = tmp.name
+ try:
+ result = ingestor.ingest_ontology(tmp_path)
+ assert result.data["name"] == "JSON-LD Ontology"
+ assert result.metadata["format"] == "json-ld"
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ # 2. N-Triples
+ nt_content = ' .\n'
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".nt", mode="w") as tmp:
+ tmp.write(nt_content)
+ tmp_path = tmp.name
+ try:
+ result = ingestor.ingest_ontology(tmp_path)
+ # N-Triples often doesn't have ontology metadata, so name might default to basename
+ assert result.data["name"] == os.path.basename(tmp_path)
+ assert any(cls["uri"] == "http://example.org/nt/Class" for cls in result.data["classes"])
+ assert result.metadata["format"] == "nt"
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ # 3. Notation3
+ n3_content = """
+ @prefix : .
+ @prefix owl: .
+ :N3Class a owl:Class .
+ """
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".n3", mode="w") as tmp:
+ tmp.write(n3_content)
+ tmp_path = tmp.name
+ try:
+ result = ingestor.ingest_ontology(tmp_path)
+ assert any(cls["uri"] == "http://example.org/n3/N3Class" for cls in result.data["classes"])
+ # format might be 'n3' or 'turtle' depending on rdflib detection as they are similar
+ assert result.metadata["format"] in ["n3", "turtle"]
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ # 4. RDF/XML (.owl)
+ owl_content = """
+
+
+
+ OwlClass
+
+
+ """
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".owl", mode="w") as tmp:
+ tmp.write(owl_content)
+ tmp_path = tmp.name
+ try:
+ result = ingestor.ingest_ontology(tmp_path)
+ assert any(cls["name"] == "OwlClass" for cls in result.data["classes"])
+ assert result.metadata["format"] in ["xml", "rdf", "owl"]
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ def test_error_handling(self):
+ ingestor = OntologyIngestor()
+ with pytest.raises(Exception): # Specific exception type depends on implementation, likely ValidationError or FileNotFoundError
+ ingestor.ingest_ontology("non_existent_file.ttl")
+
+ def test_invalid_content(self):
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".ttl", mode="w") as tmp:
+ tmp.write("This is not valid turtle content")
+ tmp_path = tmp.name
+
+ try:
+ ingestor = OntologyIngestor()
+ # Depending on implementation, this might raise an exception or return partial/empty result with error in metadata
+ # Given current implementation uses g.parse(), it likely raises an exception which is caught or propagated
+ # If propagated:
+ with pytest.raises(Exception):
+ ingestor.ingest_ontology(tmp_path)
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)