372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Tests for ToolCallAccuracy metric."""
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from ragas.dataset_schema import MultiTurnSample
|
|
from ragas.messages import AIMessage, ToolCall
|
|
from ragas.metrics import ToolCallAccuracy
|
|
|
|
|
|
@pytest.fixture
|
|
def tool_call_accuracy():
|
|
"""Fixture providing ToolCallAccuracy instance."""
|
|
return ToolCallAccuracy()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_callbacks():
|
|
"""Fixture providing mock callbacks."""
|
|
return AsyncMock()
|
|
|
|
|
|
class TestToolCallAccuracy:
|
|
"""Test cases for ToolCallAccuracy metric."""
|
|
|
|
def test_is_sequence_aligned_perfect_match(self, tool_call_accuracy):
|
|
"""Test sequence alignment with perfect match."""
|
|
pred_seq = ["func1", "func2", "func3"]
|
|
ref_seq = ["func1", "func2", "func3"]
|
|
assert tool_call_accuracy.is_sequence_aligned(pred_seq, ref_seq) is True
|
|
|
|
def test_is_sequence_aligned_different_order(self, tool_call_accuracy):
|
|
"""Test sequence alignment with different order."""
|
|
pred_seq = ["func1", "func3", "func2"]
|
|
ref_seq = ["func1", "func2", "func3"]
|
|
assert tool_call_accuracy.is_sequence_aligned(pred_seq, ref_seq) is False
|
|
|
|
def test_is_sequence_aligned_different_length(self, tool_call_accuracy):
|
|
"""Test sequence alignment with different lengths."""
|
|
pred_seq = ["func1", "func2"]
|
|
ref_seq = ["func1", "func2", "func3"]
|
|
assert tool_call_accuracy.is_sequence_aligned(pred_seq, ref_seq) is False
|
|
|
|
def test_is_sequence_aligned_empty_sequences(self, tool_call_accuracy):
|
|
"""Test sequence alignment with empty sequences."""
|
|
assert tool_call_accuracy.is_sequence_aligned([], []) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_perfect_match_scenario(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test perfect match scenario with identical tool calls."""
|
|
# Create reference tool calls
|
|
ref_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
# Create predicted tool calls
|
|
pred_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
# Create sample
|
|
sample = MultiTurnSample(
|
|
user_input=[
|
|
AIMessage(content="I'll search for you", tool_calls=pred_tool_calls)
|
|
],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
# Mock the arg comparison to return 1.0 for perfect matches
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
return_value=1.0
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 1.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_predicted_tool_calls(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case with no predicted tool calls."""
|
|
ref_tool_calls = [ToolCall(name="search", args={"query": "python"})]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="No tool calls here")],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
with pytest.warns(UserWarning, match="No tool calls found"):
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sequence_misalignment(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case where sequences don't align."""
|
|
ref_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
# Different order - should result in score 0 due to sequence misalignment
|
|
pred_tool_calls = [
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="Searching...", tool_calls=pred_tool_calls)],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
return_value=1.0
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_length_mismatch_more_predicted(
|
|
self, tool_call_accuracy, mock_callbacks
|
|
):
|
|
"""Test case with more predicted tool calls than reference."""
|
|
ref_tool_calls = [ToolCall(name="search", args={"query": "python"})]
|
|
|
|
pred_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="Searching...", tool_calls=pred_tool_calls)],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
return_value=1.0
|
|
)
|
|
|
|
with pytest.warns(UserWarning, match="Length mismatch"):
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
|
|
# Should be 0 because sequences don't align (different lengths)
|
|
assert score == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_length_mismatch_fewer_predicted(
|
|
self, tool_call_accuracy, mock_callbacks
|
|
):
|
|
"""Test case with fewer predicted tool calls than reference."""
|
|
ref_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
pred_tool_calls = [ToolCall(name="search", args={"query": "python"})]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="Searching...", tool_calls=pred_tool_calls)],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
return_value=1.0
|
|
)
|
|
|
|
with pytest.warns(UserWarning, match="Length mismatch"):
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
|
|
# Should be 0 because sequences don't align (different lengths)
|
|
assert score == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_argument_match(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case with partial argument matches."""
|
|
ref_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python", "limit": 10}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
pred_tool_calls = [
|
|
ToolCall(
|
|
name="search", args={"query": "python", "limit": 5}
|
|
), # Wrong limit
|
|
ToolCall(name="filter", args={"type": "recent"}), # Perfect match
|
|
]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="Searching...", tool_calls=pred_tool_calls)],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
# Mock to return scores based on the argument comparison
|
|
# For the "search" tool call: we need to call for each argument
|
|
# For "python" vs "python": 1.0, for 5 vs 10: 0.0 -> average = 0.5
|
|
# For the "filter" tool call: "recent" vs "recent": 1.0 -> average = 1.0
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
side_effect=[1.0, 0.0, 1.0] # query match, limit mismatch, type match
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 0.75 # (0.5 + 1.0) / 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_tool_names(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case with wrong tool names."""
|
|
ref_tool_calls = [ToolCall(name="search", args={"query": "python"})]
|
|
|
|
pred_tool_calls = [ToolCall(name="wrong_tool", args={"query": "python"})]
|
|
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="Searching...", tool_calls=pred_tool_calls)],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 0.0 # Wrong tool name should result in 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_ai_messages(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case with multiple AI messages containing tool calls."""
|
|
ref_tool_calls = [
|
|
ToolCall(name="search", args={"query": "python"}),
|
|
ToolCall(name="filter", args={"type": "recent"}),
|
|
]
|
|
|
|
# Tool calls spread across multiple messages
|
|
sample = MultiTurnSample(
|
|
user_input=[
|
|
AIMessage(
|
|
content="First",
|
|
tool_calls=[ToolCall(name="search", args={"query": "python"})],
|
|
),
|
|
AIMessage(
|
|
content="Second",
|
|
tool_calls=[ToolCall(name="filter", args={"type": "recent"})],
|
|
),
|
|
],
|
|
reference_tool_calls=ref_tool_calls,
|
|
)
|
|
|
|
tool_call_accuracy.arg_comparison_metric.single_turn_ascore = AsyncMock(
|
|
return_value=1.0
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 1.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_reference_tool_calls(self, tool_call_accuracy, mock_callbacks):
|
|
"""Test case with empty reference tool calls and no predictions."""
|
|
sample = MultiTurnSample(
|
|
user_input=[AIMessage(content="No tools needed")],
|
|
reference_tool_calls=[],
|
|
)
|
|
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 1.0 # Both empty should be perfect match
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_reference_with_predictions(
|
|
self, tool_call_accuracy, mock_callbacks
|
|
):
|
|
"""Test case with empty reference but predictions exist."""
|
|
sample = MultiTurnSample(
|
|
user_input=[
|
|
AIMessage(
|
|
content="Calling tool",
|
|
tool_calls=[ToolCall(name="unexpected", args={})],
|
|
)
|
|
],
|
|
reference_tool_calls=[],
|
|
)
|
|
|
|
with pytest.warns(UserWarning, match="Reference tool calls are empty"):
|
|
score = await tool_call_accuracy._multi_turn_ascore(sample, mock_callbacks)
|
|
assert score == 0.0
|
|
|
|
def test_metric_name(self, tool_call_accuracy):
|
|
"""Test that metric has correct name."""
|
|
assert tool_call_accuracy.name == "tool_call_accuracy"
|
|
|
|
def test_required_columns(self, tool_call_accuracy):
|
|
"""Test that metric has correct required columns."""
|
|
from ragas.metrics.base import MetricType
|
|
|
|
required = tool_call_accuracy._required_columns[MetricType.MULTI_TURN]
|
|
assert "user_input" in required
|
|
assert "reference_tool_calls" in required
|
|
|
|
def test_strict_order_parameter_default(self):
|
|
"""Test that strict_order defaults to True for backward compatibility."""
|
|
metric = ToolCallAccuracy()
|
|
assert metric.strict_order is True
|
|
|
|
def test_strict_order_parameter_explicit(self):
|
|
"""Test explicit strict_order parameter setting."""
|
|
strict_metric = ToolCallAccuracy(strict_order=True)
|
|
flexible_metric = ToolCallAccuracy(strict_order=False)
|
|
|
|
assert strict_metric.strict_order is True
|
|
assert flexible_metric.strict_order is False
|
|
|
|
def test_is_sequence_aligned_flexible_mode(self):
|
|
"""Test sequence alignment with flexible ordering."""
|
|
flexible_metric = ToolCallAccuracy(strict_order=False)
|
|
|
|
pred_seq = ["func2", "func1", "func3"]
|
|
ref_seq = ["func1", "func2", "func3"]
|
|
|
|
# Flexible mode should return True for same elements in different order
|
|
assert flexible_metric.is_sequence_aligned(pred_seq, ref_seq) is True
|
|
|
|
# Strict mode should return False for different order
|
|
strict_metric = ToolCallAccuracy(strict_order=True)
|
|
assert strict_metric.is_sequence_aligned(pred_seq, ref_seq) is False
|
|
|
|
def test_flexible_order_sorting_behavior(self):
|
|
"""Test that flexible mode sorts tool calls before evaluation."""
|
|
|
|
# Test that tool calls get sorted when not in strict order mode
|
|
reference_calls = [
|
|
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
|
|
ToolCall(name="UVIndex", args={"location": "Paris"}),
|
|
]
|
|
|
|
predicted_calls = [
|
|
ToolCall(name="UVIndex", args={"location": "Paris"}),
|
|
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
|
|
]
|
|
|
|
# Test sequence alignment logic directly
|
|
strict_metric = ToolCallAccuracy(strict_order=True)
|
|
flexible_metric = ToolCallAccuracy(strict_order=False)
|
|
|
|
# Sequence names for comparison
|
|
pred_seq = [
|
|
call.name for call in predicted_calls
|
|
] # ["UVIndex", "WeatherForecast"]
|
|
ref_seq = [
|
|
call.name for call in reference_calls
|
|
] # ["WeatherForecast", "UVIndex"]
|
|
|
|
# Strict should fail on order
|
|
strict_aligned = strict_metric.is_sequence_aligned(pred_seq, ref_seq)
|
|
assert strict_aligned is False
|
|
|
|
# Flexible should pass (sorts both before comparing)
|
|
flexible_aligned = flexible_metric.is_sequence_aligned(pred_seq, ref_seq)
|
|
assert flexible_aligned is True
|
|
|
|
def test_sorted_key_for_tool_call(self):
|
|
"""Test the sorting key generation for tool calls."""
|
|
tool_call_1 = ToolCall(
|
|
name="WeatherForecast", args={"location": "Paris", "units": "metric"}
|
|
)
|
|
tool_call_2 = ToolCall(
|
|
name="WeatherForecast", args={"units": "metric", "location": "Paris"}
|
|
)
|
|
|
|
key_1 = ToolCallAccuracy._sorted_key_for_tool_call(tool_call_1)
|
|
key_2 = ToolCallAccuracy._sorted_key_for_tool_call(tool_call_2)
|
|
|
|
# Same content with different arg order should produce same key
|
|
assert key_1 == key_2
|
|
|
|
# Different tool call should produce different key
|
|
different_call = ToolCall(name="UVIndex", args={"location": "Paris"})
|
|
key_3 = ToolCallAccuracy._sorted_key_for_tool_call(different_call)
|
|
assert key_1 != key_3
|