550 lines
16 KiB
Python
550 lines
16 KiB
Python
"""Pytest configuration for test suite."""
|
|
|
|
import importlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
import pytest
|
|
from suthing import FileHandle
|
|
|
|
if TYPE_CHECKING:
|
|
from langchain_huggingface import HuggingFaceEmbeddings
|
|
|
|
from ontocast.config import (
|
|
Config,
|
|
LLMConfig,
|
|
LLMProvider,
|
|
OpenAIModel,
|
|
PathConfig,
|
|
ToolConfig,
|
|
)
|
|
from ontocast.onto.constants import DEFAULT_DOMAIN
|
|
from ontocast.onto.rdfgraph import RDFGraph
|
|
from ontocast.onto.state import AgentState
|
|
from ontocast.tool import (
|
|
FilesystemTripleStoreManager,
|
|
LLMTool,
|
|
OntologyManager,
|
|
)
|
|
from ontocast.tool.triple_manager.mock import (
|
|
MockFusekiTripleStoreManager,
|
|
MockNeo4jTripleStoreManager,
|
|
)
|
|
from ontocast.toolbox import ToolBox
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Suppress deprecation warnings from third-party libraries that we cannot control
|
|
# Note: We adapt to new conventions where possible (e.g., using pyld directly for JSON-LD
|
|
# instead of rdflib's deprecated ConjunctiveGraph). These suppressions are only for
|
|
# warnings from external libraries that we cannot modify.
|
|
|
|
warnings.filterwarnings(
|
|
"ignore",
|
|
category=DeprecationWarning,
|
|
message=".*@model_validator.*mode='after'.*",
|
|
module="docling_core",
|
|
)
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Configure pytest to suppress known deprecation warnings from third-party libraries."""
|
|
# Suppress Pydantic deprecation warnings from docling_core (third-party library we cannot modify)
|
|
config.addinivalue_line(
|
|
"filterwarnings",
|
|
"ignore::DeprecationWarning:docling_core",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def current_domain():
|
|
return os.getenv("CURRENT_DOMAIN", DEFAULT_DOMAIN)
|
|
|
|
|
|
@pytest.fixture
|
|
def llm_base_url():
|
|
return os.getenv("LLM_BASE_URL", None)
|
|
|
|
|
|
@pytest.fixture
|
|
def provider():
|
|
return os.getenv("LLM_PROVIDER", LLMProvider.OPENAI)
|
|
|
|
|
|
@pytest.fixture
|
|
def model_name():
|
|
return OpenAIModel(os.getenv("LLM_MODEL_NAME", OpenAIModel.GPT4_O_MINI))
|
|
|
|
|
|
@pytest.fixture
|
|
def temperature():
|
|
return 0.1
|
|
|
|
|
|
@pytest.fixture
|
|
def test_ontology():
|
|
from ontocast.onto.ontology import Ontology
|
|
|
|
graph = RDFGraph._from_turtle_str(
|
|
"""
|
|
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
|
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
|
@prefix ex: <http://example.org/to/> .
|
|
@prefix schema: <https://schema.org/> .
|
|
@prefix dcterms: <http://purl.org/dc/terms/> .
|
|
|
|
ex: rdf:type owl:Ontology ;
|
|
rdfs:label "Test Domain Ontology" ;
|
|
dcterms:title "test_onto"^^rdf:XMLLiteral ;
|
|
rdfs:comment "An ontology for testing that covers basic concepts and relationships in a test domain. Used for validating ontology processing functionality." .
|
|
|
|
ex:SpaceTimeEvent a rdfs:Class ;
|
|
rdfs:label "Event" ;
|
|
rdfs:comment "Some kind of event with spacetime coordinates" ;
|
|
rdfs:subClassOf schema:Event . """
|
|
)
|
|
return Ontology(graph=graph)
|
|
|
|
|
|
@pytest.fixture
|
|
def ontology_path():
|
|
return Path("data/ontologies")
|
|
|
|
|
|
@pytest.fixture
|
|
def working_directory():
|
|
return None
|
|
# return Path("test/tmp")
|
|
|
|
|
|
@pytest.fixture
|
|
def llm_tool(provider, model_name, temperature, llm_base_url):
|
|
config = LLMConfig(
|
|
provider=LLMProvider(provider),
|
|
model_name=model_name,
|
|
temperature=temperature,
|
|
base_url=llm_base_url,
|
|
)
|
|
llm_tool = LLMTool.create(config=config)
|
|
return llm_tool
|
|
|
|
|
|
@pytest.fixture
|
|
def tsm_tool(ontology_path, working_directory):
|
|
return FilesystemTripleStoreManager(
|
|
working_directory=working_directory, ontology_path=ontology_path
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def tools(
|
|
ontology_path,
|
|
working_directory,
|
|
model_name,
|
|
temperature,
|
|
provider,
|
|
llm_base_url,
|
|
om_tool_fname,
|
|
) -> ToolBox:
|
|
# Create LLM config
|
|
llm_config = LLMConfig(
|
|
provider=LLMProvider(provider),
|
|
model_name=model_name,
|
|
temperature=temperature,
|
|
base_url=llm_base_url,
|
|
)
|
|
|
|
# Create path config
|
|
path_config = PathConfig(
|
|
working_directory=working_directory,
|
|
ontology_directory=ontology_path,
|
|
)
|
|
|
|
# Create tool config
|
|
tool_config = ToolConfig(
|
|
llm_config=llm_config,
|
|
path_config=path_config,
|
|
)
|
|
|
|
# Create main config
|
|
config = Config(tool_config=tool_config)
|
|
|
|
tools: ToolBox = ToolBox(config=config)
|
|
import asyncio
|
|
|
|
asyncio.run(tools.initialize())
|
|
|
|
# Load ontologies from JSON file if it exists (using Pydantic's load method)
|
|
json_path = Path(om_tool_fname)
|
|
if json_path.exists():
|
|
try:
|
|
loaded_om = OntologyManager.load(json_path)
|
|
# Merge loaded ontologies into the toolbox's ontology manager
|
|
for iri, versions in loaded_om.ontology_versions.items():
|
|
for ontology in versions:
|
|
tools.ontology_manager.add_ontology(ontology)
|
|
except Exception:
|
|
# Silently fail if JSON loading fails
|
|
pass
|
|
|
|
return tools
|
|
|
|
|
|
@pytest.fixture
|
|
def state_chunked(state_chunked_filename):
|
|
return AgentState.load(state_chunked_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_ontology_selected(state_onto_selected_filename):
|
|
return AgentState.load(state_onto_selected_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_ontology_rendered(state_ontology_rendered_filename):
|
|
return AgentState.load(state_ontology_rendered_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_ontology_criticized(state_ontology_criticized_filename):
|
|
return AgentState.load(state_ontology_criticized_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_rendered_facts(state_rendered_facts_filename):
|
|
return AgentState.load(state_rendered_facts_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_sublimated(state_sublimated_filename):
|
|
return AgentState.load(state_sublimated_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_facts_failed(state_facts_failed_filename):
|
|
return AgentState.load(state_facts_failed_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_facts_success(state_facts_success_filename):
|
|
return AgentState.load(state_facts_success_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_state_select_ontology_null(state_onto_null_filename):
|
|
return AgentState.load(state_onto_null_filename)
|
|
|
|
|
|
@pytest.fixture
|
|
def om_tool(om_tool_fname):
|
|
try:
|
|
return OntologyManager.load(om_tool_fname)
|
|
except (FileNotFoundError, Exception):
|
|
return OntologyManager()
|
|
|
|
|
|
@pytest.fixture
|
|
def max_iter():
|
|
return 2
|
|
|
|
|
|
@pytest.fixture
|
|
def apple_report():
|
|
r = FileHandle.load(Path("data/json/fin.10Q.apple.json"))
|
|
return {"text": r["text"]}
|
|
|
|
|
|
@pytest.fixture
|
|
def random_report():
|
|
return FileHandle.load(Path("data/json/random.json"))
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_state_onto_fresh():
|
|
return AgentState.load("test/data/state_onto_addendum.json")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def neo4j_uri():
|
|
return os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def neo4j_auth():
|
|
return os.environ.get("NEO4J_AUTH", "neo4j/test")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def neo4j_triple_store_manager(neo4j_uri, neo4j_auth):
|
|
"""Mock Neo4j triple store manager for testing."""
|
|
return MockNeo4jTripleStoreManager(uri=neo4j_uri, auth=neo4j_auth, clean=True)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def fuseki_triple_store_manager():
|
|
"""Mock Fuseki triple store manager for testing."""
|
|
uri = os.environ.get("FUSEKI_URI", "http://localhost:3030/test")
|
|
auth = os.environ.get("FUSEKI_AUTH", None)
|
|
if auth and "/" in auth:
|
|
auth = tuple(auth.split("/", 1))
|
|
return MockFusekiTripleStoreManager(uri=uri, auth=auth, dataset="test", clean=True)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def real_embeddings() -> Optional["HuggingFaceEmbeddings"]:
|
|
"""Fixture providing real HuggingFace embeddings if available, otherwise None.
|
|
|
|
Uses the same model as in split_chunks.py for consistency.
|
|
Session-scoped so the model is loaded only once per test session and reused.
|
|
"""
|
|
|
|
try:
|
|
torch = importlib.import_module("torch")
|
|
from langchain_huggingface import HuggingFaceEmbeddings
|
|
|
|
embeddings = HuggingFaceEmbeddings(
|
|
model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
|
model_kwargs={
|
|
"device": "cuda"
|
|
if torch is not None and torch.cuda.is_available()
|
|
else "cpu"
|
|
},
|
|
encode_kwargs={"normalize_embeddings": False},
|
|
)
|
|
return embeddings
|
|
except ImportError as e:
|
|
logger.error(f"Could not import HuggingFaceEmbeddings: {e}")
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def mock_embeddings():
|
|
try:
|
|
from langchain_core.embeddings import Embeddings
|
|
except ImportError as e:
|
|
logger.error(f"Could not import Embeddings: {e}")
|
|
|
|
class MockEmbeddings(Embeddings):
|
|
"""Mock embeddings for testing.
|
|
|
|
Returns deterministic embeddings based on text content.
|
|
"""
|
|
|
|
def __init__(self, embedding_dim: int = 384):
|
|
"""Initialize mock embeddings.
|
|
|
|
Args:
|
|
embedding_dim: Dimension of the embedding vectors. Defaults to 384.
|
|
"""
|
|
self.embedding_dim = embedding_dim
|
|
# Simple hash-based embedding for deterministic results
|
|
self._cache: dict[str, list[float]] = {}
|
|
|
|
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
"""Generate embeddings for a list of texts."""
|
|
return [self.embed_query(text) for text in texts]
|
|
|
|
def embed_query(self, text: str) -> list[float]:
|
|
"""Generate an embedding for a single text."""
|
|
if text in self._cache:
|
|
return self._cache[text]
|
|
|
|
from ontocast.util import render_text_hash
|
|
|
|
hash_int = int(render_text_hash(text, digits=None), 16)
|
|
|
|
embedding = []
|
|
for i in range(self.embedding_dim):
|
|
val = (hash_int + i * 17) % 1000
|
|
embedding.append((val / 1000.0) - 0.5)
|
|
|
|
self._cache[text] = embedding
|
|
return embedding
|
|
|
|
return MockEmbeddings()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def embeddings(real_embeddings, mock_embeddings):
|
|
"""Fixture providing embeddings - prefers real embeddings, falls back to mock.
|
|
|
|
Session-scoped so the model is loaded only once per test session.
|
|
"""
|
|
if real_embeddings is not None:
|
|
return real_embeddings
|
|
return mock_embeddings
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_text():
|
|
"""Fixture providing realistic sample text (~10k characters) from clinical trial JSON."""
|
|
json_file = (
|
|
Path(__file__).parent.parent
|
|
/ "data"
|
|
/ "json"
|
|
/ "clinical.trials.NCT01239745.json"
|
|
)
|
|
if json_file.exists():
|
|
data = json.load(open(json_file))
|
|
|
|
def json_to_md(data, depth=1):
|
|
md = []
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if isinstance(value, (str, int, float, bool, type(None))):
|
|
md.append(f"{key}: {value}\n")
|
|
elif isinstance(value, dict):
|
|
md.append(f"{key}:\n")
|
|
md.extend(json_to_md(value, depth + 1))
|
|
elif isinstance(value, list):
|
|
md.append(f"{key}:\n")
|
|
for item in value:
|
|
if isinstance(item, (str, int, float, bool, type(None))):
|
|
md.append(f" - {item}\n")
|
|
else:
|
|
md.extend(json_to_md(item, depth + 1))
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
if isinstance(item, (str, int, float, bool, type(None))):
|
|
md.append(f"- {item}\n")
|
|
else:
|
|
md.extend(json_to_md(item, depth))
|
|
return md
|
|
|
|
text_lines = json_to_md(data)
|
|
text = "".join(text_lines)
|
|
return text[:10000]
|
|
|
|
# Fallback
|
|
return (
|
|
"This is the first sentence. "
|
|
"This is the second sentence. "
|
|
"This is the third sentence. "
|
|
"This is the fourth sentence. "
|
|
"This is the fifth sentence. "
|
|
"This is the sixth sentence. "
|
|
"This is the seventh sentence. "
|
|
"This is the eighth sentence. "
|
|
"This is the ninth sentence. "
|
|
"This is the tenth sentence."
|
|
) * 100
|
|
|
|
|
|
@pytest.fixture
|
|
def long_text():
|
|
"""Fixture providing longer text for testing min/max size constraints."""
|
|
paragraphs = []
|
|
for i in range(5):
|
|
sentences = []
|
|
for j in range(10):
|
|
sentences.append(
|
|
f"This is paragraph {i + 1}, sentence {j + 1}. "
|
|
f"It contains some content to make it longer. "
|
|
f"Here is more text to ensure we have enough characters."
|
|
)
|
|
paragraphs.append(" ".join(sentences))
|
|
return "\n\n".join(paragraphs)
|
|
|
|
|
|
# --- Aggregator test fixtures (used by test_aggregator.py) ---
|
|
|
|
|
|
@pytest.fixture
|
|
def normalizer():
|
|
"""EntityNormalizer instance for aggregator tests."""
|
|
from ontocast.tool.agg.normalizer import EntityNormalizer
|
|
|
|
return EntityNormalizer()
|
|
|
|
|
|
@pytest.fixture
|
|
def cluster_representative_selector():
|
|
"""ClusterRepresentativeSelector instance for aggregator tests."""
|
|
from ontocast.tool.agg.clustering import ClusterRepresentativeSelector
|
|
|
|
return ClusterRepresentativeSelector()
|
|
|
|
|
|
@pytest.fixture
|
|
def uri_builder():
|
|
"""URIBuilder instance for aggregator tests."""
|
|
from ontocast.tool.agg.uri_builder import URIBuilder
|
|
|
|
return URIBuilder()
|
|
|
|
|
|
@pytest.fixture
|
|
def graph_rewriter():
|
|
"""GraphRewriter instance for aggregator tests (add_sameas_links=True)."""
|
|
from ontocast.tool.agg.rewriter import GraphRewriter
|
|
|
|
return GraphRewriter(add_sameas_links=False)
|
|
|
|
|
|
def triple_store_roundtrip(manager, test_ontology):
|
|
# test_ontology is already an Ontology object, use it directly
|
|
ontology = test_ontology
|
|
# Store ontology
|
|
manager.serialize(ontology)
|
|
# Fetch ontologies
|
|
ontologies = manager.fetch_ontologies()
|
|
# There should be at least one ontology with the correct ontology_id
|
|
assert any(o.ontology_id == "to" for o in ontologies)
|
|
# The ontology graph should have the same number of triples as the input
|
|
assert len(ontologies[0].graph) == len(ontology.graph)
|
|
|
|
|
|
def triple_store_serialize_facts(manager):
|
|
"""Test serializing facts (RDF triples) to triple store and retrieving them."""
|
|
# Create test facts
|
|
facts = RDFGraph._from_turtle_str(
|
|
"""
|
|
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
|
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
@prefix ex: <http://example.org/test/> .
|
|
@prefix schema: <https://schema.org/> .
|
|
|
|
ex:Person a rdfs:Class ;
|
|
rdfs:label "Person" ;
|
|
rdfs:comment "A human being" .
|
|
|
|
ex:John a ex:Person ;
|
|
rdfs:label "John Doe" ;
|
|
schema:name "John Doe" ;
|
|
schema:email "john@example.com" .
|
|
|
|
ex:Jane a ex:Person ;
|
|
rdfs:label "Jane Smith" ;
|
|
schema:name "Jane Smith" ;
|
|
schema:email "jane@example.com" .
|
|
|
|
ex:knows a rdf:Property ;
|
|
rdfs:label "knows" ;
|
|
rdfs:comment "Relationship between people who know each other" .
|
|
|
|
ex:John ex:knows ex:Jane .
|
|
"""
|
|
)
|
|
# Verify we have the expected number of triples
|
|
expected_triple_count = len(facts)
|
|
assert expected_triple_count == 15, "Test facts should contain triples"
|
|
# Serialize facts to triple store
|
|
result = manager.serialize(facts)
|
|
assert result is not None, "serialize should return a result"
|
|
|
|
|
|
def triple_store_serialize_empty_facts(manager):
|
|
"""Test serializing empty facts graph."""
|
|
# Create empty facts
|
|
empty_facts = RDFGraph()
|
|
# Serialize empty facts - should not raise an error
|
|
result = manager.serialize(empty_facts)
|
|
assert result is not None, "serialize should return a result even for empty graph"
|