"""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/" 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: . 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: . 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)