Coverage for haystack/utils/jinja2_chat_extension.py: 99%

146 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 

6import secrets 

7from collections.abc import Callable 

8from typing import Any 

9 

10from jinja2 import TemplateSyntaxError, nodes, pass_environment 

11from jinja2.ext import Extension 

12from markupsafe import Markup 

13 

14from haystack import logging 

15from haystack.dataclasses.chat_message import ( 

16 ChatMessage, 

17 ChatMessageContentT, 

18 ChatRole, 

19 FileContent, 

20 ImageContent, 

21 ReasoningContent, 

22 TextContent, 

23 ToolCall, 

24 ToolCallResult, 

25 _deserialize_content_part, 

26 _serialize_content_part, 

27) 

28 

29logger = logging.getLogger(__name__) 

30 

31_TAG_NAME = "haystack_content_part" 

32_NONCE_ATTR = "_haystack_content_part_nonce" 

33 

34 

35def _sentinel_tags(nonce: str) -> tuple[str, str]: 

36 """Build the (opening, closing) sentinel tags for the given nonce""" 

37 return f"<{_TAG_NAME}:{nonce}>", f"</{_TAG_NAME}:{nonce}>" 

38 

39 

40class _TemplatizedPart(Markup): 

41 """Marker type for content produced by `templatize_part`.""" 

42 

43 pass 

44 

45 

46def _finalize(value: object) -> str: 

47 """ 

48 Jinja2 `finalize` callback that prevents sentinel tag injection. 

49 

50 Called automatically on every `{{ }}` expression result during template rendering. 

51 Legitimate structured content from the `templatize_part` filter is wrapped in `_TemplatizedPart` and passes. 

52 Any other value containing sentinel tags has those tags replaced with harmless HTML entities so that 

53 `_parse_content_parts` will not treat them as structured content. 

54 """ 

55 if isinstance(value, _TemplatizedPart): 

56 return value 

57 

58 # escape the leading "<" of any sentinel tag so that `_parse_content_parts` cannot recognize it 

59 return str(value).replace(f"<{_TAG_NAME}", f"&lt;{_TAG_NAME}").replace(f"</{_TAG_NAME}", f"&lt;/{_TAG_NAME}") 

60 

61 

62def _redact_nonce(text: str, start_tag: str, end_tag: str) -> str: 

63 return text.replace(start_tag, f"<{_TAG_NAME}:redacted>").replace(end_tag, f"</{_TAG_NAME}:redacted>") 

64 

65 

66class ChatMessageExtension(Extension): 

67 """ 

68 A Jinja2 extension for creating structured chat messages with mixed content types. 

69 

70 This extension provides a custom `{% message %}` tag that allows creating chat messages 

71 with different attributes (role, name, meta) and mixed content types (text, images, etc.). 

72 

73 Inspired by [Banks](https://github.com/masci/banks). 

74 

75 Example: 

76 ``` 

77 {% message role="system" %} 

78 You are a helpful assistant. You like to talk with {{user_name}}. 

79 {% endmessage %} 

80 

81 {% message role="user" %} 

82 Hello! I am {{user_name}}. Please describe the images. 

83 {% for image in images %} 

84 {{ image | templatize_part }} 

85 {% endfor %} 

86 {% endmessage %} 

87 ``` 

88 

89 This extension also provides an `{% insert %}` placeholder tag that evaluates an expression to a `ChatMessage` 

90 or a list of `ChatMessage` objects and expands it into the prompt, so a runtime conversation can be interleaved 

91 with literal `{% message %}` blocks: 

92 

93 ``` 

94 {% message role="system" %}You are a helpful assistant.{% endmessage %} 

95 {% insert messages %} 

96 {% message role="user" %}{{ query }}{% endmessage %} 

97 ``` 

98 

99 The expression can be a plain variable (`{% insert messages %}`), a slice or index 

100 (`{% insert messages[-1:] %}`, `{% insert messages[-1] %}`), or a combination of variables 

101 (`{% insert previous + current %}`). 

102 

103 ### How it works 

104 1. The `{% message %}` tag is used to define a chat message. 

105 2. The message can contain text and other structured content parts. 

106 3. To include a structured content part in the message, the `| templatize_part` filter is used. 

107 The filter serializes the content part into a JSON string and wraps it in a `<haystack_content_part>` tag. 

108 4. The `_build_chat_message_json` method of the extension parses the message content parts, 

109 converts them into a ChatMessage object and serializes it to a JSON string. 

110 5. The obtained JSON string is usable in the ChatPromptBuilder component, where templates are rendered to actual 

111 ChatMessage objects. 

112 """ 

113 

114 SUPPORTED_ROLES = [role.value for role in ChatRole] 

115 

