Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ def _validate_dict_field(
)
return None

def _validate_text_content_item(self, content_item: Dict[str, Any], role: str) -> Optional[EvaluationException]:
def _validate_text_content_item(
self, content_item: Dict[str, Any], role: str
) -> Optional[EvaluationException]:
"""Validate a text content item."""
if "text" not in content_item:
return EvaluationException(
Expand All @@ -148,16 +150,23 @@ def _validate_text_content_item(self, content_item: Dict[str, Any], role: str) -
)
return None

def _validate_tool_call_content_item(self, content_item: Dict[str, Any]) -> Optional[EvaluationException]:
def _validate_tool_call_content_item(
self, content_item: Dict[str, Any]
) -> Optional[EvaluationException]:
"""Validate a tool_call content item."""
valid_tool_call_content_types = [
ContentType.TOOL_CALL,
ContentType.FUNCTION_CALL,
ContentType.OPENAPI_CALL,
ContentType.MCP_APPROVAL_REQUEST,
]
valid_tool_call_content_types_as_strings = [t.value for t in valid_tool_call_content_types]
if "type" not in content_item or content_item["type"] not in valid_tool_call_content_types:
valid_tool_call_content_types_as_strings = [
t.value for t in valid_tool_call_content_types
]
if (
"type" not in content_item
or content_item["type"] not in valid_tool_call_content_types
):
return EvaluationException(
message=f"The content item must be of type {valid_tool_call_content_types_as_strings} in tool_call content item.",
blame=ErrorBlame.USER_ERROR,
Expand All @@ -168,21 +177,29 @@ def _validate_tool_call_content_item(self, content_item: Dict[str, Any]) -> Opti
if content_item["type"] == ContentType.MCP_APPROVAL_REQUEST:
return None

error = self._validate_string_field(content_item, "name", "tool_call content items")
error = self._validate_string_field(
content_item, "name", "tool_call content items"
)
if error:
return error

error = self._validate_dict_field(content_item, "arguments", "tool_call content items")
error = self._validate_dict_field(
content_item, "arguments", "tool_call content items"
)
if error:
return error

error = self._validate_string_field(content_item, "tool_call_id", "tool_call content items")
error = self._validate_string_field(
content_item, "tool_call_id", "tool_call content items"
)
if error:
return error

return None

def _validate_user_or_system_message(self, message: Dict[str, Any], role: str) -> Optional[EvaluationException]:
def _validate_user_or_system_message(
self, message: Dict[str, Any], role: str
) -> Optional[EvaluationException]:
"""Validate user or system message content."""
content = message["content"]

Expand All @@ -203,7 +220,9 @@ def _validate_user_or_system_message(self, message: Dict[str, Any], role: str) -
return error
return None

def _validate_assistant_message(self, message: Dict[str, Any]) -> Optional[EvaluationException]:
def _validate_assistant_message(
self, message: Dict[str, Any]
) -> Optional[EvaluationException]:
"""Validate assistant message content."""
content = message["content"]

Expand All @@ -216,7 +235,9 @@ def _validate_assistant_message(self, message: Dict[str, Any]) -> Optional[Evalu
ContentType.MCP_APPROVAL_REQUEST,
ContentType.OPENAPI_CALL,
]
valid_assistant_content_types_as_strings = [t.value for t in valid_assistant_content_types]
valid_assistant_content_types_as_strings = [
t.value for t in valid_assistant_content_types
]
for content_item in content:
content_type = content_item["type"]
if content_type not in valid_assistant_content_types:
Expand All @@ -228,7 +249,9 @@ def _validate_assistant_message(self, message: Dict[str, Any]) -> Optional[Evalu
)

if content_type in [ContentType.TEXT, ContentType.OUTPUT_TEXT]:
error = self._validate_text_content_item(content_item, MessageRole.ASSISTANT)
error = self._validate_text_content_item(
content_item, MessageRole.ASSISTANT
)
if error:
return error
elif content_type in [
Expand All @@ -242,9 +265,14 @@ def _validate_assistant_message(self, message: Dict[str, Any]) -> Optional[Evalu

# Raise error in case of unsupported tools for evaluators that enabled check_for_unsupported_tools
if self.check_for_unsupported_tools:
if content_type == ContentType.TOOL_CALL or content_type == ContentType.OPENAPI_CALL:
if (
content_type == ContentType.TOOL_CALL
or content_type == ContentType.OPENAPI_CALL
):
name = (
"openapi_call" if content_type == ContentType.OPENAPI_CALL else content_item["name"].lower()
"openapi_call"
if content_type == ContentType.OPENAPI_CALL
else content_item["name"].lower()
)
if name in self.UNSUPPORTED_TOOLS:
return EvaluationException(
Expand All @@ -255,7 +283,9 @@ def _validate_assistant_message(self, message: Dict[str, Any]) -> Optional[Evalu
)
return None

def _validate_tool_message(self, message: Dict[str, Any]) -> Optional[EvaluationException]:
def _validate_tool_message(
self, message: Dict[str, Any]
) -> Optional[EvaluationException]:
"""Validate tool message content."""
content = message["content"]

Expand All @@ -281,7 +311,9 @@ def _validate_tool_message(self, message: Dict[str, Any]) -> Optional[Evaluation
ContentType.MCP_APPROVAL_RESPONSE,
ContentType.OPENAPI_CALL_OUTPUT,
]
valid_tool_content_types_as_strings = [t.value for t in valid_tool_content_types]
valid_tool_content_types_as_strings = [
t.value for t in valid_tool_content_types
]
for content_item in content:
content_type = content_item["type"]
if content_type not in valid_tool_content_types:
Expand All @@ -307,7 +339,9 @@ def _validate_tool_message(self, message: Dict[str, Any]) -> Optional[Evaluation

return None

def _validate_message_dict(self, message: Dict[str, Any]) -> Optional[EvaluationException]:
def _validate_message_dict(
self, message: Dict[str, Any]
) -> Optional[EvaluationException]:
"""Validate a single message dictionary."""
if "role" not in message:
return EvaluationException(
Expand All @@ -329,7 +363,8 @@ def _validate_message_dict(self, message: Dict[str, Any]) -> Optional[Evaluation
content = message["content"]

content_is_string_or_list_of_dicts = isinstance(content, str) or (
isinstance(content, list) and all(item and isinstance(item, dict) for item in content)
isinstance(content, list)
and all(item and isinstance(item, dict) for item in content)
)
if not content_is_string_or_list_of_dicts:
return EvaluationException(
Expand Down Expand Up @@ -372,7 +407,9 @@ def _validate_message_dict(self, message: Dict[str, Any]) -> Optional[Evaluation

return None

def _validate_input_messages_list(self, input_messages: Any, input_name: str) -> Optional[EvaluationException]:
def _validate_input_messages_list(
self, input_messages: Any, input_name: str
) -> Optional[EvaluationException]:
if input_messages is None:
return EvaluationException(
message=f"{input_name} is a required input and cannot be None.",
Expand Down Expand Up @@ -423,7 +460,9 @@ def _validate_input_messages_list(self, input_messages: Any, input_name: str) ->

return None

def _validate_conversation(self, conversation: Any) -> Optional[EvaluationException]:
def _validate_conversation(
self, conversation: Any
) -> Optional[EvaluationException]:
"""Validate the conversation input."""
if not isinstance(conversation, dict):
return EvaluationException(
Expand Down Expand Up @@ -456,7 +495,9 @@ def validate_eval_input(self, eval_input: Dict[str, Any]) -> bool:
"""Validate the evaluation input dictionary."""
conversation = eval_input.get("conversation")
if conversation:
conversation_validation_exception = self._validate_conversation(conversation)
conversation_validation_exception = self._validate_conversation(
conversation
)
if conversation_validation_exception:
raise conversation_validation_exception
return True
Expand All @@ -478,22 +519,17 @@ class GroundednessConversationValidator(ConversationValidator):
"""
ConversationValidator override used by ``GroundednessEvaluator``.

Groundedness still rejects ``azure_ai_search``, ``azure_fabric``, and
``sharepoint_grounding`` tool calls pending a helper that can extract
document bodies out of structured ``tool_result`` envelopes and feed
them in as the ``context`` input the groundedness judge prompt scores
against. Without that helper the judge would receive empty or wrong
context and return a silently low groundedness score, so the
enablement performed for ``ToolCallSuccessEvaluator`` and
``ToolOutputUtilizationEvaluator`` is intentionally not extended here.

Once the context-extractor helper lands, this subclass can be deleted
and ``GroundednessEvaluator`` can use ``ConversationValidator``
directly.
Groundedness now accepts the same structured grounding tools enabled in
the shared ``ConversationValidator`` path, and additionally allows
``bing_grounding``, ``bing_custom_search``, and ``openapi_call``.

A dedicated subclass is still kept so Groundedness can preserve stricter
guardrails for the remaining unsupported tool families without changing
behavior of other evaluators.
"""

UNSUPPORTED_TOOLS: List[str] = ConversationValidator.UNSUPPORTED_TOOLS + [
"azure_ai_search",
"azure_fabric",
"sharepoint_grounding",
UNSUPPORTED_TOOLS: List[str] = [
tool_name
for tool_name in ConversationValidator.UNSUPPORTED_TOOLS
if tool_name not in {"bing_custom_search", "bing_grounding", "openapi_call"}
Comment on lines +531 to +534
]
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,16 @@ class GroundednessEvaluator(PromptyEvaluatorBase[Union[str, float]]):
@override
def __init__(self, model_config, *, threshold=3, credential=None, **kwargs):
current_dir = os.path.dirname(__file__)
prompty_path = os.path.join(current_dir, self._PROMPTY_FILE_NO_QUERY) # Default to no query
prompty_path = os.path.join(
current_dir, self._PROMPTY_FILE_NO_QUERY
) # Default to no query

self._higher_is_better = True

# Initialize input validator. ``GroundednessConversationValidator``
# carries a wider ``UNSUPPORTED_TOOLS`` list than the shared base:
# ``azure_ai_search``, ``azure_fabric``, and ``sharepoint_grounding``
# are still rejected here pending a context-extractor helper.
# keeps stricter guardrails than the shared base for the remaining
# unsupported tool families, while allowing grounding/search/openapi
# tool calls supported by this evaluator.
Comment on lines +123 to +125
self._validator = GroundednessConversationValidator(
error_target=ErrorTarget.GROUNDEDNESS_EVALUATOR,
requires_query=False,
Expand Down Expand Up @@ -306,8 +308,12 @@ async def _do_eval(self, eval_input: Dict) -> Dict[str, Union[float, str]]:

contains_context = self._has_context(eval_input)

simplified_query = simplify_messages(eval_input["query"], drop_tool_calls=contains_context)
simplified_response = simplify_messages(eval_input["response"], drop_tool_calls=False)
simplified_query = simplify_messages(
eval_input["query"], drop_tool_calls=contains_context
)
simplified_response = simplify_messages(
eval_input["response"], drop_tool_calls=False
)

# Build simplified input
simplified_eval_input = {
Expand Down Expand Up @@ -371,11 +377,17 @@ def _convert_kwargs_to_eval_input(self, **kwargs):

# If response is a string, we can skip the context extraction and just return the eval input
if response and isinstance(response, str):
return super()._convert_kwargs_to_eval_input(query=query, response=response, context=response)
return super()._convert_kwargs_to_eval_input(
query=query, response=response, context=response
)

context = self._get_context_from_agent_response(response, tool_definitions)

if not self._validate_context(context) and self._is_single_entry(response) and self._is_single_entry(query):
if (
not self._validate_context(context)
and self._is_single_entry(response)
and self._is_single_entry(query)
):
msg = (
f"{type(self).__name__}: No valid context provided or could be extracted from the query or response. "
"Please either provide valid context or pass the full items list for 'response' and 'query' "
Expand All @@ -388,14 +400,26 @@ def _convert_kwargs_to_eval_input(self, **kwargs):
target=ErrorTarget.GROUNDEDNESS_EVALUATOR,
)

filtered_response = self._filter_file_search_results(response) if self._validate_context(context) else response
return super()._convert_kwargs_to_eval_input(response=filtered_response, context=context, query=query)
filtered_response = (
self._filter_file_search_results(response)
if self._validate_context(context)
else response
)
return super()._convert_kwargs_to_eval_input(
response=filtered_response, context=context, query=query
)

def _filter_file_search_results(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def _filter_file_search_results(
self, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Filter out file_search tool results from the messages."""
file_search_ids = self._get_file_search_tool_call_ids(messages)
return [
msg for msg in messages if not (msg.get("role") == "tool" and msg.get("tool_call_id") in file_search_ids)
msg
for msg in messages
if not (
msg.get("role") == "tool" and msg.get("tool_call_id") in file_search_ids
)
]

def _get_context_from_agent_response(self, response, tool_definitions):
Expand All @@ -405,14 +429,20 @@ def _get_context_from_agent_response(self, response, tool_definitions):
try:
logger.debug("Extracting context from response")
tool_calls = self._parse_tools_from_response(response=response)
logger.debug("Tool calls parsed successfully: count=%d", len(tool_calls) if tool_calls else 0)
logger.debug(
"Tool calls parsed successfully: count=%d",
len(tool_calls) if tool_calls else 0,
)

if not tool_calls:
return NO_CONTEXT

context_lines = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict) or tool_call.get("type") != "tool_call":
if (
not isinstance(tool_call, dict)
or tool_call.get("type") != "tool_call"
):
continue

tool_name = tool_call.get("name")
Expand Down Expand Up @@ -441,4 +471,8 @@ def _get_context_from_agent_response(self, response, tool_definitions):
def _get_file_search_tool_call_ids(self, query_or_response):
"""Return a list of tool_call_ids for file search tool calls."""
tool_calls = self._parse_tools_from_response(query_or_response)
return [tc.get("tool_call_id") for tc in tool_calls if tc.get("name") == "file_search"]
return [
tc.get("tool_call_id")
for tc in tool_calls
if tc.get("name") == "file_search"
]
Loading
Loading