297 lines
10 KiB
Python
297 lines
10 KiB
Python
import importlib
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from ontocast.onto.ontology import Ontology
|
|
from ontocast.onto.rdfgraph import RDFGraph
|
|
from ontocast.tool import FusekiTripleStoreManager, OntologyManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def merge_ontologies(onto1: Ontology, onto2: Ontology) -> Ontology:
|
|
"""Merge two ontologies algorithmically.
|
|
|
|
This performs a union merge of the two ontology graphs, mapping contradictions.
|
|
The result has both ontologies as parents. This is similar to a git merge:
|
|
- Takes union of all triples from both ontologies
|
|
- Detects contradictions (same subject-predicate with different objects)
|
|
- Creates a new ontology with both parents
|
|
- Sets created_at to merge time
|
|
|
|
Args:
|
|
onto1: First ontology to merge
|
|
onto2: Second ontology to merge
|
|
|
|
Returns:
|
|
Ontology: Merged ontology with both parents
|
|
|
|
Raises:
|
|
ValueError: If ontologies have different IRIs
|
|
"""
|
|
# Validate that both ontologies have the same IRI
|
|
if onto1.iri != onto2.iri:
|
|
raise ValueError(
|
|
f"Cannot merge ontologies with different IRIs: {onto1.iri} != {onto2.iri}"
|
|
)
|
|
|
|
# Ensure both ontologies have hashes
|
|
if not onto1.hash:
|
|
onto1._compute_and_set_hash()
|
|
if not onto2.hash:
|
|
onto2._compute_and_set_hash()
|
|
|
|
if not onto1.hash or not onto2.hash:
|
|
raise ValueError("Cannot merge ontologies without hashes")
|
|
|
|
# Create merged graph (union) - use RDFGraph's __add__ operator
|
|
merged_graph = onto1.graph + onto2.graph
|
|
|
|
# Map contradictions (same subject-predicate with different objects)
|
|
contradictions = _find_contradictions(onto1.graph, onto2.graph)
|
|
if contradictions:
|
|
logger.warning(f"Found {len(contradictions)} contradictions in merge")
|
|
for (s, p), (obj1_set, obj2) in contradictions.items():
|
|
logger.debug(f"Contradiction: {s} {p} -> {obj1_set} vs {obj2}")
|
|
# For now, keep both objects (RDF allows multiple values)
|
|
# In future LLM-based merge, this would be resolved intelligently
|
|
|
|
# Create merged ontology
|
|
# Note: sync_properties_to_graph() will remove existing versionInfo triples,
|
|
# so we need to preserve them manually after creation
|
|
merged_ontology = Ontology(
|
|
graph=merged_graph,
|
|
iri=onto1.iri, # Use IRI from first ontology (should be same)
|
|
title=onto1.title or onto2.title,
|
|
description=onto1.description or onto2.description,
|
|
ontology_id=onto1.ontology_id or onto2.ontology_id,
|
|
version=onto1.version or onto2.version or "1.0.0",
|
|
parent_hashes=[onto1.hash, onto2.hash],
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
# Compute hash for merged ontology
|
|
# Note: Hash excludes metadata (version, title, description, created_at, hash, parent_hash)
|
|
# so it only reflects the actual ontology content (classes, properties, etc.)
|
|
merged_ontology._compute_and_set_hash()
|
|
|
|
logger.info(
|
|
f"Merged ontologies {onto1.hash[:8]}... "
|
|
f"and {onto2.hash[:8]}... "
|
|
f"-> {merged_ontology.hash[:8] if merged_ontology.hash else 'None'}..."
|
|
)
|
|
|
|
return merged_ontology
|
|
|
|
|
|
def _find_contradictions(graph1: RDFGraph, graph2: RDFGraph) -> dict:
|
|
"""Find contradictions between two graphs.
|
|
|
|
Contradictions are triples with the same subject-predicate but different objects.
|
|
Note: RDF allows multiple values for the same property, so this detects potential
|
|
conflicts that might need resolution in an LLM-based merge.
|
|
|
|
Args:
|
|
graph1: First graph
|
|
graph2: Second graph
|
|
|
|
Returns:
|
|
dict: Dictionary mapping (subject, predicate) to (set of objects from graph1, object from graph2) tuples
|
|
"""
|
|
contradictions = {}
|
|
|
|
# Build index of graph1: (subject, predicate) -> set of objects
|
|
graph1_index: dict[tuple, set] = {}
|
|
for s, p, o in graph1:
|
|
key = (s, p)
|
|
if key not in graph1_index:
|
|
graph1_index[key] = set()
|
|
# Use string representation for comparison
|
|
graph1_index[key].add(str(o))
|
|
|
|
# Check graph2 against graph1
|
|
for s, p, o in graph2:
|
|
key = (s, p)
|
|
if key in graph1_index:
|
|
# Check if objects differ
|
|
graph1_objects = graph1_index[key]
|
|
obj2_str = str(o)
|
|
if obj2_str not in graph1_objects:
|
|
# Contradiction found - different object values
|
|
contradictions[key] = (graph1_objects, obj2_str)
|
|
|
|
return contradictions
|
|
|
|
|
|
def plot_ontology_graph(
|
|
ontology_manager: OntologyManager,
|
|
output_path: Path,
|
|
iri: str | None = None,
|
|
) -> None:
|
|
"""Plot the ontology version graph using pygraphviz.
|
|
|
|
Args:
|
|
ontology_manager: The ontology manager containing ontologies
|
|
output_path: Path to save the graph image
|
|
iri: Optional IRI to plot (if None, plots all ontologies)
|
|
"""
|
|
try:
|
|
pgv = importlib.import_module("pygraphviz")
|
|
except ImportError:
|
|
logger.error("pygraphviz not installed. Cannot plot graph.")
|
|
logger.info("Install with: pip install pygraphviz")
|
|
return
|
|
|
|
# Get ontologies to plot
|
|
if iri:
|
|
if iri not in ontology_manager.ontology_versions:
|
|
logger.warning(f"No ontologies found for IRI: {iri}")
|
|
return
|
|
ontologies = ontology_manager.ontology_versions[iri]
|
|
else:
|
|
ontologies = [
|
|
o
|
|
for versions in ontology_manager.ontology_versions.values()
|
|
for o in versions
|
|
]
|
|
|
|
if not ontologies:
|
|
logger.warning("No ontologies to plot")
|
|
return
|
|
|
|
# Create graph
|
|
viz = pgv.AGraph(directed=True, nodesep=0.7, ranksep=0.5)
|
|
|
|
# Add nodes
|
|
for onto in ontologies:
|
|
if not onto.hash:
|
|
continue
|
|
node_id = onto.hash[:12] # Use first 12 chars of hash as node ID
|
|
label = f"{onto.ontology_id or 'ont'}\n{onto.hash[:8]}..."
|
|
if onto.created_at:
|
|
label += f"\n{onto.created_at.strftime('%Y-%m-%d')}"
|
|
viz.add_node(
|
|
node_id,
|
|
label=label,
|
|
style="filled",
|
|
fillcolor="#a9cca9",
|
|
fontsize=10,
|
|
)
|
|
|
|
# Add edges (parent relationships)
|
|
for onto in ontologies:
|
|
if not onto.hash:
|
|
continue
|
|
node_id = onto.hash[:12]
|
|
for parent_hash in onto.parent_hashes:
|
|
# Find parent node
|
|
parent_onto = None
|
|
for o in ontologies:
|
|
if o.hash == parent_hash:
|
|
parent_onto = o
|
|
break
|
|
if parent_onto:
|
|
parent_id = parent_hash[:12]
|
|
viz.add_edge(parent_id, node_id, style="solid")
|
|
|
|
# Highlight terminal ontologies
|
|
terminals = (
|
|
ontology_manager.get_terminal_ontologies_by_iri(iri)
|
|
if iri
|
|
else ontology_manager.get_terminal_ontologies_by_iri(None)
|
|
)
|
|
for terminal in terminals:
|
|
if terminal.hash:
|
|
node_id = terminal.hash[:12]
|
|
node = viz.get_node(node_id)
|
|
if node:
|
|
node.attr["fillcolor"] = "#ffdb99" # Orange for terminals
|
|
|
|
# Save graph
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
viz.draw(str(output_path), format="png", prog="dot", args="-Gdpi=300")
|
|
logger.info(f"Saved ontology graph to {output_path}")
|
|
|
|
|
|
async def merge_terminal_ontologies(
|
|
fuseki_manager: FusekiTripleStoreManager,
|
|
ontology_manager: OntologyManager,
|
|
iri: str,
|
|
) -> Ontology | None:
|
|
"""Merge terminal ontologies for a given IRI.
|
|
|
|
Fetches all terminal ontologies from Fuseki and merges them pair-wise
|
|
until only one remains.
|
|
|
|
Args:
|
|
fuseki_manager: Fuseki triple store manager
|
|
ontology_manager: Ontology manager to add merged ontologies to
|
|
iri: IRI of the ontology to merge
|
|
|
|
Returns:
|
|
Ontology: The final merged ontology, or None if no ontologies found
|
|
"""
|
|
# Fetch all ontologies from Fuseki
|
|
logger.info(f"Fetching ontologies for IRI: {iri}")
|
|
all_ontologies = await fuseki_manager.afetch_ontologies()
|
|
|
|
# Filter by IRI and add to ontology manager
|
|
matching_ontologies = [o for o in all_ontologies if o.iri == iri]
|
|
if not matching_ontologies:
|
|
logger.warning(f"No ontologies found for IRI: {iri}")
|
|
return None
|
|
|
|
logger.info(f"Found {len(matching_ontologies)} ontologies for IRI: {iri}")
|
|
|
|
# Add all to ontology manager
|
|
for onto in matching_ontologies:
|
|
ontology_manager.add_ontology(onto)
|
|
|
|
# Get terminal ontologies
|
|
terminals = ontology_manager.get_terminal_ontologies_by_iri(iri)
|
|
logger.info(f"Found {len(terminals)} terminal ontologies")
|
|
|
|
# Merge pair-wise until only one remains
|
|
while len(terminals) > 1:
|
|
# Sort by created_at (oldest first)
|
|
terminals_with_time = [t for t in terminals if t.created_at is not None]
|
|
terminals_without_time = [t for t in terminals if t.created_at is None]
|
|
|
|
# Sort terminals with time by created_at
|
|
terminals_with_time.sort(key=lambda x: x.created_at)
|
|
|
|
# Combine: terminals with time (sorted) + terminals without time
|
|
sorted_terminals = terminals_with_time + terminals_without_time
|
|
|
|
if len(sorted_terminals) < 2:
|
|
break
|
|
|
|
# Take the two oldest
|
|
onto1 = sorted_terminals[0]
|
|
onto2 = sorted_terminals[1]
|
|
|
|
logger.info(
|
|
f"Merging ontologies: {onto1.hash[:8] if onto1.hash else 'None'}... "
|
|
f"and {onto2.hash[:8] if onto2.hash else 'None'}..."
|
|
)
|
|
|
|
# Merge
|
|
merged = merge_ontologies(onto1, onto2)
|
|
|
|
# Add merged ontology to manager
|
|
ontology_manager.add_ontology(merged)
|
|
|
|
# Update terminals list
|
|
terminals = ontology_manager.get_terminal_ontologies_by_iri(iri)
|
|
logger.info(f"After merge: {len(terminals)} terminal ontologies remaining")
|
|
|
|
if terminals:
|
|
logger.info(
|
|
f"Final terminal ontology: {terminals[0].hash[:8] if terminals[0].hash else 'None'}..."
|
|
)
|
|
return terminals[0]
|
|
else:
|
|
logger.warning("No terminal ontologies remaining after merge")
|
|
return None
|