Files
2026-07-13 13:32:05 +08:00

354 lines
12 KiB
Python

import re
import warnings
from pydantic import (
BaseModel,
Field,
PrivateAttr,
model_validator,
AliasChoices,
)
from typing import List, Optional, Dict, Literal, Union
from copy import deepcopy
from enum import Enum
from deepeval.test_case import (
ToolCall,
ToolCallType,
MLLMImage,
RetrievedContextData,
)
from deepeval.test_case.mcp import (
MCPServer,
MCPPromptCall,
MCPResourceCall,
MCPToolCall,
validate_mcp_servers,
)
from deepeval.test_case.llm_test_case import _MLLM_IMAGE_REGISTRY
class MultiTurnParams(Enum):
ROLE = "role"
CONTENT = "content"
METADATA = "metadata"
TAGS = "tags"
SCENARIO = "scenario"
EXPECTED_OUTCOME = "expected_outcome"
CONTEXT = "context"
USER_DESCRIPTION = "user_description"
RETRIEVAL_CONTEXT = "retrieval_context"
CHATBOT_ROLE = "chatbot_role"
TOOLS_CALLED = "tools_called"
MCP_TOOLS = "mcp_tools_called"
MCP_RESOURCES = "mcp_resources_called"
MCP_PROMPTS = "mcp_prompts_called"
def __getattr__(name: str):
if name == "TurnParams":
warnings.warn(
"'TurnParams' is deprecated and will be removed in a future "
"release. Use 'MultiTurnParams' instead.",
DeprecationWarning,
stacklevel=2,
)
return MultiTurnParams
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class Turn(BaseModel):
role: Literal["user", "assistant"]
content: str
user_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("userId", "user_id")
)
retrieval_context: Optional[List[Union[str, RetrievedContextData]]] = Field(
default=None,
validation_alias=AliasChoices("retrievalContext", "retrieval_context"),
)
tools_called: Optional[List[ToolCall]] = Field(
default=None,
validation_alias=AliasChoices("toolsCalled", "tools_called"),
)
mcp_tools_called: Optional[List[MCPToolCall]] = Field(default=None)
mcp_resources_called: Optional[List[MCPResourceCall]] = Field(default=None)
mcp_prompts_called: Optional[List[MCPPromptCall]] = Field(default=None)
metadata: Optional[Dict] = Field(
default=None,
validation_alias=AliasChoices(
"metadata", "additionalMetadata", "additional_metadata"
),
)
@property
def additional_metadata(self) -> Optional[Dict]:
warnings.warn(
"'additional_metadata' is deprecated. Use 'metadata' instead.",
DeprecationWarning,
stacklevel=2,
)
return self.metadata
@additional_metadata.setter
def additional_metadata(self, value: Optional[Dict]):
warnings.warn(
"'additional_metadata' is deprecated. Use 'metadata' instead.",
DeprecationWarning,
stacklevel=2,
)
self.metadata = value
@property
def _mcp_tool_calls(self) -> List:
if self.mcp_tools_called is not None:
return self.mcp_tools_called
return [
tool
for tool in (self.tools_called or [])
if tool.type == ToolCallType.MCP
]
@property
def _mcp_interaction(self) -> bool:
has_mcp_tools = self.mcp_tools_called is not None or any(
tool.type == ToolCallType.MCP for tool in (self.tools_called or [])
)
return (
has_mcp_tools
or self.mcp_resources_called is not None
or self.mcp_prompts_called is not None
)
def __repr__(self):
attrs = [f"role={self.role!r}", f"content={self.content!r}"]
if self.user_id is not None:
attrs.append(f"user_id={self.user_id!r}")
if self.retrieval_context is not None:
attrs.append(f"retrieval_context={self.retrieval_context!r}")
if self.tools_called is not None:
attrs.append(f"tools_called={self.tools_called!r}")
if self.mcp_tools_called is not None:
attrs.append(f"mcp_tools_called={self.mcp_tools_called!r}")
if self.mcp_resources_called is not None:
attrs.append(f"mcp_resources_called={self.mcp_resources_called!r}")
if self.mcp_prompts_called is not None:
attrs.append(f"mcp_prompts_called={self.mcp_prompts_called!r}")
if self.metadata is not None:
attrs.append(f"metadata={self.metadata!r}")
return f"Turn({', '.join(attrs)})"
@model_validator(mode="before")
def validate_input(cls, data):
mcp_tools_called = data.get("mcp_tools_called")
mcp_prompts_called = data.get("mcp_prompts_called")
mcp_resources_called = data.get("mcp_resources_called")
if (
mcp_tools_called is not None
or mcp_prompts_called is not None
or mcp_resources_called is not None
):
from mcp.types import (
CallToolResult,
ReadResourceResult,
GetPromptResult,
)
if mcp_tools_called is not None:
if not isinstance(mcp_tools_called, list) or not all(
isinstance(tool_called, MCPToolCall)
and isinstance(tool_called.result, CallToolResult)
for tool_called in mcp_tools_called
):
raise TypeError(
"The 'tools_called' must be a list of 'MCPToolCall' with result of type 'CallToolResult' from mcp.types"
)
if mcp_resources_called is not None:
if not isinstance(mcp_resources_called, list) or not all(
isinstance(resource_called, MCPResourceCall)
and isinstance(resource_called.result, ReadResourceResult)
for resource_called in mcp_resources_called
):
raise TypeError(
"The 'resources_called' must be a list of 'MCPResourceCall' with result of type 'ReadResourceResult' from mcp.types"
)
if mcp_prompts_called is not None:
if not isinstance(mcp_prompts_called, list) or not all(
isinstance(prompt_called, MCPPromptCall)
and isinstance(prompt_called.result, GetPromptResult)
for prompt_called in mcp_prompts_called
):
raise TypeError(
"The 'prompts_called' must be a list of 'MCPPromptCall' with result of type 'GetPromptResult' from mcp.types"
)
return data
class ConversationalTestCase(BaseModel):
turns: List[Turn]
scenario: Optional[str] = Field(default=None)
context: Optional[List[str]] = Field(default=None)
name: Optional[str] = Field(default=None)
user_description: Optional[str] = Field(
default=None,
serialization_alias="userDescription",
validation_alias=AliasChoices("userDescription", "user_description"),
)
expected_outcome: Optional[str] = Field(
default=None,
serialization_alias="expectedOutcome",
validation_alias=AliasChoices("expectedOutcome", "expected_outcome"),
)
chatbot_role: Optional[str] = Field(
default=None,
serialization_alias="chatbotRole",
validation_alias=AliasChoices("chatbotRole", "chatbot_role"),
)
metadata: Optional[Dict] = Field(
default=None,
validation_alias=AliasChoices(
"metadata", "additionalMetadata", "additional_metadata"
),
)
comments: Optional[str] = Field(default=None)
tags: Optional[List[str]] = Field(default=None)
mcp_servers: Optional[List[MCPServer]] = Field(default=None)
multimodal: bool = False
_dataset_rank: Optional[int] = PrivateAttr(default=None)
_dataset_alias: Optional[str] = PrivateAttr(default=None)
_dataset_id: Optional[str] = PrivateAttr(default=None)
@property
def additional_metadata(self) -> Optional[Dict]:
warnings.warn(
"'additional_metadata' is deprecated. Use 'metadata' instead.",
DeprecationWarning,
stacklevel=2,
)
return self.metadata
@additional_metadata.setter
def additional_metadata(self, value: Optional[Dict]):
warnings.warn(
"'additional_metadata' is deprecated. Use 'metadata' instead.",
DeprecationWarning,
stacklevel=2,
)
self.metadata = value
@model_validator(mode="after")
def set_is_multimodal(self):
import re
if self.multimodal is True:
return self
pattern = r"\[DEEPEVAL:(?:IMAGE|PDF):(.*?)\]"
if self.scenario:
if re.search(pattern, self.scenario) is not None:
self.multimodal = True
return self
if self.expected_outcome:
if re.search(pattern, self.expected_outcome) is not None:
self.multimodal = True
return self
if self.user_description:
if re.search(pattern, self.user_description) is not None:
self.multimodal = True
return self
if self.turns:
for turn in self.turns:
if re.search(pattern, turn.content) is not None:
self.multimodal = True
return self
if turn.retrieval_context is not None:
self.multimodal = self.multimodal or any(
re.search(
pattern,
(
c.context
if isinstance(c, RetrievedContextData)
else c
),
)
for c in turn.retrieval_context
if isinstance(c, (RetrievedContextData, str))
)
return self
@model_validator(mode="before")
def validate_input(cls, data):
turns = data.get("turns")
context = data.get("context")
mcp_servers = data.get("mcp_servers")
if len(turns) == 0:
raise TypeError("'turns' must not be empty")
# Ensure `context` is None or a list of strings
if context is not None:
if not isinstance(context, list) or not all(
isinstance(item, (str, RetrievedContextData))
for item in context
):
raise TypeError(
"'context' must be None or a list of strings or RetrievedContextData"
)
if mcp_servers is not None:
validate_mcp_servers(mcp_servers)
copied_turns = []
for turn in turns:
if isinstance(turn, Turn):
copied_turns.append(deepcopy(turn))
elif isinstance(turn, dict):
try:
copied_turns.append(Turn.model_validate(turn))
except Exception as e:
raise TypeError(f"Invalid dict for Turn: {turn} ({e})")
else:
raise TypeError(
f"'turns' must be a list of Turn or dict, got {type(turn)}"
)
data["turns"] = copied_turns
return data
def _get_images_mapping(self) -> Dict[str, MLLMImage]:
pattern = r"\[DEEPEVAL:(?:IMAGE|PDF):(.*?)\]"
image_ids = set()
def extract_ids_from_string(s: Optional[str]) -> None:
"""Helper to extract image IDs from a string."""
if s is not None and isinstance(s, str):
matches = re.findall(pattern, s)
image_ids.update(matches)
def extract_ids_from_list(lst: Optional[List[str]]) -> None:
"""Helper to extract image IDs from a list of strings."""
if lst is not None:
for item in lst:
extract_ids_from_string(item)
extract_ids_from_string(self.scenario)
extract_ids_from_string(self.expected_outcome)
extract_ids_from_list(self.context)
extract_ids_from_string(self.user_description)
for turn in self.turns:
extract_ids_from_string(turn.content)
extract_ids_from_list(turn.retrieval_context)
images_mapping = {}
for img_id in image_ids:
if img_id in _MLLM_IMAGE_REGISTRY:
images_mapping[img_id] = _MLLM_IMAGE_REGISTRY[img_id]
return images_mapping if len(images_mapping) > 0 else None