참고소스 수정본
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
import asyncio
|
||||
|
||||
from neo4j_graphrag.experimental.pipeline import Component, DataModel
|
||||
from neo4j_graphrag.experimental.pipeline.types.context import RunContext
|
||||
|
||||
|
||||
class StringResultModel(DataModel):
|
||||
result: str
|
||||
|
||||
|
||||
class IntResultModel(DataModel):
|
||||
result: int
|
||||
|
||||
|
||||
class ComponentNoParam(Component):
|
||||
async def run(self) -> StringResultModel:
|
||||
return StringResultModel(result="")
|
||||
|
||||
|
||||
class ComponentPassThrough(Component):
|
||||
async def run(self, value: str) -> StringResultModel:
|
||||
return StringResultModel(result=f"value is: {value}")
|
||||
|
||||
|
||||
class ComponentAdd(Component):
|
||||
async def run(self, number1: int, number2: int) -> IntResultModel:
|
||||
return IntResultModel(result=number1 + number2)
|
||||
|
||||
|
||||
class ComponentMultiply(Component):
|
||||
async def run(self, number1: int, number2: int = 2) -> IntResultModel:
|
||||
return IntResultModel(result=number1 * number2)
|
||||
|
||||
|
||||
class ComponentMultiplyWithContext(Component):
|
||||
async def run_with_context(
|
||||
self, context_: RunContext, number1: int, number2: int = 2
|
||||
) -> IntResultModel:
|
||||
await context_.notify(
|
||||
message="my message", data={"number1": number1, "number2": number2}
|
||||
)
|
||||
return IntResultModel(result=number1 * number2)
|
||||
|
||||
|
||||
class SlowComponentMultiply(Component):
|
||||
def __init__(self, sleep: float = 1.0) -> None:
|
||||
self.sleep = sleep
|
||||
|
||||
async def run(self, number1: int, number2: int = 2) -> IntResultModel:
|
||||
await asyncio.sleep(self.sleep)
|
||||
return IntResultModel(result=number1 * number2)
|
||||
@@ -0,0 +1,594 @@
|
||||
# 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.
|
||||
import re
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import neo4j
|
||||
import pytest
|
||||
from neo4j_graphrag.embeddings import Embedder
|
||||
from neo4j_graphrag.experimental.components.embedder import TextChunkEmbedder
|
||||
from neo4j_graphrag.experimental.components.entity_relation_extractor import (
|
||||
LLMEntityRelationExtractor,
|
||||
OnError,
|
||||
)
|
||||
from neo4j_graphrag.experimental.components.kg_writer import Neo4jWriter
|
||||
from neo4j_graphrag.experimental.components.data_loader import MarkdownLoader, PdfLoader
|
||||
from neo4j_graphrag.experimental.components.schema import (
|
||||
SchemaBuilder,
|
||||
SchemaFromTextExtractor,
|
||||
GraphSchema,
|
||||
)
|
||||
from neo4j_graphrag.experimental.components.text_splitters.fixed_size_splitter import (
|
||||
FixedSizeSplitter,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.config.object_config import (
|
||||
ComponentConfig,
|
||||
ComponentType,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.config.template_pipeline import (
|
||||
SimpleKGPipelineConfig,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
|
||||
from neo4j_graphrag.experimental.pipeline.types.schema import (
|
||||
EntityInputType,
|
||||
RelationInputType,
|
||||
)
|
||||
from neo4j_graphrag.experimental.components.types import DocumentType
|
||||
from neo4j_graphrag.generation.prompts import ERExtractionTemplate
|
||||
from neo4j_graphrag.llm import LLMInterface
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_file_loader_from_file_is_false() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=False)
|
||||
assert config._get_file_loader() is None
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_file_loader_from_file_is_true() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=True)
|
||||
assert config._get_file_loader() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_kg_pipeline_config_default_file_loader_supports_markdown() -> (
|
||||
None
|
||||
):
|
||||
config = SimpleKGPipelineConfig(from_file=True)
|
||||
loader = config._get_file_loader()
|
||||
assert loader is not None
|
||||
doc = await loader.run(
|
||||
filepath="tests/unit/experimental/components/sample_data/hello.md"
|
||||
)
|
||||
assert doc.document_info.document_type == DocumentType.MARKDOWN
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_from_pdf_deprecated_maps_to_from_file() -> None:
|
||||
with pytest.warns(DeprecationWarning, match="from_pdf"):
|
||||
config = SimpleKGPipelineConfig(from_pdf=True)
|
||||
assert config.from_file is True
|
||||
with pytest.warns(DeprecationWarning, match="from_pdf"):
|
||||
config_false = SimpleKGPipelineConfig(from_pdf=False)
|
||||
assert config_false.from_file is False
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_pdf_loader_deprecated_maps_to_file_loader() -> None:
|
||||
my_loader = PdfLoader()
|
||||
with pytest.warns(DeprecationWarning, match="pdf_loader"):
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=True,
|
||||
pdf_loader=ComponentType(my_loader),
|
||||
)
|
||||
assert config._get_file_loader() == my_loader
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_pdf_loader_and_file_loader_conflict() -> None:
|
||||
with pytest.raises(ValueError, match="pdf_loader"):
|
||||
SimpleKGPipelineConfig(
|
||||
from_file=True,
|
||||
file_loader=ComponentType(PdfLoader()),
|
||||
pdf_loader=ComponentType(PdfLoader()),
|
||||
)
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_file_loader_from_file_is_true_class_overwrite() -> (
|
||||
None
|
||||
):
|
||||
my_file_loader = MarkdownLoader()
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=True, file_loader=ComponentType(my_file_loader)
|
||||
)
|
||||
assert config._get_file_loader() == my_file_loader
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_file_loader_class_overwrite_but_from_file_is_false() -> (
|
||||
None
|
||||
):
|
||||
my_file_loader = PdfLoader()
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=False, file_loader=ComponentType(my_file_loader)
|
||||
)
|
||||
assert config._get_file_loader() is None
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
|
||||
def test_simple_kg_pipeline_config_file_loader_from_file_is_true_class_overwrite_from_config(
|
||||
mock_component_parse: Mock,
|
||||
) -> None:
|
||||
my_file_loader_config = ComponentConfig(
|
||||
class_="",
|
||||
)
|
||||
my_file_loader = PdfLoader()
|
||||
mock_component_parse.return_value = my_file_loader
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=True,
|
||||
file_loader=ComponentType(my_file_loader_config),
|
||||
)
|
||||
assert config._get_file_loader() == my_file_loader
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_text_splitter() -> None:
|
||||
config = SimpleKGPipelineConfig()
|
||||
assert isinstance(config._get_splitter(), FixedSizeSplitter)
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
|
||||
def test_simple_kg_pipeline_config_text_splitter_overwrite(
|
||||
mock_component_parse: Mock,
|
||||
) -> None:
|
||||
my_text_splitter_config = ComponentConfig(
|
||||
class_="",
|
||||
)
|
||||
my_text_splitter = FixedSizeSplitter()
|
||||
mock_component_parse.return_value = my_text_splitter
|
||||
config = SimpleKGPipelineConfig(
|
||||
text_splitter=my_text_splitter_config, # type: ignore
|
||||
)
|
||||
assert config._get_splitter() == my_text_splitter
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_embedder"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_chunk_embedder(
|
||||
mock_embedder: Mock, embedder: Embedder
|
||||
) -> None:
|
||||
mock_embedder.return_value = embedder
|
||||
config = SimpleKGPipelineConfig()
|
||||
chunk_embedder = config._get_chunk_embedder()
|
||||
assert isinstance(chunk_embedder, TextChunkEmbedder)
|
||||
assert chunk_embedder._embedder == embedder
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_automatic_schema(
|
||||
mock_llm: Mock, llm: LLMInterface
|
||||
) -> None:
|
||||
mock_llm.return_value = llm
|
||||
config = SimpleKGPipelineConfig()
|
||||
schema = config._get_schema()
|
||||
assert isinstance(schema, SchemaFromTextExtractor)
|
||||
assert schema._llm == llm
|
||||
assert schema.use_structured_output is False
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_automatic_schema_structured_output(
|
||||
mock_llm: Mock, llm: LLMInterface
|
||||
) -> None:
|
||||
llm.supports_structured_output = True
|
||||
mock_llm.return_value = llm
|
||||
config = SimpleKGPipelineConfig()
|
||||
schema = config._get_schema()
|
||||
assert isinstance(schema, SchemaFromTextExtractor)
|
||||
assert schema.use_structured_output is True
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_manual_schema() -> None:
|
||||
config = SimpleKGPipelineConfig(entities=["Person"])
|
||||
assert isinstance(config._get_schema(), SchemaBuilder)
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_literal_schema_validation() -> None:
|
||||
config = SimpleKGPipelineConfig(schema="FREE") # type: ignore
|
||||
assert config.schema_ == GraphSchema.create_empty()
|
||||
|
||||
config = SimpleKGPipelineConfig(schema="EXTRACTED") # type: ignore
|
||||
assert config.schema_ is None
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_schema_run_params() -> None:
|
||||
config = SimpleKGPipelineConfig(
|
||||
entities=["Person"],
|
||||
relations=["KNOWS"],
|
||||
potential_schema=[("Person", "KNOWS", "Person")],
|
||||
)
|
||||
assert config._get_run_params_for_schema() == {
|
||||
"node_types": ["Person"],
|
||||
"relationship_types": ["KNOWS"],
|
||||
"patterns": [
|
||||
("Person", "KNOWS", "Person"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_extractor(mock_llm: Mock, llm: LLMInterface) -> None:
|
||||
mock_llm.return_value = llm
|
||||
config = SimpleKGPipelineConfig(
|
||||
on_error="IGNORE", # type: ignore
|
||||
prompt_template=ERExtractionTemplate(template="my template {text}"),
|
||||
)
|
||||
extractor = config._get_extractor()
|
||||
assert isinstance(extractor, LLMEntityRelationExtractor)
|
||||
assert extractor.llm == llm
|
||||
assert extractor.on_error == OnError.IGNORE
|
||||
assert extractor.prompt_template.template == "my template {text}"
|
||||
assert extractor.use_structured_output is False
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_extractor_structured_output(
|
||||
mock_llm: Mock, llm: LLMInterface
|
||||
) -> None:
|
||||
llm.supports_structured_output = True
|
||||
mock_llm.return_value = llm
|
||||
config = SimpleKGPipelineConfig()
|
||||
extractor = config._get_extractor()
|
||||
assert isinstance(extractor, LLMEntityRelationExtractor)
|
||||
assert extractor.use_structured_output is True
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_neo4j_driver"
|
||||
)
|
||||
def test_simple_kg_pipeline_config_writer(
|
||||
mock_driver: Mock,
|
||||
_: Mock,
|
||||
driver: neo4j.Driver,
|
||||
) -> None:
|
||||
mock_driver.return_value = driver
|
||||
config = SimpleKGPipelineConfig(
|
||||
neo4j_database="my_db",
|
||||
)
|
||||
writer = config._get_writer()
|
||||
assert isinstance(writer, Neo4jWriter)
|
||||
assert writer.driver == driver
|
||||
assert writer.neo4j_database == "my_db"
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
|
||||
def test_simple_kg_pipeline_config_writer_overwrite(
|
||||
mock_component_parse: Mock,
|
||||
_: Mock,
|
||||
driver: neo4j.Driver,
|
||||
) -> None:
|
||||
my_writer_config = ComponentConfig(
|
||||
class_="",
|
||||
)
|
||||
my_writer = Neo4jWriter(driver, neo4j_database="my_db")
|
||||
mock_component_parse.return_value = my_writer
|
||||
config = SimpleKGPipelineConfig(
|
||||
kg_writer=my_writer_config, # type: ignore
|
||||
neo4j_database="my_other_db",
|
||||
)
|
||||
writer: Neo4jWriter = config._get_writer() # type: ignore
|
||||
assert writer == my_writer
|
||||
# database not changed:
|
||||
assert writer.neo4j_database == "my_db"
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_connections_from_file() -> None:
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=True,
|
||||
perform_entity_resolution=False,
|
||||
)
|
||||
connections = config._get_connections()
|
||||
assert len(connections) == 7
|
||||
expected_connections = [
|
||||
("file_loader", "splitter"),
|
||||
("file_loader", "schema"),
|
||||
("schema", "extractor"),
|
||||
("splitter", "chunk_embedder"),
|
||||
("chunk_embedder", "extractor"),
|
||||
("extractor", "pruner"),
|
||||
("pruner", "writer"),
|
||||
]
|
||||
for actual, expected in zip(connections, expected_connections):
|
||||
assert (actual.start, actual.end) == expected
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_connections_from_text() -> None:
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=False,
|
||||
perform_entity_resolution=False,
|
||||
)
|
||||
connections = config._get_connections()
|
||||
assert len(connections) == 5
|
||||
expected_connections = [
|
||||
("schema", "extractor"),
|
||||
("splitter", "chunk_embedder"),
|
||||
("chunk_embedder", "extractor"),
|
||||
("extractor", "pruner"),
|
||||
("pruner", "writer"),
|
||||
]
|
||||
for actual, expected in zip(connections, expected_connections):
|
||||
assert (actual.start, actual.end) == expected
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_connections_with_er() -> None:
|
||||
config = SimpleKGPipelineConfig(
|
||||
from_file=True,
|
||||
perform_entity_resolution=True,
|
||||
)
|
||||
connections = config._get_connections()
|
||||
assert len(connections) == 8
|
||||
expected_connections = [
|
||||
("file_loader", "splitter"),
|
||||
("file_loader", "schema"),
|
||||
("schema", "extractor"),
|
||||
("splitter", "chunk_embedder"),
|
||||
("chunk_embedder", "extractor"),
|
||||
("extractor", "pruner"),
|
||||
("pruner", "writer"),
|
||||
("writer", "resolver"),
|
||||
]
|
||||
for actual, expected in zip(connections, expected_connections):
|
||||
assert (actual.start, actual.end) == expected
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_run_params_from_file_file_path() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=True)
|
||||
assert config.get_run_params({"file_path": "my_file"}) == {
|
||||
"file_loader": {"filepath": "my_file", "metadata": None}
|
||||
}
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_run_params_from_text_text() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=False)
|
||||
run_params = config.get_run_params({"text": "my text"})
|
||||
assert run_params["splitter"] == {"text": "my text"}
|
||||
assert run_params["schema"] == {"text": "my text"}
|
||||
assert run_params["extractor"]["document_info"]["path"] == "document.txt"
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_run_params_from_file_text() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=True)
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
config.get_run_params({"text": "my text"})
|
||||
assert (
|
||||
"Expected 'file_path' to a PDF or Markdown file when 'from_file' is True"
|
||||
in str(excinfo)
|
||||
)
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_run_params_from_text_file_path() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=False)
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
config.get_run_params({"file_path": "my file"})
|
||||
assert "Expected 'text' argument when 'from_file' is False" in str(excinfo)
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_run_params_no_file_no_text() -> None:
|
||||
config = SimpleKGPipelineConfig(from_file=False)
|
||||
with pytest.raises(
|
||||
PipelineDefinitionError,
|
||||
match=re.escape(
|
||||
"At least one of `text` (when from_file=False) or `file_path` (when from_file=True) argument must be provided."
|
||||
),
|
||||
):
|
||||
config.get_run_params({})
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_process_schema_with_precedence_legacy() -> None:
|
||||
entities: list[EntityInputType] = [
|
||||
"Person",
|
||||
{
|
||||
"label": "Organization",
|
||||
"description": "A group of persons",
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "STRING",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
relations: list[RelationInputType] = [
|
||||
"WORKS_FOR",
|
||||
{
|
||||
"label": "CREATED",
|
||||
"description": "A person created an organization",
|
||||
"properties": [
|
||||
{
|
||||
"name": "date",
|
||||
"description": "The date the organization was created",
|
||||
"type": "DATE",
|
||||
},
|
||||
{"name": "isActive", "type": "BOOLEAN"},
|
||||
],
|
||||
},
|
||||
]
|
||||
potential_schema = [
|
||||
("Person", "WORKS_FOR", "Organization"),
|
||||
("Person", "CREATED", "Organization"),
|
||||
]
|
||||
config = SimpleKGPipelineConfig(
|
||||
entities=entities,
|
||||
relations=relations,
|
||||
potential_schema=potential_schema,
|
||||
)
|
||||
schema_dict = config._process_schema_with_precedence()
|
||||
node_types = schema_dict["node_types"]
|
||||
relationship_types = schema_dict["relationship_types"]
|
||||
patterns = schema_dict["patterns"]
|
||||
assert len(node_types) == 2
|
||||
assert node_types[0] == "Person"
|
||||
assert node_types[1]["label"] == "Organization"
|
||||
assert len(node_types[1]["properties"]) == 1
|
||||
assert relationship_types is not None
|
||||
assert len(relationship_types) == 2
|
||||
assert relationship_types[0] == "WORKS_FOR"
|
||||
assert relationship_types[1]["label"] == "CREATED"
|
||||
assert len(relationship_types[1]["properties"]) == 2
|
||||
assert patterns is not None
|
||||
assert len(patterns) == 2
|
||||
assert "additional_node_types" not in schema_dict
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_process_schema_with_precedence_schema_dict() -> None:
|
||||
entities = [
|
||||
"Person",
|
||||
{
|
||||
"label": "Organization",
|
||||
"description": "A group of persons",
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "STRING",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
relations = [
|
||||
"WORKS_FOR",
|
||||
{
|
||||
"label": "CREATED",
|
||||
"description": "A person created an organization",
|
||||
"properties": [
|
||||
{
|
||||
"name": "date",
|
||||
"description": "The date the organization was created",
|
||||
"type": "DATE",
|
||||
},
|
||||
{"name": "isActive", "type": "BOOLEAN"},
|
||||
],
|
||||
},
|
||||
]
|
||||
potential_schema = [
|
||||
("Person", "WORKS_FOR", "Organization"),
|
||||
("Person", "CREATED", "Organization"),
|
||||
]
|
||||
config = SimpleKGPipelineConfig(
|
||||
schema={ # type: ignore
|
||||
"node_types": entities,
|
||||
"relationship_types": relations,
|
||||
"patterns": potential_schema,
|
||||
"additional_node_types": False,
|
||||
}
|
||||
)
|
||||
schema_dict = config._process_schema_with_precedence()
|
||||
node_types = schema_dict["node_types"]
|
||||
relationship_types = schema_dict["relationship_types"]
|
||||
patterns = schema_dict["patterns"]
|
||||
assert len(node_types) == 2
|
||||
assert node_types[0]["label"] == "Person"
|
||||
# String input gets default "name" property and additional_properties=True
|
||||
assert len(node_types[0]["properties"]) == 1
|
||||
assert node_types[0]["properties"][0]["name"] == "name"
|
||||
assert node_types[0]["additional_properties"] is True
|
||||
assert node_types[1]["label"] == "Organization"
|
||||
assert len(node_types[1]["properties"]) == 1
|
||||
assert relationship_types is not None
|
||||
assert len(relationship_types) == 2
|
||||
assert relationship_types[0]["label"] == "WORKS_FOR"
|
||||
assert len(relationship_types[0]["properties"]) == 0
|
||||
assert relationship_types[1]["label"] == "CREATED"
|
||||
assert len(relationship_types[1]["properties"]) == 2
|
||||
assert patterns is not None
|
||||
assert len(patterns) == 2
|
||||
assert schema_dict["additional_node_types"] is False
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_config_process_schema_with_precedence_schema_object() -> (
|
||||
None
|
||||
):
|
||||
entities = [
|
||||
"Person",
|
||||
{
|
||||
"label": "Organization",
|
||||
"description": "A group of persons",
|
||||
"properties": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "STRING",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
relations = [
|
||||
"WORKS_FOR",
|
||||
{
|
||||
"label": "CREATED",
|
||||
"description": "A person created an organization",
|
||||
"properties": [
|
||||
{
|
||||
"name": "date",
|
||||
"description": "The date the organization was created",
|
||||
"type": "DATE",
|
||||
},
|
||||
{"name": "isActive", "type": "BOOLEAN"},
|
||||
],
|
||||
},
|
||||
]
|
||||
potential_schema = [
|
||||
("Person", "WORKS_FOR", "Organization"),
|
||||
("Person", "CREATED", "Organization"),
|
||||
]
|
||||
config = SimpleKGPipelineConfig(
|
||||
schema=GraphSchema.model_validate(
|
||||
{
|
||||
"node_types": entities,
|
||||
"relationship_types": relations,
|
||||
"patterns": potential_schema,
|
||||
"additional_node_types": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
schema_dict = config._process_schema_with_precedence()
|
||||
node_types = schema_dict["node_types"]
|
||||
relationship_types = schema_dict["relationship_types"]
|
||||
patterns = schema_dict["patterns"]
|
||||
assert len(node_types) == 2
|
||||
assert node_types[0]["label"] == "Person"
|
||||
# String input gets default "name" property and additional_properties=True
|
||||
assert len(node_types[0]["properties"]) == 1
|
||||
assert node_types[0]["properties"][0]["name"] == "name"
|
||||
assert node_types[0]["additional_properties"] is True
|
||||
assert node_types[1]["label"] == "Organization"
|
||||
assert len(node_types[1]["properties"]) == 1
|
||||
assert relationship_types is not None
|
||||
assert len(relationship_types) == 2
|
||||
assert relationship_types[0]["label"] == "WORKS_FOR"
|
||||
assert len(relationship_types[0]["properties"]) == 0
|
||||
assert relationship_types[1]["label"] == "CREATED"
|
||||
assert len(relationship_types[1]["properties"]) == 2
|
||||
assert patterns is not None
|
||||
assert len(patterns) == 2
|
||||
assert schema_dict["additional_node_types"] is False
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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 patch
|
||||
|
||||
from neo4j_graphrag.experimental.pipeline.config.base import AbstractConfig
|
||||
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
|
||||
ParamToResolveConfig,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_param_with_param_to_resolve_object() -> None:
|
||||
c = AbstractConfig()
|
||||
with patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamToResolveConfig",
|
||||
spec=ParamToResolveConfig,
|
||||
) as mock_param_class:
|
||||
mock_param = mock_param_class.return_value
|
||||
mock_param.resolve.return_value = 1
|
||||
assert c.resolve_param(mock_param) == 1
|
||||
mock_param.resolve.assert_called_once_with({})
|
||||
|
||||
|
||||
def test_resolve_param_with_other_object() -> None:
|
||||
c = AbstractConfig()
|
||||
assert c.resolve_param("value") == "value"
|
||||
@@ -0,0 +1,192 @@
|
||||
# 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.
|
||||
import importlib
|
||||
import sys
|
||||
from abc import ABC
|
||||
from typing import ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
import neo4j
|
||||
import pytest
|
||||
from neo4j_graphrag.embeddings import Embedder, OpenAIEmbeddings
|
||||
from neo4j_graphrag.experimental.pipeline import Pipeline
|
||||
from neo4j_graphrag.experimental.pipeline.config.object_config import (
|
||||
EmbedderConfig,
|
||||
EmbedderType,
|
||||
LLMConfig,
|
||||
LLMType,
|
||||
Neo4jDriverConfig,
|
||||
Neo4jDriverType,
|
||||
ObjectConfig,
|
||||
)
|
||||
from neo4j_graphrag.llm import LLMInterface, OpenAILLM
|
||||
|
||||
|
||||
def test_get_class_no_optional_module() -> None:
|
||||
c: ObjectConfig[object] = ObjectConfig()
|
||||
klass = c._get_class("neo4j_graphrag.experimental.pipeline.Pipeline")
|
||||
assert klass == Pipeline
|
||||
|
||||
|
||||
def test_get_class_optional_module() -> None:
|
||||
c: ObjectConfig[object] = ObjectConfig()
|
||||
klass = c._get_class(
|
||||
"Pipeline", optional_module="neo4j_graphrag.experimental.pipeline"
|
||||
)
|
||||
assert klass == Pipeline
|
||||
|
||||
|
||||
def test_get_class_path_and_optional_module() -> None:
|
||||
c: ObjectConfig[object] = ObjectConfig()
|
||||
klass = c._get_class(
|
||||
"pipeline.Pipeline", optional_module="neo4j_graphrag.experimental"
|
||||
)
|
||||
assert klass == Pipeline
|
||||
|
||||
|
||||
def test_get_class_wrong_path() -> None:
|
||||
c: ObjectConfig[object] = ObjectConfig()
|
||||
with pytest.raises(ValueError):
|
||||
c._get_class("MyClass")
|
||||
|
||||
|
||||
class _MyClass:
|
||||
def __init__(self, param: str) -> None:
|
||||
self.param = param
|
||||
|
||||
|
||||
class _MyInterface(ABC): ...
|
||||
|
||||
|
||||
def test_parse_after_module_reload() -> None:
|
||||
class MyClassConfig(ObjectConfig[_MyClass]):
|
||||
DEFAULT_MODULE: ClassVar[str] = __name__
|
||||
INTERFACE: ClassVar[type] = _MyClass
|
||||
|
||||
param_value = "value"
|
||||
config = MyClassConfig.model_validate(
|
||||
{"class_": f"{__name__}.{_MyClass.__name__}", "params_": {"param": param_value}}
|
||||
)
|
||||
importlib.reload(sys.modules[__name__])
|
||||
|
||||
my_obj = config.parse()
|
||||
assert isinstance(my_obj, _MyClass)
|
||||
assert my_obj.param == param_value
|
||||
|
||||
|
||||
def test_neo4j_driver_config() -> None:
|
||||
config = Neo4jDriverConfig.model_validate(
|
||||
{
|
||||
"params_": {
|
||||
"uri": "bolt://",
|
||||
"user": "a user",
|
||||
"password": "a password",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert config.class_ == "not used"
|
||||
assert config.params_ == {
|
||||
"uri": "bolt://",
|
||||
"user": "a user",
|
||||
"password": "a password",
|
||||
}
|
||||
with patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.neo4j.GraphDatabase.driver"
|
||||
) as driver_mock:
|
||||
driver_mock.return_value = "a driver"
|
||||
d = config.parse()
|
||||
driver_mock.assert_called_once_with("bolt://", auth=("a user", "a password"))
|
||||
assert d == "a driver" # type: ignore
|
||||
|
||||
|
||||
def test_neo4j_driver_type_with_driver(driver: neo4j.Driver) -> None:
|
||||
driver_type = Neo4jDriverType(driver)
|
||||
assert driver_type.parse() == driver
|
||||
|
||||
|
||||
def test_neo4j_driver_type_with_config() -> None:
|
||||
driver_type = Neo4jDriverType(
|
||||
Neo4jDriverConfig(
|
||||
params_={
|
||||
"uri": "bolt://",
|
||||
"user": "",
|
||||
"password": "",
|
||||
}
|
||||
)
|
||||
)
|
||||
driver = driver_type.parse()
|
||||
assert isinstance(driver, neo4j.Driver)
|
||||
|
||||
|
||||
def test_llm_config() -> None:
|
||||
config = LLMConfig.model_validate(
|
||||
{
|
||||
"class_": "OpenAILLM",
|
||||
"params_": {"model_name": "gpt-5", "api_key": "my-api-key"},
|
||||
}
|
||||
)
|
||||
assert config.class_ == "OpenAILLM"
|
||||
assert config.get_module() == "neo4j_graphrag.llm"
|
||||
assert config.get_interface() == LLMInterface
|
||||
assert config.params_ == {"model_name": "gpt-5", "api_key": "my-api-key"}
|
||||
d = config.parse()
|
||||
assert isinstance(d, OpenAILLM)
|
||||
|
||||
|
||||
def test_llm_type_with_driver(llm: LLMInterface) -> None:
|
||||
llm_type = LLMType(llm)
|
||||
assert llm_type.parse() == llm
|
||||
|
||||
|
||||
def test_llm_type_with_config() -> None:
|
||||
llm_type = LLMType(
|
||||
LLMConfig(
|
||||
class_="OpenAILLM",
|
||||
params_={"model_name": "gpt-5", "api_key": "my-api-key"},
|
||||
)
|
||||
)
|
||||
llm = llm_type.parse()
|
||||
assert isinstance(llm, OpenAILLM)
|
||||
|
||||
|
||||
def test_embedder_config() -> None:
|
||||
config = EmbedderConfig.model_validate(
|
||||
{
|
||||
"class_": "OpenAIEmbeddings",
|
||||
"params_": {"api_key": "my-api-key"},
|
||||
}
|
||||
)
|
||||
assert config.class_ == "OpenAIEmbeddings"
|
||||
assert config.get_module() == "neo4j_graphrag.embeddings"
|
||||
assert config.get_interface() == Embedder
|
||||
assert config.params_ == {"api_key": "my-api-key"}
|
||||
d = config.parse()
|
||||
assert isinstance(d, OpenAIEmbeddings)
|
||||
|
||||
|
||||
def test_embedder_type_with_embedder(embedder: Embedder) -> None:
|
||||
embedder_type = EmbedderType(embedder)
|
||||
assert embedder_type.parse() == embedder
|
||||
|
||||
|
||||
def test_embedder_type_with_config() -> None:
|
||||
embedder_type = EmbedderType(
|
||||
EmbedderConfig(
|
||||
class_="OpenAIEmbeddings",
|
||||
params_={"api_key": "my-api-key"},
|
||||
)
|
||||
)
|
||||
embedder = embedder_type.parse()
|
||||
assert isinstance(embedder, OpenAIEmbeddings)
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
|
||||
ParamFromEnvConfig,
|
||||
ParamFromKeyConfig,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"MY_KEY": "my_value"}, clear=True)
|
||||
def test_env_param_config_happy_path() -> None:
|
||||
resolver = ParamFromEnvConfig(var_="MY_KEY")
|
||||
assert resolver.resolve({}) == "my_value"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_env_param_config_missing_env_var() -> None:
|
||||
resolver = ParamFromEnvConfig(var_="MY_KEY")
|
||||
assert resolver.resolve({}) is None
|
||||
|
||||
|
||||
def test_config_key_param_simple_key() -> None:
|
||||
resolver = ParamFromKeyConfig(key_="my_key")
|
||||
assert resolver.resolve({"my_key": "my_value"}) == "my_value"
|
||||
|
||||
|
||||
def test_config_key_param_missing_key() -> None:
|
||||
resolver = ParamFromKeyConfig(key_="my_key")
|
||||
with pytest.raises(KeyError):
|
||||
resolver.resolve({})
|
||||
|
||||
|
||||
def test_config_complex_key_param() -> None:
|
||||
resolver = ParamFromKeyConfig(key_="my_key.my_sub_key")
|
||||
assert resolver.resolve({"my_key": {"my_sub_key": "value"}}) == "value"
|
||||
|
||||
|
||||
def test_config_complex_key_param_missing_subkey() -> None:
|
||||
resolver = ParamFromKeyConfig(key_="my_key.my_sub_key")
|
||||
with pytest.raises(KeyError):
|
||||
resolver.resolve({"my_key": {}})
|
||||
@@ -0,0 +1,378 @@
|
||||
# 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 Mock, patch
|
||||
|
||||
import neo4j
|
||||
from neo4j_graphrag.embeddings import Embedder
|
||||
from neo4j_graphrag.experimental.pipeline import Component
|
||||
from neo4j_graphrag.experimental.pipeline.config.object_config import (
|
||||
ComponentConfig,
|
||||
ComponentType,
|
||||
Neo4jDriverConfig,
|
||||
Neo4jDriverType,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
|
||||
ParamFromEnvConfig,
|
||||
ParamFromKeyConfig,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.config.pipeline_config import (
|
||||
AbstractPipelineConfig,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.types.definitions import ComponentDefinition
|
||||
from neo4j_graphrag.llm import LLMInterface
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
|
||||
)
|
||||
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_params_(
|
||||
mock_neo4j_config: Mock,
|
||||
) -> None:
|
||||
mock_neo4j_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"neo4j_config": {
|
||||
"params_": {
|
||||
"uri": "bolt://",
|
||||
"user": "",
|
||||
"password": "",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.neo4j_config, dict)
|
||||
assert "default" in config.neo4j_config
|
||||
config.parse()
|
||||
mock_neo4j_config.assert_called_once()
|
||||
assert config._global_data["neo4j_config"]["default"] == "text"
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
|
||||
)
|
||||
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_names(
|
||||
mock_neo4j_config: Mock,
|
||||
) -> None:
|
||||
mock_neo4j_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"neo4j_config": {
|
||||
"my_driver": {
|
||||
"params_": {
|
||||
"uri": "bolt://",
|
||||
"user": "",
|
||||
"password": "",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.neo4j_config, dict)
|
||||
assert "my_driver" in config.neo4j_config
|
||||
config.parse()
|
||||
mock_neo4j_config.assert_called_once()
|
||||
assert config._global_data["neo4j_config"]["my_driver"] == "text"
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
|
||||
)
|
||||
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_driver(
|
||||
mock_neo4j_config: Mock, driver: neo4j.Driver
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"neo4j_config": {
|
||||
"my_driver": driver,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.neo4j_config, dict)
|
||||
assert "my_driver" in config.neo4j_config
|
||||
config.parse()
|
||||
assert not mock_neo4j_config.called
|
||||
assert config._global_data["neo4j_config"]["my_driver"] == driver
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
|
||||
)
|
||||
def test_abstract_pipeline_config_neo4j_config_is_a_driver(
|
||||
mock_neo4j_config: Mock, driver: neo4j.Driver
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"neo4j_config": driver,
|
||||
}
|
||||
)
|
||||
assert isinstance(config.neo4j_config, dict)
|
||||
assert "default" in config.neo4j_config
|
||||
config.parse()
|
||||
assert not mock_neo4j_config.called
|
||||
assert config._global_data["neo4j_config"]["default"] == driver
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
|
||||
def test_abstract_pipeline_config_llm_config_is_a_dict_with_params_(
|
||||
mock_llm_config: Mock,
|
||||
) -> None:
|
||||
mock_llm_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{"llm_config": {"class_": "OpenAILLM", "params_": {"model_name": "gpt-5"}}}
|
||||
)
|
||||
assert isinstance(config.llm_config, dict)
|
||||
assert "default" in config.llm_config
|
||||
config.parse()
|
||||
mock_llm_config.assert_called_once()
|
||||
assert config._global_data["llm_config"]["default"] == "text"
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
|
||||
def test_abstract_pipeline_config_llm_config_is_a_dict_with_names(
|
||||
mock_llm_config: Mock,
|
||||
) -> None:
|
||||
mock_llm_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"llm_config": {
|
||||
"my_llm": {"class_": "OpenAILLM", "params_": {"model_name": "gpt-5"}}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.llm_config, dict)
|
||||
assert "my_llm" in config.llm_config
|
||||
config.parse()
|
||||
mock_llm_config.assert_called_once()
|
||||
assert config._global_data["llm_config"]["my_llm"] == "text"
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
|
||||
def test_abstract_pipeline_config_llm_config_is_a_dict_with_llm(
|
||||
mock_llm_config: Mock, llm: LLMInterface
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"llm_config": {
|
||||
"my_llm": llm,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.llm_config, dict)
|
||||
assert "my_llm" in config.llm_config
|
||||
config.parse()
|
||||
assert not mock_llm_config.called
|
||||
assert config._global_data["llm_config"]["my_llm"] == llm
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
|
||||
def test_abstract_pipeline_config_llm_config_is_a_llm(
|
||||
mock_llm_config: Mock, llm: LLMInterface
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"llm_config": llm,
|
||||
}
|
||||
)
|
||||
assert isinstance(config.llm_config, dict)
|
||||
assert "default" in config.llm_config
|
||||
config.parse()
|
||||
assert not mock_llm_config.called
|
||||
assert config._global_data["llm_config"]["default"] == llm
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
|
||||
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_params_(
|
||||
mock_embedder_config: Mock,
|
||||
) -> None:
|
||||
mock_embedder_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{"embedder_config": {"class_": "OpenAIEmbeddings", "params_": {}}}
|
||||
)
|
||||
assert isinstance(config.embedder_config, dict)
|
||||
assert "default" in config.embedder_config
|
||||
config.parse()
|
||||
mock_embedder_config.assert_called_once()
|
||||
assert config._global_data["embedder_config"]["default"] == "text"
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
|
||||
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_names(
|
||||
mock_embedder_config: Mock,
|
||||
) -> None:
|
||||
mock_embedder_config.return_value = "text"
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"embedder_config": {
|
||||
"my_embedder": {"class_": "OpenAIEmbeddings", "params_": {}}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.embedder_config, dict)
|
||||
assert "my_embedder" in config.embedder_config
|
||||
config.parse()
|
||||
mock_embedder_config.assert_called_once()
|
||||
assert config._global_data["embedder_config"]["my_embedder"] == "text"
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
|
||||
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_llm(
|
||||
mock_embedder_config: Mock, embedder: Embedder
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"embedder_config": {
|
||||
"my_embedder": embedder,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(config.embedder_config, dict)
|
||||
assert "my_embedder" in config.embedder_config
|
||||
config.parse()
|
||||
assert not mock_embedder_config.called
|
||||
assert config._global_data["embedder_config"]["my_embedder"] == embedder
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
|
||||
def test_abstract_pipeline_config_embedder_config_is_an_embedder(
|
||||
mock_embedder_config: Mock, embedder: Embedder
|
||||
) -> None:
|
||||
config = AbstractPipelineConfig.model_validate(
|
||||
{
|
||||
"embedder_config": embedder,
|
||||
}
|
||||
)
|
||||
assert isinstance(config.embedder_config, dict)
|
||||
assert "default" in config.embedder_config
|
||||
config.parse()
|
||||
assert not mock_embedder_config.called
|
||||
assert config._global_data["embedder_config"]["default"] == embedder
|
||||
|
||||
|
||||
def test_abstract_pipeline_config_parse_global_data_no_extras(driver: Mock) -> None:
|
||||
config = AbstractPipelineConfig(
|
||||
neo4j_config={"my_driver": Neo4jDriverType(driver)},
|
||||
)
|
||||
gd = config._parse_global_data()
|
||||
assert gd == {
|
||||
"extras": {},
|
||||
"neo4j_config": {
|
||||
"my_driver": driver,
|
||||
},
|
||||
"llm_config": {},
|
||||
"embedder_config": {},
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamFromEnvConfig.resolve"
|
||||
)
|
||||
def test_abstract_pipeline_config_parse_global_data_extras(
|
||||
mock_param_resolver: Mock,
|
||||
) -> None:
|
||||
mock_param_resolver.return_value = "my value"
|
||||
config = AbstractPipelineConfig(
|
||||
extras={"my_extra_var": ParamFromEnvConfig(var_="some key")},
|
||||
)
|
||||
gd = config._parse_global_data()
|
||||
assert gd == {
|
||||
"extras": {"my_extra_var": "my value"},
|
||||
"neo4j_config": {},
|
||||
"llm_config": {},
|
||||
"embedder_config": {},
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamFromEnvConfig.resolve"
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverType.parse"
|
||||
)
|
||||
def test_abstract_pipeline_config_parse_global_data_use_extras_in_other_config(
|
||||
mock_neo4j_parser: Mock,
|
||||
mock_param_resolver: Mock,
|
||||
) -> None:
|
||||
"""Parser is able to read variables in the 'extras' section of config
|
||||
to instantiate another object (neo4j.Driver in this test case)
|
||||
"""
|
||||
mock_param_resolver.side_effect = ["bolt://myhost", "myuser", "mypwd"]
|
||||
mock_neo4j_parser.return_value = "my driver"
|
||||
config = AbstractPipelineConfig(
|
||||
extras={
|
||||
"my_extra_uri": ParamFromEnvConfig(var_="some key"),
|
||||
"my_extra_user": ParamFromEnvConfig(var_="some key"),
|
||||
"my_extra_pwd": ParamFromEnvConfig(var_="some key"),
|
||||
},
|
||||
neo4j_config={
|
||||
"my_driver": Neo4jDriverType(
|
||||
Neo4jDriverConfig(
|
||||
params_=dict(
|
||||
uri=ParamFromKeyConfig(key_="extras.my_extra_uri"),
|
||||
user=ParamFromKeyConfig(key_="extras.my_extra_user"),
|
||||
password=ParamFromKeyConfig(key_="extras.my_extra_pwd"),
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
gd = config._parse_global_data()
|
||||
expected_extras = {
|
||||
"my_extra_uri": "bolt://myhost",
|
||||
"my_extra_user": "myuser",
|
||||
"my_extra_pwd": "mypwd",
|
||||
}
|
||||
assert gd["extras"] == expected_extras
|
||||
assert gd["neo4j_config"] == {"my_driver": "my driver"}
|
||||
mock_neo4j_parser.assert_called_once_with({"extras": expected_extras})
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
|
||||
def test_abstract_pipeline_config_resolve_component_definition_no_run_params(
|
||||
mock_component_parse: Mock,
|
||||
component: Component,
|
||||
) -> None:
|
||||
mock_component_parse.return_value = component
|
||||
config = AbstractPipelineConfig()
|
||||
component_type = ComponentType(component)
|
||||
component_definition = config._resolve_component_definition("name", component_type)
|
||||
assert isinstance(component_definition, ComponentDefinition)
|
||||
mock_component_parse.assert_called_once_with({})
|
||||
assert component_definition.name == "name"
|
||||
assert component_definition.component == component
|
||||
assert component_definition.run_params == {}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.config.pipeline_config.AbstractPipelineConfig.resolve_params"
|
||||
)
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
|
||||
def test_abstract_pipeline_config_resolve_component_definition_with_run_params(
|
||||
mock_component_parse: Mock,
|
||||
mock_resolve_params: Mock,
|
||||
component: Component,
|
||||
) -> None:
|
||||
mock_component_parse.return_value = component
|
||||
mock_resolve_params.return_value = {"param": "resolver param result"}
|
||||
config = AbstractPipelineConfig()
|
||||
component_type = ComponentType(
|
||||
ComponentConfig(class_="", params_={}, run_params_={"param1": "value1"})
|
||||
)
|
||||
component_definition = config._resolve_component_definition("name", component_type)
|
||||
assert isinstance(component_definition, ComponentDefinition)
|
||||
mock_component_parse.assert_called_once_with({})
|
||||
assert component_definition.name == "name"
|
||||
assert component_definition.component == component
|
||||
assert component_definition.run_params == {"param": "resolver param result"}
|
||||
mock_resolve_params.assert_called_once_with({"param1": "value1"})
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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 Mock, patch
|
||||
|
||||
from neo4j_graphrag.experimental.pipeline import Pipeline
|
||||
from neo4j_graphrag.experimental.pipeline.config.pipeline_config import PipelineConfig
|
||||
from neo4j_graphrag.experimental.pipeline.config.runner import PipelineRunner
|
||||
from neo4j_graphrag.experimental.pipeline.types.definitions import PipelineDefinition
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.pipeline.Pipeline.from_definition")
|
||||
def test_pipeline_runner_from_def_empty(mock_from_definition: Mock) -> None:
|
||||
mock_from_definition.return_value = Pipeline()
|
||||
runner = PipelineRunner(
|
||||
pipeline_definition=PipelineDefinition(components=[], connections=[])
|
||||
)
|
||||
assert runner.config is None
|
||||
assert runner.pipeline is not None
|
||||
assert runner.pipeline._nodes == {}
|
||||
assert runner.pipeline._edges == []
|
||||
assert runner.run_params == {}
|
||||
mock_from_definition.assert_called_once()
|
||||
|
||||
|
||||
def test_pipeline_runner_from_config() -> None:
|
||||
config = PipelineConfig(component_config={}, connection_config=[])
|
||||
runner = PipelineRunner.from_config(config)
|
||||
assert runner.config is not None
|
||||
assert runner.pipeline is not None
|
||||
assert runner.pipeline._nodes == {}
|
||||
assert runner.pipeline._edges == []
|
||||
assert runner.run_params == {}
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.config.runner.PipelineRunner.from_config")
|
||||
@patch("neo4j_graphrag.utils.file_handler.FileHandler.read")
|
||||
def test_pipeline_runner_from_config_file(
|
||||
mock_read: Mock, mock_from_config: Mock
|
||||
) -> None:
|
||||
mock_read.return_value = {"dict": "with data"}
|
||||
PipelineRunner.from_config_file("file.yaml")
|
||||
|
||||
mock_read.assert_called_once_with("file.yaml")
|
||||
mock_from_config.assert_called_once_with({"dict": "with data"}, do_cleaning=True)
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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 AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from neo4j_graphrag.experimental.pipeline import Component
|
||||
from neo4j_graphrag.experimental.pipeline.types.context import RunContext
|
||||
from .components import ComponentMultiply, ComponentMultiplyWithContext, IntResultModel
|
||||
|
||||
|
||||
def test_component_inputs() -> None:
|
||||
inputs = ComponentMultiply.component_inputs
|
||||
assert "number1" in inputs
|
||||
assert inputs["number1"]["has_default"] is False
|
||||
assert "number2" in inputs
|
||||
assert inputs["number2"]["has_default"] is True
|
||||
|
||||
|
||||
def test_component_outputs() -> None:
|
||||
outputs = ComponentMultiply.component_outputs
|
||||
assert "result" in outputs
|
||||
assert outputs["result"]["has_default"] is True
|
||||
assert outputs["result"]["annotation"] == int
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_run() -> None:
|
||||
c = ComponentMultiply()
|
||||
result = await c.run(number1=1, number2=2)
|
||||
assert isinstance(result, IntResultModel)
|
||||
assert isinstance(
|
||||
result.result,
|
||||
# we know this is a type and not a bool or str:
|
||||
ComponentMultiply.component_outputs["result"]["annotation"], # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_run_with_context_default_implementation() -> None:
|
||||
c = ComponentMultiply()
|
||||
result = await c.run_with_context(
|
||||
# context can not be null in the function signature,
|
||||
# but it's ignored in this case
|
||||
None, # type: ignore
|
||||
number1=1,
|
||||
number2=2,
|
||||
)
|
||||
# the type checker doesn't know about the type
|
||||
# because the method is not re-declared
|
||||
assert result.result == 2 # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_run_with_context() -> None:
|
||||
c = ComponentMultiplyWithContext()
|
||||
notifier_mock = AsyncMock()
|
||||
result = await c.run_with_context(
|
||||
RunContext(run_id="run_id", task_name="task_name", notifier=notifier_mock),
|
||||
number1=1,
|
||||
number2=2,
|
||||
)
|
||||
assert result.result == 2
|
||||
notifier_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_component_missing_method() -> None:
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
|
||||
class WrongComponent(Component):
|
||||
# we must have either run or run_with_context
|
||||
pass
|
||||
|
||||
assert (
|
||||
"You must implement either `run` or `run_with_context` in Component 'WrongComponent'"
|
||||
in str(e)
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
# 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 import mock
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import neo4j
|
||||
import pytest
|
||||
from neo4j_graphrag.embeddings import Embedder
|
||||
from neo4j_graphrag.experimental.components.data_loader import PdfLoader
|
||||
from neo4j_graphrag.experimental.components.types import (
|
||||
LexicalGraphConfig,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
|
||||
from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
|
||||
from neo4j_graphrag.experimental.pipeline.pipeline import PipelineResult
|
||||
from neo4j_graphrag.llm.base import LLMInterface
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_from_pdf_deprecated_kwarg(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="from_pdf"):
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
from_pdf=True,
|
||||
)
|
||||
|
||||
file_path = "path/to/test.pdf"
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(file_path=file_path)
|
||||
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert "file_loader" in pipe_inputs
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_pdf_loader_deprecated_kwarg(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
loader = PdfLoader()
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="pdf_loader"):
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
pdf_loader=loader,
|
||||
)
|
||||
|
||||
file_path = "path/to/test.pdf"
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(file_path=file_path)
|
||||
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert "file_loader" in pipe_inputs
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_document_info_with_file(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
from_file=True,
|
||||
)
|
||||
|
||||
file_path = "path/to/test.pdf"
|
||||
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(
|
||||
file_path=file_path, document_metadata={"source": "google drive"}
|
||||
)
|
||||
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert "file_loader" in pipe_inputs
|
||||
assert pipe_inputs["file_loader"] == {
|
||||
"filepath": file_path,
|
||||
"metadata": {"source": "google drive"},
|
||||
}
|
||||
assert "extractor" not in pipe_inputs
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_document_info_with_text(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
from_file=False,
|
||||
)
|
||||
|
||||
text_input = "May thy knife chip and shatter."
|
||||
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(
|
||||
text=text_input,
|
||||
file_path="my_document.txt",
|
||||
document_metadata={"source": "google drive"},
|
||||
)
|
||||
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert "splitter" in pipe_inputs
|
||||
assert pipe_inputs["splitter"] == {"text": text_input}
|
||||
assert pipe_inputs["extractor"]["document_info"]["path"] == "my_document.txt"
|
||||
assert pipe_inputs["extractor"]["document_info"]["metadata"] == {
|
||||
"source": "google drive"
|
||||
}
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_with_entities_and_file(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
entities = ["Document", "Section"]
|
||||
relations = ["CONTAINS"]
|
||||
potential_schema = [("Document", "CONTAINS", "Section")]
|
||||
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
entities=entities,
|
||||
relations=relations,
|
||||
potential_schema=potential_schema,
|
||||
from_file=True,
|
||||
)
|
||||
|
||||
file_path = "path/to/test.pdf"
|
||||
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(file_path=file_path)
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert pipe_inputs["schema"]["node_types"] == entities
|
||||
assert pipe_inputs["schema"]["relationship_types"] == relations
|
||||
assert pipe_inputs["schema"]["patterns"] == potential_schema
|
||||
|
||||
|
||||
def test_simple_kg_pipeline_on_error_invalid_value() -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
on_error="INVALID_VALUE",
|
||||
)
|
||||
|
||||
|
||||
def test_knowledge_graph_builder_pdf_loader_and_file_loader_conflict() -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
with pytest.raises(ValueError, match="pdf_loader"):
|
||||
SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
file_loader=PdfLoader(),
|
||||
pdf_loader=PdfLoader(),
|
||||
)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"neo4j_graphrag.experimental.components.kg_writer.get_version",
|
||||
return_value=((5, 23, 0), False, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_graph_builder_with_lexical_graph_config(_: Mock) -> None:
|
||||
llm = MagicMock(spec=LLMInterface)
|
||||
driver = MagicMock(spec=neo4j.Driver)
|
||||
embedder = MagicMock(spec=Embedder)
|
||||
|
||||
chunk_node_label = "TestChunk"
|
||||
document_nodel_label = "TestDocument"
|
||||
lexical_graph_config = LexicalGraphConfig(
|
||||
chunk_node_label=chunk_node_label, document_node_label=document_nodel_label
|
||||
)
|
||||
|
||||
kg_builder = SimpleKGPipeline(
|
||||
llm=llm,
|
||||
driver=driver,
|
||||
embedder=embedder,
|
||||
from_file=False,
|
||||
lexical_graph_config=lexical_graph_config,
|
||||
)
|
||||
|
||||
text_input = "May thy knife chip and shatter."
|
||||
|
||||
with patch.object(
|
||||
kg_builder.runner.pipeline,
|
||||
"run",
|
||||
return_value=PipelineResult(run_id="test_run", result=None),
|
||||
) as mock_run:
|
||||
await kg_builder.run_async(text=text_input)
|
||||
|
||||
pipe_inputs = mock_run.call_args[1]["data"]
|
||||
assert "extractor" in pipe_inputs
|
||||
assert pipe_inputs["extractor"]["lexical_graph_config"] == lexical_graph_config
|
||||
assert pipe_inputs["extractor"]["document_info"] is not None
|
||||
assert pipe_inputs["extractor"]["document_info"]["path"] == "document.txt"
|
||||
@@ -0,0 +1,384 @@
|
||||
# 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 Mock, patch
|
||||
|
||||
import pytest
|
||||
from neo4j_graphrag.experimental.pipeline import (
|
||||
Component,
|
||||
Pipeline,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.exceptions import (
|
||||
PipelineDefinitionError,
|
||||
PipelineMissingDependencyError,
|
||||
PipelineStatusUpdateError,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.orchestrator import Orchestrator
|
||||
from neo4j_graphrag.experimental.pipeline.types.orchestration import RunStatus
|
||||
|
||||
from tests.unit.experimental.pipeline.components import (
|
||||
ComponentNoParam,
|
||||
ComponentPassThrough,
|
||||
)
|
||||
|
||||
|
||||
def test_orchestrator_get_input_config_for_task_pipeline_not_validated() -> None:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentPassThrough(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
orchestrator = Orchestrator(pipe)
|
||||
with pytest.raises(PipelineDefinitionError) as exc:
|
||||
orchestrator.get_input_config_for_task(pipe.get_node_by_name("a"))
|
||||
assert "You must validate the pipeline input config first" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_get_component_inputs_from_user_only() -> None:
|
||||
"""Components take all their inputs from user input."""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentPassThrough(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
orchestrator = Orchestrator(pipe)
|
||||
input_data = {
|
||||
"a": {"value": "user input for component a"},
|
||||
"b": {"value": "user input for component b"},
|
||||
}
|
||||
data = await orchestrator.get_component_inputs("a", {}, input_data)
|
||||
assert data == {"value": "user input for component a"}
|
||||
data = await orchestrator.get_component_inputs("b", {}, input_data)
|
||||
assert data == {"value": "user input for component b"}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_get_component_inputs_from_parent_specific(
|
||||
mock_result: Mock,
|
||||
) -> None:
|
||||
"""Propagate one specific output field from parent to a child component."""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentPassThrough(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
pipe.connect("a", "b", input_config={"value": "a.result"})
|
||||
|
||||
# component "a" already run and results stored:
|
||||
mock_result.return_value = {"result": "output from component a"}
|
||||
|
||||
orchestrator = Orchestrator(pipe)
|
||||
data = await orchestrator.get_component_inputs(
|
||||
"b", {"value": {"component": "a", "param": "result"}}, {}
|
||||
)
|
||||
assert data == {"value": "output from component a"}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_get_component_inputs_from_parent_all(
|
||||
mock_result: Mock,
|
||||
) -> None:
|
||||
"""Use the component name to get the full output
|
||||
(without extracting a specific field).
|
||||
"""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentNoParam(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
pipe.connect("a", "b", input_config={"value": "a"})
|
||||
|
||||
# component "a" already run and results stored:
|
||||
mock_result.return_value = {"result": "output from component a"}
|
||||
|
||||
orchestrator = Orchestrator(pipe)
|
||||
data = await orchestrator.get_component_inputs(
|
||||
"b", {"value": {"component": "a"}}, {}
|
||||
)
|
||||
assert data == {"value": {"result": "output from component a"}}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_get_component_inputs_from_parent_and_input(
|
||||
mock_result: Mock,
|
||||
) -> None:
|
||||
"""Some parameters from user input, some other parameter from previous component."""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentNoParam(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
pipe.connect("a", "b", input_config={"value": "a"})
|
||||
|
||||
# component "a" already run and results stored:
|
||||
mock_result.return_value = {"result": "output from component a"}
|
||||
|
||||
orchestrator = Orchestrator(pipe)
|
||||
data = await orchestrator.get_component_inputs(
|
||||
"b",
|
||||
{"value": {"component": "a"}},
|
||||
{"b": {"other_value": "user input for component b 'other_value' param"}},
|
||||
)
|
||||
assert data == {
|
||||
"value": {"result": "output from component a"},
|
||||
"other_value": "user input for component b 'other_value' param",
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_get_component_inputs_ignore_user_input_if_input_def_provided(
|
||||
mock_result: Mock,
|
||||
) -> None:
|
||||
"""If a parameter is defined both in the user input and in an input definition
|
||||
(ie propagated from a previous component), the user input is ignored and a
|
||||
warning is raised.
|
||||
"""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentNoParam(), "a")
|
||||
pipe.add_component(ComponentPassThrough(), "b")
|
||||
pipe.connect("a", "b", input_config={"value": "a"})
|
||||
|
||||
# component "a" already run and results stored:
|
||||
mock_result.return_value = {"result": "output from component a"}
|
||||
|
||||
orchestrator = Orchestrator(pipe)
|
||||
with pytest.warns(Warning) as w:
|
||||
data = await orchestrator.get_component_inputs(
|
||||
"b",
|
||||
{"value": {"component": "a"}},
|
||||
{"b": {"value": "user input for component a"}},
|
||||
)
|
||||
assert data == {"value": {"result": "output from component a"}}
|
||||
assert (
|
||||
w[0].message.args[0] # type: ignore[union-attr]
|
||||
== "In component 'b', parameter 'value' from user input will be ignored and replaced by 'a.value'"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"old_status, new_status, result",
|
||||
[
|
||||
# Normal path: from UNKNOWN to RUNNING to DONE
|
||||
(RunStatus.UNKNOWN, RunStatus.RUNNING, "ok"),
|
||||
(RunStatus.RUNNING, RunStatus.DONE, "ok"),
|
||||
# Error: status is already set to this value
|
||||
(RunStatus.RUNNING, RunStatus.RUNNING, "Status is already RunStatus.RUNNING"),
|
||||
(RunStatus.DONE, RunStatus.DONE, "Status is already RunStatus.DONE"),
|
||||
# Error: can't go back in time
|
||||
(
|
||||
RunStatus.DONE,
|
||||
RunStatus.RUNNING,
|
||||
"Can't go from RunStatus.DONE to RunStatus.RUNNING",
|
||||
),
|
||||
(
|
||||
RunStatus.RUNNING,
|
||||
RunStatus.UNKNOWN,
|
||||
"Can't go from RunStatus.RUNNING to RunStatus.UNKNOWN",
|
||||
),
|
||||
(
|
||||
RunStatus.DONE,
|
||||
RunStatus.UNKNOWN,
|
||||
"Can't go from RunStatus.DONE to RunStatus.UNKNOWN",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_orchestrator_set_component_status(
|
||||
mock_status: Mock,
|
||||
old_status: RunStatus,
|
||||
new_status: RunStatus,
|
||||
result: str,
|
||||
) -> None:
|
||||
pipe = Pipeline()
|
||||
orchestrator = Orchestrator(pipeline=pipe)
|
||||
mock_status.side_effect = [
|
||||
old_status,
|
||||
]
|
||||
if result == "ok":
|
||||
await orchestrator.set_task_status("task_name", new_status)
|
||||
else:
|
||||
with pytest.raises(PipelineStatusUpdateError) as exc:
|
||||
await orchestrator.set_task_status("task_name", new_status)
|
||||
assert result in str(exc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pipeline_branch() -> Pipeline:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(Component(), "a") # type: ignore[abstract,unused-ignore]
|
||||
pipe.add_component(Component(), "b") # type: ignore[abstract,unused-ignore]
|
||||
pipe.add_component(Component(), "c") # type: ignore[abstract,unused-ignore]
|
||||
pipe.connect("a", "b")
|
||||
pipe.connect("a", "c")
|
||||
return pipe
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pipeline_aggregation() -> Pipeline:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(Component(), "a") # type: ignore[abstract,unused-ignore]
|
||||
pipe.add_component(Component(), "b") # type: ignore[abstract,unused-ignore]
|
||||
pipe.add_component(Component(), "c") # type: ignore[abstract,unused-ignore]
|
||||
pipe.connect("a", "c")
|
||||
pipe.connect("b", "c")
|
||||
return pipe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
async def test_orchestrator_check_dependency_complete(
|
||||
mock_status: Mock, pipeline_branch: Pipeline
|
||||
) -> None:
|
||||
"""a -> b, c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_branch)
|
||||
node_a = pipeline_branch.get_node_by_name("a")
|
||||
await orchestrator.check_dependencies_complete(node_a)
|
||||
node_b = pipeline_branch.get_node_by_name("b")
|
||||
# dependency is DONE:
|
||||
mock_status.side_effect = [RunStatus.DONE]
|
||||
await orchestrator.check_dependencies_complete(node_b)
|
||||
# dependency is not DONE:
|
||||
mock_status.side_effect = [RunStatus.RUNNING]
|
||||
with pytest.raises(PipelineMissingDependencyError):
|
||||
await orchestrator.check_dependencies_complete(node_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
|
||||
)
|
||||
async def test_orchestrator_next_task_branch_no_missing_dependencies(
|
||||
mock_dep: Mock, mock_status: Mock, pipeline_branch: Pipeline
|
||||
) -> None:
|
||||
"""a -> b, c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_branch)
|
||||
node_a = pipeline_branch.get_node_by_name("a")
|
||||
mock_status.side_effect = [
|
||||
# next "b"
|
||||
RunStatus.UNKNOWN,
|
||||
# next "c"
|
||||
RunStatus.UNKNOWN,
|
||||
]
|
||||
mock_dep.side_effect = [
|
||||
None, # "b" has no missing dependencies
|
||||
None, # "c" has no missing dependencies
|
||||
]
|
||||
next_tasks = [n async for n in orchestrator.next(node_a)]
|
||||
next_task_names = [n.name for n in next_tasks]
|
||||
assert next_task_names == ["b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
|
||||
)
|
||||
async def test_orchestrator_next_task_branch_missing_dependencies(
|
||||
mock_dep: Mock, mock_status: Mock, pipeline_branch: Pipeline
|
||||
) -> None:
|
||||
"""a -> b, c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_branch)
|
||||
node_a = pipeline_branch.get_node_by_name("a")
|
||||
mock_status.side_effect = [
|
||||
# next "b"
|
||||
RunStatus.UNKNOWN,
|
||||
# next "c"
|
||||
RunStatus.UNKNOWN,
|
||||
]
|
||||
mock_dep.side_effect = [
|
||||
PipelineMissingDependencyError, # "b" has missing dependencies
|
||||
None, # "c" has no missing dependencies
|
||||
]
|
||||
next_tasks = [n async for n in orchestrator.next(node_a)]
|
||||
next_task_names = [n.name for n in next_tasks]
|
||||
assert next_task_names == ["c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
|
||||
)
|
||||
async def test_orchestrator_next_task_aggregation_no_missing_dependencies(
|
||||
mock_dep: Mock, mock_status: Mock, pipeline_aggregation: Pipeline
|
||||
) -> None:
|
||||
"""a, b -> c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
|
||||
node_a = pipeline_aggregation.get_node_by_name("a")
|
||||
mock_status.side_effect = [
|
||||
RunStatus.UNKNOWN, # status for "c", not started
|
||||
]
|
||||
mock_dep.side_effect = [
|
||||
None, # no missing deps
|
||||
]
|
||||
# then "c" can start
|
||||
next_tasks = [n async for n in orchestrator.next(node_a)]
|
||||
next_task_names = [n.name for n in next_tasks]
|
||||
assert next_task_names == ["c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
|
||||
)
|
||||
async def test_orchestrator_next_task_aggregation_missing_dependency(
|
||||
mock_dep: Mock, mock_status: Mock, pipeline_aggregation: Pipeline
|
||||
) -> None:
|
||||
"""a, b -> c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
|
||||
node_a = pipeline_aggregation.get_node_by_name("a")
|
||||
mock_status.side_effect = [
|
||||
RunStatus.UNKNOWN, # status for "c" is unknown, it's a possible next
|
||||
]
|
||||
mock_dep.side_effect = [
|
||||
PipelineMissingDependencyError, # some dependencies are not done yet
|
||||
]
|
||||
next_task_names = [n.name async for n in orchestrator.next(node_a)]
|
||||
# "c" dependencies not ready yet
|
||||
assert next_task_names == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
|
||||
)
|
||||
async def test_orchestrator_next_task_aggregation_next_already_started(
|
||||
mock_status: Mock, pipeline_aggregation: Pipeline
|
||||
) -> None:
|
||||
"""a, b -> c"""
|
||||
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
|
||||
node_a = pipeline_aggregation.get_node_by_name("a")
|
||||
mock_status.side_effect = [
|
||||
RunStatus.RUNNING, # status for "c" is already running, do not start it again
|
||||
]
|
||||
next_task_names = [n.name async for n in orchestrator.next(node_a)]
|
||||
assert next_task_names == []
|
||||
@@ -0,0 +1,607 @@
|
||||
# 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 __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import tempfile
|
||||
from typing import Sized
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
from neo4j_graphrag.experimental.pipeline import Component, Pipeline
|
||||
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
|
||||
from neo4j_graphrag.experimental.pipeline.notification import (
|
||||
EventCallbackProtocol,
|
||||
EventType,
|
||||
PipelineEvent,
|
||||
TaskEvent,
|
||||
Event,
|
||||
)
|
||||
from neo4j_graphrag.experimental.pipeline.types.orchestration import RunResult
|
||||
|
||||
from .components import (
|
||||
ComponentAdd,
|
||||
ComponentMultiply,
|
||||
ComponentNoParam,
|
||||
ComponentPassThrough,
|
||||
StringResultModel,
|
||||
SlowComponentMultiply,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_pipeline_two_components() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentNoParam()
|
||||
pipe.add_component(
|
||||
component_a,
|
||||
"a",
|
||||
)
|
||||
pipe.add_component(
|
||||
component_b,
|
||||
"b",
|
||||
)
|
||||
pipe.connect("a", "b", {})
|
||||
with mock.patch(
|
||||
"tests.unit.experimental.pipeline.test_pipeline.ComponentNoParam.run"
|
||||
) as mock_run:
|
||||
mock_run.side_effect = [
|
||||
StringResultModel(result="1"),
|
||||
StringResultModel(result="2"),
|
||||
]
|
||||
res = await pipe.run({})
|
||||
mock_run.assert_awaited_with(**{})
|
||||
mock_run.assert_awaited_with(**{})
|
||||
assert "b" in res.result
|
||||
assert res.result["b"] == {"result": "2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_parameter_propagation() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
# first component output product goes to second component input number1
|
||||
pipe.connect("a", "b", {"value": "a.result"})
|
||||
with mock.patch(
|
||||
"tests.unit.experimental.pipeline.test_pipeline.ComponentPassThrough.run"
|
||||
) as mock_run:
|
||||
mock_run.side_effect = [
|
||||
StringResultModel(result="1"),
|
||||
StringResultModel(result="2"),
|
||||
]
|
||||
res = await pipe.run({"a": {"value": "text"}})
|
||||
mock_run.assert_has_awaits([call(**{"value": "text"}), call(**{"value": "1"})])
|
||||
assert res.result == {"b": {"result": "2"}}
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_no_expected_params() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
pipe.add_component(component_a, "a")
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
|
||||
assert is_valid is True
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_one_component_all_good() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
|
||||
assert is_valid is True
|
||||
|
||||
|
||||
def test_pipeline_invalidate() -> None:
|
||||
pipe = Pipeline()
|
||||
pipe.is_validated = True
|
||||
pipe.param_mapping = {"a": {"key": {"component": "component", "param": "param"}}}
|
||||
pipe.missing_inputs = {"a": ["other_key"]}
|
||||
pipe.invalidate()
|
||||
assert pipe.is_validated is False
|
||||
assert len(pipe.param_mapping) == 0
|
||||
assert len(pipe.missing_inputs) == 0
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_called_twice() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"value": "a.result"})
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert is_valid is True
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
pipe.invalidate()
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert is_valid is True
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_one_component_input_param_missing() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
|
||||
assert pipe.missing_inputs["a"] == ["value"]
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_param_mapped_twice() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
component_b = ComponentPassThrough()
|
||||
component_c = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.add_component(component_c, "c")
|
||||
pipe.connect("a", "c", {"value": "a.result"})
|
||||
pipe.connect("b", "c", {"value": "b.result"})
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("c"))
|
||||
assert (
|
||||
"Parameter 'value' already mapped to {'component': 'a', 'param': 'result'}"
|
||||
in str(excinfo)
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_unexpected_input() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentPassThrough()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"unexpected_input_name": "a.result"})
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert (
|
||||
"Parameter 'unexpected_input_name' is not a valid input for component 'b' of type 'ComponentPassThrough'"
|
||||
in str(excinfo)
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_connected_components_input() -> None:
|
||||
"""Parameter for component 'b' comes from the pipeline inputs"""
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert is_valid is True
|
||||
assert dict(pipe.missing_inputs) == {"b": ["value"]}
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_connected_components_result() -> None:
|
||||
"""Parameter for component 'b' comes from the result of component 'a'"""
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"value": "b.result"})
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert is_valid is True
|
||||
assert pipe.missing_inputs == {"b": []}
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_connected_components_missing_input() -> None:
|
||||
"""Parameter for component 'b' is missing"""
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
|
||||
assert is_valid is True
|
||||
assert pipe.missing_inputs["b"] == ["value"]
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_full_missing_inputs_in_user_data() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
is_valid = pipe.validate_input_data(data={"b": {"value": "input for b"}})
|
||||
assert is_valid is True
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_full_missing_inputs_in_component_name() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
pipe.validate_input_data(data={"b": {}})
|
||||
|
||||
|
||||
def test_pipeline_parameter_validation_full_missing_inputs() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentPassThrough()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
pipe.validate_input_data(data={})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_branches() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = AsyncMock(spec=Component)
|
||||
component_a.run_with_context = AsyncMock(return_value={})
|
||||
component_b = AsyncMock(spec=Component)
|
||||
component_b.run_with_context = AsyncMock(return_value={})
|
||||
component_c = AsyncMock(spec=Component)
|
||||
component_c.run_with_context = AsyncMock(return_value={})
|
||||
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.add_component(component_c, "c")
|
||||
pipe.connect("a", "b")
|
||||
pipe.connect("a", "c")
|
||||
pipeline_result = await pipe.run({})
|
||||
res = pipeline_result.result
|
||||
assert "b" in res
|
||||
assert "c" in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_aggregation() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = AsyncMock(spec=Component)
|
||||
component_a.run_with_context = AsyncMock(return_value={})
|
||||
component_b = AsyncMock(spec=Component)
|
||||
component_b.run_with_context = AsyncMock(return_value={})
|
||||
component_c = AsyncMock(spec=Component)
|
||||
component_c.run_with_context = AsyncMock(return_value={})
|
||||
|
||||
pipe.add_component(
|
||||
component_a,
|
||||
"a",
|
||||
)
|
||||
pipe.add_component(
|
||||
component_b,
|
||||
"b",
|
||||
)
|
||||
pipe.add_component(component_c, "c")
|
||||
pipe.connect("a", "c")
|
||||
pipe.connect("b", "c")
|
||||
pipeline_result = await pipe.run({})
|
||||
res = pipeline_result.result
|
||||
assert "c" in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_missing_param_on_init() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentAdd()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
await pipe.run({"a": {"number1": 1}})
|
||||
assert (
|
||||
"Missing input parameters for a: Expected parameters: ['number1', 'number2']. Got: ['number1']"
|
||||
in str(excinfo.value)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_missing_param_on_connect() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentAdd()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
await pipe.run({"a": {"number1": 1, "number2": 2}})
|
||||
assert (
|
||||
"Missing input parameters for b: Expected parameters: ['number1', 'number2']. Got: ['number1']"
|
||||
in str(excinfo.value)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_with_default_params() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentMultiply()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
pipeline_result = await pipe.run({"a": {"number1": 1, "number2": 2}})
|
||||
res = pipeline_result.result
|
||||
assert res == {"b": {"result": 6}} # (1+2)*2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_cycle() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentNoParam()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {})
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
pipe.connect("b", "a", {})
|
||||
assert "Cycles are not allowed" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_wrong_component_name() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentNoParam()
|
||||
component_b = ComponentNoParam()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
with pytest.raises(PipelineDefinitionError) as excinfo:
|
||||
pipe.connect("a", "c", {})
|
||||
assert "a or c not in the Pipeline" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_async() -> None:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentAdd(), "add")
|
||||
run_params = [[1, 20], [10, 2]]
|
||||
runs = []
|
||||
for a, b in run_params:
|
||||
runs.append(pipe.run({"add": {"number1": a, "number2": b}}))
|
||||
pipeline_result = await asyncio.gather(*runs)
|
||||
assert len(pipeline_result) == 2
|
||||
assert pipeline_result[0].run_id != pipeline_result[1].run_id
|
||||
assert pipeline_result[0].result == {"add": {"result": 21}}
|
||||
assert pipeline_result[1].result == {"add": {"result": 12}}
|
||||
|
||||
|
||||
def test_pipeline_to_viz() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentMultiply()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
g = pipe._get_neo4j_viz_graph()
|
||||
# 3 nodes:
|
||||
# - 2 components 'a' and 'b'
|
||||
# - 1 output 'a.result'
|
||||
assert len(g.nodes) == 3
|
||||
g = pipe._get_neo4j_viz_graph(hide_unused_outputs=False)
|
||||
# 4 nodes:
|
||||
# - 2 components 'a' and 'b'
|
||||
# - 2 output 'a.result' and 'b.result'
|
||||
assert len(g.nodes) == 4
|
||||
|
||||
|
||||
def test_pipeline_draw() -> None:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentAdd(), "add")
|
||||
t = tempfile.NamedTemporaryFile(suffix=".html")
|
||||
pipe.draw(t.name)
|
||||
content = t.file.read()
|
||||
assert len(content) > 0
|
||||
|
||||
|
||||
@patch("neo4j_graphrag.experimental.pipeline.pipeline.neo4j_viz_available", False)
|
||||
def test_pipeline_draw_missing_neo4j_viz_dep() -> None:
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(ComponentAdd(), "add")
|
||||
t = tempfile.NamedTemporaryFile(suffix=".html")
|
||||
with pytest.raises(ImportError):
|
||||
pipe.draw(t.name)
|
||||
|
||||
|
||||
def test_run_result_no_warning(recwarn: Sized) -> None:
|
||||
RunResult()
|
||||
assert len(recwarn) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_event_notification() -> None:
|
||||
callback = AsyncMock(spec=EventCallbackProtocol)
|
||||
pipe = Pipeline(callback=callback)
|
||||
component_a = ComponentMultiply()
|
||||
pipe.add_component(
|
||||
component_a,
|
||||
"a",
|
||||
)
|
||||
a_input_data = {"number1": 2, "number2": 3}
|
||||
pipeline_result = await pipe.run({"a": a_input_data})
|
||||
|
||||
await_calls = callback.await_args_list
|
||||
|
||||
expected_event_list = [
|
||||
PipelineEvent(
|
||||
event_type=EventType.PIPELINE_STARTED,
|
||||
run_id=pipeline_result.run_id,
|
||||
timestamp=datetime.datetime.now(),
|
||||
message=None,
|
||||
payload={"a": a_input_data},
|
||||
),
|
||||
TaskEvent(
|
||||
event_type=EventType.TASK_STARTED,
|
||||
run_id=pipeline_result.run_id,
|
||||
task_name="a",
|
||||
timestamp=datetime.datetime.now(),
|
||||
message=None,
|
||||
payload=a_input_data,
|
||||
),
|
||||
TaskEvent(
|
||||
event_type=EventType.TASK_FINISHED,
|
||||
run_id=pipeline_result.run_id,
|
||||
task_name="a",
|
||||
timestamp=datetime.datetime.now(),
|
||||
message=None,
|
||||
payload={"result": 6},
|
||||
),
|
||||
PipelineEvent(
|
||||
event_type=EventType.PIPELINE_FINISHED,
|
||||
run_id=pipeline_result.run_id,
|
||||
timestamp=datetime.datetime.now(),
|
||||
message=None,
|
||||
payload={"a": {"result": 6}},
|
||||
),
|
||||
]
|
||||
assert len(await_calls) == len(expected_event_list)
|
||||
|
||||
previous_ts = None
|
||||
for await_call, expected_event in zip(await_calls, expected_event_list):
|
||||
actual_event = await_call[0][0]
|
||||
assert isinstance(actual_event, type(expected_event))
|
||||
assert actual_event.event_type == expected_event.event_type
|
||||
assert actual_event.run_id == expected_event.run_id
|
||||
assert actual_event.message == expected_event.message
|
||||
assert actual_event.payload == expected_event.payload
|
||||
if previous_ts:
|
||||
assert actual_event.timestamp > previous_ts
|
||||
previous_ts = actual_event.timestamp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_event_notification_error_in_pipeline_run() -> None:
|
||||
callback = AsyncMock(spec=EventCallbackProtocol)
|
||||
pipe = Pipeline(callback=callback)
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentAdd()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
await pipe.run({"a": {"number1": 1, "number2": 2}})
|
||||
assert len(callback.await_args_list) == 2
|
||||
assert callback.await_args_list[0][0][0].event_type == EventType.PIPELINE_STARTED
|
||||
assert callback.await_args_list[1][0][0].event_type == EventType.PIPELINE_FAILED
|
||||
|
||||
|
||||
def test_event_model_no_warning(recwarn: Sized) -> None:
|
||||
event = Event(
|
||||
event_type=EventType.PIPELINE_STARTED,
|
||||
run_id="run_id",
|
||||
message=None,
|
||||
payload=None,
|
||||
)
|
||||
assert event.timestamp is not None
|
||||
assert len(recwarn) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_no_user_callback_happy_path() -> None:
|
||||
pipe = Pipeline()
|
||||
events = []
|
||||
async for e in pipe.stream({}):
|
||||
events.append(e)
|
||||
assert len(events) == 2
|
||||
assert events[0].event_type == EventType.PIPELINE_STARTED
|
||||
assert events[1].event_type == EventType.PIPELINE_FINISHED
|
||||
assert len(pipe.event_notifier.callbacks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_with_user_callback_happy_path() -> None:
|
||||
callback = AsyncMock()
|
||||
pipe = Pipeline(callback=callback)
|
||||
events = []
|
||||
async for e in pipe.stream({}):
|
||||
events.append(e)
|
||||
assert len(events) == 2
|
||||
assert len(callback.call_args_list) == 2
|
||||
assert len(pipe.event_notifier.callbacks) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_very_long_running_user_callback() -> None:
|
||||
async def callback(event: Event) -> None:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
pipe = Pipeline(callback=callback)
|
||||
events = []
|
||||
async for e in pipe.stream({}):
|
||||
events.append(e)
|
||||
assert len(events) == 2
|
||||
assert len(pipe.event_notifier.callbacks) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_very_long_running_pipeline() -> None:
|
||||
slow_component = SlowComponentMultiply()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(slow_component, "slow_component")
|
||||
events = []
|
||||
async for e in pipe.stream({"slow_component": {"number1": 1, "number2": 2}}):
|
||||
events.append(e)
|
||||
assert len(events) == 4
|
||||
last_event = events[-1]
|
||||
assert last_event.event_type == EventType.PIPELINE_FINISHED
|
||||
assert last_event.payload == {"slow_component": {"result": 2}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_error_in_pipeline_definition() -> None:
|
||||
pipe = Pipeline()
|
||||
component_a = ComponentAdd()
|
||||
component_b = ComponentAdd()
|
||||
pipe.add_component(component_a, "a")
|
||||
pipe.add_component(component_b, "b")
|
||||
pipe.connect("a", "b", {"number1": "a.result"})
|
||||
events = []
|
||||
with pytest.raises(PipelineDefinitionError):
|
||||
async for e in pipe.stream({"a": {"number1": 1, "number2": 2}}):
|
||||
events.append(e)
|
||||
assert len(events) == 2
|
||||
assert events[0].event_type == EventType.PIPELINE_STARTED
|
||||
assert events[1].event_type == EventType.PIPELINE_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_error_in_component() -> None:
|
||||
component = ComponentMultiply()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(component, "component")
|
||||
events = []
|
||||
with pytest.raises(TypeError):
|
||||
async for e in pipe.stream({"component": {"number1": None, "number2": 2}}):
|
||||
events.append(e)
|
||||
assert len(events) == 3
|
||||
assert events[0].event_type == EventType.PIPELINE_STARTED
|
||||
assert events[1].event_type == EventType.TASK_STARTED
|
||||
assert events[2].event_type == EventType.PIPELINE_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_streaming_error_in_user_callback() -> None:
|
||||
async def callback(event: Event) -> None:
|
||||
raise Exception("error in callback")
|
||||
|
||||
pipe = Pipeline(callback=callback)
|
||||
events = []
|
||||
async for e in pipe.stream({}):
|
||||
events.append(e)
|
||||
assert len(events) == 2
|
||||
assert len(pipe.event_notifier.callbacks) == 1
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
import pytest
|
||||
from neo4j_graphrag.experimental.pipeline.pipeline_graph import (
|
||||
PipelineEdge,
|
||||
PipelineGraph,
|
||||
PipelineNode,
|
||||
)
|
||||
|
||||
|
||||
def test_node_alone() -> None:
|
||||
n = PipelineNode(name="node", data={})
|
||||
assert n.is_root() is True
|
||||
assert n.is_leaf() is True
|
||||
|
||||
|
||||
def test_node_not_root() -> None:
|
||||
n = PipelineNode(name="node", data={})
|
||||
n.parents = ["other_node"]
|
||||
assert n.is_root() is False
|
||||
assert n.is_leaf() is True
|
||||
|
||||
|
||||
def test_node_not_leaf() -> None:
|
||||
n = PipelineNode(name="node", data={})
|
||||
n.children = ["other_node"]
|
||||
assert n.is_root() is True
|
||||
assert n.is_leaf() is False
|
||||
|
||||
|
||||
def test_graph_add_nodes() -> None:
|
||||
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
|
||||
n1 = PipelineNode("n1", {})
|
||||
n2 = PipelineNode("n2", {})
|
||||
g.add_node(n1)
|
||||
g.add_node(n2)
|
||||
assert len(g._nodes) == 2
|
||||
edge = PipelineEdge(n1.name, n2.name, {"key": "value"})
|
||||
g.add_edge(edge)
|
||||
assert len(g._edges) == 1
|
||||
|
||||
assert n1.children == [n2.name]
|
||||
assert n2.parents == [n1.name]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def graph() -> PipelineGraph[PipelineNode, PipelineEdge]:
|
||||
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
|
||||
n1 = PipelineNode("n1", {})
|
||||
n2 = PipelineNode("n2", {})
|
||||
g.add_node(n1)
|
||||
g.add_node(n2)
|
||||
edge = PipelineEdge(n1.name, n2.name, {"key": "value"})
|
||||
g.add_edge(edge)
|
||||
return g
|
||||
|
||||
|
||||
def test_graph_roots(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
roots = graph.roots()
|
||||
assert len(roots) == 1
|
||||
assert roots[0].name == "n1"
|
||||
|
||||
|
||||
def test_graph_next_edge(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
start = graph._nodes["n1"]
|
||||
next_edges = graph.next_edges(start.name)
|
||||
assert len(next_edges) == 1
|
||||
next_edge = next_edges[0]
|
||||
assert isinstance(next_edge, PipelineEdge)
|
||||
assert next_edge.start == "n1"
|
||||
assert next_edge.end == "n2"
|
||||
|
||||
|
||||
def test_graph_prev_edge(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
start = graph._nodes["n2"]
|
||||
next_edges = graph.previous_edges(start.name)
|
||||
assert len(next_edges) == 1
|
||||
next_edge = next_edges[0]
|
||||
assert isinstance(next_edge, PipelineEdge)
|
||||
assert next_edge.start == "n1"
|
||||
assert next_edge.end == "n2"
|
||||
|
||||
|
||||
def test_graph_contains(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
start = graph._nodes["n2"]
|
||||
assert start in graph
|
||||
|
||||
|
||||
def test_graph_is_cyclic(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
|
||||
n1 = PipelineNode("n1", {})
|
||||
n2 = PipelineNode("n2", {})
|
||||
g.add_node(n1)
|
||||
g.add_node(n2)
|
||||
edge = PipelineEdge(n1.name, n2.name, {})
|
||||
g.add_edge(edge)
|
||||
assert g.is_cyclic() is False
|
||||
|
||||
edge = PipelineEdge(n2.name, n1.name, {})
|
||||
g.add_edge(edge)
|
||||
assert g.is_cyclic() is True
|
||||
|
||||
|
||||
def test_graph_set_node(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
|
||||
new_node = PipelineNode("n1", {})
|
||||
graph.set_node(new_node)
|
||||
new_node_from_graph = graph.get_node_by_name("n1")
|
||||
assert new_node_from_graph.parents == []
|
||||
assert new_node_from_graph.children == ["n2"]
|
||||
|
||||
|
||||
def test_graph_validate_edge_bad_node_name(
|
||||
graph: PipelineGraph[PipelineNode, PipelineEdge],
|
||||
) -> None:
|
||||
with pytest.raises(KeyError):
|
||||
graph.add_edge(PipelineEdge("n0", "n1", {}))
|
||||
with pytest.raises(KeyError):
|
||||
graph.add_edge(PipelineEdge("n1", "n12", {}))
|
||||
|
||||
|
||||
def test_graph_validate_edge_no_parallel_edges(
|
||||
graph: PipelineGraph[PipelineNode, PipelineEdge],
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
graph.add_edge(PipelineEdge("n1", "n2", {}))
|
||||
@@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
from neo4j_graphrag.experimental.pipeline.stores import InMemoryStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_store() -> None:
|
||||
store = InMemoryStore()
|
||||
await store.add("key", "value")
|
||||
res = await store.get("key")
|
||||
assert res == "value"
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
await store.add("key", "value", overwrite=False)
|
||||
|
||||
assert store.all() == {"key": "value"}
|
||||
Reference in New Issue
Block a user