574 lines
22 KiB
Python
574 lines
22 KiB
Python
|
|
# 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"
|