chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (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
+22
View File
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
_import_structure = {
"answer_builder": ["AnswerBuilder"],
"chat_prompt_builder": ["ChatPromptBuilder"],
"prompt_builder": ["PromptBuilder"],
}
if TYPE_CHECKING:
from .answer_builder import AnswerBuilder as AnswerBuilder
from .chat_prompt_builder import ChatPromptBuilder as ChatPromptBuilder
from .prompt_builder import PromptBuilder as PromptBuilder
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
@@ -0,0 +1,314 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import re
from dataclasses import replace
from typing import Any
from haystack import Document, GeneratedAnswer, component, logging
from haystack.dataclasses.chat_message import ChatMessage
logger = logging.getLogger(__name__)
DEFAULT_REFERENCE_PATTERN = r"\[(\d+)\]"
EXPANDED_REFERENCE_PATTERN = r"\[(\d+(?:[,-]\d+)*)\]"
@component
class AnswerBuilder:
"""
Converts a query and Generator replies into a `GeneratedAnswer` object.
AnswerBuilder parses Generator replies using custom regular expressions.
Check out the usage example below to see how it works.
Optionally, it can also take documents and metadata from the Generator to add to the `GeneratedAnswer` object.
AnswerBuilder works with both non-chat and chat Generators.
### Usage example
```python
from haystack.components.builders import AnswerBuilder
builder = AnswerBuilder(pattern="Answer: (.*)")
builder.run(query="What's the answer?", replies=["This is an argument. Answer: This is the answer."])
```
### Usage example with documents and reference pattern
```python
from haystack import Document
from haystack.components.builders import AnswerBuilder
replies = ["The capital of France is Paris [2]."]
docs = [
Document(content="Berlin is the capital of Germany."),
Document(content="Paris is the capital of France."),
Document(content="Rome is the capital of Italy."),
]
builder = AnswerBuilder(reference_pattern="\\[(\\d+)\\]", return_only_referenced_documents=False)
result = builder.run(query="What is the capital of France?", replies=replies, documents=docs)["answers"][0]
print(f"Answer: {result.data}")
print("References:")
for doc in result.documents:
if doc.meta["referenced"]:
print(f"[{doc.meta['source_index']}] {doc.content}")
print("Other sources:")
for doc in result.documents:
if not doc.meta["referenced"]:
print(f"[{doc.meta['source_index']}] {doc.content}")
# >> Answer: The capital of France is Paris
# >> References:
# >> [2] Paris is the capital of France.
# >> Other sources:
# >> [1] Berlin is the capital of Germany.
# >> [3] Rome is the capital of Italy.
```
"""
def __init__(
self,
pattern: str | None = None,
reference_pattern: str | None = None,
last_message_only: bool = False,
*,
return_only_referenced_documents: bool = True,
expand_reference_ranges: bool = False,
) -> None:
"""
Creates an instance of the AnswerBuilder component.
:param pattern:
The regular expression pattern to extract the answer text from the Generator.
If not specified, the entire response is used as the answer.
The regular expression can have one capture group at most.
If present, the capture group text
is used as the answer. If no capture group is present, the whole match is used as the answer.
Examples:
`[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
`Answer: (.*)` finds "this is an answer" in a string "this is an argument. Answer: this is an answer".
:param reference_pattern:
The regular expression pattern used for parsing the document references.
If not specified, no parsing is done, and all documents are returned.
References need to be specified as indices of the input documents and start at [1].
Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
If this parameter is provided, documents metadata will contain a "referenced" key with a boolean value.
:param last_message_only:
If False (default value), all messages are used as the answer.
If True, only the last message is used as the answer.
:param return_only_referenced_documents:
To be used in conjunction with `reference_pattern`.
If True (default value), only the documents that were actually referenced in `replies` are returned.
If False, all documents are returned.
If `reference_pattern` is not provided, this parameter has no effect, and all documents are returned.
:param expand_reference_ranges:
If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
Defaults to False for backwards compatibility.
When enabled with the default `reference_pattern`, a broader pattern is used automatically.
"""
if pattern:
AnswerBuilder._check_num_groups_in_regex(pattern)
self.pattern = pattern
self.reference_pattern = reference_pattern
self.last_message_only = last_message_only
self.return_only_referenced_documents = return_only_referenced_documents
self.expand_reference_ranges = expand_reference_ranges
@component.output_types(answers=list[GeneratedAnswer])
def run(
self,
query: str,
replies: list[str] | list[ChatMessage],
meta: list[dict[str, Any]] | None = None,
documents: list[Document] | None = None,
pattern: str | None = None,
reference_pattern: str | None = None,
expand_reference_ranges: bool | None = None,
) -> dict[str, Any]:
"""
Turns the output of a Generator into `GeneratedAnswer` objects using regular expressions.
:param query:
The input query used as the Generator prompt.
:param replies:
The output of the Generator. Can be a list of strings or a list of `ChatMessage` objects.
:param meta:
The metadata returned by the Generator. If not specified, the generated answer will contain no metadata.
:param documents:
The documents used as the Generator inputs. If specified, they are added to
the `GeneratedAnswer` objects.
The Document copies inside the returned `GeneratedAnswer.documents` each include a "source_index" key,
representing the document's 1-based position in the input list. The original input documents are
not modified.
When `reference_pattern` is provided:
- "referenced" key is added to the Document copies inside `GeneratedAnswer.documents`, indicating if
the document was referenced in the output.
- `return_only_referenced_documents` init parameter controls if all or only referenced documents are
returned.
:param pattern:
The regular expression pattern to extract the answer text from the Generator.
If not specified, the entire response is used as the answer.
The regular expression can have one capture group at most.
If present, the capture group text
is used as the answer. If no capture group is present, the whole match is used as the answer.
Examples:
`[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
`Answer: (.*)` finds "this is an answer" in a string
"this is an argument. Answer: this is an answer".
:param reference_pattern:
The regular expression pattern used for parsing the document references.
If not specified, no parsing is done, and all documents are returned.
References need to be specified as indices of the input documents and start at [1].
Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
:param expand_reference_ranges:
If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
If not specified, the value from the component initialization is used.
:returns: A dictionary with the following keys:
- `answers`: The answers received from the output of the Generator.
"""
if not meta:
meta = [{}] * len(replies)
elif len(replies) != len(meta):
raise ValueError(f"Number of replies ({len(replies)}), and metadata ({len(meta)}) must match.")
if pattern:
AnswerBuilder._check_num_groups_in_regex(pattern)
pattern = pattern or self.pattern
reference_pattern = reference_pattern or self.reference_pattern
expand_reference_ranges = (
self.expand_reference_ranges if expand_reference_ranges is None else expand_reference_ranges
)
reference_pattern = AnswerBuilder._resolve_reference_pattern(
reference_pattern=reference_pattern, expand_reference_ranges=expand_reference_ranges
)
replies_to_iterate = replies[-1:] if self.last_message_only and replies else replies
meta_to_iterate = meta[-1:] if self.last_message_only and meta else meta
all_answers = []
for reply, given_metadata in zip(replies_to_iterate, meta_to_iterate, strict=True):
# Extract content from ChatMessage objects if reply is a ChatMessages, else use the string as is
extracted_reply = reply.text or "" if isinstance(reply, ChatMessage) else str(reply)
extracted_metadata = reply.meta if isinstance(reply, ChatMessage) else {}
extracted_metadata = {**extracted_metadata, **given_metadata}
extracted_metadata["all_messages"] = replies
referenced_docs = []
if documents:
referenced_idxs = (
AnswerBuilder._extract_reference_idxs(
extracted_reply,
reference_pattern,
expand_ranges=expand_reference_ranges,
num_documents=len(documents),
)
if reference_pattern
else set()
)
doc_idxs = (
referenced_idxs
if reference_pattern and self.return_only_referenced_documents
else set(range(len(documents)))
)
for idx in doc_idxs:
try:
doc = documents[idx]
except IndexError:
logger.warning(
"Document index '{index}' referenced in Generator output is out of range. ", index=idx + 1
)
continue
doc_meta: dict[str, Any] = dict(doc.meta or {})
doc_meta["source_index"] = idx + 1
if reference_pattern:
doc_meta["referenced"] = idx in referenced_idxs
referenced_docs.append(replace(doc, meta=doc_meta))
answer_string = AnswerBuilder._extract_answer_string(extracted_reply, pattern)
answer = GeneratedAnswer(
data=answer_string, query=query, documents=referenced_docs, meta=extracted_metadata
)
all_answers.append(answer)
return {"answers": all_answers}
@staticmethod
def _extract_answer_string(reply: str, pattern: str | None = None) -> str:
"""
Extract the answer string from the generator output using the specified pattern.
If no pattern is specified, the whole string is used as the answer.
:param reply:
The output of the Generator. A string.
:param pattern:
The regular expression pattern to use to extract the answer text from the generator output.
"""
if pattern is None:
return reply
if match := re.search(pattern, reply):
# No capture group in pattern -> use the whole match as answer
if not match.lastindex:
return match.group(0)
# One capture group in pattern -> use the capture group as answer
return match.group(1)
return ""
@staticmethod
def _resolve_reference_pattern(reference_pattern: str | None, expand_reference_ranges: bool) -> str | None:
if not reference_pattern or not expand_reference_ranges:
return reference_pattern
if reference_pattern == DEFAULT_REFERENCE_PATTERN:
return EXPANDED_REFERENCE_PATTERN
return reference_pattern
@staticmethod
def _extract_reference_idxs(
reply: str, reference_pattern: str, expand_ranges: bool = False, num_documents: int | None = None
) -> set[int]:
matches = re.findall(reference_pattern, reply)
idxs: set[int] = set()
for match in matches:
if expand_ranges:
for part in match.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
start_str, end_str = part.split("-", 1)
start, end = int(start_str), int(end_str)
if start > end:
continue
# Clamp the range end to the number of documents to avoid materializing a huge
# set from an out-of-range citation like `[1-999999999]` in the Generator output.
if num_documents is not None:
end = min(end, num_documents)
idxs.update(range(start - 1, end))
else:
idxs.add(int(part) - 1)
else:
idxs.add(int(match) - 1)
return idxs
@staticmethod
def _check_num_groups_in_regex(pattern: str) -> None:
num_groups = re.compile(pattern).groups
if num_groups > 1:
raise ValueError(
f"Pattern '{pattern}' contains multiple capture groups. "
f"Please specify a pattern with at most one capture group."
)
@@ -0,0 +1,359 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from dataclasses import replace
from typing import Any, Literal
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.dataclasses.chat_message import ChatMessage, ChatRole, TextContent
from haystack.lazy_imports import LazyImport
from haystack.utils import Jinja2TimeExtension
from haystack.utils.jinja2_chat_extension import ChatMessageExtension
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
logger = logging.getLogger(__name__)
with LazyImport("Run 'pip install \"arrow>=1.3.0\"'") as arrow_import:
import arrow # noqa: F401
NO_TEXT_ERROR_MESSAGE = "ChatMessages from {role} role must contain text. Received ChatMessage with no text: {message}"
FILTER_NOT_ALLOWED_ERROR_MESSAGE = (
"The templatize_part filter cannot be used with a template containing a list of"
"ChatMessage objects. Use a string template or remove the templatize_part filter "
"from the template."
)
@component
class ChatPromptBuilder:
"""
Renders a chat prompt from a template using Jinja2 syntax.
A template can be a list of `ChatMessage` objects, or a special string, as shown in the usage examples.
It constructs prompts using static or dynamic templates, which you can update for each pipeline run.
Template variables in the template are required by default. To make any subset of variables optional,
set `required_variables` to an explicit list of the variables that should remain required; any variable
not listed becomes optional and defaults to an empty string when missing.
Set `required_variables` to `None` to mark every variable as optional.
### Usage examples
#### Static ChatMessage prompt template
```python
template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")]
builder = ChatPromptBuilder(template=template)
builder.run(target_language="spanish", snippet="I can't speak spanish.")
```
#### Overriding static ChatMessage template at runtime
```python
template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")]
builder = ChatPromptBuilder(template=template)
builder.run(target_language="spanish", snippet="I can't speak spanish.")
msg = "Translate to {{ target_language }} and summarize. Context: {{ snippet }}; Summary:"
summary_template = [ChatMessage.from_user(msg)]
builder.run(target_language="spanish", snippet="I can't speak spanish.", template=summary_template)
```
#### Dynamic ChatMessage prompt template
```python
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack import Pipeline
# no parameter init, we don't use any runtime template variables
prompt_builder = ChatPromptBuilder()
llm = OpenAIChatGenerator(model="gpt-5-mini")
pipe = Pipeline()
pipe.add_component("prompt_builder", prompt_builder)
pipe.add_component("llm", llm)
pipe.connect("prompt_builder.prompt", "llm.messages")
location = "Berlin"
language = "English"
system_message = ChatMessage.from_system("You are an assistant giving information to tourists in {{language}}")
messages = [system_message, ChatMessage.from_user("Tell me about {{location}}")]
res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "language": language},
"template": messages}})
print(res)
# >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
# "Berlin is the capital city of Germany and one of the most vibrant
# and diverse cities in Europe. Here are some key things to know...Enjoy your time exploring the vibrant and dynamic
# capital of Germany!")], _name=None, _meta={'model': 'gpt-5-mini',
# 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 27, 'completion_tokens': 681, 'total_tokens':
# 708}})]}}
messages = [system_message, ChatMessage.from_user("What's the weather forecast for {{location}} in the next {{day_count}} days?")]
res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "day_count": "5"},
"template": messages}})
print(res)
# >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
# "Here is the weather forecast for Berlin in the next 5
# days:\\n\\nDay 1: Mostly cloudy with a high of 22°C (72°F) and...so it's always a good idea to check for updates
# closer to your visit.")], _name=None, _meta={'model': 'gpt-5-mini',
# 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 37, 'completion_tokens': 201,
# 'total_tokens': 238}})]}}
```
#### String prompt template
```python
from haystack.components.builders import ChatPromptBuilder
from haystack.dataclasses.image_content import ImageContent
template = \"\"\"
{% message role="system" %}
You are a helpful assistant.
{% endmessage %}
{% message role="user" %}
Hello! I am {{user_name}}. What's the difference between the following images?
{% for image in images %}
{{ image | templatize_part }}
{% endfor %}
{% endmessage %}
\"\"\"
images = [ImageContent.from_file_path("test/test_files/images/apple.jpg"),
ImageContent.from_file_path("test/test_files/images/haystack-logo.png")]
builder = ChatPromptBuilder(template=template)
builder.run(user_name="John", images=images)
```
""" # noqa: E501
def __init__(
self,
template: list[ChatMessage] | str | None = None,
required_variables: list[str] | Literal["*"] | None = "*",
variables: list[str] | None = None,
) -> None:
"""
Constructs a ChatPromptBuilder component.
:param template:
A list of `ChatMessage` objects or a string template. The component looks for Jinja2 template syntax and
renders the prompt with the provided variables. Provide the template in either
the `init` method` or the `run` method.
:param required_variables:
List variables that must be provided as input to ChatPromptBuilder.
Defaults to `"*"`, which marks every variable found in the prompt as required.
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
optional and is replaced with an empty string in the rendered prompt when missing.
Set to `None` to mark every variable as optional.
:param variables:
List input variables to use in prompt templates instead of the ones inferred from the
`template` parameter. For example, to use more variables during prompt engineering than the ones present
in the default template, you can provide them here.
"""
self._variables = variables
self._required_variables = required_variables
self.template = template
self._env = SandboxedEnvironment(extensions=[ChatMessageExtension])
if arrow_import.is_successful():
self._env.add_extension(Jinja2TimeExtension)
extracted_variables = []
if template and not variables:
if isinstance(template, list):
for message in template:
if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM):
# infer variables from template
if message.text is None:
raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message))
if message.text and "templatize_part" in message.text:
raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE)
assigned_variables, template_variables = _extract_template_variables_and_assignments(
env=self._env, template=message.text
)
extracted_variables += list(template_variables - assigned_variables)
elif isinstance(template, str):
assigned_variables, template_variables = _extract_template_variables_and_assignments(
env=self._env, template=template
)
extracted_variables = list(template_variables - assigned_variables)
extracted_variables = extracted_variables or []
self.variables = variables or extracted_variables
self.required_variables = required_variables or []
if len(self.variables) > 0 and required_variables is None:
logger.warning(
"ChatPromptBuilder has {length} prompt variables and `required_variables` is explicitly set to "
"`None`. This treats all prompt variables as optional, which may lead to unintended behavior in "
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
"variables to be optional.",
length=len(self.variables),
)
# setup inputs
for var in self.variables:
if self.required_variables == "*" or var in self.required_variables:
component.set_input_type(self, var, Any)
else:
component.set_input_type(self, var, Any, "")
@component.output_types(prompt=list[ChatMessage])
def run(
self,
template: list[ChatMessage] | str | None = None,
template_variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> dict[str, list[ChatMessage]]:
"""
Renders the prompt template with the provided variables.
It applies the template variables to render the final prompt. You can provide variables with pipeline kwargs.
To overwrite the default template, you can set the `template` parameter.
To overwrite pipeline kwargs, you can set the `template_variables` parameter.
:param template:
An optional list of `ChatMessage` objects or string template to overwrite ChatPromptBuilder's default
template.
If `None`, the default template provided at initialization is used.
:param template_variables:
An optional dictionary of template variables to overwrite the pipeline variables.
:param kwargs:
Pipeline variables used for rendering the prompt.
:returns: A dictionary with the following keys:
- `prompt`: The updated list of `ChatMessage` objects after rendering the templates.
:raises ValueError:
If `chat_messages` is empty or contains elements that are not instances of `ChatMessage`.
"""
kwargs = kwargs or {}
template_variables = template_variables or {}
template_variables_combined = {**kwargs, **template_variables}
if template is None:
template = self.template
if not template:
raise ValueError(
f"The {self.__class__.__name__} requires a non-empty list of ChatMessage instances. "
f"Please provide a valid list of ChatMessage instances to render the prompt."
)
if isinstance(template, list) and not all(isinstance(message, ChatMessage) for message in template):
raise ValueError(
f"The {self.__class__.__name__} expects a list containing only ChatMessage instances. "
f"The provided list contains other types. Please ensure that all elements in the list "
f"are ChatMessage instances."
)
processed_messages = []
if isinstance(template, list):
for message in template:
if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM):
self._validate_variables(set(template_variables_combined.keys()))
if message.text is None:
raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message))
if message.text and "templatize_part" in message.text:
raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE)
compiled_template = self._env.from_string(message.text)
rendered_text = compiled_template.render(template_variables_combined)
# use dataclasses.replace to avoid in-place mutation of the original message
rendered_message: ChatMessage = replace(message, _content=[TextContent(text=rendered_text)])
processed_messages.append(rendered_message)
else:
processed_messages.append(message)
elif isinstance(template, str):
self._validate_variables(set(template_variables_combined.keys()))
processed_messages = self._render_chat_messages_from_str_template(template, template_variables_combined)
return {"prompt": processed_messages}
def _render_chat_messages_from_str_template(
self, template: str, template_variables: dict[str, Any]
) -> list[ChatMessage]:
"""
Renders a chat message from a string template.
This must be used in conjunction with the `ChatMessageExtension` Jinja2 extension
and the `templatize_part` filter.
"""
compiled_template = self._env.from_string(template)
rendered = compiled_template.render(template_variables)
messages = []
for line in rendered.strip().split("\n"):
line = line.strip()
if line:
messages.append(ChatMessage.from_dict(json.loads(line)))
return messages
def _validate_variables(self, provided_variables: set[str]) -> None:
"""
Checks if all the required template variables are provided.
:param provided_variables:
A set of provided template variables.
:raises ValueError:
If no template is provided or if all the required template variables are not provided.
"""
if self.required_variables == "*":
required_variables = sorted(self.variables)
else:
required_variables = self.required_variables
missing_variables = [var for var in required_variables if var not in provided_variables]
if missing_variables:
missing_vars_str = ", ".join(missing_variables)
raise ValueError(
f"Missing required input variables in ChatPromptBuilder: {missing_vars_str}. "
f"Required variables: {required_variables}. Provided variables: {provided_variables}."
)
def to_dict(self) -> dict[str, Any]:
"""
Returns a dictionary representation of the component.
:returns:
Serialized dictionary representation of the component.
"""
template: list[dict[str, Any]] | str | None = None
if isinstance(self.template, list):
template = [m.to_dict() for m in self.template]
elif isinstance(self.template, str):
template = self.template
return default_to_dict(
self, template=template, variables=self._variables, required_variables=self._required_variables
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChatPromptBuilder":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary to deserialize and create the component.
:returns:
The deserialized component.
"""
init_parameters = data["init_parameters"]
template = init_parameters.get("template")
if template:
if isinstance(template, list):
init_parameters["template"] = [ChatMessage.from_dict(d) for d in template]
elif isinstance(template, str):
init_parameters["template"] = template
return default_from_dict(cls, data)
@@ -0,0 +1,271 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any, Literal
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_to_dict, logging
from haystack.utils import Jinja2TimeExtension
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
logger = logging.getLogger(__name__)
@component
class PromptBuilder:
"""
Renders a prompt filling in any variables so that it can send it to a Generator.
The prompt uses Jinja2 template syntax.
The variables in the default template are used as PromptBuilder's input and are all required by default.
To make any subset of variables optional, set `required_variables` to an explicit list of the variables that
should remain required. Optional variables are replaced with an empty string in the rendered prompt.
To try out different prompts, you can replace the prompt template at runtime by
providing a template for each pipeline run invocation.
### Usage examples
#### On its own
This example uses PromptBuilder to render a prompt template and fill it with `target_language`
and `snippet`. PromptBuilder returns a prompt with the string "Translate the following context to Spanish.
Context: I can't speak Spanish.; Translation:".
```python
from haystack.components.builders import PromptBuilder
template = "Translate the following context to {{ target_language }}. Context: {{ snippet }}; Translation:"
builder = PromptBuilder(template=template)
builder.run(target_language="spanish", snippet="I can't speak spanish.")
```
#### In a Pipeline
This is an example of a RAG pipeline where PromptBuilder renders a custom prompt template and fills it
with the contents of the retrieved documents and a query. The rendered prompt is then sent to a ChatGenerator.
```python
from haystack import Pipeline, Document
from haystack.utils import Secret
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.builders.prompt_builder import PromptBuilder
# in a real world use case documents could come from a retriever, web, or any other source
documents = [Document(content="Joe lives in Berlin"), Document(content="Joe is a software engineer")]
prompt_template = \"\"\"
Given these documents, answer the question.
Documents:
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{query}}
Answer:
\"\"\"
p = Pipeline()
p.add_component(instance=PromptBuilder(template=prompt_template), name="prompt_builder")
p.add_component(instance=OpenAIChatGenerator(api_key=Secret.from_env_var("OPENAI_API_KEY")), name="llm")
p.connect("prompt_builder", "llm")
question = "Where does Joe live?"
result = p.run({"prompt_builder": {"documents": documents, "query": question}})
print(result)
```
#### Changing the template at runtime (prompt engineering)
You can change the prompt template of an existing pipeline, like in this example:
```python
documents = [
Document(content="Joe lives in Berlin", meta={"name": "doc1"}),
Document(content="Joe is a software engineer", meta={"name": "doc1"}),
]
new_template = \"\"\"
You are a helpful assistant.
Given these documents, answer the question.
Documents:
{% for doc in documents %}
Document {{ loop.index }}:
Document name: {{ doc.meta['name'] }}
{{ doc.content }}
{% endfor %}
Question: {{ query }}
Answer:
\"\"\"
p.run({
"prompt_builder": {
"documents": documents,
"query": question,
"template": new_template,
},
})
```
To replace the variables in the default template when testing your prompt,
pass the new variables in the `variables` parameter.
#### Overwriting variables at runtime
To overwrite the values of variables, use `template_variables` during runtime:
```python
language_template = \"\"\"
You are a helpful assistant.
Given these documents, answer the question.
Documents:
{% for doc in documents %}
Document {{ loop.index }}:
Document name: {{ doc.meta['name'] }}
{{ doc.content }}
{% endfor %}
Question: {{ query }}
Please provide your answer in {{ answer_language | default('English') }}
Answer:
\"\"\"
p.run({
"prompt_builder": {
"documents": documents,
"query": question,
"template": language_template,
"template_variables": {"answer_language": "German"},
},
})
```
Note that `language_template` introduces variable `answer_language` which is not bound to any pipeline variable.
If not set otherwise, it will use its default value 'English'.
This example overwrites its value to 'German'.
Use `template_variables` to overwrite pipeline variables (such as documents) as well.
"""
def __init__(
self,
template: str,
required_variables: list[str] | Literal["*"] | None = "*",
variables: list[str] | None = None,
) -> None:
"""
Constructs a PromptBuilder component.
:param template:
A prompt template that uses Jinja2 syntax to add variables. For example:
`"Summarize this document: {{ documents[0].content }}\\nSummary:"`
It's used to render the prompt.
The variables in the default template are input for PromptBuilder and are all required by default.
:param required_variables: List variables that must be provided as input to PromptBuilder.
Defaults to `"*"`, which marks every variable found in the prompt as required.
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
optional and is replaced with an empty string in the rendered prompt when missing.
Set to `None` to mark every variable as optional.
:param variables:
List input variables to use in prompt templates instead of the ones inferred from the
`template` parameter. For example, to use more variables during prompt engineering than the ones present
in the default template, you can provide them here.
"""
self._template_string = template
self._variables = variables
self._required_variables = required_variables
self.required_variables = required_variables or []
try:
# The Jinja2TimeExtension needs an optional dependency to be installed.
# If it's not available we can do without it and use the PromptBuilder as is.
self._env = SandboxedEnvironment(extensions=[Jinja2TimeExtension])
except ImportError:
self._env = SandboxedEnvironment()
self.template = self._env.from_string(template)
if not variables:
assigned_variables, template_variables = _extract_template_variables_and_assignments(
env=self._env, template=template
)
variables = list(template_variables - assigned_variables)
variables = variables or []
self.variables = variables
if len(self.variables) > 0 and required_variables is None:
logger.warning(
"PromptBuilder has {length} prompt variables and `required_variables` is explicitly set to `None`. "
"This treats all prompt variables as optional, which may lead to unintended behavior in "
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
"variables to be optional.",
length=len(self.variables),
)
# setup inputs
for var in self.variables:
if self.required_variables == "*" or var in self.required_variables:
component.set_input_type(self, var, Any)
else:
component.set_input_type(self, var, Any, "")
def to_dict(self) -> dict[str, Any]:
"""
Returns a dictionary representation of the component.
:returns:
Serialized dictionary representation of the component.
"""
return default_to_dict(
self, template=self._template_string, variables=self._variables, required_variables=self._required_variables
)
@component.output_types(prompt=str)
def run(
self, template: str | None = None, template_variables: dict[str, Any] | None = None, **kwargs: Any
) -> dict[str, Any]:
"""
Renders the prompt template with the provided variables.
It applies the template variables to render the final prompt. You can provide variables via pipeline kwargs.
In order to overwrite the default template, you can set the `template` parameter.
In order to overwrite pipeline kwargs, you can set the `template_variables` parameter.
:param template:
An optional string template to overwrite PromptBuilder's default template. If None, the default template
provided at initialization is used.
:param template_variables:
An optional dictionary of template variables to overwrite the pipeline variables.
:param kwargs:
Pipeline variables used for rendering the prompt.
:returns: A dictionary with the following keys:
- `prompt`: The updated prompt text after rendering the prompt template.
:raises ValueError:
If any of the required template variables is not provided.
"""
kwargs = kwargs or {}
template_variables = template_variables or {}
template_variables_combined = {**kwargs, **template_variables}
self._validate_variables(set(template_variables_combined.keys()))
compiled_template = self.template
if template is not None:
compiled_template = self._env.from_string(template)
result = compiled_template.render(template_variables_combined)
return {"prompt": result}
def _validate_variables(self, provided_variables: set[str]) -> None:
"""
Checks if all the required template variables are provided.
:param provided_variables:
A set of provided template variables.
:raises ValueError:
If any of the required template variables is not provided.
"""
if self.required_variables == "*":
required_variables = sorted(self.variables)
else:
required_variables = self.required_variables
missing_variables = [var for var in required_variables if var not in provided_variables]
if missing_variables:
missing_vars_str = ", ".join(missing_variables)
raise ValueError(
f"Missing required input variables in PromptBuilder: {missing_vars_str}. "
f"Required variables: {required_variables}. Provided variables: {provided_variables}."
)