Files
AI/참고/ontocast-main/ontocast/onto/ontology_operations.py

297 lines
10 KiB
Python
Raw Permalink Normal View History

2026-05-12 19:40:31 +09:00
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