Files
AI/참고/ontocast-main/ontocast/tool/validate.py
2026-05-12 19:40:31 +09:00

424 lines
15 KiB
Python

"""Validation tools for OntoCast.
This module provides functionality for validating RDF graphs and chunks,
including connectivity validation and graph structure verification.
"""
import logging
from collections import defaultdict, deque
from typing import cast
from pydantic import BaseModel, ConfigDict, Field
from rdflib import RDF, RDFS, Graph, Literal, URIRef
from ontocast.onto.constants import PROV, SCHEMA
from ontocast.onto.content_unit import ContentUnit
from ontocast.onto.rdfgraph import RDFGraph
logger = logging.getLogger(__name__)
class PredicateStats(BaseModel):
"""Type definition for predicate statistics."""
model_config = ConfigDict(arbitrary_types_allowed=True)
total: int = 0
with_labels: int = 0
with_domains: int = 0
with_ranges: int = 0
class PredicateValidationResult(BaseModel):
"""Type definition for predicate validation results."""
model_config = ConfigDict(arbitrary_types_allowed=True)
has_required_properties: bool = True
domain_range_consistent: bool = True
missing_labels: list[str] = Field(default_factory=list)
domain_range_violations: list[str] = Field(default_factory=list)
predicate_stats: PredicateStats = Field(default_factory=PredicateStats)
class ConnectivityResult(BaseModel):
"""Type definition for connectivity validation results."""
model_config = ConfigDict(arbitrary_types_allowed=True)
is_fully_connected: bool = False
num_components: int = 0
total_entities: int = 0
components: list[set[URIRef]] = Field(default_factory=list)
isolated_entities: list[URIRef] = Field(default_factory=list)
largest_component_size: int = 0
has_required_properties: bool = True
domain_range_consistent: bool = True
missing_labels: list[str] = Field(default_factory=list)
domain_range_violations: list[str] = Field(default_factory=list)
predicate_stats: PredicateStats = Field(default_factory=PredicateStats)
def validate_and_connect_content_unit(
unit: ContentUnit,
auto_connect: bool = True,
) -> ContentUnit:
"""Validate and optionally connect a content unit graph.
This function validates the connectivity of a content unit RDF graph and
optionally connects any disconnected components.
Args:
unit: The content unit containing the RDF graph to validate.
auto_connect: Whether to automatically connect disconnected graphs.
Returns:
ContentUnit: The content unit with a validated and optionally connected graph.
"""
# Ensure an RDFGraph instance
if not isinstance(unit.graph, RDFGraph):
logger.warning("received an redflib.Graph rather than RDFGraph")
new_graph = RDFGraph()
# Cast to Graph to satisfy type checker
graph = cast(Graph, unit.graph)
for triple in graph:
new_graph.add(triple)
for prefix, namespace in graph.namespaces():
new_graph.bind(prefix, namespace)
unit.graph = new_graph
validator = RDFGraphConnectivityValidator(unit.graph)
result = validator.validate_connectivity()
logger.debug(f"\n=== Connectivity Analysis for Content Unit {unit.iri} ===")
logger.debug(f"Fully connected: {result.is_fully_connected}")
logger.debug(f"Number of components: {result.num_components}")
logger.debug(f"Total entities: {result.total_entities}")
logger.debug(f"Largest component size: {result.largest_component_size}")
if result.isolated_entities:
logger.debug(f"Isolated entities: {[str(e) for e in result.isolated_entities]}")
# Create a new RDFGraph instance instead of using deepcopy
final_graph = RDFGraph()
for triple in unit.graph:
final_graph.add(triple)
# Copy namespace bindings
for prefix, namespace in unit.graph.namespaces():
final_graph.bind(prefix, namespace)
if not result.is_fully_connected and auto_connect:
final_graph = validator.make_graph_connected(unit.iri)
unit.graph = final_graph
return unit
def validate_and_connect_chunk(
chunk: ContentUnit,
auto_connect: bool = True,
) -> ContentUnit:
"""Backward-compatible alias for validate_and_connect_content_unit()."""
return validate_and_connect_content_unit(unit=chunk, auto_connect=auto_connect)
class RDFGraphConnectivityValidator:
"""Validator for RDF graph connectivity.
This class provides functionality for validating and ensuring connectivity
in RDF graphs, including finding connected components and adding bridging
relationships.
Attributes:
graph: The RDF graph to validate.
"""
def __init__(self, graph: RDFGraph):
"""Initialize the validator.
Args:
graph: The RDF graph to validate.
"""
self.graph = graph
def get_all_entities(self) -> set[URIRef]:
"""Extract all unique entities from the graph.
Returns:
set[URIRef]: Set of all unique entity URIs in the graph.
"""
entities = set()
for subj, _, obj in self.graph:
if isinstance(subj, URIRef):
entities.add(subj)
if isinstance(obj, URIRef):
entities.add(obj)
return entities
def build_adjacency_graph(self) -> dict[URIRef, set[URIRef]]:
"""Build an adjacency representation of the RDF graph.
Returns:
dict[URIRef, set[URIRef]]: Dictionary mapping entities to their neighbors.
"""
adjacency = defaultdict(set)
for subj, _, obj in self.graph:
if isinstance(subj, URIRef) and isinstance(obj, URIRef):
adjacency[subj].add(obj)
adjacency[obj].add(subj) # Treat as undirected for connectivity
return adjacency
def find_connected_components(self) -> list[set[URIRef]]:
"""Find all connected components in the graph using BFS.
Returns:
list[set[URIRef]]: List of sets, each containing entities in a component.
"""
entities = self.get_all_entities()
adjacency = self.build_adjacency_graph()
visited = set()
components = []
for entity in entities:
if entity not in visited:
component = set()
queue = deque([entity])
while queue:
current = queue.popleft()
if current not in visited:
visited.add(current)
component.add(current)
# Add neighbors to queue
for neighbor in adjacency.get(current, set()):
if neighbor not in visited:
queue.append(neighbor)
if component:
components.append(component)
return components
def validate_predicates(self) -> PredicateValidationResult:
"""Validate predicate consistency and required properties.
Returns:
PredicateValidationResult: Pydantic model containing validation results and statistics.
"""
result = PredicateValidationResult()
# Track all predicates
predicates = set()
for _, pred, _ in self.graph:
if isinstance(pred, URIRef):
predicates.add(pred)
result.predicate_stats.total = len(predicates)
# Check each predicate
for pred in predicates:
has_label = False
has_domain = False
has_range = False
domain = None
range_ = None
# Get predicate properties
for s, p, o in self.graph:
if s == pred:
if p == RDFS.label:
has_label = True
result.predicate_stats.with_labels += 1
elif p == RDFS.domain:
has_domain = True
domain = o
result.predicate_stats.with_domains += 1
elif p == RDFS.range:
has_range = True
range_ = o
result.predicate_stats.with_ranges += 1
# Check required properties
if not has_label:
result.has_required_properties = False
result.missing_labels.append(str(pred))
# Check domain/range consistency in usage
if has_domain or has_range:
for s, p, o in self.graph:
if p == pred:
if has_domain and isinstance(s, URIRef):
# Check if subject is of correct domain type
subject_type = None
for s2, p2, o2 in self.graph:
if s2 == s and p2 == RDF.type:
subject_type = o2
break
if subject_type and domain and subject_type != domain:
result.domain_range_consistent = False
result.domain_range_violations.append(
f"Subject {s} of type {subject_type} "
f"used with predicate {pred} "
f"that requires domain {domain}"
)
if has_range and isinstance(o, URIRef):
# Check if object is of correct range type
object_type = None
for s2, p2, o2 in self.graph:
if s2 == o and p2 == RDF.type:
object_type = o2
break
if object_type and range_ and object_type != range_:
result.domain_range_consistent = False
result.domain_range_violations.append(
f"Object {o} of type {object_type} "
f"used with predicate {pred} "
f"that requires range {range_}"
)
return result
def validate_connectivity(self) -> ConnectivityResult:
"""Validate graph connectivity and return detailed results.
Returns:
ConnectivityResult: Pydantic model containing connectivity information and
validation results.
"""
components = self.find_connected_components()
entities = self.get_all_entities()
result = ConnectivityResult(
is_fully_connected=len(components) <= 1,
num_components=len(components),
total_entities=len(entities),
components=components,
)
if components:
result.largest_component_size = max(len(comp) for comp in components)
# Find isolated entities (components of size 1)
result.isolated_entities = [
list(comp)[0] for comp in components if len(comp) == 1
]
# Add predicate validation results
predicate_validation = self.validate_predicates()
result.has_required_properties = predicate_validation.has_required_properties
result.domain_range_consistent = predicate_validation.domain_range_consistent
result.missing_labels = predicate_validation.missing_labels
result.domain_range_violations = predicate_validation.domain_range_violations
result.predicate_stats = predicate_validation.predicate_stats
return result
def make_graph_connected(self, chunk_iri) -> RDFGraph:
"""Make a disconnected graph connected by adding bridging relationships.
Args:
chunk_iri: The IRI of the chunk to use for the hub entity.
Returns:
RDFGraph: A new connected graph.
"""
components = self.find_connected_components()
if len(components) <= 1:
logger.info("RDFGraph is already connected")
return self.graph
# Create a new graph with all original triples
connected_graph = RDFGraph()
for triple in self.graph:
connected_graph.add(triple)
# Copy namespace bindings
for prefix, namespace in self.graph.namespaces():
connected_graph.bind(prefix, namespace)
connected_graph = self._connect_via_chunk_hub(
connected_graph, components, chunk_iri
)
logger.info(f"Connected {len(components)} components")
return connected_graph
def _connect_via_chunk_hub(
self, graph: RDFGraph, components: list[set[URIRef]], chunk_iri
) -> RDFGraph:
"""Connect components by creating a chunk hub entity.
Args:
graph: The graph to modify.
components: List of connected components to connect.
chunk_iri: The IRI to use for the hub entity.
Returns:
RDFGraph: The modified graph with connected components.
"""
# Create or use existing chunk URI
hub_uri = URIRef(chunk_iri)
hub_id = hub_uri.split("/")[-1]
# Add hub entity metadata
graph.add((hub_uri, RDF.type, SCHEMA.TextDigitalDocument))
graph.add((hub_uri, RDFS.label, Literal(f"Chunk {hub_id}")))
# Connect hub to one representative entity from each component
for i, component in enumerate(components):
# Choose representative entity (could be improved with better heuristics)
representative = self._choose_representative_entity(component, graph)
if representative is not None:
# Add bidirectional connections
graph.add((hub_uri, SCHEMA.hasPart, representative))
graph.add((representative, PROV.wasQuotedFrom, hub_uri))
return graph
def _choose_representative_entity(
self, component: set[URIRef], graph: RDFGraph
) -> URIRef | None:
"""Choose the best representative entity from a component.
Args:
component: Set of entities in the component.
graph: The RDF graph containing the entities.
Returns:
URIRef | None: The chosen representative entity, or None if empty.
"""
if not component:
return None
entity_degrees = {}
entities_with_labels = set()
for entity in component:
# Count connections
degree = sum(1 for s, p, o in graph if s == entity or o == entity)
entity_degrees[entity] = degree
# Check if entity has a label
for s, p, o in graph:
if s == entity and p in [RDFS.label, RDFS.comment]:
entities_with_labels.add(entity)
break
# Prefer entities with labels and high degree
if entities_with_labels:
return max(entities_with_labels, key=lambda e: entity_degrees.get(e, 0))
else:
return max(component, key=lambda e: entity_degrees.get(e, 0))