Files
vibrantlabsai--ragas/tests/unit/test_tool_call_f1_collections.py
2026-07-13 13:35:10 +08:00

398 lines
13 KiB
Python

"""Tests for ToolCallF1 metric (collections implementation)."""
import pytest
from ragas.messages import AIMessage, HumanMessage, ToolCall
from ragas.metrics.collections.tool_call_f1 import ToolCallF1
@pytest.fixture
def tool_call_f1():
"""Fixture providing ToolCallF1 instance."""
return ToolCallF1()
class TestToolCallF1Collections:
"""Test cases for ToolCallF1 metric from collections."""
@pytest.mark.asyncio
async def test_perfect_match(self, tool_call_f1):
"""Test perfect match scenario with identical tool calls."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="What is the weather in Paris?"),
AIMessage(
content="Let me check the weather forecast",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"})
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
assert result.value == 1.0
@pytest.mark.asyncio
async def test_partial_match_missing_prediction(self, tool_call_f1):
"""Test case where prediction has fewer tool calls than reference."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="UVIndex", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather info please"),
AIMessage(
content="Checking",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"})
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# TP=1, FP=0, FN=1 -> Precision=1.0, Recall=0.5, F1=0.67
assert round(result.value, 2) == 0.67
@pytest.mark.asyncio
async def test_partial_match_extra_prediction(self, tool_call_f1):
"""Test case where prediction has more tool calls than reference."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather info"),
AIMessage(
content="Getting info",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="AirQuality", args={"location": "Paris"}),
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# TP=1, FP=1, FN=0 -> Precision=0.5, Recall=1.0, F1=0.67
assert round(result.value, 2) == 0.67
@pytest.mark.asyncio
async def test_no_match(self, tool_call_f1):
"""Test case with no matching tool calls."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather"),
AIMessage(
content="Getting data",
tool_calls=[ToolCall(name="AirQuality", args={"location": "Paris"})],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# TP=0, FP=1, FN=1 -> F1=0.0
assert result.value == 0.0
@pytest.mark.asyncio
async def test_multiple_messages(self, tool_call_f1):
"""Test with tool calls spread across multiple messages."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="UVIndex", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Get weather and UV info"),
AIMessage(
content="Getting weather",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"})
],
),
AIMessage(
content="Getting UV",
tool_calls=[ToolCall(name="UVIndex", args={"location": "Paris"})],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
assert result.value == 1.0
@pytest.mark.asyncio
async def test_both_empty(self, tool_call_f1):
"""Test case with no tool calls in both predicted and reference."""
user_input = [
HumanMessage(content="Hello"),
AIMessage(content="Hi there"),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=[],
)
# No predictions, no references -> F1=0.0
assert result.value == 0.0
@pytest.mark.asyncio
async def test_only_predicted_no_reference(self, tool_call_f1):
"""Test case with predicted tool calls but no reference."""
user_input = [
HumanMessage(content="Weather"),
AIMessage(
content="Checking",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"})
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=[],
)
# TP=0, FP=1, FN=0 -> Precision=0.0 -> F1=0.0
assert result.value == 0.0
@pytest.mark.asyncio
async def test_only_reference_no_predicted(self, tool_call_f1):
"""Test case with reference tool calls but no predictions."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather"),
AIMessage(content="I don't know"),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# TP=0, FP=0, FN=1 -> Recall=0.0 -> F1=0.0
assert result.value == 0.0
@pytest.mark.asyncio
async def test_argument_mismatch(self, tool_call_f1):
"""Test case where tool names match but arguments differ."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather"),
AIMessage(
content="Checking",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "London"})
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# Different arguments means no match -> TP=0, FP=1, FN=1 -> F1=0.0
assert result.value == 0.0
@pytest.mark.asyncio
async def test_duplicate_tool_calls_in_prediction(self, tool_call_f1):
"""Test case with duplicate tool calls in prediction."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Weather"),
AIMessage(
content="Checking multiple times",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# Sets will deduplicate, so TP=1, FP=0, FN=0 -> F1=1.0
assert result.value == 1.0
@pytest.mark.asyncio
async def test_complex_scenario(self, tool_call_f1):
"""Test complex scenario with multiple correct and incorrect calls."""
ref_tool_calls = [
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="UVIndex", args={"location": "Paris"}),
ToolCall(name="AirQuality", args={"location": "Paris"}),
]
user_input = [
HumanMessage(content="Get all environmental data"),
AIMessage(
content="Fetching data",
tool_calls=[
ToolCall(name="WeatherForecast", args={"location": "Paris"}),
ToolCall(name="UVIndex", args={"location": "Paris"}),
ToolCall(name="Humidity", args={"location": "Paris"}),
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
# TP=2 (Weather, UV), FP=1 (Humidity), FN=1 (AirQuality)
# Precision=2/3, Recall=2/3, F1=2/3=0.6667
assert round(result.value, 2) == 0.67
@pytest.mark.asyncio
async def test_input_validation(self, tool_call_f1):
"""Test input validation."""
with pytest.raises(ValueError, match="user_input must be a list"):
await tool_call_f1.ascore(
user_input="not a list",
reference_tool_calls=[],
)
with pytest.raises(ValueError, match="reference_tool_calls must be a list"):
await tool_call_f1.ascore(
user_input=[],
reference_tool_calls="not a list",
)
@pytest.mark.asyncio
async def test_nested_dict_in_args(self, tool_call_f1):
"""Test handling of nested dicts in tool call args (issue #2506)."""
ref_tool_calls = [
ToolCall(
name="store_data",
args={
"title": "Backend Engineer",
"kwargs": {}, # Nested empty dict
},
),
]
user_input = [
HumanMessage(content="Store the data"),
AIMessage(
content="Storing...",
tool_calls=[
ToolCall(
name="store_data",
args={
"title": "Backend Engineer",
"kwargs": {},
},
)
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
assert result.value == 1.0
@pytest.mark.asyncio
async def test_nested_list_in_args(self, tool_call_f1):
"""Test handling of nested lists in tool call args."""
ref_tool_calls = [
ToolCall(
name="search",
args={
"categories": ["a", "b"],
"filters": {"min": 10, "max": 100},
},
),
]
user_input = [
HumanMessage(content="Search"),
AIMessage(
content="Searching...",
tool_calls=[
ToolCall(
name="search",
args={
"categories": ["a", "b"],
"filters": {"min": 10, "max": 100},
},
)
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
assert result.value == 1.0
@pytest.mark.asyncio
async def test_deeply_nested_args(self, tool_call_f1):
"""Test handling of deeply nested structures in tool call args."""
ref_tool_calls = [
ToolCall(
name="complex_tool",
args={
"level1": {
"level2": {
"level3": ["x", "y", "z"],
}
}
},
),
]
user_input = [
HumanMessage(content="Do something"),
AIMessage(
content="Processing...",
tool_calls=[
ToolCall(
name="complex_tool",
args={
"level1": {
"level2": {
"level3": ["x", "y", "z"],
}
}
},
)
],
),
]
result = await tool_call_f1.ascore(
user_input=user_input,
reference_tool_calls=ref_tool_calls,
)
assert result.value == 1.0