# type: ignore # Copyright (c) "Neo4j" # Neo4j Sweden AB [https://neo4j.com] # # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # # https://www.apache.org/licenses/LICENSE-2.0 # # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Tests for retriever parameter inference and convert_to_tool functionality. """ import pytest from unittest.mock import MagicMock, patch from typing import Optional, Any, Dict import neo4j from neo4j_graphrag.retrievers.base import Retriever from neo4j_graphrag.retrievers import ( VectorRetriever, VectorCypherRetriever, HybridRetriever, Text2CypherRetriever, ) from neo4j_graphrag.retrievers.tools_retriever import ToolsRetriever from neo4j_graphrag.tool import Tool, ParameterType from neo4j_graphrag.types import RawSearchResult from neo4j_graphrag.embeddings.base import Embedder from neo4j_graphrag.llm.base import LLMInterface # Helper functions for creating mock objects def create_mock_driver() -> neo4j.Driver: driver = MagicMock(spec=neo4j.Driver) mock_result = MagicMock() mock_result.records = [] driver.execute_query.return_value = mock_result return driver def create_mock_embedder() -> Embedder: embedder = MagicMock(spec=Embedder) embedder.embed_query.return_value = [0.1, 0.2, 0.3] return embedder def create_mock_llm() -> LLMInterface: llm = MagicMock(spec=LLMInterface) llm.invoke.return_value = MagicMock(content="MATCH (n) RETURN n") return llm class MockRetriever(Retriever): """Test retriever with well-documented parameters.""" VERIFY_NEO4J_VERSION = False def get_search_results( self, query_text: str, top_k: int = 5, filters: Optional[Dict[str, Any]] = None, score_threshold: Optional[float] = None, ) -> RawSearchResult: """Test search method with documented parameters. Args: query_text (str): The text query to search for in the database top_k (int): The maximum number of results to return filters (Optional[Dict[str, Any]]): Optional metadata filters to apply score_threshold (Optional[float]): Minimum similarity score threshold Returns: RawSearchResult: The search results """ return RawSearchResult(records=[], metadata={}) class MockRetrieverNoDocstring(Retriever): """Test retriever without parameter documentation.""" VERIFY_NEO4J_VERSION = False def get_search_results( self, param_one: str, param_two: Optional[int] = None ) -> RawSearchResult: """No parameter documentation here.""" return RawSearchResult(records=[], metadata={}) class TestParameterInference: """Test parameter inference from method signatures and docstrings.""" def test_parameter_inference_with_docstring(self): """Test that parameters are correctly inferred from method signature and docstring.""" driver = create_mock_driver() retriever = MockRetriever(driver) # Get inferred parameters params = retriever.get_parameters() # Check basic structure assert params.type == ParameterType.OBJECT assert params.description == "Parameters for MockRetriever" assert not params.additional_properties # Check properties properties = params.properties assert len(properties) == 4 # Check query_text parameter query_text_param = properties["query_text"] assert query_text_param.type == ParameterType.STRING assert query_text_param.description == "Parameter query_text" assert query_text_param.required is True # Check top_k parameter top_k_param = properties["top_k"] assert top_k_param.type == ParameterType.INTEGER assert top_k_param.description == "Parameter top_k" assert top_k_param.required is False assert top_k_param.minimum == 1 # Should be set for top_k parameters # Check filters parameter filters_param = properties["filters"] assert filters_param.type == ParameterType.OBJECT assert filters_param.description == "Parameter filters" assert filters_param.required is False assert filters_param.additional_properties is True # Check score_threshold parameter score_param = properties["score_threshold"] assert score_param.type == ParameterType.NUMBER assert score_param.description == "Parameter score_threshold" assert score_param.required is False def test_parameter_inference_without_docstring(self): """Test that parameters work with fallback descriptions when no docstring documentation.""" driver = create_mock_driver() retriever = MockRetrieverNoDocstring(driver) # Get inferred parameters params = retriever.get_parameters() # Check properties properties = params.properties assert len(properties) == 2 # Check param_one with fallback description param_one = properties["param_one"] assert param_one.type == ParameterType.STRING assert param_one.description == "Parameter param_one" # Simple fallback format assert param_one.required is True # Check param_two with fallback description param_two = properties["param_two"] assert param_two.type == ParameterType.INTEGER assert param_two.description == "Parameter param_two" # Simple fallback format assert param_two.required is False def test_convert_to_tool_basic(self): """Test basic convert_to_tool functionality.""" driver = create_mock_driver() retriever = MockRetriever(driver) # Convert to tool tool = retriever.convert_to_tool( name="TestTool", description="A test tool for searching" ) # Check tool properties assert isinstance(tool, Tool) assert tool.get_name() == "TestTool" assert tool.get_description() == "A test tool for searching" # Check that parameters were inferred params = tool.get_parameters() assert len(params["properties"]) == 4 assert "query_text" in params["properties"] assert "top_k" in params["properties"] def test_convert_to_tool_with_custom_descriptions(self): """Test convert_to_tool with custom parameter descriptions.""" driver = create_mock_driver() retriever = MockRetriever(driver) # Convert to tool with custom parameter descriptions tool = retriever.convert_to_tool( name="CustomTool", description="A custom search tool", parameter_descriptions={ "query_text": "The search query to execute", "top_k": "Maximum number of results to return", "filters": "Optional filters to apply to the search", }, ) # Check tool properties assert tool.get_name() == "CustomTool" assert tool.get_description() == "A custom search tool" # Check custom parameter descriptions params = tool.get_parameters() properties = params["properties"] assert properties["query_text"]["description"] == "The search query to execute" assert ( properties["top_k"]["description"] == "Maximum number of results to return" ) assert ( properties["filters"]["description"] == "Optional filters to apply to the search" ) # Parameter without custom description should use fallback assert ( properties["score_threshold"]["description"] == "Parameter score_threshold" ) class TestRealRetrieverParameterInference: """Test parameter inference on real retriever classes.""" @patch("neo4j_graphrag.retrievers.base.get_version") def test_vector_retriever_parameters(self, mock_get_version): """Test VectorRetriever parameter inference.""" mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() embedder = create_mock_embedder() # Patch _fetch_index_infos to avoid database calls with patch.object(VectorRetriever, "_fetch_index_infos"): retriever = VectorRetriever( driver=driver, index_name="test_index", embedder=embedder ) params = retriever.get_parameters() properties = params.properties # Check expected parameters from VectorRetriever.get_search_results expected_params = { "query_vector", "query_text", "top_k", "effective_search_ratio", "filters", } assert set(properties.keys()) == expected_params # Check specific parameter types assert properties["query_vector"].type == ParameterType.ARRAY assert properties["query_text"].type == ParameterType.STRING assert properties["top_k"].type == ParameterType.INTEGER assert properties["effective_search_ratio"].type == ParameterType.INTEGER assert properties["filters"].type == ParameterType.OBJECT # Check that default descriptions are used when no custom descriptions provided assert properties["query_vector"].description == "Parameter query_vector" assert properties["query_text"].description == "Parameter query_text" @patch("neo4j_graphrag.retrievers.base.get_version") def test_vector_cypher_retriever_parameters(self, mock_get_version): """Test VectorCypherRetriever parameter inference.""" mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() embedder = create_mock_embedder() # Patch _fetch_index_infos to avoid database calls with patch.object(VectorCypherRetriever, "_fetch_index_infos"): retriever = VectorCypherRetriever( driver=driver, index_name="test_index", retrieval_query="RETURN node.name", embedder=embedder, ) params = retriever.get_parameters() properties = params.properties # Should have all VectorRetriever params plus query_params expected_params = { "query_vector", "query_text", "top_k", "effective_search_ratio", "query_params", "filters", } assert set(properties.keys()) == expected_params # Check query_params is properly typed assert properties["query_params"].type == ParameterType.OBJECT assert properties["query_params"].additional_properties is True @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_parameters(self, mock_get_version): """Test HybridRetriever parameter inference.""" mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() embedder = create_mock_embedder() # Patch _fetch_index_infos to avoid database calls with patch.object(HybridRetriever, "_fetch_index_infos"): retriever = HybridRetriever( driver=driver, vector_index_name="vector_index", fulltext_index_name="fulltext_index", embedder=embedder, ) params = retriever.get_parameters() properties = params.properties # Check expected parameters from HybridRetriever.get_search_results expected_params = { "query_text", "query_vector", "top_k", "effective_search_ratio", "ranker", "alpha", } assert set(properties.keys()) == expected_params # Check that query_text is required for hybrid retriever assert properties["query_text"].required is True assert properties["alpha"].type == ParameterType.NUMBER assert properties["alpha"].minimum == 0.0 assert properties["alpha"].maximum == 1.0 @patch("neo4j_graphrag.retrievers.base.get_version") def test_text2cypher_retriever_parameters(self, mock_get_version): """Test Text2CypherRetriever parameter inference.""" mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() llm = create_mock_llm() retriever = Text2CypherRetriever( driver=driver, llm=llm, neo4j_schema="(Person)-[:KNOWS]->(Person)" ) params = retriever.get_parameters() properties = params.properties # Check expected parameters expected_params = {"query_text", "prompt_params"} assert set(properties.keys()) == expected_params # Check parameter types assert properties["query_text"].type == ParameterType.STRING assert properties["query_text"].required is True assert ( properties["prompt_params"].type == ParameterType.OBJECT ) # Dict maps to object assert properties["prompt_params"].required is False def test_tools_retriever_parameters(self): """Test ToolsRetriever parameter inference.""" driver = create_mock_driver() llm = create_mock_llm() retriever = ToolsRetriever(driver=driver, llm=llm, tools=[]) params = retriever.get_parameters() properties = params.properties # Check expected parameters from ToolsRetriever.get_search_results expected_params = {"query_text", "message_history"} assert set(properties.keys()) == expected_params # Check parameter types assert properties["query_text"].type == ParameterType.STRING assert properties["query_text"].required is True assert ( properties["message_history"].type == ParameterType.OBJECT ) # List[LLMMessage] maps to Object assert properties["message_history"].required is False class TestToolExecution: """Test that tools created from retrievers actually work.""" def test_tool_execution(self): """Test that a tool created from a retriever can be executed.""" driver = create_mock_driver() retriever = MockRetriever(driver) # Convert to tool tool = retriever.convert_to_tool(name="TestTool", description="A test tool") # Execute the tool result = tool.execute(query_text="test query", top_k=3) # Check that we get a result (even if empty due to mocking) assert result is not None assert hasattr(result, "items") # Should return RetrieverResult now assert hasattr(result, "metadata") def test_tool_execution_with_validation(self): """Test that tool parameter validation works.""" driver = create_mock_driver() retriever = MockRetriever(driver) # Convert to tool tool = retriever.convert_to_tool(name="TestTool", description="A test tool") # Test with missing required parameter should work due to our setup # (the actual validation happens in the Tool class) result = tool.execute(query_text="test query") assert result is not None class TestParameterDescriptions: """Test parameter description functionality.""" def test_custom_parameter_descriptions(self): """Test that custom parameter descriptions are used correctly.""" class TestRetriever(Retriever): VERIFY_NEO4J_VERSION = False def get_search_results( self, param_a: str, param_b: int = 5, param_c: Optional[float] = None ) -> RawSearchResult: return RawSearchResult(records=[], metadata={}) driver = create_mock_driver() retriever = TestRetriever(driver) # Test with custom descriptions custom_descriptions = { "param_a": "Custom description for param A", "param_b": "Custom description for param B", # param_c intentionally omitted to test fallback } params = retriever.get_parameters(custom_descriptions) properties = params.properties # Check that custom descriptions are used assert properties["param_a"].description == "Custom description for param A" assert properties["param_b"].description == "Custom description for param B" # Check fallback for param without custom description assert properties["param_c"].description == "Parameter param_c" def test_no_custom_descriptions(self): """Test behavior when no custom descriptions are provided.""" class SimpleRetriever(Retriever): VERIFY_NEO4J_VERSION = False def get_search_results(self, test_param: str) -> RawSearchResult: return RawSearchResult(records=[], metadata={}) driver = create_mock_driver() retriever = SimpleRetriever(driver) params = retriever.get_parameters() properties = params.properties # Should use fallback description assert properties["test_param"].description == "Parameter test_param" class TestOpenAICompatibilityFix: """Test the specific fixes for OpenAI API compatibility.""" @patch("neo4j_graphrag.retrievers.base.get_version") def test_text2cypher_retriever_openai_schema_compatibility(self, mock_get_version): """Test that Text2CypherRetriever generates OpenAI-compatible schema. This test specifically covers the bug that was causing: 'Invalid schema for function 't2c_retriever': True is not of type 'array'' """ mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() llm = create_mock_llm() retriever = Text2CypherRetriever( driver=driver, llm=llm, neo4j_schema="(Person)-[:KNOWS]->(Person)" ) # Convert to tool (this is where the original bug occurred) tool = retriever.convert_to_tool( name="t2c_retriever", description="Use this tool when no other tool can help. It will directly try to build a Cypher query to query the graph.", ) # Get the tool parameters schema schema = tool.get_parameters() # Verify JSON Schema structure is correct for OpenAI assert schema["type"] == "object" assert "properties" in schema assert "required" in schema assert "additionalProperties" in schema # Check that required is an array, not a boolean assert isinstance(schema["required"], list) assert "query_text" in schema["required"] # Check individual properties don't have 'required' field for prop_name, prop_schema in schema["properties"].items(): assert ( "required" not in prop_schema ), f"Property {prop_name} should not have 'required' field" # Check the specific property that was causing issues prompt_params_schema = schema["properties"]["prompt_params"] assert prompt_params_schema["type"] == "object" assert "additionalProperties" in prompt_params_schema assert prompt_params_schema["additionalProperties"] is True # Ensure the schema is valid JSON Schema format import json try: # This should not raise any exceptions json_str = json.dumps(schema) parsed = json.loads(json_str) assert parsed == schema except (TypeError, ValueError) as e: pytest.fail(f"Schema is not JSON serializable: {e}") @patch("neo4j_graphrag.retrievers.base.get_version") def test_tools_retriever_with_t2c_tool_integration(self, mock_get_version): """Integration test showing the full ToolsRetriever + Text2CypherRetriever workflow.""" mock_get_version.return_value = ((5, 20, 0), False, False) driver = create_mock_driver() llm = create_mock_llm() # Create a Text2CypherRetriever t2c_retriever = Text2CypherRetriever( driver=driver, llm=llm, neo4j_schema="(Movie)-[:ACTED_IN]-(Person)" ) # Convert it to a tool (this was failing before the fix) t2c_tool = t2c_retriever.convert_to_tool( name="t2c_retriever", description="Generate Cypher queries from natural language", ) # Create ToolsRetriever with the t2c_tool tools_retriever = ToolsRetriever(driver=driver, llm=llm, tools=[t2c_tool]) # Verify that the tools_retriever was created successfully assert len(tools_retriever._tools) == 1 assert tools_retriever._tools[0].get_name() == "t2c_retriever" # Get the tool's parameters to verify schema structure tool_params = t2c_tool.get_parameters() # This should have the correct structure that OpenAI expects assert tool_params["type"] == "object" assert isinstance(tool_params["required"], list) assert "additionalProperties" in tool_params # All nested objects should also have additionalProperties for prop_name, prop_schema in tool_params["properties"].items(): if prop_schema.get("type") == "object": assert ( "additionalProperties" in prop_schema ), f"Nested object {prop_name} missing additionalProperties"