chore: import upstream snapshot with attribution
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+385
View File
@@ -0,0 +1,385 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from typing import Annotated, Any
from unittest.mock import MagicMock
import pytest
from haystack import component
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.human_in_the_loop import (
AlwaysAskPolicy,
BlockingConfirmationStrategy,
ConfirmationHook,
ConfirmationUIResult,
NeverAskPolicy,
SimpleConsoleUI,
)
from haystack.human_in_the_loop.types import ConfirmationStrategy, ConfirmationUI
from haystack.tools import Tool, Toolset, create_tool_from_function
class MockUserInterface(ConfirmationUI):
def __init__(self, ui_result: ConfirmationUIResult) -> None:
self.ui_result = ui_result
def get_user_confirmation(
self, tool_name: str, tool_description: str, tool_params: dict[str, Any]
) -> ConfirmationUIResult:
return self.ui_result
def addition_tool(a: int, b: int) -> int:
return a + b
@pytest.fixture
def tools() -> list[Tool]:
tool = create_tool_from_function(
function=addition_tool, name="addition_tool", description="A tool that adds two integers together."
)
return [tool]
@pytest.fixture
def confirmation_strategies() -> dict[str, ConfirmationStrategy]:
return {
"addition_tool": BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
}
@pytest.fixture
def confirmation_hook(confirmation_strategies) -> ConfirmationHook:
return ConfirmationHook(confirmation_strategies=confirmation_strategies)
class TestAgent:
def test_confirmation_hook_accepted_on_before_tool(self, tools, confirmation_hook):
agent = Agent(chat_generator=MockChatGenerator(), tools=tools, hooks={"before_tool": [confirmation_hook]})
assert agent.hooks["before_tool"] == [confirmation_hook]
@pytest.mark.parametrize("bad_point", ["before_llm", "on_exit"])
def test_confirmation_hook_rejected_on_wrong_hook_point(self, tools, confirmation_hook, bad_point):
with pytest.raises(ValueError, match="only supports"):
Agent(chat_generator=MockChatGenerator(), tools=tools, hooks={bad_point: [confirmation_hook]})
def test_to_dict(self, tools, confirmation_hook, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=tools, hooks={"before_tool": [confirmation_hook]})
agent_dict = agent.to_dict()
assert agent_dict == {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-5-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "addition_tool",
"description": "A tool that adds two integers together.",
"parameters": {
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
"type": "object",
},
"function": "test_agent_hitl.addition_tool",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
],
"system_prompt": None,
"exit_conditions": ["text"],
"state_schema": {},
"max_agent_steps": 100,
"streaming_callback": None,
"raise_on_tool_invocation_failure": False,
"tool_concurrency_limit": 4,
"tool_streaming_callback_passthrough": False,
"required_variables": None,
"user_prompt": None,
"hooks": {
"before_tool": [
{
"type": "haystack.human_in_the_loop.hooks.ConfirmationHook",
"init_parameters": {
"confirmation_strategies": {
"addition_tool": {
"type": "haystack.human_in_the_loop.strategies.BlockingConfirmationStrategy",
"init_parameters": {
"confirmation_policy": {
"type": "haystack.human_in_the_loop.policies.NeverAskPolicy",
"init_parameters": {},
},
"confirmation_ui": {
"type": "haystack.human_in_the_loop.user_interfaces.SimpleConsoleUI",
"init_parameters": {},
},
"reject_template": "Tool execution for '{tool_name}' was rejected by "
"the user.",
"modify_template": "The parameters for tool '{tool_name}' were updated "
"by the user to:\n{final_tool_params}",
"user_feedback_template": "With user feedback: {feedback}",
},
}
}
},
}
]
},
},
}
def test_from_dict(self, tools, confirmation_hook, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=tools, hooks={"before_tool": [confirmation_hook]})
deserialized_agent = Agent.from_dict(agent.to_dict())
assert deserialized_agent.to_dict() == agent.to_dict()
assert isinstance(deserialized_agent.chat_generator, OpenAIChatGenerator)
assert len(deserialized_agent.tools) == 1
assert deserialized_agent.tools[0].name == "addition_tool"
assert deserialized_agent.tool_concurrency_limit == agent.tool_concurrency_limit
assert deserialized_agent.tool_streaming_callback_passthrough == agent.tool_streaming_callback_passthrough
hook = deserialized_agent.hooks["before_tool"][0]
assert isinstance(hook, ConfirmationHook)
assert isinstance(hook.confirmation_strategies["addition_tool"], BlockingConfirmationStrategy)
assert isinstance(hook.confirmation_strategies["addition_tool"].confirmation_policy, NeverAskPolicy)
assert isinstance(hook.confirmation_strategies["addition_tool"].confirmation_ui, SimpleConsoleUI)
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
@pytest.mark.integration
def test_run_blocking_confirmation_strategy_modify(self, tools):
confirmation_hook = ConfirmationHook(
confirmation_strategies={
"addition_tool": BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(),
confirmation_ui=MockUserInterface(
ConfirmationUIResult(action="modify", new_tool_params={"a": 2, "b": 3})
),
)
}
)
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
tools=tools,
hooks={"before_tool": [confirmation_hook]},
)
result = agent.run([ChatMessage.from_user("What is 2+2?")])
assert isinstance(result["last_message"], ChatMessage)
assert result["last_message"].text is not None
assert "5" in result["last_message"].text
# Auto-populated run-metadata outputs: at least one tool call plus a final answer.
assert result["step_count"] >= 2
assert result["tool_call_counts"]["addition_tool"] >= 1
assert result["token_usage"]["prompt_tokens"] > 0
assert result["token_usage"]["completion_tokens"] > 0
assert result["token_usage"]["total_tokens"] > 0
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_run_async_blocking_confirmation_strategy_modify(self, tools):
confirmation_hook = ConfirmationHook(
confirmation_strategies={
"addition_tool": BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(),
confirmation_ui=MockUserInterface(
ConfirmationUIResult(action="modify", new_tool_params={"a": 2, "b": 3})
),
)
}
)
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
tools=tools,
hooks={"before_tool": [confirmation_hook]},
)
result = await agent.run_async([ChatMessage.from_user("What is 2+2?")])
assert isinstance(result["last_message"], ChatMessage)
assert result["last_message"].text is not None
assert "5" in result["last_message"].text
# Auto-populated run-metadata outputs: at least one tool call plus a final answer.
assert result["step_count"] >= 2
assert result["tool_call_counts"]["addition_tool"] >= 1
assert result["token_usage"]["prompt_tokens"] > 0
assert result["token_usage"]["completion_tokens"] > 0
assert result["token_usage"]["total_tokens"] > 0
@component
class MockChatGenerator:
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
def _producer() -> dict[str, str]:
return {"value": "PRODUCED"}
def _consumer(value: Annotated[str, "the shared value"]) -> str:
return f"consumed:{value}"
# `producer` overwrites the `shared` state key; `consumer` reads it via inputs_from_state. Called together in one
# step, the batched executor must run producer first so consumer sees the fresh value.
producer_tool = Tool(
name="producer",
description="Produce a value into state.",
parameters={"type": "object", "properties": {}, "required": []},
function=_producer,
outputs_to_state={"shared": {"source": "value"}},
)
consumer_tool = Tool(
name="consumer",
description="Consume the shared value.",
parameters={"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]},
function=_consumer,
inputs_from_state={"shared": "value"},
)
class TestConfirmationStrategyToolArgPrep:
def test_confirmed_dependent_tool_runs_with_fresh_state(self):
"""
When a confirmation strategy is configured, a tool that reads State a same-step tool writes must still run
with the freshly-produced value, not the stale step-start value.
"""
confirmation_hook = ConfirmationHook(
confirmation_strategies={
"consumer": BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
}
)
agent = Agent(
chat_generator=MockChatGenerator(),
tools=[producer_tool, consumer_tool],
state_schema={"shared": {"type": str}},
hooks={"before_tool": [confirmation_hook]},
)
agent.warm_up()
# Step 1: the model calls producer and consumer together (consumer relies on inputs_from_state for `value`).
# Step 2: a plain text reply ends the run.
agent.chat_generator.run = MagicMock(
side_effect=[
{
"replies": [
ChatMessage.from_assistant(tool_calls=[ToolCall("producer", {}), ToolCall("consumer", {})])
]
},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
# `shared` starts stale; producer overwrites it to "PRODUCED" before consumer runs.
result = agent.run(messages=[ChatMessage.from_user("go")], shared="OLD")
consumer_results = [
m.tool_call_result.result
for m in result["messages"]
if m.tool_call_result is not None and m.tool_call_result.origin.tool_name == "consumer"
]
assert consumer_results == ["consumed:PRODUCED"]
def _echo(x: Annotated[int, "the value to echo"]) -> dict[str, int]:
return {"echoed": x}
def _echo_tool(name: str) -> Tool:
return Tool(
name=name,
description=f"Echo tool {name}.",
parameters={"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]},
function=_echo,
)
def _single_tool_hook(tool_name: str, ui_result: ConfirmationUIResult) -> ConfirmationHook:
return ConfirmationHook(
confirmation_strategies={
tool_name: BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=MockUserInterface(ui_result)
)
}
)
class TestMultipleConfirmationHooks:
def test_multiple_hooks_each_targeting_a_different_tool(self):
"""
Three before_tool ConfirmationHooks, each owning a different tool of a three-call batch, compose correctly:
a tool a hook does not own passes through unchanged, so reject/modify decisions from all three hooks land on
the right calls in the final, re-read pending message.
"""
foo, bar, baz = _echo_tool("foo"), _echo_tool("bar"), _echo_tool("baz")
hooks = {
"before_tool": [
_single_tool_hook("foo", ConfirmationUIResult(action="reject")),
_single_tool_hook("bar", ConfirmationUIResult(action="modify", new_tool_params={"x": 99})),
_single_tool_hook("baz", ConfirmationUIResult(action="modify", new_tool_params={"x": 77})),
]
}
agent = Agent(chat_generator=MockChatGenerator(), tools=[foo, bar, baz], hooks=hooks)
agent.warm_up()
agent.chat_generator.run = MagicMock(
side_effect=[
{
"replies": [
ChatMessage.from_assistant(
tool_calls=[ToolCall("foo", {"x": 1}), ToolCall("bar", {"x": 2}), ToolCall("baz", {"x": 3})]
)
]
},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("go")])
# The full transcript, in order: foo is rejected (its call + an error tool result appear before the
# surviving batch), bar and baz are modified (each with an explanation message) and then executed.
assert result["messages"] == [
ChatMessage.from_user("go"),
ChatMessage.from_assistant(tool_calls=[ToolCall("foo", {"x": 1})]),
ChatMessage.from_tool(
tool_result="Tool execution for 'foo' was rejected by the user.",
origin=ToolCall("foo", {"x": 1}),
error=True,
),
ChatMessage.from_user("The parameters for tool 'bar' were updated by the user to:\n{'x': 99}"),
ChatMessage.from_user("The parameters for tool 'baz' were updated by the user to:\n{'x': 77}"),
ChatMessage.from_assistant(tool_calls=[ToolCall("bar", {"x": 99}), ToolCall("baz", {"x": 77})]),
ChatMessage.from_tool(tool_result='{"echoed": 99}', origin=ToolCall("bar", {"x": 99}), error=False),
ChatMessage.from_tool(tool_result='{"echoed": 77}', origin=ToolCall("baz", {"x": 77}), error=False),
ChatMessage.from_assistant("done"),
]
# Only the two confirmed tools actually executed.
assert result["tool_call_counts"] == {"foo": 0, "bar": 1, "baz": 1}
+504
View File
@@ -0,0 +1,504 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Annotated, Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from haystack import component
from haystack.components.agents import Agent
from haystack.components.agents.state import State, replace_values
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.hooks import hook
from haystack.tools import Tool, Toolset, tool
@component
class MockChatGenerator:
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
@tool
def save(content: Annotated[str, "what to save"]) -> str:
"""Save content."""
return "saved"
@tool
def final_answer(answer: Annotated[str, "the answer"]) -> str:
"""Provide the final answer."""
return answer
# --- Module-level hooks (importable so they can be serialized) ---
@hook
def record_a(state: State) -> None:
state.set("trace", ["a"])
@hook
def record_b(state: State) -> None:
state.set("trace", ["b"])
@hook
def build_context(state: State) -> None:
if state.get("step_count") == 0:
state.set("messages", [ChatMessage.from_system("INJECTED")])
@hook
def record_tool_calls(state: State) -> None:
# Realistic before_tool use case: audit the tool calls the model is about to run.
pending = state.data["messages"][-1].tool_calls
state.set("trace", [tc.tool_name for tc in pending])
@hook
def replace_pending_save_call(state: State) -> None:
messages = state.data["messages"]
replacement = ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "changed"})])
state.set("messages", [*messages[:-1], replacement], handler_override=replace_values)
@hook
def replace_pending_call_with_non_tool_message(state: State) -> None:
messages = state.data["messages"]
replacement = ChatMessage.from_assistant("Skipping tool execution.")
state.set("messages", [*messages[:-1], replacement], handler_override=replace_values)
@hook
def record_after_tool(state: State) -> None:
state.set("trace", ["after_tool"])
@hook
def offload_tool_results(state: State) -> None:
# Realistic after_tool use case: replace freshly produced tool results with a compact pointer, preserving origin.
rewritten = [
ChatMessage.from_tool(tool_result="OFFLOADED", origin=m.tool_call_result.origin, error=m.tool_call_result.error)
if m.tool_call_result is not None
else m
for m in state.data["messages"]
]
state.set("messages", rewritten, handler_override=replace_values)
@hook
def require_save(state: State) -> None:
if state.get("tool_call_counts", {}).get("save", 0) == 0:
state.set("messages", [ChatMessage.from_system("You must call save before finishing.")])
state.set("continue_run", True)
@hook
def always_continue(state: State) -> None:
state.set("messages", [ChatMessage.from_system("keep going")])
state.set("continue_run", True)
@hook
def critique(state: State) -> None:
# Push back on the first final answer to force one more loop.
if state.get("tool_call_counts", {}).get("final_answer", 0) < 2:
state.set("messages", [ChatMessage.from_user("Please expand.")])
state.set("continue_run", True)
class LifecycleHook:
"""A class-based hook that records its lifecycle calls (e.g. opening/closing a client)."""
def __init__(self) -> None:
self.warmed = 0
self.warmed_async = 0
self.closed = 0
self.closed_async = 0
def run(self, state: State) -> None:
pass
def warm_up(self) -> None:
self.warmed += 1
async def warm_up_async(self) -> None:
self.warmed_async += 1
def close(self) -> None:
self.closed += 1
async def close_async(self) -> None:
self.closed_async += 1
def _agent(generator, **kwargs):
agent = Agent(chat_generator=generator, **kwargs)
agent.warm_up()
return agent
class TestAgentHooksValidation:
def test_invalid_hook_point_raises(self):
with pytest.raises(ValueError):
Agent(chat_generator=MockChatGenerator(), hooks={"bogus": [record_a]})
def test_non_callable_object_raises(self):
with pytest.raises(TypeError, match="must have a callable 'run"):
Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [object()]})
def test_unwrapped_function_hints_at_hook_decorator(self):
def my_hook(state: State) -> None:
pass
with pytest.raises(TypeError, match="@hook decorator"):
Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [my_hook]})
def test_continue_run_is_a_reserved_state_schema_key(self):
with pytest.raises(ValueError):
Agent(chat_generator=MockChatGenerator(), state_schema={"continue_run": {"type": bool}})
def test_continue_run_is_not_exposed_as_output(self):
agent = _agent(MockChatGenerator())
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert "continue_run" not in result
class TestBeforeLlmHook:
def test_step_0_only_context_injected_once_across_loops(self):
agent = _agent(MockChatGenerator(), tools=[save], hooks={"before_llm": [build_context]})
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
# build_context only injects on step_count == 0, so it appears once despite the two-loop run
injected = [m for m in result["messages"] if m.is_from("system") and m.text == "INJECTED"]
assert len(injected) == 1
assert agent.chat_generator.run.call_count == 2
def test_hooks_run_in_list_order(self):
agent = _agent(
MockChatGenerator(), state_schema={"trace": {"type": list}}, hooks={"before_llm": [record_a, record_b]}
)
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert result["trace"] == ["a", "b"]
class TestBeforeToolHook:
def test_runs_before_tools_when_model_calls_a_tool(self):
agent = _agent(
MockChatGenerator(),
tools=[save],
state_schema={"trace": {"type": list}},
hooks={"before_tool": [record_tool_calls]},
)
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
# before_tool sees the pending tool call and does not fire on the final text-only step
assert result["trace"] == ["save"]
assert result["tool_call_counts"]["save"] == 1
def test_rereads_last_state_message_after_hook(self):
agent = _agent(MockChatGenerator(), tools=[save], hooks={"before_tool": [replace_pending_save_call]})
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "original"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
tool_messages = [m for m in result["messages"] if m.tool_call_result is not None]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_result.origin.arguments == {"content": "changed"}
def test_skips_tool_execution_when_hook_leaves_last_message_without_tool_calls(self):
agent = _agent(
MockChatGenerator(), tools=[save], hooks={"before_tool": [replace_pending_call_with_non_tool_message]}
)
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 2
assert result["tool_call_counts"]["save"] == 0
assert not [m for m in result["messages"] if m.tool_call_result is not None]
assert [m.text for m in result["messages"]][-2:] == ["Skipping tool execution.", "done"]
class TestAfterToolHook:
def test_runs_after_tools_and_not_on_text_only_exit(self):
agent = _agent(
MockChatGenerator(),
tools=[save],
state_schema={"trace": {"type": list}},
hooks={"after_tool": [record_after_tool]},
)
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
# Fires once for the single tool step; does not fire on the final text-only step (no tools ran there).
assert result["trace"] == ["after_tool"]
def test_does_not_run_when_no_tools_are_called(self):
agent = _agent(
MockChatGenerator(),
tools=[save],
state_schema={"trace": {"type": list}},
hooks={"after_tool": [record_after_tool]},
)
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert result.get("trace", []) == []
def test_rewrites_tool_results_before_next_llm_call(self):
agent = _agent(MockChatGenerator(), tools=[save], hooks={"after_tool": [offload_tool_results]})
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
# The rewritten result is what the next LLM call sees...
second_call_messages = agent.chat_generator.run.call_args_list[1].kwargs["messages"]
assert [m.tool_call_result.result for m in second_call_messages if m.tool_call_result is not None] == [
"OFFLOADED"
]
# ...and what ends up in the final conversation.
assert [m.tool_call_result.result for m in result["messages"] if m.tool_call_result is not None] == [
"OFFLOADED"
]
def test_rewriting_results_does_not_change_exit_matching(self):
# Exit conditions match on tool name + error, not result content, so offloading the result still exits.
agent = _agent(
MockChatGenerator(),
tools=[final_answer],
exit_conditions=["final_answer"],
hooks={"after_tool": [offload_tool_results]},
)
agent.chat_generator.run = MagicMock(
return_value={
"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("final_answer", {"answer": "42"})])]
}
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 1
assert [m.tool_call_result.result for m in result["messages"] if m.tool_call_result is not None] == [
"OFFLOADED"
]
class TestOnExitHook:
def test_mandatory_tool_forces_extra_step(self):
agent = _agent(MockChatGenerator(), tools=[save], hooks={"on_exit": [require_save]})
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant("Done")]}, # tries to exit without saving
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("All finished")]},
]
)
result = agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 3
assert result["tool_call_counts"]["save"] == 1
assert result["last_message"].text == "All finished"
def test_readonly_hook_does_not_change_exit(self):
fired = []
def record(state: State) -> None:
fired.append(1)
agent = _agent(MockChatGenerator(), tools=[save], hooks={"on_exit": [hook(record)]})
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("Final")]})
agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 1
assert fired == [1]
def test_critique_on_tool_based_exit(self):
agent = _agent(
MockChatGenerator(), tools=[final_answer], exit_conditions=["final_answer"], hooks={"on_exit": [critique]}
)
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("final_answer", {"answer": "short"})])]},
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("final_answer", {"answer": "longer"})])]},
]
)
result = agent.run(messages=[ChatMessage.from_user("q")])
# critique forces one extra loop before the final answer is accepted
assert agent.chat_generator.run.call_count == 2
assert result["tool_call_counts"]["final_answer"] == 2
def test_max_agent_steps_bounds_always_cancel_hook(self):
agent = _agent(MockChatGenerator(), max_agent_steps=3, hooks={"on_exit": [always_continue]})
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("text")]})
agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 3
def test_continue_run_from_before_llm_hook_does_not_force_continuation(self):
def leak_continue(state: State) -> None:
state.set("continue_run", True)
def audit(state: State) -> None:
pass
agent = _agent(
MockChatGenerator(),
max_agent_steps=5,
hooks={"before_llm": [hook(leak_continue)], "on_exit": [hook(audit)]},
)
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("Final")]})
agent.run(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run.call_count == 1
class TestHookReuseAcrossHookPoints:
def test_same_hook_under_two_hook_points(self):
counter = []
def count_call(state: State) -> None:
counter.append(1)
counting = hook(count_call)
agent = _agent(MockChatGenerator(), hooks={"before_llm": [counting], "on_exit": [counting]})
agent.chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
agent.run(messages=[ChatMessage.from_user("hi")])
# once for before_llm, once for on_exit (which appends nothing, so the run still exits)
assert len(counter) == 2
class TestAgentHooksSerde:
def test_to_dict_from_dict_roundtrip(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
from haystack.components.generators.chat import OpenAIChatGenerator
agent = Agent(
chat_generator=OpenAIChatGenerator(),
tools=[save],
hooks={"before_llm": [build_context], "on_exit": [require_save]},
)
restored = Agent.from_dict(agent.to_dict())
assert set(restored.hooks) == {"before_llm", "on_exit"}
assert restored.hooks["before_llm"][0].function is build_context.function
assert restored.hooks["on_exit"][0].function is require_save.function
class TestAgentHooksAsync:
@pytest.mark.asyncio
async def test_sync_hook_runs_in_async_run(self):
fired = []
def record(state: State) -> None:
fired.append(1)
agent = _agent(MockChatGenerator(), hooks={"before_llm": [hook(record)]})
agent.chat_generator.run_async = AsyncMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
await agent.run_async(messages=[ChatMessage.from_user("hi")])
assert fired == [1]
@pytest.mark.asyncio
async def test_after_tool_hook_rewrites_results(self):
agent = _agent(MockChatGenerator(), tools=[save], hooks={"after_tool": [offload_tool_results]})
agent.chat_generator.run_async = AsyncMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = await agent.run_async(messages=[ChatMessage.from_user("hi")])
assert [m.tool_call_result.result for m in result["messages"] if m.tool_call_result is not None] == [
"OFFLOADED"
]
@pytest.mark.asyncio
async def test_async_on_exit_hook_forces_extra_step(self):
async def require_save_async(state: State) -> None:
if state.get("tool_call_counts", {}).get("save", 0) == 0:
state.set("messages", [ChatMessage.from_system("call save first")])
state.set("continue_run", True)
agent = _agent(MockChatGenerator(), tools=[save], hooks={"on_exit": [hook(require_save_async)]})
agent.chat_generator.run_async = AsyncMock(
side_effect=[
{"replies": [ChatMessage.from_assistant("Done")]},
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("save", {"content": "x"})])]},
{"replies": [ChatMessage.from_assistant("All finished")]},
]
)
result = await agent.run_async(messages=[ChatMessage.from_user("hi")])
assert agent.chat_generator.run_async.call_count == 3
assert result["tool_call_counts"]["save"] == 1
class TestAgentHookLifecycle:
def test_init_does_not_warm_up(self):
h = LifecycleHook()
Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h]})
assert h.warmed == 0
def test_warm_up_warms_hooks_once(self):
h = LifecycleHook()
agent = Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h]})
agent.warm_up()
agent.warm_up()
assert h.warmed == 1
def test_reused_hook_warmed_once(self):
h = LifecycleHook()
agent = Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h], "on_exit": [h]})
agent.warm_up()
assert h.warmed == 1
def test_close_closes_hooks(self):
h = LifecycleHook()
agent = Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h]})
agent.close()
assert h.closed == 1
class TestAgentHookLifecycleAsync:
@pytest.mark.asyncio
async def test_warm_up_async_prefers_async(self):
h = LifecycleHook()
agent = Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h]})
await agent.warm_up_async()
assert h.warmed_async == 1
assert h.warmed == 0
@pytest.mark.asyncio
async def test_close_async_prefers_async(self):
h = LifecycleHook()
agent = Agent(chat_generator=MockChatGenerator(), hooks={"before_llm": [h]})
await agent.close_async()
assert h.closed_async == 1
assert h.closed == 0
+561
View File
@@ -0,0 +1,561 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
from dataclasses import dataclass
from typing import Dict, Generic, List, Optional, TypeVar, Union
import pytest
from haystack.components.agents.state.state import (
State,
_is_list_type,
_is_valid_type,
_schema_from_dict,
_schema_to_dict,
_validate_schema,
merge_lists,
)
from haystack.dataclasses import ChatMessage
@pytest.fixture
def basic_schema():
return {"numbers": {"type": list}, "metadata": {"type": dict}, "name": {"type": str}}
def numbers_handler(current, new):
if current is None:
return sorted(set(new))
return sorted(set(current + new))
@pytest.fixture
def complex_schema():
return {"numbers": {"type": list, "handler": numbers_handler}, "metadata": {"type": dict}, "name": {"type": str}}
def test_is_list_type():
assert _is_list_type(list) is True
assert _is_list_type(list[int]) is True
assert _is_list_type(list[str]) is True
assert _is_list_type(dict) is False
assert _is_list_type(int) is False
assert _is_list_type(Union[list[int], None]) is False
assert _is_list_type(list[int] | None) is False
class TestMergeLists:
def test_merge_two_lists(self):
current = [1, 2, 3]
new = [4, 5, 6]
result = merge_lists(current, new)
assert result == [1, 2, 3, 4, 5, 6]
# Ensure original lists weren't modified
assert current == [1, 2, 3]
assert new == [4, 5, 6]
def test_append_to_list(self):
current = [1, 2, 3]
new = 4
result = merge_lists(current, new)
assert result == [1, 2, 3, 4]
assert current == [1, 2, 3] # Ensure original wasn't modified
def test_create_new_list(self):
current = 1
new = 2
result = merge_lists(current, new)
assert result == [1, 2]
def test_replace_with_list(self):
current = 1
new = [2, 3]
result = merge_lists(current, new)
assert result == [1, 2, 3]
class TestIsValidType:
def test_builtin_types(self):
assert _is_valid_type(str) is True
assert _is_valid_type(int) is True
assert _is_valid_type(dict) is True
assert _is_valid_type(list) is True
assert _is_valid_type(tuple) is True
assert _is_valid_type(set) is True
assert _is_valid_type(bool) is True
assert _is_valid_type(float) is True
def test_generic_types(self):
assert _is_valid_type(list[str]) is True
assert _is_valid_type(List[str]) is True
assert _is_valid_type(dict[str, int]) is True
assert _is_valid_type(Dict[str, int]) is True
assert _is_valid_type(list[dict[str, int]]) is True
assert _is_valid_type(List[Dict[str, int]]) is True
assert _is_valid_type(dict[str, list[int]]) is True
assert _is_valid_type(Dict[str, List[int]]) is True
def test_custom_classes(self):
@dataclass
class CustomClass:
value: int
T = TypeVar("T")
class GenericCustomClass(Generic[T]):
pass
# Test regular and generic custom classes
assert _is_valid_type(CustomClass) is True
assert _is_valid_type(GenericCustomClass) is True
assert _is_valid_type(GenericCustomClass[int]) is True
# Test generic types with custom classes
assert _is_valid_type(list[CustomClass]) is True
assert _is_valid_type(List[CustomClass]) is True
assert _is_valid_type(dict[str, CustomClass]) is True
assert _is_valid_type(Dict[str, CustomClass]) is True
assert _is_valid_type(dict[str, GenericCustomClass[int]]) is True
assert _is_valid_type(Dict[str, GenericCustomClass[int]]) is True
def test_invalid_types(self):
# Test regular values
assert _is_valid_type(42) is False
assert _is_valid_type("string") is False
assert _is_valid_type([1, 2, 3]) is False
assert _is_valid_type({"a": 1}) is False
assert _is_valid_type(True) is False
# Test class instances
@dataclass
class SampleClass:
value: int
instance = SampleClass(42)
assert _is_valid_type(instance) is False
# Test callable objects
assert _is_valid_type(len) is False
assert _is_valid_type(lambda x: x) is False
assert _is_valid_type(print) is False
def test_union_and_optional_types(self):
# Test basic Union types
assert _is_valid_type(Union[str, int]) is True
assert _is_valid_type(Union[str, None]) is True
assert _is_valid_type(Union[list[int], dict[str, str]]) is True
# Test Optional types (which are Union[T, None])
assert _is_valid_type(Optional[str]) is True
assert _is_valid_type(Optional[list[int]]) is True
assert _is_valid_type(Optional[dict[str, list]]) is True
# Test that Union itself is not a valid type (only instantiated Unions are)
assert _is_valid_type(Union) is False
# Test PEP 604 union types (X | Y syntax)
assert _is_valid_type(str | int) is True
assert _is_valid_type(str | None) is True
assert _is_valid_type(list[int] | dict[str, str]) is True
# Test PEP 604 Optional-like types (X | None syntax)
assert _is_valid_type(list[int] | None) is True
assert _is_valid_type(dict[str, list] | None) is True
def test_nested_generic_types(self):
assert _is_valid_type(list[list[dict[str, list[int]]]]) is True
assert _is_valid_type(dict[str, list[dict[str, set]]]) is True
assert _is_valid_type(dict[str, Optional[list[int]]]) is True
assert _is_valid_type(list[Union[str, dict[str, list[int]]]]) is True
# PEP 604 nested types
assert _is_valid_type(dict[str, list[int] | None]) is True
assert _is_valid_type(list[str | dict[str, list[int]]]) is True
def test_edge_cases(self):
# Test None and NoneType
assert _is_valid_type(None) is False
assert _is_valid_type(type(None)) is True
# Test functions and methods
def sample_func():
pass
assert _is_valid_type(sample_func) is False
assert _is_valid_type(type(sample_func)) is True
# Test modules
assert _is_valid_type(inspect) is False
# Test type itself
assert _is_valid_type(type) is True
@pytest.mark.parametrize(
"test_input,expected",
[
(str, True),
(int, True),
(list[int], True),
(dict[str, int], True),
(List[int], True),
(Dict[str, int], True),
(Union[str, int], True),
(Optional[str], True),
# PEP 604 union types
(str | int, True),
(str | None, True),
(list[int] | None, True),
(42, False),
("string", False),
([1, 2, 3], False),
(lambda x: x, False),
],
)
def test_parametrized_cases(self, test_input, expected):
assert _is_valid_type(test_input) is expected
class TestState:
def test_validate_schema_valid(self, basic_schema):
# Should not raise any exceptions
_validate_schema(basic_schema)
def test_validate_schema_invalid_type(self):
invalid_schema = {"test": {"type": "not_a_type"}}
with pytest.raises(ValueError, match="must be a Python type"):
_validate_schema(invalid_schema)
def test_validate_schema_missing_type(self):
invalid_schema = {"test": {"handler": lambda x, y: x + y}}
with pytest.raises(ValueError, match="missing a 'type' entry"):
_validate_schema(invalid_schema)
def test_validate_schema_invalid_handler(self):
invalid_schema = {"test": {"type": list, "handler": "not_callable"}}
with pytest.raises(ValueError, match="must be callable or None"):
_validate_schema(invalid_schema)
def test_validate_schema_with_messages(self):
class ChatMessageSubclass(ChatMessage):
pass
schema_with_messages = {"messages": {"type": List[ChatMessage]}}
_validate_schema(schema_with_messages)
schema_with_messages_subclass = {"messages": {"type": List[ChatMessageSubclass]}}
_validate_schema(schema_with_messages_subclass)
def test_state_initialization(self, basic_schema):
# Test empty initialization
state = State(basic_schema)
assert state.data == {}
# Test initialization with data
initial_data = {"numbers": [1, 2, 3], "name": "test"}
state = State(basic_schema, initial_data)
assert state.data["numbers"] == [1, 2, 3]
assert state.data["name"] == "test"
def test_state_get(self, basic_schema):
state = State(basic_schema, {"name": "test"})
assert state.get("name") == "test"
assert state.get("non_existent") is None
assert state.get("non_existent", "default") == "default"
def test_state_set_basic(self, basic_schema):
state = State(basic_schema)
# Test setting new values
state.set("numbers", [1, 2])
assert state.get("numbers") == [1, 2]
# Test updating existing values
state.set("numbers", [3, 4])
assert state.get("numbers") == [1, 2, 3, 4]
def test_state_set_with_handler(self, complex_schema):
state = State(complex_schema)
# Test custom handler for numbers
state.set("numbers", [3, 2, 1])
assert state.get("numbers") == [1, 2, 3]
state.set("numbers", [6, 5, 4])
assert state.get("numbers") == [1, 2, 3, 4, 5, 6]
def test_state_set_with_handler_override(self, basic_schema):
state = State(basic_schema)
# Custom handler that concatenates strings
custom_handler = lambda current, new: f"{current}-{new}" if current else new
state.set("name", "first")
state.set("name", "second", handler_override=custom_handler)
assert state.get("name") == "first-second"
def test_state_has(self, basic_schema):
state = State(basic_schema, {"name": "test"})
assert state.has("name") is True
assert state.has("non_existent") is False
def test_state_empty_schema(self):
state = State({})
assert state.data == {}
# Instead of comparing the entire schema directly, check structure separately
assert "messages" in state.schema
assert state.schema["messages"]["type"] == list[ChatMessage]
assert callable(state.schema["messages"]["handler"])
with pytest.raises(ValueError, match="Key 'any_key' not found in schema"):
state.set("any_key", "value")
def test_state_none_values(self, basic_schema):
state = State(basic_schema)
state.set("name", None)
assert state.get("name") is None
state.set("name", "value")
assert state.get("name") == "value"
def test_state_merge_lists(self, basic_schema):
state = State(basic_schema)
state.set("numbers", "not_a_list")
assert state.get("numbers") == ["not_a_list"]
state.set("numbers", [1, 2])
assert state.get("numbers") == ["not_a_list", 1, 2]
def test_state_nested_structures(self):
schema = {
"complex": {
"type": dict[str, list[int]],
"handler": lambda current, new: (
{k: current.get(k, []) + new.get(k, []) for k in set(current.keys()) | set(new.keys())}
if current
else new
),
}
}
state = State(schema)
state.set("complex", {"a": [1, 2], "b": [3, 4]})
state.set("complex", {"b": [5, 6], "c": [7, 8]})
expected = {"a": [1, 2], "b": [3, 4, 5, 6], "c": [7, 8]}
assert state.get("complex") == expected
def test_schema_to_dict(self, basic_schema):
expected_dict = {"numbers": {"type": "list"}, "metadata": {"type": "dict"}, "name": {"type": "str"}}
result = _schema_to_dict(basic_schema)
assert result == expected_dict
def test_schema_to_dict_with_handlers(self, complex_schema):
expected_dict = {
"numbers": {"type": "list", "handler": "test_state_class.numbers_handler"},
"metadata": {"type": "dict"},
"name": {"type": "str"},
}
result = _schema_to_dict(complex_schema)
assert result == expected_dict
def test_schema_from_dict(self, basic_schema):
schema_dict = {"numbers": {"type": "list"}, "metadata": {"type": "dict"}, "name": {"type": "str"}}
result = _schema_from_dict(schema_dict)
assert result == basic_schema
def test_schema_from_dict_with_handlers(self, complex_schema):
schema_dict = {
"numbers": {"type": "list", "handler": "test_state_class.numbers_handler"},
"metadata": {"type": "dict"},
"name": {"type": "str"},
}
result = _schema_from_dict(schema_dict)
assert result == complex_schema
def test_state_mutability(self):
state = State({"my_list": {"type": list}}, {"my_list": [1, 2]})
my_list = state.get("my_list")
my_list.append(3)
assert state.get("my_list") == [1, 2]
def test_state_to_dict(self):
# we test dict, a python type and a haystack dataclass
state_schema = {
"numbers": {"type": int},
"messages": {"type": list[ChatMessage]},
"dict_of_lists": {"type": dict},
}
data = {
"numbers": 1,
"messages": [ChatMessage.from_user(text="Hello, world!")],
"dict_of_lists": {"numbers": [1, 2, 3]},
}
state = State(state_schema, data)
state_dict = state.to_dict()
assert state_dict["schema"] == {
"numbers": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"},
"messages": {
"type": "list[haystack.dataclasses.chat_message.ChatMessage]",
"handler": "haystack.components.agents.state.state_utils.merge_lists",
},
"dict_of_lists": {"type": "dict", "handler": "haystack.components.agents.state.state_utils.replace_values"},
}
assert state_dict["data"] == {
"serialization_schema": {
"type": "object",
"properties": {
"numbers": {"type": "integer"},
"messages": {"type": "array", "items": {"type": "haystack.dataclasses.chat_message.ChatMessage"}},
"dict_of_lists": {
"type": "object",
"properties": {"numbers": {"type": "array", "items": {"type": "integer"}}},
},
},
},
"serialized_data": {
"numbers": 1,
"messages": [{"role": "user", "meta": {}, "name": None, "content": [{"text": "Hello, world!"}]}],
"dict_of_lists": {"numbers": [1, 2, 3]},
},
}
def test_state_from_dict(self):
state_dict = {
"schema": {
"numbers": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"},
"messages": {
"type": "list[haystack.dataclasses.chat_message.ChatMessage]",
"handler": "haystack.components.agents.state.state_utils.merge_lists",
},
"dict_of_lists": {
"type": "dict",
"handler": "haystack.components.agents.state.state_utils.replace_values",
},
},
"data": {
"serialization_schema": {
"type": "object",
"properties": {
"numbers": {"type": "integer"},
"messages": {
"type": "array",
"items": {"type": "haystack.dataclasses.chat_message.ChatMessage"},
},
"dict_of_lists": {
"type": "object",
"properties": {"numbers": {"type": "array", "items": {"type": "integer"}}},
},
},
},
"serialized_data": {
"numbers": 1,
"messages": [{"role": "user", "meta": {}, "name": None, "content": [{"text": "Hello, world!"}]}],
"dict_of_lists": {"numbers": [1, 2, 3]},
},
},
}
state = State.from_dict(state_dict)
# Check types are correctly converted
assert state.schema["numbers"]["type"] == int
assert state.schema["dict_of_lists"]["type"] == dict
# Check handlers are functions, not comparing exact functions as they might be different references
assert callable(state.schema["numbers"]["handler"])
assert callable(state.schema["messages"]["handler"])
assert callable(state.schema["dict_of_lists"]["handler"])
# Check data is correct
assert state.data["numbers"] == 1
assert state.data["messages"] == [ChatMessage.from_user(text="Hello, world!")]
assert state.data["dict_of_lists"] == {"numbers": [1, 2, 3]}
def test_state_to_dict_typing_list(self):
# we test dict, a python type and a haystack dataclass
state_schema = {
"numbers": {"type": int},
"messages": {"type": List[ChatMessage]},
"dict_of_lists": {"type": dict},
}
data = {
"numbers": 1,
"messages": [ChatMessage.from_user(text="Hello, world!")],
"dict_of_lists": {"numbers": [1, 2, 3]},
}
state = State(state_schema, data)
state_dict = state.to_dict()
assert state_dict["schema"] == {
"numbers": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"},
"messages": {
"type": "typing.List[haystack.dataclasses.chat_message.ChatMessage]",
"handler": "haystack.components.agents.state.state_utils.merge_lists",
},
"dict_of_lists": {"type": "dict", "handler": "haystack.components.agents.state.state_utils.replace_values"},
}
assert state_dict["data"] == {
"serialization_schema": {
"type": "object",
"properties": {
"numbers": {"type": "integer"},
"messages": {"type": "array", "items": {"type": "haystack.dataclasses.chat_message.ChatMessage"}},
"dict_of_lists": {
"type": "object",
"properties": {"numbers": {"type": "array", "items": {"type": "integer"}}},
},
},
},
"serialized_data": {
"numbers": 1,
"messages": [{"role": "user", "meta": {}, "name": None, "content": [{"text": "Hello, world!"}]}],
"dict_of_lists": {"numbers": [1, 2, 3]},
},
}
def test_state_from_dict_typing_list(self):
state_dict = {
"schema": {
"numbers": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"},
"messages": {
"type": "typing.List[haystack.dataclasses.chat_message.ChatMessage]",
"handler": "haystack.components.agents.state.state_utils.merge_lists",
},
"dict_of_lists": {
"type": "dict",
"handler": "haystack.components.agents.state.state_utils.replace_values",
},
},
"data": {
"serialization_schema": {
"type": "object",
"properties": {
"numbers": {"type": "integer"},
"messages": {
"type": "array",
"items": {"type": "haystack.dataclasses.chat_message.ChatMessage"},
},
"dict_of_lists": {
"type": "object",
"properties": {"numbers": {"type": "array", "items": {"type": "integer"}}},
},
},
},
"serialized_data": {
"numbers": 1,
"messages": [{"role": "user", "meta": {}, "name": None, "content": [{"text": "Hello, world!"}]}],
"dict_of_lists": {"numbers": [1, 2, 3]},
},
},
}
state = State.from_dict(state_dict)
# Check types are correctly converted
assert state.schema["numbers"]["type"] == int
assert state.schema["dict_of_lists"]["type"] == dict
# Check handlers are functions, not comparing exact functions as they might be different references
assert callable(state.schema["numbers"]["handler"])
assert callable(state.schema["messages"]["handler"])
assert callable(state.schema["dict_of_lists"]["handler"])
# Check data is correct
assert state.data["numbers"] == 1
assert state.data["messages"] == [ChatMessage.from_user(text="Hello, world!")]
assert state.data["dict_of_lists"] == {"numbers": [1, 2, 3]}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,481 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack import Document, GeneratedAnswer
from haystack.components.builders.answer_builder import AnswerBuilder
from haystack.dataclasses.chat_message import ChatMessage
def _check_metadata_excluding_all_messages(actual_meta, expected_meta):
"""Helper function to check metadata while ignoring the all_messages field"""
actual_without_messages = {k: v for k, v in actual_meta.items() if k != "all_messages"}
assert actual_without_messages == expected_meta
class TestAnswerBuilder:
def test_run_unmatching_input_len(self):
component = AnswerBuilder()
with pytest.raises(ValueError):
component.run(query="query", replies=["reply1"], meta=[{"test": "meta"}, {"test": "meta2"}])
def test_run_without_meta(self):
component = AnswerBuilder()
output = component.run(query="query", replies=["reply1"])
answers = output["answers"]
assert answers[0].data == "reply1"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta # Check that all_messages exists
assert answers[0].query == "query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_meta_is_an_empty_list(self):
component = AnswerBuilder()
output = component.run(query="query", replies=["reply1"], meta=[])
answers = output["answers"]
assert answers[0].data == "reply1"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_meta(self):
component = AnswerBuilder()
output = component.run(query="query", replies=["reply1"], meta=[{"test": "meta"}])
answers = output["answers"]
assert answers[0].data == "reply1"
_check_metadata_excluding_all_messages(answers[0].meta, {"test": "meta"})
assert "all_messages" in answers[0].meta
assert answers[0].meta["all_messages"] == ["reply1"]
assert answers[0].query == "query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_without_pattern(self):
component = AnswerBuilder()
output = component.run(query="test query", replies=["Answer: AnswerString"], meta=[{}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_pattern_with_capturing_group(self):
component = AnswerBuilder(pattern=r"Answer: (.*)")
output = component.run(query="test query", replies=["Answer: AnswerString"], meta=[{}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_pattern_without_capturing_group(self):
component = AnswerBuilder(pattern=r"'.*'")
output = component.run(query="test query", replies=["Answer: 'AnswerString'"], meta=[{}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "'AnswerString'"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_pattern_with_more_than_one_capturing_group(self):
with pytest.raises(ValueError, match="contains multiple capture groups"):
AnswerBuilder(pattern=r"Answer: (.*), (.*)")
def test_run_with_pattern_set_at_runtime(self):
component = AnswerBuilder(pattern="unused pattern")
output = component.run(query="test query", replies=["Answer: AnswerString"], meta=[{}], pattern=r"Answer: (.*)")
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_documents_without_reference_pattern(self):
component = AnswerBuilder()
output = component.run(
query="test query",
replies=["Answer: AnswerString"],
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2")],
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 2
assert answers[0].documents[0].content == "test doc 1"
assert answers[0].documents[1].content == "test doc 2"
def test_run_with_documents_with_reference_pattern(self):
component = AnswerBuilder(reference_pattern="\\[(\\d+)\\]")
output = component.run(
query="test query",
replies=["Answer: AnswerString[2]"],
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2")],
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString[2]"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 1
assert answers[0].documents[0].content == "test doc 2"
assert answers[0].documents[0].meta["referenced"] is True
assert answers[0].documents[0].meta["source_index"] == 2
def test_run_with_documents_with_reference_pattern_return_all_documents(self):
component = AnswerBuilder(reference_pattern="\\[(\\d+)\\]", return_only_referenced_documents=False)
output = component.run(
query="test query",
replies=["Answer: AnswerString[2]"],
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2")],
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString[2]"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 2
assert answers[0].documents[0].content == "test doc 1"
assert answers[0].documents[0].meta["referenced"] is False
assert answers[0].documents[0].meta["source_index"] == 1
assert answers[0].documents[1].content == "test doc 2"
assert answers[0].documents[1].meta["referenced"] is True
assert answers[0].documents[1].meta["source_index"] == 2
def test_run_with_documents_with_reference_pattern_and_no_match(self, caplog):
component = AnswerBuilder(reference_pattern="\\[(\\d+)\\]")
with caplog.at_level(logging.WARNING):
output = component.run(
query="test query",
replies=["Answer: AnswerString[3]"],
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2")],
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString[3]"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 0
assert "Document index '3' referenced in Generator output is out of range." in caplog.text
def test_run_with_reference_pattern_set_at_runtime(self):
component = AnswerBuilder(reference_pattern="unused pattern")
output = component.run(
query="test query",
replies=["Answer: AnswerString[2][3]"],
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2"), Document(content="test doc 3")],
reference_pattern="\\[(\\d+)\\]",
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString[2][3]"
_check_metadata_excluding_all_messages(answers[0].meta, {})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 2
assert answers[0].documents[0].content == "test doc 2"
assert answers[0].documents[1].content == "test doc 3"
def test_run_with_chat_message_replies_without_pattern(self):
component = AnswerBuilder()
message_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
}
replies = [ChatMessage.from_assistant("Answer: AnswerString", meta=message_meta)]
output = component.run(query="test query", replies=replies, meta=[{}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString"
# Check metadata excluding all_messages
_check_metadata_excluding_all_messages(answers[0].meta, message_meta)
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_chat_message_replies_with_pattern(self):
component = AnswerBuilder(pattern=r"Answer: (.*)")
message_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
}
replies = [ChatMessage.from_assistant("Answer: AnswerString", meta=message_meta)]
output = component.run(query="test query", replies=replies, meta=[{}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
_check_metadata_excluding_all_messages(answers[0].meta, message_meta)
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_chat_message_replies_with_documents(self):
component = AnswerBuilder(reference_pattern="\\[(\\d+)\\]")
message_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
}
replies = [ChatMessage.from_assistant("Answer: AnswerString[2]", meta=message_meta)]
output = component.run(
query="test query",
replies=replies,
meta=[{}],
documents=[Document(content="test doc 1"), Document(content="test doc 2")],
)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "Answer: AnswerString[2]"
# Check metadata excluding all_messages
_check_metadata_excluding_all_messages(answers[0].meta, message_meta)
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert len(answers[0].documents) == 1
assert answers[0].documents[0].content == "test doc 2"
assert answers[0].meta["all_messages"] == [
ChatMessage.from_assistant("Answer: AnswerString[2]", meta=message_meta)
]
def test_run_with_chat_message_replies_with_pattern_set_at_runtime(self):
component = AnswerBuilder(pattern="unused pattern")
message_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
}
replies = [ChatMessage.from_assistant("Answer: AnswerString", meta=message_meta)]
output = component.run(query="test query", replies=replies, meta=[{}], pattern=r"Answer: (.*)")
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
# Check metadata excluding all_messages
_check_metadata_excluding_all_messages(answers[0].meta, message_meta)
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_chat_message_replies_with_meta_set_at_run_time(self):
component = AnswerBuilder()
message_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
}
replies = [ChatMessage.from_assistant("AnswerString", meta=message_meta)]
output = component.run(query="test query", replies=replies, meta=[{"test": "meta"}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
# Check metadata excluding all_messages
expected_meta = {
"model": "gpt-4o-mini",
"index": 0,
"finish_reason": "stop",
"usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
"test": "meta",
}
_check_metadata_excluding_all_messages(answers[0].meta, expected_meta)
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_run_with_chat_message_no_meta_with_meta_set_at_run_time(self):
component = AnswerBuilder()
replies = [ChatMessage.from_assistant("AnswerString")]
output = component.run(query="test query", replies=replies, meta=[{"test": "meta"}])
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "AnswerString"
# Check metadata excluding all_messages
_check_metadata_excluding_all_messages(answers[0].meta, {"test": "meta"})
assert "all_messages" in answers[0].meta
assert answers[0].query == "test query"
assert answers[0].documents == []
assert isinstance(answers[0], GeneratedAnswer)
def test_conversation_history_with_last_message_only_false(self):
"""Test behavior with last_message_only=False (default)"""
# Test with two messages in replies
component = AnswerBuilder(last_message_only=False)
replies = [
ChatMessage.from_user("What is Haystack?"),
ChatMessage.from_assistant("Haystack is a framework for building LLM applications."),
]
output = component.run(query="test query", replies=replies)
answers = output["answers"]
# Should have one answer for each message in replies
assert len(answers) == 2
assert answers[0].data == "What is Haystack?"
assert answers[1].data == "Haystack is a framework for building LLM applications."
# Check that each answer contains the full conversation history
for answer in answers:
assert "all_messages" in answer.meta
assert len(answer.meta["all_messages"]) == 2
assert answer.meta["all_messages"] == replies
# Test with one message in replies
component = AnswerBuilder(last_message_only=False)
replies = [ChatMessage.from_user("What is Haystack?")]
output = component.run(query="test query", replies=replies)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "What is Haystack?"
assert "all_messages" in answers[0].meta
assert answers[0].meta["all_messages"] == replies
assert len(answers[0].meta["all_messages"]) == 1
def test_conversation_history_with_last_message_only_true(self):
"""Test behavior with last_message_only=True"""
# Test with two messages in replies
component = AnswerBuilder(last_message_only=True)
replies = [
ChatMessage.from_user("What is Haystack?"),
ChatMessage.from_assistant("Haystack is a framework for building LLM applications."),
]
output = component.run(query="test query", replies=replies)
answers = output["answers"]
# Should only have one answer - the last message
assert len(answers) == 1
assert answers[0].data == "Haystack is a framework for building LLM applications."
# Check that the answer contains the full conversation history
assert "all_messages" in answers[0].meta
assert len(answers[0].meta["all_messages"]) == 2
assert answers[0].meta["all_messages"] == replies
# Test with one message in replies
component = AnswerBuilder(last_message_only=True)
replies = [ChatMessage.from_user("What is Haystack?")]
output = component.run(query="test query", replies=replies)
answers = output["answers"]
assert len(answers) == 1
assert answers[0].data == "What is Haystack?"
assert "all_messages" in answers[0].meta
assert answers[0].meta["all_messages"] == replies
assert len(answers[0].meta["all_messages"]) == 1
def test_run_does_not_mutate_input_documents_meta(self):
doc = Document(content="Paris is the capital of France.", meta={"source": "wiki"})
component = AnswerBuilder()
component.run(query="Capital of France?", replies=["Paris."], documents=[doc])
assert doc.meta == {"source": "wiki"}
def test_run_does_not_mutate_input_documents_meta_with_reference_pattern(self):
doc = Document(content="Paris is the capital of France.", meta={"source": "wiki"})
component = AnswerBuilder(reference_pattern=r"\[(\d+)\]", return_only_referenced_documents=False)
component.run(query="Capital?", replies=["Paris [1]."], documents=[doc])
assert doc.meta == {"source": "wiki"}
def test_run_does_not_mutate_document_with_empty_meta(self):
doc = Document(content="Paris is the capital of France.")
component = AnswerBuilder()
component.run(query="Capital of France?", replies=["Paris."], documents=[doc])
assert doc.meta == {}
def test_run_expands_reference_ranges_when_enabled(self):
docs = [Document(content=f"doc {i}") for i in range(1, 11)]
component = AnswerBuilder(
reference_pattern=r"\[(\d+)\]", expand_reference_ranges=True, return_only_referenced_documents=False
)
output = component.run(query="test query", replies=["Answer citing sources [6-10]."], documents=docs)
answer = output["answers"][0]
referenced_docs = [doc for doc in answer.documents if doc.meta["referenced"]]
assert [doc.meta["source_index"] for doc in referenced_docs] == [6, 7, 8, 9, 10]
def test_run_expands_comma_separated_reference_ranges(self):
docs = [Document(content=f"doc {i}") for i in range(1, 11)]
component = AnswerBuilder(
reference_pattern=r"\[(\d+)\]", expand_reference_ranges=True, return_only_referenced_documents=False
)
output = component.run(query="test query", replies=["Answer citing sources [1-3,7-9]."], documents=docs)
answer = output["answers"][0]
referenced_docs = [doc for doc in answer.documents if doc.meta["referenced"]]
assert [doc.meta["source_index"] for doc in referenced_docs] == [1, 2, 3, 7, 8, 9]
def test_run_ignores_invalid_reference_ranges(self):
docs = [Document(content=f"doc {i}") for i in range(1, 5)]
component = AnswerBuilder(
reference_pattern=r"\[(\d+)\]", expand_reference_ranges=True, return_only_referenced_documents=True
)
output = component.run(query="test query", replies=["Answer citing sources [3-1]."], documents=docs)
assert output["answers"][0].documents == []
def test_run_clamps_reference_range_to_number_of_documents(self):
docs = [Document(content=f"doc {i}") for i in range(1, 4)]
component = AnswerBuilder(
reference_pattern=r"\[(\d+)\]", expand_reference_ranges=True, return_only_referenced_documents=True
)
output = component.run(query="test query", replies=["Answer citing sources [1-100]."], documents=docs)
referenced_docs = output["answers"][0].documents
assert [doc.meta["source_index"] for doc in referenced_docs] == [1, 2, 3]
def test_run_does_not_expand_reference_ranges_by_default(self):
docs = [Document(content=f"doc {i}") for i in range(1, 11)]
component = AnswerBuilder(reference_pattern=r"\[(\d+)\]", return_only_referenced_documents=True)
output = component.run(query="test query", replies=["Answer citing sources [6-10]."], documents=docs)
assert output["answers"][0].documents == []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,378 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from typing import Any
from unittest.mock import patch
import arrow
import pytest
from jinja2 import TemplateSyntaxError
from haystack import component
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.core.pipeline.pipeline import Pipeline
from haystack.dataclasses.document import Document
class TestPromptBuilder:
def test_init(self):
builder = PromptBuilder(template="This is a {{ variable }}")
assert builder.template is not None
assert builder.required_variables == "*"
assert builder._template_string == "This is a {{ variable }}"
assert builder._variables is None
assert builder._required_variables == "*"
# we have inputs that contain: template, template_variables + inferred variables
inputs = builder.__haystack_input__._sockets_dict
assert set(inputs.keys()) == {"template", "template_variables", "variable"}
assert inputs["template"].type == str | None
assert inputs["template_variables"].type == dict[str, Any] | None
assert inputs["variable"].type == Any
# response is always prompt
outputs = builder.__haystack_output__._sockets_dict
assert set(outputs.keys()) == {"prompt"}
assert outputs["prompt"].type == str
def test_init_with_required_variables(self):
builder = PromptBuilder(template="This is a {{ variable }}", required_variables=["variable"])
assert builder.template is not None
assert builder.required_variables == ["variable"]
assert builder._template_string == "This is a {{ variable }}"
assert builder._variables is None
assert builder._required_variables == ["variable"]
# we have inputs that contain: template, template_variables + inferred variables
inputs = builder.__haystack_input__._sockets_dict
assert set(inputs.keys()) == {"template", "template_variables", "variable"}
assert inputs["template"].type == str | None
assert inputs["template_variables"].type == dict[str, Any] | None
assert inputs["variable"].type == Any
# response is always prompt
outputs = builder.__haystack_output__._sockets_dict
assert set(outputs.keys()) == {"prompt"}
assert outputs["prompt"].type == str
def test_init_with_custom_variables(self):
variables = ["var1", "var2", "var3"]
template = "Hello, {{ var1 }}, {{ var2 }}!"
builder = PromptBuilder(template=template, variables=variables)
assert builder.template is not None
assert builder.required_variables == "*"
assert builder._variables == variables
assert builder._template_string == "Hello, {{ var1 }}, {{ var2 }}!"
assert builder._required_variables == "*"
# we have inputs that contain: template, template_variables + variables
inputs = builder.__haystack_input__._sockets_dict
assert set(inputs.keys()) == {"template", "template_variables", "var1", "var2", "var3"}
assert inputs["template"].type == str | None
assert inputs["template_variables"].type == dict[str, Any] | None
assert inputs["var1"].type == Any
assert inputs["var2"].type == Any
assert inputs["var3"].type == Any
# response is always prompt
outputs = builder.__haystack_output__._sockets_dict
assert set(outputs.keys()) == {"prompt"}
assert outputs["prompt"].type == str
def test_to_dict(self):
builder = PromptBuilder(
template="This is a {{ variable }}", variables=["var1", "var2"], required_variables=["var1", "var3"]
)
res = builder.to_dict()
assert res == {
"type": "haystack.components.builders.prompt_builder.PromptBuilder",
"init_parameters": {
"template": "This is a {{ variable }}",
"variables": ["var1", "var2"],
"required_variables": ["var1", "var3"],
},
}
def test_to_dict_without_optional_params(self):
builder = PromptBuilder(template="This is a {{ variable }}")
res = builder.to_dict()
assert res == {
"type": "haystack.components.builders.prompt_builder.PromptBuilder",
"init_parameters": {"template": "This is a {{ variable }}", "variables": None, "required_variables": "*"},
}
def test_run(self):
builder = PromptBuilder(template="This is a {{ variable }}")
res = builder.run(variable="test")
assert res == {"prompt": "This is a test"}
def test_run_template_variable(self):
builder = PromptBuilder(template="This is a {{ variable }}")
res = builder.run(template_variables={"variable": "test"})
assert res == {"prompt": "This is a test"}
def test_run_template_variable_overrides_variable(self):
builder = PromptBuilder(template="This is a {{ variable }}")
res = builder.run(template_variables={"variable": "test_from_template_var"}, variable="test")
assert res == {"prompt": "This is a test_from_template_var"}
def test_run_without_input(self):
builder = PromptBuilder(template="This is a template without input")
res = builder.run()
assert res == {"prompt": "This is a template without input"}
def test_run_with_missing_optional_input(self):
builder = PromptBuilder(template="This is a {{ variable }}", required_variables=None)
res = builder.run()
assert res == {"prompt": "This is a "}
def test_run_with_missing_required_input_default(self):
builder = PromptBuilder(template="This is a {{ variable }}")
with pytest.raises(ValueError, match="variable"):
builder.run()
def test_run_with_missing_required_input(self):
builder = PromptBuilder(template="This is a {{ foo }}, not a {{ bar }}", required_variables=["foo", "bar"])
with pytest.raises(ValueError, match="foo"):
builder.run(bar="bar")
with pytest.raises(ValueError, match="bar"):
builder.run(foo="foo")
with pytest.raises(ValueError, match="foo, bar"):
builder.run()
def test_run_with_missing_required_input_using_star(self):
builder = PromptBuilder(template="This is a {{ foo }}, not a {{ bar }}", required_variables="*")
with pytest.raises(ValueError, match="foo"):
builder.run(bar="bar")
with pytest.raises(ValueError, match="bar"):
builder.run(foo="foo")
with pytest.raises(ValueError, match="bar, foo"):
builder.run()
def test_run_with_variables(self):
variables = ["var1", "var2", "var3"]
template = "Hello, {{ name }}! {{ var1 }}"
builder = PromptBuilder(template=template, variables=variables, required_variables=["name", "var1"])
assert builder.run(name="John", var1="How are you?") == {"prompt": "Hello, John! How are you?"}
def test_run_overwriting_default_template(self):
builder = PromptBuilder(template="Hello, {{ name }}!")
assert builder.run("Hello, {{ var1 }}{{ name }}!!", name="John") == {"prompt": "Hello, John!!"}
def test_run_overwriting_default_template_with_template_variables(self):
builder = PromptBuilder(template="Hello, {{ name }}!")
template = "Hello, {{ var1 }} {{ name }}!"
assert builder.run(template, var1="Big", name="John") == {"prompt": "Hello, Big John!"}
def test_run_overwriting_default_template_with_variables(self):
variables = ["var1", "var2", "name"]
builder = PromptBuilder(template="Hello, {{ name }}!", variables=variables, required_variables=["name"])
template = "Hello, {{ var1 }} {{ name }}!"
assert builder.run(template, name="John", var1="Big") == {"prompt": "Hello, Big John!"}
def test_run_with_invalid_template(self):
builder = PromptBuilder(template="Hello, {{ name }}!")
template = "Hello, {{ name }!"
template_variables = {"name": "John"}
with pytest.raises(TemplateSyntaxError):
builder.run(template, template_variables)
def test_init_with_invalid_template(self):
template = "Hello, {{ name }!"
with pytest.raises(TemplateSyntaxError):
PromptBuilder(template)
def test_provided_template_variables(self):
prompt_builder = PromptBuilder(template="", variables=["documents"], required_variables=["city"])
# both variables are provided
prompt_builder._validate_variables({"name", "city"})
# provided variables are a superset of the required variables
prompt_builder._validate_variables({"name", "city", "age"})
with pytest.raises(ValueError):
prompt_builder._validate_variables({"name"})
def test_example_in_pipeline(self):
default_template = "Here is the document: {{documents[0].content}} \\n Answer: {{query}}"
prompt_builder = PromptBuilder(template=default_template, variables=["documents"])
@component
class DocumentProducer:
@component.output_types(documents=list[Document])
def run(self, doc_input: str):
return {"documents": [Document(content=doc_input)]}
pipe = Pipeline()
pipe.add_component("doc_producer", DocumentProducer())
pipe.add_component("prompt_builder", prompt_builder)
pipe.connect("doc_producer.documents", "prompt_builder.documents")
template = "Here is the document: {{documents[0].content}} \n Query: {{query}}"
result = pipe.run(
data={
"doc_producer": {"doc_input": "Hello world, I live in Berlin"},
"prompt_builder": {
"template": template,
"template_variables": {"query": "Where does the speaker live?"},
},
}
)
assert result == {
"prompt_builder": {
"prompt": "Here is the document: Hello world, I live in Berlin \n Query: Where does the speaker live?"
}
}
def test_example_in_pipeline_simple(self):
default_template = "This is the default prompt:\n Query: {{query}}"
prompt_builder = PromptBuilder(template=default_template)
pipe = Pipeline()
pipe.add_component("prompt_builder", prompt_builder)
# using the default prompt
result = pipe.run(data={"query": "Where does the speaker live?"})
expected_default = {
"prompt_builder": {"prompt": "This is the default prompt:\n Query: Where does the speaker live?"}
}
assert result == expected_default
# using the dynamic prompt
result = pipe.run(
data={"query": "Where does the speaker live?", "template": "This is the dynamic prompt:\n Query: {{query}}"}
)
expected_dynamic = {
"prompt_builder": {"prompt": "This is the dynamic prompt:\n Query: Where does the speaker live?"}
}
assert result == expected_dynamic
def test_warning_when_required_variables_explicitly_none(self, caplog):
with caplog.at_level(logging.WARNING):
_ = PromptBuilder(template="This is a {{ variable }}", required_variables=None)
assert "explicitly set to `None`" in caplog.text
def test_no_warning_with_default_required_variables(self, caplog):
with caplog.at_level(logging.WARNING):
_ = PromptBuilder(template="This is a {{ variable }}")
assert "required_variables" not in caplog.text
def test_variables_correct_with_assignment(self) -> None:
template = """{% if existing_documents is not none %}
{% set existing_doc_len = existing_documents|length %}
{% else %}
{% set existing_doc_len = 0 %}
{% endif %}
{% for doc in docs %}
<document reference="{{loop.index + existing_doc_len}}">
{{ doc.content }}
</document>
{% endfor %}
"""
builder = PromptBuilder(template=template, required_variables="*")
assert set(builder.variables) == {"docs", "existing_documents"}
assert builder.required_variables == "*"
def test_variables_correct_with_tuple_assignment(self):
template = """{% if existing_documents is not none -%}
{% set x, y = (existing_documents|length, 1) -%}
{% else -%}
{% set x, y = (0, 1) -%}
{% endif -%}
x={{ x }}, y={{ y }}
"""
builder = PromptBuilder(template=template, required_variables="*")
assert builder.variables == ["existing_documents"]
assert builder.required_variables == "*"
res = builder.run(existing_documents=None)
assert res["prompt"] == "x=0, y=1"
def test_variables_correct_with_list_assignment(self):
template = """{% if existing_documents is not none -%}
{% set x, y = [existing_documents|length, 1] -%}
{% else -%}
{% set x, y = [0, 1] -%}
{% endif -%}
x={{ x }}, y={{ y }}
"""
builder = PromptBuilder(template=template, required_variables="*")
assert builder.variables == ["existing_documents"]
assert builder.required_variables == "*"
res = builder.run(existing_documents=None)
assert res["prompt"] == "x=0, y=1"
class TestPromptBuilderWithJinja2TimeExtension:
@pytest.fixture(autouse=True)
def mock_now(self, monkeypatch):
"""Mock the arrow.now function to return a fixed datetime"""
monkeypatch.setattr("arrow.now", lambda timezone="UTC": arrow.get("1970-01-01 00:00:00").to(timezone))
@patch("haystack.components.builders.prompt_builder.Jinja2TimeExtension")
def test_init_with_missing_extension_dependency(self, extension_mock):
extension_mock.side_effect = ImportError
builder = PromptBuilder(template="This is a {{ variable }}")
assert builder._env.extensions == {}
res = builder.run(variable="test")
assert res == {"prompt": "This is a test"}
def test_with_custom_dateformat(self) -> None:
template = "Formatted date: {% now 'UTC', '%Y-%m-%d' %}"
builder = PromptBuilder(template=template)
result = builder.run()["prompt"]
now_formatted = f"Formatted date: {arrow.now('UTC').strftime('%Y-%m-%d')}"
assert now_formatted == result
def test_with_different_timezone(self) -> None:
template = "Current time in New York is: {% now 'America/New_York' %}"
builder = PromptBuilder(template=template)
result = builder.run()["prompt"]
now_ny = f"Current time in New York is: {arrow.now('America/New_York').strftime('%Y-%m-%d %H:%M:%S')}"
assert now_ny == result
def test_date_with_addition_offset(self) -> None:
template = "Time after 2 hours is: {% now 'UTC' + 'hours=2' %}"
builder = PromptBuilder(template=template)
result = builder.run()["prompt"]
now_plus_2 = f"Time after 2 hours is: {(arrow.now('UTC').shift(hours=+2)).strftime('%Y-%m-%d %H:%M:%S')}"
assert now_plus_2 == result
def test_date_with_subtraction_offset(self) -> None:
template = "Time after 12 days is: {% now 'UTC' - 'days=12' %}"
builder = PromptBuilder(template=template)
result = builder.run()["prompt"]
now_plus_2 = f"Time after 12 days is: {(arrow.now('UTC').shift(days=-12)).strftime('%Y-%m-%d %H:%M:%S')}"
assert now_plus_2 == result
def test_invalid_timezone(self) -> None:
template = "Current time is: {% now 'Invalid/Timezone' %}"
builder = PromptBuilder(template=template)
# Expect ValueError for invalid timezone
with pytest.raises(ValueError, match="Invalid timezone"):
builder.run()
def test_invalid_offset(self) -> None:
template = "Time after invalid offset is: {% now 'UTC' + 'invalid_offset' %}"
builder = PromptBuilder(template=template)
# Expect ValueError for invalid offset
with pytest.raises(ValueError, match="Invalid offset or operator"):
builder.run()
@@ -0,0 +1,62 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import AsyncMock, MagicMock
import pytest
from haystack import Document
from haystack.components.caching.cache_checker import CacheChecker
from haystack.testing.factory import document_store_class
class TestCacheCheckerAsync:
@pytest.mark.asyncio
async def test_run_async_invalid_docstore(self):
mocked_docstore_class = document_store_class("MockedDocumentStore")
checker = CacheChecker(document_store=mocked_docstore_class(), cache_field="url")
with pytest.raises(TypeError, match="does not provide async support"):
await checker.run_async(items=["https://example.com/1"])
@pytest.mark.asyncio
async def test_run_async(self, in_memory_doc_store):
documents = [
Document(content="doc1", meta={"url": "https://example.com/1"}),
Document(content="doc2", meta={"url": "https://example.com/2"}),
Document(content="doc3", meta={"url": "https://example.com/1"}),
Document(content="doc4", meta={"url": "https://example.com/2"}),
]
in_memory_doc_store.write_documents(documents)
checker = CacheChecker(in_memory_doc_store, cache_field="url")
results = await checker.run_async(items=["https://example.com/1", "https://example.com/5"])
assert results == {"hits": [documents[0], documents[2]], "misses": ["https://example.com/5"]}
@pytest.mark.asyncio
async def test_run_async_all_hits(self, in_memory_doc_store):
documents = [
Document(content="doc1", meta={"url": "https://example.com/1"}),
Document(content="doc2", meta={"url": "https://example.com/2"}),
]
in_memory_doc_store.write_documents(documents)
checker = CacheChecker(in_memory_doc_store, cache_field="url")
results = await checker.run_async(items=["https://example.com/1", "https://example.com/2"])
assert results["hits"] == documents
assert results["misses"] == []
@pytest.mark.asyncio
async def test_run_async_all_misses(self, in_memory_doc_store):
checker = CacheChecker(in_memory_doc_store, cache_field="url")
results = await checker.run_async(items=["https://example.com/1", "https://example.com/2"])
assert results["hits"] == []
assert results["misses"] == ["https://example.com/1", "https://example.com/2"]
@pytest.mark.asyncio
async def test_run_async_filters_syntax(self):
mock_store = MagicMock()
mock_store.filter_documents_async = AsyncMock(return_value=[])
checker = CacheChecker(document_store=mock_store, cache_field="url")
await checker.run_async(items=["https://example.com/1"])
expected_filters = {"field": "url", "operator": "==", "value": "https://example.com/1"}
mock_store.filter_documents_async.assert_awaited_once_with(filters=expected_filters)
@@ -0,0 +1,94 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import MagicMock
import pytest
from haystack import Document
from haystack.components.caching.cache_checker import CacheChecker
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.testing.factory import document_store_class
class TestCacheChecker:
def test_to_dict(self):
mocked_docstore_class = document_store_class("MockedDocumentStore")
component = CacheChecker(document_store=mocked_docstore_class(), cache_field="url")
data = component.to_dict()
assert data == {
"type": "haystack.components.caching.cache_checker.CacheChecker",
"init_parameters": {
"document_store": {"type": "haystack.testing.factory.MockedDocumentStore", "init_parameters": {}},
"cache_field": "url",
},
}
def test_to_dict_with_custom_init_parameters(self):
mocked_docstore_class = document_store_class("MockedDocumentStore")
component = CacheChecker(document_store=mocked_docstore_class(), cache_field="my_url_field")
data = component.to_dict()
assert data == {
"type": "haystack.components.caching.cache_checker.CacheChecker",
"init_parameters": {
"document_store": {"type": "haystack.testing.factory.MockedDocumentStore", "init_parameters": {}},
"cache_field": "my_url_field",
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.caching.cache_checker.CacheChecker",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {},
},
"cache_field": "my_url_field",
},
}
component = CacheChecker.from_dict(data)
assert isinstance(component.document_store, InMemoryDocumentStore)
assert component.cache_field == "my_url_field"
def test_from_dict_without_docstore(self):
data = {"type": "haystack.components.caching.cache_checker.CacheChecker", "init_parameters": {}}
with pytest.raises(
TypeError, match="missing 2 required positional arguments: 'document_store' and 'cache_field'"
):
CacheChecker.from_dict(data)
def test_from_dict_nonexisting_docstore(self):
# Use a type whose module passes the deserialization allowlist (haystack.*) but cannot be
# resolved, so we still exercise the "import failed" code path rather than the allowlist gate.
data = {
"type": "haystack.components.caching.cache_checker.CacheChecker",
"init_parameters": {
"document_store": {"type": "haystack.does.not.exist.DocumentStore", "init_parameters": {}}
},
}
with pytest.raises(
ImportError, match=r"Failed to deserialize 'document_store':.*haystack\.does\.not\.exist\.DocumentStore"
):
CacheChecker.from_dict(data)
def test_run(self, in_memory_doc_store):
documents = [
Document(content="doc1", meta={"url": "https://example.com/1"}),
Document(content="doc2", meta={"url": "https://example.com/2"}),
Document(content="doc3", meta={"url": "https://example.com/1"}),
Document(content="doc4", meta={"url": "https://example.com/2"}),
]
in_memory_doc_store.write_documents(documents)
checker = CacheChecker(in_memory_doc_store, cache_field="url")
results = checker.run(items=["https://example.com/1", "https://example.com/5"])
assert results == {"hits": [documents[0], documents[2]], "misses": ["https://example.com/5"]}
def test_filters_syntax(self):
mocked_docstore_class = document_store_class("MockedDocumentStore")
mocked_docstore_class.filter_documents = MagicMock()
checker = CacheChecker(document_store=mocked_docstore_class(), cache_field="url")
checker.run(items=["https://example.com/1"])
valid_filters_syntax = {"field": "url", "operator": "==", "value": "https://example.com/1"}
mocked_docstore_class.filter_documents.assert_any_call(filters=valid_filters_syntax)
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,143 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import patch
import pytest
from PIL import Image
from haystack import Document
from haystack.components.converters.image.document_to_image import DocumentToImageContent
from haystack.core.serialization import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
class TestDocumentToImageContent:
def test_to_dict(self) -> None:
converter = DocumentToImageContent()
assert component_to_dict(converter, "converter") == {
"init_parameters": {"file_path_meta_field": "file_path", "root_path": "", "detail": None, "size": None},
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
}
def test_to_dict_not_defaults(self) -> None:
converter = DocumentToImageContent(
file_path_meta_field="image_path", root_path="/data", detail="high", size=(800, 600)
)
assert component_to_dict(converter, "converter") == {
"init_parameters": {
"file_path_meta_field": "image_path",
"root_path": "/data",
"detail": "high",
"size": (800, 600),
},
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
}
def test_from_dict(self) -> None:
data = {
"init_parameters": {
"file_path_meta_field": "image_path",
"root_path": "/test",
"detail": "auto",
"size": (512, 512),
},
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
}
converter = component_from_dict(DocumentToImageContent, data, "name")
assert component_to_dict(converter, "converter") == data
def test_run_with_empty_documents_list(self) -> None:
converter = DocumentToImageContent()
results = converter.run(documents=[])
assert results == {"image_contents": []}
def test_run_with_missing_file_path_metadata(self) -> None:
converter = DocumentToImageContent()
# Document without file_path in metadata
doc_no_path = Document(content="test", meta={})
# Document with file_path but file doesn't exist
doc_no_file = Document(content="test", meta={"file_path": "nonexistent.jpg"})
with pytest.raises(ValueError, match="is missing the 'file_path' key"):
_ = converter.run(documents=[doc_no_path, doc_no_file])
def test_run_with_non_image_documents(self) -> None:
converter = DocumentToImageContent()
docx_doc = Document(content="test", meta={"file_path": "test/test_files/docx/sample_docx.docx"})
with pytest.raises(ValueError, match="has an unsupported MIME type"):
_ = converter.run(documents=[docx_doc])
def test_run_with_invalid_file_path(self, caplog) -> None:
converter = DocumentToImageContent()
pdf_doc = Document(content="test", meta={"file_path": "wrong_name.jpg"})
with pytest.raises(ValueError, match="has an invalid file path 'wrong_name.jpg'"):
_ = converter.run(documents=[pdf_doc])
def test_run_with_pdf_missing_page_number(self, caplog) -> None:
converter = DocumentToImageContent()
pdf_doc = Document(content="test", meta={"file_path": "test/test_files/pdf/sample_pdf_1.pdf"})
with pytest.raises(ValueError, match="is missing the 'page_number' key"):
_ = converter.run(documents=[pdf_doc])
def test_run_with_image_documents(self) -> None:
converter = DocumentToImageContent(root_path="test/test_files/images")
image_doc = Document(content="test", meta={"file_path": "apple.jpg"})
results = converter.run(documents=[image_doc])
assert len(results["image_contents"]) == 1
assert results["image_contents"][0].meta == {"file_path": "apple.jpg"}
def test_run_with_pdf_documents(self) -> None:
converter = DocumentToImageContent()
pdf_doc = Document(content="test", meta={"file_path": "test/test_files/pdf/sample_pdf_1.pdf", "page_number": 1})
results = converter.run(documents=[pdf_doc])
assert len(results["image_contents"]) == 1
assert results["image_contents"][0].meta == {
"file_path": "test/test_files/pdf/sample_pdf_1.pdf",
"page_number": 1,
}
def test_run_with_mixed_document_types(self) -> None:
converter = DocumentToImageContent(root_path="test/test_files")
documents = [
Document(content="", meta={"file_path": "images/apple.jpg"}),
Document(content="", meta={"file_path": "pdf/sample_pdf_1.pdf", "page_number": 1}),
Document(content="text", meta={"file_path": "docx/sample_docx.docx"}),
]
with pytest.raises(ValueError, match="has an unsupported MIME type"):
_ = converter.run(documents=documents)
@patch("haystack.components.converters.image.document_to_image._extract_image_sources_info")
@patch("haystack.components.converters.image.document_to_image._batch_convert_pdf_pages_to_images")
@patch("PIL.Image.open")
@patch("haystack.components.converters.image.document_to_image.ByteStream")
def test_run_none_images(
self,
mocked_byte_stream,
mocked_pil_open,
mocked_batch_convert_pdf_pages_to_images,
mocked_extract_image_sources_info,
caplog,
):
converter = DocumentToImageContent()
mocked_extract_image_sources_info.return_value = [
{"path": "doc1.pdf", "mime_type": "application/pdf", "page_number": 999}, # Page 999 doesn't exist
{"path": "image1.jpg", "mime_type": "image/jpeg"},
]
mocked_batch_convert_pdf_pages_to_images.return_value = {} # Empty dict because page was skipped
mocked_pil_open.return_value = Image.new("RGB", (100, 100))
mocked_byte_stream.from_file_path.return_value = ByteStream(b"")
documents = [
Document(content="PDF 1", meta={"file_path": "doc1.pdf", "page_number": 999}),
Document(content="Image 1", meta={"file_path": "image1.jpg"}),
]
image_contents = converter.run(documents=documents)["image_contents"]
assert caplog.records[-1].levelname == "WARNING"
assert "Conversion failed for some documents." in caplog.records[-1].message
assert image_contents[0] is None
assert image_contents[1] is not None
@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from pathlib import Path
import pytest
from haystack.components.converters.image.file_to_document import ImageFileToDocument
from haystack.core.serialization import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
class TestImageFileToDocument:
def test_to_dict(self) -> None:
converter = ImageFileToDocument()
assert component_to_dict(converter, "converter") == {
"init_parameters": {"store_full_path": False},
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
}
def test_to_dict_not_defaults(self) -> None:
converter = ImageFileToDocument(store_full_path=True)
assert component_to_dict(converter, "converter") == {
"init_parameters": {"store_full_path": True},
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
}
def test_from_dict(self) -> None:
data = {
"init_parameters": {"store_full_path": False},
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
}
converter = component_from_dict(ImageFileToDocument, data, "name")
assert component_to_dict(converter, "converter") == data
@pytest.mark.parametrize(
("image_path", "mime_type"),
[
("./test/test_files/images/haystack-logo.png", "image/png"),
("./test/test_files/images/apple.jpg", "image/jpeg"),
],
)
def test_run_with_valid_sources(self, image_path: str, mime_type: str) -> None:
converter = ImageFileToDocument(store_full_path=True)
results = converter.run(sources=[image_path], meta={"source": "test_source"})
assert len(results["documents"]) == 1
assert results["documents"][0].content is None
assert results["documents"][0].meta == {"source": "test_source", "file_path": image_path}
def test_run_with_no_sources(self) -> None:
converter = ImageFileToDocument()
results = converter.run(sources=[])
assert len(results["documents"]) == 0
assert results == {"documents": []}
def test_run_with_invalid_source_type(self, caplog) -> None:
converter = ImageFileToDocument()
converter.run(sources=[123]) # Invalid source type
assert "Could not read" in caplog.text
def test_run_with_non_existent_file(self, caplog) -> None:
converter = ImageFileToDocument()
converter.run(sources=["./non_existent_file.png"])
assert "Could not read" in caplog.text
assert "No such file or directory:" in caplog.text
@pytest.mark.parametrize(
("image_path", "mime_type"),
[
("./test/test_files/images/haystack-logo.png", "image/png"),
("./test/test_files/images/apple.jpg", "image/jpeg"),
],
)
def test_run_with_bytestream_sources(self, image_path: str, mime_type: str) -> None:
byte_stream = ByteStream.from_file_path(Path(image_path), mime_type=mime_type, meta={"file_path": image_path})
converter = ImageFileToDocument(store_full_path=True)
results = converter.run(sources=[byte_stream])
assert len(results["documents"]) == 1
assert results["documents"][0].content is None
assert results["documents"][0].meta == {"file_path": image_path}
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from pathlib import Path
import pytest
from haystack.components.converters.image.file_to_image import ImageFileToImageContent
from haystack.components.converters.image.image_utils import _encode_image_to_base64
from haystack.core.serialization import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
class TestImageFileToImageContent:
def test_to_dict(self) -> None:
converter = ImageFileToImageContent()
assert component_to_dict(converter, "converter") == {
"init_parameters": {"detail": None, "size": None},
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
}
def test_to_dict_not_defaults(self) -> None:
converter = ImageFileToImageContent(detail="low", size=(128, 128))
assert component_to_dict(converter, "converter") == {
"init_parameters": {"detail": "low", "size": (128, 128)},
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
}
def test_from_dict(self) -> None:
data = {
"init_parameters": {"detail": "auto", "size": None},
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
}
converter = component_from_dict(ImageFileToImageContent, data, "name")
assert component_to_dict(converter, "converter") == data
@pytest.mark.parametrize(
("image_path", "mime_type"),
[
("./test/test_files/images/haystack-logo.png", "image/png"),
("./test/test_files/images/apple.jpg", "image/jpeg"),
],
)
def test_run_with_valid_sources(self, image_path: str, mime_type: str) -> None:
converter = ImageFileToImageContent()
results = converter.run(sources=[image_path], size=(128, 128))
byte_stream = ByteStream.from_file_path(
Path(image_path), mime_type=mime_type, meta={"file_name": image_path.rsplit("/", maxsplit=1)[-1]}
)
assert len(results["image_contents"]) == 1
assert results["image_contents"][0].base64_image is not None
assert (
results["image_contents"][0].base64_image
== _encode_image_to_base64(bytestream=byte_stream, size=(128, 128))[1]
)
assert results["image_contents"][0].mime_type == mime_type
assert results["image_contents"][0].detail is None
assert results["image_contents"][0].meta["file_path"] == str(Path(image_path))
def test_run_with_no_sources(self) -> None:
converter = ImageFileToImageContent()
results = converter.run(sources=[])
assert len(results["image_contents"]) == 0
assert results == {"image_contents": []}
def test_run_with_invalid_source_type(self, caplog) -> None:
converter = ImageFileToImageContent()
converter.run(sources=[123]) # Invalid source type
assert "Could not read" in caplog.text
def test_run_with_non_existent_file(self, caplog) -> None:
converter = ImageFileToImageContent()
converter.run(sources=["./non_existent_file.png"])
assert "Could not read" in caplog.text
assert "No such file or directory:" in caplog.text
@pytest.mark.parametrize(
("image_path", "mime_type"),
[
("./test/test_files/images/haystack-logo.png", "image/png"),
("./test/test_files/images/apple.jpg", "image/jpeg"),
],
)
def test_run_with_bytestream_sources(self, image_path: str, mime_type: str) -> None:
byte_stream = ByteStream.from_file_path(Path(image_path), mime_type=mime_type, meta={"file_path": image_path})
# Initialize the converter
converter = ImageFileToImageContent(size=(128, 128))
# Run the converter with the ByteStream
results = converter.run(sources=[byte_stream])
# Assertions
assert len(results["image_contents"]) == 1
assert results["image_contents"][0].base64_image is not None
assert (
results["image_contents"][0].base64_image
== _encode_image_to_base64(bytestream=byte_stream, size=(128, 128))[1]
)
assert results["image_contents"][0].mime_type == mime_type
assert results["image_contents"][0].detail is None
assert results["image_contents"][0].meta["file_path"] == image_path
def test_run_with_empty_bytestream(self) -> None:
# Create an empty ByteStream object
byte_stream = ByteStream(data=b"", meta={"file_path": "empty_file.png"})
# Initialize the converter
converter = ImageFileToImageContent()
# Run the converter with the empty ByteStream
results = converter.run(sources=[byte_stream])
assert results["image_contents"] == []
@@ -0,0 +1,227 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import glob
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from PIL import Image
from pytest import LogCaptureFixture
from haystack.components.converters.image.image_utils import (
_batch_convert_pdf_pages_to_images,
_convert_pdf_to_images,
_encode_image_to_base64,
_encode_pil_image_to_base64,
_extract_image_sources_info,
_PDFPageInfo,
)
from haystack.components.converters.utils import get_bytestream_from_source
from haystack.dataclasses import ByteStream, Document
class TestToBase64Jpeg:
def test_to_base64_jpeg(self) -> None:
image_array = np.array(
[
[[34.215402, 132.78745697, 71.04739979], [24.23156181, 35.26147199, 124.95610316]],
[[155.47443501, 196.98050276, 154.74734292], [253.24590033, 84.62392497, 157.34396641]],
]
)
image = Image.fromarray(image_array.astype("uint8"))
b64_str = _encode_pil_image_to_base64(image=image, mime_type="image/jpeg")
assert (
b64_str
== "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCe50jTTdTE6daffb/livr9KKKK9aHwo9ql8EfRH//Z" # noqa: E501
)
class TestConvertPdfToImages:
def test_convert_pdf_to_images(self) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
output = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
assert output is not None
def test_convert_pdf_to_images_with_size(self) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
pages_images = _convert_pdf_to_images(bytestream=bytestream, page_range=[1], size=(100, 100))
assert len(pages_images) == 1
assert pages_images[0][0] == 1
assert pages_images[0][1].width <= 100
assert pages_images[0][1].height <= 100
def test_convert_pdf_to_images_invalid_page(self, caplog: LogCaptureFixture) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[5])
assert out == []
assert "Page 5 is out of range for the PDF file. Skipping it." in caplog.text
def test_convert_pdf_to_images_error_reading_file(self, caplog: LogCaptureFixture) -> None:
bytestream = ByteStream(data=b"", mime_type="application/pdf")
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
assert out == []
assert "Could not read PDF file" in caplog.text
def test_convert_pdf_to_images_empty_file(self, caplog: LogCaptureFixture) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
with patch("haystack.components.converters.image.image_utils.PdfDocument") as mock_pdf_document:
mock_pdf_document.__len__.return_value = 0
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
assert out == []
assert "PDF file is empty" in caplog.text
def test_convert_pdf_to_images_scale_if_large_pdf(self, caplog: LogCaptureFixture) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
caplog.set_level(logging.INFO)
mock_pdf_document = MagicMock()
mock_pdf_document.__len__.return_value = 1
mock_page = MagicMock()
mock_page.get_mediabox.return_value = (0, 0, 1e6, 1e6)
mock_pdf_document.__getitem__.return_value = mock_page
with patch("haystack.components.converters.image.image_utils.PdfDocument", return_value=mock_pdf_document):
_convert_pdf_to_images(bytestream=bytestream, page_range=[1])
assert "Large PDF detected" in caplog.text
class TestEncodeImageToBase64:
def test_encode_image_to_base64(self) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/images/haystack-logo.png"))
base64_str = _encode_image_to_base64(bytestream=bytestream)
assert base64_str is not None
def test_encode_image_to_base64_downsize(self) -> None:
bytestream = get_bytestream_from_source(Path("test/test_files/images/haystack-logo.png"))
base64_str = _encode_image_to_base64(bytestream=bytestream, size=(128, 128))
assert base64_str is not None
class TestExtractImageSourcesInfo:
def test_extract_image_source_info(self, test_files_path):
image_paths = glob.glob(str(test_files_path / "images" / "*.*")) + glob.glob(
str(test_files_path / "pdf" / "*.pdf")
)
documents = []
for i, path in enumerate(image_paths):
document = Document(content=f"document number {i}", meta={"file_path": path})
if path.endswith(".pdf"):
document.meta["page_number"] = 1
documents.append(document)
images_source_info = _extract_image_sources_info(
documents=documents, file_path_meta_field="file_path", root_path=""
)
assert len(images_source_info) == len(documents)
for image_source_info in images_source_info:
assert str(image_source_info["path"]) in image_paths
assert image_source_info["mime_type"] in ["image/jpeg", "image/png", "application/pdf"]
if image_source_info["mime_type"] == "application/pdf":
assert image_source_info.get("page_number") == 1
else:
assert "page_number" not in image_source_info
def test_extract_image_source_info_errors(self, test_files_path):
document = Document(content="test")
with pytest.raises(ValueError, match="missing the 'file_path' key"):
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
document = Document(content="test", meta={"file_path": "invalid_path"})
with pytest.raises(ValueError, match="has an invalid file path"):
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
document = Document(content="test", meta={"file_path": str(test_files_path / "docx" / "sample_docx.docx")})
with pytest.raises(ValueError, match="has an unsupported MIME type"):
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
document = Document(content="test", meta={"file_path": str(test_files_path / "pdf" / "sample_pdf_1.pdf")})
with pytest.raises(ValueError, match="missing the 'page_number' key"):
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
def test_extract_image_source_info_rejects_path_traversal(self, test_files_path):
# Attacker-controlled document metadata attempts to escape the configured root.
document = Document(content="test", meta={"file_path": "../../../../../../etc/passwd"})
with pytest.raises(ValueError, match="escapes the configured root"):
_extract_image_sources_info(
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
)
def test_extract_image_source_info_rejects_absolute_outside_root(self, test_files_path):
# Absolute path that lies outside the configured root must be rejected before any IO.
document = Document(content="test", meta={"file_path": "/etc/passwd"})
with pytest.raises(ValueError, match="escapes the configured root"):
_extract_image_sources_info(
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
)
def test_extract_image_source_info_accepts_path_inside_root(self, test_files_path):
# When the resolved path is inside the configured root, processing must succeed.
document = Document(content="test", meta={"file_path": "haystack-logo.png"})
images_source_info = _extract_image_sources_info(
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
)
assert len(images_source_info) == 1
assert images_source_info[0]["mime_type"] == "image/png"
class TestBatchConvertPdfPagesToImages:
@patch("haystack.components.converters.image.image_utils._convert_pdf_to_images")
def test_batch_convert_pdf_pages_to_images(self, mocked_convert_pdf_to_images, test_files_path):
mocked_convert_pdf_to_images.return_value = [
(1, Image.new("RGB", (100, 100))),
(2, Image.new("RGB", (100, 100))),
]
pdf_path = test_files_path / "pdf" / "sample_pdf_1.pdf"
pdf_doc_1: _PDFPageInfo = {"doc_idx": 0, "path": pdf_path, "page_number": 1}
pdf_doc_2: _PDFPageInfo = {"doc_idx": 1, "path": pdf_path, "page_number": 2}
pdf_documents = [pdf_doc_1, pdf_doc_2]
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=pdf_documents, return_base64=False)
pdf_bytestream = ByteStream.from_file_path(pdf_path)
mocked_convert_pdf_to_images.assert_called_once_with(
bytestream=pdf_bytestream, page_range=[1, 2], size=None, return_base64=False
)
assert len(result) == len(pdf_documents)
assert 0 in result and 1 in result
assert isinstance(result[0], Image.Image)
assert isinstance(result[1], Image.Image)
@patch("haystack.components.converters.image.image_utils._convert_pdf_to_images")
def test_batch_convert_pdf_pages_to_images_base64(self, mocked_convert_pdf_to_images, test_files_path):
mocked_convert_pdf_to_images.return_value = [(1, "base64_image_1"), (2, "base64_image_2")]
pdf_path = test_files_path / "pdf" / "sample_pdf_1.pdf"
pdf_doc_1: _PDFPageInfo = {"doc_idx": 0, "path": pdf_path, "page_number": 1}
pdf_doc_2: _PDFPageInfo = {"doc_idx": 1, "path": pdf_path, "page_number": 2}
pdf_documents = [pdf_doc_1, pdf_doc_2]
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=pdf_documents, return_base64=True)
pdf_bytestream = ByteStream.from_file_path(pdf_path)
mocked_convert_pdf_to_images.assert_called_once_with(
bytestream=pdf_bytestream, page_range=[1, 2], size=None, return_base64=True
)
assert result == {0: "base64_image_1", 1: "base64_image_2"}
def test_batch_convert_pdf_pages_to_images_no_pages_info(self):
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=[])
assert isinstance(result, dict)
assert len(result) == 0
@@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from pathlib import Path
from haystack.components.converters.image.image_utils import _convert_pdf_to_images
from haystack.components.converters.image.pdf_to_image import PDFToImageContent
from haystack.core.serialization import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
class TestPDFToImageContent:
def test_to_dict(self) -> None:
converter = PDFToImageContent()
assert component_to_dict(converter, "converter") == {
"init_parameters": {"detail": None, "size": None, "page_range": None},
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
}
def test_to_dict_not_defaults(self) -> None:
converter = PDFToImageContent(detail="low", size=(128, 128), page_range=[1])
assert component_to_dict(converter, "converter") == {
"init_parameters": {"detail": "low", "size": (128, 128), "page_range": [1]},
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
}
def test_from_dict(self) -> None:
data = {
"init_parameters": {"detail": "auto", "size": None, "page_range": [1]},
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
}
converter = component_from_dict(PDFToImageContent, data, "name")
assert component_to_dict(converter, "converter") == data
def test_run_with_valid_source(self) -> None:
file_path = "./test/test_files/pdf/sample_pdf_1.pdf"
mime_type = "application/pdf"
converter = PDFToImageContent()
results = converter.run(sources=[file_path])
byte_stream = ByteStream.from_file_path(Path(file_path), mime_type=mime_type, meta={"file_path": file_path})
assert len(results["image_contents"]) == 4
assert results["image_contents"][0].base64_image is not None
assert (
results["image_contents"][0].base64_image
== _convert_pdf_to_images(bytestream=byte_stream, size=None, page_range=[1], return_base64=True)[0][1]
)
assert results["image_contents"][0].mime_type == "image/jpeg"
assert results["image_contents"][0].detail is None
assert results["image_contents"][0].meta["file_path"] == str(Path(file_path))
assert results["image_contents"][0].meta["page_number"] == 1
assert results["image_contents"][1].meta["page_number"] == 2
assert results["image_contents"][2].meta["page_number"] == 3
assert results["image_contents"][3].meta["page_number"] == 4
def test_run_with_no_sources(self) -> None:
converter = PDFToImageContent()
results = converter.run(sources=[])
assert len(results["image_contents"]) == 0
assert results == {"image_contents": []}
def test_run_with_invalid_source_type(self, caplog) -> None:
converter = PDFToImageContent()
converter.run(sources=[123]) # Invalid source type
assert "Could not read" in caplog.text
def test_run_with_non_existent_file(self, caplog) -> None:
converter = PDFToImageContent()
converter.run(sources=["./non_existent_file.png"])
assert "Could not read" in caplog.text
assert "No such file or directory:" in caplog.text
def test_run_with_bytestream_sources(self) -> None:
file_path = "./test/test_files/pdf/sample_pdf_1.pdf"
mime_type = "application/pdf"
byte_stream = ByteStream.from_file_path(Path(file_path), mime_type=mime_type, meta={"file_path": file_path})
# Initialize the converter
converter = PDFToImageContent()
# Run the converter with the ByteStream
results = converter.run(sources=[byte_stream])
# Assertions
assert len(results["image_contents"]) == 4
assert results["image_contents"][0].base64_image is not None
assert (
results["image_contents"][0].base64_image
== _convert_pdf_to_images(bytestream=byte_stream, size=None, page_range=[1], return_base64=True)[0][1]
)
assert results["image_contents"][0].mime_type == "image/jpeg"
assert results["image_contents"][0].detail is None
assert results["image_contents"][0].meta["file_path"] == file_path
assert results["image_contents"][0].meta["page_number"] == 1
def test_run_with_empty_bytestream(self) -> None:
# Create an empty ByteStream object
byte_stream = ByteStream(data=b"", meta={"file_path": "empty_file.pdf"})
# Initialize the converter
converter = PDFToImageContent()
# Run the converter with the empty ByteStream
results = converter.run(sources=[byte_stream])
assert results["image_contents"] == []
@@ -0,0 +1,244 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import os
import pytest
from haystack.components.converters.csv import CSVToDocument
from haystack.dataclasses import ByteStream
@pytest.fixture
def csv_converter():
return CSVToDocument()
class TestCSVToDocument:
def test_init(self, csv_converter):
assert isinstance(csv_converter, CSVToDocument)
def test_run(self, test_files_path):
"""
Test if the component runs correctly.
"""
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
bytestream.meta["file_path"] = str(test_files_path / "csv" / "sample_1.csv")
bytestream.meta["key"] = "value"
files = [bytestream, test_files_path / "csv" / "sample_2.csv", test_files_path / "csv" / "sample_3.csv"]
converter = CSVToDocument()
output = converter.run(sources=files)
docs = output["documents"]
assert len(docs) == 3
assert docs[0].content == "Name,Age\r\nJohn Doe,27\r\nJane Smith,37\r\nMike Johnson,47\r\n"
assert isinstance(docs[0].content, str)
assert docs[0].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
assert docs[1].meta["file_path"] == os.path.basename(files[1])
assert docs[2].meta["file_path"] == os.path.basename(files[2])
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
bytestream.meta["file_path"] = str(test_files_path / "csv" / "sample_1.csv")
bytestream.meta["key"] = "value"
files = [bytestream, test_files_path / "csv" / "sample_2.csv", test_files_path / "csv" / "sample_3.csv"]
converter = CSVToDocument(store_full_path=False)
output = converter.run(sources=files)
docs = output["documents"]
assert len(docs) == 3
assert docs[0].content == "Name,Age\r\nJohn Doe,27\r\nJane Smith,37\r\nMike Johnson,47\r\n"
assert isinstance(docs[0].content, str)
assert docs[0].meta["file_path"] == "sample_1.csv"
assert docs[0].meta["key"] == "value"
assert docs[1].meta["file_path"] == "sample_2.csv"
assert docs[2].meta["file_path"] == "sample_3.csv"
def test_run_error_handling(self, test_files_path, caplog):
"""
Test if the component correctly handles errors.
"""
paths = [
test_files_path / "csv" / "sample_2.csv",
"non_existing_file.csv",
test_files_path / "csv" / "sample_3.csv",
]
converter = CSVToDocument()
with caplog.at_level(logging.WARNING):
output = converter.run(sources=paths)
assert "non_existing_file.csv" in caplog.text
docs = output["documents"]
assert len(docs) == 2
assert docs[0].meta["file_path"] == os.path.basename(paths[0])
def test_encoding_override(self, test_files_path, caplog):
"""
Test if the encoding metadata field is used properly
"""
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
bytestream.meta["key"] = "value"
converter = CSVToDocument(encoding="utf-16-le")
_ = converter.run(sources=[bytestream])
with caplog.at_level(logging.ERROR):
_ = converter.run(sources=[bytestream])
assert "codec can't decode" in caplog.text
converter = CSVToDocument(encoding="utf-8")
output = converter.run(sources=[bytestream])
assert "Name,Age\r\n" in output["documents"][0].content
def test_run_with_meta(self):
bytestream = ByteStream(
data=b"Name,Age,City\r\nAlice,30,New York\r\nBob,25,Los Angeles\r\nCharlie,35,Chicago\r\n",
meta={"name": "test_name", "language": "en"},
)
converter = CSVToDocument()
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
document = output["documents"][0]
assert document.meta == {"name": "test_name", "language": "it"}
# --- NEW TESTS for strict row mode ---
def test_row_mode_requires_content_column_param(self, tmp_path):
# Missing content_column must raise in row mode
f = tmp_path / "t.csv"
f.write_text("a,b\r\n1,2\r\n", encoding="utf-8")
conv = CSVToDocument(conversion_mode="row")
with pytest.raises(ValueError):
_ = conv.run(sources=[f]) # content_column missing
def test_row_mode_missing_header_raises(self, tmp_path):
# content_column must exist in header
f = tmp_path / "t.csv"
f.write_text("a,b\r\n1,2\r\n", encoding="utf-8")
conv = CSVToDocument(conversion_mode="row")
with pytest.raises(ValueError):
_ = conv.run(sources=[f], content_column="missing")
def test_row_mode_with_content_column(self, tmp_path):
csv_text = "text,author,stars\r\nNice app,Ada,5\r\nBuggy,Bob,2\r\n"
f = tmp_path / "fb.csv"
f.write_text(csv_text, encoding="utf-8")
bytestream = ByteStream.from_file_path(f)
bytestream.meta["file_path"] = str(f)
converter = CSVToDocument(conversion_mode="row")
output = converter.run(sources=[bytestream], content_column="text")
docs = output["documents"]
assert len(docs) == 2
assert [d.content for d in docs] == ["Nice app", "Buggy"]
assert docs[0].meta["author"] == "Ada"
assert docs[0].meta["stars"] == "5"
assert docs[0].meta["row_number"] == 0
assert os.path.basename(f) == docs[0].meta["file_path"]
def test_row_mode_meta_collision_prefixed(self, tmp_path):
# ByteStream meta has file_path and encoding; CSV also has those columns.
csv_text = "file_path,encoding,comment\r\nrowpath.csv,latin1,ok\r\n"
f = tmp_path / "collide.csv"
f.write_text(csv_text, encoding="utf-8")
bs = ByteStream.from_file_path(f)
bs.meta["file_path"] = str(f)
bs.meta["encoding"] = "utf-8"
conv = CSVToDocument(conversion_mode="row")
out = conv.run(sources=[bs], content_column="comment")
d = out["documents"][0]
# Original meta preserved
assert d.meta["file_path"] == os.path.basename(str(f))
assert d.meta["encoding"] == "utf-8"
# CSV columns stored with csv_ prefix (no clobber)
assert d.meta["csv_file_path"] == "rowpath.csv"
assert d.meta["csv_encoding"] == "latin1"
# content column isn't duplicated in meta
assert "comment" not in d.meta
assert d.meta["row_number"] == 0
assert d.content == "ok"
def test_row_mode_meta_collision_multiple_suffixes(self, tmp_path):
"""
If meta already has csv_file_path and csv_file_path_1, we should write the next as csv_file_path_2.
"""
csv_text = "file_path,comment\r\nrow.csv,ok\r\n"
f = tmp_path / "multi.csv"
f.write_text(csv_text, encoding="utf-8")
bs = ByteStream.from_file_path(f)
bs.meta["file_path"] = str(f)
# Pre-seed meta so we force two collisions.
extra_meta = {"csv_file_path": "existing0", "csv_file_path_1": "existing1"}
conv = CSVToDocument(conversion_mode="row")
out = conv.run(sources=[bs], meta=[extra_meta], content_column="comment")
d = out["documents"][0]
assert d.meta["csv_file_path"] == "existing0"
assert d.meta["csv_file_path_1"] == "existing1"
assert d.meta["csv_file_path_2"] == "row.csv"
assert d.content == "ok"
def test_init_validates_delimiter_and_quotechar(self):
with pytest.raises(ValueError):
CSVToDocument(delimiter=";;")
with pytest.raises(ValueError):
CSVToDocument(quotechar='""')
def test_row_mode_large_file_warns(self, caplog, monkeypatch):
# Make the threshold tiny so the warning always triggers.
import haystack.components.converters.csv as csv_mod
monkeypatch.setattr(csv_mod, "_ROW_MODE_SIZE_WARN_BYTES", 1, raising=False)
bs = ByteStream(data=b"text,author\nhi,Ada\n", meta={"file_path": "big.csv"})
conv = CSVToDocument(conversion_mode="row")
with caplog.at_level(logging.WARNING, logger="haystack.components.converters.csv"):
_ = conv.run(sources=[bs], content_column="text")
assert "parsing a large CSV" in caplog.text
def test_row_mode_reader_failure_raises_runtimeerror(self, monkeypatch, tmp_path):
# Simulate DictReader failing -> we should raise RuntimeError (no fallback).
import haystack.components.converters.csv as csv_mod
f = tmp_path / "bad.csv"
f.write_text("a,b\n1,2\n", encoding="utf-8")
conv = CSVToDocument(conversion_mode="row")
class Boom(Exception):
pass
def broken_reader(*_args, **_kwargs): # noqa: D401
raise Boom("broken")
monkeypatch.setattr(csv_mod.csv, "DictReader", broken_reader, raising=True)
with pytest.raises(RuntimeError):
_ = conv.run(sources=[f], content_column="a")
def test_row_mode_ragged_row_does_not_crash(self):
# A data row with more fields than the header (e.g. an unquoted comma inside a value).
# Previously the surplus value landed under the None key, which broke Document id
# generation (TypeError sorting None against str keys) and aborted the whole batch.
valid = ByteStream(data=b"text,author\r\nfine,Ada\r\n", meta={"file_path": "valid.csv"})
ragged = ByteStream(data=b"text,note\r\nhello,city,state\r\n", meta={"file_path": "ragged.csv"})
conv = CSVToDocument(conversion_mode="row")
out = conv.run(sources=[valid, ragged], content_column="text")
docs = out["documents"]
# Both sources yielded a Document; the earlier valid source is not lost.
assert len(docs) == 2
assert docs[0].content == "fine"
assert docs[0].meta["author"] == "Ada"
ragged_doc = docs[1]
assert ragged_doc.content == "hello"
assert ragged_doc.meta["note"] == "city"
# Surplus value is preserved under an explicit (non-None) string meta key.
assert None not in ragged_doc.meta
assert "state" in ragged_doc.meta["extra_columns"]
@@ -0,0 +1,455 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import csv
import json
import logging
import os
from io import StringIO
import pytest
from haystack import Document, Pipeline
from haystack.components.converters.docx import DOCXLinkFormat, DOCXMetadata, DOCXTableFormat, DOCXToDocument
from haystack.dataclasses import ByteStream
@pytest.fixture
def docx_converter():
return DOCXToDocument()
class TestDOCXToDocument:
def test_init(self, docx_converter):
assert isinstance(docx_converter, DOCXToDocument)
def test_init_with_string(self):
converter = DOCXToDocument(table_format="markdown")
assert isinstance(converter, DOCXToDocument)
assert converter.table_format == DOCXTableFormat.MARKDOWN
def test_init_with_invalid_string(self):
with pytest.raises(ValueError, match="Unknown table format 'invalid_format'"):
DOCXToDocument(table_format="invalid_format")
def test_to_dict(self):
converter = DOCXToDocument()
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "none"},
}
def test_to_dict_custom_parameters(self):
converter = DOCXToDocument(table_format="markdown", link_format="markdown")
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"store_full_path": False, "table_format": "markdown", "link_format": "markdown"},
}
converter = DOCXToDocument(table_format="csv", link_format="plain")
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "plain"},
}
converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN, link_format=DOCXLinkFormat.MARKDOWN)
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"store_full_path": False, "table_format": "markdown", "link_format": "markdown"},
}
converter = DOCXToDocument(table_format=DOCXTableFormat.CSV, link_format=DOCXLinkFormat.PLAIN)
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "plain"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"table_format": "csv"},
}
converter = DOCXToDocument.from_dict(data)
assert converter.table_format == DOCXTableFormat.CSV
def test_from_dict_custom_parameters(self):
data = {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"table_format": "markdown", "link_format": "markdown"},
}
converter = DOCXToDocument.from_dict(data)
assert converter.table_format == DOCXTableFormat.MARKDOWN
assert converter.link_format == DOCXLinkFormat.MARKDOWN
def test_from_dict_invalid_table_format(self):
data = {
"type": "haystack.components.converters.docx.DOCXToDocument",
"init_parameters": {"table_format": "invalid_format"},
}
with pytest.raises(ValueError, match="Unknown table format 'invalid_format'"):
DOCXToDocument.from_dict(data)
def test_from_dict_empty_init_parameters(self):
data = {"type": "haystack.components.converters.docx.DOCXToDocument", "init_parameters": {}}
converter = DOCXToDocument.from_dict(data)
assert converter.table_format == DOCXTableFormat.CSV
def test_pipeline_serde(self):
pipeline = Pipeline()
converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN)
pipeline.add_component("converter", converter)
pipeline_str = pipeline.dumps()
assert "haystack.components.converters.docx.DOCXToDocument" in pipeline_str
assert "table_format" in pipeline_str
assert "markdown" in pipeline_str
new_pipeline = Pipeline.loads(pipeline_str)
new_converter = new_pipeline.get_component("converter")
assert isinstance(new_converter, DOCXToDocument)
assert new_converter.table_format == DOCXTableFormat.MARKDOWN
def test_run(self, test_files_path, docx_converter):
"""
Test if the component runs correctly
"""
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
output = docx_converter.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert "History" in docs[0].content
assert docs[0].meta.keys() == {"file_path", "docx"}
assert docs[0].meta == {
"file_path": os.path.basename(paths[0]),
"docx": {
"author": "Microsoft Office User",
"category": "",
"comments": "",
"content_status": "",
"created": "2024-06-09T21:17:00+00:00",
"identifier": "",
"keywords": "",
"language": "",
"last_modified_by": "Carlos Fernández Lorán",
"last_printed": None,
"modified": "2024-06-09T21:27:00+00:00",
"revision": 2,
"subject": "",
"title": "",
"version": "",
},
}
def test_run_with_table(self, test_files_path):
"""
Test if the component runs correctly
"""
docx_converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN)
paths = [test_files_path / "docx" / "sample_docx.docx"]
output = docx_converter.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert "Donald Trump" in docs[0].content ## :-)
assert docs[0].meta.keys() == {"file_path", "docx"}
assert docs[0].meta == {
"file_path": os.path.basename(paths[0]),
"docx": {
"author": "Saha, Anirban",
"category": "",
"comments": "",
"content_status": "",
"created": "2020-07-14T08:14:00+00:00",
"identifier": "",
"keywords": "",
"language": "",
"last_modified_by": "Saha, Anirban",
"last_printed": None,
"modified": "2020-07-14T08:16:00+00:00",
"revision": 1,
"subject": "",
"title": "",
"version": "",
},
}
# let's now detect that the table markdown is correctly added and that order of elements is correct
content_parts = docs[0].content.split("\n\n")
table_index = next(i for i, part in enumerate(content_parts) if "| This | Is | Just a |" in part)
# check that natural order of the document is preserved
assert any("Donald Trump" in part for part in content_parts[:table_index]), "Text before table not found"
assert any("Now we are in Page 2" in part for part in content_parts[table_index + 1 :]), (
"Text after table not found"
)
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
docx_converter = DOCXToDocument(store_full_path=False)
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
output = docx_converter.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert "History" in docs[0].content
assert docs[0].meta.keys() == {"file_path", "docx"}
assert docs[0].meta == {
"file_path": "sample_docx_1.docx",
"docx": {
"author": "Microsoft Office User",
"category": "",
"comments": "",
"content_status": "",
"created": "2024-06-09T21:17:00+00:00",
"identifier": "",
"keywords": "",
"language": "",
"last_modified_by": "Carlos Fernández Lorán",
"last_printed": None,
"modified": "2024-06-09T21:27:00+00:00",
"revision": 2,
"subject": "",
"title": "",
"version": "",
},
}
@pytest.mark.parametrize("table_format", ["markdown", "csv"])
def test_table_between_two_paragraphs(self, test_files_path, table_format):
docx_converter = DOCXToDocument(table_format=table_format)
paths = [test_files_path / "docx" / "sample_docx_3.docx"]
output = docx_converter.run(sources=paths)
content = output["documents"][0].content
paragraphs_one = content.find("Table: AI Use Cases in Different Industries")
paragraphs_two = content.find("Paragraph 2:")
table = content[
paragraphs_one + len("Table: AI Use Cases in Different Industries") + 1 : paragraphs_two
].strip()
if table_format == "markdown":
split = list(filter(None, table.split("\n")))
expected_table_header = "| Industry | AI Use Case | Impact |"
expected_last_row = "| Finance | Fraud detection and prevention | Reduced financial losses |"
assert split[0] == expected_table_header
assert split[-1] == expected_last_row
if table_format == "csv": # CSV format
csv_reader = csv.reader(StringIO(table))
rows = list(csv_reader)
assert len(rows) == 3 # Header + 2 data rows
assert rows[0] == ["Industry", "AI Use Case", "Impact"]
assert rows[-1] == ["Finance", "Fraud detection and prevention", "Reduced financial losses"]
@pytest.mark.parametrize("table_format", ["markdown", "csv"])
def test_table_content_correct_parsing(self, test_files_path, table_format):
docx_converter = DOCXToDocument(table_format=table_format)
paths = [test_files_path / "docx" / "sample_docx_3.docx"]
output = docx_converter.run(sources=paths)
content = output["documents"][0].content
paragraphs_one = content.find("Table: AI Use Cases in Different Industries")
paragraphs_two = content.find("Paragraph 2:")
table = content[
paragraphs_one + len("Table: AI Use Cases in Different Industries") + 1 : paragraphs_two
].strip()
if table_format == "markdown":
split = list(filter(None, table.split("\n")))
assert len(split) == 4
expected_table_header = "| Industry | AI Use Case | Impact |"
expected_table_top_border = "| ---------- | ------------------------------ | ------------------------- |"
expected_table_row_one = "| Healthcare | Predictive diagnostics | Improved patient outcomes |"
expected_table_row_two = "| Finance | Fraud detection and prevention | Reduced financial losses |"
assert split[0] == expected_table_header
assert split[1] == expected_table_top_border
assert split[2] == expected_table_row_one
assert split[3] == expected_table_row_two
if table_format == "csv": # CSV format
csv_reader = csv.reader(StringIO(table))
rows = list(csv_reader)
assert len(rows) == 3 # Header + 2 data rows
expected_header = ["Industry", "AI Use Case", "Impact"]
expected_row_one = ["Healthcare", "Predictive diagnostics", "Improved patient outcomes"]
expected_row_two = ["Finance", "Fraud detection and prevention", "Reduced financial losses"]
assert rows[0] == expected_header
assert rows[1] == expected_row_one
assert rows[2] == expected_row_two
def test_run_with_additional_meta(self, test_files_path, docx_converter):
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
output = docx_converter.run(sources=paths, meta={"language": "it", "author": "test_author"})
doc = output["documents"][0]
assert doc.meta == {
"file_path": os.path.basename(paths[0]),
"docx": {
"author": "Microsoft Office User",
"category": "",
"comments": "",
"content_status": "",
"created": "2024-06-09T21:17:00+00:00",
"identifier": "",
"keywords": "",
"language": "",
"last_modified_by": "Carlos Fernández Lorán",
"last_printed": None,
"modified": "2024-06-09T21:27:00+00:00",
"revision": 2,
"subject": "",
"title": "",
"version": "",
},
"language": "it",
"author": "test_author",
}
def test_run_error_wrong_file_type(self, caplog, test_files_path, docx_converter):
sources = [str(test_files_path / "txt" / "doc_1.txt")]
with caplog.at_level(logging.WARNING):
results = docx_converter.run(sources=sources)
assert "doc_1.txt and convert it" in caplog.text
assert results["documents"] == []
def test_run_error_non_existent_file(self, docx_converter, caplog):
"""
Test if the component correctly handles errors.
"""
paths = ["non_existing_file.docx"]
with caplog.at_level(logging.WARNING):
docx_converter.run(sources=paths)
assert "Could not read non_existing_file.docx" in caplog.text
def test_run_page_breaks(self, test_files_path, docx_converter):
"""
Test if the component correctly parses page breaks.
"""
paths = [test_files_path / "docx" / "sample_docx_2_page_breaks.docx"]
output = docx_converter.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert docs[0].content.count("\f") == 4
def test_mixed_sources_run(self, test_files_path, docx_converter):
"""
Test if the component runs correctly when mixed sources are provided.
"""
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
with open(test_files_path / "docx" / "sample_docx_1.docx", "rb") as f:
paths.append(ByteStream(f.read()))
output = docx_converter.run(sources=paths)
docs = output["documents"]
assert len(docs) == 2
assert "History and standardization" in docs[0].content
assert "History and standardization" in docs[1].content
def test_document_with_docx_metadata_to_dict(self):
docx_metadata = DOCXMetadata(
author="Microsoft Office User",
category="category",
comments="comments",
content_status="",
created="2024-06-09T21:17:00+00:00",
identifier="",
keywords="",
language="",
last_modified_by="Carlos Fernández Lorán",
last_printed=None,
modified="2024-06-09T21:27:00+00:00",
revision=2,
subject="",
title="",
version="",
)
doc = Document(content="content", meta={"test": 1, "docx": docx_metadata}, id="1")
assert doc.to_dict(flatten=False) == {
"blob": None,
"content": "content",
"id": "1",
"score": None,
"embedding": None,
"sparse_embedding": None,
"meta": {
"test": 1,
"docx": {
"author": "Microsoft Office User",
"category": "category",
"comments": "comments",
"content_status": "",
"created": "2024-06-09T21:17:00+00:00",
"identifier": "",
"keywords": "",
"language": "",
"last_modified_by": "Carlos Fernández Lorán",
"last_printed": None,
"modified": "2024-06-09T21:27:00+00:00",
"revision": 2,
"subject": "",
"title": "",
"version": "",
},
},
}
# check it is JSON serializable
json_str = json.dumps(doc.to_dict(flatten=False))
assert json.loads(json_str) == doc.to_dict(flatten=False)
def test_link_format_initialization(self):
converter = DOCXToDocument(link_format="markdown")
assert converter.link_format == DOCXLinkFormat.MARKDOWN
converter = DOCXToDocument(link_format=DOCXLinkFormat.PLAIN)
assert converter.link_format == DOCXLinkFormat.PLAIN
def test_link_format_invalid(self):
with pytest.raises(ValueError, match="Unknown link format 'invalid_format'"):
DOCXToDocument(link_format="invalid_format")
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
def test_link_extraction(self, test_files_path, link_format):
docx_converter = DOCXToDocument(link_format=link_format)
paths = [test_files_path / "docx" / "sample_docx_with_single_link.docx"]
output = docx_converter.run(sources=paths)
content = output["documents"][0].content
if link_format == "markdown":
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" in content
else: # plain format
assert "PDF (https://en.wikipedia.org/wiki/PDF)" in content
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
def test_link_extraction_page_break(self, test_files_path, link_format):
docx_converter = DOCXToDocument(link_format=link_format)
paths = [test_files_path / "docx" / "sample_docx_with_links.docx"]
output = docx_converter.run(sources=paths)
content = output["documents"][0].content
if link_format == "markdown":
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" in content
assert "[of](https://en.wikipedia.org/wiki/OF)" in content
assert "[charge](https://en.wikipedia.org/wiki/Charge)" in content
assert "[disambiguation link](https://en.wikipedia.org/wiki/PDF_(disambiguation))" in content
else: # plain format
assert "PDF (https://en.wikipedia.org/wiki/PDF)" in content
assert "of (https://en.wikipedia.org/wiki/OF)" in content
assert "charge (https://en.wikipedia.org/wiki/Charge)" in content
assert "disambiguation link (https://en.wikipedia.org/wiki/PDF_(disambiguation))" in content
def test_no_link_extraction(self, test_files_path):
docx_converter = DOCXToDocument()
paths = [test_files_path / "docx" / "sample_docx_with_single_link.docx"]
output = docx_converter.run(sources=paths)
content = output["documents"][0].content
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" not in content
assert "PDF (https://en.wikipedia.org/wiki/PDF)" not in content
@@ -0,0 +1,138 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import base64
from pathlib import Path
import pytest
from haystack.components.converters.file_to_file_content import FileToFileContent
from haystack.core.serialization import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
class TestFileToFileContent:
def test_to_dict(self) -> None:
converter = FileToFileContent()
assert component_to_dict(converter, "converter") == {
"init_parameters": {},
"type": "haystack.components.converters.file_to_file_content.FileToFileContent",
}
def test_from_dict(self) -> None:
data = {"init_parameters": {}, "type": "haystack.components.converters.file_to_file_content.FileToFileContent"}
converter = component_from_dict(FileToFileContent, data, "name")
assert component_to_dict(converter, "converter") == data
@pytest.mark.parametrize(
("file_path", "mime_type"),
[
("./test/test_files/pdf/sample_pdf_1.pdf", "application/pdf"),
("./test/test_files/txt/doc_1.txt", "text/plain"),
],
)
def test_run_with_valid_sources(self, file_path: str, mime_type: str) -> None:
converter = FileToFileContent()
results = converter.run(sources=[file_path])
assert len(results["file_contents"]) == 1
file_content = results["file_contents"][0]
assert file_content.base64_data is not None
assert file_content.mime_type == mime_type
assert file_content.filename == Path(file_path).name
assert file_content.extra == {}
with open(file_path, "rb") as f:
expected_base64 = base64.b64encode(f.read()).decode("utf-8")
assert file_content.base64_data == expected_base64
@pytest.mark.parametrize(
("file_path", "mime_type"),
[
("./test/test_files/pdf/sample_pdf_1.pdf", "application/pdf"),
("./test/test_files/audio/answer.wav", "audio/x-wav"),
],
)
def test_run_with_bytestream_sources(self, file_path: str, mime_type: str) -> None:
byte_stream = ByteStream.from_file_path(Path(file_path))
converter = FileToFileContent()
results = converter.run(sources=[byte_stream])
assert len(results["file_contents"]) == 1
file_content = results["file_contents"][0]
assert file_content.base64_data is not None
assert file_content.mime_type == mime_type
assert file_content.filename is None
assert file_content.extra == {}
expected_base64 = base64.b64encode(byte_stream.data).decode("utf-8")
assert file_content.base64_data == expected_base64
def test_run_with_no_sources(self) -> None:
converter = FileToFileContent()
results = converter.run(sources=[])
assert results == {"file_contents": []}
def test_run_with_invalid_source_type(self, caplog) -> None:
converter = FileToFileContent()
converter.run(sources=[123])
assert "Could not read" in caplog.text
def test_run_with_non_existent_file(self, caplog) -> None:
converter = FileToFileContent()
converter.run(sources=["./non_existent_file.pdf"])
assert "Could not read" in caplog.text
def test_run_with_empty_bytestream(self) -> None:
byte_stream = ByteStream(data=b"")
converter = FileToFileContent()
results = converter.run(sources=[byte_stream])
assert results["file_contents"] == []
def test_run_with_extra_dict(self) -> None:
converter = FileToFileContent()
extra = {"key": "value"}
results = converter.run(sources=["./test/test_files/txt/doc_1.txt"], extra=extra)
assert len(results["file_contents"]) == 1
assert results["file_contents"][0].extra == extra
def test_run_with_extra_list(self) -> None:
converter = FileToFileContent()
sources = ["./test/test_files/txt/doc_1.txt", "./test/test_files/txt/doc_2.txt"]
extra = [{"key": "value1"}, {"key": "value2"}]
results = converter.run(sources=sources, extra=extra)
assert len(results["file_contents"]) == 2
assert results["file_contents"][0].extra == {"key": "value1"}
assert results["file_contents"][1].extra == {"key": "value2"}
def test_run_with_extra_dict_does_not_share_reference(self) -> None:
# A single ``extra`` dict is applied to every source; each FileContent must get its own copy
# so that mutating one file's ``extra`` downstream does not leak into the others.
converter = FileToFileContent()
sources = ["./test/test_files/txt/doc_1.txt", "./test/test_files/txt/doc_2.txt"]
results = converter.run(sources=sources, extra={"tenant": "acme"})
file_contents = results["file_contents"]
assert len(file_contents) == 2
assert file_contents[0].extra is not file_contents[1].extra
file_contents[0].extra["page"] = 1
assert "page" not in file_contents[1].extra
def test_run_skips_empty_files_among_valid(self, caplog) -> None:
byte_stream_empty = ByteStream(data=b"")
valid_source = "./test/test_files/txt/doc_1.txt"
converter = FileToFileContent()
results = converter.run(sources=[byte_stream_empty, valid_source])
assert len(results["file_contents"]) == 1
assert "empty" in caplog.text
@@ -0,0 +1,254 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from haystack.components.converters import HTMLToDocument
from haystack.dataclasses import ByteStream
class TestHTMLToDocument:
def test_run(self, test_files_path):
"""
Test if the component runs correctly.
"""
sources = [test_files_path / "html" / "what_is_haystack.html"]
converter = HTMLToDocument()
results = converter.run(sources=sources, meta={"test": "TEST"})
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta["test"] == "TEST"
def test_run_doc_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is supplied by the user.
"""
converter = HTMLToDocument()
sources = [test_files_path / "html" / "what_is_haystack.html"]
metadata = [{"file_name": "what_is_haystack.html"}]
results = converter.run(sources=sources, meta=metadata)
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta["file_name"] == "what_is_haystack.html"
def test_run_with_store_full_path(self, test_files_path):
"""
Test if the component runs correctly when metadata is supplied by the user.
"""
converter = HTMLToDocument(store_full_path=True)
sources = [test_files_path / "html" / "what_is_haystack.html"]
results = converter.run(sources=sources) # store_full_path is True by default
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta["file_path"] == str(sources[0])
converter_2 = HTMLToDocument(store_full_path=False)
results = converter_2.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta["file_path"] == "what_is_haystack.html"
def test_incorrect_meta(self, test_files_path):
"""
Test if the component raises an error when incorrect metadata is supplied by the user.
"""
converter = HTMLToDocument()
sources = [test_files_path / "html" / "what_is_haystack.html"]
metadata = [{"file_name": "what_is_haystack.html"}, {"file_name": "haystack.html"}]
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
converter.run(sources=sources, meta=metadata)
def test_run_bytestream_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is read from the ByteStream object.
"""
converter = HTMLToDocument()
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
byte_stream = file.read()
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url"})
results = converter.run(sources=[stream])
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta == {"content_type": "text/html", "url": "test_url"}
def test_run_bytestream_and_doc_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
There is no overlap between the metadata received.
"""
converter = HTMLToDocument()
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
byte_stream = file.read()
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url"})
metadata = [{"file_name": "what_is_haystack.html"}]
results = converter.run(sources=[stream], meta=metadata)
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta == {"file_name": "what_is_haystack.html", "content_type": "text/html", "url": "test_url"}
def test_run_bytestream_doc_overlapping_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
There is an overlap between the metadata received.
The component should use the supplied metadata to overwrite the values if there is an overlap between the keys.
"""
converter = HTMLToDocument()
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
byte_stream = file.read()
# ByteStream has "url" present in metadata
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url_correct"})
# "url" supplied by the user overwrites value present in metadata
metadata = [{"file_name": "what_is_haystack.html", "url": "test_url_new"}]
results = converter.run(sources=[stream], meta=metadata)
docs = results["documents"]
assert len(docs) == 1
assert "Haystack" in docs[0].content
assert docs[0].meta == {
"file_name": "what_is_haystack.html",
"content_type": "text/html",
"url": "test_url_new",
}
def test_run_wrong_file_type(self, test_files_path, caplog):
"""
Test if the component runs correctly when an input file is not of the expected type.
"""
sources = [test_files_path / "audio" / "answer.wav"]
converter = HTMLToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "Failed to extract text from" in caplog.text
assert results["documents"] == []
def test_run_error_handling(self, caplog):
"""
Test if the component correctly handles errors.
"""
sources = ["non_existing_file.html"]
converter = HTMLToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "Could not read non_existing_file.html" in caplog.text
assert results["documents"] == []
def test_run_empty_bytestream(self, caplog):
"""
Test that an empty ByteStream is skipped without invoking extraction,
so no noisy lxml parse errors are emitted.
"""
empty_stream = ByteStream(data=b"")
empty_stream.mime_type = "text/html"
converter = HTMLToDocument()
with patch("haystack.components.converters.html.extract") as mock_extract:
with caplog.at_level(logging.WARNING):
results = converter.run(sources=[empty_stream])
assert results["documents"] == []
mock_extract.assert_not_called()
assert "because it is empty" in caplog.text
def test_mixed_sources_run(self, test_files_path):
"""
Test if the component runs correctly if the input is a mix of paths and ByteStreams.
"""
sources = [
test_files_path / "html" / "what_is_haystack.html",
str((test_files_path / "html" / "what_is_haystack.html").absolute()),
]
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as f:
byte_stream = f.read()
sources.append(ByteStream(byte_stream))
converter = HTMLToDocument()
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 3
for doc in docs:
assert "Haystack" in doc.content
def test_bytestream_encoding_from_meta(self):
"""
Test that a non-UTF-8 ByteStream is decoded using the encoding specified in its meta.
"""
# "caf\xe9" is "café" in latin-1; decoding as utf-8 would raise UnicodeDecodeError.
latin1_html = b"<html><body><p>caf\xe9</p></body></html>"
bytestream = ByteStream(data=latin1_html, meta={"encoding": "latin-1"})
converter = HTMLToDocument()
results = converter.run(sources=[bytestream])
docs = results["documents"]
assert len(docs) == 1
assert "café" in docs[0].content
def test_bytestream_encoding_from_init(self):
"""
Test that the encoding passed to __init__ is used as a fallback when not set in ByteStream meta.
"""
latin1_html = b"<html><body><p>caf\xe9</p></body></html>"
bytestream = ByteStream(data=latin1_html)
converter = HTMLToDocument(encoding="latin-1")
results = converter.run(sources=[bytestream])
docs = results["documents"]
assert len(docs) == 1
assert "café" in docs[0].content
def test_serde(self):
"""
Test if the component runs correctly gets serialized and deserialized.
"""
converter = HTMLToDocument(encoding="latin-1")
serde_data = converter.to_dict()
new_converter = HTMLToDocument.from_dict(serde_data)
assert new_converter.extraction_kwargs == converter.extraction_kwargs
assert new_converter.encoding == converter.encoding
def test_run_difficult_html(self, test_files_path):
converter = HTMLToDocument()
result = converter.run(sources=[Path(test_files_path / "html" / "paul_graham_superlinear.html")])
assert len(result["documents"]) == 1
assert "Superlinear" in result["documents"][0].content
@patch("haystack.components.converters.html.extract", return_value="test")
def test_run_with_extraction_kwargs(self, mock_extract, test_files_path):
sources = [test_files_path / "html" / "what_is_haystack.html"]
converter = HTMLToDocument()
converter.run(sources=sources)
assert mock_extract.call_count == 1
assert "favor_precision" not in mock_extract.call_args[1]
precise_converter = HTMLToDocument(extraction_kwargs={"favor_precision": True})
mock_extract.reset_mock()
precise_converter.run(sources=sources)
assert mock_extract.call_count == 1
assert mock_extract.call_args[1]["favor_precision"] is True
+518
View File
@@ -0,0 +1,518 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import logging
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from haystack.components.converters import JSONConverter
from haystack.dataclasses import ByteStream
test_data = [
{
"year": "1997",
"category": "literature",
"laureates": [
{
"id": "674",
"firstname": "Dario",
"surname": "Fokin",
"motivation": "who emulates the jesters of the Middle Ages in scourging authority and upholding the "
"dignity of the downtrodden",
"share": "1",
}
],
},
{
"year": "1986",
"category": "medicine",
"laureates": [
{
"id": "434",
"firstname": "Stanley",
"surname": "Cohen",
"motivation": "for their discoveries of growth factors",
"share": "2",
},
{
"id": "435",
"firstname": "Rita",
"surname": "Levi-Montalcini",
"motivation": "for their discoveries of growth factors",
"share": "2",
},
],
},
{
"year": "1938",
"category": "physics",
"laureates": [
{
"id": "46",
"firstname": "Enrico",
"surname": "Fermi",
"motivation": "for his demonstrations of the existence of new radioactive elements produced by neutron "
"irradiation, and for his related discovery of nuclear reactions brought about by slow "
"neutrons",
"share": "1",
}
],
},
]
def test_init_without_jq_schema_and_content_key():
with pytest.raises(
ValueError, match="No `jq_schema` nor `content_key` specified. Set either or both to extract data."
):
JSONConverter()
@patch("haystack.components.converters.json.jq_import")
def test_init_without_jq_schema_and_missing_dependency(jq_import):
converter = JSONConverter(content_key="foo")
jq_import.check.assert_not_called()
assert converter._jq_schema is None
assert converter._content_key == "foo"
assert converter._meta_fields is None
@patch("haystack.components.converters.json.jq_import")
def test_init_with_jq_schema_and_missing_dependency(jq_import):
jq_import.check.side_effect = ImportError
with pytest.raises(ImportError):
JSONConverter(jq_schema=".laureates[].motivation")
def test_init_with_jq_schema():
converter = JSONConverter(jq_schema=".")
assert converter._jq_schema == "."
assert converter._content_key is None
assert converter._meta_fields is None
def test_to_dict():
converter = JSONConverter(
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
)
assert converter.to_dict() == {
"type": "haystack.components.converters.json.JSONConverter",
"init_parameters": {
"content_key": "motivation",
"jq_schema": ".laureates[]",
"extra_meta_fields": {"firstname", "surname"},
"store_full_path": False,
},
}
def test_from_dict():
data = {
"type": "haystack.components.converters.json.JSONConverter",
"init_parameters": {
"content_key": "motivation",
"jq_schema": ".laureates[]",
"extra_meta_fields": ["firstname", "surname"],
"store_full_path": True,
},
}
converter = JSONConverter.from_dict(data)
assert converter._jq_schema == ".laureates[]"
assert converter._content_key == "motivation"
assert converter._meta_fields == ["firstname", "surname"]
def test_run(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
assert result["documents"][2].meta == {"file_path": os.path.basename(second_test_file)}
assert (
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {}
def test_run_with_store_full_path_false(tmpdir):
"""
Test if the component runs correctly with store_full_path=False
"""
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(
jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation', store_full_path=False
)
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {"file_path": "first_test_file.json"}
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
assert result["documents"][1].meta == {"file_path": "second_test_file.json"}
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
assert result["documents"][2].meta == {"file_path": "second_test_file.json"}
assert (
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {}
def test_run_with_non_json_file(tmpdir, caplog):
test_file = Path(tmpdir / "test_file.md")
test_file.write_text("This is not a JSON file.", "utf-8")
sources = [test_file]
converter = JSONConverter(".laureates | .motivation")
caplog.clear()
with caplog.at_level(logging.WARNING):
result = converter.run(sources=sources)
records = caplog.records
assert len(records) == 1
assert (
records[0].msg
== f"Failed to extract text from {test_file}. Skipping it. Error: parse error: Invalid numeric literal at "
f"line 1, column 5"
)
assert result == {"documents": []}
def test_run_with_bad_filter(tmpdir, caplog):
test_file = Path(tmpdir / "test_file.json")
test_file.write_text(json.dumps(test_data[0]), "utf-8")
sources = [test_file]
converter = JSONConverter(".laureates | .motivation")
caplog.clear()
with caplog.at_level(logging.WARNING):
result = converter.run(sources=sources)
records = caplog.records
assert len(records) == 1
assert (
records[0].msg
== f'Failed to extract text from {test_file}. Skipping it. Error: Cannot index array with string "motivation"'
)
assert result == {"documents": []}
def test_run_with_bad_encoding(tmpdir, caplog):
test_file = Path(tmpdir / "test_file.json")
test_file.write_text(json.dumps(test_data[0]), "utf-16")
sources = [test_file]
converter = JSONConverter(".laureates")
caplog.clear()
with caplog.at_level(logging.WARNING):
result = converter.run(sources=sources)
records = caplog.records
assert len(records) == 1
assert records[0].msg.startswith(
f"Failed to extract text from {test_file}. Skipping it. Error: 'utf-8' codec can't decode byte"
)
assert result == {"documents": []}
def test_run_with_single_meta(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
meta = {"creation_date": "1945-05-25T00:00:00"}
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
result = converter.run(sources=sources, meta=meta)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {
"file_path": os.path.basename(first_test_file),
"creation_date": "1945-05-25T00:00:00",
}
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
assert result["documents"][1].meta == {
"file_path": os.path.basename(second_test_file),
"creation_date": "1945-05-25T00:00:00",
}
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
assert result["documents"][2].meta == {
"file_path": os.path.basename(second_test_file),
"creation_date": "1945-05-25T00:00:00",
}
assert (
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {"creation_date": "1945-05-25T00:00:00"}
def test_run_with_meta_list(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
meta = [
{"creation_date": "1945-05-25T00:00:00"},
{"creation_date": "1943-09-03T00:00:00"},
{"creation_date": "1989-11-09T00:00:00"},
]
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
result = converter.run(sources=sources, meta=meta)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {
"file_path": os.path.basename(first_test_file),
"creation_date": "1945-05-25T00:00:00",
}
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
assert result["documents"][1].meta == {
"file_path": os.path.basename(second_test_file),
"creation_date": "1943-09-03T00:00:00",
}
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
assert result["documents"][2].meta == {
"file_path": os.path.basename(second_test_file),
"creation_date": "1943-09-03T00:00:00",
}
assert (
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {"creation_date": "1989-11-09T00:00:00"}
def test_run_with_meta_list_of_differing_length(tmpdir):
sources = ["random_file.json"]
meta = [{}, {}]
converter = JSONConverter(jq_schema=".")
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
converter.run(sources=sources, meta=meta)
def test_run_with_jq_schema_and_content_key(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(jq_schema=".laureates[]", content_key="motivation")
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content == "who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
assert result["documents"][1].content == "for their discoveries of growth factors"
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
assert result["documents"][2].content == "for their discoveries of growth factors"
assert result["documents"][2].meta == {"file_path": os.path.basename(second_test_file)}
assert (
result["documents"][3].content == "for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {}
def test_run_with_jq_schema_content_key_and_extra_meta_fields(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
)
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content == "who emulates the jesters of the Middle Ages in scourging authority and "
"upholding the dignity of the downtrodden"
)
assert result["documents"][0].meta == {
"file_path": os.path.basename(first_test_file),
"firstname": "Dario",
"surname": "Fokin",
}
assert result["documents"][1].content == "for their discoveries of growth factors"
assert result["documents"][1].meta == {
"file_path": os.path.basename(second_test_file),
"firstname": "Stanley",
"surname": "Cohen",
}
assert result["documents"][2].content == "for their discoveries of growth factors"
assert result["documents"][2].meta == {
"file_path": os.path.basename(second_test_file),
"firstname": "Rita",
"surname": "Levi-Montalcini",
}
assert (
result["documents"][3].content == "for his demonstrations of the existence of new "
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
"reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {"firstname": "Enrico", "surname": "Fermi"}
def test_run_with_content_key(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(content_key="category")
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 3
assert result["documents"][0].content == "literature"
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
assert result["documents"][1].content == "medicine"
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
assert result["documents"][2].content == "physics"
assert result["documents"][2].meta == {}
def test_run_with_content_key_and_extra_meta_fields(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(content_key="category", extra_meta_fields={"year"})
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 3
assert result["documents"][0].content == "literature"
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file), "year": "1997"}
assert result["documents"][1].content == "medicine"
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file), "year": "1986"}
assert result["documents"][2].content == "physics"
assert result["documents"][2].meta == {"year": "1938"}
def test_run_with_jq_schema_content_key_and_extra_meta_fields_literal(tmpdir):
first_test_file = Path(tmpdir / "first_test_file.json")
second_test_file = Path(tmpdir / "second_test_file.json")
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
sources = [str(first_test_file), second_test_file, byte_stream]
converter = JSONConverter(jq_schema=".laureates[]", content_key="motivation", extra_meta_fields="*")
result = converter.run(sources=sources)
assert len(result) == 1
assert len(result["documents"]) == 4
assert (
result["documents"][0].content
== "who emulates the jesters of the Middle Ages in scourging authority and upholding the dignity of the "
"downtrodden"
)
assert result["documents"][0].meta == {
"file_path": os.path.basename(first_test_file),
"id": "674",
"firstname": "Dario",
"surname": "Fokin",
"share": "1",
}
assert result["documents"][1].content == "for their discoveries of growth factors"
assert result["documents"][1].meta == {
"file_path": os.path.basename(second_test_file),
"id": "434",
"firstname": "Stanley",
"surname": "Cohen",
"share": "2",
}
assert result["documents"][2].content == "for their discoveries of growth factors"
assert result["documents"][2].meta == {
"file_path": os.path.basename(second_test_file),
"id": "435",
"firstname": "Rita",
"surname": "Levi-Montalcini",
"share": "2",
}
assert (
result["documents"][3].content
== "for his demonstrations of the existence of new radioactive elements produced by neutron irradiation, "
"and for his related discovery of nuclear reactions brought about by slow neutrons"
)
assert result["documents"][3].meta == {"id": "46", "firstname": "Enrico", "surname": "Fermi", "share": "1"}
@@ -0,0 +1,233 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from unittest.mock import patch
import pytest
from haystack.components.converters.markdown import MarkdownToDocument
from haystack.dataclasses import ByteStream
class TestMarkdownToDocument:
def test_init_params_default(self):
converter = MarkdownToDocument()
assert converter.table_to_single_line is False
assert converter.progress_bar is True
assert converter.encoding == "utf-8"
assert converter.extract_frontmatter is False
def test_init_params_custom(self):
converter = MarkdownToDocument(table_to_single_line=True, progress_bar=False, store_full_path=False)
assert converter.table_to_single_line is True
assert converter.progress_bar is False
assert converter.store_full_path is False
@pytest.mark.integration
def test_run(self, test_files_path):
converter = MarkdownToDocument()
sources = [test_files_path / "markdown" / "sample.md"]
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
for doc in docs:
assert "What to build with Haystack" in doc.content
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
converter = MarkdownToDocument(store_full_path=False)
sources = [test_files_path / "markdown" / "sample.md"]
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
for doc in docs:
assert "What to build with Haystack" in doc.content
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
assert doc.meta["file_path"] == "sample.md"
def test_run_calls_normalize_metadata(self, test_files_path):
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
converter = MarkdownToDocument()
with patch("haystack.components.converters.markdown.normalize_metadata") as normalize_metadata:
normalize_metadata.return_value = [{"language": "it"}, {"language": "it"}]
converter.run(sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"})
# check that the metadata normalizer is called properly
normalize_metadata.assert_called_with(meta={"language": "it"}, sources_count=2)
def test_run_with_meta(self, test_files_path):
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
converter = MarkdownToDocument()
with patch("haystack.components.converters.markdown.MarkdownIt.render") as mock_render:
mock_render.return_value = "test"
output = converter.run(
sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"}
)
# check that the metadata from the bytestream is merged with that from the meta parameter
assert output["documents"][0].meta["author"] == "test_author"
assert output["documents"][0].meta["language"] == "it"
assert output["documents"][1].meta["language"] == "it"
def test_run_extracts_yaml_frontmatter_into_metadata(self):
bytestream = ByteStream(
data=(
b"---\n"
b"ticker: AAPL\n"
b"date: 2026-06-12\n"
b"rating_score: 4\n"
b"source: earnings_call\n"
b"tags:\n"
b" - guidance\n"
b"---\n"
b"# Thesis\n"
b"Revenue guidance improved.\n"
),
meta={"file_path": "/tmp/aapl.md"},
)
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
output = converter.run(sources=[bytestream])
document = output["documents"][0]
assert "Revenue guidance improved." in document.content
assert "ticker: AAPL" not in document.content
assert document.meta["ticker"] == "AAPL"
assert document.meta["date"] == "2026-06-12"
assert document.meta["rating_score"] == 4
assert document.meta["source"] == "earnings_call"
assert document.meta["tags"] == ["guidance"]
assert document.meta["file_path"] == "aapl.md"
def test_run_keeps_frontmatter_as_content_by_default(self):
bytestream = ByteStream(data=b"---\nticker: AAPL\n---\n# Thesis\n")
converter = MarkdownToDocument(progress_bar=False)
output = converter.run(sources=[bytestream])
document = output["documents"][0]
assert "ticker: AAPL" in document.content
assert "ticker" not in document.meta
def test_run_meta_overrides_frontmatter_metadata(self):
bytestream = ByteStream(
data=b"---\nticker: AAPL\nsource: filing\n---\n# Thesis\n", meta={"source": "bytestream"}
)
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
output = converter.run(sources=[bytestream], meta={"ticker": "MSFT"})
document = output["documents"][0]
assert document.meta["ticker"] == "MSFT"
assert document.meta["source"] == "filing"
def test_run_keeps_malformed_frontmatter_as_content_and_logs_warning(self, caplog):
bytestream = ByteStream(data=b"---\nticker: [AAPL\n---\n# Thesis\n")
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
with caplog.at_level(logging.WARNING):
output = converter.run(sources=[bytestream])
document = output["documents"][0]
assert "ticker: [AAPL" in document.content
assert "ticker" not in document.meta
assert "Could not parse YAML frontmatter" in caplog.text
def test_run_keeps_unserializable_frontmatter_as_content_and_logs_warning(self, caplog):
bytestream = ByteStream(data=b"---\ncycle: &cycle\n - *cycle\n---\n# Thesis\n")
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
with caplog.at_level(logging.WARNING):
output = converter.run(sources=[bytestream])
document = output["documents"][0]
assert "cycle:" in document.content
assert "cycle" not in document.meta
assert "Could not convert YAML frontmatter" in caplog.text
@pytest.mark.integration
def test_run_wrong_file_type(self, test_files_path, caplog):
"""
Test if the component runs correctly when an input file is not of the expected type.
"""
sources = [test_files_path / "audio" / "answer.wav"]
converter = MarkdownToDocument()
with caplog.at_level(logging.WARNING):
output = converter.run(sources=sources)
assert "codec can't decode byte" in caplog.text
docs = output["documents"]
assert not docs
@pytest.mark.integration
def test_run_error_handling(self, caplog):
"""
Test if the component correctly handles errors.
"""
sources = ["non_existing_file.md"]
converter = MarkdownToDocument()
with caplog.at_level(logging.WARNING):
result = converter.run(sources=sources)
assert "Could not read non_existing_file.md" in caplog.text
assert not result["documents"]
def test_mixed_sources_run(self, test_files_path):
"""
Test if the component runs correctly if the input is a mix of strings, paths and ByteStreams.
"""
sources = [
test_files_path / "markdown" / "sample.md",
str((test_files_path / "markdown" / "sample.md").absolute()),
]
with open(test_files_path / "markdown" / "sample.md", "rb") as f:
byte_stream = f.read()
sources.append(ByteStream(byte_stream))
converter = MarkdownToDocument()
output = converter.run(sources=sources)
docs = output["documents"]
assert len(docs) == 3
for doc in docs:
assert "What to build with Haystack" in doc.content
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
def test_bytestream_encoding_from_meta(self):
"""
Test that a non-UTF-8 ByteStream is decoded using the encoding specified in its meta.
"""
# "caf\xe9" is "café" in latin-1; decoding as utf-8 would raise UnicodeDecodeError.
latin1_md = "# caf\xe9".encode("latin-1")
bytestream = ByteStream(data=latin1_md, meta={"encoding": "latin-1"})
converter = MarkdownToDocument(progress_bar=False)
output = converter.run(sources=[bytestream])
docs = output["documents"]
assert len(docs) == 1
assert "café" in docs[0].content
def test_bytestream_encoding_from_init(self):
"""
Test that the encoding passed to __init__ is used as a fallback when not set in ByteStream meta.
"""
latin1_md = "# caf\xe9".encode("latin-1")
bytestream = ByteStream(data=latin1_md)
converter = MarkdownToDocument(encoding="latin-1", progress_bar=False)
output = converter.run(sources=[bytestream])
docs = output["documents"]
assert len(docs) == 1
assert "café" in docs[0].content
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack.components.converters.msg import MSGToDocument
class TestMSGToDocument:
def test_run(self, test_files_path):
converter = MSGToDocument(store_full_path=True)
paths = [test_files_path / "msg" / "sample.msg"]
result = converter.run(sources=paths, meta={"date_added": "2021-09-01T00:00:00"})
assert len(result["documents"]) == 1
assert result["documents"][0].content.startswith('From: "Sebastian Lee"')
assert result["documents"][0].meta == {
"date_added": "2021-09-01T00:00:00",
"file_path": str(test_files_path / "msg" / "sample.msg"),
}
assert len(result["attachments"]) == 1
assert result["attachments"][0].mime_type == "application/pdf"
assert result["attachments"][0].meta == {
"date_added": "2021-09-01T00:00:00",
"parent_file_path": str(test_files_path / "msg" / "sample.msg"),
"file_path": "sample_pdf_1.pdf",
}
def test_run_wrong_file_type(self, test_files_path, caplog):
converter = MSGToDocument(store_full_path=False)
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
result = converter.run(sources=paths, meta={"date_added": "2021-09-01T00:00:00"})
assert len(result["documents"]) == 0
assert "msg_file is not an Outlook MSG file" in caplog.text
def test_run_empty_sources(self, test_files_path):
converter = MSGToDocument(store_full_path=False)
result = converter.run(sources=[])
assert len(result["documents"]) == 0
assert len(result["attachments"]) == 0
@@ -0,0 +1,145 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, Pipeline
from haystack.components.converters.multi_file_converter import MultiFileConverter
from haystack.core.component.component import Component
from haystack.core.pipeline.base import component_from_dict, component_to_dict
from haystack.dataclasses import ByteStream
@pytest.fixture
def converter():
converter = MultiFileConverter()
converter.warm_up()
return converter
class TestMultiFileConverter:
def test_init_default_params(self, converter):
"""Test initialization with default parameters"""
assert converter.encoding == "utf-8"
assert converter.json_content_key == "content"
assert isinstance(converter, Component)
def test_init_custom_params(self):
"""Test initialization with custom parameters"""
converter = MultiFileConverter(encoding="latin-1", json_content_key="text")
assert converter.encoding == "latin-1"
assert converter.json_content_key == "text"
def test_to_dict(self, converter):
"""Test serialization to dictionary"""
data = component_to_dict(converter, "converter")
assert data == {
"type": "haystack.components.converters.multi_file_converter.MultiFileConverter",
"init_parameters": {"encoding": "utf-8", "json_content_key": "content"},
}
def test_from_dict(self):
"""Test deserialization from dictionary"""
data = {
"type": "haystack.components.converters.multi_file_converter.MultiFileConverter",
"init_parameters": {"encoding": "latin-1", "json_content_key": "text"},
}
conv = component_from_dict(MultiFileConverter, data, "converter")
assert conv.encoding == "latin-1"
assert conv.json_content_key == "text"
@pytest.mark.parametrize(
"suffix,file_path",
[
("csv", "csv/sample_1.csv"),
("docx", "docx/sample_docx.docx"),
("html", "html/what_is_haystack.html"),
("json", "json/json_conversion_testfile.json"),
("md", "markdown/sample.md"),
("pdf", "pdf/sample_pdf_1.pdf"),
("pptx", "pptx/sample_pptx.pptx"),
("txt", "txt/doc_1.txt"),
("xlsx", "xlsx/table_empty_rows_and_columns.xlsx"),
],
)
@pytest.mark.integration
def test_run(self, test_files_path, converter, suffix, file_path):
unclassified_bytestream = ByteStream(b"unclassified content")
unclassified_bytestream.meta["content_type"] = "unknown_type"
paths = [test_files_path / file_path, unclassified_bytestream]
output = converter.run(sources=paths)
docs = output["documents"]
unclassified = output["unclassified"]
assert len(docs) == 1
assert isinstance(docs[0], Document)
assert docs[0].content is not None
assert docs[0].meta["file_path"].endswith(suffix)
assert len(unclassified) == 1
assert isinstance(unclassified[0], ByteStream)
assert unclassified[0].meta["content_type"] == "unknown_type"
def test_run_with_meta(self, test_files_path, converter):
"""Test conversion with metadata"""
paths = [test_files_path / "txt" / "doc_1.txt"]
meta = {"language": "en", "author": "test"}
output = converter.run(sources=paths, meta=meta)
docs = output["documents"]
assert docs[0].meta["language"] == "en"
assert docs[0].meta["author"] == "test"
def test_run_with_bytestream(self, converter):
"""Test converting ByteStream input"""
bytestream = ByteStream(data=b"test content", mime_type="text/plain", meta={"file_path": "test.txt"})
output = converter.run(sources=[bytestream])
docs = output["documents"]
assert len(docs) == 1
assert docs[0].content == "test content"
assert docs[0].meta["file_path"] == "test.txt"
def test_run_error_handling(self, test_files_path, converter, caplog):
"""Test error handling for non-existent files"""
paths = [test_files_path / "non_existent.txt"]
with caplog.at_level("WARNING"):
output = converter.run(sources=paths)
assert "File not found" in caplog.text
assert len(output["failed"]) == 1
@pytest.mark.integration
def test_run_all_file_types(self, test_files_path, converter):
"""Test converting all supported file types in parallel"""
paths = [
test_files_path / "csv" / "sample_1.csv",
test_files_path / "docx" / "sample_docx.docx",
test_files_path / "html" / "what_is_haystack.html",
test_files_path / "json" / "json_conversion_testfile.json",
test_files_path / "markdown" / "sample.md",
test_files_path / "txt" / "doc_1.txt",
test_files_path / "pdf" / "sample_pdf_1.pdf",
test_files_path / "pptx" / "sample_pptx.pptx",
test_files_path / "xlsx" / "table_empty_rows_and_columns.xlsx",
]
output = converter.run(sources=paths)
docs = output["documents"]
# Verify we got a document for each file
assert len(docs) == len(paths)
assert all(isinstance(doc, Document) for doc in docs)
@pytest.mark.integration
def test_run_in_pipeline(self, test_files_path, converter):
pipeline = Pipeline(max_runs_per_component=1)
pipeline.add_component("converter", converter)
paths = [test_files_path / "txt" / "doc_1.txt", test_files_path / "pdf" / "sample_pdf_1.pdf"]
output = pipeline.run(data={"sources": paths})
docs = output["converter"]["documents"]
assert len(docs) == 2
assert all(isinstance(doc, Document) for doc in docs)
assert all(doc.content is not None for doc in docs)
@@ -0,0 +1,219 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from typing import Any, List
import pytest
from haystack import Pipeline, component
from haystack.components.converters import OutputAdapter
from haystack.components.converters.output_adapter import OutputAdaptationException
from haystack.core.component.sockets import InputSocket
from haystack.dataclasses import Document
def custom_filter_to_sede(value):
return value.upper()
def another_custom_filter(value):
return value.upper()
class TestOutputAdapter:
# OutputAdapter can be initialized with a valid Jinja2 template string and output type.
def test_initialized_with_valid_template_and_output_type(self):
template = "{{ documents[0].content }}"
output_type = str
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
assert adapter.template == template
assert adapter.__haystack_output__.output.name == "output"
assert adapter.__haystack_output__.output.type == output_type
# OutputAdapter can adapt the output of one component to be compatible with the input of another
# component using Jinja2 template expressions.
def test_output_adaptation(self):
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
input_data = {"documents": [{"content": "Test content"}]}
expected_output = {"output": "Test content"}
assert adapter.run(**input_data) == expected_output
# OutputAdapter can add filter 'json_loads' and use it
def test_predefined_filters(self):
adapter = OutputAdapter(
template="{{ documents[0].content|json_loads }}",
output_type=dict,
custom_filters={"json_loads": lambda s: json.loads(str(s))},
)
input_data = {"documents": [{"content": '{"key": "value"}'}]}
expected_output = {"output": {"key": "value"}}
assert adapter.run(**input_data) == expected_output
# OutputAdapter can handle custom filters provided in the component configuration.
def test_custom_filters(self):
def custom_filter(value):
return value.upper()
custom_filters = {"custom_filter": custom_filter}
adapter = OutputAdapter(
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
)
input_data = {"documents": [{"content": "test content"}]}
expected_output = {"output": "TEST CONTENT"}
assert adapter.run(**input_data) == expected_output
# OutputAdapter raises an exception on init if the Jinja2 template string is invalid.
def test_invalid_template_string(self):
with pytest.raises(ValueError):
OutputAdapter(template="{{ documents[0].content }", output_type=str)
# OutputAdapter raises an exception if no input data is provided for output adaptation.
def test_no_input_data_provided(self):
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
with pytest.raises(ValueError):
adapter.run()
# OutputAdapter raises an exception if there's an error during the adaptation process.
def test_error_during_adaptation(self):
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
input_data = {"documents": [{"title": "Test title"}]}
with pytest.raises(OutputAdaptationException):
adapter.run(**input_data)
# OutputAdapter can be serialized to a dictionary and deserialized back to an OutputAdapter instance.
def test_sede(self):
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
adapter_dict = adapter.to_dict()
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
assert adapter.template == deserialized_adapter.template
assert adapter.output_type == deserialized_adapter.output_type
# OutputAdapter can be serialized to a dictionary and deserialized along with custom filters
def test_sede_with_custom_filters(self):
# NOTE: filters need to be declared in a namespace visible to the deserialization function
custom_filters = {"custom_filter": custom_filter_to_sede}
adapter = OutputAdapter(
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
)
adapter_dict = adapter.to_dict()
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
assert adapter.template == deserialized_adapter.template
assert adapter.output_type == deserialized_adapter.output_type
assert adapter.custom_filters == deserialized_adapter.custom_filters == custom_filters
# invoke the custom filter to check if it is deserialized correctly
assert deserialized_adapter.custom_filters["custom_filter"]("test") == "TEST"
# OutputAdapter can be serialized to a dictionary and deserialized along with multiple custom filters
def test_sede_with_multiple_custom_filters(self):
# NOTE: filters need to be declared in a namespace visible to the deserialization function
custom_filters = {"custom_filter": custom_filter_to_sede, "another_custom_filter": another_custom_filter}
adapter = OutputAdapter(
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
)
adapter_dict = adapter.to_dict()
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
assert adapter.template == deserialized_adapter.template
assert adapter.output_type == deserialized_adapter.output_type
assert adapter.custom_filters == deserialized_adapter.custom_filters == custom_filters
# invoke the custom filter to check if it is deserialized correctly
assert deserialized_adapter.custom_filters["custom_filter"]("test") == "TEST"
def test_sede_with_list_output_type_in_pipeline(self):
pipe = Pipeline()
pipe.add_component("adapter", OutputAdapter(template="{{ test }}", output_type=list[str]))
serialized_pipe = pipe.dumps()
# we serialize the pipeline and check if the output type is serialized correctly (as list[str])
assert "list[str]" in serialized_pipe
deserialized_pipe = Pipeline.loads(serialized_pipe)
assert deserialized_pipe.get_component("adapter").output_type == list[str]
def test_sede_with_typing_list_output_type_in_pipeline(self):
pipe = Pipeline()
pipe.add_component("adapter", OutputAdapter(template="{{ test }}", output_type=List[str]))
serialized_pipe = pipe.dumps()
# we serialize the pipeline and check if the output type is serialized correctly (as list[str])
assert "typing.List[str]" in serialized_pipe
deserialized_pipe = Pipeline.loads(serialized_pipe)
assert deserialized_pipe.get_component("adapter").output_type == List[str]
def test_output_adapter_from_dict_custom_filters_none(self):
component = OutputAdapter.from_dict(
data={
"type": "haystack.components.converters.output_adapter.OutputAdapter",
"init_parameters": {
"template": "{{ documents[0].content}}",
"output_type": "str",
"custom_filters": None,
"unsafe": False,
},
}
)
assert component.template == "{{ documents[0].content}}"
assert component.output_type == str
assert component.custom_filters == {}
assert not component._unsafe
def test_output_adapter_in_pipeline(self):
@component
class DocumentProducer:
@component.output_types(documents=dict)
def run(self):
return {"documents": [{"content": '{"framework": "Haystack"}'}]}
pipe = Pipeline()
pipe.add_component(
name="output_adapter",
instance=OutputAdapter(
template="{{ documents[0].content | json_loads}}",
output_type=str,
custom_filters={"json_loads": lambda s: json.loads(str(s))},
),
)
pipe.add_component(name="document_producer", instance=DocumentProducer())
pipe.connect("document_producer", "output_adapter")
result = pipe.run(data={})
assert result
assert result["output_adapter"]["output"] == {"framework": "Haystack"}
def test_unsafe(self):
adapter = OutputAdapter(template="{{ documents[0] }}", output_type=Document, unsafe=True)
documents = [
Document(content="Test document"),
Document(content="Another test document"),
Document(content="Yet another test document"),
]
res = adapter.run(documents=documents)
assert res["output"] == documents[0]
def test_variables_correct_with_assignment(self) -> None:
template = """{% if control == 'something' %}
{% set output = 1 %}
{% else %}
{% set output = 3 %}
{% endif %}
{{ output }}
"""
adapter = OutputAdapter(template=template, output_type=int)
assert adapter.__haystack_input__._sockets_dict == {"control": InputSocket(name="control", type=Any)}
res = adapter.run(control="something")
assert res["output"] == 1
@@ -0,0 +1,242 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from unittest.mock import patch
import pytest
from haystack import Document
from haystack.components.converters.pdfminer import PDFMinerToDocument
from haystack.components.preprocessors import DocumentSplitter
from haystack.dataclasses import ByteStream
class TestPDFMinerToDocument:
def test_run(self, test_files_path):
"""
Test if the component runs correctly.
"""
converter = PDFMinerToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
for doc in docs:
assert "the page 3 is empty" in doc.content
assert "Page 4 of Sample PDF" in doc.content
def test_init_params_custom(self, test_files_path):
"""
Test if init arguments are passed successfully to PDFMinerToDocument layout parameters
"""
converter = PDFMinerToDocument(char_margin=0.5, all_texts=True, store_full_path=False)
assert converter.layout_params.char_margin == 0.5
assert converter.layout_params.all_texts is True
assert converter.store_full_path is False
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
converter = PDFMinerToDocument(store_full_path=False)
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
for doc in docs:
assert "the page 3 is empty" in doc.content
assert "Page 4 of Sample PDF" in doc.content
assert doc.meta["file_path"] == "sample_pdf_1.pdf"
def test_run_wrong_file_type(self, test_files_path, caplog):
"""
Test if the component runs correctly when an input file is not of the expected type.
"""
sources = [test_files_path / "audio" / "answer.wav"]
converter = PDFMinerToDocument()
with caplog.at_level(logging.WARNING):
output = converter.run(sources=sources)
assert "Is this really a PDF?" in caplog.text
docs = output["documents"]
assert not docs
def test_arg_is_none(self, test_files_path):
"""
Test if the component runs correctly when an argument is None.
"""
converter = PDFMinerToDocument(char_margin=None)
assert converter.layout_params.char_margin is None
def test_run_doc_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is supplied by the user.
"""
converter = PDFMinerToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
metadata = [{"file_name": "sample_pdf_2.pdf"}]
results = converter.run(sources=sources, meta=metadata)
docs = results["documents"]
assert len(docs) == 1
assert "Ward Cunningham" in docs[0].content
assert docs[0].meta["file_name"] == "sample_pdf_2.pdf"
def test_incorrect_meta(self, test_files_path):
"""
Test if the component raises an error when incorrect metadata is supplied by the user.
"""
converter = PDFMinerToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_3.pdf"]
metadata = [{"file_name": "sample_pdf_3.pdf"}, {"file_name": "sample_pdf_2.pdf"}]
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
converter.run(sources=sources, meta=metadata)
def test_run_bytestream_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is read from the ByteStream object.
"""
converter = PDFMinerToDocument()
with open(test_files_path / "pdf" / "sample_pdf_2.pdf", "rb") as file:
byte_stream = file.read()
stream = ByteStream(byte_stream, meta={"content_type": "text/pdf", "url": "test_url"})
results = converter.run(sources=[stream])
docs = results["documents"]
assert len(docs) == 1
assert "Ward Cunningham" in docs[0].content
assert docs[0].meta == {"content_type": "text/pdf", "url": "test_url"}
def test_run_bytestream_doc_overlapping_metadata(self, test_files_path):
"""
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
There is an overlap between the metadata received.
The component should use the supplied metadata to overwrite the values if there is an overlap between the keys.
"""
converter = PDFMinerToDocument()
with open(test_files_path / "pdf" / "sample_pdf_2.pdf", "rb") as file:
byte_stream = file.read()
# ByteStream has "url" present in metadata
stream = ByteStream(byte_stream, meta={"content_type": "text/pdf", "url": "test_url_correct"})
# "url" supplied by the user overwrites value present in metadata
metadata = [{"file_name": "sample_pdf_2.pdf", "url": "test_url_new"}]
results = converter.run(sources=[stream], meta=metadata)
docs = results["documents"]
assert len(docs) == 1
assert "Ward Cunningham" in docs[0].content
assert docs[0].meta == {"file_name": "sample_pdf_2.pdf", "content_type": "text/pdf", "url": "test_url_new"}
def test_run_error_handling(self, caplog):
"""
Test if the component correctly handles errors.
"""
sources = ["non_existing_file.pdf"]
converter = PDFMinerToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "Could not read non_existing_file.pdf" in caplog.text
assert results["documents"] == []
def test_run_empty_document(self, caplog, test_files_path):
sources = [test_files_path / "pdf" / "non_text_searchable.pdf"]
converter = PDFMinerToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "PDFMinerToDocument could not extract text from the file" in caplog.text
assert results["documents"][0].content == ""
# Check that not only content is used when the returned document is initialized and doc id is generated
assert results["documents"][0].meta["file_path"] == "non_text_searchable.pdf"
assert results["documents"][0].id != Document(content="").id
def test_run_detect_pages_and_split_by_passage(self, test_files_path):
converter = PDFMinerToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
pdf_doc = converter.run(sources=sources)
splitter = DocumentSplitter(split_length=1, split_by="page")
docs = splitter.run(pdf_doc["documents"])
assert len(docs["documents"]) == 4
def test_run_detect_paragraphs_to_be_used_in_split_passage(self, test_files_path):
converter = PDFMinerToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
pdf_doc = converter.run(sources=sources)
splitter = DocumentSplitter(split_length=1, split_by="passage")
docs = splitter.run(pdf_doc["documents"])
assert len(docs["documents"]) == 29
expected = (
"\nA wiki (/ˈwɪki/ (About this soundlisten) WIK-ee) is a hypertext publication collaboratively"
" \nedited and managed by its own audience directly using a web browser. A typical wiki \ncontains "
"multiple pages for the subjects or scope of the project and may be either open \nto the public or "
"limited to use within an organization for maintaining its internal knowledge \nbase. Wikis are "
"enabled by wiki software, otherwise known as wiki engines. A wiki engine, \nbeing a form of a "
"content management system, differs from other web-based systems \nsuch as blog software, in that "
"the content is created without any defined owner or leader, \nand wikis have little inherent "
"structure, allowing structure to emerge according to the \nneeds of the users.[1] \n\n"
)
assert docs["documents"][6].content == expected
def test_detect_undecoded_cid_characters(self):
"""
Test if the component correctly detects and reports undecoded CID characters in text.
"""
converter = PDFMinerToDocument()
# Test text with no CID characters
text = "This is a normal text without any CID characters."
result = converter.detect_undecoded_cid_characters(text)
assert result["total_chars"] == len(text)
assert result["cid_chars"] == 0
assert result["percentage"] == 0
# Test text with CID characters
text = "Some text with (cid:123) and (cid:456) characters"
result = converter.detect_undecoded_cid_characters(text)
assert result["total_chars"] == len(text)
assert result["cid_chars"] == len("(cid:123)") + len("(cid:456)") # 18 characters total
assert result["percentage"] == round((18 / len(text)) * 100, 2)
# Test text with multiple consecutive CID characters
text = "(cid:123)(cid:456)(cid:789)"
result = converter.detect_undecoded_cid_characters(text)
assert result["total_chars"] == len(text)
assert result["cid_chars"] == len("(cid:123)(cid:456)(cid:789)")
assert result["percentage"] == 100.0
# Test empty text
text = ""
result = converter.detect_undecoded_cid_characters(text)
assert result["total_chars"] == 0
assert result["cid_chars"] == 0
assert result["percentage"] == 0
def test_pdfminer_logs_warning_for_cid_characters(self, caplog, monkeypatch):
"""
Test if the component correctly logs a warning when undecoded CID characters are detected.
"""
test_data = ByteStream(data=b"fake", meta={"file_path": "test.pdf"})
def mock_converter(*args, **kwargs):
return "This is text with (cid:123) and (cid:456) characters"
def mock_extract_pages(*args, **kwargs):
return ["mocked page"]
with patch("haystack.components.converters.pdfminer.extract_pages", side_effect=mock_extract_pages):
with patch.object(PDFMinerToDocument, "_converter", side_effect=mock_converter):
with caplog.at_level(logging.WARNING):
converter = PDFMinerToDocument()
converter.run(sources=[test_data])
assert "Detected 18 undecoded CID characters in 52 characters (34.62%)" in caplog.text
@@ -0,0 +1,123 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import os
import pytest
from haystack.components.converters.pptx import PPTXToDocument
from haystack.dataclasses import ByteStream
class TestPPTXToDocument:
def test_run(self, test_files_path):
"""
Test if the component runs correctly.
"""
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["key"] = "value"
files = [str(test_files_path / "pptx" / "sample_pptx.pptx"), bytestream]
converter = PPTXToDocument()
output = converter.run(sources=files)
docs = output["documents"]
assert len(docs) == 2
assert (
"Sample Title Slide\nJane Doe\fTitle of First Slide\nThis is a bullet point\nThis is another bullet point"
in docs[0].content
)
assert (
"Sample Title Slide\nJane Doe\fTitle of First Slide\nThis is a bullet point\nThis is another bullet point"
in docs[0].content
)
assert docs[0].meta["file_path"] == os.path.basename(files[0])
assert docs[1].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
def test_run_error_non_existent_file(self, caplog):
sources = ["non_existing_file.pptx"]
converter = PPTXToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "Could not read non_existing_file.pptx" in caplog.text
assert results["documents"] == []
def test_run_error_wrong_file_type(self, caplog, test_files_path):
sources = [str(test_files_path / "txt" / "doc_1.txt")]
converter = PPTXToDocument()
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "doc_1.txt and convert it" in caplog.text
assert results["documents"] == []
def test_run_with_meta(self, test_files_path):
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["key"] = "value"
converter = PPTXToDocument()
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
document = output["documents"][0]
assert document.meta == {
"file_path": os.path.basename(test_files_path / "pptx" / "sample_pptx.pptx"),
"key": "value",
"language": "it",
}
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
bytestream.meta["key"] = "value"
converter = PPTXToDocument(store_full_path=False)
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
document = output["documents"][0]
assert document.meta == {"file_path": "sample_pptx.pptx", "key": "value", "language": "it"}
def test_to_dict(self):
converter = PPTXToDocument(link_format="markdown", store_full_path=True)
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.pptx.PPTXToDocument",
"init_parameters": {"link_format": "markdown", "store_full_path": True},
}
def test_to_dict_defaults(self):
converter = PPTXToDocument()
data = converter.to_dict()
assert data == {
"type": "haystack.components.converters.pptx.PPTXToDocument",
"init_parameters": {"link_format": "none", "store_full_path": False},
}
def test_link_format_invalid(self):
with pytest.raises(ValueError, match="Unknown link format"):
PPTXToDocument(link_format="invalid")
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
def test_link_extraction(self, test_files_path, link_format):
converter = PPTXToDocument(link_format=link_format)
paths = [test_files_path / "pptx" / "sample_pptx_with_link.pptx"]
output = converter.run(sources=paths)
content = output["documents"][0].content
if link_format == "markdown":
assert "[Example](https://example.com)" in content
else:
assert "Example (https://example.com)" in content
def test_no_link_extraction(self, test_files_path):
converter = PPTXToDocument()
paths = [test_files_path / "pptx" / "sample_pptx_with_link.pptx"]
output = converter.run(sources=paths)
content = output["documents"][0].content
assert "https://example.com" not in content
assert "Example" in content
@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from unittest.mock import Mock, patch
import pytest
from haystack import Document
from haystack.components.converters.pypdf import PyPDFExtractionMode, PyPDFToDocument
from haystack.components.preprocessors import DocumentSplitter
from haystack.dataclasses import ByteStream
@pytest.fixture
def pypdf_component():
return PyPDFToDocument()
class TestPyPDFToDocument:
def test_init(self, pypdf_component):
assert pypdf_component.extraction_mode == PyPDFExtractionMode.PLAIN
assert pypdf_component.plain_mode_orientations == (0, 90, 180, 270)
assert pypdf_component.plain_mode_space_width == 200.0
assert pypdf_component.layout_mode_space_vertically is True
assert pypdf_component.layout_mode_scale_weight == 1.25
assert pypdf_component.layout_mode_strip_rotated is True
assert pypdf_component.layout_mode_font_height_weight == 1.0
def test_init_custom_params(self):
pypdf_component = PyPDFToDocument(
extraction_mode="layout",
plain_mode_orientations=(0, 90),
plain_mode_space_width=150.0,
layout_mode_space_vertically=False,
layout_mode_scale_weight=2.0,
layout_mode_strip_rotated=False,
layout_mode_font_height_weight=0.5,
)
assert pypdf_component.extraction_mode == PyPDFExtractionMode.LAYOUT
assert pypdf_component.plain_mode_orientations == (0, 90)
assert pypdf_component.plain_mode_space_width == 150.0
assert pypdf_component.layout_mode_space_vertically is False
assert pypdf_component.layout_mode_scale_weight == 2.0
assert pypdf_component.layout_mode_strip_rotated is False
assert pypdf_component.layout_mode_font_height_weight == 0.5
def test_init_invalid_extraction_mode(self):
with pytest.raises(ValueError):
PyPDFToDocument(extraction_mode="invalid")
def test_to_dict(self, pypdf_component):
data = pypdf_component.to_dict()
assert data == {
"type": "haystack.components.converters.pypdf.PyPDFToDocument",
"init_parameters": {
"extraction_mode": "plain",
"plain_mode_orientations": (0, 90, 180, 270),
"plain_mode_space_width": 200.0,
"layout_mode_space_vertically": True,
"layout_mode_scale_weight": 1.25,
"layout_mode_strip_rotated": True,
"layout_mode_font_height_weight": 1.0,
"store_full_path": False,
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.converters.pypdf.PyPDFToDocument",
"init_parameters": {
"extraction_mode": "plain",
"plain_mode_orientations": (0, 90, 180, 270),
"plain_mode_space_width": 200.0,
"layout_mode_space_vertically": True,
"layout_mode_scale_weight": 1.25,
"layout_mode_strip_rotated": True,
"layout_mode_font_height_weight": 1.0,
},
}
instance = PyPDFToDocument.from_dict(data)
assert isinstance(instance, PyPDFToDocument)
assert instance.extraction_mode == PyPDFExtractionMode.PLAIN
assert instance.plain_mode_orientations == (0, 90, 180, 270)
assert instance.plain_mode_space_width == 200.0
assert instance.layout_mode_space_vertically is True
assert instance.layout_mode_scale_weight == 1.25
assert instance.layout_mode_strip_rotated is True
assert instance.layout_mode_font_height_weight == 1.0
def test_from_dict_defaults(self):
data = {"type": "haystack.components.converters.pypdf.PyPDFToDocument", "init_parameters": {}}
instance = PyPDFToDocument.from_dict(data)
assert isinstance(instance, PyPDFToDocument)
assert instance.extraction_mode == PyPDFExtractionMode.PLAIN
def test_default_convert(self):
mock_page1 = Mock()
mock_page2 = Mock()
mock_page1.extract_text.return_value = "Page 1 content"
mock_page2.extract_text.return_value = "Page 2 content"
mock_reader = Mock()
mock_reader.pages = [mock_page1, mock_page2]
converter = PyPDFToDocument(
extraction_mode="layout",
plain_mode_orientations=(0, 90),
plain_mode_space_width=150.0,
layout_mode_space_vertically=False,
layout_mode_scale_weight=2.0,
layout_mode_strip_rotated=False,
layout_mode_font_height_weight=1.5,
)
text = converter._default_convert(mock_reader)
assert text == "Page 1 content\fPage 2 content"
expected_params = {
"extraction_mode": "layout",
"orientations": (0, 90),
"space_width": 150.0,
"layout_mode_space_vertically": False,
"layout_mode_scale_weight": 2.0,
"layout_mode_strip_rotated": False,
"layout_mode_font_height_weight": 1.5,
}
for mock_page in mock_reader.pages:
mock_page.extract_text.assert_called_once_with(**expected_params)
@pytest.mark.integration
def test_run(self, test_files_path, pypdf_component):
"""
Test if the component runs correctly.
"""
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
output = pypdf_component.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert "History" in docs[0].content
@pytest.mark.integration
def test_page_breaks_added(self, test_files_path, pypdf_component):
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
output = pypdf_component.run(sources=paths)
docs = output["documents"]
assert len(docs) == 1
assert docs[0].content.count("\f") == 3
def test_run_with_meta(self, test_files_path, pypdf_component):
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
with patch("haystack.components.converters.pypdf.PdfReader"):
output = pypdf_component.run(
sources=[bytestream, test_files_path / "pdf" / "sample_pdf_1.pdf"], meta={"language": "it"}
)
# check that the metadata from the bytestream is merged with that from the meta parameter
assert output["documents"][0].meta["author"] == "test_author"
assert output["documents"][0].meta["language"] == "it"
assert output["documents"][1].meta["language"] == "it"
def test_run_with_store_full_path_false(self, test_files_path):
"""
Test if the component runs correctly with store_full_path=False
"""
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
converter = PyPDFToDocument(store_full_path=True)
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
assert docs[0].meta["file_path"] == str(sources[0])
converter = PyPDFToDocument(store_full_path=False)
results = converter.run(sources=sources)
docs = results["documents"]
assert len(docs) == 1
assert docs[0].meta["file_path"] == "sample_pdf_1.pdf"
def test_run_error_handling(self, test_files_path, pypdf_component, caplog):
"""
Test if the component correctly handles errors.
"""
paths = ["non_existing_file.pdf"]
with caplog.at_level(logging.WARNING):
pypdf_component.run(sources=paths)
assert "Could not read non_existing_file.pdf" in caplog.text
@pytest.mark.integration
def test_mixed_sources_run(self, test_files_path, pypdf_component):
"""
Test if the component runs correctly when mixed sources are provided.
"""
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
with open(test_files_path / "pdf" / "sample_pdf_1.pdf", "rb") as f:
paths.append(ByteStream(f.read()))
output = pypdf_component.run(sources=paths)
docs = output["documents"]
assert len(docs) == 2
assert "History and standardization" in docs[0].content
assert "History and standardization" in docs[1].content
def test_run_empty_document(self, caplog, test_files_path):
paths = [test_files_path / "pdf" / "non_text_searchable.pdf"]
with caplog.at_level(logging.WARNING):
output = PyPDFToDocument().run(sources=paths)
assert "PyPDFToDocument could not extract text from the file" in caplog.text
assert output["documents"][0].content == ""
# Check that meta is used when the returned document is initialized and thus when doc id is generated
assert output["documents"][0].meta["file_path"] == "non_text_searchable.pdf"
assert output["documents"][0].id != Document(content="").id
def test_run_detect_paragraphs_to_be_used_in_split_passage(self, test_files_path):
converter = PyPDFToDocument(extraction_mode=PyPDFExtractionMode.LAYOUT)
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
pdf_doc = converter.run(sources=sources)
splitter = DocumentSplitter(split_length=1, split_by="passage")
docs = splitter.run(pdf_doc["documents"])
assert len(docs["documents"]) == 51
expected = (
"A wiki (/ˈwɪki/ (About this soundlisten) WIK-ee) is a hypertext publication collaboratively\n"
"edited and managed by its own audience directly using a web browser. A typical wiki\ncontains "
"multiple pages for the subjects or scope of the project and may be either open\nto the public or "
"limited to use within an organization for maintaining its internal knowledge\nbase. Wikis are "
"enabled by wiki software, otherwise known as wiki engines. A wiki engine,\nbeing a form of a "
"content management system, differs from other web-based systems\nsuch as blog software, in that "
"the content is created without any defined owner or leader,\nand wikis have little inherent "
"structure, allowing structure to emerge according to the\nneeds of the users.[1]\n\n"
)
assert docs["documents"][2].content == expected
@@ -0,0 +1,87 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import os
from haystack.components.converters.txt import TextFileToDocument
from haystack.dataclasses import ByteStream
class TestTextfileToDocument:
def test_run(self, test_files_path):
"""
Test if the component runs correctly.
"""
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_3.txt")
bytestream.meta["file_path"] = str(test_files_path / "txt" / "doc_3.txt")
bytestream.meta["key"] = "value"
files = [str(test_files_path / "txt" / "doc_1.txt"), test_files_path / "txt" / "doc_2.txt", bytestream]
converter = TextFileToDocument()
output = converter.run(sources=files)
docs = output["documents"]
assert len(docs) == 3
assert "Some text for testing." in docs[0].content
assert "This is a test line." in docs[1].content
assert "That's yet another file!" in docs[2].content
assert docs[0].meta["file_path"] == os.path.basename(files[0])
assert docs[1].meta["file_path"] == os.path.basename(files[1])
assert docs[2].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
def test_run_with_store_full_path(self, test_files_path):
"""
Test if the component runs correctly with store_full_path= False.
"""
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_3.txt")
bytestream.meta["file_path"] = str(test_files_path / "txt" / "doc_3.txt")
bytestream.meta["key"] = "value"
files = [str(test_files_path / "txt" / "doc_1.txt"), bytestream]
converter = TextFileToDocument(store_full_path=False)
output = converter.run(sources=files)
docs = output["documents"]
assert len(docs) == 2
assert "Some text for testing." in docs[0].content
assert "That's yet another file!" in docs[1].content
assert docs[0].meta["file_path"] == "doc_1.txt"
assert docs[1].meta["file_path"] == "doc_3.txt"
def test_run_error_handling(self, test_files_path, caplog):
"""
Test if the component correctly handles errors.
"""
paths = [test_files_path / "txt" / "doc_1.txt", "non_existing_file.txt", test_files_path / "txt" / "doc_3.txt"]
converter = TextFileToDocument()
with caplog.at_level(logging.WARNING):
output = converter.run(sources=paths)
assert "non_existing_file.txt" in caplog.text
docs = output["documents"]
assert len(docs) == 2
assert docs[0].meta["file_path"] == os.path.basename(paths[0])
assert docs[1].meta["file_path"] == os.path.basename(paths[2])
def test_encoding_override(self, test_files_path):
"""
Test if the encoding metadata field is used properly
"""
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_1.txt")
bytestream.meta["key"] = "value"
converter = TextFileToDocument(encoding="utf-16")
output = converter.run(sources=[bytestream])
assert "Some text for testing." not in output["documents"][0].content
bytestream.meta["encoding"] = "utf-8"
output = converter.run(sources=[bytestream])
assert "Some text for testing." in output["documents"][0].content
def test_run_with_meta(self):
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
converter = TextFileToDocument()
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
document = output["documents"][0]
# check that the metadata from the bytestream is merged with that from the meta parameter
assert document.meta == {"author": "test_author", "language": "it"}
+72
View File
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
from haystack.dataclasses import ByteStream
def test_normalize_metadata_None():
assert normalize_metadata(None, sources_count=1) == [{}]
assert normalize_metadata(None, sources_count=3) == [{}, {}, {}]
def test_normalize_metadata_single_dict():
assert normalize_metadata({"a": 1}, sources_count=1) == [{"a": 1}]
assert normalize_metadata({"a": 1}, sources_count=3) == [{"a": 1}, {"a": 1}, {"a": 1}]
def test_normalize_metadata_list_of_right_size():
assert normalize_metadata([{"a": 1}], sources_count=1) == [{"a": 1}]
assert normalize_metadata([{"a": 1}, {"b": 2}, {"c": 3}], sources_count=3) == [{"a": 1}, {"b": 2}, {"c": 3}]
def test_normalize_metadata_list_of_wrong_size():
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
normalize_metadata([{"a": 1}], sources_count=3)
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
assert normalize_metadata([{"a": 1}, {"b": 2}, {"c": 3}], sources_count=1)
def test_normalize_metadata_other_type():
with pytest.raises(ValueError, match="meta must be either None, a dictionary or a list of dictionaries."):
normalize_metadata(({"a": 1},), sources_count=1)
def test_get_bytestream_from_path_object(tmp_path):
bytes_ = b"hello world"
source = tmp_path / "test.txt"
source.write_bytes(bytes_)
bs = get_bytestream_from_source(source, guess_mime_type=True)
assert isinstance(bs, ByteStream)
assert bs.data == bytes_
assert bs.mime_type == "text/plain"
assert bs.meta["file_path"].endswith("test.txt")
def test_get_bytestream_from_string_path(tmp_path):
bytes_ = b"hello world"
source = tmp_path / "test.txt"
source.write_bytes(bytes_)
bs = get_bytestream_from_source(str(source), guess_mime_type=True)
assert isinstance(bs, ByteStream)
assert bs.data == bytes_
assert bs.mime_type == "text/plain"
assert bs.meta["file_path"].endswith("test.txt")
def test_get_bytestream_from_source_invalid_type():
with pytest.raises(ValueError, match="Unsupported source type"):
get_bytestream_from_source(123)
def test_get_bytestream_from_source_bytestream_passthrough():
bs = ByteStream(data=b"spam", mime_type="text/custom", meta={"spam": "eggs"})
result = get_bytestream_from_source(bs)
assert result is bs
@@ -0,0 +1,170 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack.components.converters.xlsx import XLSXToDocument
class TestXLSXToDocument:
def test_init(self) -> None:
converter = XLSXToDocument()
assert converter.sheet_name is None
assert converter.read_excel_kwargs == {}
assert converter.table_format == "csv"
assert converter.link_format == "none"
assert converter.table_format_kwargs == {}
def test_run_basic_tables(self, test_files_path) -> None:
converter = XLSXToDocument(store_full_path=True)
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
documents = results["documents"]
assert len(documents) == 2
assert documents[0].content == ",A,B\n1,col_a,col_b\n2,1.5,test\n"
assert documents[0].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": "Basic Table"},
}
assert documents[1].content == ",A,B\n1,col_c,col_d\n2,True,\n"
assert documents[1].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": "Table Missing Value"},
}
def test_run_table_empty_rows_and_columns(self, test_files_path) -> None:
converter = XLSXToDocument(store_full_path=False)
paths = [test_files_path / "xlsx" / "table_empty_rows_and_columns.xlsx"]
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
documents = results["documents"]
assert len(documents) == 1
assert documents[0].content == ",A,B,C\n1,,,\n2,,,\n3,,,\n4,,col_a,col_b\n5,,1.5,test\n"
assert documents[0].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": "table_empty_rows_and_columns.xlsx",
"xlsx": {"sheet_name": "Sheet1"},
}
def test_run_multiple_tables_in_one_sheet(self, test_files_path) -> None:
converter = XLSXToDocument(store_full_path=True)
paths = [test_files_path / "xlsx" / "multiple_tables.xlsx"]
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
documents = results["documents"]
assert len(documents) == 1
assert (
documents[0].content
== ",A,B,C,D,E,F\n1,,,,,,\n2,,,,,,\n3,,col_a,col_b,,,\n4,,1.5,test,,col_c,col_d\n5,,,,,3,True\n"
)
assert documents[0].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "multiple_tables.xlsx"),
"xlsx": {"sheet_name": "Sheet1"},
}
def test_run_markdown(self, test_files_path) -> None:
converter = XLSXToDocument(table_format="markdown", store_full_path=True)
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
documents = results["documents"]
assert len(documents) == 2
assert (
documents[0].content
== "| | A | B |\n|---:|:------|:------|\n| 1 | col_a | col_b |\n| 2 | 1.5 | test |"
)
assert documents[0].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": "Basic Table"},
}
assert (
documents[1].content
== "| | A | B |\n|---:|:------|:------|\n| 1 | col_c | col_d |\n| 2 | True | nan |"
)
assert documents[1].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": "Table Missing Value"},
}
@pytest.mark.parametrize(
"sheet_name, expected_sheet_name, expected_content",
[
("Basic Table", "Basic Table", ",A,B\n1,col_a,col_b\n2,1.5,test\n"),
("Table Missing Value", "Table Missing Value", ",A,B\n1,col_c,col_d\n2,True,\n"),
(0, 0, ",A,B\n1,col_a,col_b\n2,1.5,test\n"),
(1, 1, ",A,B\n1,col_c,col_d\n2,True,\n"),
],
)
def test_run_sheet_name(
self, sheet_name: int | str, expected_sheet_name: str, expected_content: str, test_files_path
) -> None:
converter = XLSXToDocument(sheet_name=sheet_name, store_full_path=True)
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
results = converter.run(sources=paths)
documents = results["documents"]
assert len(documents) == 1
assert documents[0].content == expected_content
assert documents[0].meta == {
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": expected_sheet_name},
}
def test_run_with_read_excel_kwargs(self, test_files_path) -> None:
converter = XLSXToDocument(sheet_name="Basic Table", read_excel_kwargs={"skiprows": 1}, store_full_path=True)
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
documents = results["documents"]
assert len(documents) == 1
assert documents[0].content == ",A,B\n1,1.5,test\n"
assert documents[0].meta == {
"date_added": "2022-01-01T00:00:00",
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
"xlsx": {"sheet_name": "Basic Table"},
}
def test_run_error_wrong_file_type(self, caplog: pytest.LogCaptureFixture, test_files_path) -> None:
converter = XLSXToDocument()
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
with caplog.at_level(logging.WARNING):
results = converter.run(sources=sources)
assert "sample_pdf_1.pdf and convert it" in caplog.text
assert results["documents"] == []
def test_run_error_non_existent_file(self, caplog: pytest.LogCaptureFixture) -> None:
converter = XLSXToDocument()
paths = ["non_existing_file.docx"]
with caplog.at_level(logging.WARNING):
converter.run(sources=paths)
assert "Could not read non_existing_file.docx" in caplog.text
def test_link_format_invalid(self) -> None:
with pytest.raises(ValueError, match="Unknown link format"):
XLSXToDocument(link_format="invalid")
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
def test_link_extraction(self, test_files_path, link_format) -> None:
converter = XLSXToDocument(link_format=link_format)
paths = [test_files_path / "xlsx" / "spreadsheet_with_links.xlsx"]
results = converter.run(sources=paths)
content = results["documents"][0].content
if link_format == "markdown":
assert "[Click here](https://example.com)" in content
assert "[Docs](https://python.org)" in content
else:
assert "Click here (https://example.com)" in content
assert "Docs (https://python.org)" in content
def test_no_link_extraction(self, test_files_path) -> None:
converter = XLSXToDocument()
paths = [test_files_path / "xlsx" / "spreadsheet_with_links.xlsx"]
results = converter.run(sources=paths)
content = results["documents"][0].content
assert "https://example.com" not in content
assert "Click here" in content
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,385 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openai import APIError, OpenAIError
import haystack.components.embedders.azure_document_embedder as azure_document_embedder_module
from haystack import Document
from haystack.components.embedders import AzureOpenAIDocumentEmbedder
from haystack.utils.auth import Secret
from haystack.utils.azure import default_azure_ad_token_provider
class TestAzureOpenAIDocumentEmbedder:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.azure_deployment == "text-embedding-ada-002"
assert embedder.model == "text-embedding-ada-002"
assert embedder.dimensions is None
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.batch_size == 32
assert embedder.progress_bar is True
assert embedder.meta_fields_to_embed == []
assert embedder.embedding_separator == "\n"
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.default_headers == {}
assert embedder.azure_ad_token_provider is None
assert embedder.http_client_kwargs is None
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_0_max_retries(self, monkeypatch):
"""Tests that the max_retries init param is set correctly if equal 0"""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAIDocumentEmbedder(
azure_endpoint="https://example-resource.azure.openai.com/", max_retries=0
)
assert embedder.azure_deployment == "text-embedding-ada-002"
assert embedder.model == "text-embedding-ada-002"
assert embedder.dimensions is None
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.batch_size == 32
assert embedder.progress_bar is True
assert embedder.meta_fields_to_embed == []
assert embedder.embedding_separator == "\n"
assert embedder.default_headers == {}
assert embedder.azure_ad_token_provider is None
assert embedder.max_retries == 0
assert embedder.client is None
assert embedder.async_client is None
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
component = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.azure_document_embedder.AzureOpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2023-05-15",
"azure_deployment": "text-embedding-ada-002",
"dimensions": None,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"organization": None,
"prefix": "",
"suffix": "",
"batch_size": 32,
"progress_bar": True,
"meta_fields_to_embed": [],
"embedding_separator": "\n",
"max_retries": None,
"timeout": None,
"default_headers": {},
"azure_ad_token_provider": None,
"http_client_kwargs": None,
"raise_on_failure": False,
},
}
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
component = AzureOpenAIDocumentEmbedder(
azure_endpoint="https://example-resource.azure.openai.com/",
azure_deployment="text-embedding-ada-002",
dimensions=768,
organization="HaystackCI",
timeout=60.0,
max_retries=10,
prefix="prefix ",
suffix=" suffix",
default_headers={"x-custom-header": "custom-value"},
azure_ad_token_provider=default_azure_ad_token_provider,
http_client_kwargs={"proxy": "http://example.com:3128", "verify": False},
raise_on_failure=True,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.azure_document_embedder.AzureOpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2023-05-15",
"azure_deployment": "text-embedding-ada-002",
"dimensions": 768,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"organization": "HaystackCI",
"prefix": "prefix ",
"suffix": " suffix",
"batch_size": 32,
"progress_bar": True,
"meta_fields_to_embed": [],
"embedding_separator": "\n",
"max_retries": 10,
"timeout": 60.0,
"default_headers": {"x-custom-header": "custom-value"},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://example.com:3128", "verify": False},
"raise_on_failure": True,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.embedders.azure_document_embedder.AzureOpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2023-05-15",
"azure_deployment": "text-embedding-ada-002",
"dimensions": None,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"organization": None,
"prefix": "",
"suffix": "",
"batch_size": 32,
"progress_bar": True,
"meta_fields_to_embed": [],
"embedding_separator": "\n",
"max_retries": 5,
"timeout": 30.0,
"default_headers": {},
"azure_ad_token_provider": None,
"http_client_kwargs": None,
"raise_on_failure": False,
},
}
component = AzureOpenAIDocumentEmbedder.from_dict(data)
assert component.azure_deployment == "text-embedding-ada-002"
assert component.azure_endpoint == "https://example-resource.azure.openai.com/"
assert component.api_version == "2023-05-15"
assert component.max_retries == 5
assert component.timeout == 30.0
assert component.prefix == ""
assert component.suffix == ""
assert component.default_headers == {}
assert component.azure_ad_token_provider is None
assert component.http_client_kwargs is None
assert component.raise_on_failure is False
def test_from_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.embedders.azure_document_embedder.AzureOpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2023-05-15",
"azure_deployment": "text-embedding-ada-002",
"dimensions": 768,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"organization": "HaystackCI",
"prefix": "prefix ",
"suffix": " suffix",
"batch_size": 32,
"progress_bar": True,
"meta_fields_to_embed": [],
"embedding_separator": "\n",
"max_retries": 10,
"timeout": 60.0,
"default_headers": {"x-custom-header": "custom-value"},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://example.com:3128", "verify": False},
"raise_on_failure": True,
},
}
component = AzureOpenAIDocumentEmbedder.from_dict(data)
assert component.azure_deployment == "text-embedding-ada-002"
assert component.azure_endpoint == "https://example-resource.azure.openai.com/"
assert component.api_version == "2023-05-15"
assert component.max_retries == 10
assert component.timeout == 60.0
assert component.prefix == "prefix "
assert component.suffix == " suffix"
assert component.default_headers == {"x-custom-header": "custom-value"}
assert component.azure_ad_token_provider is not None
assert component.http_client_kwargs == {"proxy": "http://example.com:3128", "verify": False}
assert component.raise_on_failure is True
def test_embed_batch_handles_exceptions_gracefully(self, caplog):
embedder = AzureOpenAIDocumentEmbedder(
azure_endpoint="https://test.openai.azure.com",
api_key=Secret.from_token("fake-api-key"),
azure_deployment="text-embedding-ada-002",
embedding_separator=" | ",
)
embedder.warm_up()
assert embedder.client is not None
fake_texts_to_embed = {"1": "text1", "2": "text2"}
with patch.object(
embedder.client.embeddings,
"create",
side_effect=APIError(message="Mocked error", request=Mock(), body=None),
):
embedder._embed_batch(texts_to_embed=fake_texts_to_embed, batch_size=32)
assert len(caplog.records) == 1
assert "Failed embedding of documents 1, 2 caused by Mocked error" in caplog.text
def test_embed_batch_raises_exception_on_failure(self):
embedder = AzureOpenAIDocumentEmbedder(
azure_endpoint="https://test.openai.azure.com",
api_key=Secret.from_token("fake-api-key"),
azure_deployment="text-embedding-ada-002",
raise_on_failure=True,
)
embedder.warm_up()
assert embedder.client is not None
fake_texts_to_embed = {"1": "text1", "2": "text2"}
with patch.object(
embedder.client.embeddings,
"create",
side_effect=APIError(message="Mocked error", request=Mock(), body=None),
):
with pytest.raises(APIError, match="Mocked error"):
embedder._embed_batch(texts_to_embed=fake_texts_to_embed, batch_size=2)
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) and not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
def test_run(self):
docs = [
Document(content="I love cheese", meta={"topic": "Cuisine"}),
Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
]
# the default model is text-embedding-ada-002 even if we don't specify it, but let's be explicit
embedder = AzureOpenAIDocumentEmbedder(
azure_deployment="text-embedding-ada-002",
meta_fields_to_embed=["topic"],
embedding_separator=" | ",
organization="HaystackCI",
)
result = embedder.run(documents=docs)
documents_with_embeddings = result["documents"]
metadata = result["meta"]
assert isinstance(documents_with_embeddings, list)
assert len(documents_with_embeddings) == len(docs)
for doc, new_doc in zip(docs, documents_with_embeddings, strict=True):
assert doc.embedding is None
assert new_doc is not doc
assert isinstance(new_doc, Document)
assert isinstance(new_doc.embedding, list)
assert len(new_doc.embedding) == 1536
assert all(isinstance(x, float) for x in new_doc.embedding)
assert metadata["usage"]["prompt_tokens"] == 15
assert metadata["usage"]["total_tokens"] == 15
assert "ada" in metadata["model"]
@pytest.fixture
def mock_azure_clients(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="AzureOpenAI")
async_cls = MagicMock(name="AsyncAzureOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(azure_document_embedder_module, "AzureOpenAI", sync_cls)
monkeypatch.setattr(azure_document_embedder_module, "AsyncAzureOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 5
assert embedder.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAIDocumentEmbedder(
azure_endpoint="https://example-resource.azure.openai.com/", timeout=40.0, max_retries=1
)
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 1
assert embedder.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 10
assert embedder.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.client is None
with pytest.raises(OpenAIError):
embedder.warm_up()
def test_sync_lifecycle(self, mock_azure_clients):
sync_cls, _ = mock_azure_clients
sync_client = sync_cls.return_value
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.client is None
assert embedder.async_client is None
embedder.warm_up()
assert embedder.client is sync_cls.return_value
assert embedder.async_client is None
embedder.close()
sync_client.close.assert_called_once()
assert embedder.client is None
async def test_async_lifecycle(self, mock_azure_clients):
_, async_cls = mock_azure_clients
async_client = async_cls.return_value
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
await embedder.warm_up_async()
assert embedder.async_client is async_cls.return_value
assert embedder.client is None
await embedder.close_async()
async_client.close.assert_awaited_once()
assert embedder.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.close()
await embedder.close_async()
assert embedder.client is None
assert embedder.async_client is None
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
await embedder.warm_up_async()
embedder.close()
assert embedder.client is None
assert embedder.async_client is not None
await embedder.close_async()
assert embedder.async_client is None
@@ -0,0 +1,296 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from unittest.mock import AsyncMock, MagicMock
import pytest
from openai import OpenAIError
import haystack.components.embedders.azure_text_embedder as azure_text_embedder_module
from haystack.components.embedders import AzureOpenAITextEmbedder
from haystack.utils.azure import default_azure_ad_token_provider
class TestAzureOpenAITextEmbedder:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.azure_deployment == "text-embedding-ada-002"
assert embedder.model == "text-embedding-ada-002"
assert embedder.dimensions is None
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.default_headers == {}
assert embedder.azure_ad_token_provider is None
assert embedder.http_client_kwargs is None
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_zero_max_retries(self, monkeypatch):
"""Tests that the max_retries init param is set correctly if equal 0"""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/", max_retries=0)
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.azure_deployment == "text-embedding-ada-002"
assert embedder.model == "text-embedding-ada-002"
assert embedder.dimensions is None
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.default_headers == {}
assert embedder.azure_ad_token_provider is None
assert embedder.max_retries == 0
assert embedder.client is None
assert embedder.async_client is None
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
component = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.azure_text_embedder.AzureOpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_deployment": "text-embedding-ada-002",
"dimensions": None,
"organization": None,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"api_version": "2023-05-15",
"max_retries": None,
"timeout": None,
"prefix": "",
"suffix": "",
"default_headers": {},
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
def test_to_dict_with_params(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
component = AzureOpenAITextEmbedder(
azure_endpoint="https://example-resource.azure.openai.com/",
azure_deployment="text-embedding-ada-002",
dimensions=768,
organization="HaystackCI",
timeout=60.0,
max_retries=10,
prefix="prefix ",
suffix=" suffix",
default_headers={"x-custom-header": "custom-value"},
azure_ad_token_provider=default_azure_ad_token_provider,
http_client_kwargs={"proxy": "http://example.com:3128", "verify": False},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.azure_text_embedder.AzureOpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_deployment": "text-embedding-ada-002",
"dimensions": 768,
"organization": "HaystackCI",
"azure_endpoint": "https://example-resource.azure.openai.com/",
"api_version": "2023-05-15",
"max_retries": 10,
"timeout": 60.0,
"prefix": "prefix ",
"suffix": " suffix",
"default_headers": {"x-custom-header": "custom-value"},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://example.com:3128", "verify": False},
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.embedders.azure_text_embedder.AzureOpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_deployment": "text-embedding-ada-002",
"dimensions": None,
"organization": None,
"azure_endpoint": "https://example-resource.azure.openai.com/",
"api_version": "2023-05-15",
"max_retries": 5,
"timeout": 30.0,
"prefix": "",
"suffix": "",
"default_headers": {},
"http_client_kwargs": None,
},
}
component = AzureOpenAITextEmbedder.from_dict(data)
assert component.azure_deployment == "text-embedding-ada-002"
assert component.model == "text-embedding-ada-002"
assert component.azure_endpoint == "https://example-resource.azure.openai.com/"
assert component.api_version == "2023-05-15"
assert component.max_retries == 5
assert component.timeout == 30.0
assert component.prefix == ""
assert component.suffix == ""
assert component.default_headers == {}
assert component.azure_ad_token_provider is None
assert component.http_client_kwargs is None
def test_from_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.embedders.azure_text_embedder.AzureOpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_deployment": "text-embedding-ada-002",
"dimensions": 768,
"organization": "HaystackCI",
"azure_endpoint": "https://example-resource.azure.openai.com/",
"api_version": "2023-05-15",
"max_retries": 10,
"timeout": 60.0,
"prefix": "prefix ",
"suffix": " suffix",
"default_headers": {"x-custom-header": "custom-value"},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://example.com:3128", "verify": False},
},
}
component = AzureOpenAITextEmbedder.from_dict(data)
assert component.azure_deployment == "text-embedding-ada-002"
assert component.model == "text-embedding-ada-002"
assert component.azure_endpoint == "https://example-resource.azure.openai.com/"
assert component.api_version == "2023-05-15"
assert component.max_retries == 10
assert component.timeout == 60.0
assert component.prefix == "prefix "
assert component.suffix == " suffix"
assert component.default_headers == {"x-custom-header": "custom-value"}
assert component.azure_ad_token_provider is not None
assert component.http_client_kwargs == {"proxy": "http://example.com:3128", "verify": False}
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) and not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
def test_run(self):
# the default model is text-embedding-ada-002 even if we don't specify it, but let's be explicit
embedder = AzureOpenAITextEmbedder(
azure_deployment="text-embedding-ada-002", prefix="prefix ", suffix=" suffix", organization="HaystackCI"
)
result = embedder.run(text="The food was delicious")
assert len(result["embedding"]) == 1536
assert all(isinstance(x, float) for x in result["embedding"])
assert result["meta"]["usage"] == {"prompt_tokens": 6, "total_tokens": 6}
assert "ada" in result["meta"]["model"]
@pytest.fixture
def mock_azure_clients(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="AzureOpenAI")
async_cls = MagicMock(name="AsyncAzureOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(azure_text_embedder_module, "AzureOpenAI", sync_cls)
monkeypatch.setattr(azure_text_embedder_module, "AsyncAzureOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 5
assert embedder.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
embedder = AzureOpenAITextEmbedder(
azure_endpoint="https://example-resource.azure.openai.com/", timeout=40.0, max_retries=1
)
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 1
assert embedder.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 10
assert embedder.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.client is None
with pytest.raises(OpenAIError):
embedder.warm_up()
def test_sync_lifecycle(self, mock_azure_clients):
sync_cls, _ = mock_azure_clients
sync_client = sync_cls.return_value
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
assert embedder.client is None
assert embedder.async_client is None
embedder.warm_up()
assert embedder.client is sync_cls.return_value
assert embedder.async_client is None
embedder.close()
sync_client.close.assert_called_once()
assert embedder.client is None
async def test_async_lifecycle(self, mock_azure_clients):
_, async_cls = mock_azure_clients
async_client = async_cls.return_value
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
await embedder.warm_up_async()
assert embedder.async_client is async_cls.return_value
assert embedder.client is None
await embedder.close_async()
async_client.close.assert_awaited_once()
assert embedder.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.close()
await embedder.close_async()
assert embedder.client is None
assert embedder.async_client is None
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
embedder.warm_up()
await embedder.warm_up_async()
embedder.close()
assert embedder.client is None
assert embedder.async_client is not None
await embedder.close_async()
assert embedder.async_client is None
@@ -0,0 +1,119 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, Pipeline
from haystack.components.embedders import MockDocumentEmbedder, MockTextEmbedder
def _ones(text: str) -> list[float]:
"""Module-level embedding function used to test `embedding_fn` and its serialization."""
return [1.0, 1.0, 1.0]
class TestMockDocumentEmbedder:
@pytest.mark.parametrize(
("args", "kwargs", "match"),
[
(([0.1],), {"embedding_fn": _ones}, "either 'embedding' or 'embedding_fn'"),
((), {"dimension": -1}, "must be a positive integer"),
],
)
def test_init_rejects_invalid_config(self, args, kwargs, match):
with pytest.raises(ValueError, match=match):
MockDocumentEmbedder(*args, **kwargs)
def test_embeds_documents(self):
embedder = MockDocumentEmbedder(dimension=16)
result = embedder.run([Document(content="first"), Document(content="second")])
embeddings = [doc.embedding for doc in result["documents"]]
assert all(len(embedding) == 16 for embedding in embeddings)
assert embeddings[0] != embeddings[1]
def test_consistent_with_text_embedder(self):
# the same prepared text yields the same embedding from both mock embedders (shared deterministic algorithm)
text_embedding = MockTextEmbedder(dimension=8).run("pizza")["embedding"]
doc_embedding = MockDocumentEmbedder(dimension=8).run([Document(content="pizza")])["documents"][0].embedding
assert text_embedding == doc_embedding
def test_fixed_embedding(self):
result = MockDocumentEmbedder([0.5, 0.5]).run([Document(content="a"), Document(content="b")])
assert all(doc.embedding == [0.5, 0.5] for doc in result["documents"])
def test_embedding_fn(self):
result = MockDocumentEmbedder(embedding_fn=_ones).run([Document(content="a")])
assert result["documents"][0].embedding == [1.0, 1.0, 1.0]
def test_meta_fields_to_embed_affect_embedding(self):
document = Document(content="hello", meta={"title": "Greetings"})
without_meta = MockDocumentEmbedder(dimension=8).run([document])["documents"][0].embedding
with_meta = (
MockDocumentEmbedder(dimension=8, meta_fields_to_embed=["title"]).run([document])["documents"][0].embedding
)
assert without_meta != with_meta
def test_preserves_document_fields(self):
document = Document(content="hello", meta={"title": "Greetings"})
embedded = MockDocumentEmbedder(dimension=8).run([document])["documents"][0]
assert embedded.id == document.id
assert embedded.content == "hello"
assert embedded.meta == {"title": "Greetings"}
assert embedded.embedding is not None
# the original document is not mutated
assert document.embedding is None
def test_empty_documents(self):
result = MockDocumentEmbedder().run([])
assert result["documents"] == []
assert result["meta"]["usage"] == {"prompt_tokens": 0, "total_tokens": 0}
def test_meta(self):
meta = MockDocumentEmbedder(dimension=4).run([Document(content="a b"), Document(content="c")])["meta"]
assert meta["model"] == "mock-model"
assert meta["usage"] == {"prompt_tokens": 3, "total_tokens": 3}
@pytest.mark.parametrize("documents", ["not a list", [1, 2, 3]])
def test_run_rejects_non_documents(self, documents):
with pytest.raises(TypeError, match="expects a list of Documents"):
MockDocumentEmbedder().run(documents)
async def test_run_async(self):
embedder = MockDocumentEmbedder(dimension=8)
documents = [Document(content="hello")]
async_embedding = (await embedder.run_async(documents))["documents"][0].embedding
assert async_embedding == embedder.run(documents)["documents"][0].embedding
def test_warm_up_sets_flag_and_run_auto_warms(self):
embedder = MockDocumentEmbedder(dimension=8)
assert embedder._is_warmed_up is False
embedder.warm_up()
assert embedder._is_warmed_up is True
# run auto-warms a fresh instance
fresh = MockDocumentEmbedder(dimension=8)
assert len(fresh.run([Document(content="hello")])["documents"][0].embedding) == 8
assert fresh._is_warmed_up is True
@pytest.mark.parametrize(
"embedder",
[
MockDocumentEmbedder(
dimension=8, model="m", meta={"k": "v"}, meta_fields_to_embed=["title"], embedding_separator=" | "
),
MockDocumentEmbedder(embedding_fn=_ones),
],
ids=["deterministic", "embedding_fn"],
)
def test_serialization_roundtrip(self, embedder):
restored = MockDocumentEmbedder.from_dict(embedder.to_dict())
assert isinstance(restored, MockDocumentEmbedder)
document = Document(content="hello", meta={"title": "t"})
assert restored.run([document])["documents"][0].embedding == embedder.run([document])["documents"][0].embedding
def test_in_pipeline(self):
pipeline = Pipeline()
pipeline.add_component("embedder", MockDocumentEmbedder(dimension=8))
restored = Pipeline.from_dict(pipeline.to_dict())
result = restored.run({"embedder": {"documents": [Document(content="hello")]}})
assert len(result["embedder"]["documents"][0].embedding) == 8
@@ -0,0 +1,120 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
import pytest
from haystack import Pipeline
from haystack.components.embedders import MockTextEmbedder
from haystack.components.embedders.mock_utils import _l2_normalize
def _ones(text: str) -> list[float]:
"""Module-level embedding function used to test `embedding_fn` and its serialization."""
return [1.0, 1.0, 1.0]
def test_l2_normalize_handles_zero_vector():
# defensive guard in the shared deterministic-embedding helper: a zero vector is returned unchanged
assert _l2_normalize([0.0, 0.0]) == [0.0, 0.0]
class TestMockTextEmbedder:
@pytest.mark.parametrize(
("args", "kwargs", "exception", "match"),
[
(([0.1, 0.2],), {"embedding_fn": _ones}, ValueError, "either 'embedding' or 'embedding_fn'"),
((), {"dimension": 0}, ValueError, "must be a positive integer"),
(([],), {}, ValueError, "must not be empty"),
((["not", "numbers"],), {}, TypeError, "must be a sequence of numbers"),
],
)
def test_init_rejects_invalid_config(self, args, kwargs, exception, match):
with pytest.raises(exception, match=match):
MockTextEmbedder(*args, **kwargs)
def test_deterministic_embedding(self):
embedding = MockTextEmbedder(dimension=16).run("hello")["embedding"]
assert len(embedding) == 16
assert all(isinstance(value, float) for value in embedding)
# embeddings are L2-normalized, like real ones
assert math.isclose(math.sqrt(sum(value * value for value in embedding)), 1.0, abs_tol=1e-9)
def test_deterministic_distinguishes_texts(self):
embedder = MockTextEmbedder(dimension=8)
assert embedder.run("pizza")["embedding"] == embedder.run("pizza")["embedding"]
assert embedder.run("pizza")["embedding"] != embedder.run("pasta")["embedding"]
# determinism holds across instances and processes (stable hash, not the salted built-in hash)
assert (
MockTextEmbedder(dimension=8).run("x")["embedding"] == MockTextEmbedder(dimension=8).run("x")["embedding"]
)
def test_fixed_embedding(self):
embedder = MockTextEmbedder([0.1, 0.2, 0.3])
assert embedder.run("anything")["embedding"] == [0.1, 0.2, 0.3]
assert embedder.run("something else")["embedding"] == [0.1, 0.2, 0.3]
def test_embedding_fn(self):
assert MockTextEmbedder(embedding_fn=_ones).run("hello")["embedding"] == [1.0, 1.0, 1.0]
def test_embedding_fn_invalid_return_raises(self):
# embedding_fn deliberately returns a non-vector to exercise the runtime type check
embedder = MockTextEmbedder(embedding_fn=lambda text: "not a vector") # type: ignore[arg-type, return-value]
with pytest.raises(TypeError, match="must be a sequence of numbers"):
embedder.run("hello")
def test_prefix_suffix_affect_embedding(self):
plain = MockTextEmbedder(dimension=8).run("hello")["embedding"]
prefixed = MockTextEmbedder(dimension=8, prefix="search: ").run("hello")["embedding"]
assert plain != prefixed
def test_meta(self):
meta = MockTextEmbedder(dimension=4).run("a b c")["meta"]
assert meta["model"] == "mock-model"
assert meta["usage"] == {"prompt_tokens": 3, "total_tokens": 3}
# init model and meta are reflected and merged
custom = MockTextEmbedder(dimension=4, model="custom", meta={"extra": "value"}).run("hi")["meta"]
assert custom["model"] == "custom"
assert custom["extra"] == "value"
def test_run_rejects_non_string(self):
# a non-string input is passed on purpose to exercise the runtime type check
with pytest.raises(TypeError, match="expects a string"):
MockTextEmbedder().run(["not", "a", "string"]) # type: ignore[arg-type]
async def test_run_async(self):
embedder = MockTextEmbedder(dimension=8)
assert (await embedder.run_async("hello"))["embedding"] == embedder.run("hello")["embedding"]
def test_warm_up_sets_flag_and_run_auto_warms(self):
embedder = MockTextEmbedder(dimension=8)
assert embedder._is_warmed_up is False
embedder.warm_up()
assert embedder._is_warmed_up is True
# run auto-warms a fresh instance
fresh = MockTextEmbedder(dimension=8)
assert len(fresh.run("hello")["embedding"]) == 8
assert fresh._is_warmed_up is True
@pytest.mark.parametrize(
"embedder",
[
MockTextEmbedder(dimension=8, model="m", meta={"k": "v"}, prefix="p", suffix="s"),
MockTextEmbedder(embedding_fn=_ones),
MockTextEmbedder([0.1, 0.2, 0.3]),
],
ids=["deterministic", "embedding_fn", "fixed"],
)
def test_serialization_roundtrip(self, embedder):
restored = MockTextEmbedder.from_dict(embedder.to_dict())
assert isinstance(restored, MockTextEmbedder)
assert restored.run("hello")["embedding"] == embedder.run("hello")["embedding"]
def test_in_pipeline(self):
pipeline = Pipeline()
pipeline.add_component("embedder", MockTextEmbedder(dimension=8))
restored = Pipeline.from_dict(pipeline.to_dict())
result = restored.run({"embedder": {"text": "hello"}})
assert len(result["embedder"]["embedding"]) == 8
@@ -0,0 +1,431 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openai import APIError
import haystack.components.embedders.openai_document_embedder as openai_document_embedder_module
from haystack import Document
from haystack.components.embedders.openai_document_embedder import OpenAIDocumentEmbedder
from haystack.utils.auth import Secret
class TestOpenAIDocumentEmbedder:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
embedder = OpenAIDocumentEmbedder()
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.model == "text-embedding-ada-002"
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.batch_size == 32
assert embedder.progress_bar is True
assert embedder.meta_fields_to_embed == []
assert embedder.embedding_separator == "\n"
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAIDocumentEmbedder(
api_key=Secret.from_token("fake-api-key-2"),
model="model",
organization="my-org",
prefix="prefix",
suffix="suffix",
batch_size=64,
progress_bar=False,
meta_fields_to_embed=["test_field"],
embedding_separator=" | ",
timeout=40.0,
max_retries=1,
)
assert embedder.api_key.resolve_value() == "fake-api-key-2"
assert embedder.organization == "my-org"
assert embedder.model == "model"
assert embedder.prefix == "prefix"
assert embedder.suffix == "suffix"
assert embedder.batch_size == 64
assert embedder.progress_bar is False
assert embedder.meta_fields_to_embed == ["test_field"]
assert embedder.embedding_separator == " | "
assert embedder.timeout == 40.0
assert embedder.max_retries == 1
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_parameters_and_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAIDocumentEmbedder(
api_key=Secret.from_token("fake-api-key-2"),
model="model",
organization="my-org",
prefix="prefix",
suffix="suffix",
batch_size=64,
progress_bar=False,
meta_fields_to_embed=["test_field"],
embedding_separator=" | ",
)
assert embedder.api_key.resolve_value() == "fake-api-key-2"
assert embedder.organization == "my-org"
assert embedder.model == "model"
assert embedder.prefix == "prefix"
assert embedder.suffix == "suffix"
assert embedder.batch_size == 64
assert embedder.progress_bar is False
assert embedder.meta_fields_to_embed == ["test_field"]
assert embedder.embedding_separator == " | "
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.client is None
assert embedder.async_client is None
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
component = OpenAIDocumentEmbedder()
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.openai_document_embedder.OpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["OPENAI_API_KEY"], "strict": True, "type": "env_var"},
"api_base_url": None,
"model": "text-embedding-ada-002",
"dimensions": None,
"organization": None,
"http_client_kwargs": None,
"prefix": "",
"suffix": "",
"batch_size": 32,
"progress_bar": True,
"meta_fields_to_embed": [],
"embedding_separator": "\n",
"timeout": None,
"max_retries": None,
"raise_on_failure": False,
},
}
def test_to_dict_with_custom_init_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "fake-api-key")
component = OpenAIDocumentEmbedder(
api_key=Secret.from_env_var("ENV_VAR", strict=False),
model="model",
organization="my-org",
http_client_kwargs={"proxy": "http://localhost:8080"},
prefix="prefix",
suffix="suffix",
batch_size=64,
progress_bar=False,
meta_fields_to_embed=["test_field"],
embedding_separator=" | ",
timeout=10.0,
max_retries=2,
raise_on_failure=True,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.openai_document_embedder.OpenAIDocumentEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"},
"api_base_url": None,
"model": "model",
"dimensions": None,
"organization": "my-org",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
"prefix": "prefix",
"suffix": "suffix",
"batch_size": 64,
"progress_bar": False,
"meta_fields_to_embed": ["test_field"],
"embedding_separator": " | ",
"timeout": 10.0,
"max_retries": 2,
"raise_on_failure": True,
},
}
def test_prepare_texts_to_embed_w_metadata(self):
documents = [
Document(id=f"{i}", content=f"document number {i}:\ncontent", meta={"meta_field": f"meta_value {i}"})
for i in range(5)
]
embedder = OpenAIDocumentEmbedder(
api_key=Secret.from_token("fake-api-key"), meta_fields_to_embed=["meta_field"], embedding_separator=" | "
)
prepared_texts = embedder._prepare_texts_to_embed(documents)
assert prepared_texts == {
"0": "meta_value 0 | document number 0:\ncontent",
"1": "meta_value 1 | document number 1:\ncontent",
"2": "meta_value 2 | document number 2:\ncontent",
"3": "meta_value 3 | document number 3:\ncontent",
"4": "meta_value 4 | document number 4:\ncontent",
}
def test_prepare_texts_to_embed_w_suffix(self):
documents = [Document(id=f"{i}", content=f"document number {i}") for i in range(5)]
embedder = OpenAIDocumentEmbedder(
api_key=Secret.from_token("fake-api-key"), prefix="my_prefix ", suffix=" my_suffix"
)
prepared_texts = embedder._prepare_texts_to_embed(documents)
assert prepared_texts == {
"0": "my_prefix document number 0 my_suffix",
"1": "my_prefix document number 1 my_suffix",
"2": "my_prefix document number 2 my_suffix",
"3": "my_prefix document number 3 my_suffix",
"4": "my_prefix document number 4 my_suffix",
}
def test_run_wrong_input_format(self):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"))
# wrong formats
string_input = "text"
list_integers_input = [1, 2, 3]
with pytest.raises(TypeError, match="OpenAIDocumentEmbedder expects a list of Documents as input"):
embedder.run(documents=string_input) # type: ignore[arg-type]
with pytest.raises(TypeError, match="OpenAIDocumentEmbedder expects a list of Documents as input"):
embedder.run(documents=list_integers_input) # type: ignore[arg-type]
def test_run_on_empty_list(self):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"))
empty_list_input: list[Document] = []
result = embedder.run(documents=empty_list_input)
assert result["documents"] is not None
assert not result["documents"] # empty list
def test_embed_batch_handles_exceptions_gracefully(self, caplog):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"))
embedder.warm_up()
assert embedder.client is not None
fake_texts_to_embed = {"1": "text1", "2": "text2"}
with patch.object(
embedder.client.embeddings,
"create",
side_effect=APIError(message="Mocked error", request=Mock(), body=None),
):
embedder._embed_batch(texts_to_embed=fake_texts_to_embed, batch_size=2)
assert len(caplog.records) == 1
assert "Failed embedding of documents 1, 2 caused by Mocked error" in caplog.records[0].msg
def test_run_handles_exceptions_gracefully(self, caplog):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"), batch_size=1)
embedder.warm_up()
assert embedder.client is not None
docs = [
Document(content="I love cheese", meta={"topic": "Cuisine"}),
Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
]
# Create a successful response for the second call
successful_response = Mock()
successful_response.data = [
Mock(embedding=[0.4, 0.5, 0.6]) # Mock embedding for second doc
]
successful_response.model = "text-embedding-ada-002"
successful_response.usage = {"prompt_tokens": 10, "total_tokens": 10}
with patch.object(
embedder.client.embeddings,
"create",
side_effect=[
APIError(message="Mocked error", request=Mock(), body=None), # First call fails
successful_response, # Second call succeeds
],
):
result = embedder.run(documents=docs)
assert len(result["documents"]) == 2
assert result["documents"][0].embedding is None
assert result["documents"][1].embedding == [0.4, 0.5, 0.6]
def test_embed_batch_raises_exception_on_failure(self):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"), raise_on_failure=True)
embedder.warm_up()
assert embedder.client is not None
fake_texts_to_embed = {"1": "text1", "2": "text2"}
with patch.object(
embedder.client.embeddings,
"create",
side_effect=APIError(message="Mocked error", request=Mock(), body=None),
):
with pytest.raises(APIError, match="Mocked error"):
embedder._embed_batch(texts_to_embed=fake_texts_to_embed, batch_size=2)
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run(self):
docs = [
Document(content="I love cheese", meta={"topic": "Cuisine"}),
Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
]
model = "text-embedding-ada-002"
embedder = OpenAIDocumentEmbedder(model=model, meta_fields_to_embed=["topic"], embedding_separator=" | ")
result = embedder.run(documents=docs)
assert embedder.client is not None
documents_with_embeddings = result["documents"]
assert isinstance(documents_with_embeddings, list)
assert len(documents_with_embeddings) == len(docs)
for doc, new_doc in zip(docs, documents_with_embeddings, strict=True):
assert doc.embedding is None
assert new_doc is not doc
assert isinstance(new_doc, Document)
assert isinstance(new_doc.embedding, list)
assert len(new_doc.embedding) == 1536
assert all(isinstance(x, float) for x in new_doc.embedding)
assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
"The model name does not contain 'text' and 'ada'"
)
assert result["meta"]["usage"] == {"prompt_tokens": 15, "total_tokens": 15}, "Usage information does not match"
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_run_async(self):
embedder = OpenAIDocumentEmbedder(
model="text-embedding-ada-002", meta_fields_to_embed=["topic"], embedding_separator=" | "
)
docs = [
Document(content="I love cheese", meta={"topic": "Cuisine"}),
Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
]
result = await embedder.run_async(documents=docs)
assert embedder.async_client is not None
documents_with_embeddings = result["documents"]
assert isinstance(documents_with_embeddings, list)
assert len(documents_with_embeddings) == len(docs)
for doc, new_doc in zip(docs, documents_with_embeddings, strict=True):
assert doc.embedding is None
assert new_doc is not doc
assert isinstance(new_doc, Document)
assert isinstance(new_doc.embedding, list)
assert len(new_doc.embedding) == 1536
assert all(isinstance(x, float) for x in new_doc.embedding)
assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
"The model name does not contain 'text' and 'ada'"
)
assert result["meta"]["usage"] == {"prompt_tokens": 15, "total_tokens": 15}, "Usage information does not match"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await embedder.close_async()
@pytest.fixture
def mock_openai_clients(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="OpenAI")
async_cls = MagicMock(name="AsyncOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(openai_document_embedder_module, "OpenAI", sync_cls)
monkeypatch.setattr(openai_document_embedder_module, "AsyncOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
embedder = OpenAIDocumentEmbedder()
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 5
assert embedder.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 1
assert embedder.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"))
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 10
assert embedder.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
embedder = OpenAIDocumentEmbedder()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
embedder.warm_up()
def test_sync_lifecycle(self, mock_openai_clients):
sync_cls, _ = mock_openai_clients
sync_client = sync_cls.return_value
embedder = OpenAIDocumentEmbedder()
assert embedder.client is None
assert embedder.async_client is None
embedder.warm_up()
assert embedder.client is sync_cls.return_value
assert embedder.async_client is None
embedder.close()
sync_client.close.assert_called_once()
assert embedder.client is None
async def test_async_lifecycle(self, mock_openai_clients):
_, async_cls = mock_openai_clients
async_client = async_cls.return_value
embedder = OpenAIDocumentEmbedder()
await embedder.warm_up_async()
assert embedder.async_client is async_cls.return_value
assert embedder.client is None
await embedder.close_async()
async_client.close.assert_awaited_once()
assert embedder.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
embedder = OpenAIDocumentEmbedder()
embedder.close()
await embedder.close_async()
assert embedder.client is None
assert embedder.async_client is None
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
embedder = OpenAIDocumentEmbedder()
embedder.warm_up()
await embedder.warm_up_async()
embedder.close()
assert embedder.client is None
assert embedder.async_client is not None
await embedder.close_async()
assert embedder.async_client is None
@@ -0,0 +1,316 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock
import pytest
from openai.types import CreateEmbeddingResponse, Embedding
from openai.types.create_embedding_response import Usage
import haystack.components.embedders.openai_text_embedder as openai_text_embedder_module
from haystack.components.embedders.openai_text_embedder import OpenAITextEmbedder
from haystack.utils.auth import Secret
class TestOpenAITextEmbedder:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
embedder = OpenAITextEmbedder()
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.model == "text-embedding-ada-002"
assert embedder.api_base_url is None
assert embedder.organization is None
assert embedder.prefix == ""
assert embedder.suffix == ""
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAITextEmbedder(
api_key=Secret.from_token("fake-api-key"),
model="model",
api_base_url="https://my-custom-base-url.com",
organization="fake-organization",
prefix="prefix",
suffix="suffix",
timeout=40.0,
max_retries=1,
)
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.model == "model"
assert embedder.api_base_url == "https://my-custom-base-url.com"
assert embedder.organization == "fake-organization"
assert embedder.prefix == "prefix"
assert embedder.suffix == "suffix"
assert embedder.timeout == 40.0
assert embedder.max_retries == 1
assert embedder.client is None
assert embedder.async_client is None
def test_init_with_parameters_and_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAITextEmbedder(
api_key=Secret.from_token("fake-api-key"),
model="model",
api_base_url="https://my-custom-base-url.com",
organization="fake-organization",
prefix="prefix",
suffix="suffix",
)
assert embedder.api_key.resolve_value() == "fake-api-key"
assert embedder.model == "model"
assert embedder.api_base_url == "https://my-custom-base-url.com"
assert embedder.organization == "fake-organization"
assert embedder.prefix == "prefix"
assert embedder.suffix == "suffix"
assert embedder.timeout is None
assert embedder.max_retries is None
assert embedder.client is None
assert embedder.async_client is None
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
component = OpenAITextEmbedder()
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.openai_text_embedder.OpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["OPENAI_API_KEY"], "strict": True, "type": "env_var"},
"api_base_url": None,
"dimensions": None,
"model": "text-embedding-ada-002",
"organization": None,
"http_client_kwargs": None,
"prefix": "",
"suffix": "",
"timeout": None,
"max_retries": None,
},
}
def test_to_dict_with_custom_init_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "fake-api-key")
component = OpenAITextEmbedder(
api_key=Secret.from_env_var("ENV_VAR", strict=False),
model="model",
api_base_url="https://my-custom-base-url.com",
organization="fake-organization",
prefix="prefix",
suffix="suffix",
timeout=10.0,
max_retries=2,
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.embedders.openai_text_embedder.OpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"},
"api_base_url": "https://my-custom-base-url.com",
"model": "model",
"dimensions": None,
"organization": "fake-organization",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
"prefix": "prefix",
"suffix": "suffix",
"timeout": 10.0,
"max_retries": 2,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.embedders.openai_text_embedder.OpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["OPENAI_API_KEY"], "strict": True, "type": "env_var"},
"model": "text-embedding-ada-002",
"api_base_url": "https://my-custom-base-url.com",
"organization": "fake-organization",
"http_client_kwargs": None,
"prefix": "prefix",
"suffix": "suffix",
},
}
component = OpenAITextEmbedder.from_dict(data)
assert component.api_key.resolve_value() == "fake-api-key"
assert component.model == "text-embedding-ada-002"
assert component.api_base_url == "https://my-custom-base-url.com"
assert component.organization == "fake-organization"
assert component.http_client_kwargs is None
assert component.prefix == "prefix"
assert component.suffix == "suffix"
def test_prepare_input(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
embedder = OpenAITextEmbedder(dimensions=1536)
inp = "The food was delicious"
prepared_input = embedder._prepare_input(inp)
assert prepared_input == {
"model": "text-embedding-ada-002",
"input": "The food was delicious",
"encoding_format": "float",
"dimensions": 1536,
}
def test_prepare_output(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
response = CreateEmbeddingResponse(
data=[Embedding(embedding=[0.1, 0.2, 0.3], index=0, object="embedding")],
model="text-embedding-ada-002",
object="list",
usage=Usage(prompt_tokens=6, total_tokens=6),
)
embedder = OpenAITextEmbedder()
result = embedder._prepare_output(result=response)
assert result == {
"embedding": [0.1, 0.2, 0.3],
"meta": {"model": "text-embedding-ada-002", "usage": {"prompt_tokens": 6, "total_tokens": 6}},
}
def test_run_wrong_input_format(self):
embedder = OpenAITextEmbedder(api_key=Secret.from_token("fake-api-key"))
list_integers_input = [1, 2, 3]
with pytest.raises(TypeError, match="OpenAITextEmbedder expects a string as an input"):
embedder.run(text=list_integers_input) # type: ignore[arg-type]
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run(self):
model = "text-embedding-ada-002"
embedder = OpenAITextEmbedder(model=model, prefix="prefix ", suffix=" suffix")
result = embedder.run(text="The food was delicious")
assert len(result["embedding"]) == 1536
assert all(isinstance(x, float) for x in result["embedding"])
assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
"The model name does not contain 'text' and 'ada'"
)
assert result["meta"]["usage"] == {"prompt_tokens": 6, "total_tokens": 6}, "Usage information does not match"
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_run_async(self):
embedder = OpenAITextEmbedder(model="text-embedding-ada-002", prefix="prefix ", suffix=" suffix")
result = await embedder.run_async(text="The food was delicious")
assert len(result["embedding"]) == 1536
assert all(isinstance(x, float) for x in result["embedding"])
assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
"The model name does not contain 'text' and 'ada'"
)
assert result["meta"]["usage"] == {"prompt_tokens": 6, "total_tokens": 6}, "Usage information does not match"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await embedder.close_async()
@pytest.fixture
def mock_openai_clients(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="OpenAI")
async_cls = MagicMock(name="AsyncOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(openai_text_embedder_module, "OpenAI", sync_cls)
monkeypatch.setattr(openai_text_embedder_module, "AsyncOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
embedder = OpenAITextEmbedder()
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 5
assert embedder.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
embedder = OpenAITextEmbedder(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 1
assert embedder.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
embedder = OpenAITextEmbedder(api_key=Secret.from_token("fake-api-key"))
embedder.warm_up()
assert embedder.client is not None
assert embedder.client.max_retries == 10
assert embedder.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
embedder = OpenAITextEmbedder()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
embedder.warm_up()
def test_sync_lifecycle(self, mock_openai_clients):
sync_cls, _ = mock_openai_clients
sync_client = sync_cls.return_value
embedder = OpenAITextEmbedder()
assert embedder.client is None
assert embedder.async_client is None
embedder.warm_up()
assert embedder.client is sync_cls.return_value
assert embedder.async_client is None
embedder.close()
sync_client.close.assert_called_once()
assert embedder.client is None
async def test_async_lifecycle(self, mock_openai_clients):
_, async_cls = mock_openai_clients
async_client = async_cls.return_value
embedder = OpenAITextEmbedder()
await embedder.warm_up_async()
assert embedder.async_client is async_cls.return_value
assert embedder.client is None
await embedder.close_async()
async_client.close.assert_awaited_once()
assert embedder.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
embedder = OpenAITextEmbedder()
embedder.close()
await embedder.close_async()
assert embedder.client is None
assert embedder.async_client is None
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
embedder = OpenAITextEmbedder()
embedder.warm_up()
await embedder.warm_up_async()
embedder.close()
assert embedder.client is None
assert embedder.async_client is not None
await embedder.close_async()
assert embedder.async_client is None
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.evaluators import AnswerExactMatchEvaluator
def test_run_with_all_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin", "Paris"])
assert result == {"individual_scores": [1, 1], "score": 1.0}
def test_run_with_no_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Paris", "London"])
assert result == {"individual_scores": [0, 0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin", "London"])
assert result == {"individual_scores": [1, 0], "score": 0.5}
def test_run_with_complex_data():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(
ground_truth_answers=[
"France",
"9th century",
"9th",
"classical music",
"classical",
"11th century",
"the 11th",
"Denmark",
"Iceland",
"Norway",
"10th century",
"10th",
],
predicted_answers=[
"France",
"9th century",
"10th century",
"9th",
"classic music",
"rock music",
"dubstep",
"the 11th",
"11th century",
"Denmark, Iceland and Norway",
"10th century",
"10th",
],
)
assert result == {"individual_scores": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1], "score": 0.3333333333333333}
def test_run_with_different_lengths():
evaluator = AnswerExactMatchEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=["Berlin"], predicted_answers=["Berlin", "London"])
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin"])
@@ -0,0 +1,346 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
import os
import pytest
from haystack import Pipeline
from haystack.components.evaluators import ContextRelevanceEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils.auth import Secret
class TestContextRelevanceEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
assert component.instructions == (
"Please extract only sentences from the provided context which are absolutely relevant and "
"required to answer the following question. If no relevant sentences are found, or if you "
"believe the question cannot be answered from the given context, return an empty list, example: []"
)
assert component.inputs == [("questions", list[str]), ("contexts", list[list[str]])]
assert component.outputs == ["relevant_statements"]
assert component.examples == [
{
"inputs": {
"questions": "What is the capital of Germany?",
"contexts": ["Berlin is the capital of Germany. Berlin and was founded in 1244."],
},
"outputs": {"relevant_statements": ["Berlin is the capital of Germany."]},
},
{
"inputs": {
"questions": "What is the capital of France?",
"contexts": [
"Berlin is the capital of Germany and was founded in 1244.",
"Europe is a continent with 44 countries.",
"Madrid is the capital of Spain.",
],
},
"outputs": {"relevant_statements": []},
},
{
"inputs": {"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."]},
"outputs": {"relevant_statements": ["Rome is the capital of Italy."]},
},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = ContextRelevanceEvaluator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(
examples=[
{"inputs": {"questions": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"questions": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
)
assert component.examples == [
{"inputs": {"questions": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"questions": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = ContextRelevanceEvaluator(chat_generator=chat_generator)
assert component._chat_generator is chat_generator
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "test-api-key")
chat_generator = OpenAIChatGenerator(
generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42},
api_key=Secret.from_env_var("ENV_VAR"),
)
component = ContextRelevanceEvaluator(
chat_generator=chat_generator,
examples=[{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
raise_on_failure=False,
progress_bar=False,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.context_relevance.ContextRelevanceEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
"progress_bar": False,
"raise_on_failure": False,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.context_relevance.ContextRelevanceEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
},
}
component = ContextRelevanceEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.examples == [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
pipeline = Pipeline()
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python is design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
results = component.run(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
def test_run_no_statements_extracted(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[],
]
results = component.run(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
def test_run_missing_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
component.run()
def test_run_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(raise_on_failure=False)
def chat_generator_run(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["c", "d"], "score": 1}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.context_relevance"):
results = component.run(questions=questions, contexts=contexts)
assert results["score"] == 1
assert results["results"][0] == {"relevant_statements": ["c", "d"], "score": 1}
assert results["results"][1]["relevant_statements"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_live_run(self):
questions = ["Who created the Python language?"]
contexts = [["Python, created by Guido van Rossum, is a high-level general-purpose programming language."]]
evaluator = ContextRelevanceEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = evaluator.run(questions=questions, contexts=contexts)
required_fields = {"results"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "relevant_statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
class TestContextRelevanceEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
async def chat_generator_run_async(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
["Football is the world's most popular sport."],
["Python is a cross-platform programming language."],
]
results = await component.run_async(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
@pytest.mark.asyncio
async def test_run_async_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(raise_on_failure=False)
async def chat_generator_run_async(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["c", "d"], "score": 1}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is popular."], ["Python was created by Guido van Rossum."]]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.context_relevance"):
results = await component.run_async(questions=questions, contexts=contexts)
assert results["score"] == 1
assert results["results"][0] == {"relevant_statements": ["c", "d"], "score": 1}
assert results["results"][1]["relevant_statements"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
async def test_live_run_async(self):
questions = ["Who created the Python language?"]
contexts = [["Python, created by Guido van Rossum, is a high-level general-purpose programming language."]]
evaluator = ContextRelevanceEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = await evaluator.run_async(questions=questions, contexts=contexts)
required_fields = {"results"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "relevant_statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_map import DocumentMAPEvaluator
def test_to_dict():
evaluator = DocumentMAPEvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_map.DocumentMAPEvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_map.DocumentMAPEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentMAPEvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"})],
[Document(content="y", meta={"file_id": "b"})],
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"})],
[Document(content="w", meta={"file_id": "c"})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"source": {"url": "https://a.com"}})],
[Document(content="y", meta={"source": {"url": "https://b.com"}})],
],
retrieved_documents=[
[Document(content="z", meta={"source": {"url": "https://a.com"}})],
[Document(content="w", meta={"source": {"url": "https://c.com"}})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_all_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert result == {
"individual_scores": [
1.0,
pytest.approx(0.8333333333333333),
1.0,
pytest.approx(0.5833333333333333),
0.0,
pytest.approx(0.8055555555555555),
],
"score": pytest.approx(0.7037037037037037),
}
def test_run_with_different_lengths():
with pytest.raises(ValueError):
evaluator = DocumentMAPEvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator = DocumentMAPEvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
@@ -0,0 +1,152 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_mrr import DocumentMRREvaluator
def test_to_dict():
evaluator = DocumentMRREvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_to_dict_custom_field():
evaluator = DocumentMRREvaluator(document_comparison_field="id")
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentMRREvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"})],
[Document(content="y", meta={"file_id": "b"})],
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"})],
[Document(content="w", meta={"file_id": "c"})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"source": {"url": "https://a.com"}})],
[Document(content="y", meta={"source": {"url": "https://b.com"}})],
],
retrieved_documents=[
[Document(content="z", meta={"source": {"url": "https://a.com"}})],
[Document(content="w", meta={"source": {"url": "https://c.com"}})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_all_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="10th century"), Document(content="9th century"), Document(content="9th")],
[Document(content="rock music"), Document(content="dubstep"), Document(content="classical")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert result == {
"individual_scores": [1.0, 0.5, 0.3333333333333333, 0.5, 0.0, 1.0],
"score": pytest.approx(0.555555555555555),
}
def test_run_with_different_lengths():
with pytest.raises(ValueError):
evaluator = DocumentMRREvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator = DocumentMRREvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
@@ -0,0 +1,355 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_ndcg import DocumentNDCGEvaluator
def test_run_with_scores():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[
[
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc6", score=2),
Document(content="doc7", score=3),
Document(content="doc8", score=2),
]
],
retrieved_documents=[
[
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.6592, abs=1e-4)
def test_run_without_scores():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="France"), Document(content="Paris")]],
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_multiple_lists_of_docs():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France"), Document(content="Paris")],
[
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc6", score=2),
Document(content="doc7", score=3),
Document(content="doc8", score=2),
],
],
retrieved_documents=[
[Document(content="France"), Document(content="Germany"), Document(content="Paris")],
[
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
],
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["individual_scores"][1] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.7895, abs=1e-4)
def test_run_with_different_lengths():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_run_with_mixed_documents_with_and_without_scores():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="France", score=3), Document(content="Paris")]],
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
)
def test_run_empty_retrieved():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[Document(content="France")]], retrieved_documents=[[]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_empty_ground_truth():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[]], retrieved_documents=[[Document(content="France")]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_empty_retrieved_and_empty_ground_truth():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[]], retrieved_documents=[[]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_no_retrieved():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
_ = evaluator.run(ground_truth_documents=[[Document(content="France")]], retrieved_documents=[])
def test_run_no_ground_truth():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_documents=[], retrieved_documents=[[Document(content="France")]])
def test_run_no_retrieved_and_no_ground_truth():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_documents=[], retrieved_documents=[])
def test_calculate_dcg_with_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc4", score=0),
Document(content="doc5", score=1),
Document(content="doc6", score=2),
]
ret_docs = [
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
Document(content="doc6"),
]
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == pytest.approx(6.8611, abs=1e-4)
def test_calculate_dcg_without_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1"), Document(content="doc2")]
ret_docs = [Document(content="doc2"), Document(content="doc3"), Document(content="doc1")]
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == pytest.approx(1.5, abs=1e-4)
def test_calculate_dcg_empty():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1")]
ret_docs = []
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == 0
def test_calculate_idcg_with_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [
Document(content="doc1", score=3),
Document(content="doc2", score=3),
Document(content="doc3", score=2),
Document(content="doc4", score=3),
Document(content="doc5", score=2),
Document(content="doc6", score=2),
]
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == pytest.approx(8.7403, abs=1e-4)
def test_calculate_idcg_without_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1"), Document(content="doc2"), Document(content="doc3")]
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == pytest.approx(2.1309, abs=1e-4)
def test_calculate_idcg_empty():
evaluator = DocumentNDCGEvaluator()
gt_docs = []
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == 0
def test_to_dict_default():
evaluator = DocumentNDCGEvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_to_dict_custom_field():
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentNDCGEvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
# Documents with same content but different IDs — id comparison
# must match on id, not content
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="France"), Document(id="doc2", content="Paris")]],
retrieved_documents=[
[
Document(id="doc1", content="different text"),
Document(id="doc3", content="Germany"),
Document(id="doc2", content="also different"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_id_comparison_no_match():
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="France")]],
retrieved_documents=[[Document(id="doc99", content="France")]],
)
# Same content, different ID — should NOT match when comparing by id
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_with_meta_comparison():
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="France", meta={"file_id": "f1"}), Document(content="Paris", meta={"file_id": "f2"})]
],
retrieved_documents=[
[
Document(content="different", meta={"file_id": "f1"}),
Document(content="irrelevant", meta={"file_id": "f99"}),
Document(content="also different", meta={"file_id": "f2"}),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_nested_meta_comparison():
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[[Document(content="x", meta={"source": {"url": "https://a.com"}})]],
retrieved_documents=[[Document(content="z", meta={"source": {"url": "https://a.com"}})]],
)
assert result["individual_scores"] == [1.0]
assert result["score"] == 1.0
def test_run_with_meta_missing_key_treated_as_no_match():
# Documents missing the meta key should not match anything
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[[Document(content="France", meta={"file_id": "f1"})]],
retrieved_documents=[[Document(content="France", meta={})]],
)
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_with_id_comparison_with_scores():
# Verify that relevance scores are honoured when comparing by id
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[
[
Document(id="doc1", content="foo", score=3),
Document(id="doc2", content="bar", score=2),
Document(id="doc3", content="baz", score=3),
Document(id="doc6", content="qux", score=2),
Document(id="doc7", content="quux", score=3),
Document(id="doc8", content="corge", score=2),
]
],
retrieved_documents=[
[
Document(id="doc1", content="x"),
Document(id="doc2", content="y"),
Document(id="doc3", content="z"),
Document(id="doc4", content="w"),
Document(id="doc5", content="v"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.6592, abs=1e-4)
def test_unsupported_comparison_field_raises():
evaluator = DocumentNDCGEvaluator(document_comparison_field="embedding")
with pytest.raises(ValueError, match="Unsupported document_comparison_field"):
evaluator.run(
ground_truth_documents=[[Document(content="France")]], retrieved_documents=[[Document(content="France")]]
)
def test_run_with_meta_missing_key_can_still_reach_perfect_ndcg():
"""
Regression test for the IDCG/DCG inflation bug: ground truth documents that
cannot be matched (missing the configured meta key) must be excluded from
IDCG too, otherwise NDCG can never reach 1.0 even for a perfect retrieval.
"""
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[
Document(content="France", meta={"file_id": "f1"}),
Document(content="unmatchable", meta={}), # no file_id -> cannot be matched
]
],
retrieved_documents=[[Document(content="France", meta={"file_id": "f1"})]],
)
# Perfect retrieval of the one matchable document should yield NDCG of exactly 1.0
assert result["individual_scores"] == [1.0]
assert result["score"] == 1.0
@@ -0,0 +1,267 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import default_from_dict
from haystack.components.evaluators.document_recall import DocumentRecallEvaluator, RecallMode
from haystack.dataclasses import Document
def test_init_with_unknown_mode_string():
with pytest.raises(ValueError):
DocumentRecallEvaluator(mode="unknown_mode")
def test_init_with_string_mode():
evaluator = DocumentRecallEvaluator(mode="single_hit")
assert evaluator.mode == RecallMode.SINGLE_HIT
evaluator = DocumentRecallEvaluator(mode="multi_hit")
assert evaluator.mode == RecallMode.MULTI_HIT
def test_init_default_comparison_field():
evaluator = DocumentRecallEvaluator()
assert evaluator.document_comparison_field == "content"
def test_run_with_id_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT, document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"}), Document(content="y", meta={"file_id": "b"})]
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"}), Document(content="w", meta={"file_id": "c"})]
],
)
assert result == {"individual_scores": [0.5], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[
Document(content="x", meta={"source": {"url": "https://a.com"}}),
Document(content="y", meta={"source": {"url": "https://b.com"}}),
]
],
retrieved_documents=[
[
Document(content="z", meta={"source": {"url": "https://a.com"}}),
Document(content="w", meta={"source": {"url": "https://c.com"}}),
]
],
)
assert result == {"individual_scores": [0.5], "score": 0.5}
class TestDocumentRecallEvaluatorSingleHit:
@pytest.fixture
def evaluator(self):
return DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT)
def test_run_with_all_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data(self, evaluator):
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1, 1, 1, 1, 0, 1], "score": 0.8333333333333334}
def test_run_with_different_lengths(self, evaluator):
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_to_dict(self, evaluator):
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "single_hit", "document_comparison_field": "content"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "single_hit"},
}
new_evaluator = default_from_dict(DocumentRecallEvaluator, data)
assert new_evaluator.mode == RecallMode.SINGLE_HIT
class TestDocumentRecallEvaluatorMultiHit:
@pytest.fixture
def evaluator(self):
return DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT)
def test_run_with_all_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data(self, evaluator):
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[
Document(content="Denmark"),
Document(content="Iceland"),
Document(content="Norway"),
Document(content="Denmark, Iceland and Norway"),
],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0, 0.5, 1.0, 0.75, 1.0], "score": 0.875}
def test_run_with_different_lengths(self, evaluator):
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_to_dict(self, evaluator):
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "multi_hit", "document_comparison_field": "content"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "multi_hit"},
}
new_evaluator = default_from_dict(DocumentRecallEvaluator, data)
assert new_evaluator.mode == RecallMode.MULTI_HIT
def test_empty_ground_truth_documents(self, evaluator):
ground_truth_documents = [[]]
retrieved_documents = [[Document(content="test")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_retrieved_documents(self, evaluator):
ground_truth_documents = [[Document(content="test")]]
retrieved_documents = [[]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_string_ground_truth_documents(self, evaluator):
ground_truth_documents = [[Document(content="")]]
retrieved_documents = [[Document(content="test")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_string_retrieved_documents(self, evaluator):
ground_truth_documents = [[Document(content="test")]]
retrieved_documents = [[Document(content="")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
@@ -0,0 +1,407 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
import os
import pytest
from haystack import Pipeline
from haystack.components.evaluators import FaithfulnessEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils.auth import Secret
class TestFaithfulnessEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
assert component.instructions == (
"Your task is to judge the faithfulness or groundedness of statements based "
"on context information. First, please extract statements from a provided predicted "
"answer to a question. Second, calculate a faithfulness score for each "
"statement made in the predicted answer. The score is 1 if the statement can be "
"inferred from the provided context or 0 if it cannot be inferred."
)
assert component.inputs == [
("questions", list[str]),
("contexts", list[list[str]]),
("predicted_answers", list[str]),
]
assert component.outputs == ["statements", "statement_scores"]
assert component.examples == [
{
"inputs": {
"questions": "What is the capital of Germany and when was it founded?",
"contexts": ["Berlin is the capital of Germany and was founded in 1244."],
"predicted_answers": "The capital of Germany, Berlin, was founded in the 13th century.",
},
"outputs": {
"statements": ["Berlin is the capital of Germany.", "Berlin was founded in 1244."],
"statement_scores": [1, 1],
},
},
{
"inputs": {
"questions": "What is the capital of France?",
"contexts": ["Berlin is the capital of Germany."],
"predicted_answers": "Paris",
},
"outputs": {"statements": ["Paris is the capital of France."], "statement_scores": [0]},
},
{
"inputs": {
"questions": "What is the capital of Italy?",
"contexts": ["Rome is the capital of Italy."],
"predicted_answers": "Rome is the capital of Italy with more than 4 million inhabitants.",
},
"outputs": {
"statements": ["Rome is the capital of Italy.", "Rome has more than 4 million inhabitants."],
"statement_scores": [1, 0],
},
},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = FaithfulnessEvaluator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(
examples=[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
]
)
assert component.examples == [
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = FaithfulnessEvaluator(chat_generator=chat_generator)
assert component._chat_generator is chat_generator
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "test-api-key")
chat_generator = OpenAIChatGenerator(
generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42},
api_key=Secret.from_env_var("ENV_VAR"),
)
component = FaithfulnessEvaluator(
chat_generator=chat_generator,
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=False,
progress_bar=False,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.faithfulness.FaithfulnessEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
"progress_bar": False,
"raise_on_failure": False,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.faithfulness.FaithfulnessEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
component = FaithfulnessEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
pipeline = Pipeline()
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"Python is a high-level general-purpose programming language that was created by George Lucas.",
]
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 1],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 1, "statement_scores": [1, 1], "statements": ["c", "d"]},
],
"score": 0.75,
"meta": None,
}
def test_run_no_statements_extracted(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": [], "statement_scores": []}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"I don't know.",
]
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 0],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 0, "statement_scores": [], "statements": []},
],
"score": 0.25,
"meta": None,
}
def test_run_missing_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
component.run()
def test_run_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(raise_on_failure=False)
def chat_generator_run(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"Guido van Rossum.",
]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.faithfulness"):
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results["score"] == 1.0
assert results["individual_scores"][0] == 1.0
assert math.isnan(results["individual_scores"][1])
assert results["results"][0] == {"statements": ["c", "d"], "statement_scores": [1, 1], "score": 1.0}
assert results["results"][1]["statements"] == []
assert results["results"][1]["statement_scores"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_live_run(self):
questions = ["What is Python and who created it?"]
contexts = [["Python is a programming language created by Guido van Rossum."]]
predicted_answers = ["Python is a programming language created by George Lucas."]
evaluator = FaithfulnessEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = evaluator.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
required_fields = {"individual_scores", "results", "score"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "statement_scores", "statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
# assert that metadata is present in the result
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
class TestFaithfulnessEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
async def chat_generator_run_async(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is the world's most popular sport."], ["Python was created by Guido van Rossum."]]
predicted_answers = ["Football is the most popular sport.", "Python is a language created by George Lucas."]
results = await component.run_async(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 1.0],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 1.0, "statement_scores": [1, 1], "statements": ["c", "d"]},
],
"score": 0.75,
"meta": None,
}
@pytest.mark.asyncio
async def test_run_async_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(raise_on_failure=False)
async def chat_generator_run_async(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is popular."], ["Python was created by Guido."]]
predicted_answers = ["Football is popular.", "Guido van Rossum."]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.faithfulness"):
results = await component.run_async(
questions=questions, contexts=contexts, predicted_answers=predicted_answers
)
assert results["score"] == 1.0
assert results["individual_scores"][0] == 1.0
assert math.isnan(results["individual_scores"][1])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
async def test_live_run_async(self):
questions = ["What is Python and who created it?"]
contexts = [["Python is a programming language created by Guido van Rossum."]]
predicted_answers = ["Python is a programming language created by George Lucas."]
evaluator = FaithfulnessEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = await evaluator.run_async(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
required_fields = {"individual_scores", "results", "score"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "statement_scores", "statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
# assert that metadata is present in the result
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
@@ -0,0 +1,636 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import AsyncMock, Mock
import pytest
from haystack import Pipeline
from haystack.components.evaluators import LLMEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
class TestLLMEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
assert component.instructions == "test-instruction"
assert component.inputs == [("predicted_answers", list[str])]
assert component.outputs == ["score"]
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"custom_key": "custom_value"})
component = LLMEvaluator(
instructions="test-instruction",
chat_generator=chat_generator,
inputs=[("predicted_answers", list[str])],
outputs=["custom_score"],
examples=[
{"inputs": {"predicted_answers": "answer 1"}, "outputs": {"custom_score": 1}},
{"inputs": {"predicted_answers": "answer 2"}, "outputs": {"custom_score": 0}},
],
)
assert component._chat_generator is chat_generator
def test_init_with_invalid_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Invalid inputs
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs={("predicted_answers", list[str])},
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[(list[str], "predicted_answers")],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[list[str]],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs={("predicted_answers", str)},
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# Invalid outputs
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs="score",
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=[["score"]],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# Invalid examples
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples={
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
}
]
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"wrong_key": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": [{"predicted_answers": "Damn, this is straight outta hell!!!"}],
"outputs": [{"custom_score": 1}],
}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[{"inputs": {1: "Damn, this is straight outta hell!!!"}, "outputs": {2: 1}}],
)
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["score"],
"raise_on_failure": True,
"progress_bar": True,
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["custom_score"],
raise_on_failure=False,
examples=[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
],
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["custom_score"],
"raise_on_failure": False,
"progress_bar": True,
"examples": [
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
],
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["score"],
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
component = LLMEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.instructions == "test-instruction"
assert component.inputs == [("predicted_answers", list[str])]
assert component.outputs == ["score"]
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
pipeline = Pipeline()
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_with_different_lengths(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 0.5}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
with pytest.raises(ValueError):
component.run(questions=["What is the capital of Germany?"], predicted_answers=[["Berlin"], ["Paris"]])
with pytest.raises(ValueError):
component.run(
questions=["What is the capital of Germany?", "What is the capital of France?"],
predicted_answers=[["Berlin"]],
)
def test_run_returns_parsed_result(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 0.5}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
results = component.run(questions=["What is the capital of Germany?"], predicted_answers=["Berlin"])
assert results == {"results": [{"score": 0.5}], "meta": None}
def test_prepare_template(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"score": 1}},
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}},
],
)
template = component.prepare_template()
assert (
template
== "Instructions:\ntest-instruction\n\nGenerate the response in JSON format with the following keys:"
'\n["score"]\nConsider the instructions and the examples below to determine those values.\n\n'
'Examples:\nInputs:\n{"predicted_answers": "Damn, this is straight outta hell!!!"}\nOutputs:'
'\n{"score": 1}\nInputs:\n{"predicted_answers": "Football is the most popular sport."}\nOutputs:'
'\n{"score": 0}\n\nInputs:\n{"predicted_answers": {{ predicted_answers }}}\nOutputs:\n'
)
def test_invalid_input_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# None of the expected parameters are received
with pytest.raises(ValueError):
component.validate_input_parameters(
expected={"predicted_answers": list[str]}, received={"questions": list[str]}
)
# Only one but not all the expected parameters are received
with pytest.raises(ValueError):
component.validate_input_parameters(
expected={"predicted_answers": list[str], "questions": list[str]}, received={"questions": list[str]}
)
# Received inputs are not lists
with pytest.raises(ValueError):
component.validate_input_parameters(expected={"questions": list[str]}, received={"questions": str})
def test_invalid_outputs(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 1.0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
# Test missing key "another_expected_output"
component.outputs = ["score", "another_expected_output"]
with pytest.raises(ValueError, match="Missing expected keys"):
component.run(predicted_answers=["answer"])
# Test wrong key
def chat_generator_run_wrong_key(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"wrong_name": 1.0}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run_wrong_key
)
component.outputs = ["score"]
with pytest.raises(ValueError, match="Missing expected keys"):
component.run(predicted_answers=["answer"])
def test_output_invalid_json_raise_on_failure_false(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=False,
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant("some_invalid_json_output")]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
result = component.run(predicted_answers=["answer"])
assert result["results"] == [None]
def test_output_invalid_json_raise_on_failure_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=True,
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant("some_invalid_json_output")]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
with pytest.raises(
ValueError
): # json_utils/LLMEvaluator might raise JSONDecodeError which inherits from ValueError or wrapped
component.run(predicted_answers=["answer"])
class TestLLMEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_returns_parsed_result(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the value of any non-zero number raised to the power of zero?",
"predicted_answers": "Zero",
},
"outputs": {"score": 0},
}
],
)
async def chat_generator_run_async(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 1}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
results = await component.run_async(
questions=["What is the perimeter of a circle called?"], predicted_answers=["Circumference"]
)
assert results == {"results": [{"score": 1}], "meta": None}
@pytest.mark.asyncio
async def test_run_async_fallback_to_thread_with_sync_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
class SyncOnlyGenerator:
def run(self, messages):
return {"replies": [ChatMessage.from_assistant('{"score": 0}')]}
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the top sport?",
"predicted_answers": "Football is the most popular sport.",
},
"outputs": {"score": 1},
}
],
chat_generator=SyncOnlyGenerator(),
)
results = await component.run_async(questions=["question"], predicted_answers=["answer"])
assert results == {"results": [{"score": 0}], "meta": None}
@pytest.mark.asyncio
async def test_run_async_raise_on_failure_false(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the value of any non-zero number raised to the power of zero?",
"predicted_answers": "One",
},
"outputs": {"score": 1},
}
],
raise_on_failure=False,
)
async def chat_generator_run_async(self, *args, **kwargs):
raise Exception("API error")
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
result = await component.run_async(questions=["question"], predicted_answers=["answer"])
assert result["results"] == [None]
@pytest.mark.asyncio
async def test_run_async_raise_on_failure_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the smallest unit of data in a computer?",
"predicted_answers": "Bit",
},
"outputs": {"score": 1},
}
],
)
async def chat_generator_run_async(self, *args, **kwargs):
raise Exception("API error")
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
with pytest.raises(ValueError):
await component.run_async(questions=["question"], predicted_answers=["answer"])
class TestComponentLifecycle:
@staticmethod
def _make_evaluator(chat_generator):
return LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
chat_generator=chat_generator,
)
def test_warm_up_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "warm_up"])
evaluator = self._make_evaluator(chat_generator)
evaluator.warm_up()
chat_generator.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "warm_up_async"])
chat_generator.warm_up_async = AsyncMock()
evaluator = self._make_evaluator(chat_generator)
await evaluator.warm_up_async()
chat_generator.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
chat_generator = Mock(spec=["run", "warm_up"])
evaluator = self._make_evaluator(chat_generator)
await evaluator.warm_up_async()
chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "close"])
evaluator = self._make_evaluator(chat_generator)
evaluator.close()
chat_generator.close.assert_called_once()
async def test_close_async_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "close_async"])
chat_generator.close_async = AsyncMock()
evaluator = self._make_evaluator(chat_generator)
await evaluator.close_async()
chat_generator.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
chat_generator = Mock(spec=["run", "close"])
evaluator = self._make_evaluator(chat_generator)
await evaluator.close_async()
chat_generator.close.assert_called_once()
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
chat_generator = Mock(spec=["run"])
evaluator = self._make_evaluator(chat_generator)
evaluator.warm_up()
await evaluator.warm_up_async()
evaluator.close()
await evaluator.close_async()
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.evaluators.sas_evaluator import SASEvaluator
from haystack.utils.device import ComponentDevice
class TestSASEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator()
assert evaluator._model == "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
assert evaluator._batch_size == 32
assert evaluator._device is None
assert evaluator._token.resolve_value() == "fake-token"
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator(device=ComponentDevice.from_str("cuda:0"))
expected_dict = {
"type": "haystack.components.evaluators.sas_evaluator.SASEvaluator",
"init_parameters": {
"model": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"batch_size": 32,
"device": {"type": "single", "device": "cuda:0"},
"token": {"type": "env_var", "env_vars": ["HF_API_TOKEN", "HF_TOKEN"], "strict": False},
},
}
assert evaluator.to_dict() == expected_dict
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator.from_dict(
{
"type": "haystack.components.evaluators.sas_evaluator.SASEvaluator",
"init_parameters": {
"model": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"batch_size": 32,
"device": {"type": "single", "device": "cuda:0"},
"token": {"type": "env_var", "env_vars": ["HF_API_TOKEN", "HF_TOKEN"], "strict": False},
},
}
)
assert evaluator._model == "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
assert evaluator._batch_size == 32
assert evaluator._device.to_torch_str() == "cuda:0"
assert evaluator._token.resolve_value() == "fake-token"
def test_run_with_empty_inputs(self):
evaluator = SASEvaluator()
result = evaluator.run(ground_truth_answers=[], predicted_answers=[])
assert len(result) == 2
assert result["score"] == 0.0
assert result["individual_scores"] == [0.0]
def test_run_with_different_lengths(self):
evaluator = SASEvaluator()
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
def test_run_with_none_in_predictions(self):
evaluator = SASEvaluator()
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
predictions = [
"A construction budget of US $2.3 billion",
None,
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
@pytest.mark.integration
@pytest.mark.slow
def test_run_with_bi_encoder_model(self, del_hf_env_vars):
evaluator = SASEvaluator("sentence-transformers-testing/stsb-bert-tiny-safetensors")
ground_truths = [
"US $2.3 billion",
"Paris's cultural magnificence is symbolized by the Eiffel Tower",
"Japan was transformed into a modernized world power after the Meiji Restoration.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
result = evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
assert len(result) == 2
assert result["score"] == pytest.approx(0.912335)
assert result["individual_scores"] == pytest.approx([0.855047, 0.907907, 0.974050], abs=1e-5)
@pytest.mark.integration
@pytest.mark.slow
def test_run_with_cross_encoder_model(self, del_hf_env_vars):
evaluator = SASEvaluator(model="cross-encoder-testing/reranker-bert-tiny-gooaq-bce")
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
result = evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
assert len(result) == 2
assert result["score"] == pytest.approx(0.938108, abs=1e-5)
assert result["individual_scores"] == pytest.approx([0.930112, 0.9431504, 0.9410622], abs=1e-5)
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,694 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import os
from unittest.mock import AsyncMock, Mock, patch
import pytest
from haystack import Document, Pipeline
from haystack.components.converters.image.document_to_image import DocumentToImageContent
from haystack.components.extractors.image import LLMDocumentContentExtractor
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.writers import DocumentWriter
from haystack.core.serialization import component_to_dict
from haystack.dataclasses.chat_message import ChatMessage, ImageContent
class TestLLMDocumentContentExtractor:
def test_init(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMDocumentContentExtractor(
chat_generator=chat_generator,
prompt="Extract content from this image",
file_path_meta_field="file_path",
root_path="/test/path",
detail="high",
size=(800, 600),
raise_on_failure=True,
max_workers=5,
)
assert isinstance(extractor._chat_generator, OpenAIChatGenerator)
# Not testing specific model name, just that it's set
assert extractor._chat_generator.model is not None
assert extractor._chat_generator.generation_kwargs == {"temperature": 0.5}
assert extractor.prompt == "Extract content from this image"
assert extractor.file_path_meta_field == "file_path"
assert extractor.root_path == "/test/path"
assert extractor.detail == "high"
assert extractor.size == (800, 600)
assert extractor.raise_on_failure is True
assert extractor.max_workers == 5
def test_init_with_defaults(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
assert extractor.prompt.startswith("\nYou are part of an information extraction pipeline")
assert extractor.file_path_meta_field == "file_path"
assert extractor.root_path == ""
assert extractor.detail is None
assert extractor.size is None
assert extractor.raise_on_failure is False
assert extractor.max_workers == 3
def test_init_with_variables_in_prompt(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError, match="The prompt must not have any variables"):
LLMDocumentContentExtractor(
chat_generator=chat_generator, prompt="Extract content from {{document.content}}"
)
def test_init_fails_without_chat_generator(self):
with pytest.raises(TypeError):
LLMDocumentContentExtractor()
def test_to_dict_openai(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMDocumentContentExtractor(
chat_generator=chat_generator,
prompt="Custom extraction prompt",
file_path_meta_field="custom_path",
root_path="/custom/root",
detail="low",
size=(1024, 768),
raise_on_failure=True,
max_workers=4,
)
extractor_dict = extractor.to_dict()
assert extractor_dict == {
"type": "haystack.components.extractors.image.llm_document_content_extractor.LLMDocumentContentExtractor",
"init_parameters": {
"chat_generator": component_to_dict(chat_generator, "chat_generator"),
"prompt": "Custom extraction prompt",
"file_path_meta_field": "custom_path",
"root_path": "/custom/root",
"detail": "low",
"size": (1024, 768),
"raise_on_failure": True,
"max_workers": 4,
},
}
def test_from_dict_openai(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor_dict = {
"type": "haystack.components.extractors.image.llm_document_content_extractor.LLMDocumentContentExtractor",
"init_parameters": {
"chat_generator": component_to_dict(chat_generator, "chat_generator"),
"prompt": "Custom extraction prompt",
"file_path_meta_field": "custom_path",
"root_path": "/custom/root",
"detail": "low",
"size": (1024, 768),
"raise_on_failure": True,
"max_workers": 4,
},
}
extractor = LLMDocumentContentExtractor.from_dict(extractor_dict)
assert extractor.prompt == "Custom extraction prompt"
assert extractor.file_path_meta_field == "custom_path"
assert extractor.root_path == "/custom/root"
assert extractor.detail == "low"
assert extractor.size == (1024, 768)
assert extractor.raise_on_failure is True
assert extractor.max_workers == 4
assert component_to_dict(extractor._chat_generator, "name") == component_to_dict(chat_generator, "name")
def test_run_no_documents(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
result = extractor.run(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
@patch.object(DocumentToImageContent, "run")
def test_run_with_failed_image_conversion(self, mock_doc_to_image_run, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
# Mock DocumentToImageContent to return None (failed conversion)
mock_doc_to_image_run.return_value = {"image_contents": [None]}
doc = Document(content="", meta={"file_path": "/path/to/image.pdf"})
docs = [doc]
result = extractor.run(documents=docs)
# Document should be in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 1
failed_doc = result["failed_documents"][0]
assert failed_doc.id == doc.id
assert "extraction_error" in failed_doc.meta
assert failed_doc.meta["extraction_error"] == "Document has no content, skipping LLM call."
# Ensure no attempt was made to call the LLM
mock_chat_generator.run.assert_not_called()
@patch.object(DocumentToImageContent, "run")
def test_run_with_llm_success(self, mock_doc_to_image_run):
# Mock successful LLM response (JSON with document_content)
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted content from the image"}')]
}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 0
processed_doc = result["documents"][0]
assert processed_doc.id == docs[0].id
assert processed_doc.content == "Extracted content from the image"
assert "extraction_error" not in processed_doc.meta
mock_chat_generator.run.assert_called_once()
@patch.object(DocumentToImageContent, "run")
def test_run_plain_string_response_goes_to_content(self, mock_doc_to_image_run):
"""When LLM returns plain string (non-JSON), it is written to document content."""
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant(text="Plain text, not JSON")]}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 0
assert result["documents"][0].content == "Plain text, not JSON"
@patch.object(DocumentToImageContent, "run")
def test_run_valid_json_not_object_reports_error(self, mock_doc_to_image_run):
"""When LLM returns valid JSON that is not an object (e.g. array or primitive), report error."""
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant(text='["array", "not", "object"]')]
}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 1
assert "extraction_error" in result["failed_documents"][0].meta
assert "JSON object" in result["failed_documents"][0].meta["extraction_error"]
@patch.object(DocumentToImageContent, "run")
def test_run_with_content_and_metadata_extraction(self, mock_doc_to_image_run):
"""When content mode gets JSON with document_content and other keys, other keys are merged into metadata."""
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {
"replies": [
ChatMessage.from_assistant(text='{"document_content": "Main text", "title": "Doc Title", "page": "1"}')
]
}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
assert len(result["documents"]) == 1
processed = result["documents"][0]
assert processed.content == "Main text"
assert processed.meta["title"] == "Doc Title"
assert processed.meta["page"] == "1"
@patch.object(DocumentToImageContent, "run")
def test_run_with_llm_failure_raise_on_failure_false(self, mock_doc_to_image_run, caplog):
# Mock LLM failure
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.side_effect = Exception("LLM API error")
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
# Mock DocumentToImageContent to return valid image content
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
# Document should be in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 1
failed_doc = result["failed_documents"][0]
assert failed_doc.id == docs[0].id
assert "extraction_error" in failed_doc.meta
assert "LLM failed with exception: LLM API error" in failed_doc.meta["extraction_error"]
# Check that error was logged
assert "LLM" in caplog.text
assert "execution failed" in caplog.text
@patch.object(DocumentToImageContent, "run")
def test_run_with_llm_failure_raise_on_failure_true(self, mock_doc_to_image_run):
# Mock LLM failure
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.side_effect = Exception("LLM API error")
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=True)
# Mock DocumentToImageContent to return valid image content
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
with pytest.raises(Exception, match="LLM API error"):
extractor.run(documents=[Document(content="", meta={"file_path": "/path/to/image.pdf"})])
@patch.object(DocumentToImageContent, "run")
def test_run_removes_extraction_error_from_previous_runs(self, mock_doc_to_image_run):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant(text='{"document_content": "Successfully extracted content"}')]
}
# Mock DocumentToImageContent to return valid image content
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
# Document with previous extraction error
docs = [
Document(
content="",
meta={
"file_path": "/path/to/image.pdf",
"extraction_error": "Previous error",
"other_meta": "should_remain",
},
)
]
result = extractor.run(documents=docs)
# Document should be successfully processed
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 0
processed_doc = result["documents"][0]
assert processed_doc.content == "Successfully extracted content"
assert "extraction_error" not in processed_doc.meta
assert processed_doc.meta["other_meta"] == "should_remain"
@patch.object(DocumentToImageContent, "run")
def test_run_with_mixed_success_and_failure(self, mock_doc_to_image_run):
# Mock successful LLM response for first call, failure for second
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.side_effect = [
{"replies": [ChatMessage.from_assistant(text='{"document_content": "Successfully extracted content"}')]},
Exception("LLM API error"),
]
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
# Mock DocumentToImageContent - first succeeds, second fails
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg"), None]
}
doc1 = Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})
doc2 = Document(content="", meta={"file_path": "/path/to/image.jpg"})
docs = [doc1, doc2]
result = extractor.run(documents=docs)
# One document should succeed, one should fail
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 1
successful_doc = result["documents"][0]
assert successful_doc.id == doc1.id
assert successful_doc.content == "Successfully extracted content"
failed_doc = result["failed_documents"][0]
assert failed_doc.id == doc2.id
assert "extraction_error" in failed_doc.meta
@patch.object(DocumentToImageContent, "run")
def test_run_json_multiple_keys_metadata_merged(self, mock_doc_to_image_run):
"""When LLM returns JSON with multiple keys and no document_content, all keys are merged into metadata."""
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run.return_value = {
"replies": [
ChatMessage.from_assistant(text='{"title": "Sample Doc", "author": "Test", "document_type": "report"}')
]
}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
original_content = "Original content"
docs = [Document(content=original_content, meta={"file_path": "/path/to/image.pdf"})]
result = extractor.run(documents=docs)
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 0
processed = result["documents"][0]
assert processed.content == original_content
assert processed.meta["title"] == "Sample Doc"
assert processed.meta["author"] == "Test"
assert processed.meta["document_type"] == "report"
def test_run_on_thread_with_none_prompt(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator())
result = extractor._run_on_thread(None)
assert "error" in result
assert result["error"] == "Document has no content, skipping LLM call."
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_live_run(self, in_memory_doc_store):
docs = [Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})]
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("extractor", extractor)
pipeline.add_component("doc_writer", writer)
pipeline.connect("extractor.documents", "doc_writer.documents")
pipeline.run(data={"documents": docs})
doc_store_docs = in_memory_doc_store.filter_documents()
assert len(doc_store_docs) >= 1
assert len(doc_store_docs[0].content) > 0
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_live_run_on_image_with_metadata(self, in_memory_doc_store):
"""
Live test using image_metadata.png: single prompt; LLM can return JSON with document_content
and metadata keys (author, date, document_type, topic) in one response.
"""
prompt = """
You are part of an information extraction pipeline that extracts the content of image-based documents.
Extract the content from the provided image.
You need to extract the content exactly.
Format everything as markdown.
Make sure to retain the reading order of the document.
**Visual Elements**
Do not extract figures, drawings, maps, graphs or any other visual elements.
Instead, add a caption that describes briefly what you see in the visual element.
You must describe each visual element.
If you only see a visual element without other content, you must describe this visual element.
Enclose each image caption with [img-caption][/img-caption]
**Tables**
Make sure to format the table in markdown.
Add a short caption below the table that describes the table's content.
Enclose each table caption with [table-caption][/table-caption].
The caption must be placed below the extracted table.
**Forms**
Reproduce checkbox selections with markdown.
Return a single JSON object. It must contain the key "document_content" with the extracted text as value.
Include all other extracted information as keys for metadata. All metadata should be returned as separate keys
in the JSON object. For example, if you extract the document type and date, you should return:
{"title": "Example Document", "author": "John Doe", "date": "2024-01-15", "document_type": "invoice"}
Don't include any metadata in the "document_content" field. The "document_content" field should only contain the
image description and any possible text extracted from the image.
No markdown, no code fence, only raw JSON.
Document:"""
image_path = "./test/test_files/images/image_metadata.png"
docs = [Document(content="", meta={"file_path": image_path})]
extractor = LLMDocumentContentExtractor(
prompt=prompt,
chat_generator=OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"schema": {
"type": "object",
"properties": {
"author": {"type": "string"},
"date": {"type": "string"},
"document_type": {"type": "string"},
"title": {"type": "string"},
},
"additionalProperties": False,
},
},
}
},
),
)
writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("extractor", extractor)
pipeline.add_component("doc_writer", writer)
pipeline.connect("extractor.documents", "doc_writer.documents")
pipeline.run(data={"documents": docs})
doc_store_docs = in_memory_doc_store.filter_documents()
assert len(doc_store_docs) >= 1
doc = doc_store_docs[0]
assert len(doc.content) > 0, "Expected non-empty content (image/document description)"
assert "extraction_error" not in doc.meta
assert "author" in doc.meta, "Expected 'author' key in metadata"
assert "date" in doc.meta, "Expected 'date' key in metadata"
assert "document_type" in doc.meta, "Expected 'document_type' key in metadata"
assert "title" in doc.meta, "Expected 'title' key in metadata"
class TestLLMDocumentContentExtractorAsync:
@pytest.mark.asyncio
async def test_run_async_no_documents(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
result = await extractor.run_async(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
@pytest.mark.asyncio
@patch.object(DocumentToImageContent, "run")
async def test_run_async_success(self, mock_doc_to_image_run):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(
return_value={
"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted content from the image"}')]
}
)
mock_doc_to_image_run.return_value = {
"image_contents": [
ImageContent.from_file_path("./test/test_files/images/apple.jpg"),
ImageContent.from_file_path("./test/test_files/images/apple.jpg"),
]
}
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
docs = [
Document(content="", meta={"file_path": "/path/to/image1.pdf"}),
Document(content="", meta={"file_path": "/path/to/image2.pdf"}),
]
result = await extractor.run_async(documents=docs)
assert len(result["documents"]) == 2
assert len(result["failed_documents"]) == 0
for processed_doc in result["documents"]:
assert processed_doc.content == "Extracted content from the image"
assert "extraction_error" not in processed_doc.meta
# one async LLM call per document
assert mock_chat_generator.run_async.await_count == 2
@pytest.mark.asyncio
@patch.object(DocumentToImageContent, "run")
async def test_run_async_with_llm_failure_raise_on_failure_false(self, mock_doc_to_image_run):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(side_effect=Exception("LLM API error"))
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = await extractor.run_async(documents=docs)
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 1
failed_doc = result["failed_documents"][0]
assert failed_doc.id == docs[0].id
assert "extraction_error" in failed_doc.meta
assert "LLM failed with exception: LLM API error" in failed_doc.meta["extraction_error"]
@pytest.mark.asyncio
@patch.object(DocumentToImageContent, "run")
async def test_run_async_with_llm_failure_raise_on_failure_true(self, mock_doc_to_image_run):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(side_effect=Exception("LLM API error"))
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=True)
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
with pytest.raises(Exception, match="LLM API error"):
await extractor.run_async(documents=[Document(content="", meta={"file_path": "/path/to/image.pdf"})])
@pytest.mark.asyncio
@patch.object(DocumentToImageContent, "run")
async def test_run_async_falls_back_to_sync_run(self, mock_doc_to_image_run):
"""If the chat generator has no run_async, the sync run is used and output is still correct."""
class SyncOnlyChatGenerator:
def run(self, messages, **kwargs):
return {"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted via sync run"}')]}
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
}
extractor = LLMDocumentContentExtractor(chat_generator=SyncOnlyChatGenerator())
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
result = await extractor.run_async(documents=docs)
assert len(result["documents"]) == 1
assert len(result["failed_documents"]) == 0
assert result["documents"][0].content == "Extracted via sync run"
@pytest.mark.asyncio
@patch.object(DocumentToImageContent, "run")
async def test_run_async_respects_max_workers(self, mock_doc_to_image_run):
max_workers = 2
in_flight = 0
peak_in_flight = 0
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
async def fake_run_async(messages, **kwargs):
nonlocal in_flight, peak_in_flight
in_flight += 1
peak_in_flight = max(peak_in_flight, in_flight)
try:
await asyncio.sleep(0.01)
return {"replies": [ChatMessage.from_assistant(text='{"document_content": "content"}')]}
finally:
in_flight -= 1
mock_chat_generator.run_async = fake_run_async
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, max_workers=max_workers)
mock_doc_to_image_run.return_value = {
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg") for _ in range(10)]
}
docs = [Document(content="", meta={"file_path": f"/path/to/image{i}.pdf"}) for i in range(10)]
result = await extractor.run_async(documents=docs)
assert len(result["documents"]) == 10
assert peak_in_flight <= max_workers
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.asyncio
async def test_live_run_async(self):
docs = [Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})]
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = await extractor.run_async(documents=docs)
assert len(result["failed_documents"]) == 0
assert len(result["documents"]) == 1
assert len(result["documents"][0].content) > 0
class TestComponentLifecycle:
def test_warm_up_delegates_to_chat_generator(self):
mock_chat_generator = Mock(spec=["run", "warm_up"])
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
extractor.warm_up()
mock_chat_generator.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_chat_generator(self):
mock_chat_generator = Mock(spec=["run", "warm_up_async"])
mock_chat_generator.warm_up_async = AsyncMock()
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
await extractor.warm_up_async()
mock_chat_generator.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
mock_chat_generator = Mock(spec=["run", "warm_up"])
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
await extractor.warm_up_async()
mock_chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
extractor.close()
mock_chat_generator.close.assert_called_once()
async def test_close_async_delegates_to_chat_generator(self):
mock_chat_generator = Mock(spec=["run", "close_async"])
mock_chat_generator.close_async = AsyncMock()
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
await extractor.close_async()
mock_chat_generator.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
await extractor.close_async()
mock_chat_generator.close.assert_called_once()
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
mock_chat_generator = Mock(spec=["run"])
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
extractor.warm_up()
await extractor.warm_up_async()
extractor.close()
await extractor.close_async()
@@ -0,0 +1,571 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import os
from unittest.mock import AsyncMock, Mock
import pytest
from haystack import Document, Pipeline
from haystack.components.extractors import LLMMetadataExtractor
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.writers import DocumentWriter
from haystack.dataclasses import ChatMessage
from haystack.document_stores.in_memory import InMemoryDocumentStore
@pytest.fixture
def ner_prompt() -> str:
return """-Goal-
Given text and a list of entity types, identify all entities of those types from the text.
-Steps-
1. Identify all entities. For each identified entity, extract the following information:
- entity_name: Name of the entity, capitalized
- entity_type: One of the following types: [organization, product, service, industry]
Format each entity as {"entity": <entity_name>, "entity_type": <entity_type>}
2. Return output in a single list with all the entities identified in steps 1.
-Examples-
######################
Example 1:
entity_types: [organization, person, partnership, financial metric, product, service, industry, investment strategy, market trend]
text: Another area of strength is our co-brand issuance. Visa is the primary network partner for eight of the top
10 co-brand partnerships in the US today and we are pleased that Visa has finalized a multi-year extension of
our successful credit co-branded partnership with Alaska Airlines, a portfolio that benefits from a loyal customer
base and high cross-border usage.
We have also had significant co-brand momentum in CEMEA. First, we launched a new co-brand card in partnership
with Qatar Airways, British Airways and the National Bank of Kuwait. Second, we expanded our strong global
Marriott relationship to launch Qatar's first hospitality co-branded card with Qatar Islamic Bank. Across the
United Arab Emirates, we now have exclusive agreements with all the leading airlines marked by a recent
agreement with Emirates Skywards.
And we also signed an inaugural Airline co-brand agreement in Morocco with Royal Air Maroc. Now newer digital
issuers are equally
------------------------
output:
{"entities": [{"entity": "Visa", "entity_type": "company"}, {"entity": "Alaska Airlines", "entity_type": "company"}, {"entity": "Qatar Airways", "entity_type": "company"}, {"entity": "British Airways", "entity_type": "company"}, {"entity": "National Bank of Kuwait", "entity_type": "company"}, {"entity": "Marriott", "entity_type": "company"}, {"entity": "Qatar Islamic Bank", "entity_type": "company"}, {"entity": "Emirates Skywards", "entity_type": "company"}, {"entity": "Royal Air Maroc", "entity_type": "company"}]}
#############################
-Real Data-
######################
entity_types: [company, organization, person, country, product, service]
text: {{ document.content }}
######################
output:
""" # noqa: E501
class TestLLMMetadataExtractor:
def test_init(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", expected_keys=["key1", "key2"], chat_generator=chat_generator
)
assert isinstance(extractor._chat_generator, OpenAIChatGenerator)
# Not testing specific model name, just that it's set (truthy)
assert extractor._chat_generator.model
assert extractor._chat_generator.generation_kwargs == {"temperature": 0.5}
assert extractor.expected_keys == ["key1", "key2"]
def test_init_missing_prompt_variable(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError):
_ = LLMMetadataExtractor(
prompt="prompt {{ wrong_variable }}", expected_keys=["key1", "key2"], chat_generator=chat_generator
)
def test_init_no_prompt_variable(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError, match="exactly one variable called 'document'.*no variables"):
_ = LLMMetadataExtractor(prompt="prompt without variables", chat_generator=chat_generator)
def test_init_too_many_prompt_variables(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError, match="exactly one variable called 'document'"):
_ = LLMMetadataExtractor(prompt="prompt {{ document.content }} {{ extra }}", chat_generator=chat_generator)
def test_init_fails_without_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
with pytest.raises(TypeError):
_ = LLMMetadataExtractor(prompt="prompt {{document.content}}", expected_keys=["key1", "key2"])
def test_to_dict_openai(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMMetadataExtractor(
prompt="some prompt that was used with the LLM {{document.content}}",
expected_keys=["key1", "key2"],
chat_generator=chat_generator,
raise_on_failure=True,
)
extractor_dict = extractor.to_dict()
assert extractor_dict == {
"type": "haystack.components.extractors.llm_metadata_extractor.LLMMetadataExtractor",
"init_parameters": {
"prompt": "some prompt that was used with the LLM {{document.content}}",
"expected_keys": ["key1", "key2"],
"raise_on_failure": True,
"chat_generator": chat_generator.to_dict(),
"page_range": None,
"max_workers": 3,
},
}
def test_from_dict_openai(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor_dict = {
"type": "haystack.components.extractors.llm_metadata_extractor.LLMMetadataExtractor",
"init_parameters": {
"prompt": "some prompt that was used with the LLM {{document.content}}",
"expected_keys": ["key1", "key2"],
"chat_generator": chat_generator.to_dict(),
"raise_on_failure": True,
},
}
extractor = LLMMetadataExtractor.from_dict(extractor_dict)
assert extractor.raise_on_failure is True
assert extractor.expected_keys == ["key1", "key2"]
assert extractor.prompt == "some prompt that was used with the LLM {{document.content}}"
assert extractor._chat_generator.to_dict() == chat_generator.to_dict()
def test_extract_metadata(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = extractor._extract_metadata(llm_answer='{"output": "valid json"}')
assert result == {"output": "valid json"}
def test_extract_metadata_invalid_json(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator(), raise_on_failure=True
)
with pytest.raises(ValueError):
extractor._extract_metadata(llm_answer='{"output: "valid json"}')
def test_extract_metadata_missing_key(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator(), expected_keys=["key1"]
)
extractor._extract_metadata(llm_answer='{"output": "valid json"}')
assert "Response from the LLM is not valid JSON or missing expected keys" in caplog.text
def test_prepare_prompts(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}", chat_generator=OpenAIChatGenerator()
)
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
prompts = extractor._prepare_prompts(docs)
assert prompts == [
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt deepset was founded in 2018 in Berlin, and is known for "
"its Haystack framework"
}
],
}
),
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, France and "
"is known for its Transformers library"
}
],
}
),
]
def test_prepare_prompts_empty_document(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}", chat_generator=OpenAIChatGenerator()
)
docs = [
Document(content=""),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
prompts = extractor._prepare_prompts(docs)
assert prompts == [
None,
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, "
"France and is known for its Transformers library"
}
],
}
),
]
def test_prepare_prompts_expanded_range(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}",
chat_generator=OpenAIChatGenerator(),
page_range=["1-2"],
)
docs = [
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers "
"library\fPage 2\fPage 3"
)
]
prompts = extractor._prepare_prompts(docs, expanded_range=[1, 2])
assert prompts == [
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, France and "
"is known for its Transformers library\x0cPage 2\x0c"
}
],
}
)
]
def test_run_no_documents(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = extractor.run(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
@pytest.mark.asyncio
async def test_run_no_documents_async(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = await extractor.run_async(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
def test_run_with_document_content_none(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Mock the chat generator to prevent actual LLM calls
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=mock_chat_generator, expected_keys=["some_key"]
)
# Document with None content
doc_with_none_content = Document(content=None)
# also test with empty string content
doc_with_empty_content = Document(content="")
docs = [doc_with_none_content, doc_with_empty_content]
result = extractor.run(documents=docs)
# Assert that the documents are in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 2
failed_doc_none = result["failed_documents"][0]
assert failed_doc_none.id == doc_with_none_content.id
assert "metadata_extraction_error" in failed_doc_none.meta
assert failed_doc_none.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_none_content.meta
failed_doc_empty = result["failed_documents"][1]
assert failed_doc_empty.id == doc_with_empty_content.id
assert "metadata_extraction_error" in failed_doc_empty.meta
assert failed_doc_empty.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_empty_content.meta
# Ensure no attempt was made to call the LLM
mock_chat_generator.run.assert_not_called()
@pytest.mark.asyncio
async def test_run_with_document_content_none_async(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Mock the chat generator to prevent actual LLM calls
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=mock_chat_generator, expected_keys=["some_key"]
)
# Document with None content
doc_with_none_content = Document(content=None)
# also test with empty string content
doc_with_empty_content = Document(content="")
docs = [doc_with_none_content, doc_with_empty_content]
result = await extractor.run_async(documents=docs)
# Assert that the documents are in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 2
failed_doc_none = result["failed_documents"][0]
assert failed_doc_none.id == doc_with_none_content.id
assert "metadata_extraction_error" in failed_doc_none.meta
assert failed_doc_none.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_none_content.meta
failed_doc_empty = result["failed_documents"][1]
assert failed_doc_empty.id == doc_with_empty_content.id
assert "metadata_extraction_error" in failed_doc_empty.meta
assert failed_doc_empty.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_empty_content.meta
# Ensure no attempt was made to call the LLM
mock_chat_generator.run_async.assert_not_called()
@pytest.mark.asyncio
async def test_run_async_respects_max_workers(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
max_workers = 2
in_flight = 0
peak_in_flight = 0
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
async def fake_run_async(messages, **kwargs):
nonlocal in_flight, peak_in_flight
in_flight += 1
peak_in_flight = max(peak_in_flight, in_flight)
try:
await asyncio.sleep(0.01)
return {"replies": [ChatMessage.from_assistant('{"entities": []}')]}
finally:
in_flight -= 1
mock_chat_generator.run_async = fake_run_async
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}",
chat_generator=mock_chat_generator,
expected_keys=["entities"],
max_workers=max_workers,
)
docs = [Document(content=f"doc {i}") for i in range(10)]
result = await extractor.run_async(documents=docs)
assert len(result["documents"]) == 10
assert peak_in_flight <= max_workers
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_live_run(self, in_memory_doc_store: InMemoryDocumentStore, ner_prompt: str) -> None:
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
extractor = LLMMetadataExtractor(
prompt=ner_prompt,
expected_keys=["entities"],
chat_generator=OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entity": {"type": "string"},
"entity_type": {"type": "string"},
},
"required": ["entity", "entity_type"],
"additionalProperties": False,
},
}
},
"required": ["entities"],
"additionalProperties": False,
},
},
}
},
),
)
writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("extractor", extractor)
pipeline.add_component("doc_writer", writer)
pipeline.connect("extractor.documents", "doc_writer.documents")
pipeline.run(data={"documents": docs})
doc_store_docs = in_memory_doc_store.filter_documents()
assert len(doc_store_docs) == 2
assert "entities" in doc_store_docs[0].meta
assert "entities" in doc_store_docs[1].meta
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
async def test_live_run_async(self, in_memory_doc_store: InMemoryDocumentStore, ner_prompt: str) -> None:
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
extractor = LLMMetadataExtractor(
prompt=ner_prompt,
expected_keys=["entities"],
chat_generator=OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entity": {"type": "string"},
"entity_type": {"type": "string"},
},
"required": ["entity", "entity_type"],
"additionalProperties": False,
},
}
},
"required": ["entities"],
"additionalProperties": False,
},
},
}
},
),
)
writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("extractor", extractor)
pipeline.add_component("doc_writer", writer)
pipeline.connect("extractor.documents", "doc_writer.documents")
await pipeline.run_async(data={"documents": docs})
doc_store_docs = await in_memory_doc_store.filter_documents_async()
assert len(doc_store_docs) == 2
assert "entities" in doc_store_docs[0].meta
assert "entities" in doc_store_docs[1].meta
class TestComponentLifecycle:
def test_warm_up_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "warm_up"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up"])
extractor.warm_up()
mock_chat_generator.warm_up.assert_called_once()
extractor.splitter.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "warm_up", "warm_up_async"])
mock_chat_generator.warm_up_async = AsyncMock()
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up_async"])
extractor.splitter.warm_up_async = AsyncMock()
await extractor.warm_up_async()
mock_chat_generator.warm_up_async.assert_awaited_once()
extractor.splitter.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
mock_chat_generator = Mock(spec=["run", "warm_up"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up"])
await extractor.warm_up_async()
mock_chat_generator.warm_up.assert_called_once()
extractor.splitter.warm_up.assert_called_once()
def test_close_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close"])
extractor.close()
mock_chat_generator.close.assert_called_once()
extractor.splitter.close.assert_called_once()
async def test_close_async_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "close_async"])
mock_chat_generator.close_async = AsyncMock()
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close_async"])
extractor.splitter.close_async = AsyncMock()
await extractor.close_async()
mock_chat_generator.close_async.assert_awaited_once()
extractor.splitter.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close"])
await extractor.close_async()
mock_chat_generator.close.assert_called_once()
extractor.splitter.close.assert_called_once()
async def test_lifecycle_is_safe_when_inner_lacks_methods(self):
mock_chat_generator = Mock(spec=["run"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run"])
extractor.warm_up()
await extractor.warm_up_async()
extractor.close()
await extractor.close_async()
@@ -0,0 +1,227 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack import Pipeline
from haystack.components.extractors.regex_text_extractor import RegexTextExtractor
from haystack.dataclasses import ChatMessage
class TestRegexTextExtractor:
def test_init_with_capture_group(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
assert extractor.regex_pattern == pattern
def test_init_without_capture_group(self):
pattern = r"<issue>"
extractor = RegexTextExtractor(regex_pattern=pattern)
assert extractor.regex_pattern == pattern
def test_to_dict(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
data = extractor.to_dict()
assert data == {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": pattern},
}
def test_from_dict(self):
data = {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": r'<issue url="(.+?)">'},
}
extractor = RegexTextExtractor.from_dict(data=data)
assert extractor.regex_pattern == r'<issue url="(.+?)">'
def test_from_dict_with_removed_parameter(self, caplog):
caplog.set_level(logging.WARNING)
data = {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": r'<issue url="(.+?)">', "return_empty_on_no_match": False},
}
extractor = RegexTextExtractor.from_dict(data=data)
assert extractor.regex_pattern == r'<issue url="(.+?)">'
assert "The `return_empty_on_no_match` init parameter has been removed" in caplog.text
def test_extract_from_string_with_capture_group(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = '<issue url="github.com/hahahaha">hahahah</issue>'
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "github.com/hahahaha"}
def test_extract_from_string_without_capture_group(self):
pattern = r"<issue>"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This is an <issue> tag in the text"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "<issue>"}
def test_extract_from_string_no_match(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This text has no matching pattern"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": ""}
def test_extract_from_string_empty_input(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = ""
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_single_message(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [ChatMessage.from_user('<issue url="github.com/test">test issue</issue>')]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": "github.com/test"}
def test_extract_from_chat_messages_multiple_messages(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [
ChatMessage.from_user('First message with <issue url="first.com">first</issue>'),
ChatMessage.from_user('Second message with <issue url="second.com">second</issue>'),
ChatMessage.from_user('Last message with <issue url="last.com">last</issue>'),
]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": "last.com"}
def test_extract_from_chat_messages_no_match_in_last(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [
ChatMessage.from_user('First message with <issue url="first.com">first</issue>'),
ChatMessage.from_user("Last message with no matching pattern"),
]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_empty_list(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = []
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_invalid_type(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = ["not a ChatMessage object"]
with pytest.raises(TypeError, match="Expected ChatMessage object, got <class 'str'>"):
extractor.run(text_or_messages=messages)
def test_extract_from_chat_messages_last_message_no_text(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [ChatMessage.from_assistant(text=None)]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_multiple_capture_groups(self):
pattern = r"(\w+)@(\w+)\.(\w+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "Contact us at user@example.com for support"
result = extractor.run(text_or_messages=text)
# return the first capture group (username)
assert result == {"captured_text": "user"}
def test_special_characters_in_pattern(self):
"""Test regex pattern with special characters."""
pattern = r"\[(\w+)\]"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This has [special] characters [in] brackets"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "special"}
def test_whitespace_handling(self):
"""Test regex pattern with whitespace handling."""
pattern = r"\s+(\w+)\s+"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "word1 word2 word3"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "word2"}
def test_nested_capture_groups(self):
"""Test regex with nested capture groups."""
pattern = r'<(\w+)\s+attr="([^"]+)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = '<div attr="value">content</div>'
result = extractor.run(text_or_messages=text)
# Should return the first capture group (tag name)
assert result == {"captured_text": "div"}
def test_optional_capture_group(self):
"""Test regex with optional capture group."""
pattern = r"(\w+)(?:@(\w+))?"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "username@domain"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "username"}
def test_optional_capture_group_no_match(self):
"""Test regex with optional capture group when optional part is missing."""
pattern = r"(\w+)(?:@(\w+))?"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "username"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "username"}
def test_pipeline_integration(self):
"""Test component integration in a Haystack pipeline."""
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
pipe = Pipeline()
pipe.add_component("extractor", extractor)
text = '<issue url="github.com/pipeline-test">pipeline test</issue>'
result = pipe.run(data={"extractor": {"text_or_messages": text}})
assert result["extractor"] == {"captured_text": "github.com/pipeline-test"}
def test_pipeline_integration_with_chat_messages(self):
"""Test component integration in pipeline with ChatMessages."""
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
pipe = Pipeline()
pipe.add_component("extractor", extractor)
messages = [ChatMessage.from_user('<issue url="github.com/chat-test">chat test</issue>')]
result = pipe.run(data={"extractor": {"text_or_messages": messages}})
assert result["extractor"] == {"captured_text": "github.com/chat-test"}
def test_very_long_text(self):
pattern = r"(\d+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
long_text = "a" * 10000 + "123" + "b" * 10000
result = extractor.run(text_or_messages=long_text)
assert result == {"captured_text": "123"}
def test_multiple_matches_first_is_captured(self):
pattern = r"(\d+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "First: 123, Second: 456, Third: 789"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "123"}
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,535 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import AsyncMock, Mock, patch
import httpx
import pytest
from haystack.components.fetchers.link_content import (
DEFAULT_USER_AGENT,
LinkContentFetcher,
_binary_content_handler,
_text_content_handler,
)
HTML_URL = "https://docs.haystack.deepset.ai/docs/intro"
TEXT_URL = "https://raw.githubusercontent.com/deepset-ai/haystack/main/README.md"
PDF_URL = "https://raw.githubusercontent.com/deepset-ai/haystack/b5987a6d8d0714eb2f3011183ab40093d2e4a41a/e2e/samples/pipelines/sample_pdf_1.pdf"
@pytest.fixture
def mock_get_link_text_content():
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_response = Mock(status_code=200, text="Example test response", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
yield mock_get
@pytest.fixture
def mock_get_link_content(test_files_path):
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
with open(test_files_path / "pdf" / "sample_pdf_1.pdf", "rb") as f1:
file_bytes = f1.read()
mock_response = Mock(status_code=200, content=file_bytes, headers={"Content-Type": "application/pdf"})
mock_get.return_value = mock_response
yield mock_get
class TestLinkContentFetcher:
def test_init(self):
"""Test initialization with default parameters"""
fetcher = LinkContentFetcher()
assert fetcher.raise_on_failure is True
assert fetcher.user_agents == [DEFAULT_USER_AGENT]
assert fetcher.retry_attempts == 2
assert fetcher.timeout == 3
assert fetcher.http2 is False
assert isinstance(fetcher.client_kwargs, dict)
assert fetcher.handlers == {
"text/*": _text_content_handler,
"text/html": _binary_content_handler,
"application/json": _text_content_handler,
"application/*": _binary_content_handler,
"image/*": _binary_content_handler,
"audio/*": _binary_content_handler,
"video/*": _binary_content_handler,
}
assert hasattr(fetcher, "_get_response")
assert fetcher._client is None
assert fetcher._async_client is None
def test_init_with_params(self):
"""Test initialization with custom parameters"""
fetcher = LinkContentFetcher(
raise_on_failure=False,
user_agents=["test"],
retry_attempts=1,
timeout=2,
http2=True,
client_kwargs={"verify": False},
)
assert fetcher.raise_on_failure is False
assert fetcher.user_agents == ["test"]
assert fetcher.retry_attempts == 1
assert fetcher.timeout == 2
assert fetcher.http2 is True
assert "verify" in fetcher.client_kwargs
assert fetcher.client_kwargs["verify"] is False
def test_run_text(self):
"""Test fetching text content"""
correct_response = b"Example test response"
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_response = Mock(status_code=200, text="Example test response", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
streams = fetcher.run(urls=["https://www.example.com"])["streams"]
first_stream = streams[0]
assert first_stream.data == correct_response
assert first_stream.meta["content_type"] == "text/plain"
assert first_stream.mime_type == "text/plain"
def test_run_html(self):
"""Test fetching HTML content"""
correct_response = b"<h1>Example test response</h1>"
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_response = Mock(
status_code=200, content=b"<h1>Example test response</h1>", headers={"Content-Type": "text/html"}
)
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
streams = fetcher.run(urls=["https://www.example.com"])["streams"]
first_stream = streams[0]
assert first_stream.data == correct_response
assert first_stream.meta["content_type"] == "text/html"
assert first_stream.mime_type == "text/html"
def test_run_binary(self, test_files_path):
"""Test fetching binary content"""
with open(test_files_path / "pdf" / "sample_pdf_1.pdf", "rb") as f1:
file_bytes = f1.read()
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_response = Mock(status_code=200, content=file_bytes, headers={"Content-Type": "application/pdf"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
streams = fetcher.run(urls=["https://www.example.com"])["streams"]
first_stream = streams[0]
assert first_stream.data == file_bytes
assert first_stream.meta["content_type"] == "application/pdf"
assert first_stream.mime_type == "application/pdf"
def test_run_bad_request_no_exception(self):
"""Test behavior when a request results in an error status code"""
empty_byte_stream = b""
fetcher = LinkContentFetcher(raise_on_failure=False, retry_attempts=0)
mock_response = Mock(status_code=403)
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"403 Client Error", request=Mock(), response=mock_response
)
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_get.return_value = mock_response
streams = fetcher.run(urls=["https://www.example.com"])["streams"]
# empty byte stream is returned because raise_on_failure is False
assert len(streams) == 1
first_stream = streams[0]
assert first_stream.data == empty_byte_stream
assert first_stream.meta["content_type"] == "text/html"
assert first_stream.mime_type == "text/html"
def test_bad_request_exception_raised(self):
"""
This test is to ensure that the fetcher raises an exception when a single bad request is made and it is
configured to do so.
"""
fetcher = LinkContentFetcher(raise_on_failure=True, retry_attempts=0)
mock_response = Mock(status_code=403)
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"403 Client Error", request=Mock(), response=mock_response
)
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_get.return_value = mock_response
with pytest.raises(httpx.HTTPStatusError):
fetcher.run(["https://non_existent_website_dot.com/"])
def test_request_headers_merging_and_ua_override(self):
# Patch the Client class to control the instance created by LinkContentFetcher
with patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock:
client = ClientMock.return_value
client.headers = {} # base headers used in the merge
mock_response = Mock(status_code=200, text="OK", headers={"Content-Type": "text/plain"})
client.get.return_value = mock_response
fetcher = LinkContentFetcher(
user_agents=["ua-sync-1", "ua-sync-2"],
request_headers={
"Accept-Language": "fr-FR",
"X-Test": "1",
"User-Agent": "will-be-overridden", # rotating UA must override this
},
)
_ = fetcher.run(urls=["https://example.com"])["streams"]
client.get.assert_called_once()
sent_headers = client.get.call_args.kwargs["headers"]
assert sent_headers["X-Test"] == "1"
assert sent_headers["Accept-Language"] == "fr-FR"
assert sent_headers["User-Agent"] == "ua-sync-1" # rotating UA wins
class TestComponentLifecycle:
def test_clients_are_none_after_init(self):
fetcher = LinkContentFetcher()
assert fetcher._client is None
assert fetcher._async_client is None
def test_sync_lifecycle(self):
with patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock:
client_instance = ClientMock.return_value
fetcher = LinkContentFetcher()
fetcher.warm_up()
assert fetcher._client is client_instance
assert fetcher._async_client is None
ClientMock.assert_called_once()
fetcher.close()
client_instance.close.assert_called_once()
assert fetcher._client is None
def test_warm_up_is_idempotent(self):
with patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock:
fetcher = LinkContentFetcher()
fetcher.warm_up()
fetcher.warm_up()
ClientMock.assert_called_once()
@pytest.mark.asyncio
async def test_async_lifecycle(self):
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
async_client_instance = AsyncClientMock.return_value
async_client_instance.aclose = AsyncMock()
fetcher = LinkContentFetcher()
await fetcher.warm_up_async()
assert fetcher._async_client is async_client_instance
assert fetcher._client is None
AsyncClientMock.assert_called_once()
await fetcher.close_async()
async_client_instance.aclose.assert_awaited_once()
assert fetcher._async_client is None
@pytest.mark.asyncio
async def test_warm_up_async_is_idempotent(self):
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
fetcher = LinkContentFetcher()
await fetcher.warm_up_async()
await fetcher.warm_up_async()
AsyncClientMock.assert_called_once()
@pytest.mark.asyncio
async def test_close_is_safe_without_warm_up(self):
fetcher = LinkContentFetcher()
fetcher.close()
await fetcher.close_async()
assert fetcher._client is None
assert fetcher._async_client is None
@pytest.mark.asyncio
async def test_close_and_close_async_are_independent(self):
with (
patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock,
patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock,
):
client_instance = ClientMock.return_value
async_client_instance = AsyncClientMock.return_value
async_client_instance.aclose = AsyncMock()
fetcher = LinkContentFetcher()
fetcher.warm_up()
await fetcher.warm_up_async()
fetcher.close()
assert fetcher._client is None
assert fetcher._async_client is async_client_instance
async_client_instance.aclose.assert_not_awaited()
await fetcher.close_async()
assert fetcher._async_client is None
client_instance.close.assert_called_once()
def test_run_self_heals(self):
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
mock_response = Mock(status_code=200, text="ok", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
fetcher.run(urls=["https://www.example.com"])
assert fetcher._client is not None
@pytest.mark.asyncio
async def test_run_async_self_heals(self):
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get:
mock_response = Mock(status_code=200, text="ok", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
await fetcher.run_async(urls=["https://www.example.com"])
assert fetcher._async_client is not None
@pytest.mark.flaky(reruns=3, reruns_delay=5)
@pytest.mark.integration
class TestLinkContentFetcherIntegration:
def test_link_content_fetcher_html(self):
"""
Test fetching HTML content from a real URL.
"""
fetcher = LinkContentFetcher()
streams = fetcher.run([HTML_URL])["streams"]
first_stream = streams[0]
assert "Haystack" in first_stream.data.decode("utf-8")
assert first_stream.meta["content_type"] == "text/html"
assert "url" in first_stream.meta and first_stream.meta["url"] == HTML_URL
assert first_stream.mime_type == "text/html"
def test_link_content_fetcher_text(self):
"""
Test fetching text content from a real URL.
"""
fetcher = LinkContentFetcher()
streams = fetcher.run([TEXT_URL])["streams"]
first_stream = streams[0]
assert "Haystack" in first_stream.data.decode("utf-8")
assert first_stream.meta["content_type"] == "text/plain"
assert "url" in first_stream.meta and first_stream.meta["url"] == TEXT_URL
assert first_stream.mime_type == "text/plain"
def test_link_content_fetcher_multiple_different_content_types(self):
"""
This test is to ensure that the fetcher can handle a list of URLs that contain different content types.
"""
fetcher = LinkContentFetcher()
streams = fetcher.run([PDF_URL, HTML_URL])["streams"]
assert len(streams) == 2
for stream in streams:
assert stream.meta["content_type"] in ("text/html", "application/pdf", "application/octet-stream")
if stream.meta["content_type"] == "text/html":
assert "Haystack" in stream.data.decode("utf-8")
assert stream.mime_type == "text/html"
elif stream.meta["content_type"] == "application/pdf":
assert len(stream.data) > 0
assert stream.mime_type == "application/pdf"
def test_link_content_fetcher_multiple_html_streams(self):
"""
This test is to ensure that the fetcher can handle a list of URLs that contain different content types,
and that we have two html streams.
"""
fetcher = LinkContentFetcher()
streams = fetcher.run([PDF_URL, HTML_URL, "https://google.com"])["streams"]
assert len(streams) == 3
for stream in streams:
assert stream.meta["content_type"] in ("text/html", "application/pdf", "application/octet-stream")
if stream.meta["content_type"] == "text/html":
assert "Haystack" in stream.data.decode("utf-8") or "Google" in stream.data.decode("utf-8")
assert stream.mime_type == "text/html"
elif stream.meta["content_type"] == "application/pdf":
assert len(stream.data) > 0
assert stream.mime_type == "application/pdf"
def test_mix_of_good_and_failed_requests(self):
"""
This test is to ensure that the fetcher can handle a list of URLs that contain URLs that fail to be fetched.
In such a case, the fetcher should return the content of the URLs that were successfully fetched and not raise
an exception.
"""
fetcher = LinkContentFetcher(retry_attempts=0)
result = fetcher.run(["https://non_existent_website_dot.com/", "https://www.google.com/"])
assert len(result["streams"]) == 1
first_stream = result["streams"][0]
assert first_stream.meta["content_type"] == "text/html"
assert first_stream.mime_type == "text/html"
@pytest.mark.asyncio
class TestLinkContentFetcherAsync:
async def test_run_async(self):
"""Test basic async fetching with a mocked response"""
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get:
mock_response = Mock(status_code=200, text="Example test response", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
streams = (await fetcher.run_async(urls=["https://www.example.com"]))["streams"]
first_stream = streams[0]
expected_content = b"Example test response"
assert first_stream.data == expected_content
assert first_stream.meta["content_type"] == "text/plain"
assert first_stream.mime_type == "text/plain"
async def test_run_async_multiple(self):
"""Test async fetching of multiple URLs with mocked responses"""
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get:
mock_response = Mock(status_code=200, text="Example test response", headers={"Content-Type": "text/plain"})
mock_get.return_value = mock_response
fetcher = LinkContentFetcher()
streams = (await fetcher.run_async(urls=["https://www.example1.com", "https://www.example2.com"]))[
"streams"
]
assert len(streams) == 2
for stream in streams:
expected_data = b"Example test response"
assert stream.data == expected_data
assert stream.meta["content_type"] == "text/plain"
assert stream.mime_type == "text/plain"
async def test_run_async_empty_urls(self):
"""Test async fetching with empty URL list"""
fetcher = LinkContentFetcher()
streams = (await fetcher.run_async(urls=[]))["streams"]
assert len(streams) == 0
async def test_run_async_error_handling(self):
"""Test error handling for async fetching"""
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get:
mock_response = Mock(status_code=404)
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"404 Not Found", request=Mock(), response=mock_response
)
mock_get.return_value = mock_response
# With raise_on_failure=False
fetcher = LinkContentFetcher(raise_on_failure=False, retry_attempts=0)
streams = (await fetcher.run_async(urls=["https://www.example.com"]))["streams"]
assert len(streams) == 1 # Returns an empty stream
# With raise_on_failure=True
fetcher = LinkContentFetcher(raise_on_failure=True, retry_attempts=0)
with pytest.raises(httpx.HTTPStatusError):
await fetcher.run_async(urls=["https://www.example.com"])
async def test_run_async_user_agent_rotation(self):
"""Test user agent rotation in async fetching"""
with (
patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get,
patch("asyncio.sleep") as mock_sleep,
):
# Mock asyncio.sleep used by tenacity to keep this test fast
mock_sleep.return_value = None
# First call raises an error to trigger user agent rotation
first_response = Mock(status_code=403)
first_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"403 Forbidden", request=Mock(), response=first_response
)
# Second call succeeds
second_response = Mock(status_code=200, text="Success", headers={"Content-Type": "text/plain"})
# Use side_effect to return different responses on consecutive calls
mock_get.side_effect = [first_response, second_response]
# Create fetcher with custom user agents
fetcher = LinkContentFetcher(user_agents=["agent1", "agent2"], retry_attempts=1)
# Should succeed on the second attempt with the second user agent
streams = (await fetcher.run_async(urls=["https://www.example.com"]))["streams"]
assert len(streams) == 1
expected_result = b"Success"
assert streams[0].data == expected_result
mock_sleep.assert_called_once()
async def test_request_headers_merging_and_ua_override(self):
# Patch the AsyncClient class to control the instance created by LinkContentFetcher
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
aclient = AsyncClientMock.return_value
aclient.headers = {} # base headers used in the merge
mock_response = Mock(status_code=200, text="OK", headers={"Content-Type": "text/plain"})
aclient.get = AsyncMock(return_value=mock_response)
fetcher = LinkContentFetcher(
user_agents=["ua-async-1", "ua-async-2"],
request_headers={"Accept-Language": "de-DE", "X-Async": "true", "User-Agent": "ignored-here-too"},
)
_ = (await fetcher.run_async(urls=["https://example.com"]))["streams"]
assert aclient.get.await_count == 1
sent_headers = aclient.get.call_args.kwargs["headers"]
assert sent_headers["X-Async"] == "true"
assert sent_headers["Accept-Language"] == "de-DE"
assert sent_headers["User-Agent"] == "ua-async-1" # rotating UA wins
async def test_duplicated_request_headers_merging(self):
# Patch the AsyncClient class to control the instance created by LinkContentFetcher
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
aclient = AsyncClientMock.return_value
aclient.headers = {} # base headers used in the merge
mock_response = Mock(status_code=200, text="OK", headers={"Content-Type": "text/plain"})
aclient.get = AsyncMock(return_value=mock_response)
fetcher = LinkContentFetcher(
request_headers={
"x-test-header": "header-1",
"X-Test-Header": "agent-2",
"X-TEST-HEADER": "agent-3",
"X-TeSt-HeAdEr": "good-one",
}
)
_ = (await fetcher.run_async(urls=["https://example.com"]))["streams"]
assert aclient.get.await_count == 1
sent_headers = aclient.get.call_args.kwargs["headers"]
existing_keys = {}
for key, value in sent_headers.items():
lower_key = key.lower()
if lower_key in existing_keys:
raise AssertionError()
if lower_key == "x-test-header":
assert value == "good-one"
existing_keys[lower_key] = key
assert "x-test-header" in existing_keys
assert existing_keys["x-test-header"] == "X-TeSt-HeAdEr"
@pytest.mark.flaky(reruns=3, reruns_delay=5)
@pytest.mark.integration
@pytest.mark.asyncio
class TestLinkContentFetcherAsyncIntegration:
async def test_run_async_multiple_integration(self):
"""Test async fetching of multiple URLs with real HTTP requests"""
fetcher = LinkContentFetcher()
streams = (await fetcher.run_async([HTML_URL, TEXT_URL]))["streams"]
assert len(streams) == 2
for stream in streams:
assert "Haystack" in stream.data.decode("utf-8")
if stream.meta["url"] == HTML_URL:
assert stream.meta["content_type"] == "text/html"
assert stream.mime_type == "text/html"
elif stream.meta["url"] == TEXT_URL:
assert stream.meta["content_type"] == "text/plain"
assert stream.mime_type == "text/plain"
async def test_run_async_with_client_kwargs(self):
"""Test async fetching with custom client kwargs"""
fetcher = LinkContentFetcher(client_kwargs={"follow_redirects": True, "timeout": 10.0})
streams = (await fetcher.run_async([HTML_URL]))["streams"]
assert len(streams) == 1
assert "Haystack" in streams[0].data.decode("utf-8")
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,736 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from openai import OpenAIError
from pydantic import BaseModel
import haystack.components.generators.chat.azure as azure_chat_module
from haystack import Pipeline, component
from haystack.components.generators.chat import AzureOpenAIChatGenerator
from haystack.components.generators.utils import print_streaming_chunk
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.tools import ComponentTool, Tool
from haystack.tools.toolset import Toolset
from haystack.utils.auth import Secret
from haystack.utils.azure import default_azure_ad_token_provider
class CalendarEvent(BaseModel):
event_name: str
event_date: str
event_location: str
@pytest.fixture
def calendar_event_model():
return CalendarEvent
def get_weather(city: str) -> dict[str, Any]:
weather_info = {
"Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
"Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
"Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
}
return weather_info.get(city, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
@component
class MessageExtractor:
@component.output_types(messages=list[str], meta=dict[str, Any])
def run(self, messages: list[ChatMessage], meta: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Extracts the text content of ChatMessage objects
:param messages: List of Haystack ChatMessage objects
:param meta: Optional metadata to include in the response.
:returns:
A dictionary with keys "messages" and "meta".
"""
if meta is None:
meta = {}
return {"messages": [m.text for m in messages], "meta": meta}
@pytest.fixture
def tools():
weather_tool = Tool(
name="weather",
description="useful to determine the weather in a given location",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=get_weather,
)
# We add a tool that has a more complex parameter signature
message_extractor_tool = ComponentTool(
component=MessageExtractor(),
name="message_extractor",
description="Useful for returning the text content of ChatMessage objects",
)
return [weather_tool, message_extractor_tool]
class TestAzureOpenAIChatGenerator:
def test_supported_models(self) -> None:
"""SUPPORTED_MODELS is a non-empty list of strings."""
models = AzureOpenAIChatGenerator.SUPPORTED_MODELS
assert isinstance(models, list)
assert len(models) > 0
assert all(isinstance(m, str) for m in models)
def test_init_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
assert component.azure_deployment == "gpt-4.1-mini"
assert component.streaming_callback is None
assert not component.generation_kwargs
assert component.client is None
assert component.async_client is None
def test_init_does_not_fail_wo_api_key(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.client is None
assert component.async_client is None
def test_init_with_parameters(self, tools):
component = AzureOpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
tools=tools,
tools_strict=True,
azure_ad_token_provider=default_azure_ad_token_provider,
)
assert component.api_key == Secret.from_token("test-api-key")
assert component.azure_deployment == "gpt-4.1-mini"
assert component.streaming_callback is print_streaming_chunk
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
assert component.tools == tools
assert component.tools_strict
assert component.azure_ad_token_provider is not None
assert component.max_retries is None
assert component.client is None
assert component.async_client is None
def test_init_with_0_max_retries(self, tools):
"""Tests that the max_retries init param is set correctly if equal 0"""
component = AzureOpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
tools=tools,
tools_strict=True,
azure_ad_token_provider=default_azure_ad_token_provider,
max_retries=0,
)
assert component.api_key == Secret.from_token("test-api-key")
assert component.azure_deployment == "gpt-4.1-mini"
assert component.streaming_callback is print_streaming_chunk
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
assert component.tools == tools
assert component.tools_strict
assert component.azure_ad_token_provider is not None
assert component.max_retries == 0
assert component.client is None
assert component.async_client is None
def test_init_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""`azure_endpoint` and `api_version` accept a Secret that is resolved from an environment variable."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
# The Secret objects are kept on the instance so they can be serialized
assert component.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert component.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
def test_init_fail_with_unset_secret_azure_endpoint(self, monkeypatch):
"""A Secret azure_endpoint that resolves to nothing raises the same error as a missing endpoint."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False)
with pytest.raises(ValueError, match="Azure endpoint"):
AzureOpenAIChatGenerator(azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT", strict=False))
def test_to_dict_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""Secret `azure_endpoint` and `api_version` are serialized as Secret dictionaries."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
init_params = component.to_dict()["init_parameters"]
assert init_params["azure_endpoint"] == {
"type": "env_var",
"env_vars": ["AZURE_OPENAI_ENDPOINT"],
"strict": True,
}
assert init_params["api_version"] == {
"type": "env_var",
"env_vars": ["AZURE_OPENAI_API_VERSION"],
"strict": True,
}
def test_secret_azure_endpoint_and_api_version_roundtrip(self, monkeypatch):
"""Serializing and deserializing a component with Secret endpoint/version restores the Secrets."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
deserialized = AzureOpenAIChatGenerator.from_dict(component.to_dict())
assert deserialized.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert deserialized.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
deserialized.warm_up()
assert str(deserialized.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
assert deserialized.client._api_version == "2024-08-01-preview"
def test_from_dict_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""from_dict deserializes Secret azure_endpoint/api_version dicts and resolves them for the client."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
data = {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_endpoint": {"env_vars": ["AZURE_OPENAI_ENDPOINT"], "strict": True, "type": "env_var"},
"api_version": {"env_vars": ["AZURE_OPENAI_API_VERSION"], "strict": True, "type": "env_var"},
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
generator = AzureOpenAIChatGenerator.from_dict(data)
# The Secret dicts are deserialized back into Secret objects
assert generator.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert generator.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
# And they are resolved to the string values the client expects
generator.warm_up()
assert str(generator.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
assert generator.client._api_version == "2024-08-01-preview"
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model):
monkeypatch.setenv("ENV_VAR", "test-api-key")
component = AzureOpenAIChatGenerator(
api_key=Secret.from_env_var("ENV_VAR", strict=False),
azure_ad_token=Secret.from_env_var("ENV_VAR1", strict=False),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
timeout=2.5,
max_retries=10,
generation_kwargs={
"max_completion_tokens": 10,
"some_test_param": "test-params",
"response_format": calendar_event_model,
},
azure_ad_token_provider=default_azure_ad_token_provider,
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["ENV_VAR1"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
"timeout": 2.5,
"max_retries": 10,
"generation_kwargs": {
"max_completion_tokens": 10,
"some_test_param": "test-params",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "CalendarEvent",
"strict": True,
"schema": {
"properties": {
"event_name": {"title": "Event Name", "type": "string"},
"event_date": {"title": "Event Date", "type": "string"},
"event_location": {"title": "Event Location", "type": "string"},
},
"required": ["event_name", "event_date", "event_location"],
"title": "CalendarEvent",
"type": "object",
"additionalProperties": False,
},
},
},
},
"tools": None,
"tools_strict": False,
"default_headers": {},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_AD_TOKEN", "test-ad-token")
data = {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": 30.0,
"max_retries": 5,
"default_headers": {},
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"description": "description",
"function": "builtins.print",
"name": "name",
"parameters": {"x": {"type": "string"}},
},
}
],
"tools_strict": False,
"http_client_kwargs": None,
},
}
generator = AzureOpenAIChatGenerator.from_dict(data)
assert isinstance(generator, AzureOpenAIChatGenerator)
assert generator.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
assert generator.azure_ad_token == Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False)
assert generator.api_version == "2024-12-01-preview"
assert generator.azure_endpoint == "some-non-existing-endpoint"
assert generator.azure_deployment == "gpt-4.1-mini"
assert generator.organization is None
assert generator.streaming_callback is None
assert generator.generation_kwargs == {}
assert generator.timeout == 30.0
assert generator.max_retries == 5
assert generator.default_headers == {}
assert generator.tools == [
Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print)
]
assert generator.tools_strict is False
assert generator.http_client_kwargs is None
def test_pipeline_serialization_deserialization(self, tmp_path, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
p = Pipeline()
p.add_component(instance=generator, name="generator")
assert p.to_dict() == {
"metadata": {},
"max_runs_per_component": 100,
"connection_type_validation": True,
"components": {
"generator": {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"api_version": "2024-12-01-preview",
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"api_key": {"type": "env_var", "env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False},
"azure_ad_token": {"type": "env_var", "env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False},
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
},
"connections": [],
}
p_str = p.dumps()
q = Pipeline.loads(p_str)
assert p.to_dict() == q.to_dict(), "Pipeline serialization/deserialization w/ AzureOpenAIChatGenerator failed."
def test_azure_chat_generator_with_toolset_initialization(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be initialized with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools)
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
assert generator.tools == toolset
def test_from_dict_with_toolset(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be deserialized from a dictionary with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools)
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
data = component.to_dict()
deserialized_component = AzureOpenAIChatGenerator.from_dict(data)
assert isinstance(deserialized_component.tools, Toolset)
assert len(deserialized_component.tools) == len(tools)
assert all(isinstance(tool, Tool) for tool in deserialized_component.tools)
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
def test_live_run(self):
chat_messages = [ChatMessage.from_user("What's the capital of France")]
component = AzureOpenAIChatGenerator(organization="HaystackCI")
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert "gpt-4.1-mini" in message.meta["model"]
assert message.meta["finish_reason"] == "stop"
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
def test_live_run_with_tools(self, tools):
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
component = AzureOpenAIChatGenerator(organization="HaystackCI", tools=tools)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert tool_call.arguments == {"city": "Paris"}
assert message.meta["finish_reason"] == "tool_calls"
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None),
reason="Export an env var called AZURE_OPENAI_API_KEY containing the Azure OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_live_run_with_response_format(self):
class CalendarEvent(BaseModel):
event_name: str
event_date: str
event_location: str
chat_messages = [
ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.")
]
component = AzureOpenAIChatGenerator(
api_version="2024-08-01-preview", generation_kwargs={"response_format": CalendarEvent}
)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
msg = json.loads(message.text)
assert "Marketing Summit" in msg["event_name"]
assert isinstance(msg["event_date"], str)
assert isinstance(msg["event_location"], str)
assert message.meta["finish_reason"] == "stop"
def test_to_dict_with_toolset(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be serialized to a dictionary with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools[:1])
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
data = component.to_dict()
expected_tools_data = {
"type": "haystack.tools.toolset.Toolset",
"data": {
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather",
"description": "useful to determine the weather in a given location",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
"function": "generators.chat.test_azure.get_weather",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
}
assert data["init_parameters"]["tools"] == expected_tools_data
class TestAzureOpenAIChatGeneratorAsync:
async def test_warm_up_async_builds_async_client(self, tools):
component = AzureOpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
tools=tools,
tools_strict=True,
)
assert component.async_client is None
await component.warm_up_async()
assert component.async_client.api_key == "test-api-key"
assert component.client is None
assert component.azure_deployment == "gpt-4.1-mini"
assert component.streaming_callback is print_streaming_chunk
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
assert component.tools == tools
assert component.tools_strict
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
@pytest.mark.asyncio
async def test_live_run_async(self):
component = AzureOpenAIChatGenerator(generation_kwargs={"n": 1})
chat_messages = [ChatMessage.from_user("What's the capital of France")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert "gpt-4.1-mini" in message.meta["model"]
assert message.meta["finish_reason"] == "stop"
await component.close_async()
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
@pytest.mark.asyncio
async def test_live_run_with_tools_async(self, tools):
component = AzureOpenAIChatGenerator(tools=tools)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert tool_call.arguments == {"city": "Paris"}
assert message.meta["finish_reason"] == "tool_calls"
await component.close_async()
# additional tests intentionally omitted as they are covered by test_openai.py
@pytest.fixture
def mock_azure_clients(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="AzureOpenAI")
async_cls = MagicMock(name="AsyncAzureOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(azure_chat_module, "AzureOpenAI", sync_cls)
monkeypatch.setattr(azure_chat_module, "AsyncAzureOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
assert generator.client.max_retries == 5
assert generator.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
generator = AzureOpenAIChatGenerator(
api_key=Secret.from_token("fake-api-key"),
azure_endpoint="some-non-existing-endpoint",
timeout=40.0,
max_retries=1,
)
generator.warm_up()
assert generator.client.max_retries == 1
assert generator.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
generator = AzureOpenAIChatGenerator(
api_key=Secret.from_token("fake-api-key"), azure_endpoint="some-non-existing-endpoint"
)
generator.warm_up()
assert generator.client.max_retries == 10
assert generator.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
with pytest.raises(OpenAIError):
generator.warm_up()
def test_warm_up_warms_tools_once(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
warm_up_calls = []
class MockTool(Tool):
def __init__(self, tool_name):
super().__init__(
name=tool_name,
description=f"Mock tool {tool_name}",
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
function=lambda x: x,
)
def warm_up(self):
warm_up_calls.append(self.name)
generator = AzureOpenAIChatGenerator(
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
)
assert not generator._tools_warmed_up
generator.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
assert generator._tools_warmed_up
generator.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
assert generator._tools_warmed_up
def test_sync_lifecycle(self, mock_azure_clients):
sync_cls, _ = mock_azure_clients
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert generator.client is None
assert generator.async_client is None
generator.warm_up()
assert generator.client is sync_cls.return_value
assert generator.async_client is None
generator.close()
sync_cls.return_value.close.assert_called_once()
assert generator.client is None
async def test_async_lifecycle(self, mock_azure_clients):
_, async_cls = mock_azure_clients
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
await generator.warm_up_async()
assert generator.async_client is async_cls.return_value
assert generator.client is None
await generator.close_async()
async_cls.return_value.close.assert_awaited_once()
assert generator.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.close()
await generator.close_async()
assert generator.client is None
assert generator.async_client is None
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
await generator.warm_up_async()
generator.close()
assert generator.client is None
assert generator.async_client is not None
await generator.close_async()
assert generator.async_client is None
@@ -0,0 +1,607 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from typing import Any
import pytest
from pydantic import BaseModel
from haystack import Pipeline, component
from haystack.components.generators.chat import AzureOpenAIResponsesChatGenerator
from haystack.components.generators.utils import print_streaming_chunk
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.tools import ComponentTool, Tool
from haystack.tools.toolset import Toolset
from haystack.utils.auth import Secret
from haystack.utils.azure import default_azure_ad_token_provider
class CalendarEvent(BaseModel):
event_name: str
event_date: str
event_location: str
@pytest.fixture
def calendar_event_model():
return CalendarEvent
def get_weather(city: str) -> dict[str, Any]:
weather_info = {
"Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
"Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
"Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
}
return weather_info.get(city, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
@component
class MessageExtractor:
@component.output_types(messages=list[str], meta=dict[str, Any])
def run(self, messages: list[ChatMessage], meta: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Extracts the text content of ChatMessage objects
:param messages: List of Haystack ChatMessage objects
:param meta: Optional metadata to include in the response.
:returns:
A dictionary with keys "messages" and "meta".
"""
if meta is None:
meta = {}
return {"messages": [m.text for m in messages], "meta": meta}
@pytest.fixture
def tools():
weather_tool = Tool(
name="weather",
description="useful to determine the weather in a given location",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=get_weather,
)
# We add a tool that has a more complex parameter signature
message_extractor_tool = ComponentTool(
component=MessageExtractor(),
name="message_extractor",
description="Useful for returning the text content of ChatMessage objects",
)
return [weather_tool, message_extractor_tool]
class TestInitialization:
def test_supported_models(self) -> None:
"""SUPPORTED_MODELS is a non-empty list of strings."""
models = AzureOpenAIResponsesChatGenerator.SUPPORTED_MODELS
assert isinstance(models, list)
assert len(models) > 0
assert all(isinstance(m, str) for m in models)
def test_init_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.client is None
assert component.async_client is None
assert component._azure_deployment == "gpt-5-mini"
assert component.streaming_callback is None
assert not component.generation_kwargs
def test_init_fail_wo_azure_endpoint(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False)
with pytest.raises(ValueError):
AzureOpenAIResponsesChatGenerator()
def test_init_with_parameters(self, tools):
component = AzureOpenAIResponsesChatGenerator(
api_key=Secret.from_token("test-api-key"),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
tools=tools,
tools_strict=True,
)
assert component.client is None
assert component.async_client is None
assert component._azure_deployment == "gpt-5-mini"
assert component.streaming_callback is print_streaming_chunk
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
assert component.tools == tools
assert component.tools_strict
assert component.max_retries is None
def test_init_with_toolset(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be initialized with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools)
generator = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
assert generator.tools == toolset
class TestSerDe:
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
}
def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model):
monkeypatch.setenv("ENV_VAR", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(
api_key=Secret.from_env_var("ENV_VAR", strict=False),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
timeout=2.5,
max_retries=10,
generation_kwargs={
"max_completion_tokens": 10,
"some_test_param": "test-params",
"text_format": calendar_event_model,
},
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"},
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
"timeout": 2.5,
"max_retries": 10,
"generation_kwargs": {
"max_completion_tokens": 10,
"some_test_param": "test-params",
"text": {
"format": {
"type": "json_schema",
"name": "CalendarEvent",
"strict": True,
"schema": {
"properties": {
"event_name": {"title": "Event Name", "type": "string"},
"event_date": {"title": "Event Date", "type": "string"},
"event_location": {"title": "Event Location", "type": "string"},
},
"required": ["event_name", "event_date", "event_location"],
"title": "CalendarEvent",
"type": "object",
"additionalProperties": False,
},
}
},
},
"tools": None,
"tools_strict": False,
"http_client_kwargs": {"proxy": "http://localhost:8080"},
},
}
def test_to_dict_with_ad_token_provider(self):
component = AzureOpenAIResponsesChatGenerator(
api_key=default_azure_ad_token_provider, azure_endpoint="some-non-existing-endpoint"
)
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"api_key": "haystack.utils.azure.default_azure_ad_token_provider",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
}
def test_to_dict_with_toolset(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be serialized to a dictionary with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools[:1])
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
data = component.to_dict()
expected_tools_data = {
"type": "haystack.tools.toolset.Toolset",
"data": {
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather",
"description": "useful to determine the weather in a given location",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
"function": "generators.chat.test_azure_responses.get_weather",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
}
assert data["init_parameters"]["tools"] == expected_tools_data
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_AD_TOKEN", "test-ad-token")
data = {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": 30.0,
"max_retries": 5,
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"description": "description",
"function": "builtins.print",
"name": "name",
"parameters": {"x": {"type": "string"}},
},
}
],
"tools_strict": False,
"http_client_kwargs": None,
},
}
generator = AzureOpenAIResponsesChatGenerator.from_dict(data)
assert isinstance(generator, AzureOpenAIResponsesChatGenerator)
assert generator.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
assert generator._azure_endpoint == "some-non-existing-endpoint"
assert generator._azure_deployment == "gpt-5-mini"
assert generator.organization is None
assert generator.streaming_callback is None
assert generator.generation_kwargs == {}
assert generator.timeout == 30.0
assert generator.max_retries == 5
assert generator.tools == [
Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print)
]
assert generator.tools_strict is False
assert generator.http_client_kwargs is None
def test_from_dict_with_ad_token_provider(self):
data = {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"api_key": "haystack.utils.azure.default_azure_ad_token_provider",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
}
generator = AzureOpenAIResponsesChatGenerator.from_dict(data)
assert isinstance(generator, AzureOpenAIResponsesChatGenerator)
assert generator.api_key == default_azure_ad_token_provider
assert generator._azure_endpoint == "some-non-existing-endpoint"
assert generator._azure_deployment == "gpt-5-mini"
assert generator.organization is None
assert generator.streaming_callback is None
assert generator.generation_kwargs == {}
assert generator.timeout is None
assert generator.max_retries is None
assert generator.tools is None
assert generator.tools_strict is False
assert generator.http_client_kwargs is None
def test_from_dict_with_toolset(self, tools, monkeypatch):
"""Test that the AzureOpenAIChatGenerator can be deserialized from a dictionary with a Toolset."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
toolset = Toolset(tools)
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
data = component.to_dict()
deserialized_component = AzureOpenAIResponsesChatGenerator.from_dict(data)
assert isinstance(deserialized_component.tools, Toolset)
assert len(deserialized_component.tools) == len(tools)
assert all(isinstance(tool, Tool) for tool in deserialized_component.tools)
def test_pipeline_serialization_deserialization(self, tmp_path, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
generator = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
p = Pipeline()
p.add_component(instance=generator, name="generator")
assert p.to_dict() == {
"metadata": {},
"max_runs_per_component": 100,
"connection_type_validation": True,
"components": {
"generator": {
"type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator",
"init_parameters": {
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-5-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"api_key": {"type": "env_var", "env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False},
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
}
},
"connections": [],
}
p_str = p.dumps()
q = Pipeline.loads(p_str)
assert p.to_dict() == q.to_dict()
class TestComponentLifecycle:
def test_warm_up_warms_tools_once(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
warm_up_calls = []
class MockTool(Tool):
def __init__(self, tool_name):
super().__init__(
name=tool_name,
description=f"Mock tool {tool_name}",
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
function=lambda x: x,
)
def warm_up(self):
warm_up_calls.append(self.name)
component = AzureOpenAIResponsesChatGenerator(
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
)
assert not component._tools_warmed_up
component.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
assert component._tools_warmed_up
component.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
component.warm_up()
assert component._tools_warmed_up
def test_sync_lifecycle(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.client is None
assert component.async_client is None
component.warm_up()
assert component.client is not None
assert component.async_client is None
component.close()
assert component.client is None
async def test_async_lifecycle(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
await component.warm_up_async()
assert component.async_client is not None
assert component.client is None
await component.close_async()
assert component.async_client is None
async def test_close_is_safe_without_warm_up(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
component.close()
await component.close_async()
assert component.client is None
assert component.async_client is None
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
class TestIntegration:
def test_live_run(self):
chat_messages = [ChatMessage.from_user("What's the capital of France")]
component = AzureOpenAIResponsesChatGenerator(azure_deployment="gpt-4o-mini")
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "paris" in message.text.lower()
assert "gpt-4o-mini" in message.meta["model"]
assert message.meta["status"] == "completed"
def test_live_run_with_tools(self, tools):
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
component = AzureOpenAIResponsesChatGenerator(
organization="HaystackCI", tools=tools, azure_deployment="gpt-4o-mini"
)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert "city" in tool_call.arguments
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["status"] == "completed"
def test_live_run_with_text_format(self, calendar_event_model):
chat_messages = [
ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.")
]
component = AzureOpenAIResponsesChatGenerator(
azure_deployment="gpt-4o-mini", generation_kwargs={"text_format": calendar_event_model}
)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
msg = json.loads(message.text)
assert "marketing summit" in msg["event_name"].lower()
assert isinstance(msg["event_date"], str)
assert isinstance(msg["event_location"], str)
assert message.meta["status"] == "completed"
# So far from documentation, responses.parse only supports BaseModel
def test_live_run_with_text_format_json_schema(self):
json_schema = {
"format": {
"type": "json_schema",
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1},
"age": {"type": "number", "minimum": 0, "maximum": 130},
},
"required": ["name", "age"],
"additionalProperties": False,
},
}
}
chat_messages = [ChatMessage.from_user("Jane 54 years old")]
component = AzureOpenAIResponsesChatGenerator(
azure_deployment="gpt-4o-mini", generation_kwargs={"text": json_schema}
)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
msg = json.loads(message.text)
assert "jane" in msg["name"].lower()
assert msg["age"] == 54
assert message.meta["status"] == "completed"
assert message.meta["usage"]["output_tokens"] > 0
class TestAzureOpenAIResponsesChatGeneratorAsync:
async def test_warm_up_async_creates_async_client_with_expected_args(self, tools):
component = AzureOpenAIResponsesChatGenerator(
api_key=Secret.from_token("test-api-key"),
azure_endpoint="some-non-existing-endpoint",
streaming_callback=print_streaming_chunk,
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
tools=tools,
tools_strict=True,
)
assert component.async_client is None
await component.warm_up_async()
assert component.async_client.api_key == "test-api-key"
assert component._azure_deployment == "gpt-5-mini"
assert component.streaming_callback is print_streaming_chunk
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
assert component.tools == tools
assert component.tools_strict
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
@pytest.mark.asyncio
async def test_live_run_async(self):
chat_messages = [ChatMessage.from_user("What's the capital of France")]
component = AzureOpenAIResponsesChatGenerator(azure_deployment="gpt-4o-mini")
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "paris" in message.text.lower()
assert "gpt-4o-mini" in message.meta["model"]
assert message.meta["status"] == "completed"
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None),
reason=(
"Please export env variables called AZURE_OPENAI_API_KEY containing "
"the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing "
"the Azure OpenAI endpoint URL to run this test."
),
)
@pytest.mark.asyncio
async def test_live_run_with_tools_async(self, tools):
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
component = AzureOpenAIResponsesChatGenerator(tools=tools, azure_deployment="gpt-4o-mini")
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert "city" in tool_call.arguments
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["status"] == "completed"
# additional tests intentionally omitted as they are covered by test_openai_responses.py
# and test_openai_responses_conversion.py
@@ -0,0 +1,489 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import time
from typing import Any
from unittest.mock import AsyncMock, Mock
from urllib.error import HTTPError as URLLibHTTPError
import pytest
from haystack import component, default_from_dict, default_to_dict
from haystack.components.generators.chat.fallback import FallbackChatGenerator
from haystack.core.errors import SerializationError
from haystack.dataclasses import ChatMessage, StreamingCallbackT
from haystack.tools import ToolsType
@component
class _DummySuccessGen:
def __init__(self, text: str = "ok", delay: float = 0.0, streaming_callback: StreamingCallbackT | None = None):
self.text = text
self.delay = delay
self.streaming_callback = streaming_callback
self.received_messages: list[list[ChatMessage]] = []
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, text=self.text, delay=self.delay, streaming_callback=None)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummySuccessGen":
return default_from_dict(cls, data)
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
self.received_messages.append(messages)
if self.delay:
time.sleep(self.delay)
if streaming_callback:
streaming_callback({"dummy": True}) # type: ignore[arg-type]
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {"dummy_meta": True}}
async def run_async(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
self.received_messages.append(messages)
if self.delay:
await asyncio.sleep(self.delay)
if streaming_callback:
await asyncio.sleep(0)
streaming_callback({"dummy": True}) # type: ignore[arg-type]
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {"dummy_meta": True}}
@component
class _DummyFailGen:
def __init__(self, exc: Exception | None = None, delay: float = 0.0):
self.exc = exc or RuntimeError("boom")
self.delay = delay
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, exc={"message": str(self.exc)}, delay=self.delay)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummyFailGen":
init = data.get("init_parameters", {})
msg = None
if isinstance(init.get("exc"), dict):
msg = init.get("exc", {}).get("message")
return cls(exc=RuntimeError(msg or "boom"), delay=init.get("delay", 0.0))
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.delay:
time.sleep(self.delay)
raise self.exc
async def run_async(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.delay:
await asyncio.sleep(self.delay)
raise self.exc
def test_init_validation():
with pytest.raises(ValueError):
FallbackChatGenerator(chat_generators=[])
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
assert len(gen.chat_generators) == 1
def test_sequential_first_success():
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "A"
assert res["meta"]["successful_chat_generator_index"] == 0
assert res["meta"]["total_attempts"] == 1
def test_run_with_string_input():
inner = _DummySuccessGen()
gen = FallbackChatGenerator(chat_generators=[inner])
res = gen.run("hi")
assert inner.received_messages[0] == [ChatMessage.from_user("hi")]
assert isinstance(res["replies"][0], ChatMessage)
async def test_run_async_with_string_input():
inner = _DummySuccessGen()
gen = FallbackChatGenerator(chat_generators=[inner])
res = await gen.run_async("hi")
assert inner.received_messages[0] == [ChatMessage.from_user("hi")]
assert isinstance(res["replies"][0], ChatMessage)
def test_sequential_second_success_after_failure():
gen = FallbackChatGenerator(chat_generators=[_DummyFailGen(), _DummySuccessGen(text="B")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "B"
assert res["meta"]["successful_chat_generator_index"] == 1
assert res["meta"]["failed_chat_generators"]
def test_all_fail_raises():
gen = FallbackChatGenerator(chat_generators=[_DummyFailGen(), _DummyFailGen()])
with pytest.raises(RuntimeError):
gen.run([ChatMessage.from_user("hi")])
def test_timeout_handling_sync():
slow = _DummySuccessGen(text="slow", delay=0.01)
fast = _DummySuccessGen(text="fast", delay=0.0)
gen = FallbackChatGenerator(chat_generators=[slow, fast])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "slow"
@pytest.mark.asyncio
async def test_timeout_handling_async():
slow = _DummySuccessGen(text="slow", delay=0.01)
fast = _DummySuccessGen(text="fast", delay=0.0)
gen = FallbackChatGenerator(chat_generators=[slow, fast])
res = await gen.run_async([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "slow"
def test_streaming_callback_forwarding_sync():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
_ = gen.run([ChatMessage.from_user("hi")], streaming_callback=cb)
assert calls
@pytest.mark.asyncio
async def test_streaming_callback_forwarding_async():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
_ = await gen.run_async([ChatMessage.from_user("hi")], streaming_callback=cb)
assert calls
def test_serialization_roundtrip():
original = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="hello")])
data = original.to_dict()
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 1
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "hello"
original = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="hello"), _DummySuccessGen(text="world")])
data = original.to_dict()
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 2
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "hello"
def test_automatic_completion_mode_without_streaming():
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="completion")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "completion"
assert res["meta"]["successful_chat_generator_index"] == 0
def test_automatic_ttft_mode_with_streaming():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="streaming")])
res = gen.run([ChatMessage.from_user("hi")], streaming_callback=cb)
assert res["replies"][0].text == "streaming"
assert calls
@pytest.mark.asyncio
async def test_automatic_ttft_mode_with_streaming_async():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="streaming_async")])
res = await gen.run_async([ChatMessage.from_user("hi")], streaming_callback=cb)
assert res["replies"][0].text == "streaming_async"
assert calls
def create_http_error(status_code: int, message: str) -> URLLibHTTPError:
return URLLibHTTPError("", status_code, message, {}, None)
@component
class _DummyHTTPErrorGen:
def __init__(self, text: str = "success", error: Exception | None = None):
self.text = text
self.error = error
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, text=self.text, error=str(self.error) if self.error else None)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummyHTTPErrorGen":
init = data.get("init_parameters", {})
error = None
if init.get("error"):
error = RuntimeError(init["error"])
return cls(text=init.get("text", "success"), error=error)
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.error:
raise self.error
return {
"replies": [ChatMessage.from_assistant(self.text)],
"meta": {"error_type": type(self.error).__name__ if self.error else None},
}
def test_failover_trigger_429_rate_limit():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
success_gen = _DummySuccessGen(text="success_after_rate_limit")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_rate_limit"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_401_authentication():
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
success_gen = _DummySuccessGen(text="success_after_auth")
fallback = FallbackChatGenerator(chat_generators=[auth_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_auth"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_400_bad_request():
bad_request_gen = _DummyHTTPErrorGen(text="bad_request", error=create_http_error(400, "Context length exceeded"))
success_gen = _DummySuccessGen(text="success_after_bad_request")
fallback = FallbackChatGenerator(chat_generators=[bad_request_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_bad_request"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_500_server_error():
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
success_gen = _DummySuccessGen(text="success_after_server_error")
fallback = FallbackChatGenerator(chat_generators=[server_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_server_error"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_multiple_errors():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
success_gen = _DummySuccessGen(text="success_after_all_errors")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, auth_error_gen, server_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_all_errors"
assert result["meta"]["successful_chat_generator_index"] == 3
assert len(result["meta"]["failed_chat_generators"]) == 3
def test_failover_trigger_all_generators_fail():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, auth_error_gen, server_error_gen])
with pytest.raises(RuntimeError) as exc_info:
fallback.run([ChatMessage.from_user("test")])
error_msg = str(exc_info.value)
assert "All 3 chat generators failed" in error_msg
assert "Failed chat generators: [_DummyHTTPErrorGen, _DummyHTTPErrorGen, _DummyHTTPErrorGen]" in error_msg
@pytest.mark.asyncio
async def test_failover_trigger_429_rate_limit_async():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
success_gen = _DummySuccessGen(text="success_after_rate_limit")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, success_gen])
result = await fallback.run_async([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_rate_limit"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
@pytest.mark.asyncio
async def test_failover_trigger_401_authentication_async():
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
success_gen = _DummySuccessGen(text="success_after_auth")
fallback = FallbackChatGenerator(chat_generators=[auth_error_gen, success_gen])
result = await fallback.run_async([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_auth"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
class TestComponentLifecycle:
def test_warm_up_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.warm_up()
for gen in gens:
gen.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "warm_up_async"]) for _ in range(3)]
for gen in gens:
gen.warm_up_async = AsyncMock()
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.warm_up_async()
for gen in gens:
gen.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.warm_up_async()
for gen in gens:
gen.warm_up.assert_called_once()
def test_close_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.close()
for gen in gens:
gen.close.assert_called_once()
async def test_close_async_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "close_async"]) for _ in range(3)]
for gen in gens:
gen.close_async = AsyncMock()
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.close_async()
for gen in gens:
gen.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.close_async()
for gen in gens:
gen.close.assert_called_once()
def test_lifecycle_is_safe_when_generators_lack_methods(self):
gens = [Mock(spec=["run"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.warm_up()
fallback.close()
@component
class CustomGeneratorWithoutSerDe:
def __init__(self, text: str = "custom_ok"):
self.text = text
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {}}
@component
class NonSerializableGenerator:
def __init__(self, non_serializable_arg: Any):
self.non_serializable_arg = non_serializable_arg
def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
return {"replies": []}
def test_serialization_with_custom_generators_without_to_dict():
# 1. Test mixed chain serialization, order preservation, execution, and round-trip
gen0 = _DummySuccessGen(text="dummy_has_dict")
gen1 = CustomGeneratorWithoutSerDe(text="custom_no_dict_1")
gen2 = CustomGeneratorWithoutSerDe(text="custom_no_dict_2")
original = FallbackChatGenerator(chat_generators=[gen0, gen1, gen2])
data = original.to_dict()
# Ensure all three components are serialized and not silently omitted
assert len(data["init_parameters"]["chat_generators"]) == 3
# Reconstruct/Deserialize
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 3
# Assert fallback order is exactly preserved
assert restored.chat_generators[0].text == "dummy_has_dict"
assert restored.chat_generators[1].text == "custom_no_dict_1"
assert restored.chat_generators[2].text == "custom_no_dict_2"
assert isinstance(restored.chat_generators[1], CustomGeneratorWithoutSerDe)
assert isinstance(restored.chat_generators[2], CustomGeneratorWithoutSerDe)
# Verify pipeline execution on the restored instance
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "dummy_has_dict"
# 2. Test failure path (fail loud) when a component is not serializable
non_serializable_fallback = FallbackChatGenerator(chat_generators=[NonSerializableGenerator(object())])
with pytest.raises(SerializationError, match="unsupported value of type"):
non_serializable_fallback.to_dict()
+369
View File
@@ -0,0 +1,369 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from unittest.mock import MagicMock
import pytest
from haystack import Document, Pipeline, component
from haystack.components.agents.agent import Agent
from haystack.components.generators.chat import LLM
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.joiners.branch import BranchJoiner
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.routers.conditional_router import ConditionalRouter
from haystack.core.component.types import InputSocket, OutputSocket
from haystack.dataclasses import ChatMessage
from haystack.dataclasses.chat_message import ChatRole
from haystack.dataclasses.streaming_chunk import StreamingChunk
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.tools import Tool
from haystack.tools.toolset import Toolset
def sync_streaming_callback(chunk: StreamingChunk) -> None:
pass
@component
class MockChatGeneratorWithTools:
"""A mock chat generator that accepts a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGeneratorWithTools", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithTools":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Reply with tools support")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply with tools support")]}
@component
class MockChatGenerator:
"""A mock chat generator that does NOT accept a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGenerator", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGenerator":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Sync reply")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply")]}
class TestLLM:
class TestInit:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_is_subclass_of_agent(self):
assert issubclass(LLM, Agent)
def test_defaults(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.chat_generator is not None
assert llm.tools == []
assert llm.system_prompt is None
assert llm.user_prompt == self.USER_PROMPT
assert llm.required_variables == "*"
assert llm.streaming_callback is None
def test_output_sockets(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.__haystack_output__._sockets_dict == {
"messages": OutputSocket(name="messages", type=list[ChatMessage], receivers=[]),
"last_message": OutputSocket(name="last_message", type=ChatMessage, receivers=[]),
"token_usage": OutputSocket(name="token_usage", type=dict[str, Any], receivers=[]),
}
def test_detects_no_tools_support(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is False
def test_detects_tools_support(self):
llm = LLM(chat_generator=MockChatGeneratorWithTools(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is True
def test_messages_required_when_no_prompt_variables(self):
llm = LLM(
chat_generator=MockChatGenerator(), user_prompt='{% message role="user" %}Hello world{% endmessage %}'
)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert messages_socket.is_mandatory
def test_messages_optional_when_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
def test_messages_optional_when_plain_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
assert "query" in llm.__haystack_input__._sockets_dict
def test_runtime_prompt_overrides_not_component_inputs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert "system_prompt" not in llm.__haystack_input__._sockets_dict
assert "user_prompt" not in llm.__haystack_input__._sockets_dict
def test_raises_if_required_variables_empty(self):
with pytest.raises(ValueError, match="required_variables must not be empty"):
LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT, required_variables=[])
class TestSerialization:
def test_to_dict_excludes_agent_only_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
llm = LLM(chat_generator=OpenAIChatGenerator(), system_prompt="You are helpful.", user_prompt=user_prompt)
serialized = llm.to_dict()
assert serialized["type"] == "haystack.components.generators.chat.llm.LLM"
assert "chat_generator" in serialized["init_parameters"]
assert serialized["init_parameters"]["system_prompt"] == "You are helpful."
agent_only_params = [
"tools",
"exit_conditions",
"max_agent_steps",
"raise_on_tool_invocation_failure",
"tool_concurrency_limit",
"tool_streaming_callback_passthrough",
"confirmation_strategies",
"state_schema",
]
for param in agent_only_params:
assert param not in serialized["init_parameters"], (
f"Agent-only param '{param}' should not be serialized"
)
def test_to_dict_includes_llm_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
llm = LLM(
chat_generator=OpenAIChatGenerator(),
system_prompt="Be concise.",
user_prompt='{% message role="user" %}{{ query }}{% endmessage %}',
required_variables=["query"],
)
serialized = llm.to_dict()
assert serialized["init_parameters"]["system_prompt"] == "Be concise."
assert "{{ query }}" in serialized["init_parameters"]["user_prompt"]
assert serialized["init_parameters"]["required_variables"] == ["query"]
assert serialized["init_parameters"]["streaming_callback"] is None
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.generators.chat.llm.LLM",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4o-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"system_prompt": "You are helpful.",
"user_prompt": '{% message role="user" %}{{ query }}{% endmessage %}',
"required_variables": "*",
"streaming_callback": None,
},
}
llm = LLM.from_dict(data)
assert isinstance(llm, LLM)
assert isinstance(llm.chat_generator, OpenAIChatGenerator)
assert llm.system_prompt == "You are helpful."
assert llm.tools == []
def test_roundtrip(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
original = LLM(
chat_generator=OpenAIChatGenerator(), system_prompt="You are a poet.", user_prompt=user_prompt
)
restored = LLM.from_dict(original.to_dict())
assert isinstance(restored, LLM)
assert isinstance(restored.chat_generator, OpenAIChatGenerator)
assert restored.system_prompt == original.system_prompt
assert restored.tools == []
class TestRun:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_run_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = llm.run(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Sync reply"
assert prior_message in result["messages"]
def test_run_without_messages(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("What is 2+2?" in m.text for m in user_messages)
def test_run_with_plain_user_prompt(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("Question: What is 2+2?" in m.text for m in user_messages)
@pytest.mark.asyncio
async def test_run_async_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = await llm.run_async(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Async reply"
assert prior_message in result["messages"]
class TestPipelineIntegration:
@pytest.fixture()
def document_store_with_docs(self):
store = InMemoryDocumentStore()
store.write_documents(
[
Document(content="The Eiffel Tower is located in Paris."),
Document(content="The Brandenburg Gate is in Berlin."),
Document(content="The Colosseum is in Rome."),
]
)
return store
def test_rag_pipeline(self, document_store_with_docs):
user_prompt = (
'{% message role="user" %}'
"Use the following documents to answer the question.\n"
"Documents:\n{% for doc in documents %}{{ doc.content }}\n{% endfor %}"
"Question: {{ query }}"
"{% endmessage %}"
)
llm = LLM(
chat_generator=MockChatGenerator(),
system_prompt="You are a knowledgeable assistant.",
user_prompt=user_prompt,
required_variables=["query", "documents"],
)
pipe = Pipeline()
pipe.add_component("retriever", InMemoryBM25Retriever(document_store=document_store_with_docs))
pipe.add_component("llm", llm)
pipe.connect("retriever.documents", "llm.documents")
query = "Where is the Colosseum?"
result = pipe.run(data={"retriever": {"query": query}, "llm": {"query": query}})
assert "llm" in result
llm_output = result["llm"]
assert "messages" in llm_output
assert "last_message" in llm_output
messages = llm_output["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "You are a knowledgeable assistant."
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert len(user_messages) == 1
rendered = user_messages[0].text
assert "Question: Where is the Colosseum?" in rendered
assert "Documents:" in rendered
assert "Colosseum" in rendered
assert llm_output["last_message"].is_from(ChatRole.ASSISTANT)
assert llm_output["last_message"].text == "Sync reply"
class TestLLMNotTriggeredByInjectedInput:
"""
Regression guard for the optional-messages scheduling hazard described in
https://github.com/deepset-ai/haystack/issues/11109.
When `user_prompt` contains template variables, `messages` is optional on the LLM.
An optional input with `sender=None` (i.e., injected directly via `pipeline.run`)
would flip `has_user_input()` to True and incorrectly trigger the component even
when its required inputs (e.g. `query`) never arrive.
"""
def test_llm_not_triggered_by_injected_streaming_callback(self):
@component
class Planner:
@component.output_types(messages=list[ChatMessage], last_role=str)
def run(self) -> dict:
return {"messages": [ChatMessage.from_user("hello")], "last_role": "assistant"}
chat_generator = MockChatGenerator()
llm = LLM(chat_generator=chat_generator)
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("x")]})
router = ConditionalRouter(
routes=[
{
"condition": "{{ last_role == 'tool' }}",
"output": "{{ messages }}",
"output_name": "processing",
"output_type": list[ChatMessage],
},
{
"condition": "{{ True }}",
"output": "{{ messages }}",
"output_name": "planning",
"output_type": list[ChatMessage],
},
],
unsafe=True,
)
pipeline = Pipeline()
pipeline.add_component("planner", Planner())
pipeline.add_component("router", router)
pipeline.add_component("branch_joiner", BranchJoiner(type_=list[ChatMessage]))
pipeline.add_component("llm", llm)
pipeline.connect("planner.messages", "router.messages")
pipeline.connect("planner.last_role", "router.last_role")
pipeline.connect("router.processing", "branch_joiner.value")
pipeline.connect("branch_joiner.value", "llm.messages")
result = pipeline.run(data={"llm": {"streaming_callback": sync_streaming_callback}})
assert "llm" not in result
chat_generator.run.assert_not_called()
@@ -0,0 +1,204 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
import pytest
from haystack import Pipeline
from haystack.components.generators.chat import MockChatGenerator
from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
def _exclaim(messages: list[ChatMessage]) -> str:
"""Module-level response function (returns a string) used to test `response_fn` and its serialization."""
return f"{messages[-1].text}!"
def _assistant_reply(messages: list[ChatMessage]) -> ChatMessage:
"""Module-level response function that returns a full ChatMessage."""
return ChatMessage.from_assistant("canned message")
def _noop_callback(chunk: StreamingChunk) -> None:
"""Module-level streaming callback used to test init-level callback serialization."""
class TestMockChatGenerator:
@pytest.mark.parametrize(
("args", "kwargs", "exception", "match"),
[
(("a",), {"response_fn": _exclaim}, ValueError, "either 'responses' or 'response_fn'"),
(([],), {}, ValueError, "must not be an empty list"),
((123,), {}, TypeError, "must be a string, ChatMessage, or a sequence"),
(([123],), {}, TypeError, "Each response must be a string or ChatMessage"),
((ChatMessage.from_user("hi"),), {}, ValueError, "must have the 'assistant' role"),
],
)
def test_init_rejects_invalid_config(self, args, kwargs, exception, match):
with pytest.raises(exception, match=match):
MockChatGenerator(*args, **kwargs)
def test_fixed_response(self):
gen = MockChatGenerator("the same answer")
for _ in range(3):
result = gen.run([ChatMessage.from_user("anything")])
assert result["replies"][0].text == "the same answer"
def test_cycling_responses(self):
# a mix of strings and ChatMessage objects, returned in order and wrapping around
gen = MockChatGenerator(["one", ChatMessage.from_assistant("two"), "three"])
texts = [gen.run([ChatMessage.from_user("hi")])["replies"][0].text for _ in range(4)]
assert texts == ["one", "two", "three", "one"]
@pytest.mark.parametrize(
("messages", "expected"),
[
(
[ChatMessage.from_system("sys"), ChatMessage.from_user("first"), ChatMessage.from_user("second")],
"second",
),
([ChatMessage.from_system("only system")], "only system"), # falls back to the last message with text
([], None), # nothing to echo
],
)
def test_echo_default(self, messages, expected):
replies = MockChatGenerator().run(messages)["replies"]
if expected is None:
assert replies == []
else:
assert replies[0].text == expected
@pytest.mark.parametrize(("fn", "expected"), [(_exclaim, "hello!"), (_assistant_reply, "canned message")])
def test_response_fn(self, fn, expected):
result = MockChatGenerator(response_fn=fn).run([ChatMessage.from_user("hello")])
assert result["replies"][0].text == expected
@pytest.mark.parametrize(
("fn", "exception", "match"),
[
(lambda messages: 123, TypeError, "must return a string or ChatMessage"),
(lambda messages: ChatMessage.from_user("nope"), ValueError, "must return an assistant ChatMessage"),
],
)
def test_response_fn_invalid_return_raises(self, fn, exception, match):
with pytest.raises(exception, match=match):
MockChatGenerator(response_fn=fn).run([ChatMessage.from_user("hi")])
def test_string_input_is_normalized(self):
gen = MockChatGenerator(response_fn=_exclaim)
assert gen.run("plain string")["replies"][0].text == "plain string!"
def test_tool_call_response(self):
tool_call = ToolCall(tool_name="search", arguments={"query": "Haystack"})
gen = MockChatGenerator(ChatMessage.from_assistant(tool_calls=[tool_call]))
reply = gen.run([ChatMessage.from_user("search for Haystack")])["replies"][0]
assert reply.tool_calls == [tool_call]
assert reply.meta["finish_reason"] == "tool_calls"
def test_meta_defaults(self):
meta = MockChatGenerator("hello world").run([ChatMessage.from_user("a b c")])["replies"][0].meta
assert meta["model"] == "mock-model"
assert meta["finish_reason"] == "stop"
assert meta["usage"] == {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}
def test_meta_merging_precedence(self):
# init meta overrides defaults; per-response meta overrides init meta
response = ChatMessage.from_assistant("hi", meta={"custom": "from-response", "finish_reason": "length"})
gen = MockChatGenerator(response, model="custom-model", meta={"custom": "from-init", "extra": "init"})
meta = gen.run([ChatMessage.from_user("x")])["replies"][0].meta
assert meta["model"] == "custom-model"
assert meta["custom"] == "from-response"
assert meta["finish_reason"] == "length"
assert meta["extra"] == "init"
def test_does_not_mutate_stored_responses(self):
gen = MockChatGenerator("hello")
gen.run([ChatMessage.from_user("a b")])
# the stored response keeps its original (empty) meta, untouched by the per-run meta
assert gen._responses[0].meta == {}
async def test_run_async(self):
gen = MockChatGenerator(["one", "two"])
assert (await gen.run_async([ChatMessage.from_user("hi")]))["replies"][0].text == "one"
assert (await gen.run_async([ChatMessage.from_user("hi")]))["replies"][0].text == "two"
# echo mode with empty input returns no replies (async path)
assert (await MockChatGenerator().run_async([]))["replies"] == []
def test_streaming_callback_sync(self):
chunks: list[StreamingChunk] = []
result = MockChatGenerator("hello there friend").run(
[ChatMessage.from_user("hi")], streaming_callback=chunks.append
)
assert "".join(chunk.content for chunk in chunks) == "hello there friend"
assert chunks[0].start is True
assert chunks[-1].finish_reason == "stop"
# the returned reply matches the predefined response
assert result["replies"][0].text == "hello there friend"
def test_run_signature_matches_openai_order(self):
# run()/run_async() must mirror OpenAIChatGenerator's parameter order so the mock is a positional drop-in.
expected = [
("self", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("messages", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("streaming_callback", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("generation_kwargs", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("tools", inspect.Parameter.KEYWORD_ONLY),
("tools_strict", inspect.Parameter.KEYWORD_ONLY),
]
for method in ("run", "run_async"):
params = list(inspect.signature(getattr(MockChatGenerator, method)).parameters.values())
assert [(p.name, p.kind) for p in params] == expected
# passing the callback as the 2nd positional arg must be treated as streaming_callback, not generation_kwargs
chunks: list[StreamingChunk] = []
MockChatGenerator("hi").run([ChatMessage.from_user("x")], chunks.append)
assert chunks
async def test_streaming_callback_async(self):
chunks: list[StreamingChunk] = []
async def callback(chunk: StreamingChunk) -> None:
chunks.append(chunk)
await MockChatGenerator("hello world").run_async([ChatMessage.from_user("hi")], streaming_callback=callback)
assert "".join(chunk.content for chunk in chunks) == "hello world"
assert chunks[-1].finish_reason == "stop"
def test_streaming_empty_reply(self):
chunks: list[StreamingChunk] = []
MockChatGenerator("").run([ChatMessage.from_user("hi")], streaming_callback=chunks.append)
assert chunks[-1].finish_reason == "stop"
def test_streaming_callback_with_tool_call(self):
chunks: list[StreamingChunk] = []
tool_call = ToolCall(tool_name="search", arguments={"query": "x"})
gen = MockChatGenerator(ChatMessage.from_assistant(tool_calls=[tool_call]))
gen.run([ChatMessage.from_user("hi")], streaming_callback=chunks.append)
assert any(chunk.tool_calls for chunk in chunks)
assert chunks[-1].finish_reason == "tool_calls"
@pytest.mark.parametrize(
"generator",
[
MockChatGenerator(["a", ChatMessage.from_assistant("b")], model="m", meta={"k": "v"}),
MockChatGenerator(response_fn=_exclaim),
MockChatGenerator(), # echo mode
MockChatGenerator("hi", streaming_callback=_noop_callback), # serialized init-level callback
],
ids=["responses", "response_fn", "echo", "streaming_callback"],
)
def test_serialization_roundtrip(self, generator):
restored = MockChatGenerator.from_dict(generator.to_dict())
assert isinstance(restored, MockChatGenerator)
# behavior is preserved across the roundtrip
messages = [ChatMessage.from_user("hi")]
assert restored.run(messages)["replies"][0].text == generator.run(messages)["replies"][0].text
def test_in_pipeline(self):
pipeline = Pipeline()
pipeline.add_component("generator", MockChatGenerator("from the pipeline"))
restored = Pipeline.from_dict(pipeline.to_dict())
result = restored.run({"generator": {"messages": [ChatMessage.from_user("hi")]}})
assert result["generator"]["replies"][0].text == "from the pipeline"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import contextlib
import os
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import AsyncOpenAI, AsyncStream, OpenAIError
from openai.types.chat import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
chat_completion_chunk,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call import Function
from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
from haystack.tools import Tool
from haystack.utils.auth import Secret
@pytest.fixture
def chat_messages():
return [
ChatMessage.from_system("You are a helpful assistant"),
ChatMessage.from_user("What's the capital of France"),
]
@pytest.fixture
def mock_chat_completion_chunk_with_tools(openai_mock_stream_async):
"""
Mock the OpenAI API completion chunk response and reuse it for tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="tool_calls",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(
role="assistant",
tool_calls=[
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="123",
type="function",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
name="weather", arguments='{"city": "Paris"}'
),
)
],
),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = openai_mock_stream_async(completion)
yield mock_chat_completion_create
@pytest.fixture
def tools():
tool_parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
tool = Tool(
name="weather",
description="useful to determine the weather in a given location",
parameters=tool_parameters,
function=lambda x: x,
)
return [tool]
class TestOpenAIChatGeneratorAsync:
async def test_warm_up_async_should_create_async_client_with_same_args(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
api_base_url="test-base-url",
organization="test-organization",
timeout=30,
max_retries=5,
)
await component.warm_up_async()
assert isinstance(component.async_client, AsyncOpenAI)
assert component.async_client.api_key == "test-api-key"
assert component.async_client.organization == "test-organization"
assert component.async_client.base_url == "test-base-url/"
assert component.async_client.timeout == 30
assert component.async_client.max_retries == 5
@pytest.mark.asyncio
async def test_run_async(self, chat_messages, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async(chat_messages)
# check that the component returns the correct ChatMessage response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
async def test_run_async_with_string_input(self, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async("What's the capital of France?")
_, kwargs = openai_mock_async_chat_completion.call_args
assert kwargs["messages"] == [{"role": "user", "content": "What's the capital of France?"}]
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert isinstance(response["replies"][0], ChatMessage)
@pytest.mark.asyncio
async def test_run_with_params_async(self, chat_messages, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
generation_kwargs={"max_completion_tokens": 10, "temperature": 0.5},
)
response = await component.run_async(chat_messages)
# check that the component calls the OpenAI API with the correct parameters
_, kwargs = openai_mock_async_chat_completion.call_args
assert kwargs["max_completion_tokens"] == 10
assert kwargs["temperature"] == 0.5
# check that the tools are not passed to the OpenAI API (the generator is initialized without tools)
assert "tools" not in kwargs
# check that the component returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
@pytest.mark.asyncio
async def test_run_with_params_streaming_async(self, chat_messages, openai_mock_async_chat_completion_chunk):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
)
response = await component.run_async(chat_messages)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
assert "Hello" in response["replies"][0].text # see openai_mock_chat_completion_chunk
@pytest.mark.asyncio
async def test_run_with_streaming_callback_in_run_method_async(
self, chat_messages, openai_mock_async_chat_completion_chunk
):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async(chat_messages, streaming_callback=streaming_callback)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
assert "Hello" in response["replies"][0].text # see openai_mock_chat_completion_chunk
@pytest.mark.asyncio
async def test_run_with_tools_async(self, tools):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
Choice(
finish_reason="tool_calls",
logprobs=None,
index=0,
message=ChatCompletionMessage(
role="assistant",
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id="123",
type="function",
function=Function(name="weather", arguments='{"city": "Paris"}'),
)
],
),
)
],
created=int(datetime.now().timestamp()),
usage=CompletionUsage(
completion_tokens=40,
prompt_tokens=57,
total_tokens=97,
completion_tokens_details=CompletionTokensDetails(
accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0
),
prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0),
),
)
mock_chat_completion_create.return_value = completion
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), tools=tools, tools_strict=True)
response = await component.run_async([ChatMessage.from_user("What's the weather like in Paris?")])
# ensure that the tools are passed to the OpenAI API
function_spec = {**tools[0].tool_spec}
function_spec["strict"] = True
function_spec["parameters"]["additionalProperties"] = False
assert mock_chat_completion_create.call_args[1]["tools"] == [{"type": "function", "function": function_spec}]
assert len(response["replies"]) == 1
message = response["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert tool_call.arguments == {"city": "Paris"}
assert message.meta["finish_reason"] == "tool_calls"
assert message.meta["usage"]["completion_tokens"] == 40
@pytest.mark.asyncio
async def test_run_with_tools_streaming_async(self, mock_chat_completion_chunk_with_tools, tools):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
response = await component.run_async(chat_messages, tools=tools)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
message = response["replies"][0]
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
assert tool_call.arguments == {"city": "Paris"}
assert message.meta["finish_reason"] == "tool_calls"
@pytest.mark.asyncio
async def test_async_stream_closes_on_cancellation(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
generator = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
api_base_url="test-base-url",
organization="test-organization",
timeout=30,
max_retries=5,
)
# mocked the async stream that will be passed to the _handle_async_stream_response() method
mock_stream = AsyncMock(spec=AsyncStream)
mock_stream.close = AsyncMock()
async def mock_chunk_generator():
for i in range(10):
yield MagicMock(
choices=[
MagicMock(
index=0,
delta=MagicMock(content=f"chunk{i}", role=None, tool_calls=None),
finish_reason=None,
logprobs=None,
)
],
model="gpt-4",
usage=None,
)
await asyncio.sleep(0.005) # delay between chunks
mock_stream.__aiter__ = lambda _: mock_chunk_generator()
received_chunks = []
async def test_callback(chunk: StreamingChunk):
received_chunks.append(chunk)
# the task that will be cancelled
task = asyncio.create_task(generator._handle_async_stream_response(mock_stream, test_callback))
# trigger the task, process a few chunks, then cancel
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_stream.close.assert_awaited_once()
# we received some chunks before cancellation but not all of them
assert len(received_chunks) > 0
assert len(received_chunks) < 10
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_async(self):
component = OpenAIChatGenerator(model="gpt-4.1-nano", generation_kwargs={"n": 1})
chat_messages = [ChatMessage.from_user("What's the capital of France")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert message.meta["model"]
assert message.meta["finish_reason"] == "stop"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.asyncio
async def test_run_with_wrong_model_async(self):
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = OpenAIError("Invalid model name")
generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), model="something-obviously-wrong")
generator.async_client = mock_client
with pytest.raises(OpenAIError):
await generator.run_async([ChatMessage.from_user("irrelevant")])
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_streaming_async(self):
counter = 0
responses = ""
async def callback(chunk: StreamingChunk):
nonlocal counter
nonlocal responses
counter += 1
responses += chunk.content if chunk.content else ""
component = OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={"stream_options": {"include_usage": True}},
streaming_callback=callback,
)
results = await component.run_async([ChatMessage.from_user("What's the capital of France?")])
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert message.meta["model"]
assert message.meta["finish_reason"] == "stop"
assert counter > 1
assert "Paris" in responses
# check that the completion_start_time is set and valid ISO format
assert "completion_start_time" in message.meta
assert datetime.fromisoformat(message.meta["completion_start_time"]) <= datetime.now()
assert isinstance(message.meta["usage"], dict)
assert message.meta["usage"]["prompt_tokens"] > 0
assert message.meta["usage"]["completion_tokens"] > 0
assert message.meta["usage"]["total_tokens"] > 0
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_with_tools_async(self, tools):
component = OpenAIChatGenerator(model="gpt-4.1-nano", tools=tools)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert not message.texts
assert not message.text
assert message.tool_calls
tool_call = message.tool_call
assert isinstance(tool_call, ToolCall)
assert tool_call.tool_name == "weather"
# Check that Paris is in the city argument (case-insensitive, allowing for variations like "Paris, France")
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["finish_reason"] == "tool_calls"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.asyncio
async def test_run_with_wrapped_stream_simulation_async(self, chat_messages, openai_mock_stream_async):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
assert isinstance(chunk, StreamingChunk)
chunk = ChatCompletionChunk(
id="id",
model="gpt-4",
object="chat.completion.chunk",
choices=[chat_completion_chunk.Choice(index=0, delta=chat_completion_chunk.ChoiceDelta(content="Hello"))],
created=int(datetime.now().timestamp()),
)
# Here we wrap the OpenAI async stream in an AsyncMock
# This is to simulate the behavior of some tools like Weave (https://github.com/wandb/weave)
# which wrap the OpenAI async stream in their own stream
wrapped_openai_async_stream = AsyncMock()
wrapped_openai_async_stream.__aiter__.return_value = iter([chunk])
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
await component.warm_up_async()
# Patch the async client's create method
with patch.object(
component.async_client.chat.completions,
"create",
return_value=wrapped_openai_async_stream,
new_callable=AsyncMock,
) as mock_create:
response = await component.run_async(chat_messages, streaming_callback=streaming_callback)
mock_create.assert_called_once()
assert streaming_callback_called
assert "replies" in response
assert "Hello" in response["replies"][0].text
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections.abc import Iterator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import AsyncStream, Stream
from openai.types import Reasoning
from openai.types.chat import ChatCompletion, ChatCompletionChunk, chat_completion_chunk
from openai.types.responses import (
Response,
ResponseOutputItemAddedEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
ResponseReasoningSummaryTextDeltaEvent,
ResponseTextDeltaEvent,
ResponseUsage,
)
from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
@pytest.fixture
def mock_auto_tokenizer():
"""
In the original mock_auto_tokenizer fixture, we were mocking the transformers.AutoTokenizer.from_pretrained
method directly, but we were not providing a return value for this method. Therefore, when from_pretrained
was called within HuggingFaceTGIChatGenerator, it returned None because that's the default behavior of a
MagicMock object when a return value isn't specified.
We will update the mock_auto_tokenizer fixture to return a MagicMock object when from_pretrained is called
in another PR. For now, we will use this fixture to mock the AutoTokenizer.from_pretrained method.
"""
with patch("transformers.AutoTokenizer.from_pretrained", autospec=True) as mock_from_pretrained:
mock_tokenizer = MagicMock()
mock_from_pretrained.return_value = mock_tokenizer
yield mock_tokenizer
class OpenAIMockStream(Stream[ChatCompletionChunk]):
def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
client = client or MagicMock()
super().__init__(client=client, *args, **kwargs) # noqa: B026
self.mock_chunk = mock_chunk
def __stream__(self) -> Iterator[ChatCompletionChunk]:
yield self.mock_chunk
class OpenAIAsyncMockStream(AsyncStream[ChatCompletionChunk]):
def __init__(self, mock_chunk: ChatCompletionChunk):
self.mock_chunk = mock_chunk
def __aiter__(self):
return self
async def __anext__(self):
# Only yield once, then stop iteration
if not hasattr(self, "_done"):
self._done = True
return self.mock_chunk
raise StopAsyncIteration
@pytest.fixture
def openai_mock_stream():
"""
Fixture that returns a function to create MockStream instances with custom chunks
"""
return OpenAIMockStream
@pytest.fixture
def openai_mock_stream_async():
"""
Fixture that returns a function to create AsyncMockStream instances with custom chunks
"""
return OpenAIAsyncMockStream
@pytest.fixture
def openai_mock_chat_completion():
"""
Mock the OpenAI API completion response and reuse it for tests
"""
with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
{
"finish_reason": "stop",
"logprobs": None,
"index": 0,
"message": {"content": "Hello world!", "role": "assistant"},
}
],
created=int(datetime.now().timestamp()),
usage={"prompt_tokens": 57, "completion_tokens": 40, "total_tokens": 97},
)
mock_chat_completion_create.return_value = completion
yield mock_chat_completion_create
@pytest.fixture
async def openai_mock_async_chat_completion():
"""
Mock the OpenAI API completion response and reuse it for async tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
{
"finish_reason": "stop",
"logprobs": None,
"index": 0,
"message": {"content": "Hello world!", "role": "assistant"},
}
],
created=int(datetime.now().timestamp()),
usage={"prompt_tokens": 57, "completion_tokens": 40, "total_tokens": 97},
)
mock_chat_completion_create.return_value = completion
yield mock_chat_completion_create
@pytest.fixture
def openai_mock_chat_completion_chunk():
"""
Mock the OpenAI API completion chunk response and reuse it for tests
"""
with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="stop",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = OpenAIMockStream(
completion, cast_to=None, response=None, client=None
)
yield mock_chat_completion_create
@pytest.fixture
async def openai_mock_async_chat_completion_chunk():
"""
Mock the OpenAI API completion chunk response and reuse it for async tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="stop",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = OpenAIAsyncMockStream(completion)
yield mock_chat_completion_create
@pytest.fixture
def openai_mock_responses():
"""
Mock a fully populated non-streaming Response returned by the
OpenAI Responses API (client.responses.create).
"""
with patch("openai.resources.responses.Responses.create") as mock_create:
# Build the Response object exactly like the one you provided
mock_response = Response(
id="resp_mock_123",
created_at=float(datetime.now().timestamp()),
metadata={},
model="gpt-5-mini-2025-08-07",
object="response",
output=[
ResponseReasoningItem(
id="rs_mock_1",
type="reasoning",
summary=[
Summary(
text=(
"**Providing concise information**\n\n"
"The question is simple: the answer is Paris. "
"Its useful to mention that Paris is the capital and a major "
"city in France. Theres really no need for extra details in this "
"case, so Ill keep it concise and straightforward."
),
type="summary_text",
)
],
),
ResponseOutputMessage(
id="msg_mock_1",
role="assistant",
type="message",
status="completed",
content=[
ResponseOutputText(
text="The capital of France is Paris.", type="output_text", logprobs=None, annotations=[]
)
],
),
],
parallel_tool_calls=True,
temperature=1.0,
tool_choice="auto",
tools=[],
reasoning=Reasoning(effort="low", generate_summary=None, summary="auto"),
usage=ResponseUsage(
input_tokens=11,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens=13,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=24,
),
user=None,
billing={"payer": "developer"},
prompt_cache_retention=None,
store=True,
)
mock_create.return_value = mock_response
yield mock_create
@pytest.fixture
def openai_mock_async_responses():
"""
Mock a fully populated non-streaming Response returned by the
OpenAI Responses API (client.responses.create).
"""
with patch("openai.resources.responses.AsyncResponses.create") as mock_create:
# Build the Response object exactly like the one you provided
mock_response = Response(
id="resp_mock_123",
created_at=float(datetime.now().timestamp()),
metadata={},
model="gpt-5-mini-2025-08-07",
object="response",
output=[
ResponseReasoningItem(
id="rs_mock_1",
type="reasoning",
summary=[
Summary(
text=(
"**Providing concise information**\n\n"
"The question is simple: the answer is Paris. "
"Its useful to mention that Paris is the capital and a major "
"city in France. Theres really no need for extra details in this "
"case, so Ill keep it concise and straightforward."
),
type="summary_text",
)
],
),
ResponseOutputMessage(
id="msg_mock_1",
role="assistant",
type="message",
status="completed",
content=[
ResponseOutputText(
text="The capital of France is Paris.", type="output_text", annotations=[], logprobs=None
)
],
),
],
parallel_tool_calls=True,
temperature=1.0,
tool_choice="auto",
tools=[],
reasoning=Reasoning(effort="low", generate_summary=None, summary="auto"),
usage=ResponseUsage(
input_tokens=11,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens=13,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=24,
),
user=None,
billing={"payer": "developer"},
prompt_cache_retention=None,
store=True,
)
mock_create.return_value = mock_response
yield mock_create
@pytest.fixture
def openai_mock_responses_stream_text_delta():
"""
Mock the Responses API streaming text-delta event (sync)
and reuse it for tests.
"""
with patch("openai.resources.responses.Responses.create") as mock_responses_create:
event = ResponseTextDeltaEvent(
# required fields in the current SDK
content_index=0,
delta="The capital of France is Paris.",
item_id="item_1",
logprobs=[],
output_index=0,
sequence_number=0,
type="response.output_text.delta",
)
# Your OpenAIMockStream should iterate over this event
mock_responses_create.return_value = OpenAIMockStream(event, cast_to=None, response=None, client=None)
yield mock_responses_create
@pytest.fixture
async def openai_mock_async_responses_stream_text_delta():
"""
Mock the Responses API streaming text-delta event (async)
and reuse it for async tests.
"""
with patch("openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock) as mock_responses_create:
event = ResponseTextDeltaEvent(
content_index=0,
delta="Hello",
item_id="item_1",
logprobs=[],
output_index=0,
sequence_number=0,
type="response.output_text.delta",
)
mock_responses_create.return_value = OpenAIAsyncMockStream(event)
yield mock_responses_create
@pytest.fixture
def openai_mock_responses_reasoning_summary_delta():
"""
Mock a Responses API *streaming* reasoning summary text delta event (sync).
"""
with patch("openai.resources.responses.Responses.create") as mock_responses_create:
start_event = ResponseOutputItemAddedEvent(
item=ResponseReasoningItem(
id="rs_094e3f8beffcca02006928978067848190b477543eddbf32b3",
summary=[],
type="reasoning",
content=None,
encrypted_content=None,
status=None,
),
output_index=0,
sequence_number=2,
type="response.output_item.added",
)
event = ResponseReasoningSummaryTextDeltaEvent(
delta="I need to check the capital of France.",
item_id="rs_01e88f7d57f9a2f70069284d2170c48193918c04f85244cf7c",
output_index=0,
sequence_number=4,
summary_index=0,
type="response.reasoning_summary_text.delta",
obfuscation="cGcv5W5F",
)
# Create a custom stream that yields both events sequentially
class MultiEventMockStream(OpenAIMockStream):
def __init__(self, *events, **kwargs):
self.events = events
super().__init__(events[0] if events else None, **kwargs)
def __stream__(self):
yield from self.events
mock_responses_create.return_value = MultiEventMockStream(
start_event, event, cast_to=None, response=None, client=None
)
yield mock_responses_create
@@ -0,0 +1,336 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import base64
import os
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openai import AsyncOpenAI
from openai.types import ImagesResponse
from openai.types.image import Image
import haystack.components.generators.openai_image_generator as openai_image_generator_module
from haystack.components.generators.openai_image_generator import OpenAIImageGenerator
from haystack.utils import Secret
@pytest.fixture
def mock_image_response():
with patch("openai.resources.images.Images.generate") as mock_image_generate:
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
mock_image_generate.return_value = image_response
yield mock_image_generate
class TestOpenAIImageGenerator:
def test_init_default(self, monkeypatch):
component = OpenAIImageGenerator()
assert component.model == "gpt-image-2"
assert component.quality == "auto"
assert component.size == "1024x1024"
assert component.api_key == Secret.from_env_var("OPENAI_API_KEY")
assert component.api_base_url is None
assert component.organization is None
assert component.timeout is None
assert component.max_retries is None
assert component.http_client_kwargs is None
assert component.client is None
assert component.async_client is None
def test_init_with_params(self, monkeypatch):
component = OpenAIImageGenerator(
model="gpt-image-1",
quality="high",
size="1024x1536",
api_key=Secret.from_env_var("EXAMPLE_API_KEY"),
api_base_url="https://api.openai.com",
organization="test-org",
timeout=60,
max_retries=10,
)
assert component.model == "gpt-image-1"
assert component.quality == "high"
assert component.size == "1024x1536"
assert component.api_key == Secret.from_env_var("EXAMPLE_API_KEY")
assert component.api_base_url == "https://api.openai.com"
assert component.organization == "test-org"
assert pytest.approx(component.timeout) == 60.0
assert component.max_retries == 10
assert component.client is None
assert component.async_client is None
def test_init_max_retries_0(self, monkeypatch):
component = OpenAIImageGenerator(max_retries=0)
assert component.max_retries == 0
def test_init_invalid_quality_falls_back_to_auto(self, caplog):
component = OpenAIImageGenerator(quality="hd") # type: ignore[arg-type]
assert component.quality == "auto"
assert "Invalid quality" in caplog.text
def test_init_non_default_response_format_warns(self, caplog):
OpenAIImageGenerator(response_format="url") # type: ignore[arg-type]
assert "response_format is ignored" in caplog.text
def test_to_dict(self):
generator = OpenAIImageGenerator()
data = generator.to_dict()
assert data == {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-2",
"quality": "auto",
"size": "1024x1024",
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"api_base_url": None,
"organization": None,
"http_client_kwargs": None,
},
}
def test_to_dict_with_params(self):
generator = OpenAIImageGenerator(
model="gpt-image-1",
quality="high",
size="1024x1536",
api_key=Secret.from_env_var("EXAMPLE_API_KEY"),
api_base_url="https://api.openai.com",
organization="test-org",
timeout=60,
max_retries=10,
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = generator.to_dict()
assert data == {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-1",
"quality": "high",
"size": "1024x1536",
"api_key": {"type": "env_var", "env_vars": ["EXAMPLE_API_KEY"], "strict": True},
"api_base_url": "https://api.openai.com",
"organization": "test-org",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-2",
"quality": "auto",
"size": "1024x1024",
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"api_base_url": None,
"organization": None,
"http_client_kwargs": None,
},
}
generator = OpenAIImageGenerator.from_dict(data)
assert generator.model == "gpt-image-2"
assert generator.quality == "auto"
assert generator.size == "1024x1024"
assert generator.api_key.to_dict() == {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True}
assert generator.http_client_kwargs is None
def test_from_dict_default_params(self):
data = {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {},
}
generator = OpenAIImageGenerator.from_dict(data)
assert generator.model == "gpt-image-2"
assert generator.quality == "auto"
assert generator.size == "1024x1024"
assert generator.api_key.to_dict() == {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True}
assert generator.api_base_url is None
assert generator.organization is None
assert generator.timeout is None
assert generator.max_retries is None
assert generator.http_client_kwargs is None
def test_run(self, mock_image_response):
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
response = generator.run("Show me a picture of a black cat.")
assert generator.client is not None
assert isinstance(response, dict)
assert "images" in response and "revised_prompt" in response
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.slow
def test_live_run(self):
generator = OpenAIImageGenerator(model="gpt-image-1-mini", size="1024x1024", quality="low")
response = generator.run("A nice cat")
assert isinstance(response, dict)
assert isinstance(response["revised_prompt"], str)
image_str = response["images"][0]
assert isinstance(image_str, str) and image_str
decoded = base64.b64decode(image_str, validate=True)
assert decoded.startswith(b"\x89PNG\r\n\x1a\n")
class TestOpenAIImageGeneratorAsync:
def test_async_client_none_before_warm_up(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIImageGenerator()
assert component.async_client is None
@pytest.mark.asyncio
async def test_async_client_after_warm_up_async(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIImageGenerator()
await component.warm_up_async()
assert isinstance(component.async_client, AsyncOpenAI)
assert component.async_client.api_key == "test-api-key"
@pytest.mark.asyncio
async def test_run_async(self):
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
mock_async_client = Mock()
mock_async_client.images.generate = AsyncMock(return_value=image_response)
generator.async_client = mock_async_client
response = await generator.run_async("Show me a picture of a black cat.")
assert isinstance(response, dict)
assert "images" in response and "revised_prompt" in response
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
mock_async_client.images.generate.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_async_triggers_warm_up(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
generator = OpenAIImageGenerator()
assert generator.async_client is None
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
with patch("openai.resources.images.AsyncImages.generate", new=AsyncMock(return_value=image_response)):
response = await generator.run_async("Show me a picture of a black cat.")
assert isinstance(generator.async_client, AsyncOpenAI)
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.slow
async def test_live_run_async(self):
generator = OpenAIImageGenerator(model="gpt-image-1-mini", size="1024x1024", quality="low")
response = await generator.run_async("A nice cat")
assert isinstance(response, dict)
assert isinstance(response["revised_prompt"], str)
image_str = response["images"][0]
assert isinstance(image_str, str) and image_str
decoded = base64.b64decode(image_str, validate=True)
assert decoded.startswith(b"\x89PNG\r\n\x1a\n")
@pytest.fixture
def mock_openai_clients(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="OpenAI")
async_cls = MagicMock(name="AsyncOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(openai_image_generator_module, "OpenAI", sync_cls)
monkeypatch.setattr(openai_image_generator_module, "AsyncOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
generator = OpenAIImageGenerator()
generator.warm_up()
assert generator.client.max_retries == 5
assert generator.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
generator.warm_up()
assert generator.client.max_retries == 1
assert generator.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"))
generator.warm_up()
assert generator.client.max_retries == 10
assert generator.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
generator = OpenAIImageGenerator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
generator.warm_up()
def test_sync_lifecycle(self, mock_openai_clients):
sync_cls, _ = mock_openai_clients
generator = OpenAIImageGenerator()
assert generator.client is None
assert generator.async_client is None
generator.warm_up()
assert generator.client is sync_cls.return_value
assert generator.async_client is None
generator.close()
sync_cls.return_value.close.assert_called_once()
assert generator.client is None
async def test_async_lifecycle(self, mock_openai_clients):
_, async_cls = mock_openai_clients
generator = OpenAIImageGenerator()
await generator.warm_up_async()
assert generator.async_client is async_cls.return_value
assert generator.client is None
await generator.close_async()
async_cls.return_value.close.assert_awaited_once()
assert generator.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
generator = OpenAIImageGenerator()
generator.close()
await generator.close_async()
assert generator.client is None
assert generator.async_client is None
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
generator = OpenAIImageGenerator()
generator.warm_up()
await generator.warm_up_async()
generator.close()
assert generator.client is None
assert generator.async_client is not None
await generator.close_async()
assert generator.async_client is None
+860
View File
@@ -0,0 +1,860 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import call, patch
import pytest
from openai.types.chat import chat_completion_chunk
from haystack.components.generators.utils import (
_convert_streaming_chunks_to_chat_message,
_normalize_messages,
print_streaming_chunk,
)
from haystack.dataclasses import (
ChatMessage,
ComponentInfo,
ReasoningContent,
StreamingChunk,
ToolCall,
ToolCallDelta,
ToolCallResult,
)
def test_convert_streaming_chunks_to_chat_message_tool_calls_in_any_chunk():
chunks = [
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.910076",
},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="call_ZOj5l67zhZOx6jqjg7ATQwb6",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments="", name="rag_pipeline_tool"
),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.913919",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
tool_calls=[
ToolCallDelta(id="call_ZOj5l67zhZOx6jqjg7ATQwb6", tool_name="rag_pipeline_tool", arguments="", index=0)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='{"qu')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.914439",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='{"qu', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='ery":')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924146",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='ery":', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments=' "Wher')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924420",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments=' "Wher', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="e do")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.944398",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="e do", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="es Ma")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.944958",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="es Ma", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="rk liv")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.945507",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="rk liv", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='e?"}')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946018",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='e?"}', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1,
id="call_STxsYY69wVOvxWqopAt3uWTB",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="", name="get_weather"),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946578",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
start=True,
tool_calls=[
ToolCallDelta(id="call_STxsYY69wVOvxWqopAt3uWTB", tool_name="get_weather", arguments="", index=1)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='{"ci')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946981",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='{"ci', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='ty": ')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947411",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='ty": ', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='"Berli')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947643",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='"Berli', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='n"}')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947939",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='n"}', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": "tool_calls",
"received_at": "2025-02-19T16:02:55.948772",
},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="tool_calls",
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.948772",
"usage": {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
},
component_info=ComponentInfo(name="test", type="test"),
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 2
assert result.tool_calls[0].id == "call_ZOj5l67zhZOx6jqjg7ATQwb6"
assert result.tool_calls[0].tool_name == "rag_pipeline_tool"
assert result.tool_calls[0].arguments == {"query": "Where does Mark live?"}
assert result.tool_calls[1].id == "call_STxsYY69wVOvxWqopAt3uWTB"
assert result.tool_calls[1].tool_name == "get_weather"
assert result.tool_calls[1].arguments == {"city": "Berlin"}
# Verify meta information
assert result.meta["model"] == "gpt-4o-mini-2024-07-18"
assert result.meta["finish_reason"] == "tool_calls"
assert result.meta["index"] == 0
assert result.meta["completion_start_time"] == "2025-02-19T16:02:55.910076"
assert result.meta["usage"] == {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
}
def test_convert_streaming_chunk_to_chat_message_two_tool_calls_in_same_chunk():
chunks = [
StreamingChunk(
content="",
meta={
"model": "mistral-small-latest",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"usage": None,
},
component_info=ComponentInfo(
type="haystack_integrations.components.generators.mistral.chat.chat_generator.MistralChatGenerator",
name=None,
),
),
StreamingChunk(
content="",
meta={
"model": "mistral-small-latest",
"index": 0,
"finish_reason": "tool_calls",
"usage": {
"completion_tokens": 35,
"prompt_tokens": 77,
"total_tokens": 112,
"completion_tokens_details": None,
"prompt_tokens_details": None,
},
},
component_info=ComponentInfo(
type="haystack_integrations.components.generators.mistral.chat.chat_generator.MistralChatGenerator",
name=None,
),
index=0,
tool_calls=[
ToolCallDelta(index=0, tool_name="weather", arguments='{"city": "Paris"}', id="FL1FFlqUG"),
ToolCallDelta(index=1, tool_name="weather", arguments='{"city": "Berlin"}', id="xSuhp66iB"),
],
start=True,
finish_reason="tool_calls",
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 2
assert result.tool_calls[0].id == "FL1FFlqUG"
assert result.tool_calls[0].tool_name == "weather"
assert result.tool_calls[0].arguments == {"city": "Paris"}
assert result.tool_calls[1].id == "xSuhp66iB"
assert result.tool_calls[1].tool_name == "weather"
assert result.tool_calls[1].arguments == {"city": "Berlin"}
def test_convert_streaming_chunk_to_chat_message_empty_tool_call_delta():
chunks = [
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.910076",
},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="call_ZOj5l67zhZOx6jqjg7ATQwb6",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments='{"query":', name="rag_pipeline_tool"
),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.913919",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
tool_calls=[
ToolCallDelta(
id="call_ZOj5l67zhZOx6jqjg7ATQwb6", tool_name="rag_pipeline_tool", arguments='{"query":', index=0
)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments=' "Where does Mark live?"}'
),
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924420",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments=' "Where does Mark live?"}', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction()
)
],
"finish_reason": "tool_calls",
"received_at": "2025-02-19T16:02:55.948772",
},
tool_calls=[ToolCallDelta(index=0)],
component_info=ComponentInfo(name="test", type="test"),
finish_reason="tool_calls",
index=0,
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.948772",
"usage": {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
},
component_info=ComponentInfo(name="test", type="test"),
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "call_ZOj5l67zhZOx6jqjg7ATQwb6"
assert result.tool_calls[0].tool_name == "rag_pipeline_tool"
assert result.tool_calls[0].arguments == {"query": "Where does Mark live?"}
assert result.meta["finish_reason"] == "tool_calls"
def test_convert_streaming_chunk_to_chat_message_with_empty_tool_call_arguments():
chunks = [
# Message start with input tokens
StreamingChunk(
content="",
meta={
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [],
"model": "claude-sonnet-4-20250514",
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 25, "output_tokens": 0},
},
},
index=0,
tool_calls=[],
tool_call_result=None,
start=True,
finish_reason=None,
),
# Initial text content
StreamingChunk(
content="",
meta={"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
index=1,
tool_calls=[],
tool_call_result=None,
start=True,
finish_reason=None,
),
StreamingChunk(
content="Let me check",
meta={"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Let me check"}},
index=2,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason=None,
),
StreamingChunk(
content=" the weather",
meta={"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " the weather"}},
index=3,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason=None,
),
# Tool use content
StreamingChunk(
content="",
meta={
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_123", "name": "weather", "input": {}},
},
index=5,
tool_calls=[ToolCallDelta(index=1, id="toolu_123", tool_name="weather", arguments=None)],
tool_call_result=None,
start=True,
finish_reason=None,
),
StreamingChunk(
content="",
meta={"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": ""}},
index=7,
tool_calls=[ToolCallDelta(index=1, id=None, tool_name=None, arguments="")],
tool_call_result=None,
start=False,
finish_reason=None,
),
# Final message delta
StreamingChunk(
content="",
meta={
"type": "message_delta",
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
"usage": {"completion_tokens": 40},
},
index=8,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason="tool_calls",
),
]
message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert message.texts == ["Let me check the weather"]
assert len(message.tool_calls) == 1
assert message.tool_calls[0].arguments == {}
assert message.tool_calls[0].id == "toolu_123"
assert message.tool_calls[0].tool_name == "weather"
def test_print_streaming_chunk_content_only():
chunk = StreamingChunk(
content="Hello, world!",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [call("[ASSISTANT]\n", flush=True, end=""), call("Hello, world!", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_tool_call():
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
index=0,
tool_calls=[ToolCallDelta(id="call_123", tool_name="test_tool", arguments='{"param": "value"}', index=0)],
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[TOOL CALL]\nTool: test_tool \nArguments: ", flush=True, end=""),
call('{"param": "value"}', flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_tool_call_result():
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_call_result=ToolCallResult(
result="Tool execution completed successfully",
origin=ToolCall(id="call_123", tool_name="test_tool", arguments={}),
error=False,
),
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [call("[TOOL RESULT]\nTool execution completed successfully", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_with_finish_reason():
chunk = StreamingChunk(
content="Final content.",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
finish_reason="stop",
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[ASSISTANT]\n", flush=True, end=""),
call("Final content.", flush=True, end=""),
call("\n\n", flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_empty_chunk():
chunk = StreamingChunk(
content="", meta={"model": "test-model"}, component_info=ComponentInfo(name="test", type="test")
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
mock_print.assert_not_called()
def test_convert_streaming_chunks_to_chat_message_usage_not_in_last_chunk():
"""
Test that usage info is correctly extracted even when it's not in the last chunk.
This can happen with some API providers like Qwen3 where usage info may be returned
in a different chunk than the final one.
"""
chunks = [
StreamingChunk(
content="",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.000000"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="Hello",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.100000"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
),
StreamingChunk(
content=" world",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.200000"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
),
# Chunk with usage info (not the last chunk)
StreamingChunk(
content="",
meta={
"model": "qwen-plus",
"received_at": "2025-01-01T00:00:00.300000",
"usage": {"completion_tokens": 10, "prompt_tokens": 20, "total_tokens": 30},
},
component_info=ComponentInfo(name="test", type="test"),
index=None,
),
# Final chunk with finish_reason but no usage (simulating Qwen3 behavior)
StreamingChunk(
content="",
meta={
"model": "qwen-plus",
"index": 0,
"finish_reason": "stop",
"received_at": "2025-01-01T00:00:00.400000",
"usage": None, # No usage info in final chunk
},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Hello world"
assert result.meta["model"] == "qwen-plus"
assert result.meta["finish_reason"] == "stop"
# Usage should be extracted from the chunk that has it, not just the last chunk
assert result.meta["usage"] == {"completion_tokens": 10, "prompt_tokens": 20, "total_tokens": 30}
def test_convert_streaming_chunks_to_chat_message_with_reasoning():
"""Test that reasoning content is correctly accumulated from streaming chunks."""
chunks = [
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:00"},
component_info=ComponentInfo(name="test", type="test"),
reasoning=ReasoningContent(reasoning_text="Let me think about this..."),
index=0,
),
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:01"},
component_info=ComponentInfo(name="test", type="test"),
reasoning=ReasoningContent(reasoning_text=" The capital of France is Paris."),
index=0,
),
StreamingChunk(
content="Paris",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:02"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:03"},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Paris"
assert result.reasoning is not None
assert isinstance(result.reasoning, ReasoningContent)
assert result.reasoning.reasoning_text == "Let me think about this... The capital of France is Paris."
assert result.meta["finish_reason"] == "stop"
def test_convert_streaming_chunks_to_chat_message_without_reasoning():
"""Test that messages without reasoning work correctly (backward compatibility)."""
chunks = [
StreamingChunk(
content="Hello",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:00"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content=" world",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:01"},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Hello world"
assert result.reasoning is None
def test_print_streaming_chunk_with_reasoning():
"""Test that print_streaming_chunk handles reasoning content correctly."""
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
reasoning=ReasoningContent(reasoning_text="I am thinking about this question."),
index=0,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[REASONING]\n", flush=True, end=""),
call("I am thinking about this question.", flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_with_reasoning_continuation():
"""Test that print_streaming_chunk handles reasoning continuation correctly."""
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=False, # Not the first chunk
reasoning=ReasoningContent(reasoning_text="continued reasoning..."),
index=0,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
# Should only print the reasoning text without the header since it's a continuation
expected_calls = [call("continued reasoning...", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_normalize_messages():
assert _normalize_messages("Hello") == [ChatMessage.from_user("Hello")]
assert _normalize_messages([ChatMessage.from_user("World")]) == [ChatMessage.from_user("World")]
with pytest.raises(TypeError):
_normalize_messages(123)
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,130 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document
from haystack.components.joiners.answer_joiner import AnswerJoiner, JoinMode
from haystack.dataclasses.answer import ExtractedAnswer, GeneratedAnswer
class TestAnswerJoiner:
def test_init(self):
joiner = AnswerJoiner()
assert joiner.join_mode == JoinMode.CONCATENATE
assert joiner.top_k is None
assert joiner.sort_by_score is False
def test_init_with_custom_parameters(self):
joiner = AnswerJoiner(join_mode="concatenate", top_k=5, sort_by_score=True)
assert joiner.join_mode == JoinMode.CONCATENATE
assert joiner.top_k == 5
assert joiner.sort_by_score is True
def test_to_dict(self):
joiner = AnswerJoiner()
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.answer_joiner.AnswerJoiner",
"init_parameters": {"join_mode": "concatenate", "top_k": None, "sort_by_score": False},
}
def test_to_from_dict_custom_parameters(self):
joiner = AnswerJoiner("concatenate", top_k=5, sort_by_score=True)
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.answer_joiner.AnswerJoiner",
"init_parameters": {"join_mode": "concatenate", "top_k": 5, "sort_by_score": True},
}
deserialized_joiner = AnswerJoiner.from_dict(data)
assert deserialized_joiner.join_mode == JoinMode.CONCATENATE
assert deserialized_joiner.top_k == 5
assert deserialized_joiner.sort_by_score is True
def test_from_dict(self):
data = {"type": "haystack.components.joiners.answer_joiner.AnswerJoiner", "init_parameters": {}}
answer_joiner = AnswerJoiner.from_dict(data)
assert answer_joiner.join_mode == JoinMode.CONCATENATE
assert answer_joiner.top_k is None
assert answer_joiner.sort_by_score is False
def test_from_dict_customs_parameters(self):
data = {
"type": "haystack.components.joiners.answer_joiner.AnswerJoiner",
"init_parameters": {"join_mode": "concatenate", "top_k": 5, "sort_by_score": True},
}
answer_joiner = AnswerJoiner.from_dict(data)
assert answer_joiner.join_mode == JoinMode.CONCATENATE
assert answer_joiner.top_k == 5
assert answer_joiner.sort_by_score is True
def test_empty_list(self):
joiner = AnswerJoiner()
result = joiner.run([])
assert result == {"answers": []}
def test_list_of_empty_lists(self):
joiner = AnswerJoiner()
result = joiner.run([[], []])
assert result == {"answers": []}
def test_list_of_single_answer(self):
joiner = AnswerJoiner()
answers = [
GeneratedAnswer(query="a", data="a", meta={}, documents=[Document(content="a")]),
GeneratedAnswer(query="b", data="b", meta={}, documents=[Document(content="b")]),
GeneratedAnswer(query="c", data="c", meta={}, documents=[Document(content="c")]),
]
result = joiner.run([answers])
assert result == {"answers": answers}
def test_two_lists_of_generated_answers(self):
joiner = AnswerJoiner()
answers1 = [GeneratedAnswer(query="a", data="a", meta={}, documents=[Document(content="a")])]
answers2 = [GeneratedAnswer(query="d", data="d", meta={}, documents=[Document(content="d")])]
result = joiner.run([answers1, answers2])
assert result == {"answers": answers1 + answers2}
def test_multiple_lists_of_mixed_answers(self):
joiner = AnswerJoiner()
answers1 = [GeneratedAnswer(query="a", data="a", meta={}, documents=[Document(content="a")])]
answers2 = [ExtractedAnswer(query="d", score=0.9, meta={}, document=Document(content="d"))]
answers3 = [GeneratedAnswer(query="f", data="f", meta={}, documents=[Document(content="f")])]
all_answers = answers1 + answers2 + answers3 # type: ignore
result = joiner.run([answers1, answers2, answers3])
assert result == {"answers": all_answers}
def test_unsupported_join_mode(self):
unsupported_mode = "unsupported_mode"
with pytest.raises(ValueError):
AnswerJoiner(join_mode=unsupported_mode)
def test_sort_by_score(self):
joiner = AnswerJoiner(sort_by_score=True)
answers1 = [ExtractedAnswer(query="a", score=0.3, meta={}, document=Document(content="a"))]
answers2 = [ExtractedAnswer(query="b", score=0.9, meta={}, document=Document(content="b"))]
result = joiner.run([answers1, answers2])
scores = [answer.score for answer in result["answers"]]
assert scores == [0.9, 0.3]
def test_sort_by_score_with_none_score(self):
# The docstring promises that an answer with no score is handled as if its score is -infinity.
# ExtractedAnswer with score=None must not raise a TypeError during sorting and must be sorted last.
joiner = AnswerJoiner(sort_by_score=True)
answers1 = [ExtractedAnswer(query="a", score=0.5, meta={}, document=Document(content="a"))]
answers2 = [ExtractedAnswer(query="b", score=None, meta={}, document=Document(content="b"))] # type: ignore[arg-type]
result = joiner.run([answers1, answers2])
assert [answer.data for answer in result["answers"]] == [None, None]
assert [answer.score for answer in result["answers"]] == [0.5, None]
def test_sort_by_score_with_answers_missing_score_attribute(self):
# GeneratedAnswer has no score attribute at all; it must be handled as -infinity and sorted last.
joiner = AnswerJoiner(sort_by_score=True)
answers1 = [GeneratedAnswer(query="a", data="a", meta={}, documents=[Document(content="a")])]
answers2 = [ExtractedAnswer(query="b", score=0.9, meta={}, document=Document(content="b"))]
result = joiner.run([answers1, answers2])
# The ExtractedAnswer (score 0.9) comes first, the GeneratedAnswer (no score) comes last.
assert isinstance(result["answers"][0], ExtractedAnswer)
assert isinstance(result["answers"][1], GeneratedAnswer)
@@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.joiners import BranchJoiner
class TestBranchJoiner:
def test_one_value(self):
joiner = BranchJoiner(int)
output = joiner.run(value=[2])
assert output == {"value": 2}
def test_one_value_of_wrong_type(self):
# BranchJoiner does not type check the input
joiner = BranchJoiner(int)
output = joiner.run(value=["hello"])
assert output == {"value": "hello"}
def test_one_value_of_none_type(self):
# BranchJoiner does not type check the input
joiner = BranchJoiner(int)
output = joiner.run(value=[None])
assert output == {"value": None}
def test_more_values_of_expected_type(self):
joiner = BranchJoiner(int)
with pytest.raises(ValueError, match="BranchJoiner expects only one input, but 3 were received."):
joiner.run(value=[2, 3, 4])
def test_no_values(self):
joiner = BranchJoiner(int)
with pytest.raises(ValueError, match="BranchJoiner expects only one input, but 0 were received."):
joiner.run(value=[])
@@ -0,0 +1,310 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import re
import pytest
from haystack import Document
from haystack.components.joiners.document_joiner import DocumentJoiner, JoinMode
class TestDocumentJoiner:
def test_init(self):
joiner = DocumentJoiner()
assert joiner.join_mode == JoinMode.CONCATENATE
assert joiner.weights is None
assert joiner.top_k is None
assert joiner.sort_by_score
def test_init_with_custom_parameters(self):
joiner = DocumentJoiner(join_mode="merge", weights=[0.4, 0.6], top_k=5, sort_by_score=False)
assert joiner.join_mode == JoinMode.MERGE
assert joiner.weights == [0.4, 0.6]
assert joiner.top_k == 5
assert not joiner.sort_by_score
def test_init_with_zero_sum_weights_raises(self):
# weights that sum to zero would divide by zero during normalization
with pytest.raises(ValueError, match="must not sum to zero"):
DocumentJoiner(join_mode="merge", weights=[0.0, 0.0, 0.0])
def test_to_dict(self):
joiner = DocumentJoiner()
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.document_joiner.DocumentJoiner",
"init_parameters": {"join_mode": "concatenate", "sort_by_score": True, "top_k": None, "weights": None},
}
def test_to_dict_custom_parameters(self):
joiner = DocumentJoiner("merge", weights=[0.4, 0.6], top_k=4, sort_by_score=False)
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.document_joiner.DocumentJoiner",
"init_parameters": {"join_mode": "merge", "weights": [0.4, 0.6], "top_k": 4, "sort_by_score": False},
}
def test_from_dict(self):
data = {"type": "haystack.components.joiners.document_joiner.DocumentJoiner", "init_parameters": {}}
document_joiner = DocumentJoiner.from_dict(data)
assert document_joiner.join_mode == JoinMode.CONCATENATE
assert document_joiner.weights is None
assert document_joiner.top_k is None
assert document_joiner.sort_by_score
def test_from_dict_customs_parameters(self):
data = {
"type": "haystack.components.joiners.document_joiner.DocumentJoiner",
"init_parameters": {"join_mode": "merge", "weights": [0.5, 0.6], "top_k": 6, "sort_by_score": False},
}
document_joiner = DocumentJoiner.from_dict(data)
assert document_joiner.join_mode == JoinMode.MERGE
assert document_joiner.weights == pytest.approx([0.5, 0.6], rel=0.1)
assert document_joiner.top_k == 6
assert not document_joiner.sort_by_score
@pytest.mark.parametrize(
"join_mode",
[
JoinMode.CONCATENATE,
JoinMode.MERGE,
JoinMode.RECIPROCAL_RANK_FUSION,
JoinMode.DISTRIBUTION_BASED_RANK_FUSION,
],
)
def test_empty_list(self, join_mode: JoinMode):
joiner = DocumentJoiner(join_mode=join_mode)
result = joiner.run([])
assert result == {"documents": []}
@pytest.mark.parametrize(
"join_mode",
[
JoinMode.CONCATENATE,
JoinMode.MERGE,
JoinMode.RECIPROCAL_RANK_FUSION,
JoinMode.DISTRIBUTION_BASED_RANK_FUSION,
],
)
def test_list_of_empty_lists(self, join_mode: JoinMode):
joiner = DocumentJoiner(join_mode=join_mode)
result = joiner.run([[], []])
assert result == {"documents": []}
@pytest.mark.parametrize(
"join_mode",
[
JoinMode.CONCATENATE,
JoinMode.MERGE,
JoinMode.RECIPROCAL_RANK_FUSION,
JoinMode.DISTRIBUTION_BASED_RANK_FUSION,
],
)
def test_list_with_one_empty_list(self, join_mode: JoinMode):
joiner = DocumentJoiner(join_mode=join_mode)
documents = [Document(content="a"), Document(content="b"), Document(content="c")]
result = joiner.run([[], documents])
# Verify the same documents are returned (scoring functions assign scores to the results;
# compare by ID to avoid relying on in-place score mutation of the input list).
result_ids = {doc.id for doc in result["documents"]}
expected_ids = {doc.id for doc in documents}
assert result_ids == expected_ids
def test_unsupported_join_mode(self):
unsupported_mode = "unsupported_mode"
expected_error_pattern = (
re.escape(f"Unknown join mode '{unsupported_mode}'") + r".*Supported modes in DocumentJoiner are: \[.*\]"
)
with pytest.raises(ValueError, match=expected_error_pattern):
DocumentJoiner(join_mode=unsupported_mode)
def test_run_with_concatenate_join_mode_and_top_k(self):
joiner = DocumentJoiner(top_k=6)
documents_1 = [Document(content="a"), Document(content="b"), Document(content="c")]
documents_2 = [
Document(content="d"),
Document(content="e"),
Document(content="f", meta={"key": "value"}),
Document(content="g"),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 6
assert sorted(documents_1 + documents_2[:-1], key=lambda d: d.id) == sorted(
output["documents"], key=lambda d: d.id
)
def test_run_with_concatenate_join_mode_and_duplicate_documents(self):
joiner = DocumentJoiner()
documents_1 = [Document(content="a", score=0.3), Document(content="b"), Document(content="c")]
documents_2 = [
Document(content="a", score=0.2),
Document(content="a"),
Document(content="f", meta={"key": "value"}),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 4
assert sorted(documents_1 + [documents_2[-1]], key=lambda d: d.id) == sorted(
output["documents"], key=lambda d: d.id
)
def test_run_with_concatenate_join_mode_keeps_zero_score_over_negative_duplicate(self):
joiner = DocumentJoiner(sort_by_score=False)
documents_1 = [Document(content="a", score=0.0)]
documents_2 = [Document(content="a", score=-0.5)]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 1
assert output["documents"][0].score == 0.0
def test_run_with_concatenate_join_mode_keeps_zero_score_over_none_duplicate(self):
joiner = DocumentJoiner(sort_by_score=False)
documents_1 = [Document(content="a", score=0.0)]
documents_2 = [Document(content="a")]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 1
assert output["documents"][0].score == 0.0
def test_run_with_merge_join_mode_handles_zero_score(self):
joiner = DocumentJoiner(join_mode="merge", weights=[0.5, 0.5])
documents_1 = [Document(content="a", score=0.0)]
documents_2 = [Document(content="a", score=0.0)]
output = joiner.run([documents_1, documents_2])
assert output["documents"][0].score == 0.0
def test_run_with_merge_join_mode(self):
joiner = DocumentJoiner(join_mode="merge", weights=[1.5, 0.5])
documents_1 = [Document(content="a", score=1.0), Document(content="b", score=2.0)]
documents_2 = [
Document(content="a", score=0.5),
Document(content="b", score=3.0),
Document(content="f", score=4.0, meta={"key": "value"}),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 3
expected_document_ids = [
doc.id
for doc in [
Document(content="a", score=1.25),
Document(content="b", score=2.25),
Document(content="f", score=4.0, meta={"key": "value"}),
]
]
assert all(doc.id in expected_document_ids for doc in output["documents"])
def test_run_with_reciprocal_rank_fusion_join_mode(self):
joiner = DocumentJoiner(join_mode="reciprocal_rank_fusion")
documents_1 = [Document(content="a"), Document(content="b"), Document(content="c")]
documents_2 = [
Document(content="b", score=1000.0),
Document(content="c"),
Document(content="a"),
Document(content="f", meta={"key": "value"}),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 4
expected_document_ids = [
doc.id
for doc in [
Document(content="b"),
Document(content="a"),
Document(content="c"),
Document(content="f", meta={"key": "value"}),
]
]
assert all(doc.id in expected_document_ids for doc in output["documents"])
def test_run_with_distribution_based_rank_fusion_join_mode(self):
joiner = DocumentJoiner(join_mode="distribution_based_rank_fusion")
documents_1 = [
Document(content="a", score=0.6),
Document(content="b", score=0.2),
Document(content="c", score=0.5),
]
documents_2 = [
Document(content="d", score=0.5),
Document(content="e", score=0.8),
Document(content="f", score=1.1, meta={"key": "value"}),
Document(content="g", score=0.3),
Document(content="a", score=0.3),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 7
expected_document_ids = [
doc.id
for doc in [
Document(content="a", score=0.66),
Document(content="b", score=0.27),
Document(content="c", score=0.56),
Document(content="d", score=0.44),
Document(content="e", score=0.60),
Document(content="f", score=0.76, meta={"key": "value"}),
Document(content="g", score=0.33),
]
]
assert all(doc.id in expected_document_ids for doc in output["documents"])
def test_run_with_distribution_based_rank_fusion_join_mode_same_scores(self):
joiner = DocumentJoiner(join_mode="distribution_based_rank_fusion")
documents_1 = [
Document(content="a", score=0.2),
Document(content="b", score=0.2),
Document(content="c", score=0.2),
]
documents_2 = [
Document(content="d", score=0.5),
Document(content="e", score=0.8),
Document(content="f", score=1.1, meta={"key": "value"}),
Document(content="g", score=0.3),
Document(content="a", score=0.3),
]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 7
expected_document_ids = [
doc.id
for doc in [
Document(content="a", score=0),
Document(content="b", score=0),
Document(content="c", score=0),
Document(content="d", score=0.44),
Document(content="e", score=0.60),
Document(content="f", score=0.76, meta={"key": "value"}),
Document(content="g", score=0.33),
]
]
assert all(doc.id in expected_document_ids for doc in output["documents"])
def test_run_with_distribution_based_rank_fusion_join_mode_with_none_score(self):
# Documents with score=None (e.g. from a non-scoring source) must not crash DBSF;
# a missing score is treated as 0, consistent with how the statistics are computed.
joiner = DocumentJoiner(join_mode="distribution_based_rank_fusion")
documents_1 = [Document(content="a", score=0.6), Document(content="b", score=None)]
documents_2 = [Document(content="c", score=0.5), Document(content="d", score=0.3)]
output = joiner.run([documents_1, documents_2])
assert len(output["documents"]) == 4
assert all(doc.score is not None for doc in output["documents"])
def test_run_with_top_k_in_run_method(self):
joiner = DocumentJoiner()
documents_1 = [Document(content="a"), Document(content="b"), Document(content="c")]
documents_2 = [Document(content="d"), Document(content="e"), Document(content="f")]
top_k = 4
output = joiner.run([documents_1, documents_2], top_k=top_k)
assert len(output["documents"]) == top_k
def test_sort_by_score_without_scores(self, caplog):
joiner = DocumentJoiner()
with caplog.at_level(logging.INFO):
documents = [Document(content="a"), Document(content="b", score=0.5)]
output = joiner.run([documents])
assert "those with score=None were sorted as if they had a score of -infinity" in caplog.text
assert output["documents"] == documents[::-1]
def test_output_documents_not_sorted_by_score(self):
joiner = DocumentJoiner(sort_by_score=False)
documents_1 = [Document(content="a", score=0.1)]
documents_2 = [Document(content="d", score=0.2)]
output = joiner.run([documents_1, documents_2])
assert output["documents"] == documents_1 + documents_2
+172
View File
@@ -0,0 +1,172 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import List
import pytest
from haystack import Document, Pipeline
from haystack.components.builders import AnswerBuilder, ChatPromptBuilder
from haystack.components.embedders import OpenAITextEmbedder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.joiners.list_joiner import ListJoiner
from haystack.core.errors import PipelineConnectError
from haystack.dataclasses import ChatMessage
from haystack.dataclasses.answer import GeneratedAnswer
from haystack.utils.auth import Secret
class TestListJoiner:
def test_init(self):
joiner = ListJoiner(list[ChatMessage])
assert isinstance(joiner, ListJoiner)
assert joiner.list_type_ == list[ChatMessage]
def test_init_typing_list(self):
joiner = ListJoiner(List[ChatMessage])
assert isinstance(joiner, ListJoiner)
assert joiner.list_type_ == List[ChatMessage]
def test_to_dict_defaults(self):
joiner = ListJoiner()
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.list_joiner.ListJoiner",
"init_parameters": {"list_type_": None},
}
def test_to_dict_non_default(self):
joiner = ListJoiner(list[ChatMessage])
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.list_joiner.ListJoiner",
"init_parameters": {"list_type_": "list[haystack.dataclasses.chat_message.ChatMessage]"},
}
def test_to_dict_non_default_typing_list(self):
joiner = ListJoiner(List[ChatMessage])
data = joiner.to_dict()
assert data == {
"type": "haystack.components.joiners.list_joiner.ListJoiner",
"init_parameters": {"list_type_": "typing.List[haystack.dataclasses.chat_message.ChatMessage]"},
}
def test_from_dict_default(self):
data = {"type": "haystack.components.joiners.list_joiner.ListJoiner", "init_parameters": {"list_type_": None}}
list_joiner = ListJoiner.from_dict(data)
assert isinstance(list_joiner, ListJoiner)
assert list_joiner.list_type_ is None
def test_from_dict_non_default(self):
data = {
"type": "haystack.components.joiners.list_joiner.ListJoiner",
"init_parameters": {"list_type_": "list[haystack.dataclasses.chat_message.ChatMessage]"},
}
list_joiner = ListJoiner.from_dict(data)
assert isinstance(list_joiner, ListJoiner)
assert list_joiner.list_type_ == list[ChatMessage]
def test_from_dict_non_default_typing_list(self):
data = {
"type": "haystack.components.joiners.list_joiner.ListJoiner",
"init_parameters": {"list_type_": "typing.List[haystack.dataclasses.chat_message.ChatMessage]"},
}
list_joiner = ListJoiner.from_dict(data)
assert isinstance(list_joiner, ListJoiner)
assert list_joiner.list_type_ == List[ChatMessage]
def test_empty_list(self):
joiner = ListJoiner(list[ChatMessage])
result = joiner.run([])
assert result == {"values": []}
def test_list_of_empty_lists(self):
joiner = ListJoiner(list[ChatMessage])
result = joiner.run([[], []])
assert result == {"values": []}
def test_single_list_of_chat_messages(self):
joiner = ListJoiner(list[ChatMessage])
messages = [ChatMessage.from_user("Hello"), ChatMessage.from_assistant("Hi there")]
result = joiner.run([messages])
assert result == {"values": messages}
def test_multiple_lists_of_chat_messages(self):
joiner = ListJoiner(list[ChatMessage])
messages1 = [ChatMessage.from_user("Hello")]
messages2 = [ChatMessage.from_assistant("Hi there")]
messages3 = [ChatMessage.from_system("System message")]
result = joiner.run([messages1, messages2, messages3])
assert result == {"values": messages1 + messages2 + messages3}
def test_list_of_generated_answers(self):
joiner = ListJoiner(list[GeneratedAnswer])
answers1 = [GeneratedAnswer(query="q1", data="a1", meta={}, documents=[Document(content="d1")])]
answers2 = [GeneratedAnswer(query="q2", data="a2", meta={}, documents=[Document(content="d2")])]
result = joiner.run([answers1, answers2])
assert result == {"values": answers1 + answers2}
def test_list_two_different_types(self):
joiner = ListJoiner()
result = joiner.run([["a", "b"], [1, 2]])
assert result == {"values": ["a", "b", 1, 2]}
def test_mixed_empty_and_non_empty_lists(self):
joiner = ListJoiner(list[ChatMessage])
messages = [ChatMessage.from_user("Hello")]
result = joiner.run([messages, [], messages])
assert result == {"values": messages + messages}
def test_pipeline_connection_validation(self):
joiner = ListJoiner()
llm = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
pipe = Pipeline()
pipe.add_component("joiner", joiner)
pipe.add_component("llm", llm)
with pytest.raises(PipelineConnectError):
pipe.connect("joiner.values", "llm.messages")
assert pipe is not None
def test_pipeline_connection_validation_list_chatmessage(self):
joiner = ListJoiner(list[ChatMessage])
llm = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
pipe = Pipeline()
pipe.add_component("joiner", joiner)
pipe.add_component("llm", llm)
pipe.connect("joiner", "llm.messages")
assert pipe is not None
def test_pipeline_bad_connection(self):
with pytest.raises(PipelineConnectError):
joiner = ListJoiner()
query_embedder = OpenAITextEmbedder(api_key=Secret.from_token("test-api-key"))
pipe = Pipeline()
pipe.add_component("joiner", joiner)
pipe.add_component("query_embedder", query_embedder)
pipe.connect("joiner.values", "query_embedder.text")
def test_pipeline_bad_connection_different_list_types(self):
with pytest.raises(PipelineConnectError):
joiner = ListJoiner(list[int])
llm = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
pipe = Pipeline()
pipe.add_component("joiner", joiner)
pipe.add_component("llm", llm)
pipe.connect("joiner.values", "llm.messages")
def test_result_two_different_types(self):
pipe = Pipeline()
pipe.add_component("answer_builder", AnswerBuilder())
pipe.add_component("chat_prompt_builder", ChatPromptBuilder())
pipe.add_component("joiner", ListJoiner())
pipe.connect("answer_builder", "joiner.values")
pipe.connect("chat_prompt_builder", "joiner.values")
result = pipe.run(
data={
"answer_builder": {"query": "What is nuclear physics?", "replies": ["This is an answer."]},
"chat_prompt_builder": {"template": [ChatMessage.from_user("Hello")]},
}
)
assert isinstance(result["joiner"]["values"][0], GeneratedAnswer)
assert isinstance(result["joiner"]["values"][1], ChatMessage)
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack.components.joiners.string_joiner import StringJoiner
from haystack.core.serialization import component_from_dict, component_to_dict
class TestStringJoiner:
def test_init(self):
joiner = StringJoiner()
assert isinstance(joiner, StringJoiner)
def test_to_dict(self):
joiner = StringJoiner()
data = component_to_dict(joiner, name="string_joiner")
assert data == {"type": "haystack.components.joiners.string_joiner.StringJoiner", "init_parameters": {}}
def test_from_dict(self):
data = {"type": "haystack.components.joiners.string_joiner.StringJoiner", "init_parameters": {}}
string_joiner = component_from_dict(StringJoiner, data=data, name="string_joiner")
assert isinstance(string_joiner, StringJoiner)
def test_empty_list(self):
joiner = StringJoiner()
result = joiner.run([])
assert result == {"strings": []}
def test_single_string(self):
joiner = StringJoiner()
result = joiner.run("a")
assert result == {"strings": ["a"]}
def test_two_strings(self):
joiner = StringJoiner()
result = joiner.run(["a", "b"])
assert result == {"strings": ["a", "b"]}
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,220 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack import Document
from haystack.components.preprocessors.csv_document_cleaner import CSVDocumentCleaner
def test_empty_column() -> None:
csv_content = """,A,B,C
,1,2,3
,4,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "A,B,C\n1,2,3\n4,5,6\n"
def test_empty_row() -> None:
csv_content = """A,B,C
1,2,3
,,
4,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "A,B,C\n1,2,3\n4,5,6\n"
def test_empty_column_and_row() -> None:
csv_content = """,A,B,C
,1,2,3
,,,
,4,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "A,B,C\n1,2,3\n4,5,6\n"
def test_ignore_rows() -> None:
csv_content = """,,
A,B,C
4,5,6
7,8,9
"""
csv_document = Document(content=csv_content, meta={"name": "test.csv"})
csv_document_cleaner = CSVDocumentCleaner(ignore_rows=1)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",,\nA,B,C\n4,5,6\n7,8,9\n"
assert cleaned_document.meta == {"name": "test.csv"}
def test_ignore_rows_2() -> None:
csv_content = """A,B,C
,,
4,5,6
7,8,9
"""
csv_document = Document(content=csv_content, meta={"name": "test.csv"})
csv_document_cleaner = CSVDocumentCleaner(ignore_rows=1)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "A,B,C\n4,5,6\n7,8,9\n"
assert cleaned_document.meta == {"name": "test.csv"}
def test_ignore_rows_3() -> None:
csv_content = """A,B,C
4,,6
7,,9
"""
csv_document = Document(content=csv_content, meta={"name": "test.csv"})
csv_document_cleaner = CSVDocumentCleaner(ignore_rows=1)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "A,C\n4,6\n7,9\n"
assert cleaned_document.meta == {"name": "test.csv"}
def test_ignore_columns() -> None:
csv_content = """,,A,B
,2,3,4
,7,8,9
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(ignore_columns=1)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",,A,B\n,2,3,4\n,7,8,9\n"
def test_too_many_ignore_rows() -> None:
csv_content = """,,
A,B,C
4,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(ignore_rows=4)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",,\nA,B,C\n4,5,6\n"
def test_too_many_ignore_columns() -> None:
csv_content = """,,
A,B,C
4,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(ignore_columns=4)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",,\nA,B,C\n4,5,6\n"
def test_ignore_rows_and_columns() -> None:
csv_content = """,A,B,C
1,item,s,
2,item2,fd,
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(ignore_columns=1, ignore_rows=1)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",A,B\n1,item,s\n2,item2,fd\n"
def test_zero_ignore_rows_and_columns() -> None:
csv_content = """,A,B,C
1,item,s,
2,item2,fd,
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(ignore_columns=0, ignore_rows=0)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",A,B,C\n1,item,s,\n2,item2,fd,\n"
def test_empty_document() -> None:
csv_document = Document(content="")
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ""
assert cleaned_document.meta == {}
def test_empty_documents() -> None:
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([])
assert result["documents"] == []
def test_keep_id() -> None:
csv_content = """,A,B,C
1,item,s,
"""
csv_document = Document(id="123", content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(keep_id=True)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.id == "123"
assert cleaned_document.content == ",A,B,C\n1,item,s,\n"
def test_id_not_none() -> None:
csv_content = """,A,B,C
1,item,s,
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner()
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.id != ""
assert cleaned_document.content == ",A,B,C\n1,item,s,\n"
def test_remove_empty_rows_false() -> None:
csv_content = """,B,C
,,
,5,6
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(remove_empty_rows=False)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == "B,C\n,\n5,6\n"
def test_remove_empty_columns_false() -> None:
csv_content = """,B,C
,,
,,4
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(remove_empty_columns=False)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",B,C\n,,4\n"
def test_remove_empty_rows_and_columns_false() -> None:
csv_content = """,B,C
,,4
,,
"""
csv_document = Document(content=csv_content)
csv_document_cleaner = CSVDocumentCleaner(remove_empty_rows=False, remove_empty_columns=False)
result = csv_document_cleaner.run([csv_document])
cleaned_document = result["documents"][0]
assert cleaned_document.content == ",B,C\n,,4\n,,\n"
@@ -0,0 +1,345 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
from io import StringIO
import pytest
from pandas import read_csv
from haystack import Document
from haystack.components.preprocessors.csv_document_splitter import CSVDocumentSplitter
from haystack.core.serialization import component_from_dict, component_to_dict
@pytest.fixture
def splitter() -> CSVDocumentSplitter:
return CSVDocumentSplitter()
@pytest.fixture
def csv_with_four_rows() -> str:
return """A,B,C
1,2,3
X,Y,Z
7,8,9
"""
@pytest.fixture
def two_tables_sep_by_two_empty_rows() -> str:
return """A,B,C
1,2,3
,,
,,
X,Y,Z
7,8,9
"""
@pytest.fixture
def three_tables_sep_by_empty_rows() -> str:
return """A,B,C
,,
1,2,3
,,
,,
X,Y,Z
7,8,9
"""
@pytest.fixture
def two_tables_sep_by_two_empty_columns() -> str:
return """A,B,,,X,Y
1,2,,,7,8
3,4,,,9,10
"""
class TestFindSplitIndices:
def test_find_split_indices_row_two_tables(
self, splitter: CSVDocumentSplitter, two_tables_sep_by_two_empty_rows: str
) -> None:
df = read_csv(StringIO(two_tables_sep_by_two_empty_rows), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=2, axis="row")
assert result == [(2, 3)]
def test_find_split_indices_row_two_tables_with_empty_row(
self, splitter: CSVDocumentSplitter, three_tables_sep_by_empty_rows: str
) -> None:
df = read_csv(StringIO(three_tables_sep_by_empty_rows), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=2, axis="row")
assert result == [(3, 4)]
def test_find_split_indices_row_three_tables(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,B,C
1,2,3
,,
,,
X,Y,Z
7,8,9
,,
,,
P,Q,R
"""
df = read_csv(StringIO(csv_content), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=2, axis="row")
assert result == [(2, 3), (6, 7)]
def test_find_split_indices_column_two_tables(
self, splitter: CSVDocumentSplitter, two_tables_sep_by_two_empty_columns: str
) -> None:
df = read_csv(StringIO(two_tables_sep_by_two_empty_columns), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=1, axis="column")
assert result == [(2, 3)]
def test_find_split_indices_column_two_tables_with_empty_column(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,,B,,,X,Y
1,,2,,,7,8
3,,4,,,9,10
"""
df = read_csv(StringIO(csv_content), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=2, axis="column")
assert result == [(3, 4)]
def test_find_split_indices_column_three_tables(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,B,,,X,Y,,,P,Q
1,2,,,7,8,,,11,12
3,4,,,9,10,,,13,14
"""
df = read_csv(StringIO(csv_content), header=None, dtype=object) # type: ignore
result = splitter._find_split_indices(df, split_threshold=2, axis="column")
assert result == [(2, 3), (6, 7)]
class TestInit:
def test_row_split_threshold_raises_error(self) -> None:
with pytest.raises(ValueError, match="row_split_threshold must be greater than 0"):
CSVDocumentSplitter(row_split_threshold=-1)
def test_column_split_threshold_raises_error(self) -> None:
with pytest.raises(ValueError, match="column_split_threshold must be greater than 0"):
CSVDocumentSplitter(column_split_threshold=-1)
def test_row_split_threshold_and_row_column_threshold_none(self) -> None:
with pytest.raises(
ValueError, match="At least one of row_split_threshold or column_split_threshold must be specified."
):
CSVDocumentSplitter(row_split_threshold=None, column_split_threshold=None)
class TestCSVDocumentSplitter:
def test_single_table_no_split(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,B,C
1,2,3
4,5,6
"""
doc = Document(content=csv_content, id="test_id")
result = splitter.run([doc])["documents"]
assert len(result) == 1
assert result[0].content == csv_content
assert result[0].meta == {"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0}
def test_row_split(self, splitter: CSVDocumentSplitter, two_tables_sep_by_two_empty_rows: str) -> None:
doc = Document(content=two_tables_sep_by_two_empty_rows, id="test_id")
result = splitter.run([doc])["documents"]
assert len(result) == 2
expected_tables = ["A,B,C\n1,2,3\n", "X,Y,Z\n7,8,9\n"]
expected_meta = [
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 0, "split_id": 1},
]
for i, table in enumerate(result):
assert table.content == expected_tables[i]
assert table.meta == expected_meta[i]
def test_column_split(self, splitter: CSVDocumentSplitter, two_tables_sep_by_two_empty_columns: str) -> None:
doc = Document(content=two_tables_sep_by_two_empty_columns, id="test_id")
result = splitter.run([doc])["documents"]
assert len(result) == 2
expected_tables = ["A,B\n1,2\n3,4\n", "X,Y\n7,8\n9,10\n"]
expected_meta = [
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0},
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 4, "split_id": 1},
]
for i, table in enumerate(result):
assert table.content == expected_tables[i]
assert table.meta == expected_meta[i]
def test_recursive_split_one_level(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,B,,,X,Y
1,2,,,7,8
,,,,,
,,,,,
P,Q,,,M,N
3,4,,,9,10
"""
doc = Document(content=csv_content, id="test_id")
result = splitter.run([doc])["documents"]
assert len(result) == 4
expected_tables = ["A,B\n1,2\n", "X,Y\n7,8\n", "P,Q\n3,4\n", "M,N\n9,10\n"]
expected_meta = [
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0},
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 4, "split_id": 1},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 0, "split_id": 2},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 4, "split_id": 3},
]
for i, table in enumerate(result):
assert table.content == expected_tables[i]
assert table.meta == expected_meta[i]
def test_recursive_split_two_levels(self, splitter: CSVDocumentSplitter) -> None:
csv_content = """A,B,,,X,Y
1,2,,,7,8
,,,,M,N
,,,,9,10
P,Q,,,,
3,4,,,,
"""
doc = Document(content=csv_content, id="test_id")
result = splitter.run([doc])["documents"]
assert len(result) == 3
expected_tables = ["A,B\n1,2\n", "X,Y\n7,8\nM,N\n9,10\n", "P,Q\n3,4\n"]
expected_meta = [
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0},
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 4, "split_id": 1},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 0, "split_id": 2},
]
for i, table in enumerate(result):
assert table.content == expected_tables[i]
assert table.meta == expected_meta[i]
def test_csv_with_blank_lines(self, splitter: CSVDocumentSplitter) -> None:
csv_data = """ID,LeftVal,,,RightVal,Extra
1,Hello,,,World,Joined
2,StillLeft,,,StillRight,Bridge
A,B,,,C,D
E,F,,,G,H
"""
splitter = CSVDocumentSplitter(row_split_threshold=1, column_split_threshold=1)
result = splitter.run([Document(content=csv_data, id="test_id")])
docs = result["documents"]
assert len(docs) == 4
expected_tables = [
"ID,LeftVal\n1,Hello\n2,StillLeft\n",
"RightVal,Extra\nWorld,Joined\nStillRight,Bridge\n",
"A,B\nE,F\n",
"C,D\nG,H\n",
]
expected_meta = [
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 0, "split_id": 0},
{"source_id": "test_id", "row_idx_start": 0, "col_idx_start": 4, "split_id": 1},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 0, "split_id": 2},
{"source_id": "test_id", "row_idx_start": 4, "col_idx_start": 4, "split_id": 3},
]
for i, table in enumerate(docs):
assert table.content == expected_tables[i]
assert table.meta == expected_meta[i]
def test_sub_table_with_one_row(self):
splitter = CSVDocumentSplitter(row_split_threshold=1)
doc = Document(content="""A,B,C\n1,2,3\n,,\n4,5,6""")
split_result = splitter.run([doc])
assert len(split_result["documents"]) == 2
def test_threshold_no_effect(self, two_tables_sep_by_two_empty_rows: str) -> None:
splitter = CSVDocumentSplitter(row_split_threshold=3)
doc = Document(content=two_tables_sep_by_two_empty_rows)
result = splitter.run([doc])["documents"]
assert len(result) == 1
def test_empty_input(self, splitter: CSVDocumentSplitter) -> None:
csv_content = ""
doc = Document(content=csv_content)
result = splitter.run([doc])["documents"]
assert len(result) == 1
assert result[0].content == csv_content
def test_empty_documents(self, splitter: CSVDocumentSplitter) -> None:
result = splitter.run([])["documents"]
assert len(result) == 0
def test_to_dict_with_defaults(self) -> None:
splitter = CSVDocumentSplitter()
config_serialized = component_to_dict(splitter, name="CSVDocumentSplitter")
config = {
"type": "haystack.components.preprocessors.csv_document_splitter.CSVDocumentSplitter",
"init_parameters": {
"row_split_threshold": 2,
"column_split_threshold": 2,
"read_csv_kwargs": {},
"split_mode": "threshold",
},
}
assert config_serialized == config
def test_to_dict_non_defaults(self) -> None:
splitter = CSVDocumentSplitter(row_split_threshold=1, column_split_threshold=None, read_csv_kwargs={"sep": ";"})
config_serialized = component_to_dict(splitter, name="CSVDocumentSplitter")
config = {
"type": "haystack.components.preprocessors.csv_document_splitter.CSVDocumentSplitter",
"init_parameters": {
"row_split_threshold": 1,
"column_split_threshold": None,
"read_csv_kwargs": {"sep": ";"},
"split_mode": "threshold",
},
}
assert config_serialized == config
def test_from_dict_defaults(self) -> None:
splitter = component_from_dict(
CSVDocumentSplitter,
data={
"type": "haystack.components.preprocessors.csv_document_splitter.CSVDocumentSplitter",
"init_parameters": {},
},
name="CSVDocumentSplitter",
)
assert splitter.row_split_threshold == 2
assert splitter.column_split_threshold == 2
assert splitter.read_csv_kwargs == {}
assert splitter.split_mode == "threshold"
def test_from_dict_non_defaults(self) -> None:
splitter = component_from_dict(
CSVDocumentSplitter,
data={
"type": "haystack.components.preprocessors.csv_document_splitter.CSVDocumentSplitter",
"init_parameters": {
"row_split_threshold": 1,
"column_split_threshold": None,
"read_csv_kwargs": {"sep": ";"},
"split_mode": "row-wise",
},
},
name="CSVDocumentSplitter",
)
assert splitter.row_split_threshold == 1
assert splitter.column_split_threshold is None
assert splitter.read_csv_kwargs == {"sep": ";"}
assert splitter.split_mode == "row-wise"
def test_split_by_row(self, csv_with_four_rows: str) -> None:
splitter = CSVDocumentSplitter(split_mode="row-wise")
doc = Document(content=csv_with_four_rows)
result = splitter.run([doc])["documents"]
assert len(result) == 4
assert result[0].content == "A,B,C\n"
assert result[1].content == "1,2,3\n"
assert result[2].content == "X,Y,Z\n"
def test_split_by_row_with_empty_rows(self, caplog) -> None:
splitter = CSVDocumentSplitter(split_mode="row-wise")
doc = Document(content="")
with caplog.at_level(logging.ERROR):
result = splitter.run([doc])["documents"]
assert len(result) == 1
assert result[0].content == ""
def test_incorrect_split_mode(self) -> None:
with pytest.raises(ValueError, match="not recognized"):
CSVDocumentSplitter(split_mode="incorrect_mode")
@@ -0,0 +1,331 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack import Document
from haystack.components.preprocessors import DocumentCleaner
from haystack.dataclasses import ByteStream, SparseEmbedding
class TestDocumentCleaner:
def test_init(self):
cleaner = DocumentCleaner()
assert cleaner.remove_empty_lines is True
assert cleaner.remove_extra_whitespaces is True
assert cleaner.remove_repeated_substrings is False
assert cleaner.remove_substrings is None
assert cleaner.remove_regex is None
assert cleaner.keep_id is False
def test_non_text_document(self, caplog):
with caplog.at_level(logging.WARNING):
cleaner = DocumentCleaner()
cleaner.run(documents=[Document()])
assert "DocumentCleaner only cleans text documents but document.content for document ID" in caplog.text
def test_single_document(self):
with pytest.raises(TypeError, match="DocumentCleaner expects a List of Documents as input."):
cleaner = DocumentCleaner()
cleaner.run(documents=Document())
def test_empty_list(self):
cleaner = DocumentCleaner()
result = cleaner.run(documents=[])
assert result == {"documents": []}
def test_remove_empty_lines(self):
cleaner = DocumentCleaner(remove_extra_whitespaces=False)
result = cleaner.run(
documents=[
Document(
content="This is a text with some words. \f"
""
"There is a second sentence. "
""
"And there is a third sentence."
)
]
)
assert len(result["documents"]) == 1
assert (
result["documents"][0].content
== "This is a text with some words. \fThere is a second sentence. And there is a third sentence."
)
def test_remove_whitespaces(self):
cleaner = DocumentCleaner(remove_empty_lines=False)
result = cleaner.run(
documents=[
Document(
content=" This is a text with some words. "
""
"There is a second sentence. "
""
"And there is a third sentence.\f "
)
]
)
assert len(result["documents"]) == 1
assert result["documents"][0].content == (
"This is a text with some words. There is a second sentence. And there is a third sentence.\f"
)
def test_remove_substrings(self):
cleaner = DocumentCleaner(remove_substrings=["This", "A", "words", "🪲"])
result = cleaner.run(documents=[Document(content="This is a text with some words.\f🪲")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == " is a text with some .\f"
def test_remove_regex(self):
cleaner = DocumentCleaner(remove_regex=r"\s\s+")
result = cleaner.run(documents=[Document(content="This is a text \f with some words.")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "This is a text\fwith some words."
def test_remove_repeated_substrings(self):
cleaner = DocumentCleaner(
remove_empty_lines=False, remove_extra_whitespaces=False, remove_repeated_substrings=True
)
text = """First Page\f This is a header.
Page of
2
4
Lorem ipsum dolor sit amet
This is a footer number 1
This is footer number 2 This is a header.
Page of
3
4
Sid ut perspiciatis unde
This is a footer number 1
This is footer number 2 This is a header.
Page of
4
4
Sed do eiusmod tempor.
This is a footer number 1
This is footer number 2"""
expected_text = """First Page\f 2
4
Lorem ipsum dolor sit amet 3
4
Sid ut perspiciatis unde 4
4
Sed do eiusmod tempor."""
result = cleaner.run(documents=[Document(content=text)])
assert result["documents"][0].content == expected_text
def test_remove_repeated_substrings_preserves_unique_middle_page(self):
cleaner = DocumentCleaner(
remove_empty_lines=False, remove_extra_whitespaces=False, remove_repeated_substrings=True
)
text = "PAGE ONE\fThe quick brown fox jumps high\fPAGE THREE"
result = cleaner.run(documents=[Document(content=text)])["documents"][0]
assert result.content.split("\f")[1] == "The quick brown fox jumps high"
# With no genuine repeated header/footer, all three pages must round-trip unchanged and in order.
assert result.content.split("\f") == ["PAGE ONE", "The quick brown fox jumps high", "PAGE THREE"]
assert result.content == text
def test_copy_metadata(self):
cleaner = DocumentCleaner()
documents = [
Document(content="Text. ", meta={"name": "doc 0"}),
Document(content="Text. ", meta={"name": "doc 1"}),
]
result = cleaner.run(documents=documents)
assert len(result["documents"]) == 2
assert result["documents"][0].id != result["documents"][1].id
for doc, cleaned_doc in zip(documents, result["documents"], strict=True):
assert doc.meta == cleaned_doc.meta
assert cleaned_doc.content == "Text."
def test_keep_id_does_not_alter_document_ids(self):
cleaner = DocumentCleaner(keep_id=True)
documents = [Document(content="Text. ", id="1"), Document(content="Text. ", id="2")]
result = cleaner.run(documents=documents)
assert len(result["documents"]) == 2
assert result["documents"][0].id == "1"
assert result["documents"][1].id == "2"
def test_unicode_normalization(self):
text = """\
アイウエオ
Comment ça va
مرحبا بالعالم
emSpace"""
expected_text_NFC = """\
アイウエオ
Comment ça va
مرحبا بالعالم
emSpace"""
expected_text_NFD = """\
アイウエオ
Comment ça va
مرحبا بالعالم
emSpace"""
expected_text_NFKC = """\
アイウエオ
Comment ça va
مرحبا بالعالم
em Space"""
expected_text_NFKD = """\
アイウエオ
Comment ça va
مرحبا بالعالم
em Space"""
nfc_cleaner = DocumentCleaner(unicode_normalization="NFC", remove_extra_whitespaces=False)
nfd_cleaner = DocumentCleaner(unicode_normalization="NFD", remove_extra_whitespaces=False)
nfkc_cleaner = DocumentCleaner(unicode_normalization="NFKC", remove_extra_whitespaces=False)
nfkd_cleaner = DocumentCleaner(unicode_normalization="NFKD", remove_extra_whitespaces=False)
nfc_result = nfc_cleaner.run(documents=[Document(content=text)])
nfd_result = nfd_cleaner.run(documents=[Document(content=text)])
nfkc_result = nfkc_cleaner.run(documents=[Document(content=text)])
nfkd_result = nfkd_cleaner.run(documents=[Document(content=text)])
assert nfc_result["documents"][0].content == expected_text_NFC
assert nfd_result["documents"][0].content == expected_text_NFD
assert nfkc_result["documents"][0].content == expected_text_NFKC
assert nfkd_result["documents"][0].content == expected_text_NFKD
def test_ascii_only(self):
text = """\
アイウエオ
Comment ça va
Á
مرحبا بالعالم
emSpace"""
expected_text = """\
\n\
Comment ca va
A
\n\
em Space"""
cleaner = DocumentCleaner(ascii_only=True, remove_extra_whitespaces=False, remove_empty_lines=False)
result = cleaner.run(documents=[Document(content=text)])
assert result["documents"][0].content == expected_text
def test_other_document_fields_are_not_lost(self):
cleaner = DocumentCleaner(keep_id=True)
document = Document(
content="This is a text with some words. \nThere is a second sentence. \nAnd there is a third sentence.\n",
blob=ByteStream.from_string("some_data"),
meta={"data": 1},
score=0.1,
embedding=[0.1, 0.2, 0.3],
sparse_embedding=SparseEmbedding([0, 2], [0.1, 0.3]),
)
res = cleaner.run(documents=[document])
assert len(res) == 1
assert len(res["documents"])
assert res["documents"][0].id == document.id
assert res["documents"][0].content == (
"This is a text with some words. There is a second sentence. And there is a third sentence."
)
assert res["documents"][0].blob == document.blob
assert res["documents"][0].meta == document.meta
assert res["documents"][0].score == document.score
assert res["documents"][0].embedding == document.embedding
assert res["documents"][0].sparse_embedding == document.sparse_embedding
def test_strip_whitespaces(self):
"""Test that strip_whitespaces removes only leading and trailing whitespace."""
cleaner = DocumentCleaner(remove_empty_lines=False, remove_extra_whitespaces=False, strip_whitespaces=True)
result = cleaner.run(documents=[Document(content=" \n\nHello World\n\n Some text here \n\n ")])
assert len(result["documents"]) == 1
# strip_whitespaces should only remove leading/trailing whitespace, preserving internal whitespace
assert result["documents"][0].content == "Hello World\n\n Some text here"
def test_strip_whitespaces_preserves_internal_formatting(self):
"""Test that strip_whitespaces preserves internal whitespace like markdown formatting."""
cleaner = DocumentCleaner(remove_empty_lines=False, remove_extra_whitespaces=False, strip_whitespaces=True)
markdown_content = """
# Header
This is a paragraph.
- Item 1
- Item 2
"""
result = cleaner.run(documents=[Document(content=markdown_content)])
assert len(result["documents"]) == 1
expected = """# Header
This is a paragraph.
- Item 1
- Item 2"""
assert result["documents"][0].content == expected
def test_replace_regexes_single_pattern(self):
"""Test replace_regexes with a single pattern."""
cleaner = DocumentCleaner(
remove_empty_lines=False, remove_extra_whitespaces=False, replace_regexes={r"\n\n+": "\n"}
)
result = cleaner.run(documents=[Document(content="Line 1\n\n\n\nLine 2\n\nLine 3")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Line 1\nLine 2\nLine 3"
def test_replace_regexes_multiple_patterns(self):
"""Test replace_regexes with multiple patterns."""
cleaner = DocumentCleaner(
remove_empty_lines=False, remove_extra_whitespaces=False, replace_regexes={r"\n\n+": "\n", r"\s{2,}": " "}
)
result = cleaner.run(documents=[Document(content="Hello World\n\n\nGoodbye")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Hello World\nGoodbye"
def test_replace_regexes_custom_replacement(self):
"""Test replace_regexes with custom replacement strings."""
cleaner = DocumentCleaner(
remove_empty_lines=False,
remove_extra_whitespaces=False,
replace_regexes={r"\[REDACTED\]": "***", r"(\d{4})-(\d{2})-(\d{2})": r"\2/\3/\1"},
)
result = cleaner.run(documents=[Document(content="Name: [REDACTED], Date: 2024-01-15")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Name: ***, Date: 01/15/2024"
def test_strip_whitespaces_and_replace_regexes_combined(self):
"""Test using both strip_whitespaces and replace_regexes together."""
cleaner = DocumentCleaner(
remove_empty_lines=False,
remove_extra_whitespaces=False,
strip_whitespaces=True,
replace_regexes={r"\n\n+": "\n"},
)
result = cleaner.run(documents=[Document(content="\n\n Hello\n\n\nWorld \n\n")])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Hello\nWorld"
def test_init_with_new_params(self):
"""Test that new parameters are properly initialized."""
cleaner = DocumentCleaner(strip_whitespaces=True, replace_regexes={r"\n+": "\n"})
assert cleaner.strip_whitespaces is True
assert cleaner.replace_regexes == {r"\n+": "\n"}
def test_replace_regexes_with_page_breaks(self):
"""Test replace_regexes with page breaks (form feed character)."""
cleaner = DocumentCleaner(
remove_empty_lines=False, remove_extra_whitespaces=False, replace_regexes={r"Page \d+": ""}
)
content = "Page 1 content.\fPage 2 content."
result = cleaner.run(documents=[Document(content=content)])
assert len(result["documents"]) == 1
assert result["documents"][0].content == " content.\f content."
@@ -0,0 +1,134 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import patch
import pytest
from haystack import Document, Pipeline
from haystack.components.preprocessors.document_preprocessor import DocumentPreprocessor
class TestDocumentPreprocessor:
@pytest.fixture
def preprocessor(self) -> DocumentPreprocessor:
return DocumentPreprocessor(
# Cleaner parameters
remove_empty_lines=True,
remove_extra_whitespaces=True,
remove_repeated_substrings=False,
keep_id=True,
# Splitter parameters
split_by="word",
split_length=3,
split_overlap=1,
respect_sentence_boundary=False,
language="en",
)
def test_init(self, preprocessor: DocumentPreprocessor) -> None:
assert isinstance(preprocessor.pipeline, Pipeline)
assert preprocessor.input_mapping == {"documents": ["splitter.documents"]}
assert preprocessor.output_mapping == {"cleaner.documents": "documents"}
cleaner = preprocessor.pipeline.get_component("cleaner")
assert cleaner.remove_empty_lines is True
assert cleaner.remove_extra_whitespaces is True
assert cleaner.remove_repeated_substrings is False
assert cleaner.keep_id is True
splitter = preprocessor.pipeline.get_component("splitter")
assert splitter.split_by == "word"
assert splitter.split_length == 3
assert splitter.split_overlap == 1
assert splitter.respect_sentence_boundary is False
assert splitter.language == "en"
def test_from_dict(self) -> None:
data = {
"init_parameters": {
"remove_empty_lines": True,
"remove_extra_whitespaces": True,
"remove_repeated_substrings": False,
"keep_id": True,
"remove_substrings": None,
"remove_regex": None,
"unicode_normalization": None,
"ascii_only": False,
"split_by": "word",
"split_length": 3,
"split_overlap": 1,
"split_threshold": 0,
"splitting_function": None,
"respect_sentence_boundary": False,
"language": "en",
"use_split_rules": True,
"extend_abbreviations": True,
},
"type": "haystack.components.preprocessors.document_preprocessor.DocumentPreprocessor",
}
preprocessor = DocumentPreprocessor.from_dict(data)
assert isinstance(preprocessor, DocumentPreprocessor)
def test_to_dict(self, preprocessor: DocumentPreprocessor) -> None:
expected = {
"init_parameters": {
"remove_empty_lines": True,
"remove_extra_whitespaces": True,
"remove_repeated_substrings": False,
"keep_id": True,
"remove_substrings": None,
"remove_regex": None,
"unicode_normalization": None,
"ascii_only": False,
"split_by": "word",
"split_length": 3,
"split_overlap": 1,
"split_threshold": 0,
"splitting_function": None,
"respect_sentence_boundary": False,
"language": "en",
"use_split_rules": True,
"extend_abbreviations": True,
},
"type": "haystack.components.preprocessors.document_preprocessor.DocumentPreprocessor",
}
assert preprocessor.to_dict() == expected
def test_warm_up(self, preprocessor: DocumentPreprocessor) -> None:
with patch.object(preprocessor.pipeline, "warm_up") as mock_warm_up:
preprocessor.warm_up()
mock_warm_up.assert_called_once()
def test_run(self, preprocessor: DocumentPreprocessor) -> None:
documents = [
Document(content="This is a test document. It has multiple sentences."),
Document(content="Another test document with some content."),
]
result = preprocessor.run(documents=documents)
# Check that we got processed documents back
assert "documents" in result
processed_docs = result["documents"]
assert len(processed_docs) > len(documents) # Should have more docs due to splitting
# Check that the content was cleaned and split
for doc in processed_docs:
assert doc.content.strip() == doc.content
assert len(doc.content.split()) <= 3 # Split length of 3 words
assert doc.id is not None
def test_run_with_custom_splitting_function(self) -> None:
def custom_split(text: str) -> list[str]:
return [t for t in text.split(".") if t.strip() != ""]
preprocessor = DocumentPreprocessor(split_by="function", splitting_function=custom_split, split_length=1)
documents = [Document(content="First sentence. Second sentence. Third sentence.")]
result = preprocessor.run(documents=documents)
processed_docs = result["documents"]
assert len(processed_docs) == 3 # Should be split into 3 sentences
assert all("." not in doc.content for doc in processed_docs) # Each doc should be a single sentence
@@ -0,0 +1,836 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import re
import pytest
from haystack import Document
from haystack.components.preprocessors import DocumentSplitter
from haystack.utils import deserialize_callable, serialize_callable
# custom split function for testing
def custom_split(text):
return text.split(".")
def merge_documents(documents):
"""Merge a list of doc chunks into a single doc by concatenating their content, eliminating overlapping content."""
sorted_docs = sorted(documents, key=lambda doc: doc.meta["split_idx_start"])
merged_text = ""
last_idx_end = 0
for doc in sorted_docs:
start = doc.meta["split_idx_start"] # start of the current content
# if the start of the current content is before the end of the last appended content, adjust it
start = max(start, last_idx_end)
# append the non-overlapping part to the merged text
merged_text += doc.content[start - doc.meta["split_idx_start"] :]
# update the last end index
last_idx_end = doc.meta["split_idx_start"] + len(doc.content)
return merged_text
class TestSplittingByFunctionOrCharacterRegex:
def test_non_text_document(self, caplog):
with pytest.raises(
ValueError, match="DocumentSplitter only works with text documents but content for document ID"
):
splitter = DocumentSplitter()
splitter.run(documents=[Document()])
assert "DocumentSplitter only works with text documents but content for document ID" in caplog.text
def test_single_doc(self):
with pytest.raises(TypeError, match="DocumentSplitter expects a List of Documents as input."):
splitter = DocumentSplitter()
splitter.run(documents=Document())
def test_empty_list(self):
splitter = DocumentSplitter()
res = splitter.run(documents=[])
assert res == {"documents": []}
def test_unsupported_split_by(self):
with pytest.raises(ValueError, match="split_by must be one of "):
DocumentSplitter(split_by="unsupported")
def test_undefined_function(self):
with pytest.raises(ValueError, match="When 'split_by' is set to 'function', a valid 'splitting_function'"):
DocumentSplitter(split_by="function", splitting_function=None)
def test_unsupported_split_length(self):
with pytest.raises(ValueError, match="split_length must be greater than 0."):
DocumentSplitter(split_length=0)
def test_unsupported_split_overlap(self):
with pytest.raises(ValueError, match="split_overlap must be greater than or equal to 0."):
DocumentSplitter(split_overlap=-1)
def test_split_overlap_not_less_than_split_length(self):
# split_overlap == split_length makes the window step 0, and a larger
# overlap makes it negative — both crash deep in `windowed` at run time.
# Fail fast at init with a clear error instead.
with pytest.raises(ValueError, match="split_overlap must be less than split_length."):
DocumentSplitter(split_length=3, split_overlap=3)
with pytest.raises(ValueError, match="split_overlap must be less than split_length."):
DocumentSplitter(split_length=3, split_overlap=5)
def test_split_by_word(self):
splitter = DocumentSplitter(split_by="word", split_length=10)
text = "This is a text with some words. There is a second sentence. And there is a third sentence."
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 2
assert docs[0].content == "This is a text with some words. There is a "
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[1].content == "second sentence. And there is a third sentence."
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
def test_split_by_word_with_threshold(self):
splitter = DocumentSplitter(split_by="word", split_length=15, split_threshold=10)
result = splitter.run(
documents=[
Document(
content="This is a text with some words. There is a second sentence. And there is a third sentence."
)
]
)
assert len(result["documents"]) == 1
assert (
result["documents"][0].content
== "This is a text with some words. There is a second sentence. And there is a third sentence."
)
def test_split_by_word_multiple_input_docs(self):
splitter = DocumentSplitter(split_by="word", split_length=10)
text1 = "This is a text with some words. There is a second sentence. And there is a third sentence."
text2 = (
"This is a different text with some words. There is a second sentence. And there is a third sentence. "
"And there is a fourth sentence."
)
result = splitter.run(documents=[Document(content=text1), Document(content=text2)])
docs = result["documents"]
assert len(docs) == 5
# doc 0
assert docs[0].content == "This is a text with some words. There is a "
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text1.index(docs[0].content)
# doc 1
assert docs[1].content == "second sentence. And there is a third sentence."
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text1.index(docs[1].content)
# doc 2
assert docs[2].content == "This is a different text with some words. There is "
assert docs[2].meta["split_id"] == 0
assert docs[2].meta["split_idx_start"] == text2.index(docs[2].content)
# doc 3
assert docs[3].content == "a second sentence. And there is a third sentence. And "
assert docs[3].meta["split_id"] == 1
assert docs[3].meta["split_idx_start"] == text2.index(docs[3].content)
# doc 4
assert docs[4].content == "there is a fourth sentence."
assert docs[4].meta["split_id"] == 2
assert docs[4].meta["split_idx_start"] == text2.index(docs[4].content)
def test_split_by_period(self):
splitter = DocumentSplitter(split_by="period", split_length=1)
text = "This is a text with some words. There is a second sentence. And there is a third sentence."
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 3
assert docs[0].content == "This is a text with some words."
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[1].content == " There is a second sentence."
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
assert docs[2].content == " And there is a third sentence."
assert docs[2].meta["split_id"] == 2
assert docs[2].meta["split_idx_start"] == text.index(docs[2].content)
def test_split_by_passage(self):
splitter = DocumentSplitter(split_by="passage", split_length=1)
text = (
"This is a text with some words. There is a second sentence.\n\nAnd there is a third sentence.\n\n "
"And another passage."
)
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 3
assert docs[0].content == "This is a text with some words. There is a second sentence.\n\n"
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[1].content == "And there is a third sentence.\n\n"
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
assert docs[2].content == " And another passage."
assert docs[2].meta["split_id"] == 2
assert docs[2].meta["split_idx_start"] == text.index(docs[2].content)
def test_split_by_page(self):
splitter = DocumentSplitter(split_by="page", split_length=1)
text = (
"This is a text with some words. There is a second sentence.\f And there is a third sentence.\f And "
"another passage."
)
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 3
assert docs[0].content == "This is a text with some words. There is a second sentence.\f"
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[0].meta["page_number"] == 1
assert docs[1].content == " And there is a third sentence.\f"
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
assert docs[1].meta["page_number"] == 2
assert docs[2].content == " And another passage."
assert docs[2].meta["split_id"] == 2
assert docs[2].meta["split_idx_start"] == text.index(docs[2].content)
assert docs[2].meta["page_number"] == 3
def test_split_by_function(self):
splitting_function = lambda s: s.split(".")
splitter = DocumentSplitter(split_by="function", splitting_function=splitting_function)
text = "This.Is.A.Test"
result = splitter.run(documents=[Document(id="1", content=text, meta={"key": "value"})])
docs = result["documents"]
assert len(docs) == 4
assert docs[0].content == "This"
assert docs[0].meta == {"key": "value", "source_id": "1"}
assert docs[1].content == "Is"
assert docs[1].meta == {"key": "value", "source_id": "1"}
assert docs[2].content == "A"
assert docs[2].meta == {"key": "value", "source_id": "1"}
assert docs[3].content == "Test"
assert docs[3].meta == {"key": "value", "source_id": "1"}
splitting_function = lambda s: re.split(r"[\s]{2,}", s)
splitter = DocumentSplitter(split_by="function", splitting_function=splitting_function)
text = "This Is\n A Test"
result = splitter.run(documents=[Document(id="1", content=text, meta={"key": "value"})])
docs = result["documents"]
assert len(docs) == 4
assert docs[0].content == "This"
assert docs[0].meta == {"key": "value", "source_id": "1"}
assert docs[1].content == "Is"
assert docs[1].meta == {"key": "value", "source_id": "1"}
assert docs[2].content == "A"
assert docs[2].meta == {"key": "value", "source_id": "1"}
assert docs[3].content == "Test"
assert docs[3].meta == {"key": "value", "source_id": "1"}
def test_split_by_word_with_overlap(self):
splitter = DocumentSplitter(split_by="word", split_length=10, split_overlap=2)
text = "This is a text with some words. There is a second sentence. And there is a third sentence."
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 2
# doc 0
assert docs[0].content == "This is a text with some words. There is a "
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[0].meta["_split_overlap"][0]["range"] == (0, 5)
assert docs[1].content[0:5] == "is a "
# doc 1
assert docs[1].content == "is a second sentence. And there is a third sentence."
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
assert docs[1].meta["_split_overlap"][0]["range"] == (38, 43)
assert docs[0].content[38:43] == "is a "
def test_split_by_line(self):
splitter = DocumentSplitter(split_by="line", split_length=1)
text = "This is a text with some words.\nThere is a second sentence.\nAnd there is a third sentence."
result = splitter.run(documents=[Document(content=text)])
docs = result["documents"]
assert len(docs) == 3
assert docs[0].content == "This is a text with some words.\n"
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content)
assert docs[1].content == "There is a second sentence.\n"
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content)
assert docs[2].content == "And there is a third sentence."
assert docs[2].meta["split_id"] == 2
assert docs[2].meta["split_idx_start"] == text.index(docs[2].content)
def test_source_id_stored_in_metadata(self):
splitter = DocumentSplitter(split_by="word", split_length=10)
doc1 = Document(content="This is a text with some words.")
doc2 = Document(content="This is a different text with some words.")
result = splitter.run(documents=[doc1, doc2])
assert result["documents"][0].meta["source_id"] == doc1.id
assert result["documents"][1].meta["source_id"] == doc2.id
def test_copy_metadata(self):
splitter = DocumentSplitter(split_by="word", split_length=10)
documents = [
Document(content="Text.", meta={"name": "doc 0"}),
Document(content="Text.", meta={"name": "doc 1"}),
]
result = splitter.run(documents=documents)
assert len(result["documents"]) == 2
assert result["documents"][0].id != result["documents"][1].id
for doc, split_doc in zip(documents, result["documents"], strict=True):
assert doc.meta.items() <= split_doc.meta.items()
assert split_doc.content == "Text."
def test_add_page_number_to_metadata_with_no_overlap_word_split(self):
splitter = DocumentSplitter(split_by="word", split_length=2)
doc1 = Document(content="This is some text.\f This text is on another page.")
doc2 = Document(content="This content has two.\f\f page brakes.")
result = splitter.run(documents=[doc1, doc2])
expected_pages = [1, 1, 2, 2, 2, 1, 1, 3]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_no_overlap_period_split(self):
splitter = DocumentSplitter(split_by="period", split_length=1)
doc1 = Document(content="This is some text.\f This text is on another page.")
doc2 = Document(content="This content has two.\f\f page brakes.")
result = splitter.run(documents=[doc1, doc2])
expected_pages = [1, 1, 1, 1]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_no_overlap_passage_split(self):
splitter = DocumentSplitter(split_by="passage", split_length=1)
doc1 = Document(
content="This is a text with some words.\f There is a second sentence.\n\nAnd there is a third sentence."
"\n\nAnd more passages.\n\n\f And another passage."
)
result = splitter.run(documents=[doc1])
expected_pages = [1, 2, 2, 2]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_no_overlap_page_split(self):
splitter = DocumentSplitter(split_by="page", split_length=1)
doc1 = Document(
content="This is a text with some words. There is a second sentence.\f And there is a third sentence.\f "
"And another passage."
)
result = splitter.run(documents=[doc1])
expected_pages = [1, 2, 3]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
splitter = DocumentSplitter(split_by="page", split_length=2)
doc1 = Document(
content="This is a text with some words. There is a second sentence.\f And there is a third sentence.\f "
"And another passage."
)
result = splitter.run(documents=[doc1])
expected_pages = [1, 3]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_overlap_word_split(self):
splitter = DocumentSplitter(split_by="word", split_length=3, split_overlap=1)
doc1 = Document(content="This is some text. And\f this text is on another page.")
doc2 = Document(content="This content has two.\f\f page brakes.")
result = splitter.run(documents=[doc1, doc2])
expected_pages = [1, 1, 1, 2, 2, 1, 1, 3]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_overlap_period_split(self):
splitter = DocumentSplitter(split_by="period", split_length=2, split_overlap=1)
doc1 = Document(content="This is some text. And this is more text.\f This text is on another page. End.")
doc2 = Document(content="This content has two.\f\f page brakes. More text.")
result = splitter.run(documents=[doc1, doc2])
expected_pages = [1, 1, 1, 2, 1, 1, 3]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_overlap_passage_split(self):
splitter = DocumentSplitter(split_by="passage", split_length=2, split_overlap=1)
doc1 = Document(
content="This is a text with some words.\f There is a second sentence.\n\nAnd there is a third sentence."
"\n\nAnd more passages.\n\n\f And another passage."
)
result = splitter.run(documents=[doc1])
expected_pages = [1, 2, 2]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_page_number_to_metadata_with_overlap_page_split(self):
splitter = DocumentSplitter(split_by="page", split_length=2, split_overlap=1)
doc1 = Document(
content="This is a text with some words. There is a second sentence.\f And there is a third sentence.\f "
"And another passage."
)
result = splitter.run(documents=[doc1])
expected_pages = [1, 2]
for doc, p in zip(result["documents"], expected_pages, strict=True):
assert doc.meta["page_number"] == p
def test_add_split_overlap_information(self):
splitter = DocumentSplitter(split_length=10, split_overlap=5, split_by="word")
text = "This is a text with some words. There is a second sentence. And a third sentence."
doc = Document(content="This is a text with some words. There is a second sentence. And a third sentence.")
docs = splitter.run(documents=[doc])["documents"]
# check split_overlap is added to all the documents
assert len(docs) == 3
# doc 0
assert docs[0].content == "This is a text with some words. There is a "
assert docs[0].meta["split_id"] == 0
assert docs[0].meta["split_idx_start"] == text.index(docs[0].content) # 0
assert docs[0].meta["_split_overlap"][0]["range"] == (0, 23)
assert docs[1].content[0:23] == "some words. There is a "
# doc 1
assert docs[1].content == "some words. There is a second sentence. And a third "
assert docs[1].meta["split_id"] == 1
assert docs[1].meta["split_idx_start"] == text.index(docs[1].content) # 20
assert docs[1].meta["_split_overlap"][0]["range"] == (20, 43)
assert docs[1].meta["_split_overlap"][1]["range"] == (0, 29)
assert docs[0].content[20:43] == "some words. There is a "
assert docs[2].content[0:29] == "second sentence. And a third "
# doc 2
assert docs[2].content == "second sentence. And a third sentence."
assert docs[2].meta["split_id"] == 2
assert docs[2].meta["split_idx_start"] == text.index(docs[2].content) # 43
assert docs[2].meta["_split_overlap"][0]["range"] == (23, 52)
assert docs[1].content[23:52] == "second sentence. And a third "
# reconstruct the original document content from the split documents
assert doc.content == merge_documents(docs)
def test_to_dict(self):
"""
Test the to_dict method of the DocumentSplitter class.
"""
splitter = DocumentSplitter(split_by="word", split_length=10, split_overlap=2, split_threshold=5)
serialized = splitter.to_dict()
assert serialized["type"] == "haystack.components.preprocessors.document_splitter.DocumentSplitter"
assert serialized["init_parameters"]["split_by"] == "word"
assert serialized["init_parameters"]["split_length"] == 10
assert serialized["init_parameters"]["split_overlap"] == 2
assert serialized["init_parameters"]["split_threshold"] == 5
assert serialized["init_parameters"]["skip_empty_documents"]
assert "splitting_function" not in serialized["init_parameters"]
def test_to_dict_with_splitting_function(self):
"""
Test the to_dict method of the DocumentSplitter class when a custom splitting function is provided.
"""
splitter = DocumentSplitter(split_by="function", splitting_function=custom_split)
serialized = splitter.to_dict()
assert serialized["type"] == "haystack.components.preprocessors.document_splitter.DocumentSplitter"
assert serialized["init_parameters"]["split_by"] == "function"
assert "splitting_function" in serialized["init_parameters"]
assert serialized["init_parameters"]["skip_empty_documents"]
assert callable(deserialize_callable(serialized["init_parameters"]["splitting_function"]))
def test_from_dict(self):
"""
Test the from_dict class method of the DocumentSplitter class.
"""
data = {
"type": "haystack.components.preprocessors.document_splitter.DocumentSplitter",
"init_parameters": {
"split_by": "word",
"split_length": 10,
"split_overlap": 2,
"split_threshold": 5,
"skip_empty_documents": False,
},
}
splitter = DocumentSplitter.from_dict(data)
assert splitter.split_by == "word"
assert splitter.split_length == 10
assert splitter.split_overlap == 2
assert splitter.split_threshold == 5
assert splitter.splitting_function is None
assert splitter.skip_empty_documents is False
def test_from_dict_with_splitting_function(self):
"""
Test the from_dict class method of the DocumentSplitter class when a custom splitting function is provided.
"""
data = {
"type": "haystack.components.preprocessors.document_splitter.DocumentSplitter",
"init_parameters": {"split_by": "function", "splitting_function": serialize_callable(custom_split)},
}
splitter = DocumentSplitter.from_dict(data)
assert splitter.split_by == "function"
assert callable(splitter.splitting_function)
assert splitter.splitting_function("a.b.c") == ["a", "b", "c"]
def test_roundtrip_serialization(self):
"""
Test the round-trip serialization of the DocumentSplitter class.
"""
original_splitter = DocumentSplitter(split_by="word", split_length=10, split_overlap=2, split_threshold=5)
serialized = original_splitter.to_dict()
deserialized_splitter = DocumentSplitter.from_dict(serialized)
assert original_splitter.split_by == deserialized_splitter.split_by
assert original_splitter.split_length == deserialized_splitter.split_length
assert original_splitter.split_overlap == deserialized_splitter.split_overlap
assert original_splitter.split_threshold == deserialized_splitter.split_threshold
def test_roundtrip_serialization_with_splitting_function(self):
"""
Test the round-trip serialization of the DocumentSplitter class when a custom splitting function is provided.
"""
original_splitter = DocumentSplitter(split_by="function", splitting_function=custom_split)
serialized = original_splitter.to_dict()
deserialized_splitter = DocumentSplitter.from_dict(serialized)
assert original_splitter.split_by == deserialized_splitter.split_by
assert callable(deserialized_splitter.splitting_function)
assert deserialized_splitter.splitting_function("a.b.c") == ["a", "b", "c"]
def test_run_empty_document_with_skip_empty_documents_true(self):
"""
Test if the component runs correctly with an empty document.
"""
splitter = DocumentSplitter()
doc = Document(content="")
results = splitter.run([doc])
assert results["documents"] == []
def test_run_empty_document_with_skip_empty_documents_false(self):
splitter = DocumentSplitter(skip_empty_documents=False)
doc = Document(content="")
results = splitter.run([doc])
assert len(results["documents"]) == 1
assert results["documents"][0].content == ""
def test_run_document_only_whitespaces(self):
"""
Test if the component runs correctly with a document containing only whitespaces.
"""
splitter = DocumentSplitter()
doc = Document(content=" ")
results = splitter.run([doc])
assert results["documents"][0].content == " "
class TestSplittingNLTKSentenceSplitter:
@pytest.mark.parametrize(
"sentences, expected_num_sentences",
[
(["The sun set.", "Moonlight shimmered softly, wolves howled nearby, night enveloped everything."], 0),
(["The sun set.", "It was a dark night ..."], 0),
(["The sun set.", " The moon was full."], 1),
(["The sun.", " The moon."], 1), # Ignores the first sentence
(["Sun", "Moon"], 1), # Ignores the first sentence even if its inclusion would be < split_overlap
],
)
def test_number_of_sentences_to_keep(self, sentences: list[str], expected_num_sentences: int) -> None:
num_sentences = DocumentSplitter._number_of_sentences_to_keep(
sentences=sentences, split_length=5, split_overlap=2
)
assert num_sentences == expected_num_sentences
def test_number_of_sentences_to_keep_split_overlap_zero(self) -> None:
sentences = [
"Moonlight shimmered softly, wolves howled nearby, night enveloped everything.",
" It was a dark night ...",
" The moon was full.",
]
num_sentences = DocumentSplitter._number_of_sentences_to_keep(
sentences=sentences, split_length=5, split_overlap=0
)
assert num_sentences == 0
def test_run_split_by_sentence_1(self) -> None:
document_splitter = DocumentSplitter(
split_by="sentence",
split_length=2,
split_overlap=0,
split_threshold=0,
language="en",
use_split_rules=True,
extend_abbreviations=True,
)
text = (
"Moonlight shimmered softly, wolves howled nearby, night enveloped everything. It was a dark night ... "
"The moon was full."
)
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 2
assert (
documents[0].content == "Moonlight shimmered softly, wolves howled nearby, night enveloped "
"everything. It was a dark night ... "
)
assert documents[1].content == "The moon was full."
def test_run_split_by_sentence_2(self) -> None:
document_splitter = DocumentSplitter(
split_by="sentence",
split_length=1,
split_overlap=0,
split_threshold=0,
language="en",
use_split_rules=False,
extend_abbreviations=True,
)
text = (
"This is a test sentence with many many words that exceeds the split length and should not be repeated. "
"This is another test sentence. (This is a third test sentence.) "
"This is the last test sentence."
)
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 4
assert (
documents[0].content
== "This is a test sentence with many many words that exceeds the split length and should not be repeated. "
)
assert documents[0].meta["page_number"] == 1
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["split_idx_start"] == text.index(documents[0].content)
assert documents[1].content == "This is another test sentence. "
assert documents[1].meta["page_number"] == 1
assert documents[1].meta["split_id"] == 1
assert documents[1].meta["split_idx_start"] == text.index(documents[1].content)
assert documents[2].content == "(This is a third test sentence.) "
assert documents[2].meta["page_number"] == 1
assert documents[2].meta["split_id"] == 2
assert documents[2].meta["split_idx_start"] == text.index(documents[2].content)
assert documents[3].content == "This is the last test sentence."
assert documents[3].meta["page_number"] == 1
assert documents[3].meta["split_id"] == 3
assert documents[3].meta["split_idx_start"] == text.index(documents[3].content)
def test_run_split_by_sentence_3(self) -> None:
document_splitter = DocumentSplitter(
split_by="sentence",
split_length=1,
split_overlap=0,
split_threshold=0,
language="en",
use_split_rules=True,
extend_abbreviations=True,
)
text = "Sentence on page 1.\fSentence on page 2. \fSentence on page 3. \f\f Sentence on page 5."
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 4
assert documents[0].content == "Sentence on page 1.\f"
assert documents[0].meta["page_number"] == 1
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["split_idx_start"] == text.index(documents[0].content)
assert documents[1].content == "Sentence on page 2. \f"
assert documents[1].meta["page_number"] == 2
assert documents[1].meta["split_id"] == 1
assert documents[1].meta["split_idx_start"] == text.index(documents[1].content)
assert documents[2].content == "Sentence on page 3. \f\f "
assert documents[2].meta["page_number"] == 3
assert documents[2].meta["split_id"] == 2
assert documents[2].meta["split_idx_start"] == text.index(documents[2].content)
assert documents[3].content == "Sentence on page 5."
assert documents[3].meta["page_number"] == 5
assert documents[3].meta["split_id"] == 3
assert documents[3].meta["split_idx_start"] == text.index(documents[3].content)
def test_run_split_by_sentence_4(self) -> None:
document_splitter = DocumentSplitter(
split_by="sentence",
split_length=2,
split_overlap=1,
split_threshold=0,
language="en",
use_split_rules=True,
extend_abbreviations=True,
)
text = "Sentence on page 1.\fSentence on page 2. \fSentence on page 3. \f\f Sentence on page 5."
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 3
assert documents[0].content == "Sentence on page 1.\fSentence on page 2. \f"
assert documents[0].meta["page_number"] == 1
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["split_idx_start"] == text.index(documents[0].content)
assert documents[1].content == "Sentence on page 2. \fSentence on page 3. \f\f "
assert documents[1].meta["page_number"] == 2
assert documents[1].meta["split_id"] == 1
assert documents[1].meta["split_idx_start"] == text.index(documents[1].content)
assert documents[2].content == "Sentence on page 3. \f\f Sentence on page 5."
assert documents[2].meta["page_number"] == 3
assert documents[2].meta["split_id"] == 2
assert documents[2].meta["split_idx_start"] == text.index(documents[2].content)
def test_run_split_by_word_respect_sentence_boundary(self) -> None:
document_splitter = DocumentSplitter(
split_by="word",
split_length=3,
split_overlap=0,
split_threshold=0,
language="en",
respect_sentence_boundary=True,
)
text = (
"Moonlight shimmered softly, wolves howled nearby, night enveloped everything. It was a dark night.\f"
"The moon was full."
)
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 3
assert documents[0].content == "Moonlight shimmered softly, wolves howled nearby, night enveloped everything. "
assert documents[0].meta["page_number"] == 1
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["split_idx_start"] == text.index(documents[0].content)
assert documents[1].content == "It was a dark night.\f"
assert documents[1].meta["page_number"] == 1
assert documents[1].meta["split_id"] == 1
assert documents[1].meta["split_idx_start"] == text.index(documents[1].content)
assert documents[2].content == "The moon was full."
assert documents[2].meta["page_number"] == 2
assert documents[2].meta["split_id"] == 2
assert documents[2].meta["split_idx_start"] == text.index(documents[2].content)
def test_run_split_by_word_respect_sentence_boundary_no_repeats(self) -> None:
document_splitter = DocumentSplitter(
split_by="word",
split_length=13,
split_overlap=3,
split_threshold=0,
language="en",
respect_sentence_boundary=True,
use_split_rules=False,
extend_abbreviations=False,
)
text = (
"This is a test sentence with many many words that exceeds the split length and should not be repeated. "
"This is another test sentence. (This is a third test sentence.) "
"This is the last test sentence."
)
documents = document_splitter.run([Document(content=text)])["documents"]
assert len(documents) == 3
assert (
documents[0].content
== "This is a test sentence with many many words that exceeds the split length and should not be repeated. "
)
assert "This is a test sentence with many many words" not in documents[1].content
assert "This is a test sentence with many many words" not in documents[2].content
def test_run_split_by_word_respect_sentence_boundary_with_split_overlap_and_page_breaks(self) -> None:
document_splitter = DocumentSplitter(
split_by="word",
split_length=8,
split_overlap=1,
split_threshold=0,
language="en",
use_split_rules=True,
extend_abbreviations=True,
respect_sentence_boundary=True,
)
text = (
"Sentence on page 1. Another on page 1.\fSentence on page 2. Another on page 2.\f"
"Sentence on page 3. Another on page 3.\f\f Sentence on page 5."
)
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
assert len(documents) == 6
assert documents[0].content == "Sentence on page 1. Another on page 1.\f"
assert documents[0].meta["page_number"] == 1
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["split_idx_start"] == text.index(documents[0].content)
assert documents[1].content == "Another on page 1.\fSentence on page 2. "
assert documents[1].meta["page_number"] == 1
assert documents[1].meta["split_id"] == 1
assert documents[1].meta["split_idx_start"] == text.index(documents[1].content)
assert documents[2].content == "Sentence on page 2. Another on page 2.\f"
assert documents[2].meta["page_number"] == 2
assert documents[2].meta["split_id"] == 2
assert documents[2].meta["split_idx_start"] == text.index(documents[2].content)
assert documents[3].content == "Another on page 2.\fSentence on page 3. "
assert documents[3].meta["page_number"] == 2
assert documents[3].meta["split_id"] == 3
assert documents[3].meta["split_idx_start"] == text.index(documents[3].content)
assert documents[4].content == "Sentence on page 3. Another on page 3.\f\f "
assert documents[4].meta["page_number"] == 3
assert documents[4].meta["split_id"] == 4
assert documents[4].meta["split_idx_start"] == text.index(documents[4].content)
assert documents[5].content == "Another on page 3.\f\f Sentence on page 5."
assert documents[5].meta["page_number"] == 3
assert documents[5].meta["split_id"] == 5
assert documents[5].meta["split_idx_start"] == text.index(documents[5].content)
def test_respect_sentence_boundary_checks(self):
# this combination triggers the warning
splitter = DocumentSplitter(split_by="sentence", split_length=10, respect_sentence_boundary=True)
assert splitter.respect_sentence_boundary is False
def test_sentence_serialization(self):
"""Test serialization with NLTK sentence splitting configuration and using non-default values"""
splitter = DocumentSplitter(
split_by="sentence",
language="de",
use_split_rules=False,
extend_abbreviations=False,
respect_sentence_boundary=False,
)
serialized = splitter.to_dict()
deserialized = DocumentSplitter.from_dict(serialized)
assert deserialized.split_by == "sentence"
assert hasattr(deserialized, "sentence_splitter")
assert deserialized.language == "de"
assert deserialized.use_split_rules is False
assert deserialized.extend_abbreviations is False
assert deserialized.respect_sentence_boundary is False
def test_nltk_serialization_roundtrip(self):
"""Test complete serialization roundtrip with actual document splitting"""
splitter = DocumentSplitter(
split_by="sentence",
language="de",
use_split_rules=False,
extend_abbreviations=False,
respect_sentence_boundary=False,
)
serialized = splitter.to_dict()
deserialized_splitter = DocumentSplitter.from_dict(serialized)
assert splitter.split_by == deserialized_splitter.split_by
def test_respect_sentence_boundary_serialization(self):
"""Test serialization with respect_sentence_boundary option"""
splitter = DocumentSplitter(split_by="word", respect_sentence_boundary=True, language="de")
serialized = splitter.to_dict()
deserialized = DocumentSplitter.from_dict(serialized)
assert deserialized.respect_sentence_boundary is True
assert hasattr(deserialized, "sentence_splitter")
assert deserialized.language == "de"
def test_duplicate_pages_get_different_doc_id(self):
splitter = DocumentSplitter(split_by="page", split_length=1)
doc1 = Document(content="This is some text.\fThis is some text.\fThis is some text.\fThis is some text.")
result = splitter.run(documents=[doc1])
assert len({doc.id for doc in result["documents"]}) == 4
@@ -0,0 +1,833 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from dataclasses import replace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from haystack import Document
from haystack.components.embedders import OpenAIDocumentEmbedder
from haystack.components.preprocessors import EmbeddingBasedDocumentSplitter
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# disable tqdm entirely for tests
from tqdm import tqdm
tqdm.disable = True
class TestEmbeddingBasedDocumentSplitter:
def test_init(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=mock_embedder, sentences_per_group=2, percentile=0.9, min_length=50, max_length=1000
)
assert splitter.document_embedder == mock_embedder
assert splitter.sentences_per_group == 2
assert splitter.percentile == 0.9
assert splitter.min_length == 50
assert splitter.max_length == 1000
def test_init_invalid_sentences_per_group(self):
mock_embedder = Mock()
with pytest.raises(ValueError, match="sentences_per_group must be greater than 0"):
EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, sentences_per_group=0)
def test_init_invalid_percentile(self):
mock_embedder = Mock()
with pytest.raises(ValueError, match="percentile must be between 0.0 and 1.0"):
EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, percentile=1.5)
def test_init_invalid_min_length(self):
mock_embedder = Mock()
with pytest.raises(ValueError, match="min_length must be greater than or equal to 0"):
EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, min_length=-1)
def test_init_invalid_max_length(self):
mock_embedder = Mock()
with pytest.raises(ValueError, match="max_length must be greater than min_length"):
EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, min_length=100, max_length=50)
def test_run_invalid_input(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = Mock()
with pytest.raises(TypeError, match="expects a List of Documents"):
splitter.run(documents="not a list")
@pytest.mark.asyncio
async def test_run_invalid_input_async(self) -> None:
mock_embedder = AsyncMock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = AsyncMock()
with pytest.raises(TypeError, match="expects a List of Documents"):
await splitter.run_async(documents="not a list")
def test_run_document_with_none_content(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = Mock()
with pytest.raises(ValueError, match="content for document ID"):
splitter.run(documents=[Document(content=None)])
@pytest.mark.asyncio
async def test_run_document_with_none_content_async(self) -> None:
mock_embedder = AsyncMock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = AsyncMock()
with pytest.raises(ValueError, match="content for document ID"):
await splitter.run_async(documents=[Document(content=None)])
def test_run_empty_document(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = Mock()
result = splitter.run(documents=[Document(content="")])
assert result["documents"] == []
@pytest.mark.asyncio
async def test_run_empty_document_async(self) -> None:
mock_embedder = AsyncMock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.sentence_splitter = AsyncMock()
result = await splitter.run_async(documents=[Document(content="")])
assert result["documents"] == []
def test_group_sentences_single(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, sentences_per_group=1)
sentences = ["Sentence 1.", "Sentence 2.", "Sentence 3."]
groups = splitter._group_sentences(sentences)
assert groups == sentences
def test_group_sentences_multiple(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, sentences_per_group=2)
sentences = ["Sentence 1. ", "Sentence 2. ", "Sentence 3. ", "Sentence 4."]
groups = splitter._group_sentences(sentences)
assert groups == ["Sentence 1. Sentence 2. ", "Sentence 3. Sentence 4."]
def test_cosine_distance(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
# Test with identical vectors
embedding1 = [1.0, 0.0, 0.0]
embedding2 = [1.0, 0.0, 0.0]
distance = splitter._cosine_distance(embedding1, embedding2)
assert distance == 0.0
# Test with orthogonal vectors
embedding1 = [1.0, 0.0, 0.0]
embedding2 = [0.0, 1.0, 0.0]
distance = splitter._cosine_distance(embedding1, embedding2)
assert distance == 1.0
# Test with zero vectors
embedding1 = [0.0, 0.0, 0.0]
embedding2 = [1.0, 0.0, 0.0]
distance = splitter._cosine_distance(embedding1, embedding2)
assert distance == 1.0
def test_find_split_points_empty(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
split_points = splitter._find_split_points([])
assert split_points == []
split_points = splitter._find_split_points([[1.0, 0.0]])
assert split_points == []
def test_find_split_points(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, percentile=0.5)
# Create embeddings where the second pair has high distance
embeddings = [
[1.0, 0.0, 0.0], # Similar to next
[0.9, 0.1, 0.0], # Similar to previous
[0.0, 1.0, 0.0], # Very different from next
[0.1, 0.9, 0.0], # Similar to previous
]
split_points = splitter._find_split_points(embeddings)
# Should find a split point after the second embedding (index 2)
assert 2 in split_points
def test_create_splits_from_points(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
sentence_groups = ["Group 1 ", "Group 2 ", "Group 3 ", "Group 4"]
split_points = [2] # Split after index 1
splits = splitter._create_splits_from_points(sentence_groups, split_points)
assert splits == ["Group 1 Group 2 ", "Group 3 Group 4"]
def test_create_splits_from_points_no_points(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
sentence_groups = ["Group 1 ", "Group 2 ", "Group 3"]
split_points = []
splits = splitter._create_splits_from_points(sentence_groups, split_points)
assert splits == ["Group 1 Group 2 Group 3"]
def test_merge_small_splits(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, min_length=10)
splits = ["Short ", "Also short ", "Long enough text ", "Another short"]
merged = splitter._merge_small_splits(splits)
assert len(merged) == 3
assert merged[0] == "Short Also short "
assert merged[1] == "Long enough text "
assert merged[2] == "Another short"
def test_merge_small_splits_respect_max_length(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, min_length=10, max_length=15)
splits = ["123456", "123456789", "1234"]
merged = splitter._merge_small_splits(splits=splits)
assert len(merged) == 2
# First split remains beneath min_length b/c next split is too long
assert merged[0] == "123456"
# Second split is merged with third split to get above min_length and still beneath max_length
assert merged[1] == "1234567891234"
def test_create_documents_from_splits(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
original_doc = Document(content="test", meta={"key": "value"})
splits = ["Split 1", "Split 2"]
documents = splitter._create_documents_from_splits(splits, original_doc)
assert len(documents) == 2
assert documents[0].content == "Split 1"
assert documents[0].meta["source_id"] == original_doc.id
assert documents[0].meta["split_id"] == 0
assert documents[0].meta["key"] == "value"
assert documents[1].content == "Split 2"
assert documents[1].meta["split_id"] == 1
def test_create_documents_from_splits_with_page_numbers(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
original_doc = Document(content="Page 1 content.\fPage 2 content.\f\fPage 4 content.", meta={"key": "value"})
splits = ["Page 1 content.\f", "Page 2 content.\f\f", "Page 4 content."]
documents = splitter._create_documents_from_splits(splits, original_doc)
assert len(documents) == 3
assert documents[0].content == "Page 1 content.\f"
assert documents[0].meta["page_number"] == 1
assert documents[1].content == "Page 2 content.\f\f"
assert documents[1].meta["page_number"] == 2
assert documents[2].content == "Page 4 content."
assert documents[2].meta["page_number"] == 4
def test_create_documents_from_splits_with_consecutive_page_breaks(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
# Test with consecutive page breaks at the end
original_doc = Document(content="Page 1 content.\fPage 2 content.\f\f\f", meta={"key": "value"})
splits = ["Page 1 content.\f", "Page 2 content.\f\f\f"]
documents = splitter._create_documents_from_splits(splits, original_doc)
assert len(documents) == 2
assert documents[0].content == "Page 1 content.\f"
assert documents[0].meta["page_number"] == 1
assert documents[1].content == "Page 2 content.\f\f\f"
# Should be page 2, not 4, because consecutive page breaks at the end are adjusted
assert documents[1].meta["page_number"] == 2
def test_calculate_embeddings(self):
mock_embedder = Mock()
# Mock the document embedder to return documents with embeddings
def mock_run(documents):
return {"documents": [replace(doc, embedding=[1.0, 2.0, 3.0]) for doc in documents]}
mock_embedder.run = Mock(side_effect=mock_run)
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
sentence_groups = ["Group 1", "Group 2", "Group 3"]
embeddings = splitter._calculate_embeddings(sentence_groups)
assert len(embeddings) == 3
assert all(embedding == [1.0, 2.0, 3.0] for embedding in embeddings)
mock_embedder.run.assert_called_once()
@pytest.mark.asyncio
async def test_calculate_embeddings_async(self) -> None:
mock_embedder = AsyncMock()
# Mock the document embedder to return documents with embeddings
async def mock_run_async(documents):
return {"documents": [replace(doc, embedding=[1.0, 2.0, 3.0]) for doc in documents]}
mock_embedder.run_async = AsyncMock(side_effect=mock_run_async)
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
sentence_groups = ["Group 1", "Group 2", "Group 3"]
embeddings = await splitter._calculate_embeddings_async(sentence_groups)
assert len(embeddings) == 3
assert all(embedding == [1.0, 2.0, 3.0] for embedding in embeddings)
mock_embedder.run_async.assert_called_once()
def test_to_dict(self):
mock_embedder = Mock()
mock_embedder.to_dict.return_value = {"type": "MockEmbedder"}
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=mock_embedder, sentences_per_group=2, percentile=0.9, min_length=50, max_length=1000
)
result = splitter.to_dict()
assert "EmbeddingBasedDocumentSplitter" in result["type"]
assert result["init_parameters"]["sentences_per_group"] == 2
assert result["init_parameters"]["percentile"] == 0.9
assert result["init_parameters"]["min_length"] == 50
assert result["init_parameters"]["max_length"] == 1000
assert "document_embedder" in result["init_parameters"]
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_split_document_with_multiple_topics(self):
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=2, percentile=0.9, min_length=30, max_length=300
)
# A document with multiple topics
text = (
"The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. "
"Machine learning has revolutionized many industries. Neural networks can process vast amounts of data. "
"Deep learning models achieve remarkable accuracy on complex tasks. "
"Cooking is both an art and a science. Fresh ingredients make all the difference. "
"Proper seasoning enhances the natural flavors of food. "
"The history of ancient civilizations fascinates researchers. Archaeological discoveries reveal new insights. " # noqa: E501
"Ancient texts provide valuable information about past societies."
)
doc = Document(content=text)
result = splitter.run(documents=[doc])
split_docs = result["documents"]
# There should be more than one split
assert len(split_docs) > 1
# Each split should be non-empty and respect min_length
for split_doc in split_docs:
assert split_doc.content.strip() != ""
assert len(split_doc.content) >= 30
# The splits should cover the original text
combined = "".join([d.content for d in split_docs])
original = text
assert combined in original or original in combined
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_split_document_with_multiple_topics_async(self) -> None:
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=2, percentile=0.9, min_length=30, max_length=300
)
# A document with multiple topics
text = (
"The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. "
"Machine learning has revolutionized many industries. Neural networks can process vast amounts of data. "
"Deep learning models achieve remarkable accuracy on complex tasks. "
"Cooking is both an art and a science. Fresh ingredients make all the difference. "
"Proper seasoning enhances the natural flavors of food. "
"The history of ancient civilizations fascinates researchers. Archaeological discoveries reveal new insights. " # noqa: E501
"Ancient texts provide valuable information about past societies."
)
doc = Document(content=text)
result = await splitter.run_async(documents=[doc])
split_docs = result["documents"]
# There should be more than one split
assert len(split_docs) > 1
# Each split should be non-empty and respect min_length
for split_doc in split_docs:
assert split_doc.content.strip() != ""
assert len(split_doc.content) >= 30
# The splits should cover the original text
combined = "".join([d.content for d in split_docs])
original = text
assert combined in original or original in combined
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_trailing_whitespace_is_preserved(self):
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(document_embedder=embedder, sentences_per_group=1)
# Normal trailing whitespace
text = "The weather today is beautiful. "
result = splitter.run(documents=[Document(content=text)])
assert result["documents"][0].content == text
# Newline at the end
text = "The weather today is beautiful.\n"
result = splitter.run(documents=[Document(content=text)])
assert result["documents"][0].content == text
# Page break at the end
text = "The weather today is beautiful.\f"
result = splitter.run(documents=[Document(content=text)])
assert result["documents"][0].content == text
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_trailing_whitespace_is_preserved_async(self) -> None:
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(document_embedder=embedder, sentences_per_group=1)
# Normal trailing whitespace
text = "The weather today is beautiful. "
result = await splitter.run_async(documents=[Document(content=text)])
assert result["documents"][0].content == text
# Newline at the end
text = "The weather today is beautiful.\n"
result = await splitter.run_async(documents=[Document(content=text)])
assert result["documents"][0].content == text
# Page break at the end
text = "The weather today is beautiful.\f"
result = await splitter.run_async(documents=[Document(content=text)])
assert result["documents"][0].content == text
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_no_extra_whitespaces_between_sentences(self):
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=1, percentile=0.9, min_length=10, max_length=500
)
text = (
"The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. "
"There are no clouds and no rain. Machine learning has revolutionized many industries. "
"Neural networks can process vast amounts of data. Deep learning models achieve remarkable accuracy on complex tasks." # noqa: E501
)
doc = Document(content=text)
result = splitter.run(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 2
# Expect the original whitespace structure with trailing spaces where they exist
assert (
split_docs[0].content
== "The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. There are no clouds and no rain. " # noqa: E501
) # noqa: E501
assert (
split_docs[1].content
== "Machine learning has revolutionized many industries. Neural networks can process vast amounts of data. Deep learning models achieve remarkable accuracy on complex tasks." # noqa: E501
) # noqa: E501
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_no_extra_whitespaces_between_sentences_async(self) -> None:
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
splitter = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=1, percentile=0.9, min_length=10, max_length=500
)
text = (
"The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. "
"There are no clouds and no rain. Machine learning has revolutionized many industries. "
"Neural networks can process vast amounts of data. Deep learning models achieve remarkable accuracy on complex tasks." # noqa: E501
)
doc = Document(content=text)
result = await splitter.run_async(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 2
# Expect the original whitespace structure with trailing spaces where they exist
assert (
split_docs[0].content
== "The weather today is beautiful. The sun is shining brightly. The temperature is perfect for a walk. There are no clouds and no rain. " # noqa: E501
) # noqa: E501
assert (
split_docs[1].content
== "Machine learning has revolutionized many industries. Neural networks can process vast amounts of data. Deep learning models achieve remarkable accuracy on complex tasks." # noqa: E501
) # noqa: E501
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_split_large_splits_recursion(self):
"""
Test that _split_large_splits() works correctly without infinite loops.
This test uses a longer text that will trigger the recursive splitting logic.
If the chunk cannot be split further, it is allowed to be larger than max_length.
"""
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
semantic_chunker = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=5, percentile=0.95, min_length=50, max_length=1000
)
text = """# Artificial intelligence and its Impact on Society
## Article from Wikipedia, the free encyclopedia
### Introduction to Artificial Intelligence
Artificial intelligence (AI) is the capability of computational systems to perform tasks typically associated with human intelligence, such as learning, reasoning, problem-solving, perception, and decision-making. It is a field of research in computer science that develops and studies methods and software that enable machines to perceive their environment and use learning and intelligence to take actions that maximize their chances of achieving defined goals.
### The History of Software
The history of software is closely tied to the development of digital computers in the mid-20th century. Early programs were written in the machine language specific to the hardware. The introduction of high-level programming languages in 1958 allowed for more human-readable instructions, making software development easier and more portable across different computer architectures. Software in a programming language is run through a compiler or interpreter to execute on the architecture's hardware. Over time, software has become complex, owing to developments in networking, operating systems, and databases.""" # noqa: E501
doc = Document(content=text)
result = semantic_chunker.run(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 1
# If the chunk cannot be split further, it is allowed to be larger than max_length
# At least one split should be larger than max_length in this test case
assert any(len(split_doc.content) > 1000 for split_doc in split_docs)
# Verify that the splits cover the original content
combined_content = "".join([d.content for d in split_docs])
assert combined_content == text
for i, split_doc in enumerate(split_docs):
assert split_doc.meta["source_id"] == doc.id
assert split_doc.meta["split_id"] == i
assert "page_number" in split_doc.meta
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_split_large_splits_recursion_async(self) -> None:
"""
Test that _split_large_splits() works correctly without infinite loops.
This test uses a longer text that will trigger the recursive splitting logic.
If the chunk cannot be split further, it is allowed to be larger than max_length.
"""
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
semantic_chunker = EmbeddingBasedDocumentSplitter(
document_embedder=embedder, sentences_per_group=5, percentile=0.95, min_length=50, max_length=1000
)
text = """# Artificial intelligence and its Impact on Society
## Article from Wikipedia, the free encyclopedia
### Introduction to Artificial Intelligence
Artificial intelligence (AI) is the capability of computational systems to perform tasks typically associated with human intelligence, such as learning, reasoning, problem-solving, perception, and decision-making. It is a field of research in computer science that develops and studies methods and software that enable machines to perceive their environment and use learning and intelligence to take actions that maximize their chances of achieving defined goals.
### The History of Software
The history of software is closely tied to the development of digital computers in the mid-20th century. Early programs were written in the machine language specific to the hardware. The introduction of high-level programming languages in 1958 allowed for more human-readable instructions, making software development easier and more portable across different computer architectures. Software in a programming language is run through a compiler or interpreter to execute on the architecture's hardware. Over time, software has become complex, owing to developments in networking, operating systems, and databases.""" # noqa: E501
doc = Document(content=text)
result = await semantic_chunker.run_async(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 1
# If the chunk cannot be split further, it is allowed to be larger than max_length
# At least one split should be larger than max_length in this test case
assert any(len(split_doc.content) > 1000 for split_doc in split_docs)
# Verify that the splits cover the original content
combined_content = "".join([d.content for d in split_docs])
assert combined_content == text
for i, split_doc in enumerate(split_docs):
assert split_doc.meta["source_id"] == doc.id
assert split_doc.meta["split_id"] == i
assert "page_number" in split_doc.meta
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_split_large_splits_actually_splits(self):
"""
Test that _split_large_splits() actually works and can split long texts into multiple chunks.
This test uses a very long text that should be split into multiple chunks.
"""
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
semantic_chunker = EmbeddingBasedDocumentSplitter(
document_embedder=embedder,
sentences_per_group=3,
percentile=0.85, # Lower percentile to create more splits
min_length=100,
max_length=500, # Smaller max_length to force more splits
)
# Create a very long text with multiple paragraphs and topics
text = """# Comprehensive Guide to Machine Learning and Artificial Intelligence
## Introduction to Machine Learning
Machine learning is a subset of artificial intelligence that focuses on the development of computer programs that can access data and use it to learn for themselves. The process of learning begins with observations or data, such as examples, direct experience, or instruction, in order to look for patterns in data and make better decisions in the future based on the examples that we provide. The primary aim is to allow the computers learn automatically without human intervention or assistance and adjust actions accordingly.
## Types of Machine Learning
There are several types of machine learning algorithms, each with their own strengths and weaknesses. Supervised learning involves training a model on a labeled dataset, where the correct answers are provided. The model learns to map inputs to outputs based on these examples. Unsupervised learning, on the other hand, deals with unlabeled data and seeks to find hidden patterns or structures within the data. Reinforcement learning is a type of learning where an agent learns to behave in an environment by performing certain actions and receiving rewards or penalties.
## Deep Learning and Neural Networks
Deep learning is a subset of machine learning that uses neural networks with multiple layers to model and understand complex patterns. Neural networks are inspired by the human brain and consist of interconnected nodes or neurons. Each connection between neurons has a weight that is adjusted during training. The network learns by adjusting these weights based on the error between predicted and actual outputs. Deep learning has been particularly successful in areas such as computer vision, natural language processing, and speech recognition.
\f
## Natural Language Processing
Natural Language Processing (NLP) is a field of artificial intelligence that focuses on the interaction between computers and human language. It involves developing algorithms and models that can understand, interpret, and generate human language. NLP applications include machine translation, sentiment analysis, text summarization, and question answering systems. Recent advances in deep learning have significantly improved the performance of NLP systems, leading to more accurate and sophisticated language models.
## Computer Vision and Image Recognition
Computer vision is another important area of artificial intelligence that deals with how computers can gain high-level understanding from digital images or videos. It involves developing algorithms that can identify and understand visual information from the world. Applications include facial recognition, object detection, medical image analysis, and autonomous vehicle navigation. Deep learning models, particularly convolutional neural networks (CNNs), have revolutionized computer vision by achieving human-level performance on many tasks.
## The Future of Artificial Intelligence
The future of artificial intelligence holds immense potential for transforming various industries and aspects of human life. We can expect to see more sophisticated AI systems that can handle complex reasoning tasks, understand context better, and interact more naturally with humans. However, this rapid advancement also brings challenges related to ethics, privacy, and the impact on employment. It's crucial to develop AI systems that are not only powerful but also safe, fair, and beneficial to society as a whole.
\f
## Ethical Considerations in AI
As artificial intelligence becomes more prevalent, ethical considerations become increasingly important. Issues such as bias in AI systems, privacy concerns, and the potential for misuse need to be carefully addressed. AI systems can inherit biases from their training data, leading to unfair outcomes for certain groups. Privacy concerns arise from the vast amounts of data required to train AI systems. Additionally, there are concerns about the potential for AI to be used maliciously or to replace human workers in certain industries.
## Applications in Healthcare
Artificial intelligence has the potential to revolutionize healthcare by improving diagnosis, treatment planning, and patient care. Machine learning algorithms can analyze medical images to detect diseases earlier and more accurately than human doctors. AI systems can also help in drug discovery by predicting the effectiveness of potential treatments. In addition, AI-powered chatbots and virtual assistants can provide basic healthcare information and support to patients, reducing the burden on healthcare professionals.
## AI in Finance and Banking
The financial industry has been quick to adopt artificial intelligence for various applications. AI systems can analyze market data to make investment decisions, detect fraudulent transactions, and provide personalized financial advice. Machine learning algorithms can assess credit risk more accurately than traditional methods, leading to better lending decisions. Additionally, AI-powered chatbots can handle customer service inquiries, reducing costs and improving customer satisfaction.
\f
## Transportation and Autonomous Vehicles
Autonomous vehicles represent one of the most visible applications of artificial intelligence in transportation. Self-driving cars use a combination of sensors, cameras, and AI algorithms to navigate roads safely. These systems can detect obstacles, read traffic signs, and make decisions about speed and direction. Beyond autonomous cars, AI is also being used in logistics and supply chain management to optimize routes and reduce delivery times.
## Education and Personalized Learning
Artificial intelligence is transforming education by enabling personalized learning experiences. AI systems can adapt to individual student needs, providing customized content and pacing. Intelligent tutoring systems can provide immediate feedback and support to students, helping them learn more effectively. Additionally, AI can help educators by automating administrative tasks and providing insights into student performance and learning patterns.""" # noqa: E501
doc = Document(content=text)
result = semantic_chunker.run(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 11
# Verify that the splits cover the original content
combined_content = "".join([d.content for d in split_docs])
assert combined_content == text
for i, split_doc in enumerate(split_docs):
assert split_doc.meta["source_id"] == doc.id
assert split_doc.meta["split_id"] == i
assert "page_number" in split_doc.meta
if i in [0, 1, 2, 3]:
assert split_doc.meta["page_number"] == 1
if i in [4, 5, 6]:
assert split_doc.meta["page_number"] == 2
if i in [7, 8]:
assert split_doc.meta["page_number"] == 3
if i in [9, 10]:
assert split_doc.meta["page_number"] == 4
@pytest.mark.asyncio
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
async def test_split_large_splits_actually_splits_async(self) -> None:
"""
Test that _split_large_splits() actually works and can split long texts into multiple chunks.
This test uses a very long text that should be split into multiple chunks.
"""
embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small")
semantic_chunker = EmbeddingBasedDocumentSplitter(
document_embedder=embedder,
sentences_per_group=3,
percentile=0.85, # Lower percentile to create more splits
min_length=100,
max_length=500, # Smaller max_length to force more splits
)
# Create a very long text with multiple paragraphs and topics
text = """# Comprehensive Guide to Machine Learning and Artificial Intelligence
## Introduction to Machine Learning
Machine learning is a subset of artificial intelligence that focuses on the development of computer programs that can access data and use it to learn for themselves. The process of learning begins with observations or data, such as examples, direct experience, or instruction, in order to look for patterns in data and make better decisions in the future based on the examples that we provide. The primary aim is to allow the computers learn automatically without human intervention or assistance and adjust actions accordingly.
## Types of Machine Learning
There are several types of machine learning algorithms, each with their own strengths and weaknesses. Supervised learning involves training a model on a labeled dataset, where the correct answers are provided. The model learns to map inputs to outputs based on these examples. Unsupervised learning, on the other hand, deals with unlabeled data and seeks to find hidden patterns or structures within the data. Reinforcement learning is a type of learning where an agent learns to behave in an environment by performing certain actions and receiving rewards or penalties.
## Deep Learning and Neural Networks
Deep learning is a subset of machine learning that uses neural networks with multiple layers to model and understand complex patterns. Neural networks are inspired by the human brain and consist of interconnected nodes or neurons. Each connection between neurons has a weight that is adjusted during training. The network learns by adjusting these weights based on the error between predicted and actual outputs. Deep learning has been particularly successful in areas such as computer vision, natural language processing, and speech recognition.
\f
## Natural Language Processing
Natural Language Processing (NLP) is a field of artificial intelligence that focuses on the interaction between computers and human language. It involves developing algorithms and models that can understand, interpret, and generate human language. NLP applications include machine translation, sentiment analysis, text summarization, and question answering systems. Recent advances in deep learning have significantly improved the performance of NLP systems, leading to more accurate and sophisticated language models.
## Computer Vision and Image Recognition
Computer vision is another important area of artificial intelligence that deals with how computers can gain high-level understanding from digital images or videos. It involves developing algorithms that can identify and understand visual information from the world. Applications include facial recognition, object detection, medical image analysis, and autonomous vehicle navigation. Deep learning models, particularly convolutional neural networks (CNNs), have revolutionized computer vision by achieving human-level performance on many tasks.
## The Future of Artificial Intelligence
The future of artificial intelligence holds immense potential for transforming various industries and aspects of human life. We can expect to see more sophisticated AI systems that can handle complex reasoning tasks, understand context better, and interact more naturally with humans. However, this rapid advancement also brings challenges related to ethics, privacy, and the impact on employment. It's crucial to develop AI systems that are not only powerful but also safe, fair, and beneficial to society as a whole.
\f
## Ethical Considerations in AI
As artificial intelligence becomes more prevalent, ethical considerations become increasingly important. Issues such as bias in AI systems, privacy concerns, and the potential for misuse need to be carefully addressed. AI systems can inherit biases from their training data, leading to unfair outcomes for certain groups. Privacy concerns arise from the vast amounts of data required to train AI systems. Additionally, there are concerns about the potential for AI to be used maliciously or to replace human workers in certain industries.
## Applications in Healthcare
Artificial intelligence has the potential to revolutionize healthcare by improving diagnosis, treatment planning, and patient care. Machine learning algorithms can analyze medical images to detect diseases earlier and more accurately than human doctors. AI systems can also help in drug discovery by predicting the effectiveness of potential treatments. In addition, AI-powered chatbots and virtual assistants can provide basic healthcare information and support to patients, reducing the burden on healthcare professionals.
## AI in Finance and Banking
The financial industry has been quick to adopt artificial intelligence for various applications. AI systems can analyze market data to make investment decisions, detect fraudulent transactions, and provide personalized financial advice. Machine learning algorithms can assess credit risk more accurately than traditional methods, leading to better lending decisions. Additionally, AI-powered chatbots can handle customer service inquiries, reducing costs and improving customer satisfaction.
\f
## Transportation and Autonomous Vehicles
Autonomous vehicles represent one of the most visible applications of artificial intelligence in transportation. Self-driving cars use a combination of sensors, cameras, and AI algorithms to navigate roads safely. These systems can detect obstacles, read traffic signs, and make decisions about speed and direction. Beyond autonomous cars, AI is also being used in logistics and supply chain management to optimize routes and reduce delivery times.
## Education and Personalized Learning
Artificial intelligence is transforming education by enabling personalized learning experiences. AI systems can adapt to individual student needs, providing customized content and pacing. Intelligent tutoring systems can provide immediate feedback and support to students, helping them learn more effectively. Additionally, AI can help educators by automating administrative tasks and providing insights into student performance and learning patterns.""" # noqa: E501
doc = Document(content=text)
result = await semantic_chunker.run_async(documents=[doc])
split_docs = result["documents"]
assert len(split_docs) == 11
# Verify that the splits cover the original content
combined_content = "".join([d.content for d in split_docs])
assert combined_content == text
for i, split_doc in enumerate(split_docs):
assert split_doc.meta["source_id"] == doc.id
assert split_doc.meta["split_id"] == i
assert "page_number" in split_doc.meta
if i in [0, 1, 2, 3]:
assert split_doc.meta["page_number"] == 1
if i in [4, 5, 6]:
assert split_doc.meta["page_number"] == 2
if i in [7, 8]:
assert split_doc.meta["page_number"] == 3
if i in [9, 10]:
assert split_doc.meta["page_number"] == 4
class TestComponentLifecycle:
def test_warm_up_builds_splitter_and_delegates_to_embedder(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch(
"haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"
) as mock_splitter_class:
splitter.warm_up()
assert splitter.sentence_splitter is mock_splitter_class.return_value
mock_splitter_class.assert_called_once()
mock_embedder.warm_up.assert_called_once()
def test_warm_up_builds_splitter_once(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch(
"haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"
) as mock_splitter_class:
splitter.warm_up()
first_splitter = splitter.sentence_splitter
splitter.warm_up()
mock_splitter_class.assert_called_once()
assert splitter.sentence_splitter is first_splitter
@pytest.mark.asyncio
async def test_warm_up_async_delegates_to_embedder_async(self) -> None:
mock_embedder = Mock()
mock_embedder.warm_up_async = AsyncMock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
await splitter.warm_up_async()
mock_embedder.warm_up_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_warm_up_async_falls_back_to_sync_warm_up(self) -> None:
mock_embedder = Mock(spec=["warm_up"])
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
await splitter.warm_up_async()
mock_embedder.warm_up.assert_called_once()
def test_close_delegates_to_embedder(self):
mock_embedder = Mock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
splitter.close()
mock_embedder.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_async_delegates_to_embedder(self) -> None:
mock_embedder = Mock()
mock_embedder.close_async = AsyncMock()
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
await splitter.close_async()
mock_embedder.close_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_async_falls_back_to_sync_close(self) -> None:
mock_embedder = Mock(spec=["close"])
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
await splitter.close_async()
mock_embedder.close.assert_called_once()
def test_lifecycle_is_safe_when_embedder_lacks_methods(self):
mock_embedder = Mock(spec=[])
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
splitter.warm_up()
splitter.close()
@pytest.mark.asyncio
async def test_lifecycle_is_safe_when_embedder_lacks_methods_async(self) -> None:
mock_embedder = Mock(spec=[])
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
await splitter.warm_up_async()
await splitter.close_async()
@@ -0,0 +1,269 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, Pipeline
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.writers import DocumentWriter
class TestHierarchicalDocumentSplitter:
def test_init_with_default_params(self):
builder = HierarchicalDocumentSplitter(block_sizes={100, 200, 300})
assert builder.block_sizes == [300, 200, 100]
assert builder.split_overlap == 0
assert builder.split_by == "word"
def test_init_with_custom_params(self):
builder = HierarchicalDocumentSplitter(block_sizes={100, 200, 300}, split_overlap=25, split_by="word")
assert builder.block_sizes == [300, 200, 100]
assert builder.split_overlap == 25
assert builder.split_by == "word"
def test_init_with_empty_block_sizes_raises(self):
with pytest.raises(ValueError, match="block_sizes must not be empty"):
HierarchicalDocumentSplitter(block_sizes=set())
def test_init_with_negative_split_overlap_raises(self):
with pytest.raises(ValueError, match="split_overlap must be greater than or equal to 0"):
HierarchicalDocumentSplitter(block_sizes={10, 5, 2}, split_overlap=-1)
def test_init_with_split_overlap_equal_to_smallest_block_size_raises(self):
with pytest.raises(ValueError, match="split_overlap .* must be less than the smallest value in block_sizes"):
HierarchicalDocumentSplitter(block_sizes={10, 5, 2}, split_overlap=2)
def test_init_with_split_overlap_greater_than_smallest_block_size_raises(self):
with pytest.raises(ValueError, match="split_overlap .* must be less than the smallest value in block_sizes"):
HierarchicalDocumentSplitter(block_sizes={10, 5, 2}, split_overlap=3)
def test_to_dict(self):
builder = HierarchicalDocumentSplitter(block_sizes={100, 200, 300}, split_overlap=25, split_by="word")
expected = builder.to_dict()
assert expected == {
"type": "haystack.components.preprocessors.hierarchical_document_splitter.HierarchicalDocumentSplitter",
"init_parameters": {"block_sizes": [300, 200, 100], "split_overlap": 25, "split_by": "word"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.preprocessors.hierarchical_document_splitter.HierarchicalDocumentSplitter",
"init_parameters": {"block_sizes": [10, 5, 2], "split_overlap": 0, "split_by": "word"},
}
builder = HierarchicalDocumentSplitter.from_dict(data)
assert builder.block_sizes == [10, 5, 2]
assert builder.split_overlap == 0
assert builder.split_by == "word"
def test_run(self):
builder = HierarchicalDocumentSplitter(block_sizes={10, 5, 2}, split_overlap=0, split_by="word")
text = "one two three four five six seven eight nine ten"
doc = Document(content=text)
output = builder.run([doc])
docs = output["documents"]
builder.run([doc])
assert len(docs) == 9
assert docs[0].content == "one two three four five six seven eight nine ten"
# level 1 - root node
assert docs[0].meta["__level"] == 0
assert len(docs[0].meta["__children_ids"]) == 2
# level 2 -left branch
assert docs[1].meta["__parent_id"] == docs[0].id
assert docs[1].meta["__level"] == 1
assert len(docs[1].meta["__children_ids"]) == 3
# level 2 - right branch
assert docs[2].meta["__parent_id"] == docs[0].id
assert docs[2].meta["__level"] == 1
assert len(docs[2].meta["__children_ids"]) == 3
# level 3 - left branch - leaf nodes
assert docs[3].meta["__parent_id"] == docs[1].id
assert docs[4].meta["__parent_id"] == docs[1].id
assert docs[5].meta["__parent_id"] == docs[1].id
assert docs[3].meta["__level"] == 2
assert docs[4].meta["__level"] == 2
assert docs[5].meta["__level"] == 2
assert len(docs[3].meta["__children_ids"]) == 0
assert len(docs[4].meta["__children_ids"]) == 0
assert len(docs[5].meta["__children_ids"]) == 0
# level 3 - right branch - leaf nodes
assert docs[6].meta["__parent_id"] == docs[2].id
assert docs[7].meta["__parent_id"] == docs[2].id
assert docs[8].meta["__parent_id"] == docs[2].id
assert docs[6].meta["__level"] == 2
assert docs[7].meta["__level"] == 2
assert docs[8].meta["__level"] == 2
assert len(docs[6].meta["__children_ids"]) == 0
assert len(docs[7].meta["__children_ids"]) == 0
assert len(docs[8].meta["__children_ids"]) == 0
def test_run_does_not_mutate_input_document(self):
builder = HierarchicalDocumentSplitter(block_sizes={5, 2}, split_overlap=0, split_by="word")
doc = Document(content="one two three four five six seven eight", meta={"source": "test"})
builder.run([doc])
# the caller's Document should be untouched
assert doc.meta == {"source": "test"}
for key in ("__block_size", "__parent_id", "__children_ids", "__level"):
assert key not in doc.meta
def test_to_dict_in_pipeline(self, in_memory_doc_store):
pipeline = Pipeline()
hierarchical_doc_builder = HierarchicalDocumentSplitter(block_sizes={10, 5, 2})
doc_writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline.add_component(name="hierarchical_doc_splitter", instance=hierarchical_doc_builder)
pipeline.add_component(name="doc_writer", instance=doc_writer)
pipeline.connect("hierarchical_doc_splitter", "doc_writer")
expected = pipeline.to_dict()
assert expected.keys() == {
"connections",
"connection_type_validation",
"components",
"max_runs_per_component",
"metadata",
}
assert expected["components"].keys() == {"hierarchical_doc_splitter", "doc_writer"}
assert expected["components"]["hierarchical_doc_splitter"] == {
"type": "haystack.components.preprocessors.hierarchical_document_splitter.HierarchicalDocumentSplitter",
"init_parameters": {"block_sizes": [10, 5, 2], "split_overlap": 0, "split_by": "word"},
}
def test_from_dict_in_pipeline(self):
data = {
"metadata": {},
"max_runs_per_component": 100,
"components": {
"hierarchical_document_splitter": {
"type": "haystack.components.preprocessors.hierarchical_document_splitter.HierarchicalDocumentSplitter", # noqa: E501
"init_parameters": {"block_sizes": [10, 5, 2], "split_overlap": 0, "split_by": "word"},
},
"doc_writer": {
"type": "haystack.components.writers.document_writer.DocumentWriter",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "f32ad5bf-43cb-4035-9823-1de1ae9853c1",
"shared": True,
},
},
"policy": "NONE",
},
},
},
"connections": [{"sender": "hierarchical_document_splitter.documents", "receiver": "doc_writer.documents"}],
}
assert Pipeline.from_dict(data)
@pytest.mark.integration
def test_example_in_pipeline(self, in_memory_doc_store):
pipeline = Pipeline()
hierarchical_doc_builder = HierarchicalDocumentSplitter(
block_sizes={10, 5, 2}, split_overlap=0, split_by="word"
)
doc_writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline.add_component(name="hierarchical_doc_splitter", instance=hierarchical_doc_builder)
pipeline.add_component(name="doc_writer", instance=doc_writer)
pipeline.connect("hierarchical_doc_splitter.documents", "doc_writer")
text = "one two three four five six seven eight nine ten"
doc = Document(content=text)
docs = pipeline.run({"hierarchical_doc_splitter": {"documents": [doc]}})
assert docs["doc_writer"]["documents_written"] == 9
assert len(in_memory_doc_store.storage.values()) == 9
def test_serialization_deserialization_pipeline(self, in_memory_doc_store):
pipeline = Pipeline()
hierarchical_doc_builder = HierarchicalDocumentSplitter(
block_sizes={10, 5, 2}, split_overlap=0, split_by="word"
)
doc_writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline.add_component(name="hierarchical_doc_splitter", instance=hierarchical_doc_builder)
pipeline.add_component(name="doc_writer", instance=doc_writer)
pipeline.connect("hierarchical_doc_splitter.documents", "doc_writer")
pipeline_dict = pipeline.to_dict()
new_pipeline = Pipeline.from_dict(pipeline_dict)
assert new_pipeline == pipeline
def test_split_by_sentence_assure_warm_up_was_called(self, in_memory_doc_store):
pipeline = Pipeline()
hierarchical_doc_builder = HierarchicalDocumentSplitter(
block_sizes={10, 5, 2}, split_overlap=0, split_by="sentence"
)
doc_writer = DocumentWriter(document_store=in_memory_doc_store)
pipeline.add_component(name="hierarchical_doc_splitter", instance=hierarchical_doc_builder)
pipeline.add_component(name="doc_writer", instance=doc_writer)
pipeline.connect("hierarchical_doc_splitter.documents", "doc_writer")
text = "This is one sentence. This is another sentence. This is the third sentence."
doc = Document(content=text)
docs = pipeline.run({"hierarchical_doc_splitter": {"documents": [doc]}})
assert docs["doc_writer"]["documents_written"] == 3
assert len(in_memory_doc_store.storage.values()) == 3
def test_hierarchical_splitter_multiple_block_sizes(self):
# Test with three different block sizes
doc = Document(
content="This is a simple test document with multiple sentences. It should be split into various sizes. "
"This helps test the hierarchy."
)
# Using three block sizes: 10, 5, 2 words
splitter = HierarchicalDocumentSplitter(block_sizes={10, 5, 2}, split_overlap=0, split_by="word")
result = splitter.run([doc])
documents = result["documents"]
# Verify root document
assert len(documents) > 1
root = documents[0]
assert root.meta["__level"] == 0
assert root.meta["__parent_id"] is None
# Verify level 1 documents (block_size=10)
level_1_docs = [d for d in documents if d.meta["__level"] == 1]
for doc in level_1_docs:
assert doc.meta["__block_size"] == 10
assert doc.meta["__parent_id"] == root.id
# Verify level 2 documents (block_size=5)
level_2_docs = [d for d in documents if d.meta["__level"] == 2]
for doc in level_2_docs:
assert doc.meta["__block_size"] == 5
assert doc.meta["__parent_id"] in [d.id for d in level_1_docs]
# Verify level 3 documents (block_size=2)
level_3_docs = [d for d in documents if d.meta["__level"] == 3]
for doc in level_3_docs:
assert doc.meta["__block_size"] == 2
assert doc.meta["__parent_id"] in [d.id for d in level_2_docs]
# Verify children references
for doc in documents:
if doc.meta["__children_ids"]:
child_ids = doc.meta["__children_ids"]
children = [d for d in documents if d.id in child_ids]
for child in children:
assert child.meta["__parent_id"] == doc.id
assert child.meta["__level"] == doc.meta["__level"] + 1
@@ -0,0 +1,840 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections import defaultdict
from unittest.mock import ANY
import pytest
from haystack import Document
from haystack.components.preprocessors.markdown_header_splitter import MarkdownHeaderSplitter
# Fixtures
@pytest.fixture
def sample_text():
return (
"# Header 1\n"
"Content under header 1.\n"
"## Header 1.1\n"
"### Subheader 1.1.1\n"
"Content under sub-header 1.1.1\n"
"## Header 1.2\n"
"### Subheader 1.2.1\n"
"Content under header 1.2.1.\n"
"### Subheader 1.2.2\n"
"Content under header 1.2.2.\n"
"### Subheader 1.2.3\n"
"Content under header 1.2.3."
)
@pytest.fixture
def sample_text_with_page_breaks():
return (
"# Header 1\n"
"Content under header 1.\n\f\n"
"## Header 1.1\n"
"### Subheader 1.1.1\n"
"Content under sub-header 1.1.1\n\f\n"
"## Header 1.2\n"
"### Subheader 1.2.1\n"
"Content under header 1.2.1.\n\f\n"
"### Subheader 1.2.2\n"
"Content under header 1.2.2.\n\f\n"
"### Subheader 1.2.3\n"
"Content under header 1.2.3."
)
# Basic splitting and structure
def test_basic_split(sample_text):
splitter = MarkdownHeaderSplitter()
docs = [Document(content=sample_text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Check that content is present and correct
# Test first split
header1_doc = split_docs[0]
assert header1_doc.meta["split_id"] == 0
assert header1_doc.meta["page_number"] == 1
assert header1_doc.content == "# Header 1\nContent under header 1.\n"
# Test second split
subheader111_doc = split_docs[1]
assert subheader111_doc.meta["split_id"] == 1
assert subheader111_doc.meta["page_number"] == 1
assert subheader111_doc.content == "## Header 1.1\n### Subheader 1.1.1\nContent under sub-header 1.1.1\n"
# Test third split
subheader121_doc = split_docs[2]
assert subheader121_doc.meta["split_id"] == 2
assert subheader121_doc.meta["page_number"] == 1
assert subheader121_doc.content == "## Header 1.2\n### Subheader 1.2.1\nContent under header 1.2.1.\n"
# Test fourth split
subheader122_doc = split_docs[3]
assert subheader122_doc.meta["split_id"] == 3
assert subheader122_doc.meta["page_number"] == 1
assert subheader122_doc.content == "### Subheader 1.2.2\nContent under header 1.2.2.\n"
# Test fifth split
subheader123_doc = split_docs[4]
assert subheader123_doc.meta["split_id"] == 4
assert subheader123_doc.meta["page_number"] == 1
assert subheader123_doc.content == "### Subheader 1.2.3\nContent under header 1.2.3."
# Reconstruct original text
reconstructed_doc = "".join([doc.content for doc in split_docs])
assert reconstructed_doc == sample_text
def test_keep_headers_preserves_parent_headers_for_first_child():
text = (
"# Header 1\n"
"Intro text\n\n"
"## Header 1.1\n"
"Text 1\n\n"
"## Header 1.2\n"
"Text 2\n\n"
"### Header 1.2.1\n"
"Text 3\n\n"
"### Header 1.2.2\n"
"Text 4\n"
)
splitter = MarkdownHeaderSplitter(keep_headers=True)
split_docs = splitter.run(documents=[Document(content=text)])["documents"]
assert [(doc.meta["header"], doc.meta["parent_headers"]) for doc in split_docs] == [
("Header 1", []),
("Header 1.1", ["Header 1"]),
("Header 1.2", ["Header 1"]),
("Header 1.2.1", ["Header 1", "Header 1.2"]),
("Header 1.2.2", ["Header 1", "Header 1.2"]),
]
# reconstruct original text
reconstructed_text = "".join(doc.content for doc in split_docs)
assert reconstructed_text == text
def test_split_without_headers(sample_text):
splitter = MarkdownHeaderSplitter(keep_headers=False)
docs = [Document(content=sample_text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Should split into all headers with content
headers = [doc.meta["header"] for doc in split_docs]
assert "Header 1" in headers
assert "Subheader 1.1.1" in headers
assert "Subheader 1.2.1" in headers
assert "Subheader 1.2.2" in headers
assert "Subheader 1.2.3" in headers
# Check that content is present and correct
# Test first split
header1_doc = split_docs[0]
assert header1_doc.meta["header"] == "Header 1"
assert header1_doc.meta["split_id"] == 0
assert header1_doc.meta["page_number"] == 1
assert header1_doc.meta["parent_headers"] == []
assert header1_doc.content == "\nContent under header 1.\n"
# Test second split
subheader111_doc = split_docs[1]
assert subheader111_doc.meta["header"] == "Subheader 1.1.1"
assert subheader111_doc.meta["split_id"] == 1
assert subheader111_doc.meta["page_number"] == 1
assert subheader111_doc.meta["parent_headers"] == ["Header 1", "Header 1.1"]
assert subheader111_doc.content == "\nContent under sub-header 1.1.1\n"
# Test third split
subheader121_doc = split_docs[2]
assert subheader121_doc.meta["header"] == "Subheader 1.2.1"
assert subheader121_doc.meta["split_id"] == 2
assert subheader121_doc.meta["page_number"] == 1
assert subheader121_doc.meta["parent_headers"] == ["Header 1", "Header 1.2"]
assert subheader121_doc.content == "\nContent under header 1.2.1.\n"
# Test fourth split
subheader122_doc = split_docs[3]
assert subheader122_doc.meta["header"] == "Subheader 1.2.2"
assert subheader122_doc.meta["split_id"] == 3
assert subheader122_doc.meta["page_number"] == 1
assert subheader122_doc.meta["parent_headers"] == ["Header 1", "Header 1.2"]
assert subheader122_doc.content == "\nContent under header 1.2.2.\n"
# Test fifth split
subheader123_doc = split_docs[4]
assert subheader123_doc.meta["header"] == "Subheader 1.2.3"
assert subheader123_doc.meta["split_id"] == 4
assert subheader123_doc.meta["page_number"] == 1
assert subheader123_doc.meta["parent_headers"] == ["Header 1", "Header 1.2"]
assert subheader123_doc.content == "\nContent under header 1.2.3."
def test_split_no_headers():
splitter = MarkdownHeaderSplitter()
docs = [Document(content="No headers here."), Document(content="Just some text without headers.")]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Should return one doc per input, and no header key in meta
assert len(split_docs) == 2
for doc in split_docs:
assert "header" not in doc.meta
# Sanity Checks
assert split_docs[0].content == docs[0].content
assert split_docs[1].content == docs[1].content
def test_split_multiple_documents(sample_text):
splitter = MarkdownHeaderSplitter(keep_headers=False)
docs = [
Document(content=sample_text),
Document(content="# Another Header\nSome content."),
Document(content="# H1\nA"),
Document(content="# H2\nB"),
]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 8
# First 5 splits are from sample_text
assert split_docs[5].meta["header"] == "Another Header"
assert split_docs[6].meta["header"] == "H1"
assert split_docs[7].meta["header"] == "H2"
# Verify that split_ids are per-parent-document
splits_by_source = defaultdict(list)
for doc in split_docs:
splits_by_source[doc.meta["source_id"]].append(doc.meta["split_id"])
# Each parent document should have split_ids starting from 0
for split_ids in splits_by_source.values():
assert split_ids == list(range(len(split_ids)))
def test_split_only_headers():
text = "# H1\n# H2\n# H3"
splitter = MarkdownHeaderSplitter()
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Return doc without content unchunked
assert len(split_docs) == 1
assert split_docs[0].content == text
# Metadata preservation
def test_preserve_document_metadata():
"""Test that document metadata is preserved through splitting."""
splitter = MarkdownHeaderSplitter(keep_headers=False) # keep_headers=True case is covered by this test too
docs = [Document(content="# Header\nContent", meta={"source": "test", "importance": "high", "custom_field": 123})]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Original metadata should be preserved
assert split_docs[0].meta["source"] == "test"
assert split_docs[0].meta["importance"] == "high"
assert split_docs[0].meta["custom_field"] == 123
# New metadata should be added
assert "header" in split_docs[0].meta
assert split_docs[0].meta["header"] == "Header"
assert "split_id" in split_docs[0].meta
assert split_docs[0].meta["split_id"] == 0
assert split_docs[0].content == "\nContent"
def test_secondary_split_keeps_content_before_embedded_header():
"""With keep_headers=False, prose before an embedded lower-level header must
not be dropped during the secondary split."""
splitter = MarkdownHeaderSplitter(
keep_headers=False, header_split_levels=[1], secondary_split="word", split_length=100
)
docs = splitter.run(documents=[Document(content="# Main\nintro paragraph text\n## Sub\nmore text\n")])["documents"]
combined = "".join(doc.content or "" for doc in docs)
assert "intro paragraph text" in combined
def test_secondary_split_keeps_content_before_code_fence_comment():
"""With keep_headers=False, prose before a '#' comment inside a fenced code block must
not be dropped during the secondary split (the comment is not a real header)."""
splitter = MarkdownHeaderSplitter(
keep_headers=False, header_split_levels=[1], secondary_split="word", split_length=100
)
content = "# Main\nsome intro text\n```python\n# a comment in code\n```\nmore text\n"
docs = splitter.run(documents=[Document(content=content)])["documents"]
combined = "".join(doc.content or "" for doc in docs)
assert "some intro text" in combined
# Error and edge case handling
def test_non_text_document():
"""Test that the component correctly handles non-text documents."""
splitter = MarkdownHeaderSplitter()
docs = [Document(content=None)]
# Should raise ValueError about text documents
with pytest.raises(ValueError, match="only works with text documents"):
splitter.run(documents=docs)
def test_empty_document_list():
"""Test handling of an empty document list."""
splitter = MarkdownHeaderSplitter()
result = splitter.run(documents=[])
assert result["documents"] == []
class TestHeaderSplitLevels:
def test_default_splits_on_all_levels(self, sample_text):
"""Default behaviour: all six header levels create split boundaries.
Note: empty headers (no content of their own) are folded into the next chunk via pending_headers rather
than appearing as standalone entries in meta.
"""
splitter = MarkdownHeaderSplitter() # header_split_levels defaults to [1,2,3,4,5,6]
docs = splitter.run(documents=[Document(content=sample_text)])["documents"]
# sample_text has 5 chunks with content; "Header 1.1" and "Header 1.2" are empty headers prepended to their
# first child and do not appear as their own split boundary
assert len(docs) == 5
assert docs[0].content == "# Header 1\nContent under header 1.\n"
assert docs[1].content == "## Header 1.1\n### Subheader 1.1.1\nContent under sub-header 1.1.1\n"
assert docs[2].content == "## Header 1.2\n### Subheader 1.2.1\nContent under header 1.2.1.\n"
assert docs[3].content == "### Subheader 1.2.2\nContent under header 1.2.2.\n"
assert docs[4].content == "### Subheader 1.2.3\nContent under header 1.2.3."
headers = [doc.meta["header"] for doc in docs]
assert headers == ["Header 1", "Subheader 1.1.1", "Subheader 1.2.1", "Subheader 1.2.2", "Subheader 1.2.3"]
def test_h1_and_h2_only(self, sample_text):
"""Only h1/h2 headers create splits; h3+ content is absorbed into the preceding chunk."""
splitter = MarkdownHeaderSplitter(header_split_levels=[1, 2])
docs = splitter.run(documents=[Document(content=sample_text)])["documents"]
assert len(docs) == 3
assert docs[0].content == "# Header 1\nContent under header 1.\n"
assert docs[1].content == "## Header 1.1\n### Subheader 1.1.1\nContent under sub-header 1.1.1\n"
assert docs[2].content == (
"## Header 1.2\n"
"### Subheader 1.2.1\nContent under header 1.2.1.\n"
"### Subheader 1.2.2\nContent under header 1.2.2.\n"
"### Subheader 1.2.3\nContent under header 1.2.3."
)
headers = [doc.meta["header"] for doc in docs]
assert headers == ["Header 1", "Header 1.1", "Header 1.2"]
# h3 headers must not appear as split boundaries
assert "Subheader 1.1.1" not in headers
assert "Subheader 1.2.1" not in headers
def test_single_level(self, sample_text):
"""Splitting on only h1 yields one chunk that is the full document."""
splitter = MarkdownHeaderSplitter(header_split_levels=[1])
docs = splitter.run(documents=[Document(content=sample_text)])["documents"]
assert len(docs) == 1
assert docs[0].meta["header"] == "Header 1"
# entire document is in one chunk — h1 is first, so content equals the full source text
assert docs[0].content == sample_text
def test_deep_levels_only(self):
"""Splitting on h3 only; h1/h2 headers above the first h3 are not captured in any chunk."""
text = (
"# Top Level\n"
"Ignored top content.\n"
"## Mid Level\n"
"Ignored mid content.\n"
"### Deep Section A\n"
"Content A.\n"
"### Deep Section B\n"
"Content B.\n"
)
splitter = MarkdownHeaderSplitter(header_split_levels=[3])
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
assert docs[0].content == "### Deep Section A\nContent A.\n"
assert docs[1].content == "### Deep Section B\nContent B.\n"
headers = [doc.meta["header"] for doc in docs]
assert "Top Level" not in headers
assert "Mid Level" not in headers
# text before the first h3 match is not absorbed — it is dropped entirely
assert "Ignored top content." not in docs[0].content
assert "Ignored mid content." not in docs[0].content
def test_non_contiguous_levels(self):
"""Non-contiguous level selection (e.g. [1, 3]) splits on h1 and h3 but not h2."""
text = "# H1 Title\n## H2 Ignored\nH2 content.\n### H3 Section\nH3 content.\n"
splitter = MarkdownHeaderSplitter(header_split_levels=[1, 3])
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
# h2 content sits between the h1 and h3 match boundaries, so it is absorbed into the h1 chunk
assert docs[0].content == "# H1 Title\n## H2 Ignored\nH2 content.\n"
assert docs[0].meta["header"] == "H1 Title"
assert docs[1].content == "### H3 Section\nH3 content.\n"
assert docs[1].meta["header"] == "H3 Section"
assert "H2 Ignored" not in [doc.meta["header"] for doc in docs]
def test_validation_empty_list(self):
with pytest.raises(ValueError, match="non-empty list"):
MarkdownHeaderSplitter(header_split_levels=[])
def test_validation_level_zero(self):
with pytest.raises(ValueError, match="invalid values"):
MarkdownHeaderSplitter(header_split_levels=[0, 1, 2])
def test_validation_level_seven(self):
with pytest.raises(ValueError, match="invalid values"):
MarkdownHeaderSplitter(header_split_levels=[1, 7])
def test_validation_non_integer(self):
with pytest.raises(ValueError, match="invalid values"):
MarkdownHeaderSplitter(header_split_levels=[1, "2"]) # type: ignore[list-item]
def test_validation_duplicate_levels(self):
with pytest.raises(ValueError, match="duplicate"):
MarkdownHeaderSplitter(header_split_levels=[1, 2, 2])
class TestCodeBlockExclusion:
"""Tests that hash lines inside fenced code blocks are not treated as headers."""
def test_backtick_fence(self):
"""Hash lines inside triple-backtick fences are ignored."""
text = (
"# Real Header\n"
"Some content.\n"
"```python\n"
"# this is a Python comment, not a header\n"
"## also not a header\n"
"x = 1\n"
"```\n"
"More content.\n"
"## Real Subheader\n"
"Subheader content.\n"
)
splitter = MarkdownHeaderSplitter()
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
assert docs[0].content == (
"# Real Header\n"
"Some content.\n"
"```python\n"
"# this is a Python comment, not a header\n"
"## also not a header\n"
"x = 1\n"
"```\n"
"More content.\n"
)
assert docs[0].meta["header"] == "Real Header"
assert docs[1].content == "## Real Subheader\nSubheader content.\n"
assert docs[1].meta["header"] == "Real Subheader"
assert "this is a Python comment, not a header" not in [doc.meta["header"] for doc in docs]
assert "also not a header" not in [doc.meta["header"] for doc in docs]
def test_tilde_fence(self):
"""Hash lines inside triple-tilde fences are ignored."""
text = "# Real Header\n~~~bash\n# shell comment\necho hello\n~~~\n## Real Subheader\nContent.\n"
splitter = MarkdownHeaderSplitter()
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
assert docs[0].content == "# Real Header\n~~~bash\n# shell comment\necho hello\n~~~\n"
assert docs[0].meta["header"] == "Real Header"
assert docs[1].content == "## Real Subheader\nContent.\n"
assert docs[1].meta["header"] == "Real Subheader"
assert "shell comment" not in [doc.meta["header"] for doc in docs]
def test_multiple_code_blocks(self):
"""Multiple fenced code blocks in one document are all excluded."""
text = (
"# Section One\n"
"Intro text.\n"
"```\n"
"# fake header A\n"
"```\n"
"Middle text.\n"
"```python\n"
"# fake header B\n"
"```\n"
"## Section Two\n"
"More content.\n"
)
splitter = MarkdownHeaderSplitter()
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
assert docs[0].content == (
"# Section One\nIntro text.\n```\n# fake header A\n```\nMiddle text.\n```python\n# fake header B\n```\n"
)
assert docs[0].meta["header"] == "Section One"
assert docs[1].content == "## Section Two\nMore content.\n"
assert docs[1].meta["header"] == "Section Two"
assert "fake header A" not in [doc.meta["header"] for doc in docs]
assert "fake header B" not in [doc.meta["header"] for doc in docs]
def test_longer_fence_delimiters(self):
"""Fences with more than three backticks/tildes are also recognised."""
text = "# Real Header\n````python\n# not a header\n````\n## Real Subheader\nContent.\n"
splitter = MarkdownHeaderSplitter()
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 2
assert docs[0].content == "# Real Header\n````python\n# not a header\n````\n"
assert docs[0].meta["header"] == "Real Header"
assert docs[1].content == "## Real Subheader\nContent.\n"
assert docs[1].meta["header"] == "Real Subheader"
assert "not a header" not in [doc.meta["header"] for doc in docs]
def test_code_block_with_no_real_headers(self):
"""If the only hash lines are inside code blocks, the document is returned unchunked."""
text = "Plain text before code.\n```\n# entirely fake\n```\nPlain text after code.\n"
splitter = MarkdownHeaderSplitter()
docs = splitter.run(documents=[Document(content=text)])["documents"]
assert len(docs) == 1
assert docs[0].content == text
assert "header" not in docs[0].meta
def test_invalid_secondary_split_at_init():
"""Test that an invalid secondary split type raises an error at initialization time."""
with pytest.raises(ValueError, match="split_by must be one of"):
MarkdownHeaderSplitter(secondary_split="invalid_split_type")
def test_invalid_split_parameters_at_init():
"""Test invalid split parameter validation at initialization time."""
# Test split_length validation
with pytest.raises(ValueError, match="split_length must be greater than 0"):
MarkdownHeaderSplitter(secondary_split="word", split_length=0)
# Test split_overlap validation
with pytest.raises(ValueError, match="split_overlap must be greater than or equal to 0"):
MarkdownHeaderSplitter(secondary_split="word", split_overlap=-1)
def test_empty_content_handling():
"""Test handling of documents with empty content."""
splitter_skip = MarkdownHeaderSplitter() # skip empty documents by default
docs = [Document(content="")]
result = splitter_skip.run(documents=docs)
assert len(result["documents"]) == 0
splitter_no_skip = MarkdownHeaderSplitter(skip_empty_documents=False)
docs = [Document(content="")]
result = splitter_no_skip.run(documents=docs)
assert len(result["documents"]) == 1
def test_split_id_sequentiality_primary_and_secondary(sample_text):
# Test primary splitting with single document
splitter = MarkdownHeaderSplitter()
docs = [Document(content=sample_text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Test number of documents
assert len(split_docs) == 5
# Check that split_ids are sequential from 0 for this single parent document
split_ids = [doc.meta["split_id"] for doc in split_docs]
assert split_ids == list(range(len(split_ids)))
# Test secondary splitting with single document
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=3)
docs = [Document(content=sample_text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Test number of documents
assert len(split_docs) == 12
# Check that split_ids are sequential from 0 for this single parent document
split_ids = [doc.meta["split_id"] for doc in split_docs]
assert split_ids == list(range(len(split_ids)))
# Test with multiple input documents; each should have its own split_id sequence
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=3) # Use fresh instance
docs = [Document(content=sample_text), Document(content="# Another Header\nSome more content here.")]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Test number of documents
assert len(split_docs) == 14
# Verify split_ids are per-parent-document
splits_by_source = defaultdict(list)
for doc in split_docs:
splits_by_source[doc.meta["source_id"]].append(doc.meta["split_id"])
# Each parent document should have split_ids starting from 0
for split_ids in splits_by_source.values():
assert split_ids == list(range(len(split_ids)))
def test_secondary_split_with_overlap():
text = (
"# Introduction\n"
"This is the introduction section with some words for testing overlap splitting. "
"It should be split into chunks with overlap.\n"
"## Details\n"
"Here are more details about the topic. "
"Splitting should work across multiple headers and content blocks.\n"
"### Subsection\n"
"This subsection contains additional information and should also be split with overlap."
)
# keep_headers=False
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=8, split_overlap=3, keep_headers=False)
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 9
# Verify exact content and metadata of each split
# intro (4 docs)
assert split_docs[0].content == "\nThis is the introduction section with some words "
assert split_docs[0].meta["header"] == "Introduction"
assert split_docs[0].meta["split_id"] == 0
assert split_docs[1].content == "with some words for testing overlap splitting. It "
assert split_docs[1].meta["header"] == "Introduction"
assert split_docs[1].meta["split_id"] == 1
assert split_docs[2].content == "overlap splitting. It should be split into chunks "
assert split_docs[2].meta["header"] == "Introduction"
assert split_docs[2].meta["split_id"] == 2
assert split_docs[3].content == "split into chunks with overlap.\n"
assert split_docs[3].meta["header"] == "Introduction"
assert split_docs[3].meta["split_id"] == 3
# details (3 docs)
assert split_docs[4].content == "\nHere are more details about the topic. Splitting "
assert split_docs[4].meta["header"] == "Details"
assert split_docs[4].meta["split_id"] == 4
assert split_docs[5].content == "the topic. Splitting should work across multiple headers "
assert split_docs[5].meta["header"] == "Details"
assert split_docs[5].meta["split_id"] == 5
assert split_docs[6].content == "across multiple headers and content blocks.\n"
assert split_docs[6].meta["header"] == "Details"
assert split_docs[6].meta["split_id"] == 6
# subsection (2 docs)
assert split_docs[7].content == "\nThis subsection contains additional information and should also "
assert split_docs[7].meta["header"] == "Subsection"
assert split_docs[7].meta["split_id"] == 7
assert split_docs[8].content == "and should also be split with overlap."
assert split_docs[8].meta["header"] == "Subsection"
assert split_docs[8].meta["split_id"] == 8
# verify 3-word overlap behavior (split_overlap=3)
# consecutive pairs within a header should share the 3 words at their boundary
# intro
assert split_docs[0].content.split()[-3:] == split_docs[1].content.split()[:3]
assert split_docs[1].content.split()[-3:] == split_docs[2].content.split()[:3]
assert split_docs[2].content.split()[-3:] == split_docs[3].content.split()[:3]
# details
assert split_docs[4].content.split()[-3:] == split_docs[5].content.split()[:3]
assert split_docs[5].content.split()[-3:] == split_docs[6].content.split()[:3]
# subsection
assert split_docs[7].content.split()[-3:] == split_docs[8].content.split()[:3]
# re-run with keep_headers=True, change split_length and split_overlap
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=4, split_overlap=2)
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 24
assert split_docs[0].content.startswith("# Introduction")
assert all("header" in doc.meta for doc in split_docs)
def test_secondary_split_with_threshold():
text = "# Header\n" + " ".join([f"word{i}" for i in range(1, 11)])
# keep_headers=True
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=3, split_threshold=2, keep_headers=True)
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Explicitly test each split
assert len(split_docs) == 4
assert split_docs[0].content == "# Header\nword1 word2 "
assert split_docs[0].meta["split_id"] == 0
assert split_docs[1].content == "word3 word4 word5 "
assert split_docs[1].meta["split_id"] == 1
assert split_docs[2].content == "word6 word7 word8 "
assert split_docs[2].meta["split_id"] == 2
assert split_docs[3].content == "word9 word10"
assert split_docs[3].meta["split_id"] == 3
# keep_headers=False
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=3, split_threshold=2, keep_headers=False)
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
# Explicitly test each split
assert len(split_docs) == 3
assert split_docs[0].content == "\nword1 word2 word3 "
assert split_docs[0].meta["split_id"] == 0
assert split_docs[1].content == "word4 word5 word6 "
assert split_docs[1].meta["split_id"] == 1
assert split_docs[2].content == "word7 word8 word9 word10" # 4 words (due to threshold, not possible to split 3-1)
assert split_docs[2].meta["split_id"] == 2
def test_page_break_handling_in_secondary_split():
text = "# Header\nFirst page\f Second page\f Third page"
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=1)
docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
expected_page_numbers = [1, 1, 1, 2, 2, 3, 3]
actual_page_numbers = [doc.meta.get("page_number") for doc in split_docs]
assert actual_page_numbers == expected_page_numbers
def test_page_break_handling_with_multiple_headers(sample_text_with_page_breaks):
splitter = MarkdownHeaderSplitter(secondary_split="word", split_length=3)
docs = [Document(content=sample_text_with_page_breaks)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 12
assert split_docs[0].content == "# Header 1\nContent "
assert split_docs[0].meta == {
"source_id": ANY,
"split_id": 0,
"page_number": 1,
"split_idx_start": 0,
"header": "Header 1",
"parent_headers": [],
}
assert split_docs[1].content == "under header 1.\n\f\n"
assert split_docs[1].meta == {
"source_id": ANY,
"split_id": 1,
"page_number": 1,
"split_idx_start": 19,
"header": "Header 1",
"parent_headers": [],
}
assert split_docs[2].content == "## Header 1.1\n### "
assert split_docs[2].meta == {
"source_id": ANY,
"split_id": 2,
"page_number": 2,
"split_idx_start": 0,
"header": "Subheader 1.1.1",
"parent_headers": ["Header 1", "Header 1.1"],
}
assert split_docs[3].content == "Subheader 1.1.1\nContent under "
assert split_docs[3].meta == {
"source_id": ANY,
"split_id": 3,
"page_number": 2,
"split_idx_start": 18,
"header": "Subheader 1.1.1",
"parent_headers": ["Header 1", "Header 1.1"],
}
assert split_docs[4].content == "sub-header 1.1.1\n\f\n"
assert split_docs[4].meta == {
"source_id": ANY,
"split_id": 4,
"page_number": 2,
"split_idx_start": 48,
"header": "Subheader 1.1.1",
"parent_headers": ["Header 1", "Header 1.1"],
}
assert split_docs[5].content == "## Header 1.2\n### "
assert split_docs[5].meta == {
"source_id": ANY,
"split_id": 5,
"page_number": 3,
"split_idx_start": 0,
"header": "Subheader 1.2.1",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[6].content == "Subheader 1.2.1\nContent under "
assert split_docs[6].meta == {
"source_id": ANY,
"split_id": 6,
"page_number": 3,
"split_idx_start": 18,
"header": "Subheader 1.2.1",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[7].content == "header 1.2.1.\n\f\n"
assert split_docs[7].meta == {
"source_id": ANY,
"split_id": 7,
"page_number": 3,
"split_idx_start": 48,
"header": "Subheader 1.2.1",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[8].content == "### Subheader 1.2.2\nContent "
assert split_docs[8].meta == {
"source_id": ANY,
"split_id": 8,
"page_number": 4,
"split_idx_start": 0,
"header": "Subheader 1.2.2",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[9].content == "under header 1.2.2.\n\f\n"
assert split_docs[9].meta == {
"source_id": ANY,
"split_id": 9,
"page_number": 4,
"split_idx_start": 28,
"header": "Subheader 1.2.2",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[10].content == "### Subheader 1.2.3\nContent "
assert split_docs[10].meta == {
"source_id": ANY,
"split_id": 10,
"page_number": 5,
"split_idx_start": 0,
"header": "Subheader 1.2.3",
"parent_headers": ["Header 1", "Header 1.2"],
}
assert split_docs[11].content == "under header 1.2.3."
assert split_docs[11].meta == {
"source_id": ANY,
"split_id": 11,
"page_number": 5,
"split_idx_start": 28,
"header": "Subheader 1.2.3",
"parent_headers": ["Header 1", "Header 1.2"],
}
# reconstruct original
reconstructed_text = "".join(doc.content for doc in split_docs)
assert reconstructed_text == sample_text_with_page_breaks
@@ -0,0 +1,874 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import textwrap
import pytest
from haystack import Document
from haystack.components.preprocessors import PythonCodeSplitter
@pytest.fixture
def simple_module_source():
return textwrap.dedent(
'''
"""Example module docstring."""
import os
import sys
from math import sqrt
def add(a, b):
"""Add two numbers."""
return a + b
def subtract(a, b):
"""Subtract two numbers."""
return a - b
'''
).lstrip()
@pytest.fixture
def class_source():
return textwrap.dedent(
'''
"""Geometry helpers."""
from math import pi
class Shape:
"""Base shape."""
kind = "shape"
def __init__(self, name: str) -> None:
self.name = name
def describe(self) -> str:
return f"shape {self.name}"
class Circle(Shape, metaclass=type):
"""A circle."""
def __init__(self, r: float) -> None:
super().__init__("circle")
self.r = r
@staticmethod
def pi_value() -> float:
return pi
@classmethod
def unit(cls) -> "Circle":
return cls(1.0)
def area(self) -> float:
return pi * self.r * self.r
'''
).lstrip()
@pytest.fixture
def oversized_function_source():
"""A function long enough to trigger the secondary line-based split."""
body_lines = "\n".join(f" x_{i} = {i}" for i in range(200))
return f"def giant():\n{body_lines}\n return x_0\n"
class TestInitValidation:
def test_defaults(self):
splitter = PythonCodeSplitter()
assert splitter.min_effective_lines == 20
assert splitter.max_effective_lines == 100
assert splitter.expected_chars_per_line == 45
assert splitter.oversized_factor == 3
assert splitter.strip_docstrings is False
assert splitter.preserve_class_definition is True
assert splitter.secondary_split_overlap == 5
assert splitter.secondary_split_length is None
def test_custom_values(self):
splitter = PythonCodeSplitter(
min_effective_lines=2,
max_effective_lines=10,
expected_chars_per_line=80,
oversized_factor=4,
strip_docstrings=True,
preserve_class_definition=False,
secondary_split_overlap=2,
secondary_split_length=15,
)
assert splitter.min_effective_lines == 2
assert splitter.max_effective_lines == 10
assert splitter.expected_chars_per_line == 80
assert splitter.oversized_factor == 4
assert splitter.strip_docstrings is True
assert splitter.preserve_class_definition is False
assert splitter.secondary_split_overlap == 2
assert splitter.secondary_split_length == 15
@pytest.mark.parametrize(
"kwargs",
[
{"min_effective_lines": 0},
{"min_effective_lines": -1},
{"max_effective_lines": 0},
{"max_effective_lines": -3},
{"min_effective_lines": 10, "max_effective_lines": 5},
{"expected_chars_per_line": 0},
{"oversized_factor": 0},
{"secondary_split_overlap": -1},
{"secondary_split_length": -1},
],
)
def test_invalid_init_raises(self, kwargs):
with pytest.raises(ValueError):
PythonCodeSplitter(**kwargs)
class TestRunInputValidation:
def test_none_content_raises_value_error(self):
splitter = PythonCodeSplitter()
doc = Document(content=None)
with pytest.raises(ValueError):
splitter.run(documents=[doc])
def test_non_string_content_raises_type_error(self):
splitter = PythonCodeSplitter()
doc = Document(content="placeholder")
doc.content = 12345 # type: ignore[assignment]
with pytest.raises(TypeError):
splitter.run(documents=[doc])
def test_invalid_syntax_raises(self):
splitter = PythonCodeSplitter()
doc = Document(content="def broken(:\n pass\n")
with pytest.raises(SyntaxError):
splitter.run(documents=[doc])
def test_empty_documents_list(self):
splitter = PythonCodeSplitter()
result = splitter.run(documents=[])
assert result == {"documents": []}
class TestBasicOutput:
def test_returns_dict_with_documents(self, simple_module_source):
splitter = PythonCodeSplitter()
result = splitter.run(documents=[Document(content=simple_module_source)])
assert isinstance(result, dict)
assert "documents" in result
assert isinstance(result["documents"], list)
assert len(result["documents"]) >= 1
for chunk in result["documents"]:
assert isinstance(chunk, Document)
assert isinstance(chunk.content, str)
assert chunk.content
def test_split_id_starts_at_zero_and_increments(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=class_source)])
chunks = result["documents"]
ids = [c.meta["split_id"] for c in chunks]
assert ids == list(range(len(chunks)))
def test_source_id_consistent_within_one_document(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=class_source)])
chunks = result["documents"]
source_ids = {c.meta["source_id"] for c in chunks}
assert len(source_ids) == 1
def test_source_id_differs_between_documents(self, simple_module_source, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=10)
docs = [Document(content=simple_module_source), Document(content=class_source)]
result = splitter.run(documents=docs)
source_ids = {c.meta["source_id"] for c in result["documents"]}
assert len(source_ids) == 2
def test_chunks_have_required_meta_fields(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=10)
result = splitter.run(documents=[Document(content=simple_module_source)])
for chunk in result["documents"]:
assert "source_id" in chunk.meta
assert "split_id" in chunk.meta
assert "start_line" in chunk.meta
assert "end_line" in chunk.meta
assert "unit_kinds" in chunk.meta
assert isinstance(chunk.meta["unit_kinds"], list)
assert chunk.meta["start_line"] >= 1
assert chunk.meta["end_line"] >= chunk.meta["start_line"]
def test_unit_kinds_lists_what_was_merged(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=200)
result = splitter.run(documents=[Document(content=simple_module_source)])
all_kinds = set()
for chunk in result["documents"]:
for kind in chunk.meta["unit_kinds"]:
all_kinds.add(kind)
text = " ".join(all_kinds).lower()
assert any("import" in t for t in all_kinds) or "import" in text
assert any("func" in t or "method" in t for t in all_kinds) or any("func" in t for t in text.split())
def test_multiple_documents_each_produces_chunks(self, simple_module_source, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=10)
docs = [
Document(content=simple_module_source, meta={"file_name": "a.py"}),
Document(content=class_source, meta={"file_name": "b.py"}),
]
result = splitter.run(documents=docs)
file_names = {c.meta.get("file_name") for c in result["documents"]}
assert file_names == {"a.py", "b.py"}
def test_split_id_resets_per_document(self, simple_module_source, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=10)
docs = [
Document(content=simple_module_source, meta={"file_name": "a.py"}),
Document(content=class_source, meta={"file_name": "b.py"}),
]
result = splitter.run(documents=docs)
per_file_ids: dict[str, list[int]] = {"a.py": [], "b.py": []}
for chunk in result["documents"]:
per_file_ids[chunk.meta["file_name"]].append(chunk.meta["split_id"])
for ids in per_file_ids.values():
assert ids == sorted(ids)
assert ids[0] == 0
class TestOrderingAndLineRanges:
def test_chunks_are_in_source_order(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=class_source)])
prev_start = 0
for chunk in result["documents"]:
assert chunk.meta["start_line"] >= prev_start
assert chunk.meta["end_line"] >= chunk.meta["start_line"]
prev_start = chunk.meta["start_line"]
def test_chunks_dont_overlap_in_primary_split(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
chunks = splitter.run(documents=[Document(content=class_source)])["documents"]
for prev, nxt in zip(chunks, chunks[1:], strict=False):
assert nxt.meta["start_line"] > prev.meta["end_line"]
def test_chunks_read_top_to_bottom(self, class_source):
# With the default preserve_class_definition=True, chunks may have a class
# signature prefixed but the source slice itself must still appear verbatim.
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=class_source)])
source_lines = class_source.splitlines(keepends=True)
for chunk in result["documents"]:
expected = "".join(source_lines[chunk.meta["start_line"] - 1 : chunk.meta["end_line"]])
assert expected in (chunk.content or "")
def test_chunks_equal_source_slice_without_class_preservation(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5, preserve_class_definition=False)
result = splitter.run(documents=[Document(content=class_source)])
source_lines = class_source.splitlines(keepends=True)
for chunk in result["documents"]:
expected = "".join(source_lines[chunk.meta["start_line"] - 1 : chunk.meta["end_line"]])
assert (chunk.content or "").endswith(expected)
class TestFileNamePropagation:
def test_file_name_propagated_to_all_chunks(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=simple_module_source, meta={"file_name": "sample.py"})])
for chunk in result["documents"]:
assert chunk.meta["file_name"] == "sample.py"
def test_no_file_name_when_absent(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(documents=[Document(content=simple_module_source)])
for chunk in result["documents"]:
assert "file_name" not in chunk.meta
def test_other_meta_is_propagated(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5)
result = splitter.run(
documents=[Document(content=simple_module_source, meta={"file_name": "x.py", "project": "haystack"})]
)
for chunk in result["documents"]:
assert chunk.meta["project"] == "haystack"
class TestDecorators:
def test_decorators_metadata_present(self):
source = textwrap.dedent(
"""
class A:
@staticmethod
def s():
return 1
@classmethod
def c(cls):
return 2
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=2)
result = splitter.run(documents=[Document(content=source)])
all_decorators = []
for chunk in result["documents"]:
all_decorators.extend(chunk.meta.get("decorators") or [])
assert any("staticmethod" in d for d in all_decorators)
assert any("classmethod" in d for d in all_decorators)
def test_decorator_lines_included_in_chunk_content(self):
source = textwrap.dedent(
"""
class A:
@staticmethod
def s():
return 1
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=2)
result = splitter.run(documents=[Document(content=source)])
chunks_with_s = [c for c in result["documents"] if "def s" in (c.content or "")]
assert chunks_with_s
for chunk in chunks_with_s:
assert "@staticmethod" in (chunk.content or "")
def test_decorators_deduped_in_chunk(self):
source = textwrap.dedent(
"""
class A:
@staticmethod
def one():
return 1
@staticmethod
def two():
return 2
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=20)
result = splitter.run(documents=[Document(content=source)])
for chunk in result["documents"]:
decorators = chunk.meta.get("decorators") or []
assert len(decorators) == len(set(decorators))
def test_function_with_three_decorators_lists_all(self):
source = textwrap.dedent(
"""
def deco_a(fn):
return fn
def deco_b(fn):
return fn
def deco_c(fn):
return fn
@deco_a
@deco_b
@deco_c
def triple():
return 1
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=200)
result = splitter.run(documents=[Document(content=source)])
chunks_with_triple = [c for c in result["documents"] if "def triple" in (c.content or "")]
assert chunks_with_triple
decorators = [d for c in chunks_with_triple for d in (c.meta.get("decorators") or [])]
joined = " ".join(decorators)
assert "deco_a" in joined
assert "deco_b" in joined
assert "deco_c" in joined
def test_function_with_three_decorators_all_lines_in_content(self):
source = textwrap.dedent(
"""
@deco_a
@deco_b
@deco_c
def triple():
return 1
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=50)
result = splitter.run(documents=[Document(content=source)])
chunks_with_triple = [c for c in result["documents"] if "def triple" in (c.content or "")]
assert chunks_with_triple
for chunk in chunks_with_triple:
content = chunk.content or ""
assert "@deco_a" in content
assert "@deco_b" in content
assert "@deco_c" in content
class TestIncludeClassesMeta:
@pytest.mark.parametrize("class_name", ["Shape", "Circle"])
def test_class_methods_carry_include_classes(self, class_source, class_name):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3)
result = splitter.run(documents=[Document(content=class_source)])
chunks = [c for c in result["documents"] if class_name in (c.meta.get("include_classes") or [])]
assert chunks
def test_include_classes_absent_when_no_class_involved(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=200)
result = splitter.run(documents=[Document(content=simple_module_source)])
for chunk in result["documents"]:
include_classes = chunk.meta.get("include_classes")
assert not include_classes
def test_include_classes_is_deduplicated(self):
source = textwrap.dedent(
"""
class A:
def one(self):
return 1
def two(self):
return 2
def three(self):
return 3
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=200)
result = splitter.run(documents=[Document(content=source)])
for chunk in result["documents"]:
include_classes = chunk.meta.get("include_classes") or []
assert len(include_classes) == len(set(include_classes))
def test_include_classes_preserves_source_order(self):
source = textwrap.dedent(
"""
class First:
def f(self):
return 1
class Second:
def g(self):
return 2
class Third:
def h(self):
return 3
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=500)
result = splitter.run(documents=[Document(content=source)])
for chunk in result["documents"]:
include_classes = chunk.meta.get("include_classes") or []
if len(include_classes) >= 2:
expected_order = [c for c in ["First", "Second", "Third"] if c in include_classes]
assert include_classes == expected_order
class TestPreserveClassDefinition:
@pytest.fixture
def multi_method_class_source(self):
return textwrap.dedent(
'''
class Greeter:
"""A friendly greeter."""
kind = "greeter"
def __init__(self, name: str) -> None:
self.name = name
def hello(self) -> str:
return f"hello {self.name}"
def bye(self) -> str:
return f"bye {self.name}"
def shout(self) -> str:
return f"HELLO {self.name.upper()}"
def whisper(self) -> str:
return f"hello {self.name}..."
'''
).lstrip()
def test_default_preserve_class_definition_is_true(self):
splitter = PythonCodeSplitter()
assert splitter.preserve_class_definition is True
def test_class_signature_prepended_to_later_chunks(self, multi_method_class_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=True)
chunks = splitter.run(documents=[Document(content=multi_method_class_source)])["documents"]
assert len(chunks) >= 2
for chunk in chunks:
if "Greeter" in (chunk.meta.get("include_classes") or []):
assert "class Greeter" in (chunk.content or "")
def test_disabled_does_not_prepend_class_signature(self, multi_method_class_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=False)
chunks = splitter.run(documents=[Document(content=multi_method_class_source)])["documents"]
chunks_with_header = [c for c in chunks if "class Greeter" in (c.content or "")]
assert len(chunks_with_header) == 1
def test_preserve_does_not_duplicate_header_in_original_chunk(self, multi_method_class_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=True)
result = splitter.run(documents=[Document(content=multi_method_class_source)])
for chunk in result["documents"]:
assert (chunk.content or "").count("class Greeter") <= 1
def test_preserve_keeps_inheritance_and_metaclass(self):
source = textwrap.dedent(
'''
class Base:
pass
class Meta(type):
pass
class Child(Base, metaclass=Meta):
"""A child class."""
def one(self):
return 1
def two(self):
return 2
def three(self):
return 3
def four(self):
return 4
'''
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=True)
result = splitter.run(documents=[Document(content=source)])
child_chunks = [c for c in result["documents"] if "Child" in (c.meta.get("include_classes") or [])]
assert len(child_chunks) >= 2
for chunk in child_chunks:
assert "class Child(Base, metaclass=Meta):" in (chunk.content or "")
def test_preserve_keeps_decorators_on_class(self):
source = textwrap.dedent(
"""
def reg(cls):
return cls
@reg
class Decorated:
def one(self):
return 1
def two(self):
return 2
def three(self):
return 3
def four(self):
return 4
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=True)
result = splitter.run(documents=[Document(content=source)])
decorated_chunks = [c for c in result["documents"] if "Decorated" in (c.meta.get("include_classes") or [])]
assert len(decorated_chunks) >= 2
for chunk in decorated_chunks:
content = chunk.content or ""
assert "class Decorated" in content
assert "@reg" in content
def test_preserve_handles_multiple_classes(self):
source = textwrap.dedent(
"""
class A:
def a1(self):
return 1
def a2(self):
return 2
def a3(self):
return 3
class B:
def b1(self):
return 1
def b2(self):
return 2
def b3(self):
return 3
"""
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=3, preserve_class_definition=True)
result = splitter.run(documents=[Document(content=source)])
for chunk in result["documents"]:
content = chunk.content or ""
for cls in chunk.meta.get("include_classes") or []:
assert f"class {cls}" in content
class TestDocstringStripping:
def test_default_keeps_docstrings_in_content(self, class_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=8)
result = splitter.run(documents=[Document(content=class_source)])
contents = "\n".join(c.content or "" for c in result["documents"])
assert "A circle." in contents
for chunk in result["documents"]:
assert not chunk.meta.get("docstrings")
def test_strip_docstrings_moves_them_to_meta(self):
source = textwrap.dedent(
'''
def foo():
"""Foo docstring."""
return 1
def bar():
"""Bar docstring."""
return 2
'''
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=10, strip_docstrings=True)
result = splitter.run(documents=[Document(content=source)])
all_docstrings = [d for c in result["documents"] for d in (c.meta.get("docstrings") or [])]
joined_docstrings = " | ".join(all_docstrings)
assert "Foo docstring." in joined_docstrings
assert "Bar docstring." in joined_docstrings
joined_content = "\n".join(c.content or "" for c in result["documents"])
assert "Foo docstring." not in joined_content
assert "Bar docstring." not in joined_content
def test_strip_docstrings_preserves_module_docstring(self):
source = textwrap.dedent(
'''
"""Module-level docstring."""
def foo():
"""Inner."""
return 1
'''
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=20, strip_docstrings=True)
result = splitter.run(documents=[Document(content=source)])
joined = "\n".join(c.content or "" for c in result["documents"])
assert "Module-level docstring." in joined
def test_strip_class_header_docstring_moves_to_meta(self):
source = textwrap.dedent(
'''
class MyClass:
"""Class-level docstring."""
class_var = 42
def method(self):
return self.class_var
'''
).lstrip()
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=10, strip_docstrings=True)
result = splitter.run(documents=[Document(content=source)])
header_chunks = [c for c in result["documents"] if "class_header" in c.meta.get("unit_kinds", [])]
assert header_chunks
header = header_chunks[0]
assert "Class-level docstring." not in (header.content or "")
assert "Class-level docstring." in " | ".join(header.meta.get("docstrings") or [])
class TestTopLevelStatements:
@pytest.fixture
def rich_module_source(self):
return textwrap.dedent(
'''
"""Utility helpers for the pipeline."""
import os
import sys
from pathlib import Path
MAX_RETRIES = 3
DEFAULT_TIMEOUT = 30.0
LOG_PREFIX = "app"
def process(data):
"""Process data."""
result = data.strip()
return result
def validate(value):
"""Validate a value."""
if value is None:
raise ValueError("value cannot be None")
return True
class Manager:
"""Resource manager."""
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def remove(self, item):
self.items.remove(item)
if __name__ == "__main__":
mgr = Manager()
mgr.add(process("test"))
'''
).lstrip()
@pytest.mark.parametrize("expected_kind", ["statement", "imports", "module_docstring"])
def test_unit_kind_present(self, rich_module_source, expected_kind):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=50)
result = splitter.run(documents=[Document(content=rich_module_source)])
all_kinds = [k for c in result["documents"] for k in c.meta.get("unit_kinds", [])]
assert expected_kind in all_kinds
def test_first_chunk_contains_preamble_statements(self, rich_module_source):
# max_effective_lines=6 matches the preamble (module docstring + 3 imports + 3
# assignments) so the greedy merger flushes before the first function.
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=6)
result = splitter.run(documents=[Document(content=rich_module_source)])
assert len(result["documents"]) >= 2
first = result["documents"][0]
content = first.content or ""
assert '"""Utility helpers for the pipeline."""' in content
assert "import os" in content
assert "import sys" in content
assert "from pathlib import Path" in content
assert "MAX_RETRIES = 3" in content
assert "DEFAULT_TIMEOUT = 30.0" in content
assert 'LOG_PREFIX = "app"' in content
assert "def process" not in content
assert "def validate" not in content
assert "class Manager" not in content
def test_if_main_produces_statement_unit(self, rich_module_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=50)
result = splitter.run(documents=[Document(content=rich_module_source)])
all_kinds = [k for c in result["documents"] for k in c.meta.get("unit_kinds", [])]
assert "statement" in all_kinds
joined = "\n".join(c.content or "" for c in result["documents"])
assert 'if __name__ == "__main__"' in joined
def test_all_imports_appear_in_output(self, rich_module_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=50)
result = splitter.run(documents=[Document(content=rich_module_source)])
joined = "\n".join(c.content or "" for c in result["documents"])
assert "import os" in joined
assert "import sys" in joined
assert "from pathlib import Path" in joined
def test_module_docstring_text_preserved(self, rich_module_source):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=50)
result = splitter.run(documents=[Document(content=rich_module_source)])
joined = "\n".join(c.content or "" for c in result["documents"])
assert "Utility helpers for the pipeline." in joined
class TestOversizedFallback:
def test_warns_on_oversized_function(self, oversized_function_source, caplog):
import logging
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5, oversized_factor=3)
with caplog.at_level(logging.WARNING):
result = splitter.run(documents=[Document(content=oversized_function_source)])
text = caplog.text.lower()
assert "oversiz" in text or "secondary" in text
assert len(result["documents"]) >= 2
def test_oversized_chunks_marked_with_secondary_split(self, oversized_function_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5, oversized_factor=3)
result = splitter.run(documents=[Document(content=oversized_function_source)])
assert [c for c in result["documents"] if c.meta.get("secondary_split")]
def test_oversized_chunks_belong_to_same_function(self, oversized_function_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=5, oversized_factor=3)
result = splitter.run(documents=[Document(content=oversized_function_source)])
secondary = [c for c in result["documents"] if c.meta.get("secondary_split")]
for chunk in secondary:
assert chunk.meta["start_line"] >= 1
assert chunk.meta["end_line"] <= oversized_function_source.count("\n") + 1
def test_small_function_does_not_trigger_secondary(self, simple_module_source):
splitter = PythonCodeSplitter(min_effective_lines=2, max_effective_lines=50, oversized_factor=3)
result = splitter.run(documents=[Document(content=simple_module_source)])
for chunk in result["documents"]:
assert not chunk.meta.get("secondary_split")
def test_long_lines_count_as_more_effective_lines(self):
long_line_source = (
textwrap.dedent(
"""
def short():
return 1
def longer():
return "{padding}"
"""
)
.lstrip()
.format(padding="x" * 500)
)
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=2, expected_chars_per_line=10)
result = splitter.run(documents=[Document(content=long_line_source)])
chunks_with_short = [c for c in result["documents"] if "def short" in (c.content or "")]
chunks_with_long = [c for c in result["documents"] if "def longer" in (c.content or "")]
assert chunks_with_short and chunks_with_long
assert chunks_with_short[0] is not chunks_with_long[0]
class TestEdgeCases:
def test_module_with_only_docstring(self):
source = '"""Just a docstring."""\n'
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=5)
result = splitter.run(documents=[Document(content=source)])
assert len(result["documents"]) == 1
assert "Just a docstring." in (result["documents"][0].content or "")
def test_module_with_only_imports(self):
source = "import os\nimport sys\nfrom math import pi\n"
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=5)
result = splitter.run(documents=[Document(content=source)])
joined = "\n".join(c.content or "" for c in result["documents"])
assert "import os" in joined
assert "import sys" in joined
assert "from math import pi" in joined
def test_module_with_only_one_function(self):
source = "def f():\n return 1\n"
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=5)
result = splitter.run(documents=[Document(content=source)])
assert len(result["documents"]) == 1
assert "def f" in (result["documents"][0].content or "")
def test_empty_string_source(self):
# Empty source is valid Python; the splitter must not raise.
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=5)
result = splitter.run(documents=[Document(content="")])
assert isinstance(result["documents"], list)
def test_invalid_syntax_raises_syntax_error(self):
splitter = PythonCodeSplitter(min_effective_lines=1, max_effective_lines=5)
doc = Document(content="class Broken(\n pass\n")
with pytest.raises(SyntaxError):
splitter.run(documents=[doc])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from pytest import LogCaptureFixture
from haystack.components.preprocessors.sentence_tokenizer import QUOTE_SPANS_RE, SentenceSplitter
def test_apply_split_rules_no_join() -> None:
text = "This is a test. This is another test. And a third test."
spans = [(0, 15), (16, 36), (37, 54)]
result = SentenceSplitter._apply_split_rules(text, spans)
assert len(result) == 3
assert result == [(0, 15), (16, 36), (37, 54)]
def test_apply_split_rules_join_case_1():
text = 'He said "This is sentence one. This is sentence two." Then he left.'
result = SentenceSplitter._apply_split_rules(text, [(0, 30), (31, 53), (54, 67)])
assert len(result) == 2
assert result == [(0, 53), (54, 67)]
def test_apply_split_rules_join_case_3():
splitter = SentenceSplitter(language="en", use_split_rules=True)
text = """
1. First item
2. Second item
3. Third item."""
spans = [(0, 7), (8, 25), (26, 44), (45, 56)]
result = splitter._apply_split_rules(text, spans)
assert len(result) == 1
assert result == [(0, 56)]
def test_apply_split_rules_join_case_4() -> None:
text = "This is a test. (With a parenthetical statement.) And another sentence."
spans = [(0, 15), (16, 50), (51, 74)]
result = SentenceSplitter._apply_split_rules(text, spans)
assert len(result) == 2
assert result == [(0, 50), (51, 74)]
@pytest.fixture
def mock_file_content():
return "Mr.\nDr.\nProf."
def test_read_abbreviations_existing_file(tmp_path, mock_file_content):
abbrev_dir = tmp_path / "data" / "abbreviations"
abbrev_dir.mkdir(parents=True)
abbrev_file = abbrev_dir / "en.txt"
abbrev_file.write_text(mock_file_content)
with patch("haystack.components.preprocessors.sentence_tokenizer.Path") as mock_path:
mock_path.return_value.parent.parent.parent = tmp_path
result = SentenceSplitter._read_abbreviations("en")
assert result == ["Mr.", "Dr.", "Prof."]
def test_read_abbreviations_missing_file(caplog: LogCaptureFixture):
with patch("haystack.components.preprocessors.sentence_tokenizer.Path") as mock_path:
mock_path.return_value.parent.parent = Path("/nonexistent")
result = SentenceSplitter._read_abbreviations("pt")
assert result == []
assert "No abbreviations file found for pt. Using default abbreviations." in caplog.text
def test_quote_spans_regex():
# double quotes
text1 = 'He said "Hello world" and left.'
matches1 = list(QUOTE_SPANS_RE.finditer(text1))
assert len(matches1) == 1
assert matches1[0].group() == '"Hello world"'
# single quotes
text2 = "She replied 'Goodbye world' and smiled."
matches2 = list(QUOTE_SPANS_RE.finditer(text2))
assert len(matches2) == 1
assert matches2[0].group() == "'Goodbye world'"
# multiple quotes
text3 = 'First "quote" and second "quote" in same text.'
matches3 = list(QUOTE_SPANS_RE.finditer(text3))
assert len(matches3) == 2
assert matches3[0].group() == '"quote"'
assert matches3[1].group() == '"quote"'
# quotes containing newlines
text4 = 'Text with "quote\nspanning\nmultiple\nlines"'
matches4 = list(QUOTE_SPANS_RE.finditer(text4))
assert len(matches4) == 1
assert matches4[0].group() == '"quote\nspanning\nmultiple\nlines"'
# no quotes
text5 = "This text has no quotes."
matches5 = list(QUOTE_SPANS_RE.finditer(text5))
assert len(matches5) == 0
def test_split_sentences_performance() -> None:
# make sure our regex is not vulnerable to Regex Denial of Service (ReDoS)
# https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
# this is a very long string, roughly 50 MB, but it should not take more than 2 seconds to process
splitter = SentenceSplitter()
text = " " + '"' * 20 + "A" * 50000000 + "B"
start = time.time()
_ = splitter.split_sentences(text)
end = time.time()
assert end - start < 2, f"Execution time exceeded 2 seconds: {end - start:.2f} seconds"
@@ -0,0 +1,74 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack.components.preprocessors import TextCleaner
def test_init_default():
cleaner = TextCleaner()
assert cleaner._remove_regexps is None
assert not cleaner._convert_to_lowercase
assert not cleaner._remove_punctuation
assert not cleaner._remove_numbers
assert cleaner._regex is None
assert cleaner._translator is None
def test_run():
cleaner = TextCleaner()
texts = ["Some text", "Some other text", "Yet another text"]
result = cleaner.run(texts=texts)
assert len(result) == 1
assert result["texts"] == texts
def test_run_with_empty_inputs():
cleaner = TextCleaner()
result = cleaner.run(texts=[])
assert len(result) == 1
assert result["texts"] == []
def test_run_with_regex():
cleaner = TextCleaner(remove_regexps=[r"\d+"])
result = cleaner.run(texts=["Open123 Source", "HaystackAI"])
assert len(result) == 1
assert result["texts"] == ["Open Source", "HaystackAI"]
def test_run_with_multiple_regexps():
cleaner = TextCleaner(remove_regexps=[r"\d+", r"[^\w\s]"])
result = cleaner.run(texts=["Open123! Source", "Haystack.AI"])
assert len(result) == 1
assert result["texts"] == ["Open Source", "HaystackAI"]
def test_run_with_convert_to_lowercase():
cleaner = TextCleaner(convert_to_lowercase=True)
result = cleaner.run(texts=["Open123! Source", "Haystack.AI"])
assert len(result) == 1
assert result["texts"] == ["open123! source", "haystack.ai"]
def test_run_with_remove_punctuation():
cleaner = TextCleaner(remove_punctuation=True)
result = cleaner.run(texts=["Open123! Source", "Haystack.AI"])
assert len(result) == 1
assert result["texts"] == ["Open123 Source", "HaystackAI"]
def test_run_with_remove_numbers():
cleaner = TextCleaner(remove_numbers=True)
result = cleaner.run(texts=["Open123! Source", "Haystack.AI"])
assert len(result) == 1
assert result["texts"] == ["Open! Source", "Haystack.AI"]
def test_run_with_multiple_parameters():
cleaner = TextCleaner(
remove_regexps=[r"\d+", r"[^\w\s]"], convert_to_lowercase=True, remove_punctuation=True, remove_numbers=True
)
result = cleaner.run(texts=["Open%123. !$Source", "Haystack.AI##"])
assert len(result) == 1
assert result["texts"] == ["open source", "haystackai"]
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,522 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import os
from unittest.mock import AsyncMock, Mock
import pytest
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.query.query_expander import DEFAULT_PROMPT_TEMPLATE, QueryExpander
from haystack.dataclasses.chat_message import ChatMessage
@pytest.fixture
def mock_chat_generator():
return Mock(spec=OpenAIChatGenerator)
class TestQueryExpander:
def test_init_default_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
assert expander.n_expansions == 4
assert expander.include_original_query is True
assert isinstance(expander.chat_generator, OpenAIChatGenerator)
assert expander.chat_generator.model == "gpt-4.1-mini"
assert expander._prompt_builder is not None
def test_init_custom_generator(self, mock_chat_generator):
expander = QueryExpander(chat_generator=mock_chat_generator, n_expansions=3)
assert expander.n_expansions == 3
assert expander.chat_generator is mock_chat_generator
def test_run_warms_up_chat_generator(self, mock_chat_generator):
expander = QueryExpander(chat_generator=mock_chat_generator)
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant("1. test query")]}
expander.run("test query")
mock_chat_generator.warm_up.assert_called()
def test_init_negative_expansions_raises_error(self):
with pytest.raises(ValueError, match="n_expansions must be positive"):
QueryExpander(n_expansions=-1)
def test_init_zero_expansions_raises_error(self):
with pytest.raises(ValueError, match="n_expansions must be positive"):
QueryExpander(n_expansions=0)
def test_init_custom_prompt_template(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
custom_template = "Custom template: {{ query }} with {{ n_expansions }} expansions"
expander = QueryExpander(prompt_template=custom_template)
assert expander.prompt_template == custom_template
def test_run_negative_expansions_raises_error(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
with pytest.raises(ValueError, match="n_expansions must be positive"):
expander.run("test query", n_expansions=-1)
def test_run_zero_expansions_raises_error(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander(n_expansions=4)
with pytest.raises(ValueError, match="n_expansions must be positive"):
expander.run("test query", n_expansions=0)
def test_run_with_runtime_n_expansions_override(self, mock_chat_generator):
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["alt1", "alt2"]}')]
}
expander = QueryExpander(chat_generator=mock_chat_generator, n_expansions=4, include_original_query=False)
result = expander.run("test query", n_expansions=2)
# should request 2 expansions
call_args = mock_chat_generator.run.call_args[1]["messages"][0].text
assert "2" in call_args
assert len(result["queries"]) == 2
assert result["queries"] == ["alt1", "alt2"]
def test_run_successful_expansion(self, mock_chat_generator):
mock_chat_generator.run.return_value = {
"replies": [
ChatMessage.from_assistant(
'{"queries": ["alternative query 1", "alternative query 2", "alternative query 3"]}'
)
]
}
expander = QueryExpander(chat_generator=mock_chat_generator, n_expansions=3)
result = expander.run("original query")
assert result["queries"] == [
"alternative query 1",
"alternative query 2",
"alternative query 3",
"original query",
]
mock_chat_generator.run.assert_called_once()
def test_run_without_including_original(self, mock_chat_generator):
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["alt1", "alt2"]}')]
}
expander = QueryExpander(chat_generator=mock_chat_generator, include_original_query=False)
result = expander.run("original")
assert result["queries"] == ["alt1", "alt2"]
def test_run_empty_query(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
result = expander.run("")
assert result["queries"] == [""]
def test_run_empty_query_no_original(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander(include_original_query=False)
result = expander.run(" ")
assert result["queries"] == []
def test_run_whitespace_only_query(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
with caplog.at_level(logging.WARNING):
result = expander.run("\t\n \r")
assert result["queries"] == ["\t\n \r"]
assert "Empty query provided" in caplog.text
def test_run_generator_no_replies(self, mock_chat_generator):
mock_chat_generator.run.return_value = {"replies": []}
expander = QueryExpander(chat_generator=mock_chat_generator)
result = expander.run("test query")
assert result["queries"] == ["test query"]
def test_run_generator_exception(self, mock_chat_generator):
mock_chat_generator.run.side_effect = Exception("Generator error")
expander = QueryExpander(chat_generator=mock_chat_generator)
result = expander.run("test query")
assert result["queries"] == ["test query"]
def test_run_invalid_json_response(self, mock_chat_generator):
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant("invalid json response")]}
expander = QueryExpander(chat_generator=mock_chat_generator)
result = expander.run("test query")
assert result["queries"] == ["test query"]
def test_parse_expanded_queries_valid_json(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
queries = expander._parse_expanded_queries('{"queries": ["query1", "query2", "query3"]}')
assert queries == ["query1", "query2", "query3"]
def test_parse_expanded_queries_invalid_json(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
queries = expander._parse_expanded_queries("not json")
assert queries == []
def test_parse_expanded_queries_empty_string(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
queries = expander._parse_expanded_queries("")
assert queries == []
def test_parse_expanded_queries_non_list_json(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
queries = expander._parse_expanded_queries('{"not": "a list"}')
assert queries == []
@pytest.mark.parametrize("queries_value", ['"single query"', '{"query": "value"}', "null"])
def test_parse_expanded_queries_rejects_non_list_queries_value(self, monkeypatch, caplog, queries_value):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
with caplog.at_level(logging.WARNING):
queries = expander._parse_expanded_queries(f'{{"queries": {queries_value}}}')
assert queries == []
assert "Expected 'queries' to be a list" in caplog.text
def test_parse_expanded_queries_mixed_types(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
queries = expander._parse_expanded_queries('{"queries": ["valid query", 123, "", "another valid"]}')
assert queries == ["valid query", "another valid"]
def test_run_query_deduplication(self, mock_chat_generator):
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["original query", "alt1", "alt2"]}')]
}
expander = QueryExpander(chat_generator=mock_chat_generator, include_original_query=True)
result = expander.run("original query")
assert result["queries"] == ["original query", "alt1", "alt2"]
assert len(result["queries"]) == 3
def test_run_truncates_excess_queries(self, mock_chat_generator, caplog):
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["q1", "q2", "q3", "q4", "q5"]}')]
}
expander = QueryExpander(chat_generator=mock_chat_generator, n_expansions=3, include_original_query=False)
with caplog.at_level(logging.WARNING):
result = expander.run("test query")
assert len(result["queries"]) == 3
assert result["queries"] == ["q1", "q2", "q3"]
assert "Generated 5 queries but only 3 were requested" in caplog.text
assert "Truncating" in caplog.text
def test_run_with_custom_template(self, mock_chat_generator):
custom_template = """
Create {{ n_expansions }} alternative search queries for: {{ query }}
Return as JSON: {"queries": ["query1", "query2"]}
"""
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["custom alt 1", "custom alt 2"]}')]
}
expander = QueryExpander(
chat_generator=mock_chat_generator,
prompt_template=custom_template,
n_expansions=2,
include_original_query=False,
)
result = expander.run("test query")
assert result["queries"] == ["custom alt 1", "custom alt 2"]
mock_chat_generator.run.assert_called_once()
call_args = mock_chat_generator.run.call_args[1]["messages"][0].text
assert "Create 2 alternative search queries for: test query" in call_args
assert "Return as JSON" in call_args
def test_component_output_types(self, mock_chat_generator, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
expander = QueryExpander()
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"queries": ["test1", "test2"]}')]
}
expander.chat_generator = mock_chat_generator
result = expander.run("test")
assert "queries" in result
assert isinstance(result["queries"], list)
assert all(isinstance(q, str) for q in result["queries"])
@pytest.mark.parametrize("variable", ["query", "n_expansions"])
def test_prompt_template_missing_variable(self, caplog, variable, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
if variable == "query":
template_missing_variable = "Generate {{ n_expansions }} expansions"
else:
template_missing_variable = "Generate expansions for {{ query }}"
with caplog.at_level(logging.WARNING):
QueryExpander(prompt_template=template_missing_variable)
assert f"The prompt template does not contain the '{variable}' variable" in caplog.text
assert "This may cause issues during execution" in caplog.text
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
generator = OpenAIChatGenerator(model="gpt-4.1-mini")
expander = QueryExpander(chat_generator=generator, n_expansions=2, include_original_query=False)
serialized_query_expander = expander.to_dict()
assert serialized_query_expander == {
"type": "haystack.components.query.query_expander.QueryExpander",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4.1-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"prompt_template": DEFAULT_PROMPT_TEMPLATE,
"n_expansions": 2,
"include_original_query": False,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
data = {
"type": "haystack.components.query.query_expander.QueryExpander",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4.1-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"prompt_template": DEFAULT_PROMPT_TEMPLATE,
"n_expansions": 2,
"include_original_query": False,
},
}
expander = QueryExpander.from_dict(data)
assert expander.n_expansions == 2
assert expander.include_original_query is False
assert expander.prompt_template == DEFAULT_PROMPT_TEMPLATE
assert isinstance(expander.chat_generator, OpenAIChatGenerator)
assert expander.chat_generator.model == "gpt-4.1-mini"
class FakeSyncOnlyChatGenerator:
"""A chat generator exposing only a synchronous `run` (no `run_async`) for the fallback path."""
def __init__(self):
self.run = Mock()
class TestQueryExpanderAsync:
@pytest.mark.asyncio
async def test_run_async(self):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(
return_value={
"replies": [
ChatMessage.from_assistant(
'{"queries": ["alternative query 1", "alternative query 2", "alternative query 3"]}'
)
]
}
)
expander = QueryExpander(chat_generator=mock_chat_generator, n_expansions=3)
result = await expander.run_async("original query")
assert result["queries"] == [
"alternative query 1",
"alternative query 2",
"alternative query 3",
"original query",
]
mock_chat_generator.run_async.assert_awaited_once()
mock_chat_generator.run.assert_not_called()
@pytest.mark.asyncio
async def test_run_async_without_including_original(self):
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(
return_value={"replies": [ChatMessage.from_assistant('{"queries": ["alt1", "alt2"]}')]}
)
expander = QueryExpander(chat_generator=mock_chat_generator, include_original_query=False)
result = await expander.run_async("original")
assert result["queries"] == ["alt1", "alt2"]
mock_chat_generator.run_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_async_fallback_to_sync_run(self):
fake_chat_generator = FakeSyncOnlyChatGenerator()
fake_chat_generator.run.return_value = {
"replies": [
ChatMessage.from_assistant(
'{"queries": ["alternative query 1", "alternative query 2", "alternative query 3"]}'
)
]
}
assert not hasattr(fake_chat_generator, "run_async")
expander = QueryExpander(chat_generator=fake_chat_generator, n_expansions=3)
result = await expander.run_async("original query")
assert result["queries"] == [
"alternative query 1",
"alternative query 2",
"alternative query 3",
"original query",
]
fake_chat_generator.run.assert_called_once()
@pytest.mark.integration
class TestQueryExpanderIntegration:
@pytest.fixture
def chat_generator(self):
return OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"temperature": 0.7,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "query_expansion",
"schema": {
"type": "object",
"properties": {
"queries": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 2}
},
"required": ["queries"],
"additionalProperties": False,
},
},
},
},
)
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_query_expansion(self, chat_generator):
expander = QueryExpander(n_expansions=2, chat_generator=chat_generator)
result = expander.run("renewable energy sources")
assert len(result["queries"]) == 3
assert all(len(q.strip()) > 0 for q in result["queries"])
assert "renewable energy sources" in result["queries"]
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.asyncio
async def test_query_expansion_async(self, chat_generator):
expander = QueryExpander(n_expansions=2, chat_generator=chat_generator)
result = await expander.run_async("renewable energy sources")
assert len(result["queries"]) == 3
assert all(len(q.strip()) > 0 for q in result["queries"])
assert "renewable energy sources" in result["queries"]
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_different_domains(self, chat_generator):
test_queries = ["machine learning algorithms", "climate change effects", "quantum computing applications"]
expander = QueryExpander(n_expansions=2, include_original_query=False, chat_generator=chat_generator)
for query in test_queries:
result = expander.run(query)
# Should return exactly 2 expansions (no original)
assert len(result["queries"]) == 2
# Should be different from original
assert query not in result["queries"]
class TestComponentLifecycle:
def test_warm_up_delegates_to_chat_generator(self, mock_chat_generator):
expander = QueryExpander(chat_generator=mock_chat_generator)
expander.warm_up()
mock_chat_generator.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_chat_generator(self, mock_chat_generator):
mock_chat_generator.warm_up_async = AsyncMock()
expander = QueryExpander(chat_generator=mock_chat_generator)
await expander.warm_up_async()
mock_chat_generator.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
chat_generator = Mock(spec=["run", "warm_up"])
expander = QueryExpander(chat_generator=chat_generator)
await expander.warm_up_async()
chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self, mock_chat_generator):
expander = QueryExpander(chat_generator=mock_chat_generator)
expander.close()
mock_chat_generator.close.assert_called_once()
async def test_close_async_delegates_to_chat_generator(self, mock_chat_generator):
mock_chat_generator.close_async = AsyncMock()
expander = QueryExpander(chat_generator=mock_chat_generator)
await expander.close_async()
mock_chat_generator.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
chat_generator = Mock(spec=["run", "close"])
expander = QueryExpander(chat_generator=chat_generator)
await expander.close_async()
chat_generator.close.assert_called_once()
def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
chat_generator = Mock(spec=["run"])
expander = QueryExpander(chat_generator=chat_generator)
expander.warm_up()
expander.close()
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
+481
View File
@@ -0,0 +1,481 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from unittest.mock import AsyncMock, Mock
import pytest
from jinja2 import TemplateSyntaxError
from haystack import Document
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.rankers.llm_ranker import DEFAULT_PROMPT_TEMPLATE, LLMRanker
from haystack.dataclasses import ChatMessage
@pytest.fixture
def mock_chat_generator():
return Mock(spec=OpenAIChatGenerator)
def test_init_invalid_top_k():
with pytest.raises(ValueError, match="top_k must be > 0"):
LLMRanker(top_k=0)
def test_init_default_generator(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
ranker = LLMRanker()
assert ranker.top_k == 10
assert ranker.raise_on_failure is False
assert ranker.prompt == DEFAULT_PROMPT_TEMPLATE
assert isinstance(ranker._chat_generator, OpenAIChatGenerator)
assert ranker._chat_generator.model == "gpt-4.1-mini"
assert ranker._prompt_builder is not None
def test_init_custom_generator(mock_chat_generator):
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=5, raise_on_failure=True)
assert ranker._chat_generator is mock_chat_generator
assert ranker.top_k == 5
assert ranker.raise_on_failure is True
def test_to_dict(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
ranker = LLMRanker(
chat_generator=chat_generator,
prompt="Rank {{ documents|length }} docs for {{ query }}",
top_k=3,
raise_on_failure=True,
)
assert ranker.to_dict() == {
"type": "haystack.components.rankers.llm_ranker.LLMRanker",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"prompt": "Rank {{ documents|length }} docs for {{ query }}",
"top_k": 3,
"raise_on_failure": True,
},
}
def test_from_dict(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
data = {
"type": "haystack.components.rankers.llm_ranker.LLMRanker",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"prompt": "Rank {{ documents|length }} docs for {{ query }}",
"top_k": 3,
"raise_on_failure": True,
},
}
ranker = LLMRanker.from_dict(data)
assert ranker.top_k == 3
assert ranker.raise_on_failure is True
assert ranker.prompt == "Rank {{ documents|length }} docs for {{ query }}"
assert ranker._chat_generator.to_dict() == chat_generator.to_dict()
def test_run_invalid_runtime_top_k(mock_chat_generator):
ranker = LLMRanker(chat_generator=mock_chat_generator)
with pytest.raises(ValueError, match="top_k must be > 0"):
ranker.run(query="test", documents=[Document(content="doc")], top_k=0)
def test_run_empty_documents(mock_chat_generator):
ranker = LLMRanker(chat_generator=mock_chat_generator)
assert ranker.run(query="test", documents=[]) == {"documents": []}
def test_run_whitespace_query_returns_fallback(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1)
result = ranker.run(query=" ", documents=documents)
assert result == {"documents": documents}
mock_chat_generator.run.assert_not_called()
def test_run_successful_ranking(mock_chat_generator):
documents = [
Document(id="1", content="first"),
Document(id="2", content="second"),
Document(id="3", content="third"),
]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}, {"index": 1}, {"index": 3}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=2)
result = ranker.run(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2", "1"]
def test_run_returns_only_documents_listed_by_the_llm(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}]}')]}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=2)
result = ranker.run(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2"]
def test_run_runtime_top_k_overrides_instance_top_k(mock_chat_generator):
documents = [
Document(id="doc_1", content="first"),
Document(id="doc_2", content="second"),
Document(id="doc_3", content="third"),
]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 3}, {"index": 2}, {"index": 1}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=3)
result = ranker.run(query="test query", documents=documents, top_k=1)
assert [document.id for document in result["documents"]] == ["doc_3"]
def test_run_ignores_out_of_range_indices(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 99}, {"index": 2}, {"index": 1}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2", "1"]
def test_run_empty_ranking_result_returns_empty_documents(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant('{"documents": []}')]}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": []}
def test_run_invalid_json_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant("not-json")]}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1, raise_on_failure=False)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_run_invalid_json_raises(mock_chat_generator):
documents = [Document(id="1", content="first")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant("not-json")]}
ranker = LLMRanker(chat_generator=mock_chat_generator, raise_on_failure=True)
with pytest.raises(ValueError):
ranker.run(query="test query", documents=documents)
def test_run_generator_exception_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.side_effect = RuntimeError("generator failed")
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_run_generator_exception_raises(mock_chat_generator):
documents = [Document(id="1", content="first")]
mock_chat_generator.run.side_effect = RuntimeError("generator failed")
ranker = LLMRanker(chat_generator=mock_chat_generator, raise_on_failure=True)
with pytest.raises(RuntimeError, match="generator failed"):
ranker.run(query="test query", documents=documents)
def test_run_no_replies_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": []}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_run_reply_without_text_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant(tool_calls=[])]}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_run_no_valid_document_indices_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 0}, {"index": 3}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_run_deduplicates_documents_before_ranking(mock_chat_generator):
documents = [
Document(id="duplicate", content="keep me", score=0.9),
Document(id="duplicate", content="drop me", score=0.1),
Document(id="unique", content="unique", score=0.2),
]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}, {"index": 1}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert [document.content for document in result["documents"]] == ["unique", "keep me"]
def test_run_preserves_duplicate_indices(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}, {"index": 2}, {"index": 1}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2", "2", "1"]
def test_run_numeric_string_index_is_accepted(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant('{"documents": [{"index": "2"}]}')]}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": [documents[1]]}
def test_run_invalid_index_type_falls_back(mock_chat_generator):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": "invalid"}]}')]
}
ranker = LLMRanker(chat_generator=mock_chat_generator)
result = ranker.run(query="test query", documents=documents)
assert result == {"documents": documents}
def test_init_invalid_custom_prompt_raises(mock_chat_generator):
with pytest.raises(TemplateSyntaxError):
LLMRanker(chat_generator=mock_chat_generator, prompt="Rank {{ query }")
def test_init_prompt_requires_query_and_documents(mock_chat_generator):
with pytest.raises(ValueError, match="prompt must include exactly the variables 'documents' and 'query'"):
LLMRanker(chat_generator=mock_chat_generator, prompt="Rank {{ query }}")
def test_init_prompt_rejects_additional_variables(mock_chat_generator):
with pytest.raises(ValueError, match="prompt must include exactly the variables 'documents' and 'query'"):
LLMRanker(
chat_generator=mock_chat_generator,
prompt="Rank {{ query }} using {{ documents|length }} docs with top_k={{ top_k }}",
)
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_live_run_ranks_berlin_first_for_germany_query():
documents = [
Document(id="doc-berlin", content="Berlin is the capital of Germany."),
Document(id="doc-paris", content="Paris is the capital of France."),
Document(id="doc-rust", content="Rust is a systems programming language focused on safety."),
]
ranker = LLMRanker(top_k=2)
result = ranker.run(query="What is the capital of Germany?", documents=documents)
assert result["documents"]
assert result["documents"][0].id == "doc-berlin"
assert len(result["documents"]) <= 2
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
def test_live_run_ranks_rust_for_programming_language_query():
documents = [
Document(id="doc-berlin", content="Berlin is the capital of Germany."),
Document(id="doc-paris", content="Paris is the capital of France."),
Document(id="doc-rust", content="Rust is a systems programming language focused on safety."),
]
ranker = LLMRanker(top_k=1)
result = ranker.run(query="Which document is about a programming language?", documents=documents)
assert [document.id for document in result["documents"]] == ["doc-rust"]
class FakeSyncOnlyChatGenerator:
"""A chat generator exposing only a synchronous `run` (no `run_async`) for the fallback path."""
def __init__(self):
self.run = Mock()
class TestLLMRankerAsync:
@pytest.mark.asyncio
async def test_run_async(self):
documents = [
Document(id="1", content="first"),
Document(id="2", content="second"),
Document(id="3", content="third"),
]
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(
return_value={
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}, {"index": 1}, {"index": 3}]}')]
}
)
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=2)
result = await ranker.run_async(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2", "1"]
mock_chat_generator.run_async.assert_awaited_once()
mock_chat_generator.run.assert_not_called()
@pytest.mark.asyncio
async def test_run_async_fallback_to_sync_run(self):
documents = [
Document(id="1", content="first"),
Document(id="2", content="second"),
Document(id="3", content="third"),
]
fake_chat_generator = FakeSyncOnlyChatGenerator()
fake_chat_generator.run.return_value = {
"replies": [ChatMessage.from_assistant('{"documents": [{"index": 2}, {"index": 1}, {"index": 3}]}')]
}
assert not hasattr(fake_chat_generator, "run_async")
ranker = LLMRanker(chat_generator=fake_chat_generator, top_k=2)
result = await ranker.run_async(query="test query", documents=documents)
assert [document.id for document in result["documents"]] == ["2", "1"]
fake_chat_generator.run.assert_called_once()
@pytest.mark.asyncio
async def test_run_async_generator_exception_falls_back(self):
documents = [Document(id="1", content="first"), Document(id="2", content="second")]
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(side_effect=RuntimeError("generator failed"))
ranker = LLMRanker(chat_generator=mock_chat_generator, top_k=1, raise_on_failure=False)
result = await ranker.run_async(query="test query", documents=documents)
assert result == {"documents": documents}
@pytest.mark.asyncio
async def test_run_async_generator_exception_raises(self):
documents = [Document(id="1", content="first")]
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
mock_chat_generator.run_async = AsyncMock(side_effect=RuntimeError("generator failed"))
ranker = LLMRanker(chat_generator=mock_chat_generator, raise_on_failure=True)
with pytest.raises(RuntimeError, match="generator failed"):
await ranker.run_async(query="test query", documents=documents)
@pytest.mark.integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.asyncio
async def test_live_run_async_ranks_berlin_first_for_germany_query(self):
documents = [
Document(id="doc-berlin", content="Berlin is the capital of Germany."),
Document(id="doc-paris", content="Paris is the capital of France."),
Document(id="doc-rust", content="Rust is a systems programming language focused on safety."),
]
ranker = LLMRanker(top_k=2)
result = await ranker.run_async(query="What is the capital of Germany?", documents=documents)
assert result["documents"]
assert result["documents"][0].id == "doc-berlin"
assert len(result["documents"]) <= 2
class TestComponentLifecycle:
def test_warm_up_delegates_to_chat_generator(self, mock_chat_generator):
ranker = LLMRanker(chat_generator=mock_chat_generator)
ranker.warm_up()
mock_chat_generator.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_chat_generator(self, mock_chat_generator):
mock_chat_generator.warm_up_async = AsyncMock()
ranker = LLMRanker(chat_generator=mock_chat_generator)
await ranker.warm_up_async()
mock_chat_generator.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
chat_generator = Mock(spec=["run", "warm_up"])
ranker = LLMRanker(chat_generator=chat_generator)
await ranker.warm_up_async()
chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self, mock_chat_generator):
ranker = LLMRanker(chat_generator=mock_chat_generator)
ranker.close()
mock_chat_generator.close.assert_called_once()
async def test_close_async_delegates_to_chat_generator(self, mock_chat_generator):
mock_chat_generator.close_async = AsyncMock()
ranker = LLMRanker(chat_generator=mock_chat_generator)
await ranker.close_async()
mock_chat_generator.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
chat_generator = Mock(spec=["run", "close"])
ranker = LLMRanker(chat_generator=chat_generator)
await ranker.close_async()
chat_generator.close.assert_called_once()
def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
chat_generator = Mock(spec=["run"])
ranker = LLMRanker(chat_generator=chat_generator)
ranker.warm_up()
ranker.close()
@@ -0,0 +1,114 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document
from haystack.components.rankers.lost_in_the_middle import LostInTheMiddleRanker
class TestLostInTheMiddleRanker:
def test_lost_in_the_middle_order_odd(self):
# tests that lost_in_the_middle order works with an odd number of documents
docs = [Document(content=str(i)) for i in range(1, 10)]
ranker = LostInTheMiddleRanker()
result = ranker.run(documents=docs)
assert result["documents"]
expected_order = ["1", "3", "5", "7", "9", "8", "6", "4", "2"]
assert all(doc.content == expected_order[idx] for idx, doc in enumerate(result["documents"]))
def test_lost_in_the_middle_order_even(self):
# tests that lost_in_the_middle order works with an even number of documents
docs = [Document(content=str(i)) for i in range(1, 11)]
ranker = LostInTheMiddleRanker()
result = ranker.run(documents=docs)
expected_order = ["1", "3", "5", "7", "9", "10", "8", "6", "4", "2"]
assert all(doc.content == expected_order[idx] for idx, doc in enumerate(result["documents"]))
def test_lost_in_the_middle_order_two_docs(self):
# tests that lost_in_the_middle order works with two documents
ranker = LostInTheMiddleRanker()
# two docs
docs = [Document(content="1"), Document(content="2")]
result = ranker.run(documents=docs)
assert result["documents"][0].content == "1"
assert result["documents"][1].content == "2"
def test_lost_in_the_middle_init(self):
# tests that LostInTheMiddleRanker initializes with default values
ranker = LostInTheMiddleRanker()
assert ranker.word_count_threshold is None
ranker = LostInTheMiddleRanker(word_count_threshold=10)
assert ranker.word_count_threshold == 10
def test_lost_in_the_middle_init_invalid_word_count_threshold(self):
# tests that LostInTheMiddleRanker raises an error when word_count_threshold is <= 0
with pytest.raises(ValueError, match="Invalid value for word_count_threshold"):
LostInTheMiddleRanker(word_count_threshold=0)
with pytest.raises(ValueError, match="Invalid value for word_count_threshold"):
LostInTheMiddleRanker(word_count_threshold=-5)
def test_lost_in_the_middle_with_word_count_threshold(self):
# tests that lost_in_the_middle with word_count_threshold works as expected
ranker = LostInTheMiddleRanker(word_count_threshold=6)
docs = [Document(content="word" + str(i)) for i in range(1, 10)]
# result, _ = ranker.run(query="", documents=docs)
result = ranker.run(documents=docs)
expected_order = ["word1", "word3", "word5", "word6", "word4", "word2"]
assert all(doc.content == expected_order[idx] for idx, doc in enumerate(result["documents"]))
ranker = LostInTheMiddleRanker(word_count_threshold=9)
# result, _ = ranker.run(query="", documents=docs)
result = ranker.run(documents=docs)
expected_order = ["word1", "word3", "word5", "word7", "word9", "word8", "word6", "word4", "word2"]
assert all(doc.content == expected_order[idx] for idx, doc in enumerate(result["documents"]))
def test_word_count_threshold_greater_than_total_number_of_words_returns_all_documents(self):
ranker = LostInTheMiddleRanker(word_count_threshold=100)
docs = [Document(content="word" + str(i)) for i in range(1, 10)]
ordered_docs = ranker.run(documents=docs)
# assert len(ordered_docs) == len(docs)
expected_order = ["word1", "word3", "word5", "word7", "word9", "word8", "word6", "word4", "word2"]
assert all(doc.content == expected_order[idx] for idx, doc in enumerate(ordered_docs["documents"]))
def test_empty_documents_returns_empty_list(self):
ranker = LostInTheMiddleRanker()
result = ranker.run(documents=[])
assert result == {"documents": []}
def test_list_of_one_document_returns_same_document(self):
ranker = LostInTheMiddleRanker()
doc = Document(content="test")
assert ranker.run(documents=[doc]) == {"documents": [doc]}
def test_run_deduplicates_documents(self):
ranker = LostInTheMiddleRanker()
docs = [
Document(id="duplicate", content="keep me", score=0.9),
Document(id="duplicate", content="drop me", score=0.1),
Document(id="unique", content="unique"),
]
result = ranker.run(documents=docs)
assert len(result["documents"]) == 2
assert result["documents"][0].content == "keep me"
assert result["documents"][1].content == "unique"
@pytest.mark.parametrize("top_k", [1, 2, 3, 4, 5, 6, 7, 8, 12, 20])
def test_lost_in_the_middle_order_with_top_k(self, top_k: int):
# tests that lost_in_the_middle order works with an odd number of documents and a top_k parameter
docs = [Document(content=str(i)) for i in range(1, 10)]
ranker = LostInTheMiddleRanker()
result = ranker.run(documents=docs, top_k=top_k)
if top_k < len(docs):
# top_k is less than the number of documents, so only the top_k documents should be returned in LITM order
assert len(result["documents"]) == top_k
expected_order = ranker.run(documents=[Document(content=str(i)) for i in range(1, top_k + 1)])
assert result == expected_order
else:
# top_k is greater than the number of documents, so all documents should be returned in LITM order
assert len(result["documents"]) == len(docs)
assert result == ranker.run(documents=docs)
@@ -0,0 +1,218 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack import Pipeline
from haystack.components.rankers.meta_field_grouping_ranker import MetaFieldGroupingRanker
from haystack.dataclasses import Document
DOC_LIST = [
# regular
Document(content="Javascript is a popular language", meta={"group": "42", "split_id": 7, "subgroup": "subB"}),
Document(content="A chromosome is a package of DNA", meta={"group": "314", "split_id": 2, "subgroup": "subC"}),
Document(content="DNA carries genetic information", meta={"group": "314", "split_id": 1, "subgroup": "subE"}),
Document(content="Blue whales have a big heart", meta={"group": "11", "split_id": 8, "subgroup": "subF"}),
Document(content="Python is a popular language", meta={"group": "42", "split_id": 4, "subgroup": "subB"}),
Document(content="bla bla bla bla", meta={"split_id": 8, "subgroup": "subG"}),
Document(content="Java is a popular programming language", meta={"group": "42", "split_id": 3, "subgroup": "subB"}),
Document(content="An octopus has three hearts", meta={"group": "11", "split_id": 2, "subgroup": "subD"}),
# without split id
Document(content="without split id", meta={"group": "11"}),
Document(content="without split id2", meta={"group": "22", "subgroup": "subI"}),
Document(content="without split id3", meta={"group": "11"}),
# with list values in the metadata
Document(content="list values", meta={"value_list": ["11"], "split_id": 8, "sub_value_list": ["subF"]}),
Document(content="list values2", meta={"value_list": ["12"], "split_id": 3, "sub_value_list": ["subX"]}),
Document(content="list values3", meta={"value_list": ["12"], "split_id": 8, "sub_value_list": ["subX"]}),
]
class TestMetaFieldGroupingRanker:
def test_init_default(self) -> None:
"""
Test the default initialization of the MetaFieldGroupingRanker component.
"""
sample_ranker = MetaFieldGroupingRanker(group_by="group", sort_docs_by=None)
result = sample_ranker.run(documents=[])
assert "documents" in result
assert result["documents"] == []
def test_run_group_by_only(self) -> None:
"""
Test the MetaFieldGroupingRanker component with only the 'group_by' parameter. No subgroup or sorting is done.
"""
sample_ranker = MetaFieldGroupingRanker(group_by="group")
result = sample_ranker.run(documents=DOC_LIST)
assert "documents" in result
assert len(DOC_LIST) == len(result["documents"])
assert result["documents"][0].meta["split_id"] == 7 and result["documents"][0].meta["group"] == "42"
assert result["documents"][1].meta["split_id"] == 4 and result["documents"][1].meta["group"] == "42"
assert result["documents"][2].meta["split_id"] == 3 and result["documents"][2].meta["group"] == "42"
assert result["documents"][3].meta["split_id"] == 2 and result["documents"][3].meta["group"] == "314"
assert result["documents"][4].meta["split_id"] == 1 and result["documents"][4].meta["group"] == "314"
assert result["documents"][5].meta["split_id"] == 8 and result["documents"][5].meta["group"] == "11"
assert result["documents"][6].meta["split_id"] == 2 and result["documents"][6].meta["group"] == "11"
assert result["documents"][7].content == "without split id" and result["documents"][7].meta["group"] == "11"
assert result["documents"][8].content == "without split id3" and result["documents"][8].meta["group"] == "11"
assert result["documents"][9].content == "without split id2" and result["documents"][9].meta["group"] == "22"
assert result["documents"][10].content == "bla bla bla bla"
def test_with_group_subgroup_and_sorting(self) -> None:
"""
Test the MetaFieldGroupingRanker component with all parameters set, i.e.: grouping by 'group', subgrouping by
'subgroup', and sorting by 'split_id'.
"""
ranker = MetaFieldGroupingRanker(group_by="group", subgroup_by="subgroup", sort_docs_by="split_id")
result = ranker.run(documents=DOC_LIST)
assert "documents" in result
assert len(DOC_LIST) == len(result["documents"])
assert (
result["documents"][0].meta["subgroup"] == "subB"
and result["documents"][0].meta["group"] == "42"
and result["documents"][0].meta["split_id"] == 3
)
assert (
result["documents"][1].meta["subgroup"] == "subB"
and result["documents"][1].meta["group"] == "42"
and result["documents"][1].meta["split_id"] == 4
)
assert (
result["documents"][2].meta["subgroup"] == "subB"
and result["documents"][2].meta["group"] == "42"
and result["documents"][2].meta["split_id"] == 7
)
assert result["documents"][3].meta["subgroup"] == "subC" and result["documents"][3].meta["group"] == "314"
assert result["documents"][4].meta["subgroup"] == "subE" and result["documents"][4].meta["group"] == "314"
assert result["documents"][5].meta["subgroup"] == "subF" and result["documents"][6].meta["group"] == "11"
assert result["documents"][6].meta["subgroup"] == "subD" and result["documents"][5].meta["group"] == "11"
assert result["documents"][7].content == "without split id" and result["documents"][7].meta["group"] == "11"
assert result["documents"][8].content == "without split id3" and result["documents"][8].meta["group"] == "11"
assert result["documents"][9].content == "without split id2" and result["documents"][9].meta["group"] == "22"
assert result["documents"][10].content == "bla bla bla bla"
def test_run_with_lists(self) -> None:
"""
Test if the MetaFieldGroupingRanker component can handle list values in the metadata.
"""
ranker = MetaFieldGroupingRanker(group_by="value_list", subgroup_by="sub_value_list", sort_docs_by="split_id")
result = ranker.run(documents=DOC_LIST)
assert "documents" in result
assert len(DOC_LIST) == len(result["documents"])
assert result["documents"][0].content == "list values" and result["documents"][0].meta["value_list"] == ["11"]
assert result["documents"][1].content == "list values2" and result["documents"][1].meta["value_list"] == ["12"]
assert result["documents"][2].content == "list values3" and result["documents"][2].meta["value_list"] == ["12"]
def test_run_empty_input(self) -> None:
"""
Test the behavior of the MetaFieldGroupingRanker component with an empty list of documents.
"""
sample_ranker = MetaFieldGroupingRanker(group_by="group")
result = sample_ranker.run(documents=[])
assert "documents" in result
assert result["documents"] == []
def test_run_missing_metadata_keys(self) -> None:
"""
Test the behavior of the MetaFieldGroupingRanker component when some documents are missing the required
metadata keys.
"""
docs_with_missing_keys = [
Document(content="Document without group", meta={"split_id": 1, "subgroup": "subA"}),
Document(content="Document without subgroup", meta={"group": "42", "split_id": 2}),
Document(content="Document with all keys", meta={"group": "42", "split_id": 3, "subgroup": "subB"}),
]
sample_ranker = MetaFieldGroupingRanker(group_by="group", subgroup_by="subgroup", sort_docs_by="split_id")
result = sample_ranker.run(documents=docs_with_missing_keys)
assert "documents" in result
assert len(result["documents"]) == 3
assert result["documents"][0].meta["group"] == "42"
assert result["documents"][1].meta["group"] == "42"
assert result["documents"][2].content == "Document without group"
def test_run_sort_docs_by_non_numeric_field_with_missing_values(self) -> None:
"""
Test that sorting by a non-numeric metadata field does not raise an error when some documents are missing
that field. Documents missing the sort field are placed at the end of their group.
"""
docs = [
Document(content="newest", meta={"group": "42", "date": "2023-03-01"}),
Document(content="missing date", meta={"group": "42"}),
Document(content="oldest", meta={"group": "42", "date": "2023-01-01"}),
]
ranker = MetaFieldGroupingRanker(group_by="group", sort_docs_by="date")
result = ranker.run(documents=docs)
assert "documents" in result
assert len(result["documents"]) == 3
assert result["documents"][0].content == "oldest"
assert result["documents"][1].content == "newest"
assert result["documents"][2].content == "missing date"
def test_run_sort_docs_by_field_present_but_none(self) -> None:
"""
Test that sorting by a metadata field works when the field is present but set to None for some documents.
Documents with a None value are treated like missing values and placed at the end of their group.
"""
docs = [
Document(content="present", meta={"group": "42", "date": "2023-01-01"}),
Document(content="none value", meta={"group": "42", "date": None}),
Document(content="missing", meta={"group": "42"}),
]
ranker = MetaFieldGroupingRanker(group_by="group", sort_docs_by="date")
result = ranker.run(documents=docs)
assert "documents" in result
assert len(result["documents"]) == 3
assert result["documents"][0].content == "present"
assert result["documents"][1].content == "none value"
assert result["documents"][2].content == "missing"
def test_run_metadata_with_different_data_types(self) -> None:
"""
Test the behavior of the MetaFieldGroupingRanker component when the metadata values have different data types.
"""
docs_with_mixed_data_types = [
Document(content="Document with string group", meta={"group": "42", "split_id": 1, "subgroup": "subA"}),
Document(content="Document with number group", meta={"group": 42, "split_id": 2, "subgroup": "subB"}),
Document(content="Document with boolean group", meta={"group": True, "split_id": 3, "subgroup": "subC"}),
]
sample_ranker = MetaFieldGroupingRanker(group_by="group", subgroup_by="subgroup", sort_docs_by="split_id")
result = sample_ranker.run(documents=docs_with_mixed_data_types)
assert "documents" in result
assert len(result["documents"]) == 3
assert result["documents"][0].meta["group"] == "42"
assert result["documents"][1].meta["group"] == 42
assert result["documents"][2].meta["group"] is True
def test_run_deduplicates_documents(self) -> None:
"""
Test that duplicate documents are removed before grouping.
"""
docs_with_duplicates = [
Document(id="duplicate", content="keep me", meta={"group": "42", "split_id": 1, "subgroup": "subA"}),
Document(id="duplicate", content="drop me", meta={"group": "42", "split_id": 1, "subgroup": "subA"}),
Document(id="unique", content="unique", meta={"group": "42", "split_id": 2, "subgroup": "subB"}),
Document(id="unique2", content="unique2", meta={"group": "42", "split_id": 2, "subgroup": "subA"}),
]
sample_ranker = MetaFieldGroupingRanker(group_by="group", subgroup_by="subgroup", sort_docs_by="split_id")
result = sample_ranker.run(documents=docs_with_duplicates)
assert "documents" in result
assert len(result["documents"]) == 3
assert result["documents"][0].content == "keep me"
assert result["documents"][1].content == "unique2"
assert result["documents"][2].content == "unique"
def test_run_in_pipeline_dumps_and_loads(self) -> None:
"""
Test if the MetaFieldGroupingRanker component can be dumped to a YAML string and reloaded from it.
"""
ranker = MetaFieldGroupingRanker(group_by="group", sort_docs_by="split_id")
result_single = ranker.run(documents=DOC_LIST)
pipeline = Pipeline()
pipeline.add_component("ranker", ranker)
pipeline_yaml_str = pipeline.dumps()
pipeline_reloaded = Pipeline().loads(pipeline_yaml_str)
result: dict[str, Any] = pipeline_reloaded.run(data={"documents": DOC_LIST})
result = result["ranker"]
assert result_single == result
+306
View File
@@ -0,0 +1,306 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack import Document
from haystack.components.rankers.meta_field import MetaFieldRanker
class TestMetaFieldRanker:
@pytest.mark.parametrize("meta_field_values, expected_first_value", [([1.3, 0.7, 2.1], 2.1), ([1, 5, 8], 8)])
def test_run(self, meta_field_values, expected_first_value):
"""
Test if the component ranks documents correctly.
"""
ranker = MetaFieldRanker(meta_field="rating")
docs_before = [Document(content="abc", meta={"rating": value}) for value in meta_field_values]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
assert docs_after[0].meta["rating"] == expected_first_value
sorted_scores = sorted([doc.meta["rating"] for doc in docs_after], reverse=True)
assert [doc.meta["rating"] for doc in docs_after] == sorted_scores
def test_run_with_weight_equal_to_0(self):
ranker = MetaFieldRanker(meta_field="rating", weight=0.0)
docs_before = [Document(content="abc", meta={"rating": value}) for value in [1.1, 0.5, 2.3]]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
assert [doc.meta["rating"] for doc in docs_after] == [1.1, 0.5, 2.3]
def test_run_with_weight_equal_to_1(self):
ranker = MetaFieldRanker(meta_field="rating", weight=1.0)
docs_before = [Document(content="abc", meta={"rating": value}) for value in [1.1, 0.5, 2.3]]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
sorted_scores = sorted([doc.meta["rating"] for doc in docs_after], reverse=True)
assert [doc.meta["rating"] for doc in docs_after] == sorted_scores
def test_run_with_weight_equal_to_1_passed_in_run_method(self):
ranker = MetaFieldRanker(meta_field="rating", weight=0.0)
docs_before = [Document(content="abc", meta={"rating": value}) for value in [1.1, 0.5, 2.3]]
output = ranker.run(documents=docs_before, weight=1.0)
docs_after = output["documents"]
assert len(docs_after) == 3
sorted_scores = sorted([doc.meta["rating"] for doc in docs_after], reverse=True)
assert [doc.meta["rating"] for doc in docs_after] == sorted_scores
def test_sort_order_ascending(self):
ranker = MetaFieldRanker(meta_field="rating", weight=1.0, sort_order="ascending")
docs_before = [Document(content="abc", meta={"rating": value}) for value in [1.1, 0.5, 2.3]]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
sorted_scores = sorted([doc.meta["rating"] for doc in docs_after])
assert [doc.meta["rating"] for doc in docs_after] == sorted_scores
def test_meta_value_type_float(self):
ranker = MetaFieldRanker(meta_field="rating", weight=1.0, meta_value_type="float")
docs_before = [Document(content="abc", meta={"rating": value}) for value in ["1.1", "10.5", "2.3"]]
docs_after = ranker.run(documents=docs_before)["documents"]
assert len(docs_after) == 3
assert [doc.meta["rating"] for doc in docs_after] == ["10.5", "2.3", "1.1"]
def test_run_deduplicates_documents(self):
ranker = MetaFieldRanker(meta_field="rating")
docs_before = [
Document(id="duplicate", content="keep me", meta={"rating": 1.3}, score=0.9),
Document(id="duplicate", content="drop me", meta={"rating": 1.2}, score=0.1),
Document(id="unique", content="unique", meta={"rating": 2.1}),
]
result = ranker.run(documents=docs_before)
assert len(result["documents"]) == 2
assert result["documents"][0].content == "unique"
assert result["documents"][1].content == "keep me"
def test_run_deduplicates_documents_when_weight_is_0(self):
ranker = MetaFieldRanker(meta_field="rating", weight=0)
docs_before = [
Document(id="duplicate", content="keep me", meta={"rating": 1.3}, score=0.9),
Document(id="duplicate", content="drop me", meta={"rating": 1.2}, score=0.1),
Document(id="unique", content="unique", meta={"rating": 2.1}),
]
result = ranker.run(documents=docs_before)
assert len(result["documents"]) == 2
assert result["documents"][0].content == "keep me"
assert result["documents"][1].content == "unique"
def test_meta_value_type_int(self):
ranker = MetaFieldRanker(meta_field="rating", weight=1.0, meta_value_type="int")
docs_before = [Document(content="abc", meta={"rating": value}) for value in ["1", "10", "2"]]
docs_after = ranker.run(documents=docs_before)["documents"]
assert len(docs_after) == 3
assert [doc.meta["rating"] for doc in docs_after] == ["10", "2", "1"]
def test_meta_value_type_date(self):
ranker = MetaFieldRanker(meta_field="rating", weight=1.0, meta_value_type="date")
docs_before = [Document(content="abc", meta={"rating": value}) for value in ["2022-10", "2023-01", "2022-11"]]
docs_after = ranker.run(documents=docs_before)["documents"]
assert len(docs_after) == 3
assert [doc.meta["rating"] for doc in docs_after] == ["2023-01", "2022-11", "2022-10"]
def test_returns_empty_list_if_no_documents_are_provided(self):
ranker = MetaFieldRanker(meta_field="rating")
output = ranker.run(documents=[])
docs_after = output["documents"]
assert docs_after == []
def test_warning_if_meta_not_found(self, caplog):
ranker = MetaFieldRanker(meta_field="rating")
docs_before = [Document(id="1", content="abc", meta={"wrong_field": 1.3})]
with caplog.at_level(logging.WARNING):
ranker.run(documents=docs_before)
assert (
"The parameter <meta_field> is currently set to 'rating', but none of the provided Documents with IDs "
"1 have this meta key." in caplog.text
)
def test_warning_if_some_meta_not_found(self, caplog):
ranker = MetaFieldRanker(meta_field="rating")
docs_before = [
Document(id="1", content="abc", meta={"wrong_field": 1.3}),
Document(id="2", content="def", meta={"rating": 1.3}),
]
with caplog.at_level(logging.WARNING):
ranker.run(documents=docs_before)
assert (
"The parameter <meta_field> is currently set to 'rating' but the Documents with IDs 1 don't have "
"this meta key." in caplog.text
)
def test_warning_if_unsortable_values(self, caplog):
ranker = MetaFieldRanker(meta_field="rating")
docs_before = [
Document(id="1", content="abc", meta={"rating": 1.3}),
Document(id="2", content="abc", meta={"rating": "1.2"}),
Document(id="3", content="abc", meta={"rating": 2.1}),
]
with caplog.at_level(logging.WARNING):
output = ranker.run(documents=docs_before)
assert len(output["documents"]) == 3
assert "Tried to sort Documents with IDs 1,2,3, but got TypeError with the message:" in caplog.text
def test_warning_if_meta_value_parsing_error(self, caplog):
ranker = MetaFieldRanker(meta_field="rating", meta_value_type="float")
docs_before = [
Document(id="1", content="abc", meta={"rating": "1.3"}),
Document(id="2", content="abc", meta={"rating": "1.2"}),
Document(id="3", content="abc", meta={"rating": "not a float"}),
]
with caplog.at_level(logging.WARNING):
output = ranker.run(documents=docs_before)
assert len(output["documents"]) == 3
assert (
"Tried to parse the meta values of Documents with IDs 1,2,3, but got ValueError with the message:"
in caplog.text
)
def test_warning_meta_value_type_not_all_strings(self, caplog):
ranker = MetaFieldRanker(meta_field="rating", meta_value_type="float")
docs_before = [
Document(id="1", content="abc", meta={"rating": "1.3"}),
Document(id="2", content="abc", meta={"rating": "1.2"}),
Document(id="3", content="abc", meta={"rating": 2.1}),
]
with caplog.at_level(logging.WARNING):
output = ranker.run(documents=docs_before)
assert len(output["documents"]) == 3
assert (
"The parameter <meta_value_type> is currently set to 'float', but not all of meta values in the "
"provided Documents with IDs 1,2,3 are strings." in caplog.text
)
def test_raises_value_error_if_wrong_ranking_mode(self):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", ranking_mode="wrong_mode")
def test_raises_value_error_if_wrong_top_k(self):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", top_k=-1)
@pytest.mark.parametrize("score", [-1, 2, 1.3, 2.1])
def test_raises_component_error_if_wrong_weight(self, score):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", weight=score)
def test_raises_value_error_if_wrong_sort_order(self):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", sort_order="wrong_order")
def test_raises_value_error_if_wrong_missing_meta(self):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", missing_meta="wrong_missing_meta")
def test_raises_value_error_if_wrong_meta_value_type(self):
with pytest.raises(ValueError):
MetaFieldRanker(meta_field="rating", meta_value_type="wrong_type")
def test_linear_score(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5)
docs_before = [
Document(content="abc", meta={"rating": 1.3}, score=0.3),
Document(content="abc", meta={"rating": 0.7}, score=0.4),
Document(content="abc", meta={"rating": 2.1}, score=0.6),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert docs_after[0].score == 0.8
def test_reciprocal_rank_fusion(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="reciprocal_rank_fusion", weight=0.5)
docs_before = [
Document(content="abc", meta={"rating": 1.3}, score=0.3),
Document(content="abc", meta={"rating": 0.7}, score=0.4),
Document(content="abc", meta={"rating": 2.1}, score=0.6),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert docs_after[0].score == pytest.approx(0.016261, abs=1e-5)
@pytest.mark.parametrize("score", [-1, 2, 1.3, 2.1])
def test_linear_score_raises_warning_if_doc_wrong_score(self, score, caplog):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5)
docs_before = [
Document(id="1", content="abc", meta={"rating": 1.3}, score=score),
Document(id="2", content="abc", meta={"rating": 0.7}, score=0.4),
Document(id="3", content="abc", meta={"rating": 2.1}, score=0.6),
]
with caplog.at_level(logging.WARNING):
ranker.run(documents=docs_before)
assert f"The score {score} for Document 1 is outside the [0,1] range; defaulting to 0" in caplog.text
def test_linear_score_raises_raises_warning_if_doc_without_score(self, caplog):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5)
docs_before = [
Document(content="abc", meta={"rating": 1.3}),
Document(content="abc", meta={"rating": 0.7}),
Document(content="abc", meta={"rating": 2.1}),
]
with caplog.at_level(logging.WARNING):
ranker.run(documents=docs_before)
assert "The score wasn't provided; defaulting to 0." in caplog.text
def test_different_ranking_mode_for_init_vs_run(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5)
docs_before = [
Document(content="abc", meta={"rating": 1.3}, score=0.3),
Document(content="abc", meta={"rating": 0.7}, score=0.4),
Document(content="abc", meta={"rating": 2.1}, score=0.6),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert docs_after[0].score == 0.8
output = ranker.run(documents=docs_before, ranking_mode="reciprocal_rank_fusion")
docs_after = output["documents"]
assert docs_after[0].score == pytest.approx(0.016261, abs=1e-5)
def test_missing_meta_bottom(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5, missing_meta="bottom")
docs_before = [
Document(id="1", content="abc", meta={"rating": 1.3}, score=0.6),
Document(id="2", content="abc", meta={}, score=0.4),
Document(id="3", content="abc", meta={"rating": 2.1}, score=0.39),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
assert docs_after[2].id == "2"
def test_missing_meta_top(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5, missing_meta="top")
docs_before = [
Document(id="1", content="abc", meta={"rating": 1.3}, score=0.6),
Document(id="2", content="abc", meta={}, score=0.59),
Document(id="3", content="abc", meta={"rating": 2.1}, score=0.4),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 3
assert docs_after[0].id == "2"
def test_missing_meta_drop(self):
ranker = MetaFieldRanker(meta_field="rating", ranking_mode="linear_score", weight=0.5, missing_meta="drop")
docs_before = [
Document(id="1", content="abc", meta={"rating": 1.3}, score=0.6),
Document(id="2", content="abc", meta={}, score=0.59),
Document(id="3", content="abc", meta={"rating": 2.1}, score=0.4),
]
output = ranker.run(documents=docs_before)
docs_after = output["documents"]
assert len(docs_after) == 2
assert "2" not in [doc.id for doc in docs_after]
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,254 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, Pipeline
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.retrievers import InMemoryBM25Retriever
from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
class TestAutoMergingRetriever:
def test_init_default(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert retriever.threshold == 0.5
def test_init_with_parameters(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.7)
assert retriever.threshold == 0.7
def test_init_with_invalid_threshold(self, in_memory_doc_store):
with pytest.raises(ValueError):
AutoMergingRetriever(in_memory_doc_store, threshold=-2)
def test_run_missing_parent_id(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__level": 1, "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
retriever.run(documents=docs)
def test_run_missing_level(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__level'"
):
retriever.run(documents=docs)
def test_run_missing_block_size(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__level": 1})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__block_size'"
):
retriever.run(documents=docs)
def test_run_mixed_valid_and_invalid_documents(self, in_memory_doc_store):
docs = [
Document(content="valid", meta={"__parent_id": "parent1", "__level": 1, "__block_size": 10}),
Document(content="invalid", meta={"__level": 1, "__block_size": 10}),
]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
retriever.run(documents=docs)
def test_to_dict(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.7)
expected = retriever.to_dict()
assert expected["type"] == "haystack.components.retrievers.auto_merging_retriever.AutoMergingRetriever"
assert expected["init_parameters"]["threshold"] == 0.7
assert (
expected["init_parameters"]["document_store"]["type"]
== "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore"
)
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.auto_merging_retriever.AutoMergingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "6b122bb4-211b-465e-804d-77c5857bf4c5",
"shared": True,
},
},
"threshold": 0.7,
},
}
retriever = AutoMergingRetriever.from_dict(data)
assert retriever.threshold == 0.7
def test_serialization_deserialization_pipeline(self, in_memory_doc_store):
pipeline = Pipeline()
bm_25_retriever = InMemoryBM25Retriever(in_memory_doc_store)
auto_merging_retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
pipeline.add_component(name="bm_25_retriever", instance=bm_25_retriever)
pipeline.add_component(name="auto_merging_retriever", instance=auto_merging_retriever)
pipeline.connect("bm_25_retriever.documents", "auto_merging_retriever.documents")
pipeline_dict = pipeline.to_dict()
new_pipeline = Pipeline.from_dict(pipeline_dict)
assert new_pipeline == pipeline
def test_run_parent_not_found(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# a leaf document with a non-existent parent_id
leaf_doc = Document(
content="test", meta={"__parent_id": "non_existent_parent", "__level": 1, "__block_size": 10}
)
with pytest.raises(ValueError, match="Expected 1 parent document with id non_existent_parent, found 0"):
retriever.run([leaf_doc])
def test_run_parent_without_children_metadata(self, in_memory_doc_store):
"""Test case where a parent document exists but doesn't have the __children_ids metadata field"""
# Create and store a parent document without __children_ids metadata
parent_doc = Document(
content="parent content",
id="parent1",
meta={
"__level": 1, # Add other required metadata
"__block_size": 10,
},
)
in_memory_doc_store.write_documents([parent_doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# Create a leaf document that points to this parent
leaf_doc = Document(content="leaf content", meta={"__parent_id": "parent1", "__level": 2, "__block_size": 5})
with pytest.raises(ValueError, match="Parent document with id parent1 does not have any children"):
retriever.run([leaf_doc])
def test_run_empty_documents(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert retriever.run([]) == {"documents": []}
def test_run_return_parent_document(self, in_memory_doc_store):
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
# since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
docs = retriever.run(leaf_docs[4:6])
assert len(docs["documents"]) == 1
assert docs["documents"][0].content == "warm glow over the trees. Birds began to sing."
assert len(docs["documents"][0].meta["__children_ids"]) == 3
def test_run_return_leafs_document(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = retriever.run([leaf_docs[4]])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "eastern side of "
assert result["documents"][0].meta["__parent_id"] == docs["documents"][2].id
def test_run_return_leafs_document_different_parents(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = retriever.run([leaf_docs[4], leaf_docs[3]])
assert len(result["documents"]) == 2
assert result["documents"][0].meta["__parent_id"] != result["documents"][1].meta["__parent_id"]
def test_run_go_up_hierarchy_multiple_levels(self, in_memory_doc_store):
"""
Test if the retriever can go up the hierarchy multiple levels to find the parent document.
Simulate a scenario where we have 4 leaf-documents that matched some initial query. The leaf-documents
are continuously merged up the hierarchy until the threshold is no longer met.
In this case it goes from the 4th level in the hierarchy up the 1st level.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4, 2, 1}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.4)
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [d for d in docs["documents"] if d.content in {"The ", "sun ", "rose ", "early "}]
result = retriever.run(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].content == "The sun rose early in the "
def test_run_go_up_hierarchy_multiple_levels_hit_root_document(self, in_memory_doc_store):
"""
Test case where we go up hierarchy until the root document, so the root document is returned.
It's the only document in the hierarchy which has no parent.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.1) # set a low threshold to hit root document
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [
d
for d in docs["documents"]
if d.content in {"The sun rose early ", "in the ", "morning. It cast a ", "over the trees. Birds "}
]
result = retriever.run(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].meta["__level"] == 0 # hit root document
@@ -0,0 +1,208 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
class TestAutoMergingRetrieverAsync:
@pytest.mark.asyncio
async def test_run_missing_parent_id(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__level": 1, "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_missing_level(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__level'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_missing_block_size(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__level": 1})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__block_size'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_mixed_valid_and_invalid_documents(self, in_memory_doc_store):
docs = [
Document(content="valid", meta={"__parent_id": "parent1", "__level": 1, "__block_size": 10}),
Document(content="invalid", meta={"__level": 1, "__block_size": 10}),
]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_parent_not_found(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# a leaf document with a non-existent parent_id
leaf_doc = Document(
content="test", meta={"__parent_id": "non_existent_parent", "__level": 1, "__block_size": 10}
)
with pytest.raises(ValueError, match="Expected 1 parent document with id non_existent_parent, found 0"):
await retriever.run_async([leaf_doc])
@pytest.mark.asyncio
async def test_run_parent_without_children_metadata(self, in_memory_doc_store):
"""Test case where a parent document exists but doesn't have the __children_ids metadata field"""
# Create and store a parent document without __children_ids metadata
parent_doc = Document(
content="parent content",
id="parent1",
meta={
"__level": 1, # Add other required metadata
"__block_size": 10,
},
)
in_memory_doc_store.write_documents([parent_doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# Create a leaf document that points to this parent
leaf_doc = Document(content="leaf content", meta={"__parent_id": "parent1", "__level": 2, "__block_size": 5})
with pytest.raises(ValueError, match="Parent document with id parent1 does not have any children"):
await retriever.run_async([leaf_doc])
@pytest.mark.asyncio
async def test_run_empty_documents(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert await retriever.run_async([]) == {"documents": []}
@pytest.mark.asyncio
async def test_run_return_parent_document(self, in_memory_doc_store):
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
# since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
docs = await retriever.run_async(leaf_docs[4:6])
assert len(docs["documents"]) == 1
assert docs["documents"][0].content == "warm glow over the trees. Birds began to sing."
assert len(docs["documents"][0].meta["__children_ids"]) == 3
@pytest.mark.asyncio
async def test_run_return_leafs_document(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = await retriever.run_async([leaf_docs[4]])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "eastern side of "
assert result["documents"][0].meta["__parent_id"] == docs["documents"][2].id
@pytest.mark.asyncio
async def test_run_return_leafs_document_different_parents(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = await retriever.run_async([leaf_docs[4], leaf_docs[3]])
assert len(result["documents"]) == 2
assert result["documents"][0].meta["__parent_id"] != result["documents"][1].meta["__parent_id"]
@pytest.mark.asyncio
async def test_run_go_up_hierarchy_multiple_levels(self, in_memory_doc_store):
"""
Test if the retriever can go up the hierarchy multiple levels to find the parent document.
Simulate a scenario where we have 4 leaf-documents that matched some initial query. The leaf-documents
are continuously merged up the hierarchy until the threshold is no longer met.
In this case it goes from the 4th level in the hierarchy up the 1st level.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4, 2, 1}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.4)
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [d for d in docs["documents"] if d.content in {"The ", "sun ", "rose ", "early "}]
result = await retriever.run_async(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].content == "The sun rose early in the "
@pytest.mark.asyncio
async def test_run_go_up_hierarchy_multiple_levels_hit_root_document(self, in_memory_doc_store):
"""
Test case where we go up hierarchy until the root document, so the root document is returned.
It's the only document in the hierarchy which has no parent.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.1) # set a low threshold to hit root document
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [
d
for d in docs["documents"]
if d.content in {"The sun rose early ", "in the ", "morning. It cast a ", "over the trees. Birds "}
]
result = await retriever.run_async(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].meta["__level"] == 0 # hit root document

Some files were not shown because too many files have changed in this diff Show More