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

521 lines
20 KiB
Python

"""Pydantic models for SPARQL operations.
This module provides Pydantic models for structured SPARQL queries
that can be used with PydanticOutputParser for LLM integration.
"""
import logging
from typing import Annotated, Any
from typing import Literal as TypingLiteral
from pydantic import BaseModel, BeforeValidator, Field
from rdflib import BNode, Literal, Node, URIRef
from ontocast.onto.constants import COMMON_PREFIXES
from ontocast.onto.enum import SPARQLOperationType
from ontocast.onto.rdfgraph import RDFGraph
logger = logging.getLogger(__name__)
# Convert COMMON_PREFIXES from Turtle format (with angle brackets) to SPARQL format (without)
# Example: "<http://example.org/>" -> "http://example.org/"
STANDARD_PREFIXES = {prefix: uri.strip("<>") for prefix, uri in COMMON_PREFIXES.items()}
class SPARQLOperationModel(BaseModel):
"""Pydantic model for a single SPARQL operation.
Attributes:
operation_type: Type of SPARQL operation (INSERT, UPDATE, DELETE)
query: The SPARQL query string
description: Optional description of the operation
metadata: Optional metadata dictionary
"""
operation_type: SPARQLOperationType = Field(
description="Type of SPARQL operation: INSERT, UPDATE, or DELETE"
)
query: str = Field(
description="The complete SPARQL query string with proper syntax"
)
description: str = Field(
default="", description="Optional description of the operation"
)
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Optional metadata dictionary for the operation",
)
class StructuredSPARQLQueryModel(BaseModel):
"""Pydantic model for structured SPARQL queries.
Attributes:
operations: List of SPARQL operations (INSERT, UPDATE, DELETE)
namespaces: Dictionary mapping prefixes to URIs
"""
operations: list[SPARQLOperationModel] = Field(
default_factory=list, description="List of SPARQL operations to execute"
)
namespaces: dict[str, str] = Field(
default_factory=dict,
description="Dictionary mapping namespace prefixes to URIs",
)
def get_summary(self) -> str:
"""Get a summary of the structured query."""
add_count = len(
[
op
for op in self.operations
if op.operation_type == SPARQLOperationType.INSERT
]
)
update_count = len(
[
op
for op in self.operations
if op.operation_type == SPARQLOperationType.UPDATE
]
)
remove_count = len(
[
op
for op in self.operations
if op.operation_type == SPARQLOperationType.DELETE
]
)
return (
f"Structured SPARQL Query: "
f"{add_count} ADD operations, "
f"{update_count} UPDATE operations, "
f"{remove_count} REMOVE operations"
)
def get_all_operations(self) -> list[SPARQLOperationModel]:
"""Get all operations in execution order (INSERT, UPDATE, DELETE)."""
# Sort operations by type: INSERT first, then UPDATE, then DELETE
type_order = {
SPARQLOperationType.INSERT: 0,
SPARQLOperationType.UPDATE: 1,
SPARQLOperationType.DELETE: 2,
}
return sorted(self.operations, key=lambda op: type_order[op.operation_type])
def get_add_operations(self) -> list[SPARQLOperationModel]:
"""Get all INSERT operations."""
return [
op
for op in self.operations
if op.operation_type == SPARQLOperationType.INSERT
]
def get_update_operations(self) -> list[SPARQLOperationModel]:
"""Get all UPDATE operations."""
return [
op
for op in self.operations
if op.operation_type == SPARQLOperationType.UPDATE
]
def get_remove_operations(self) -> list[SPARQLOperationModel]:
"""Get all DELETE operations."""
return [
op
for op in self.operations
if op.operation_type == SPARQLOperationType.DELETE
]
class OntologyUpdateReport(BaseModel):
"""Report from ontology update process using structured SPARQL.
Attributes:
update_success: True if the ontology update was performed successfully
structured_query: The structured SPARQL query used for the update
add_count: Number of ADD operations
update_count: Number of UPDATE operations
remove_count: Number of REMOVE operations
critique: Optional critique of the update process
"""
update_success: bool = Field(
description="True if the ontology update was performed successfully, False otherwise"
)
structured_query: StructuredSPARQLQueryModel = Field(
description="The structured SPARQL query used for the update"
)
add_count: int = Field(
description="Number of ADD operations in the structured query"
)
update_count: int = Field(
description="Number of UPDATE operations in the structured query"
)
remove_count: int = Field(
description="Number of REMOVE operations in the structured query"
)
critique: str | None = Field(
None, description="Optional critique or explanation of the update process"
)
class FactsUpdateReport(BaseModel):
"""Report from facts update process using structured SPARQL.
Attributes:
update_success: True if the facts update was performed successfully
structured_query: The structured SPARQL query used for the update
add_count: Number of ADD operations
update_count: Number of UPDATE operations
remove_count: Number of REMOVE operations
critique: Optional critique of the update process
"""
update_success: bool = Field(
description="True if the facts update was performed successfully, False otherwise"
)
structured_query: StructuredSPARQLQueryModel = Field(
description="The structured SPARQL query used for the update"
)
add_count: int = Field(
description="Number of ADD operations in the structured query"
)
update_count: int = Field(
description="Number of UPDATE operations in the structured query"
)
remove_count: int = Field(
description="Number of REMOVE operations in the structured query"
)
critique: str | None = Field(
None, description="Optional critique or explanation of the update process"
)
class FreshOntologyReport(BaseModel):
"""Report from fresh ontology generation process.
Attributes:
generation_success: True if the ontology was generated successfully
ontology_graph: The generated ontology as an RDFGraph
ontology_score: Score 0-100 for ontology quality
critique: Optional critique of the ontology generation
"""
generation_success: bool = Field(
description="True if the ontology was generated successfully, False otherwise"
)
ontology_graph: RDFGraph = Field(
default_factory=RDFGraph,
description="The generated ontology as an RDFGraph in Turtle format",
)
ontology_score: float | None = Field(
None, description="Score 0-100 for ontology quality and completeness"
)
critique: str | None = Field(
None, description="Optional critique or explanation of the ontology generation"
)
class FreshFactsReport(BaseModel):
"""Report from fresh facts generation process.
Attributes:
generation_success: True if the facts were generated successfully
facts_graph: The generated facts as an RDFGraph
facts_score: Score 0-100 for facts quality
critique: Optional critique of the facts generation
"""
generation_success: bool = Field(
description="True if the facts were generated successfully, False otherwise"
)
facts_graph: RDFGraph = Field(
default_factory=RDFGraph,
description="The generated facts as an RDFGraph in Turtle format",
)
facts_score: float | None = Field(
None, description="Score 0-100 for facts quality and completeness"
)
critique: str | None = Field(
None, description="Optional critique or explanation of the facts generation"
)
class TripleOp(BaseModel):
"""Operation to modify triples in the RDF graph.
This operation can insert or delete triples. Prefixes are automatically extracted
from the RDFGraph's namespace bindings (from @prefix declarations in Turtle).
"""
type: TypingLiteral["insert", "delete"] = Field(
description="Type of operation: 'insert' to add triples, 'delete' to remove triples"
)
graph: Annotated[
RDFGraph,
BeforeValidator(
lambda v: RDFGraph._from_turtle_str(v) if isinstance(v, str) else v
),
] = Field(
default_factory=RDFGraph,
description="RDF graph containing triples to insert or delete. "
"Must be provided as a Turtle format string or RDFGraph instance. "
'Example Turtle: "@prefix ex: <http://example.org/> . ex:John a ex:Person ; rdfs:label "John Doe" ."',
)
prefixes: dict[str, str] = Field(
default_factory=dict,
description="Optional: Additional or override prefixes. "
"Prefixes are automatically extracted from the RDFGraph's namespace bindings. "
"Standard prefixes from COMMON_PREFIXES in constants.py (rdf, rdfs, owl, xsd, dc, dcterms, skos, foaf, schema, prov, ex) are automatically available. "
"This field can be used to add or override prefixes if needed. "
"Mapping format: {'prefix_name': 'namespace_uri'}. Example: {'fca': 'http://example.org/ontologies/fca#'}",
)
class GenericSparqlQuery(BaseModel):
"""Operation for custom SPARQL queries that go beyond basic insert/delete operations.
This operation allows for complex SPARQL queries that cannot be expressed
using the structured operations. Use this when you need custom SPARQL syntax,
complex WHERE clauses, or operations that don't fit the basic patterns.
"""
type: TypingLiteral["sparql_query"] = Field(
default="sparql_query",
description="Type of operation - always 'sparql_query' for this operation",
)
query: str = Field(
description="The complete SPARQL query string with proper syntax"
)
class GraphUpdate(BaseModel):
"""Structured representation of RDF graph updates for LLM output.
This model represents ontology updates as a structured set of operations.
Each operation in the list is executed in order to modify the graph.
"""
triple_operations: list[TripleOp] = Field(
default_factory=list,
description="List of graph update operations in execution order. "
"Each operation should be a TripleOp (for insert/delete) with RDFGraph containing triples in Turtle format."
"Example: [TripleOp(type='insert', graph='@prefix ex: <http://example.org/> . ex:John a ex:Person .', prefixes={'ex': 'http://example.org/'})]",
)
sparql_operations: list[GenericSparqlQuery] = Field(
default_factory=list,
description="List of graph update operations in execution order. "
"Each operation should be a GenericSparqlQuery for complex custom queries. ",
)
def generate_sparql_queries(self) -> list[str]:
"""Generate a list of SPARQL queries to execute the graph update.
Returns:
List of SPARQL query strings that can be executed to perform the update.
The queries are generated in the exact order of operations in the operations list.
"""
queries = []
# Process triple operations first
for op in self.triple_operations:
if len(op.graph) > 0: # Only generate query if there are triples
# Build prefix block for this operation
# Start with standard prefixes from COMMON_PREFIXES
prefixes = STANDARD_PREFIXES.copy()
# Extract prefixes from RDFGraph's namespace bindings
for prefix, uri in op.graph.namespaces():
if prefix: # Skip empty prefix
prefixes[prefix] = str(uri)
# Add custom prefixes declared in this operation (may override standard ones)
prefixes.update(op.prefixes)
# Generate PREFIX declarations block
if prefixes:
prefix_declarations = []
for prefix, uri in prefixes.items():
prefix_declarations.append(f"PREFIX {prefix}: <{uri}>")
prefix_block = "\n".join(prefix_declarations)
else:
prefix_block = ""
# Generate query based on operation type
if op.type == "insert":
triple_query = self._generate_insert_query(op.graph, prefix_block)
else: # delete
triple_query = self._generate_delete_query(op.graph, prefix_block)
queries.append(triple_query)
# Process SPARQL operations
for op in self.sparql_operations:
if op.query.strip(): # Only generate query if there's content
# For custom SPARQL queries, use them as-is
queries.append(op.query)
return queries
def count_total_triples(self) -> tuple[int, int]:
"""Count total triples across all operations.
Returns:
Tuple of (total_operations, total_triples) where:
- total_operations: Number of operations
- total_triples: Total number of triples across all TripleOp operations
"""
total_triples = 0
for op in self.triple_operations:
if isinstance(op, TripleOp):
total_triples += len(op.graph)
return (len(self.triple_operations), total_triples)
def extract_insert_graph(self) -> RDFGraph:
"""Extract RDFGraph of all insert triples from triple_operations.
Only TripleOps with type='insert' are included. sparql_operations
are not extractable as triples and are skipped.
Returns:
RDFGraph containing the union of all insert triples.
"""
result = RDFGraph()
for op in self.triple_operations:
if isinstance(op, TripleOp) and op.type == "insert" and len(op.graph) > 0:
for triple in op.graph:
result.add(triple)
for prefix, uri in op.graph.namespaces():
if prefix:
result.bind(prefix, uri)
for prefix, uri in op.prefixes.items():
result.bind(prefix, uri)
return result
def generate_diff_summary(self) -> str:
"""Generate a human-readable diff summary of all operations for LLM consumption.
Returns:
String representation of all operations showing what will be added, removed, and modified.
Returns empty string if no operations to perform.
"""
if not self.triple_operations:
return ""
diff_parts = []
operation_count = 0
for i, op in enumerate(self.triple_operations, 1):
if isinstance(op, TripleOp):
if len(op.graph) > 0:
op_type = op.type.upper()
diff_parts.append(f"{i}. {op_type} {len(op.graph)} triple(s):")
# Show prefixes from graph and explicit prefixes
graph_prefixes = {
prefix: str(uri)
for prefix, uri in op.graph.namespaces()
if prefix
}
all_prefixes = {**graph_prefixes, **op.prefixes}
if all_prefixes:
prefix_list = ", ".join(
[f"{k}: {v}" for k, v in all_prefixes.items()]
)
diff_parts.append(f" Prefixes: {prefix_list}")
for subject, predicate, obj in op.graph:
symbol = "+" if op.type == "insert" else "-"
diff_parts.append(
f" {symbol} {self._serialize_rdf_term(subject)} {self._serialize_rdf_term(predicate)} {self._serialize_rdf_term(obj)}"
)
operation_count += 1
elif isinstance(op, GenericSparqlQuery):
if op.query.strip():
# Truncate long queries for readability
query_preview = op.query.strip()
if len(query_preview) > 100:
query_preview = query_preview[:97] + "..."
diff_parts.append(f"{i}. CUSTOM SPARQL QUERY:")
diff_parts.append(f" {query_preview}")
operation_count += 1
if operation_count == 0:
return ""
summary = f"Ontology Update Summary ({operation_count} operation(s)):\n\n"
summary += "\n".join(diff_parts)
return summary
def _generate_insert_query(self, graph: RDFGraph, prefix_block: str) -> str:
"""Generate a SPARQL INSERT query for the given RDFGraph."""
if len(graph) == 0:
return ""
# Format triples for SPARQL using proper RDF term serialization
triple_patterns = []
for subject, predicate, obj in graph:
triple_patterns.append(
f" {self._serialize_rdf_term(subject)} {self._serialize_rdf_term(predicate)} {self._serialize_rdf_term(obj)} ."
)
triples_block = "\n".join(triple_patterns)
query_parts = []
if prefix_block:
query_parts.append(prefix_block)
query_parts.append("INSERT DATA {")
query_parts.append(triples_block)
query_parts.append("}")
return "\n".join(query_parts)
def _generate_delete_query(self, graph: RDFGraph, prefix_block: str) -> str:
"""Generate a SPARQL DELETE query for the given RDFGraph."""
if len(graph) == 0:
return ""
# Format triples for SPARQL using proper RDF term serialization
triple_patterns = []
for subject, predicate, obj in graph:
triple_patterns.append(
f" {self._serialize_rdf_term(subject)} {self._serialize_rdf_term(predicate)} {self._serialize_rdf_term(obj)} ."
)
triples_block = "\n".join(triple_patterns)
query_parts = []
if prefix_block:
query_parts.append(prefix_block)
query_parts.append("DELETE DATA {")
query_parts.append(triples_block)
query_parts.append("}")
return "\n".join(query_parts)
def _serialize_rdf_term(self, term: Node) -> str:
"""Serialize an RDF term to its SPARQL string representation."""
if isinstance(term, URIRef):
# Check if it's already a prefixed name (contains ':')
if ":" in str(term) and not str(term).startswith("http"):
return str(term)
else:
return f"<{term}>"
elif isinstance(term, BNode):
return f"_:{term}"
elif isinstance(term, Literal):
# Handle language-tagged literals first
if term.language:
return f'"{term}"@{term.language}'
elif term.datatype:
return f'"{term}"^^<{term.datatype}>'
else:
return f'"{term}"'
else:
# Fallback to string representation
return str(term)