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

429 lines
17 KiB
Python

import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.config import Config, WebSearchProvider
from ontocast.onto.constants import ONTOLOGY_NULL_IRI
from ontocast.onto.ontology import Ontology, OntologyProperties
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.state import AgentState
from ontocast.tool import (
AtomicToolBox,
ChunkerTool,
ConverterTool,
FilesystemTripleStoreManager,
FusekiTripleStoreManager,
Neo4jTripleStoreManager,
)
from ontocast.tool.aggregate import EmbeddingBasedAggregator
from ontocast.tool.cache import Cacher
from ontocast.tool.graph_diff import DiffTool
from ontocast.tool.graph_version_manager import GraphVersionManager
from ontocast.tool.llm import LLMTool
from ontocast.tool.ontology_manager import OntologyManager
from ontocast.tool.sparql import SPARQLTool
from ontocast.tool.triple_manager.core import TripleStoreManager
from ontocast.tool.web_search import DuckDuckGoSearchProvider
logger = logging.getLogger(__name__)
async def update_ontology_properties(o: Ontology, llm_tool: LLMTool):
"""Update ontology properties using LLM analysis, only if missing.
This function uses the LLM tool to analyze and update the properties
of a given ontology based on its graph content, but only if any key
property is missing or empty.
"""
# Only update if any key property is missing or empty
if (o.title is None) or (o.ontology_id is None) or (o.description is None):
props = await render_ontology_summary(o, llm_tool)
o.set_properties(**props.model_dump())
async def update_ontology_manager(om: OntologyManager, llm_tool: LLMTool):
"""Update properties for all ontologies in the manager.
This function iterates through all ontologies in the manager and updates
their properties using the LLM tool.
Args:
om: The ontology manager containing ontologies to update.
llm_tool: The LLM tool instance for analysis.
"""
for o in om.ontologies:
await update_ontology_properties(o, llm_tool)
class ToolBox:
"""A container class for all tools used in the ontology processing workflow.
This class initializes and manages various tools needed for document processing,
ontology management, and LLM interactions.
Args:
config: Configuration object containing all necessary settings.
"""
def __init__(self, config: Config):
# Store the config for later use
self.config = config
# Get tool configuration
tool_config = config.get_tool_config()
# Extract configuration values
working_directory = tool_config.path_config.working_directory
ontology_directory = tool_config.path_config.ontology_directory
# Create shared cache instance with config
self.shared_cache = Cacher(config=config)
# LLM configuration - pass the entire LLM config to the tool
self.llm_provider = tool_config.llm_config.provider
self.llm: LLMTool = LLMTool.create(
config=tool_config.llm_config, cache=self.shared_cache
)
self.search_provider = None
if tool_config.web_search.enabled:
if tool_config.web_search.provider == WebSearchProvider.DUCKDUCKGO:
self.search_provider = DuckDuckGoSearchProvider(
timeout_seconds=tool_config.web_search.timeout_seconds,
region=tool_config.web_search.region,
safesearch=tool_config.web_search.safesearch,
)
else:
raise ValueError(
f"Unsupported web-search provider: {tool_config.web_search.provider}"
)
self.atomic_tools = AtomicToolBox(
llm_provider=self,
search_provider=self.search_provider,
web_search_config=tool_config.web_search,
)
# Initialize managers based on backend configuration
self.filesystem_manager: FilesystemTripleStoreManager | None = None
self.triple_store_manager: TripleStoreManager | None = None
# Automatically determine which backends to use based on available configuration
use_fuseki = tool_config.fuseki.uri and tool_config.fuseki.auth
use_neo4j = (
tool_config.neo4j.uri is not None and tool_config.neo4j.auth is not None
)
use_filesystem_triple_store = working_directory is not None
use_filesystem_manager = working_directory is not None
# Validate that we have at least one backend configured
if not any([use_fuseki, use_neo4j, use_filesystem_triple_store]):
raise ValueError(
"No backend configured. Please provide Fuseki/Neo4j credentials or working directory and ontology directory."
)
# Create main triple store manager (only one can be active)
# Note: Dataset/database is NOT cleaned on initialization
# Use the clean() method or /flush endpoint to explicitly clean the store
if use_fuseki and tool_config.fuseki.uri and tool_config.fuseki.auth:
self.triple_store_manager = FusekiTripleStoreManager(
uri=tool_config.fuseki.uri,
auth=tool_config.fuseki.auth,
dataset=tool_config.fuseki.dataset,
ontologies_dataset=tool_config.fuseki.ontologies_dataset,
)
elif use_neo4j and tool_config.neo4j.uri and tool_config.neo4j.auth:
self.triple_store_manager = Neo4jTripleStoreManager(
uri=tool_config.neo4j.uri, auth=tool_config.neo4j.auth
)
elif use_filesystem_triple_store:
if working_directory is None:
raise ValueError(
"Working directory directory must be provided for filesystem triple store"
)
self.triple_store_manager = FilesystemTripleStoreManager(
working_directory=working_directory,
ontology_path=ontology_directory,
)
# Create filesystem manager (can be combined with other backends)
if use_filesystem_manager:
self.filesystem_manager = FilesystemTripleStoreManager(
working_directory=working_directory,
ontology_path=ontology_directory,
)
self.ontology_manager: OntologyManager = OntologyManager()
self.converter: ConverterTool = ConverterTool(cache=self.shared_cache)
self.chunker: ChunkerTool = ChunkerTool(
chunk_config=tool_config.chunk_config, cache=self.shared_cache
)
self.aggregator: EmbeddingBasedAggregator = EmbeddingBasedAggregator(
embedding_model=tool_config.aggregation.embedding_model,
similarity_threshold=tool_config.aggregation.similarity_threshold,
)
# SPARQL, version management, and diff tools
self.sparql_tool: SPARQLTool = SPARQLTool(
triple_store_manager=self.triple_store_manager
)
self.version_manager: GraphVersionManager = GraphVersionManager()
self.diff_tool: DiffTool = DiffTool()
async def get_llm_tool(self, budget_tracker):
"""Get an LLM tool instance with a specific budget tracker.
Args:
budget_tracker: The budget tracker instance to use.
Returns:
LLMTool: LLM tool with the specified budget tracker.
"""
# Create a new LLM tool with the budget tracker
return await LLMTool.acreate(
config=self.config.tool_config.llm_config,
cache=self.shared_cache,
budget_tracker=budget_tracker,
)
async def update_dataset(self, dataset: str) -> None:
"""Update the dataset for the Fuseki triple store manager.
This method allows changing the dataset without recreating the entire
ToolBox, which is efficient for API requests that specify different datasets.
Args:
dataset: The new dataset name to use.
"""
if self.triple_store_manager is not None:
from ontocast.tool.triple_manager.fuseki import FusekiTripleStoreManager
if isinstance(self.triple_store_manager, FusekiTripleStoreManager):
await self.triple_store_manager.update_dataset(dataset)
else:
logger.warning(
"Cannot update dataset: triple store manager is not Fuseki"
)
def get_atomic_tools(self) -> AtomicToolBox:
"""Return the minimal toolbox used by atomic render/critic paths."""
return self.atomic_tools
def serialize(self, state: AgentState) -> None:
# Add current ontology to ontology manager for version tracking
if state.current_ontology and state.current_ontology.hash:
self.ontology_manager.add_ontology(state.current_ontology)
if self.filesystem_manager is not None:
self.filesystem_manager.serialize(state.current_ontology)
if state.render_facts:
self.filesystem_manager.serialize(
state.aggregated_facts,
graph_uri=state.graph_uri,
)
if (
self.triple_store_manager is not None
and self.triple_store_manager != self.filesystem_manager
):
# Store ontology in main dataset for reasoning
self.triple_store_manager.serialize(state.current_ontology)
if state.render_facts:
self.triple_store_manager.serialize(
state.aggregated_facts,
graph_uri=state.graph_uri,
)
async def initialize(self) -> None:
"""Initialize the toolbox with ontologies and their properties.
This method synchronizes ontologies between filesystem and triple store,
then fetches ontologies from the triple store and updates their properties
using the LLM tool.
"""
# Synchronize ontologies and add them to ontology manager
synchronized_ontologies = await self._synchronize_ontologies()
for ontology in synchronized_ontologies:
self.ontology_manager.add_ontology(ontology)
await update_ontology_manager(om=self.ontology_manager, llm_tool=self.llm)
async def _synchronize_ontologies(self) -> list[Ontology]:
"""Synchronize ontologies between filesystem and triple store.
This method checks both filesystem_manager and triple_store_manager for
ontologies and populates triple_store_manager with any ontologies from
filesystem_manager that are not present in triple_store_manager.
Returns:
list: The final set of ontologies after synchronization
"""
import asyncio
filesystem_ontologies = []
if self.filesystem_manager is not None:
# Run sync method in thread pool to avoid blocking
filesystem_ontologies += await asyncio.to_thread(
self.filesystem_manager.fetch_ontologies
)
logger.info(f"Found {len(filesystem_ontologies)} ontologies in filesystem")
triple_store_ontologies = []
if (
self.triple_store_manager is not None
and self.triple_store_manager != self.filesystem_manager
):
# Use async version if available, otherwise run sync version in thread pool
afetch_method = getattr(
self.triple_store_manager, "afetch_ontologies", None
)
if afetch_method is not None:
triple_store_ontologies += await afetch_method()
else:
triple_store_ontologies += await asyncio.to_thread(
self.triple_store_manager.fetch_ontologies
)
logger.info(
f"Found {len(triple_store_ontologies)} ontologies in triple store"
)
# Get IRIs from both sources
triple_store_iris = {o.iri for o in triple_store_ontologies}
# Find ontologies in filesystem that need to be synced to triple store
for fs_onto in filesystem_ontologies:
if fs_onto.iri not in triple_store_iris:
logger.info(
f"Syncing ontology from filesystem to triple store: {fs_onto.iri} "
f"(version: {fs_onto.version})"
)
# Store the filesystem ontology to triple store with its version
if self.triple_store_manager is not None:
# Use async version if available, otherwise run sync version in thread pool
aserialize_method = getattr(
self.triple_store_manager, "aserialize", None
)
if aserialize_method is not None:
await aserialize_method(fs_onto)
else:
await asyncio.to_thread(
self.triple_store_manager.serialize, fs_onto
)
# Add to triple_store_ontologies list
triple_store_ontologies.append(fs_onto)
return triple_store_ontologies
async def render_ontology_summary(ontology: Ontology, llm_tool) -> OntologyProperties:
"""Generate a summary of ontology properties using LLM analysis.
This function uses the LLM tool to analyze an RDF graph and generate
a structured summary of its properties. Only unset fields are requested.
Args:
ontology: The ontology to analyze (for checking which fields are set).
llm_tool: The LLM tool instance for analysis.
Returns:
OntologyProperties: A structured summary containing only the missing properties.
"""
from pydantic import create_model
# Sample the graph intelligently (first 100 sections)
# This provides context without overwhelming the LLM
sampled_graph = sample_ontology_graph(ontology.graph, max_triples=100)
# Serialize with consistent ordering to ensure determinism
ontology_str = sampled_graph.serialize()
# Determine which fields are unset and need LLM inference
unset_fields = {}
fields_to_fetch = []
# Fields we want to potentially fetch from LLM (excluding internal fields like created_at)
fields_to_check = ["title", "description", "ontology_id", "version", "iri"]
# For Ontology objects, only fetch fields that are unset
for field in fields_to_check:
value = getattr(ontology, field, None)
if value is None or (field == "iri" and value == ONTOLOGY_NULL_IRI):
fields_to_fetch.append(field)
# Get the field definition from the base model
base_field = OntologyProperties.model_fields[field]
unset_fields[field] = (base_field.annotation, base_field)
if not unset_fields:
# All fields are already set, return empty props
return OntologyProperties()
# Create a dynamic model with only unset fields
DynamicProps = create_model("DynamicOntologyProps", **unset_fields)
# Define the output parser
parser = PydanticOutputParser(pydantic_object=DynamicProps)
# Create the prompt template with format instructions
field_list_str = "\n- ".join(fields_to_fetch)
format_instructions = parser.get_format_instructions()
# Build the template - use format_instructions as a separate variable to avoid brace conflicts
template = (
"Below is a sample of an ontology in Turtle format:\n\n"
"```ttl\n{ontology_str}\n```\n\n"
"Extract ONLY the following properties that are missing:\n"
f"- {field_list_str}\n\n"
"{format_instructions}"
)
prompt = PromptTemplate(
template=template,
input_variables=["ontology_str"],
partial_variables={"format_instructions": format_instructions},
)
response = await llm_tool(prompt.format_prompt(ontology_str=ontology_str))
dynamic_props = parser.parse(response.content)
# Convert dynamic props to OntologyProperties
result = OntologyProperties()
for field in unset_fields.keys():
value = getattr(dynamic_props, field, None)
if value is not None:
setattr(result, field, value)
return result
def sample_ontology_graph(graph: RDFGraph, max_triples: int = 100) -> RDFGraph:
"""Sample an ontology graph to provide a representative subset.
This function serializes the graph to Turtle format and takes the first
N blank-line separated sections. This is deterministic and simpler than
complex triple selection logic.
Args:
graph: The full ontology graph
max_triples: Maximum number of sections to include in the sample
Returns:
RDFGraph: A sampled version of the ontology with representative triples
"""
# Serialize to turtle
turtle_str = graph.serialize(format="turtle")
# Split on blank lines (typical turtle format uses \n\n to separate blocks)
sections = turtle_str.split("\n\n")
# Take first max_triples sections (or fewer if graph is smaller)
num_sections = min(len(sections), max_triples)
sampled_turtle = "\n\n".join(sections[:num_sections])
# Parse back into a graph
sampled = RDFGraph()
sampled.parse(data=sampled_turtle, format="turtle")
# Copy namespace bindings from original graph
for prefix, namespace in graph.namespaces():
if prefix:
sampled.bind(prefix, namespace)
return sampled