Coverage for haystack/components/builders/chat_prompt_builder.py: 98%

110 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6from dataclasses import replace 

7from typing import Any, Literal 

8 

9from haystack import component, default_from_dict, default_to_dict, logging 

10from haystack.dataclasses.chat_message import ChatMessage, ChatRole, TextContent 

11from haystack.lazy_imports import LazyImport 

12from haystack.utils import Jinja2TimeExtension 

13from haystack.utils.jinja2_chat_extension import ChatMessageExtension 

14from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments 

15from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment 

16 

17logger = logging.getLogger(__name__) 

18 

19with LazyImport("Run 'pip install \"arrow>=1.3.0\"'") as arrow_import: 

20 import arrow # noqa: F401 

21 

22NO_TEXT_ERROR_MESSAGE = "ChatMessages from {role} role must contain text. Received ChatMessage with no text: {message}" 

23 

24FILTER_NOT_ALLOWED_ERROR_MESSAGE = ( 

25 "The templatize_part filter cannot be used with a template containing a list of" 

26 "ChatMessage objects. Use a string template or remove the templatize_part filter " 

27 "from the template." 

28) 

29 

30 

31@component 

32class ChatPromptBuilder: 

33 """ 

34 Renders a chat prompt from a template using Jinja2 syntax. 

35 

36 A template can be a list of `ChatMessage` objects, or a special string, as shown in the usage examples. 

37 

38 It constructs prompts using static or dynamic templates, which you can update for each pipeline run. 

39 

40 Template variables in the template are required by default. To make any subset of variables optional, 

41 set `required_variables` to an explicit list of the variables that should remain required; any variable 

42 not listed becomes optional and defaults to an empty string when missing. 

43 Set `required_variables` to `None` to mark every variable as optional. 

44 

45 ### Usage examples 

46 

47 #### Static ChatMessage prompt template 

48 

49 ```python 

50 template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")] 

51 builder = ChatPromptBuilder(template=template) 

52 builder.run(target_language="spanish", snippet="I can't speak spanish.") 

53 ``` 

54 

55 #### Overriding static ChatMessage template at runtime 

56 

57 ```python 

58 template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")] 

59 builder = ChatPromptBuilder(template=template) 

60 builder.run(target_language="spanish", snippet="I can't speak spanish.") 

61 

62 msg = "Translate to {{ target_language }} and summarize. Context: {{ snippet }}; Summary:" 

63 summary_template = [ChatMessage.from_user(msg)] 

64 builder.run(target_language="spanish", snippet="I can't speak spanish.", template=summary_template) 

65 ``` 

66 

67 #### Dynamic ChatMessage prompt template 

68 

69 ```python 

70 from haystack.components.builders import ChatPromptBuilder 

71 from haystack.components.generators.chat import OpenAIChatGenerator 

72 from haystack.dataclasses import ChatMessage 

73 from haystack import Pipeline 

74 

75 # no parameter init, we don't use any runtime template variables 

76 prompt_builder = ChatPromptBuilder() 

77 llm = OpenAIChatGenerator(model="gpt-5-mini") 

78 

79 pipe = Pipeline() 

80 pipe.add_component("prompt_builder", prompt_builder) 

81 pipe.add_component("llm", llm) 

82 pipe.connect("prompt_builder.prompt", "llm.messages") 

83 

84 location = "Berlin" 

85 language = "English" 

86 system_message = ChatMessage.from_system("You are an assistant giving information to tourists in {{language}}") 

87 messages = [system_message, ChatMessage.from_user("Tell me about {{location}}")] 

88 

89 res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "language": language}, 

90 "template": messages}}) 

91 print(res) 

92 # >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text= 

93 # "Berlin is the capital city of Germany and one of the most vibrant 

94 # and diverse cities in Europe. Here are some key things to know...Enjoy your time exploring the vibrant and dynamic 

95 # capital of Germany!")], _name=None, _meta={'model': 'gpt-5-mini', 

96 # 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 27, 'completion_tokens': 681, 'total_tokens': 

97 # 708}})]}} 

98 

99 messages = [system_message, ChatMessage.from_user("What's the weather forecast for {{location}} in the next {{day_count}} days?")] 

100 

101 res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "day_count": "5"}, 

102 "template": messages}}) 

103 

104 print(res) 

105 # >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text= 

106 # "Here is the weather forecast for Berlin in the next 5 

107 # 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 

108 # closer to your visit.")], _name=None, _meta={'model': 'gpt-5-mini', 

109 # 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 37, 'completion_tokens': 201, 

110 # 'total_tokens': 238}})]}} 

111 ``` 

112 

113 #### String prompt template 

114 ```python 

115 from haystack.components.builders import ChatPromptBuilder 

116 from haystack.dataclasses.image_content import ImageContent 

117 

118 template = \"\"\" 

119 {% message role="system" %} 

120 You are a helpful assistant. 

121 {% endmessage %} 

122 

123 {% message role="user" %} 

124 Hello! I am {{user_name}}. What's the difference between the following images? 

125 {% for image in images %} 

126 {{ image | templatize_part }} 

127 {% endfor %} 

128 {% endmessage %} 

129 \"\"\" 

130 

131 images = [ImageContent.from_file_path("test/test_files/images/apple.jpg"), 

132 ImageContent.from_file_path("test/test_files/images/haystack-logo.png")] 

133 

134 builder = ChatPromptBuilder(template=template) 

135 builder.run(user_name="John", images=images) 

136 ``` 

137 """ # noqa: E501 

