Files
AI/참고/neo4j-graphrag-python-main/tests/unit/retrievers/test_text2cypher.py

552 lines
19 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
# 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.
from unittest.mock import MagicMock, patch
import neo4j
import pytest
from neo4j.exceptions import CypherSyntaxError, Neo4jError
from neo4j_graphrag.exceptions import (
RetrieverInitializationError,
SchemaFetchError,
SearchValidationError,
Text2CypherRetrievalError,
)
from neo4j_graphrag.generation.prompts import Text2CypherTemplate
from neo4j_graphrag.llm import LLMResponse
from neo4j_graphrag.retrievers.text2cypher import Text2CypherRetriever, extract_cypher
from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem
def test_t2c_retriever_initialization(driver: MagicMock, llm: MagicMock) -> None:
with patch("neo4j_graphrag.retrievers.base.get_version") as mock_get_version:
mock_get_version.return_value = ((5, 23, 0), False, False)
Text2CypherRetriever(driver, llm, neo4j_schema="dummy-text")
mock_get_version.assert_called_once()
@patch("neo4j_graphrag.retrievers.base.get_version")
@patch("neo4j_graphrag.retrievers.text2cypher.get_schema")
def test_t2c_retriever_schema_retrieval(
get_schema_mock: MagicMock,
mock_get_version: MagicMock,
driver: MagicMock,
llm: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
Text2CypherRetriever(driver, llm)
get_schema_mock.assert_called_once()
@patch("neo4j_graphrag.retrievers.base.get_version")
@patch("neo4j_graphrag.retrievers.text2cypher.get_schema")
def test_t2c_retriever_schema_retrieval_failure(
get_schema_mock: MagicMock,
mock_get_version: MagicMock,
driver: MagicMock,
llm: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
get_schema_mock.side_effect = Neo4jError
with pytest.raises(SchemaFetchError):
Text2CypherRetriever(driver, llm)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_invalid_neo4j_schema(
mock_get_version: MagicMock, driver: MagicMock, llm: MagicMock
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
with pytest.raises(RetrieverInitializationError) as exc_info:
Text2CypherRetriever(
driver=driver,
llm=llm,
neo4j_schema=42, # type: ignore[arg-type, unused-ignore]
)
assert "neo4j_schema" in str(exc_info.value)
assert "Input should be a valid string" in str(exc_info.value)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_invalid_search_query(
mock_get_version: MagicMock, driver: MagicMock, llm: MagicMock
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
with pytest.raises(SearchValidationError) as exc_info:
retriever = Text2CypherRetriever(
driver=driver, llm=llm, neo4j_schema="dummy-text"
)
retriever.search(query_text=42)
assert "query_text" in str(exc_info.value)
assert "Input should be a valid string" in str(exc_info.value)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_invalid_search_examples(
mock_get_version: MagicMock, driver: MagicMock, llm: MagicMock
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
with pytest.raises(RetrieverInitializationError) as exc_info:
Text2CypherRetriever(
driver=driver,
llm=llm,
neo4j_schema="dummy-text",
examples=42, # type: ignore[arg-type, unused-ignore]
)
assert "examples" in str(exc_info.value)
assert "Initialization failed" in str(exc_info.value)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_happy_path(
mock_get_version: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
t2c_query = "MATCH (n) RETURN n;"
query_text = "may thy knife chip and shatter"
neo4j_schema = "dummy-schema"
examples = ["example-1", "example-2"]
neo4j_database = "mydb"
retriever = Text2CypherRetriever(
driver=driver,
llm=llm,
neo4j_schema=neo4j_schema,
examples=examples,
neo4j_database=neo4j_database,
)
llm.invoke.return_value = LLMResponse(content=t2c_query)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
template = Text2CypherTemplate()
prompt = template.format(
schema=neo4j_schema,
examples="\n".join(examples),
query_text=query_text,
)
retriever.search(query_text=query_text)
llm.invoke.assert_called_once_with(prompt)
assert driver.execute_query.call_count == 2
driver.execute_query.assert_any_call(
query_=f"EXPLAIN {t2c_query}",
database_=neo4j_database,
routing_=neo4j.RoutingControl.READ,
)
driver.execute_query.assert_any_call(
query_=t2c_query,
database_=neo4j_database,
routing_=neo4j.RoutingControl.READ,
)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_cypher_error(
mock_get_version: MagicMock, driver: MagicMock, llm: MagicMock
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
t2c_query = "this is not a cypher query"
neo4j_schema = "dummy-schema"
examples = ["example-1", "example-2"]
retriever = Text2CypherRetriever(
driver=driver, llm=llm, neo4j_schema=neo4j_schema, examples=examples
)
retriever.llm.invoke.return_value = LLMResponse(content=t2c_query)
query_text = "may thy knife chip and shatter"
driver.execute_query.side_effect = CypherSyntaxError
with pytest.raises(Text2CypherRetrievalError) as e:
retriever.search(query_text=query_text)
assert "Failed to get search result" in str(e)
@pytest.mark.parametrize("query_type", ["w", "rw", "s"])
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_rejects_non_read_only_query(
mock_get_version: MagicMock,
driver: MagicMock,
llm: MagicMock,
query_type: str,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
t2c_query = "MATCH (n) DETACH DELETE n"
retriever = Text2CypherRetriever(
driver=driver, llm=llm, neo4j_schema="dummy-schema"
)
retriever.llm.invoke.return_value = LLMResponse(content=t2c_query)
driver.execute_query.return_value = (
[],
MagicMock(query_type=query_type),
None,
)
with pytest.raises(Text2CypherRetrievalError) as exc_info:
retriever.search(query_text="wipe the graph")
assert "non-read-only" in str(exc_info.value)
assert query_type in str(exc_info.value)
driver.execute_query.assert_called_once_with(
query_=f"EXPLAIN {t2c_query}",
database_=None,
routing_=neo4j.RoutingControl.READ,
)
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_with_result_format_function(
mock_get_version: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
result_formatter: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
retriever = Text2CypherRetriever(
driver=driver, llm=llm, result_formatter=result_formatter
)
t2c_query = "MATCH (n) RETURN n;"
retriever.llm.invoke.return_value = LLMResponse(content=t2c_query)
query_text = "may thy knife chip and shatter"
driver.execute_query.return_value = [
[neo4j_record],
MagicMock(query_type="r"),
None,
]
records = retriever.search(query_text=query_text)
assert records == RetrieverResult(
items=[
RetrieverResultItem(
content="dummy-node", metadata={"score": 1.0, "node_id": 123}
),
],
metadata={"cypher": t2c_query, "__retriever": "Text2CypherRetriever"},
)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_initialization_with_custom_prompt(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text}"
retriever = Text2CypherRetriever(driver=driver, llm=llm, custom_prompt=prompt)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(query_text="test")
llm.invoke.assert_called_once_with("This is a custom prompt. test")
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_initialization_with_custom_prompt_and_schema_and_examples(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text}"
neo4j_schema = "dummy-schema"
examples = ["example-1", "example-2"]
retriever = Text2CypherRetriever(
driver=driver,
llm=llm,
custom_prompt=prompt,
neo4j_schema=neo4j_schema,
examples=examples,
)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(query_text="test")
llm.invoke.assert_called_once_with("This is a custom prompt. test")
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_initialization_with_custom_prompt_and_schema_and_examples_for_prompt_params(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text} {schema} {examples}"
neo4j_schema = "dummy-schema"
examples = ["example-1", "example-2"]
retriever = Text2CypherRetriever(
driver=driver,
llm=llm,
custom_prompt=prompt,
neo4j_schema=neo4j_schema,
examples=examples,
)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(query_text="test")
llm.invoke.assert_called_once_with(
"This is a custom prompt. test dummy-schema example-1\nexample-2"
)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_initialization_with_custom_prompt_and_unused_schema_and_examples(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text} {schema} {examples}"
neo4j_schema = "dummy-schema"
examples = ["example-1", "example-2"]
retriever = Text2CypherRetriever(
driver=driver,
llm=llm,
custom_prompt=prompt,
neo4j_schema=neo4j_schema,
examples=examples,
)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(
query_text="test",
prompt_params={"schema": "another-dummy-schema", "examples": "another-example"},
)
llm.invoke.assert_called_once_with(
"This is a custom prompt. test another-dummy-schema another-example"
)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_invalid_custom_prompt_type(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
with pytest.raises(RetrieverInitializationError) as exc_info:
Text2CypherRetriever(
driver=driver,
llm=llm,
custom_prompt=42, # type: ignore[arg-type, unused-ignore]
)
assert "Input should be a valid string" in str(exc_info.value)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_with_custom_prompt_prompt_params(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text} {examples_custom}"
query = "test"
examples = ["example A", "example B"]
retriever = Text2CypherRetriever(driver=driver, llm=llm, custom_prompt=prompt)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(query_text=query, prompt_params={"examples_custom": examples})
llm.invoke.assert_called_once_with(
"""This is a custom prompt. test ['example A', 'example B']"""
)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_t2c_retriever_with_custom_prompt_bad_prompt_params(
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text} {examples}"
query = "test"
examples = ["example A", "example B"]
retriever = Text2CypherRetriever(driver=driver, llm=llm, custom_prompt=prompt)
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever.search(
query_text=query,
prompt_params={
"examples": examples,
"bad_param": "this should not be present in template.",
},
)
llm.invoke.assert_called_once_with(
"""This is a custom prompt. test ['example A', 'example B']"""
)
@patch("neo4j_graphrag.retrievers.text2cypher.extract_cypher")
@patch("neo4j_graphrag.retrievers.base.get_version")
@patch("neo4j_graphrag.retrievers.text2cypher.get_schema")
def test_t2c_retriever_with_custom_prompt_and_schema(
get_schema_mock: MagicMock,
mock_get_version: MagicMock,
mock_extract_cypher: MagicMock,
driver: MagicMock,
llm: MagicMock,
neo4j_record: MagicMock,
) -> None:
mock_get_version.return_value = ((5, 23, 0), False, False)
prompt = "This is a custom prompt. {query_text} {schema}"
query = "test"
driver.execute_query.return_value = (
[neo4j_record],
MagicMock(query_type="r"),
None,
)
retriever = Text2CypherRetriever(driver=driver, llm=llm, custom_prompt=prompt)
retriever.search(
query_text=query,
prompt_params={},
)
get_schema_mock.assert_not_called()
llm.invoke.assert_called_once_with("""This is a custom prompt. test """)
@pytest.mark.parametrize(
"description, cypher_query, expected_output",
[
("No changes", "MATCH (n) RETURN n;", "MATCH (n) RETURN n;"),
(
"Surrounded by backticks",
"Cypher query: ```MATCH (n) RETURN n;```",
"MATCH (n) RETURN n;",
),
(
"Spaces in label",
"Cypher query: ```MATCH (n: Label With Spaces ) RETURN n;```",
"MATCH (n:`Label With Spaces`) RETURN n;",
),
(
"No spaces in label",
"Cypher query: ```MATCH (n: LabelWithNoSpaces ) RETURN n;```",
"MATCH (n: LabelWithNoSpaces ) RETURN n;",
),
(
"Backticks in label",
"Cypher query: ```MATCH (n: `LabelWithBackticks` ) RETURN n;```",
"MATCH (n: `LabelWithBackticks` ) RETURN n;",
),
(
"Spaces in property key",
"Cypher query: ```MATCH (n: { prop 1: 1, prop 2: 2 }) RETURN n;```",
"MATCH (n: { `prop 1`: 1, `prop 2`: 2 }) RETURN n;",
),
(
"No spaces in property key",
"Cypher query: ```MATCH (n: { prop1: 1, prop2: 2 }) RETURN n;```",
"MATCH (n: { prop1: 1, prop2: 2 }) RETURN n;",
),
(
"Backticks in property key",
"Cypher query: ```MATCH (n: { `prop 1`: 1, `prop 2`: 2 }) RETURN n;```",
"MATCH (n: { `prop 1`: 1, `prop 2`: 2 }) RETURN n;",
),
(
"Spaces in relationship type",
"Cypher query: ```MATCH (n)-[: Relationship With Spaces ]->(m) RETURN n, m;```",
"MATCH (n)-[:`Relationship With Spaces`]->(m) RETURN n, m;",
),
(
"No spaces in relationship type",
"Cypher query: ```MATCH (n)-[ : RelationshipWithNoSpaces ]->(m) RETURN n, m;```",
"MATCH (n)-[ : RelationshipWithNoSpaces ]->(m) RETURN n, m;",
),
(
"Backticks in relationship type",
"Cypher query: ```MATCH (n)-[ : `RelationshipWithBackticks` ]->(m) RETURN n, m;```",
"MATCH (n)-[ : `RelationshipWithBackticks` ]->(m) RETURN n, m;",
),
(
"Map literal values not corrupted",
"WITH collect(DISTINCT {\n tk: e.TimekeeperID,\n name: e.Name,\n title: e.Title,\n s: CASE WHEN r.start_date > d0 THEN r.start_date ELSE d0 END\n}) AS segs\nRETURN segs",
"WITH collect(DISTINCT {\n tk: e.TimekeeperID,\n name: e.Name,\n title: e.Title,\n s: CASE WHEN r.start_date > d0 THEN r.start_date ELSE d0 END\n}) AS segs\nRETURN segs",
),
(
"Code block with language tag",
"Here is the query: ```cypher\nMATCH (n) RETURN n;```",
"MATCH (n) RETURN n;",
),
],
)
def test_extract_cypher(
description: str, cypher_query: str, expected_output: str
) -> None:
assert (
extract_cypher(cypher_query) == expected_output
), f"Failed test case: {description}"