diff --git a/haystack/components/builders/dynamic_prompt_builder.py b/haystack/components/builders/dynamic_prompt_builder.py index 00fcbd5429..3e2399e799 100644 --- a/haystack/components/builders/dynamic_prompt_builder.py +++ b/haystack/components/builders/dynamic_prompt_builder.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import warnings from typing import Any, Dict, List, Optional, Set from jinja2 import Template, meta @@ -84,6 +85,12 @@ def __init__(self, runtime_variables: Optional[List[str]] = None): pipeline execution. The values associated with variables from the pipeline runtime are then injected into template placeholders of a prompt text template that is provided to the `run` method. """ + warnings.warn( + "`DynamicPromptBuilder` is deprecated and will be removed in Haystack 2.3.0." + "Use `PromptBuilder` instead.", + DeprecationWarning, + ) + runtime_variables = runtime_variables or [] # setup inputs diff --git a/haystack/components/builders/prompt_builder.py b/haystack/components/builders/prompt_builder.py index 86969f73c7..17b0387112 100644 --- a/haystack/components/builders/prompt_builder.py +++ b/haystack/components/builders/prompt_builder.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from jinja2 import Template, meta @@ -14,32 +14,159 @@ class PromptBuilder: """ PromptBuilder is a component that renders a prompt from a template string using Jinja2 templates. - The template variables found in the template string are used as input types for the component and are all optional, + For prompt engineering, users can switch the template at runtime by providing a template for each pipeline run invocation. + + The template variables found in the default template string are used as input types for the component and are all optional, unless explicitly specified. If an optional template variable is not provided as an input, it will be replaced with - an empty string in the rendered prompt. + an empty string in the rendered prompt. Use `variables` and `required_variables` to change the default variable behavior. + + ### Usage examples - Usage example: + #### On its own + + Below is an example of using the `PromptBuilder` to render a prompt template and fill it with `target_language` and `snippet`. + The 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 + + Below is an example of a RAG pipeline where we use a `PromptBuilder` to render a custom prompt template and fill it with the + contents of retrieved Documents and a query. The rendered prompt is then sent to a Generator. + ```python + from haystack import Pipeline, Document + from haystack.utils import Secret + from haystack.components.generators import OpenAIGenerator + 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=OpenAIGenerator(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) + + `PromptBuilder` allows you to switch the prompt template of an existing pipeline. + Below's example builds on top of the existing pipeline of the previous section. + The existing pipeline is invoked with a new prompt template: + ```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, + }, + }) + ``` + If you want to use different variables during prompt engineering than in the default template, + you can do so by setting `PromptBuilder`'s `variables` init parameter accordingly. + + #### Overwriting variables at runtime + + In case you want to overwrite the values of variables, you can use `template_variables` during runtime as illustrated below: + ```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 would evaluate to its default value 'English'. + In this example we are overwriting its value to 'German'. + `template_variables` allows you to overwrite pipeline variables (such as documents) as well. + """ - def __init__(self, template: str, required_variables: Optional[List[str]] = None): + def __init__( + self, template: str, required_variables: Optional[List[str]] = None, variables: Optional[List[str]] = None + ): """ Constructs a PromptBuilder component. - :param template: A Jinja2 template string, e.g. "Summarize this document: {documents}\\nSummary:" - :param required_variables: An optional list of input variables that must be provided at all times. + :param template: + A Jinja2 template string that is used to render the prompt, e.g.: + `"Summarize this document: {{ documents[0].content }}\\nSummary:"` + :param required_variables: An optional list of input variables that must be provided at runtime. + If a required variable is not provided at runtime, an exception will be raised. + :param variables: + An optional list of input variables to be used in prompt templates instead of the ones inferred from `template`. + For example, if you want 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.template = Template(template) + self._variables = variables + self._required_variables = required_variables self.required_variables = required_variables or [] - ast = self.template.environment.parse(template) - template_variables = meta.find_undeclared_variables(ast) - - for var in template_variables: + self.template = Template(template) + if not variables: + # infere variables from template + ast = self.template.environment.parse(template) + template_variables = meta.find_undeclared_variables(ast) + variables = list(template_variables) + + variables = variables or [] + + # setup inputs + static_input_slots = {"template": Optional[str], "template_variables": Optional[Dict[str, Any]]} + component.set_input_types(self, **static_input_slots) + for var in variables: if var in self.required_variables: component.set_input_type(self, var, Any) else: @@ -52,22 +179,58 @@ def to_dict(self) -> Dict[str, Any]: :returns: Serialized dictionary representation of the component. """ - return default_to_dict(self, template=self._template_string) + 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, **kwargs): + def run(self, template: Optional[str] = None, template_variables: Optional[Dict[str, Any]] = None, **kwargs): """ 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: - The variables that will be used to render the prompt template. + 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. """ - missing_variables = [var for var in self.required_variables if var not in kwargs] + 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 isinstance(template, str): + compiled_template = Template(template) + + result = compiled_template.render(template_variables_combined) + return {"prompt": result} + + def _validate_variables(self, provided_variables: Set[str]): + """ + 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. + """ + missing_variables = [var for var in self.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}") - - return {"prompt": self.template.render(kwargs)} + raise ValueError( + f"Missing required input variables in PromptBuilder: {missing_vars_str}. " + f"Required variables: {self.required_variables}. Provided variables: {provided_variables}." + ) diff --git a/releasenotes/notes/extend-promptbuilder-0322790d82248039.yaml b/releasenotes/notes/extend-promptbuilder-0322790d82248039.yaml new file mode 100644 index 0000000000..ff6e5bfaa0 --- /dev/null +++ b/releasenotes/notes/extend-promptbuilder-0322790d82248039.yaml @@ -0,0 +1,7 @@ +--- +enhancements: + - | + `PromptBuilder` now supports changing its template at runtime (e.g. for Prompt Engineering). This allows you to define a default template and then change it based on your needs at runtime. +deprecations: + - | + `DynamicPromptBuilder` has been deprecated as `PromptBuilder` fully covers its functionality. Use `PromptBuilder` instead. diff --git a/test/components/builders/test_prompt_builder.py b/test/components/builders/test_prompt_builder.py index e4877c2c81..f978596a2a 100644 --- a/test/components/builders/test_prompt_builder.py +++ b/test/components/builders/test_prompt_builder.py @@ -1,48 +1,256 @@ # SPDX-FileCopyrightText: 2022-present deepset GmbH # # SPDX-License-Identifier: Apache-2.0 +from typing import Any, Dict, List, Optional +from jinja2 import TemplateSyntaxError import pytest from haystack.components.builders.prompt_builder import PromptBuilder +from haystack import component +from haystack.core.pipeline.pipeline import Pipeline +from haystack.dataclasses.document import Document -def test_init(): - builder = PromptBuilder(template="This is a {{ variable }}") - assert builder._template_string == "This is a {{ variable }}" +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 is None + # 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 == Optional[str] + assert inputs["template_variables"].type == Optional[Dict[str, Any]] + assert inputs["variable"].type == Any -def test_to_dict(): - 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 }}"}, - } + # 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"] -def test_run(): - builder = PromptBuilder(template="This is a {{ variable }}") - res = builder.run(variable="test") - assert res == {"prompt": "This is a test"} + # 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 == Optional[str] + assert inputs["template_variables"].type == Optional[Dict[str, Any]] + 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_run_without_input(): - builder = PromptBuilder(template="This is a template without input") - res = builder.run() - assert res == {"prompt": "This is a template without input"} + 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 is None + # 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 == Optional[str] + assert inputs["template_variables"].type == Optional[Dict[str, Any]] + assert inputs["var1"].type == Any + assert inputs["var2"].type == Any + assert inputs["var3"].type == Any -def test_run_with_missing_input(): - builder = PromptBuilder(template="This is a {{ variable }}") - res = builder.run() - assert res == {"prompt": "This is a "} + # 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_run_with_missing_required_input(): - 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_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": None}, + } + + 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_input(self): + builder = PromptBuilder(template="This is a {{ variable }}") + res = builder.run() + assert res == {"prompt": "This is a "} + + 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_variables(self): + variables = ["var1", "var2", "var3"] + template = "Hello, {{ name }}! {{ var1 }}" + + builder = PromptBuilder(template=template, variables=variables) + + template_variables = {"name": "John"} + expected_result = {"prompt": "Hello, John! How are you?"} + + assert builder.run(template_variables=template_variables, var1="How are you?") == expected_result + + def test_run_overwriting_default_template(self): + default_template = "Hello, {{ name }}!" + + builder = PromptBuilder(template=default_template) + + template = "Hello, {{ var1 }}{{ name }}!" + expected_result = {"prompt": "Hello, John!"} + + assert builder.run(template, name="John") == expected_result + + def test_run_overwriting_default_template_with_template_variables(self): + default_template = "Hello, {{ name }}!" + + builder = PromptBuilder(template=default_template) + + template = "Hello, {{ var1 }} {{ name }}!" + template_variables = {"var1": "Big"} + expected_result = {"prompt": "Hello, Big John!"} + + assert builder.run(template, template_variables, name="John") == expected_result + + def test_run_overwriting_default_template_with_variables(self): + variables = ["var1", "var2", "name"] + default_template = "Hello, {{ name }}!" + + builder = PromptBuilder(template=default_template, variables=variables) + + template = "Hello, {{ var1 }} {{ name }}!" + expected_result = {"prompt": "Hello, Big John!"} + + assert builder.run(template, name="John", var1="Big") == expected_result + + 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