Skip to content

Commit e256be8

Browse files
authored
[evaluation] Consolidate message-preprocessing helpers into a single source (_common.utils) (#48013)
The helpers _is_intermediate_response, _drop_mcp_approval_messages, _normalize_function_call_types, and _preprocess_messages had divergent duplicate copies in both _evaluators/_common/_base_prompty_eval.py and _common/utils.py. The utils.py copy of _normalize_function_call_types was missing the function_call payload key-rename to tool_call. This makes _common.utils the single source of truth (adding the missing rename so it is a strict superset) and re-exports the helpers from _base_prompty_eval for backward compatibility. Internal refactor only; no public API or behavior change.
1 parent ecbc418 commit e256be8

3 files changed

Lines changed: 42 additions & 74 deletions

File tree

sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_common/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,8 @@ def _normalize_function_call_types(messages):
16261626
t = item.get("type")
16271627
if t == "function_call":
16281628
item["type"] = "tool_call"
1629+
if "function_call" in item:
1630+
item["tool_call"] = item.pop("function_call")
16291631
elif t == "function_call_output":
16301632
item["type"] = "tool_result"
16311633
if "function_call_output" in item:

sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_evaluators/_common/_base_prompty_eval.py

Lines changed: 11 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@
1919
from azure.ai.evaluation._constants import EVALUATION_PASS_FAIL_MAPPING
2020
from azure.ai.evaluation._exceptions import EvaluationException, ErrorBlame, ErrorCategory, ErrorTarget
2121
from ..._common.utils import construct_prompty_model_config, validate_model_config, parse_quality_evaluator_reason_score
22+
23+
# The message-preprocessing helpers now live in ``azure.ai.evaluation._common.utils`` (single source of
24+
# truth). They are imported here so this module can use them and so callers that historically imported
25+
# them from this module keep working. ``_drop_mcp_approval_messages`` / ``_normalize_function_call_types``
26+
# are re-exported only (used indirectly via ``_preprocess_messages``).
27+
from ..._common.utils import ( # pylint: disable=unused-import
28+
_is_intermediate_response,
29+
_drop_mcp_approval_messages,
30+
_normalize_function_call_types,
31+
_preprocess_messages,
32+
)
2233
from . import EvaluatorBase
2334

2435
try:
@@ -34,80 +45,6 @@ def value(self) -> str:
3445
T = TypeVar("T")
3546

3647

37-
def _is_intermediate_response(response):
38-
"""Check if response is intermediate (last content item is function_call or mcp_approval_request)."""
39-
if isinstance(response, list) and len(response) > 0:
40-
last_msg = response[-1]
41-
if isinstance(last_msg, dict) and last_msg.get("role") == "assistant":
42-
content = last_msg.get("content", [])
43-
if isinstance(content, list) and len(content) > 0:
44-
last_content = content[-1]
45-
if isinstance(last_content, dict) and last_content.get("type") in (
46-
"function_call",
47-
"mcp_approval_request",
48-
):
49-
return True
50-
return False
51-
52-
53-
def _drop_mcp_approval_messages(messages):
54-
"""Remove MCP approval request/response messages."""
55-
if not isinstance(messages, list):
56-
return messages
57-
return [
58-
msg
59-
for msg in messages
60-
if not (
61-
isinstance(msg, dict)
62-
and isinstance(msg.get("content"), list)
63-
and (
64-
(
65-
msg.get("role") == "assistant"
66-
and any(isinstance(c, dict) and c.get("type") == "mcp_approval_request" for c in msg["content"])
67-
)
68-
or (
69-
msg.get("role") == "tool"
70-
and any(isinstance(c, dict) and c.get("type") == "mcp_approval_response" for c in msg["content"])
71-
)
72-
)
73-
)
74-
]
75-
76-
77-
def _normalize_function_call_types(messages):
78-
"""Normalize function_call/function_call_output/openapi_call/openapi_call_output types to tool_call/tool_result."""
79-
if not isinstance(messages, list):
80-
return messages
81-
for msg in messages:
82-
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
83-
for item in msg["content"]:
84-
if not isinstance(item, dict):
85-
continue
86-
item_type = item.get("type")
87-
if item_type == "function_call":
88-
item["type"] = "tool_call"
89-
if "function_call" in item:
90-
item["tool_call"] = item.pop("function_call")
91-
elif item_type == "function_call_output":
92-
item["type"] = "tool_result"
93-
if "function_call_output" in item:
94-
item["tool_result"] = item.pop("function_call_output")
95-
elif item_type == "openapi_call":
96-
item["type"] = "tool_call"
97-
elif item_type == "openapi_call_output":
98-
item["type"] = "tool_result"
99-
if "openapi_call_output" in item:
100-
item["tool_result"] = item.pop("openapi_call_output")
101-
return messages
102-
103-
104-
def _preprocess_messages(messages):
105-
"""Drop MCP approval messages and normalize function call types."""
106-
messages = _drop_mcp_approval_messages(messages)
107-
messages = _normalize_function_call_types(messages)
108-
return messages
109-
110-
11148
class PromptyEvaluatorBase(EvaluatorBase[T]):
11249
"""Base class for all evaluators that make use of context as an input. It's also assumed that such evaluators
11350
make use of a prompty file, and return their results as a dictionary, with a single key-value pair

sdk/evaluation/azure-ai-evaluation/tests/unittests/test_multi_turn_utilities.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,20 @@ def test_function_call_output_to_tool_result(self):
746746
assert result[0]["content"][0]["tool_result"] == "result data"
747747
assert "function_call_output" not in result[0]["content"][0]
748748

749+
def test_function_call_nested_payload_key_renamed(self):
750+
# A nested ``function_call`` payload key is renamed to ``tool_call`` so downstream code that
751+
# reads ``content["tool_call"]`` finds it (canonical single-source behavior).
752+
messages = [
753+
{
754+
"role": "assistant",
755+
"content": [{"type": "function_call", "function_call": {"name": "f", "arguments": {}}}],
756+
}
757+
]
758+
result = _normalize_function_call_types(messages)
759+
assert result[0]["content"][0]["type"] == "tool_call"
760+
assert result[0]["content"][0]["tool_call"] == {"name": "f", "arguments": {}}
761+
assert "function_call" not in result[0]["content"][0]
762+
749763
def test_openapi_call_to_tool_call(self):
750764
messages = [
751765
{
@@ -779,6 +793,21 @@ def test_non_list_passthrough(self):
779793
assert _normalize_function_call_types("not a list") == "not a list"
780794

781795

796+
@pytest.mark.unittest
797+
class TestPreprocessHelpersSingleSource:
798+
"""The message-preprocessing helpers have one definition in ``_common.utils`` and are re-exported
799+
from ``_base_prompty_eval`` for backward compatibility."""
800+
801+
def test_base_prompty_eval_reexports_the_same_utils_helpers(self):
802+
from azure.ai.evaluation._common import utils as u
803+
from azure.ai.evaluation._evaluators._common import _base_prompty_eval as b
804+
805+
assert b._is_intermediate_response is u._is_intermediate_response
806+
assert b._drop_mcp_approval_messages is u._drop_mcp_approval_messages
807+
assert b._normalize_function_call_types is u._normalize_function_call_types
808+
assert b._preprocess_messages is u._preprocess_messages
809+
810+
782811
@pytest.mark.unittest
783812
class TestPreprocessMessages:
784813
def test_drops_mcp_and_normalizes(self):

0 commit comments

Comments
 (0)