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)