116 tags = {"message", "insert"} 

117 

118 def __init__(self, environment: Any) -> None: 

119 super().__init__(environment) 

120 # a fresh random nonce per environment to produce sentinel tags only usable by `templatize_part` 

121 setattr(environment, _NONCE_ATTR, secrets.token_hex(16)) 

122 # values not produced by `templatize_part` get their sentinel-like tags escaped 

123 environment.finalize = _finalize 

124 environment.filters["templatize_part"] = templatize_part 

125 

126 def parse(self, parser: Any) -> nodes.Node | list[nodes.Node]: 

127 """ 

128 Dispatch parsing based on the tag that triggered the extension. 

129 

130 Handles both the single `{% message %}` block tag and the `{% insert %}` placeholder tag. 

131 

132 :param parser: The Jinja2 parser instance 

133 :return: A CallBlock node containing the parsed configuration 

134 """ 

135 tag = next(parser.stream) 

136 if tag.value == "insert": 

137 return self._parse_insert_tag(parser, tag.lineno) 

138 return self._parse_message_tag(parser, tag.lineno) 

139 

140 def _parse_insert_tag(self, parser: Any, lineno: int) -> nodes.Node: 

141 """ 

142 Parse the `{% insert %}` placeholder tag. 

143 

144 This bodyless tag evaluates an expression to a `ChatMessage` or a list of `ChatMessage` objects and expands 

145 it into the same JSON-line format produced by `{% message %}` blocks, so messages provided at runtime can be 

146 interleaved with literal message blocks (for example a system message above and a user message below). 

147 

148 The expression can be a plain variable (`{% insert messages %}`), a slice or index 

149 (`{% insert messages[-1:] %}`, `{% insert messages[-1] %}`), or a combination of variables 

150 (`{% insert previous + current %}`). 

151 

152 :param parser: The Jinja2 parser instance 

153 :param lineno: The line number of the tag, used for error reporting. 

154 :return: A CallBlock node that expands the evaluated expression. 

155 :raises TemplateSyntaxError: If the tag is not given an expression. 

156 """ 

157 if parser.stream.current.test("block_end"): 

158 raise TemplateSyntaxError( 

159 "The 'insert' tag requires an expression that evaluates to a ChatMessage or a list of ChatMessage " 

160 "objects, for example '{% insert messages %}' or '{% insert messages[-1:] %}'.", 

161 lineno, 

162 ) 

163 expr = parser.parse_expression() 

164 # Bodyless tag: empty body, no matching end tag required. 

165 return nodes.CallBlock( 

166 self.call_method(name="_build_inserted_messages_json", args=[expr]), [], [], [] 

167 ).set_lineno(lineno) 

168 

169 def _parse_message_tag(self, parser: Any, lineno: int) -> nodes.Node | list[nodes.Node]: 

170 """ 

171 Parse the message tag and its attributes in the Jinja2 template. 

172 

173 This method handles the parsing of role (mandatory), name (optional), meta (optional) and message body content. 

174 

175 :param parser: The Jinja2 parser instance 

176 :param lineno: The line number of the tag, used for error reporting. 

177 :return: A CallBlock node containing the parsed message configuration 

178 :raises TemplateSyntaxError: If an invalid role is provided 

179 """ 

180 

181 # Parse role attribute (mandatory) 

182 parser.stream.expect("name:role") 

183 parser.stream.expect("assign") 

184 role_expr = parser.parse_expression() 

185 

186 if isinstance(role_expr, nodes.Const): 

187 role = role_expr.value 

188 if role not in self.SUPPORTED_ROLES: 

189 raise TemplateSyntaxError(f"Role must be one of: {', '.join(self.SUPPORTED_ROLES)}", lineno) 

190 

191 # Parse optional name attribute 

192 name_expr = None 

193 if parser.stream.current.test("name:name"): 

194 parser.stream.skip() 

195 parser.stream.expect("assign") 

196 name_expr = parser.parse_expression() 

197 if not isinstance(name_expr.value, str): 

198 raise TemplateSyntaxError("name must be a string", lineno) 

199 

200 # Parse optional meta attribute 

201 meta_expr = None 

202 if parser.stream.current.test("name:meta"): 

203 parser.stream.skip() 

204 parser.stream.expect("assign") 

205 meta_expr = parser.parse_expression() 

206 if not isinstance(meta_expr, nodes.Dict): 

207 raise TemplateSyntaxError("meta must be a dictionary", lineno) 

208 

209 # Parse message body 

210 body = parser.parse_statements(("name:endmessage",), drop_needle=True) 

211 

212 # Build message node with all parameters 

