Skip to content

Commit 1e98df0

Browse files
moonbox3MAF Dashboard BotCopilot
authored
Python: Improve prompt-template msg serialize and sample usage (#13738)
### Motivation and Context This PR updates the Jinja2 and Handlebars prompt-template helpers to serialize chat messages through the existing XML/message serializer instead of assembling message XML manually. It also aligns the prompt-template samples with the serializer-backed helper and adds regression coverage for common message content. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description Prompt template message serialize improvements. <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄 --------- Co-authored-by: MAF Dashboard Bot <maf-dashboard-bot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 134e52e commit 1e98df0

File tree

8 files changed

+256
-28
lines changed

8 files changed

+256
-28
lines changed

python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
chat_function = kernel.add_function(
4040
prompt_template_config=PromptTemplateConfig(
4141
template="""{{system_message}}{{#each chat_history}}
42-
{{#message role=role}}{{~content~}}{{/message}} {{/each}}""",
42+
{{message_to_prompt}} {{/each}}""",
4343
template_format="handlebars",
4444
allow_dangerously_set_content=True,
4545
),

python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
chat_function = kernel.add_function(
4040
prompt_template_config=PromptTemplateConfig(
41-
template="""{{system_message}}{% for item in chat_history %}{{ message(item) }}{% endfor %}""",
41+
template="""{{system_message}}{% for item in chat_history %}{{ message_to_prompt(item) }}{% endfor %}""",
4242
template_format="jinja2",
4343
allow_dangerously_set_content=True,
4444
),

python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
from collections.abc import Callable
77
from enum import Enum
8+
from xml.etree.ElementTree import Element, SubElement, tostring # nosec B405
89

910
logger: logging.Logger = logging.getLogger(__name__)
1011

@@ -26,23 +27,32 @@ def _message_to_prompt(this, *args, **kwargs):
2627

2728

2829
def _message(this, options, *args, **kwargs):
30+
from semantic_kernel.contents.chat_message_content import ChatMessageContent
2931
from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG
3032

31-
# everything in kwargs, goes to <ROOT_KEY_MESSAGE kwargs_key="kwargs_value">
32-
# everything in options, goes in between <ROOT_KEY_MESSAGE>options</ROOT_KEY_MESSAGE>
33-
start = f"<{CHAT_MESSAGE_CONTENT_TAG}"
33+
# When the context is a ChatMessageContent, delegate to to_element() so that
34+
# the XML contract is consistent with the Jinja2 path.
35+
if isinstance(this.context, ChatMessageContent):
36+
message = this.context.to_element()
37+
return tostring(message, encoding="unicode", short_empty_elements=False)
38+
39+
# Fallback: build the element manually from kwargs and block content.
40+
from semantic_kernel.contents.const import TEXT_CONTENT_TAG
41+
42+
message = Element(CHAT_MESSAGE_CONTENT_TAG)
3443
for key, value in kwargs.items():
3544
if isinstance(value, Enum):
3645
value = value.value
3746
if value is not None:
38-
start += f' {key}="{value}"'
39-
start += ">"
40-
end = f"</{CHAT_MESSAGE_CONTENT_TAG}>"
47+
message.set(key, str(value))
4148
try:
42-
content = options["fn"](this)
49+
content = str(options["fn"](this))
4350
except Exception: # pragma: no cover
4451
content = ""
45-
return f"{start}{content}{end}"
52+
if content:
53+
text_elem = SubElement(message, TEXT_CONTENT_TAG)
54+
text_elem.text = content
55+
return tostring(message, encoding="unicode", short_empty_elements=False)
4656

4757

4858
def _set(this, *args, **kwargs):

python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from collections.abc import Callable
6-
from enum import Enum
6+
from xml.etree.ElementTree import tostring # nosec B405
77

88
logger: logging.Logger = logging.getLogger(__name__)
99

@@ -25,17 +25,8 @@ def _message_to_prompt(context):
2525

2626

2727
def _message(item):
28-
from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG
29-
30-
start = f"<{CHAT_MESSAGE_CONTENT_TAG}"
31-
role = item.role
32-
content = item.content
33-
if isinstance(role, Enum):
34-
role = role.value
35-
start += f' role="{role}"'
36-
start += ">"
37-
end = f"</{CHAT_MESSAGE_CONTENT_TAG}>"
38-
return f"{start}{content}{end}"
28+
message = item.to_element()
29+
return tostring(message, encoding="unicode", short_empty_elements=False)
3930

4031

4132
# Wrap the _get function to safely handle calls without arguments

python/tests/unit/prompt_template/test_handlebars_prompt_template.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,108 @@ async def test_helpers_message(kernel: Kernel):
261261
assert "Assistant message" in rendered
262262

263263

264+
async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel):
265+
template = """
266+
{{#each chat_history}}
267+
{{#message role=role}}
268+
{{~content~}}
269+
{{/message}}
270+
{{/each}}
271+
"""
272+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
273+
chat_history = ChatHistory()
274+
chat_history.add_user_message('What does a < b & a > c & "d" mean?')
275+
276+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
277+
278+
assert "&lt;" in rendered
279+
assert "&amp;" in rendered
280+
# ElementTree does not escape > in text content (valid XML); verify round-trip works regardless.
281+
assert "a > c" in rendered or "a &gt; c" in rendered
282+
assert '"d"' in rendered
283+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
284+
285+
286+
async def test_helpers_message_uses_text_element(kernel: Kernel):
287+
"""Verify handlebars {{#message}} wraps content in <text> like the Jinja2 path."""
288+
template = """
289+
{{#each chat_history}}
290+
{{#message role=role}}
291+
{{~content~}}
292+
{{/message}}
293+
{{/each}}
294+
"""
295+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
296+
chat_history = ChatHistory()
297+
chat_history.add_user_message("User message")
298+
chat_history.add_assistant_message("Assistant message")
299+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
300+
assert '<message role="user"><text>User message</text></message>' in rendered
301+
assert '<message role="assistant"><text>Assistant message</text></message>' in rendered
302+
chat_history2 = ChatHistory.from_rendered_prompt(rendered)
303+
assert chat_history2 == chat_history
304+
305+
306+
async def test_helpers_message_empty_content(kernel: Kernel):
307+
"""Empty message content should produce <message role="..."></message>, not self-closing."""
308+
template = """
309+
{{#each chat_history}}
310+
{{#message role=role}}
311+
{{~content~}}
312+
{{/message}}
313+
{{/each}}
314+
"""
315+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
316+
chat_history = ChatHistory()
317+
chat_history.add_user_message("")
318+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
319+
assert "<message" in rendered
320+
assert "/>" not in rendered
321+
assert ChatHistory.from_rendered_prompt(rendered) is not None
322+
323+
324+
async def test_helpers_message_fallback_empty_content(kernel: Kernel):
325+
"""Fallback path (non-ChatMessageContent context) with empty block content.
326+
327+
Should produce <message role="..."></message>, not self-closing.
328+
"""
329+
template = '{{#message role="user"}}{{/message}}'
330+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
331+
rendered = await target.render(kernel, KernelArguments())
332+
assert '<message role="user"></message>' in rendered
333+
assert "/>" not in rendered
334+
assert ChatHistory.from_rendered_prompt(rendered) is not None
335+
336+
337+
async def test_helpers_message_fallback_with_content(kernel: Kernel):
338+
"""Fallback path wraps block content in a <text> child element."""
339+
template = '{{#message role="user"}}Hello world{{/message}}'
340+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
341+
rendered = await target.render(kernel, KernelArguments())
342+
assert '<message role="user"><text>Hello world</text></message>' in rendered
343+
chat_history = ChatHistory.from_rendered_prompt(rendered)
344+
assert chat_history is not None
345+
assert len(chat_history) == 1
346+
assert chat_history[0].content == "Hello world"
347+
348+
349+
async def test_helpers_message_escapes_greater_than(kernel: Kernel):
350+
"""ElementTree does not escape > in text; verify round-trip still works."""
351+
template = """
352+
{{#each chat_history}}
353+
{{#message role=role}}
354+
{{~content~}}
355+
{{/message}}
356+
{{/each}}
357+
"""
358+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
359+
chat_history = ChatHistory()
360+
chat_history.add_user_message("Is a > b true?")
361+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
362+
assert "a > b" in rendered or "a &gt; b" in rendered
363+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
364+
365+
264366
async def test_helpers_message_to_prompt(kernel: Kernel):
265367
template = """{{#each chat_history}}{{message_to_prompt}} {{/each}}"""
266368
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)

python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33

44
from semantic_kernel import Kernel
5+
from semantic_kernel.contents import AuthorRole
56
from semantic_kernel.contents.chat_history import ChatHistory
67
from semantic_kernel.functions import kernel_function
78
from semantic_kernel.functions.kernel_arguments import KernelArguments
@@ -94,9 +95,46 @@ async def test_chat_history_round_trip(self, kernel: Kernel):
9495
chat_history.add_user_message("User message")
9596
chat_history.add_assistant_message("Assistant message")
9697
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
97-
assert (
98-
rendered.strip()
99-
== """<message role="user">User message</message> <message role="assistant">Assistant message</message>"""
98+
expected = (
99+
'<message role="user"><text>User message</text></message>'
100+
' <message role="assistant"><text>Assistant message</text></message>'
100101
)
102+
assert rendered.strip() == expected
101103
chat_history2 = ChatHistory.from_rendered_prompt(rendered)
102104
assert chat_history2 == chat_history
105+
106+
async def test_chat_history_round_trip_with_xml_metacharacters(self, kernel: Kernel):
107+
# Arrange
108+
template = """{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}"""
109+
target = create_handlebars_prompt_template(template)
110+
chat_history = ChatHistory()
111+
chat_history.add_user_message("What does a < b mean in Python?")
112+
chat_history.add_assistant_message('Use "&" carefully in XML and HTML.')
113+
114+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
115+
116+
assert "&lt;" in rendered
117+
assert "&amp;" in rendered
118+
assert '"&amp;"' in rendered
119+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
120+
121+
async def test_message_helper_preserves_system_role_with_xml_metacharacters(self, kernel: Kernel):
122+
# Arrange
123+
template = (
124+
"""{{system_message}}{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}"""
125+
)
126+
target = create_handlebars_prompt_template(template)
127+
system_message = "You are a helpful assistant."
128+
chat_history = ChatHistory()
129+
chat_history.add_user_message("What does a < b mean in Python?")
130+
131+
rendered = await target.render(
132+
kernel,
133+
KernelArguments(system_message=system_message, chat_history=chat_history),
134+
)
135+
136+
parsed = ChatHistory.from_rendered_prompt(rendered)
137+
assert parsed.messages[0].role == AuthorRole.SYSTEM
138+
assert parsed.messages[0].content == system_message
139+
assert parsed.messages[1].role == AuthorRole.USER
140+
assert parsed.messages[1].content == "What does a < b mean in Python?"

python/tests/unit/prompt_template/test_jinja2_prompt_template.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,20 @@ async def test_helpers_message(kernel: Kernel):
264264
assert "Assistant message" in rendered
265265

266266

267+
async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel):
268+
template = """{% for item in chat_history %}{{ message(item) }}{% endfor %}"""
269+
target = create_jinja2_prompt_template(template, allow_dangerously_set_content=True)
270+
chat_history = ChatHistory()
271+
chat_history.add_user_message('What does a < b & "c" mean?')
272+
273+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
274+
275+
assert "&lt;" in rendered
276+
assert "&amp;" in rendered
277+
assert '"c"' in rendered
278+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
279+
280+
267281
async def test_helpers_message_to_prompt(kernel: Kernel):
268282
template = """
269283
{% for chat in chat_history %}

python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33

4+
from semantic_kernel.contents import AuthorRole
45
from semantic_kernel.contents.chat_history import ChatHistory
56
from semantic_kernel.functions import kernel_function
67
from semantic_kernel.functions.kernel_arguments import KernelArguments
@@ -98,9 +99,81 @@ async def test_chat_history_round_trip(kernel: Kernel):
9899
chat_history.add_user_message("User message")
99100
chat_history.add_assistant_message("Assistant message")
100101
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
101-
assert (
102-
rendered.strip()
103-
== """<message role="user">User message</message><message role="assistant">Assistant message</message>"""
102+
expected = (
103+
'<message role="user"><text>User message</text></message>'
104+
'<message role="assistant"><text>Assistant message</text></message>'
104105
)
106+
assert rendered.strip() == expected
105107
chat_history2 = ChatHistory.from_rendered_prompt(rendered)
106108
assert chat_history2 == chat_history
109+
110+
111+
async def test_from_rendered_prompt_backward_compat_old_format(kernel: Kernel):
112+
"""from_rendered_prompt handles the old format without <text> wrapper for backward compatibility."""
113+
old_format = '<message role="user">User message</message><message role="assistant">Assistant message</message>'
114+
parsed = ChatHistory.from_rendered_prompt(old_format)
115+
assert len(parsed) == 2
116+
assert parsed[0].role == AuthorRole.USER
117+
assert parsed[0].content == "User message"
118+
assert parsed[1].role == AuthorRole.ASSISTANT
119+
assert parsed[1].content == "Assistant message"
120+
121+
122+
async def test_chat_history_round_trip_with_xml_metacharacters(kernel: Kernel):
123+
template = """{% for item in chat_history %}{{ message(item) }}{% endfor %}"""
124+
target = create_jinja2_prompt_template(template)
125+
chat_history = ChatHistory()
126+
chat_history.add_user_message("What does a < b mean in Python?")
127+
chat_history.add_assistant_message('Use "&" carefully in XML and HTML.')
128+
129+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
130+
131+
assert "&lt;" in rendered
132+
assert "&amp;" in rendered
133+
assert '"&amp;"' in rendered
134+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
135+
136+
137+
async def test_message_helper_preserves_system_role_with_xml_metacharacters(kernel: Kernel):
138+
template = """{{system_message}}{% for item in chat_history %}{{ message(item) }}{% endfor %}"""
139+
target = create_jinja2_prompt_template(template)
140+
system_message = "You are a helpful assistant."
141+
chat_history = ChatHistory()
142+
chat_history.add_user_message("What does a < b mean in Python?")
143+
144+
rendered = await target.render(
145+
kernel,
146+
KernelArguments(system_message=system_message, chat_history=chat_history),
147+
)
148+
149+
parsed = ChatHistory.from_rendered_prompt(rendered)
150+
assert parsed.messages[0].role == AuthorRole.SYSTEM
151+
assert parsed.messages[0].content == system_message
152+
assert parsed.messages[1].role == AuthorRole.USER
153+
154+
assert parsed.messages[1].content == "What does a < b mean in Python?"
155+
156+
157+
def test_from_rendered_prompt_backward_compat_old_format_no_text_wrapper():
158+
"""from_rendered_prompt must handle the old format without <text> wrapper."""
159+
old_format = '<message role="user">User message</message><message role="assistant">Assistant message</message>'
160+
parsed = ChatHistory.from_rendered_prompt(old_format)
161+
assert len(parsed.messages) == 2
162+
assert parsed.messages[0].role == AuthorRole.USER
163+
assert parsed.messages[0].content == "User message"
164+
assert parsed.messages[1].role == AuthorRole.ASSISTANT
165+
assert parsed.messages[1].content == "Assistant message"
166+
167+
168+
def test_from_rendered_prompt_new_text_element_format():
169+
"""from_rendered_prompt must handle the new format with <text> wrapper."""
170+
new_format = (
171+
'<message role="user"><text>User message</text></message>'
172+
'<message role="assistant"><text>Assistant message</text></message>'
173+
)
174+
parsed = ChatHistory.from_rendered_prompt(new_format)
175+
assert len(parsed.messages) == 2
176+
assert parsed.messages[0].role == AuthorRole.USER
177+
assert parsed.messages[0].content == "User message"
178+
assert parsed.messages[1].role == AuthorRole.ASSISTANT
179+
assert parsed.messages[1].content == "Assistant message"

0 commit comments

Comments
 (0)