"""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: . @prefix rdfs: . @prefix owl: . @prefix ex: . @prefix schema: . @prefix dcterms: . 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: . @prefix rdfs: . @prefix ex: . @prefix schema: . 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"