213 return nodes.CallBlock( 

214 self.call_method( 

215 name="_build_chat_message_json", 

216 args=[role_expr, name_expr or nodes.Const(None), meta_expr or nodes.Dict([])], 

217 ), 

218 [], 

219 [], 

220 body, 

221 ).set_lineno(lineno) 

222 

223 def _build_chat_message_json(self, role: str, name: str | None, meta: dict, caller: Callable[[], str]) -> str: 

224 """ 

225 Build a ChatMessage object from template content and serialize it to a JSON string. 

226 

227 This method is called by Jinja2 when processing a `{% message %}` tag. 

228 It takes the rendered content from the template, converts XML blocks into ChatMessageContentT objects, 

229 creates a ChatMessage object and serializes it to a JSON string. 

230 

231 :param role: The role of the message 

232 :param name: Optional name for the message sender 

233 :param meta: Optional metadata dictionary 

234 :param caller: Callable that returns the rendered content 

235 :return: A JSON string representation of the ChatMessage object 

236 """ 

237 

238 content = caller() 

239 start_tag, end_tag = _sentinel_tags(getattr(self.environment, _NONCE_ATTR)) 

240 parts = self._parse_content_parts(content, start_tag, end_tag) 

241 if not parts: 

242 raise ValueError( 

243 f"Message template produced content that couldn't be parsed into any message parts. " 

244 f"Content: {_redact_nonce(content, start_tag, end_tag)!r}" 

245 ) 

246 

247 chat_message = self._validate_build_chat_message(parts=parts, role=role, meta=meta, name=name) 

248 

249 return json.dumps(chat_message.to_dict()) + "\n" 

250 

251 def _build_inserted_messages_json( 

252 self, 

253 messages: list[ChatMessage] | ChatMessage, 

254 caller: Callable[[], str], # noqa: ARG002 

255 ) -> str: 

256 """ 

257 Expand a list of ChatMessage objects into newline-separated JSON, one message per line. 

258 

259 This method is called by Jinja2 when processing an `{% insert %}` tag. It produces the same JSON-line format 

260 as `_build_chat_message_json`, so the messages are parsed back into ChatMessage objects by the 

261 ChatPromptBuilder alongside any literal `{% message %}` blocks. The full `ChatMessage.to_dict()` payload is 

262 serialized so that all content types (tool calls, tool call results, images, reasoning, name and meta) round 

263 trip without loss. 

264 

265 :param messages: The value the `{% insert %}` expression evaluated to. A missing or empty value expands to 

266 nothing. A single ChatMessage is also accepted, since indexing with an integer (for example 

267 `{% insert messages[-1] %}`) yields one message rather than a list. The value is validated at render time 

268 because it comes from untrusted template input. 

269 :param caller: Callable that returns the (empty) rendered body. Unused. 

270 :return: Newline-terminated JSON lines, one per message, or an empty string if there are no messages. 

271 :raises ValueError: If the value is not a ChatMessage or a list of ChatMessage objects. 

272 """ 

273 if isinstance(messages, ChatMessage): 

274 messages = [messages] 

275 if not messages: 

276 return "" 

277 if not isinstance(messages, (list, tuple)) or not all(isinstance(m, ChatMessage) for m in messages): 

278 raise ValueError( 

279 "The '{% insert %}' expression must evaluate to a ChatMessage or a list of ChatMessage objects. " 

280 f"Got: {type(messages).__name__}." 

281 ) 

282 return "".join(json.dumps(message.to_dict()) + "\n" for message in messages) 

283 

284 @staticmethod 

285 def _parse_content_parts(content: str, start_tag: str, end_tag: str) -> list[ChatMessageContentT]: 

286 """ 

287 Parse a string into a sequence of ChatMessageContentT objects. 

288 

289 This method handles: 

290 - Plain text content, converted to TextContent objects 

291 - Structured content parts wrapped in sentinel tags, converted to ChatMessageContentT objects 

292 

293 :param content: Input string containing mixed text and content parts 

294 :param start_tag: The opening sentinel tag (including the nonce) 

295 :param end_tag: The closing sentinel tag (including the nonce) 

296 :return: A list of ChatMessageContentT objects 

297 :raises ValueError: If the content is empty or contains only whitespace characters or if a 

298 `<haystack_content_part>` tag is found without a matching closing tag. 

299 """ 

300 if not content.strip(): 

301 raise ValueError( 

302 f"Message content in template is empty or contains only whitespace characters. " 

303 f"Content: {_redact_nonce(content, start_tag, end_tag)!r}" 

304 ) 

305 

306 parts: list[ChatMessageContentT] = [] 

307 cursor = 0 

308 total_length = len(content) 

309 

310 while cursor < total_length: 

311 tag_start = content.find(start_tag, cursor) 

312 

313 if tag_start == -1: 

314 # No more tags, add remaining text if any 