138 

139 def __init__( 

140 self, 

141 template: list[ChatMessage] | str | None = None, 

142 required_variables: list[str] | Literal["*"] | None = "*", 

143 variables: list[str] | None = None, 

144 ) -> None: 

145 """ 

146 Constructs a ChatPromptBuilder component. 

147 

148 :param template: 

149 A list of `ChatMessage` objects or a string template. The component looks for Jinja2 template syntax and 

150 renders the prompt with the provided variables. Provide the template in either 

151 the `init` method` or the `run` method. 

152 :param required_variables: 

153 List variables that must be provided as input to ChatPromptBuilder. 

154 Defaults to `"*"`, which marks every variable found in the prompt as required. 

155 Pass an explicit list to only require a subset of the variables; any variable not listed becomes 

156 optional and is replaced with an empty string in the rendered prompt when missing. 

157 Set to `None` to mark every variable as optional. 

158 :param variables: 

159 List input variables to use in prompt templates instead of the ones inferred from the 

160 `template` parameter. For example, to use more variables during prompt engineering than the ones present 

161 in the default template, you can provide them here. 

162 """ 

163 self._variables = variables 

164 self._required_variables = required_variables 

165 self.template = template 

166 

167 self._env = HaystackSandboxedEnvironment(extensions=[ChatMessageExtension]) 

168 if arrow_import.is_successful(): 

169 self._env.add_extension(Jinja2TimeExtension) 

170 

171 extracted_variables = [] 

172 if template and not variables: 

173 if isinstance(template, list): 

174 for message in template: 

175 if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM): 

176 # infer variables from template 

177 if message.text is None: 

178 raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message)) 

179 if message.text and "templatize_part" in message.text: 

180 raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE) 

181 assigned_variables, template_variables = _extract_template_variables_and_assignments( 

182 env=self._env, template=message.text 

183 ) 

184 extracted_variables += list(template_variables - assigned_variables) 

185 elif isinstance(template, str): 

186 assigned_variables, template_variables = _extract_template_variables_and_assignments( 

187 env=self._env, template=template 

188 ) 

189 extracted_variables = list(template_variables - assigned_variables) 

190 

191 extracted_variables = extracted_variables or [] 

192 self.variables = variables or extracted_variables 

193 self.required_variables = required_variables or [] 

194 

195 if len(self.variables) > 0 and required_variables is None: 

196 logger.warning( 

197 "ChatPromptBuilder has {length} prompt variables and `required_variables` is explicitly set to " 

198 "`None`. This treats all prompt variables as optional, which may lead to unintended behavior in " 

199 "multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all " 

200 "variables to be optional.", 

201 length=len(self.variables), 

202 ) 

203 

204 # setup inputs 

205 for var in self.variables: 

206 if self.required_variables == "*" or var in self.required_variables: 

207 component.set_input_type(self, var, Any) 

208 else: 

209 component.set_input_type(self, var, Any, "") 

210 

211 @component.output_types(prompt=list[ChatMessage]) 

212 def run( 

213 self, 

214 template: list[ChatMessage] | str | None = None, 

215 template_variables: dict[str, Any] | None = None, 

216 **kwargs: Any, 

217 ) -> dict[str, list[ChatMessage]]: 

218 """ 

219 Renders the prompt template with the provided variables. 

220 

221 It applies the template variables to render the final prompt. You can provide variables with pipeline kwargs. 

222 To overwrite the default template, you can set the `template` parameter. 

223 To overwrite pipeline kwargs, you can set the `template_variables` parameter. 

224 

225 :param template: 

226 An optional list of `ChatMessage` objects or string template to overwrite ChatPromptBuilder's default 

227 template. 

228 If `None`, the default template provided at initialization is used. 

229 :param template_variables: 

230 An optional dictionary of template variables to overwrite the pipeline variables. 

231 :param kwargs: 

232 Pipeline variables used for rendering the prompt. 

233 

234 :returns: A dictionary with the following keys: 

235 - `prompt`: The updated list of `ChatMessage` objects after rendering the templates. 

236 :raises ValueError: 

237 If `chat_messages` is empty or contains elements that are not instances of `ChatMessage`. 

238 """ 

