mirror of
https://github.com/deepset-ai/haystack.git
synced 2025-12-16 17:48:19 +00:00
* feat: add ReasoningContent to ChatMessage * more tests * release note * Update haystack/dataclasses/chat_message.py Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> --------- Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com>
491 lines
18 KiB
Python
491 lines
18 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from jinja2 import TemplateSyntaxError
|
|
from jinja2.sandbox import SandboxedEnvironment
|
|
|
|
from haystack.dataclasses.chat_message import ImageContent, ReasoningContent, ToolCall, ToolCallResult
|
|
from haystack.utils.jinja2_chat_extension import ChatMessageExtension, templatize_part
|
|
|
|
|
|
class TestChatMessageExtension:
|
|
@pytest.fixture
|
|
def jinja_env(self) -> SandboxedEnvironment:
|
|
# we use a SandboxedEnvironment here to replicate the conditions of the ChatPromptBuilder component
|
|
env = SandboxedEnvironment(extensions=[ChatMessageExtension])
|
|
env.filters["templatize_part"] = templatize_part
|
|
return env
|
|
|
|
def test_message_with_name_and_meta(self, jinja_env):
|
|
template = """
|
|
{% message role="user" name="Bob" meta={"language": "en"} %}
|
|
Hello!
|
|
{% endmessage %}
|
|
"""
|
|
rendered = jinja_env.from_string(template).render()
|
|
output = json.loads(rendered.strip())
|
|
expected = {"role": "user", "name": "Bob", "content": [{"text": "Hello!"}], "meta": {"language": "en"}}
|
|
assert output == expected
|
|
|
|
def test_message_no_endmessage_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
Hello!
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match="Jinja was looking for the following tags: 'endmessage'"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_no_role_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message %}
|
|
You are a helpful assistant.
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match="expected token 'role'"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_meta_not_dict_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" meta="not a dict" %}
|
|
Hello!
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match="meta must be a dictionary"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_meta_invalid_dict_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" meta={"key": "unclosed_value} %}
|
|
Hello!
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_name_not_str_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" name=123 %}
|
|
Hello!
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match="name must be a string"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_system_message(self, jinja_env):
|
|
template = """
|
|
{% message role="system" %}
|
|
You are a helpful assistant.
|
|
{% endmessage %}
|
|
"""
|
|
rendered = jinja_env.from_string(template).render()
|
|
output = json.loads(rendered.strip())
|
|
expected = {"role": "system", "content": [{"text": "You are a helpful assistant."}], "name": None, "meta": {}}
|
|
assert output == expected
|
|
|
|
def test_user_message_with_variable(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
Hello, my name is {{name}}!
|
|
{% endmessage %}
|
|
"""
|
|
rendered = jinja_env.from_string(template).render(name="Alice")
|
|
output = json.loads(rendered.strip())
|
|
expected = {"role": "user", "content": [{"text": "Hello, my name is Alice!"}], "name": None, "meta": {}}
|
|
assert output == expected
|
|
|
|
def test_assistant_message_with_tool_call(self, jinja_env):
|
|
template = """
|
|
{% message role="assistant" %}
|
|
Let me search for that information.
|
|
{{ tool_call | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
tool_call = ToolCall(tool_name="search", arguments={"query": "an interesting question"}, id="search_1")
|
|
rendered = jinja_env.from_string(template).render(tool_call=tool_call)
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"text": "Let me search for that information."},
|
|
{
|
|
"tool_call": {
|
|
"tool_name": "search",
|
|
"arguments": {"query": "an interesting question"},
|
|
"id": "search_1",
|
|
}
|
|
},
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_assistant_message_with_reasoning(self, jinja_env):
|
|
template = """
|
|
{% message role="assistant" %}
|
|
{{ reasoning | templatize_part }}
|
|
The answer is 4.
|
|
{% endmessage %}
|
|
"""
|
|
reasoning = ReasoningContent(reasoning_text="Let me think about it...", extra={"key": "value"})
|
|
rendered = jinja_env.from_string(template).render(reasoning=reasoning)
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"reasoning": {"reasoning_text": "Let me think about it...", "extra": {"key": "value"}}},
|
|
{"text": "The answer is 4."},
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_tool_message(self, jinja_env):
|
|
template = """
|
|
{% message role="tool" %}
|
|
{{ tool_result | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
tool_call = ToolCall(tool_name="search", arguments={"query": "test"}, id="search_1")
|
|
tool_result = ToolCallResult(result="Here are the search results", origin=tool_call, error=False)
|
|
rendered = jinja_env.from_string(template).render(tool_result=tool_result)
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "tool",
|
|
"content": [
|
|
{
|
|
"tool_call_result": {
|
|
"result": "Here are the search results",
|
|
"error": False,
|
|
"origin": {"tool_name": "search", "arguments": {"query": "test"}, "id": "search_1"},
|
|
}
|
|
}
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_user_message_with_image(self, jinja_env, base64_image_string):
|
|
template = """
|
|
{% message role="user" %}
|
|
Please describe this image:
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
image = ImageContent(base64_image=base64_image_string, mime_type="image/png")
|
|
rendered = jinja_env.from_string(template).render(image=image)
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "user",
|
|
"content": [
|
|
{"text": "Please describe this image:"},
|
|
{
|
|
"image": {
|
|
"base64_image": base64_image_string,
|
|
"mime_type": "image/png",
|
|
"detail": None,
|
|
"meta": {},
|
|
"validation": True,
|
|
}
|
|
},
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_user_message_with_multiple_images(self, jinja_env, base64_image_string):
|
|
template = """
|
|
{% message role="user" %}
|
|
Compare these images:
|
|
{% for img in images %}
|
|
{{ img | templatize_part }}
|
|
{% endfor %}
|
|
{% endmessage %}
|
|
"""
|
|
images = [
|
|
ImageContent(base64_image=base64_image_string, mime_type="image/png"),
|
|
ImageContent(base64_image=base64_image_string, mime_type="image/png"),
|
|
]
|
|
rendered = jinja_env.from_string(template).render(images=images)
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "user",
|
|
"content": [
|
|
{"text": "Compare these images:"},
|
|
{
|
|
"image": {
|
|
"base64_image": base64_image_string,
|
|
"mime_type": "image/png",
|
|
"detail": None,
|
|
"meta": {},
|
|
"validation": True,
|
|
}
|
|
},
|
|
{
|
|
"image": {
|
|
"base64_image": base64_image_string,
|
|
"mime_type": "image/png",
|
|
"detail": None,
|
|
"meta": {},
|
|
"validation": True,
|
|
}
|
|
},
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_user_message_with_multiple_images_and_interleaved_text(self, jinja_env, base64_image_string):
|
|
"""
|
|
Tests that messages with multiple images and interleaved text are rendered correctly.
|
|
This format is used by Anthropic models:
|
|
https://docs.anthropic.com/en/docs/build-with-claude/vision#example-multiple-images
|
|
"""
|
|
template = """
|
|
{% message role="user" %}
|
|
{% for image in images %}
|
|
Image {{ loop.index }}:
|
|
{{ image | templatize_part }}
|
|
{% endfor %}
|
|
What's the difference between the two images?
|
|
{% endmessage %}
|
|
"""
|
|
image = ImageContent(base64_image=base64_image_string, mime_type="image/png")
|
|
rendered = jinja_env.from_string(template).render(images=[image, image])
|
|
output = json.loads(rendered.strip())
|
|
|
|
expected = {
|
|
"role": "user",
|
|
"content": [
|
|
{"text": "Image 1:"},
|
|
{
|
|
"image": {
|
|
"base64_image": base64_image_string,
|
|
"mime_type": "image/png",
|
|
"detail": None,
|
|
"meta": {},
|
|
"validation": True,
|
|
}
|
|
},
|
|
{"text": "Image 2:"},
|
|
{
|
|
"image": {
|
|
"base64_image": base64_image_string,
|
|
"mime_type": "image/png",
|
|
"detail": None,
|
|
"meta": {},
|
|
"validation": True,
|
|
}
|
|
},
|
|
{"text": "What's the difference between the two images?"},
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_user_message_multiple_lines(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
What do you think of NLP?
|
|
It's an interesting domain, if you ask me.
|
|
But my favorite subject is Small Language Models.
|
|
{% endmessage %}
|
|
"""
|
|
rendered = jinja_env.from_string(template).render()
|
|
output = json.loads(rendered.strip())
|
|
expected = {
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"text": (
|
|
"What do you think of NLP?\nIt's an interesting domain, if you ask me.\n"
|
|
"But my favorite subject is Small Language Models."
|
|
)
|
|
}
|
|
],
|
|
"name": None,
|
|
"meta": {},
|
|
}
|
|
assert output == expected
|
|
|
|
def test_invalid_role(self, jinja_env):
|
|
template = """
|
|
{% message role="invalid_role" %}
|
|
This should fail
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match="Role must be one of"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_templatize_part_filter_with_invalid_type(self):
|
|
with pytest.raises(TypeError, match="Unsupported type in ChatMessage content"):
|
|
templatize_part(123)
|
|
|
|
def test_empty_message_content_raises_error(self, jinja_env):
|
|
error_message = "Message content in template is empty or contains only whitespace characters."
|
|
|
|
template = "{% message role='user' %}{% endmessage %}"
|
|
with pytest.raises(ValueError, match=error_message):
|
|
jinja_env.from_string(template).render()
|
|
|
|
template = "{% message role='user' %} \n\t\n\t {% endmessage %}"
|
|
with pytest.raises(ValueError, match=error_message):
|
|
jinja_env.from_string(template).render()
|
|
|
|
template = """
|
|
{% message role="user" %}
|
|
{% if some_condition %}
|
|
{% else %}
|
|
{% endif %}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError, match=error_message):
|
|
jinja_env.from_string(template).render()
|
|
|
|
template = """
|
|
{% message role="user" %}
|
|
{{ variable_that_doesnt_exist }}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError, match=error_message):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_no_parts_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
Something
|
|
{% endmessage %}
|
|
"""
|
|
|
|
# I am patching the _parse_content_parts method to return an empty list
|
|
# because I could not find a template that raises a similar error
|
|
with patch("haystack.utils.jinja2_chat_extension.ChatMessageExtension._parse_content_parts", return_value=[]):
|
|
with pytest.raises(ValueError, match="message parts"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_message_with_whitespace_handling(self, jinja_env):
|
|
# the following templates should all be equivalent
|
|
templates = [
|
|
"""{% message role="user" %}String{% endmessage %}""",
|
|
"""{% message role="user" %} String {% endmessage %}""",
|
|
"""{% message role="user" %}
|
|
String
|
|
{% endmessage %}""",
|
|
"""{% message role="user" %}\tString\t{% endmessage %}""",
|
|
]
|
|
expected = {"role": "user", "content": [{"text": "String"}], "name": None, "meta": {}}
|
|
for template in templates:
|
|
rendered = jinja_env.from_string(template).render()
|
|
output = json.loads(rendered.strip())
|
|
assert output == expected
|
|
|
|
def test_unclosed_content_tag_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
<haystack_content_part>{"type": "text", "text": "Hello"}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError, match="Found unclosed <haystack_content_part> tag"):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_invalid_json_in_content_part_raises_error(self, jinja_env):
|
|
template = """
|
|
{% message role="user" %}
|
|
Normal text before.
|
|
<haystack_content_part>{"this is": "invalid" json}</haystack_content_part>
|
|
<haystack_content_part>not even trying to be json</haystack_content_part>
|
|
<haystack_content_part>{]</haystack_content_part>
|
|
Normal text after.
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(json.JSONDecodeError):
|
|
jinja_env.from_string(template).render()
|
|
|
|
def test_invalid_system_message_raises_error(self, jinja_env, base64_image_string):
|
|
template = """
|
|
{% message role="system" %}
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
image = ImageContent(base64_image=base64_image_string, mime_type="image/png")
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
template = """
|
|
{% message role="system" %}
|
|
Some text.
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
def test_invalid_assistant_message_raises_error(self, jinja_env, base64_image_string):
|
|
template = """
|
|
{% message role="assistant" %}
|
|
text 1
|
|
{{ image | templatize_part }}
|
|
text 2
|
|
{% endmessage %}
|
|
"""
|
|
image = ImageContent(base64_image=base64_image_string, mime_type="image/png")
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
template = """
|
|
{% message role="assistant" %}
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
template = """
|
|
{% message role="assistant" %}
|
|
text 1
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
def test_invalid_tool_message_raises_error(self, jinja_env, base64_image_string):
|
|
template = """
|
|
{% message role="tool" %}
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
image = ImageContent(base64_image=base64_image_string, mime_type="image/png")
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(image=image)
|
|
|
|
template = """
|
|
{% message role="tool" %}
|
|
{{ tool_result | templatize_part }}
|
|
{{ tool_result | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
tool_call = ToolCall(tool_name="search", arguments={"query": "test"}, id="search_1")
|
|
tool_result = ToolCallResult(result="Here are the search results", origin=tool_call, error=False)
|
|
with pytest.raises(ValueError):
|
|
jinja_env.from_string(template).render(tool_result=tool_result)
|
|
|
|
template = """
|
|
{% message role="tool" %}
|
|
{{ tool_result | templatize_part }}
|
|
{{ image | templatize_part }}
|
|
{% endmessage %}
|
|
"""
|
|
with pytest.raises(TypeError):
|
|
jinja_env.from_string(template).render(image=image)
|