315 remaining_text = content[cursor:].strip() 

316 if remaining_text: 

317 parts.append(TextContent(text=remaining_text)) 

318 break 

319 

320 # Add text before tag if any 

321 if tag_start > cursor: 

322 plain_text = content[cursor:tag_start].strip() 

323 if plain_text: 

324 parts.append(TextContent(text=plain_text)) 

325 

326 content_start = tag_start + len(start_tag) 

327 tag_end = content.find(end_tag, content_start) 

328 

329 if tag_end == -1: 

330 snippet = _redact_nonce(content, start_tag, end_tag)[tag_start : tag_start + 50] 

331 raise ValueError( 

332 f"Found unclosed <haystack_content_part> tag at position {tag_start}. Content: '{snippet}...'" 

333 ) 

334 

335 json_content = content[content_start:tag_end] 

336 data = json.loads(json_content) 

337 parts.append(_deserialize_content_part(data)) 

338 

339 cursor = tag_end + len(end_tag) 

340 

341 return parts 

342 

343 @staticmethod 

344 def _validate_build_chat_message( 

345 parts: list[ChatMessageContentT], role: str, meta: dict, name: str | None = None 

346 ) -> ChatMessage: 

347 """ 

348 Validate the parts of a chat message and build a ChatMessage object. 

349 

350 :param parts: Content parts of the message 

351 :param role: The role of the message 

352 :param meta: The metadata of the message 

353 :param name: The optional name of the message 

354 :return: A ChatMessage object 

355 

356 :raises ValueError: If content parts don't allow to build a valid ChatMessage object or the role is not 

357 supported 

358 """ 

359 

360 if role == "user": 

361 valid_parts = [part for part in parts if isinstance(part, (TextContent, str, ImageContent, FileContent))] 

362 if len(parts) != len(valid_parts): 

363 raise ValueError( 

364 "User message must contain only TextContent, string, ImageContent or FileContent parts." 

365 ) 

366 return ChatMessage.from_user(meta=meta, name=name, content_parts=valid_parts) 

367 

368 if role == "system": 

369 if not isinstance(parts[0], TextContent): 

370 raise ValueError("System message must contain a text part.") 

371 text = parts[0].text 

372 if len(parts) > 1: 

373 raise ValueError("System message must contain only one text part.") 

374 return ChatMessage.from_system(meta=meta, name=name, text=text) 

375 

376 if role == "assistant": 

377 texts = [part.text for part in parts if isinstance(part, TextContent)] 

378 tool_calls = [part for part in parts if isinstance(part, ToolCall)] 

379 reasoning = [part for part in parts if isinstance(part, ReasoningContent)] 

380 if len(texts) > 1: 

381 raise ValueError("Assistant message must contain one text part at most.") 

382 if len(texts) == 0 and len(tool_calls) == 0: 

383 raise ValueError("Assistant message must contain at least one text or tool call part.") 

384 if len(parts) > len(texts) + len(tool_calls) + len(reasoning): 

385 raise ValueError("Assistant message must contain only text, tool call or reasoning parts.") 

386 return ChatMessage.from_assistant( 

387 meta=meta, 

388 name=name, 

389 text=texts[0] if texts else None, 

390 tool_calls=tool_calls or None, 

391 reasoning=reasoning[0] if reasoning else None, 

392 ) 

393 

394 if role == "tool": 

395 tool_call_results = [part for part in parts if isinstance(part, ToolCallResult)] 

396 if len(tool_call_results) == 0 or len(tool_call_results) > 1 or len(parts) > len(tool_call_results): 

397 raise ValueError("Tool message must contain only one tool call result.") 

398 

399 tool_result = tool_call_results[0].result 

400 origin = tool_call_results[0].origin 

401 error = tool_call_results[0].error 

402 

403 return ChatMessage.from_tool(meta=meta, tool_result=tool_result, origin=origin, error=error) 

404 

405 raise ValueError(f"Unsupported role: {role}") 

406 

407 

408@pass_environment 

409def templatize_part(environment: Any, value: ChatMessageContentT) -> "_TemplatizedPart": 

410 """ 

411 Jinja filter to convert a ChatMessageContentT object into a JSON string wrapped in sentinel content tags. 

412 

413 :param environment: The Jinja2 environment 

414 :param value: The ChatMessageContentT object to convert 

415 :return: A `_TemplatizedPart` holding a JSON string wrapped in special XML content tags 

416 :raises ValueError: If the value is not an instance of ChatMessageContentT 

417 """ 

418 start_tag, end_tag = _sentinel_tags(getattr(environment, _NONCE_ATTR)) 

419 return _TemplatizedPart(f"{start_tag}{json.dumps(_serialize_content_part(value))}{end_tag}")