239 kwargs = kwargs or {} 

240 template_variables = template_variables or {} 

241 template_variables_combined = {**kwargs, **template_variables} 

242 

243 if template is None: 

244 template = self.template 

245 

246 if not template: 

247 raise ValueError( 

248 f"The {self.__class__.__name__} requires a non-empty list of ChatMessage instances. " 

249 f"Please provide a valid list of ChatMessage instances to render the prompt." 

250 ) 

251 

252 if isinstance(template, list) and not all(isinstance(message, ChatMessage) for message in template): 

253 raise ValueError( 

254 f"The {self.__class__.__name__} expects a list containing only ChatMessage instances. " 

255 f"The provided list contains other types. Please ensure that all elements in the list " 

256 f"are ChatMessage instances." 

257 ) 

258 

259 processed_messages = [] 

260 if isinstance(template, list): 

261 for message in template: 

262 if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM): 

263 self._validate_variables(set(template_variables_combined.keys())) 

264 if message.text is None: 

265 raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message)) 

266 if message.text and "templatize_part" in message.text: 

267 raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE) 

268 compiled_template = self._env.from_string(message.text) 

269 rendered_text = compiled_template.render(template_variables_combined) 

270 # use dataclasses.replace to avoid in-place mutation of the original message 

271 rendered_message: ChatMessage = replace(message, _content=[TextContent(text=rendered_text)]) 

272 processed_messages.append(rendered_message) 

273 else: 

274 processed_messages.append(message) 

275 elif isinstance(template, str): 

276 self._validate_variables(set(template_variables_combined.keys())) 

277 processed_messages = self._render_chat_messages_from_str_template(template, template_variables_combined) 

278 

279 return {"prompt": processed_messages} 

280 

281 def _render_chat_messages_from_str_template( 

282 self, template: str, template_variables: dict[str, Any] 

283 ) -> list[ChatMessage]: 

284 """ 

285 Renders a chat message from a string template. 

286 

287 This must be used in conjunction with the `ChatMessageExtension` Jinja2 extension 

288 and the `templatize_part` filter. 

289 """ 

290 compiled_template = self._env.from_string(template) 

291 rendered = compiled_template.render(template_variables) 

292 

293 messages = [] 

294 for line in rendered.strip().split("\n"): 

295 line = line.strip() 

296 if line: 

297 messages.append(ChatMessage.from_dict(json.loads(line))) 

298 

299 return messages 

300 

301 def _validate_variables(self, provided_variables: set[str]) -> None: 

302 """ 

303 Checks if all the required template variables are provided. 

304 

305 :param provided_variables: 

306 A set of provided template variables. 

307 :raises ValueError: 

308 If no template is provided or if all the required template variables are not provided. 

309 """ 

310 if self.required_variables == "*": 

311 required_variables = sorted(self.variables) 

312 else: 

313 required_variables = self.required_variables 

314 missing_variables = [var for var in required_variables if var not in provided_variables] 

315 if missing_variables: 

316 missing_vars_str = ", ".join(missing_variables) 

317 raise ValueError( 

318 f"Missing required input variables in ChatPromptBuilder: {missing_vars_str}. " 

319 f"Required variables: {required_variables}. Provided variables: {provided_variables}." 

320 ) 

321 

322 def to_dict(self) -> dict[str, Any]: 

323 """ 

324 Returns a dictionary representation of the component. 

325 

326 :returns: 

327 Serialized dictionary representation of the component. 

328 """ 

329 template: list[dict[str, Any]] | str | None = None 

330 if isinstance(self.template, list): 

331 template = [m.to_dict() for m in self.template] 

332 elif isinstance(self.template, str): 

333 template = self.template 

334 

335 return default_to_dict( 

336 self, template=template, variables=self._variables, required_variables=self._required_variables 

337 ) 

338 

339 @classmethod 

340 def from_dict(cls, data: dict[str, Any]) -> "ChatPromptBuilder": 

341 """ 

342 Deserialize this component from a dictionary. 

343 

344 :param data: 

345 The dictionary to deserialize and create the component. 

346 

347 :returns: 

348 The deserialized component. 

349 """ 

350 init_parameters = data["init_parameters"] 

351 template = init_parameters.get("template") 

352 if template: 

353 if isinstance(template, list): 

354 init_parameters["template"] = [ChatMessage.from_dict(d) for d in template] 

355 elif isinstance(template, str): 

356 init_parameters["template"] = template 

357 

358 return default_from_dict(cls, data)