1. New Eval SDK (`arcade/sdk/eval.py`): - Introduces `EvalSuite`, `EvalCase`, and `EvalRubric` classes for structured evaluation. - Implements various Critic classes (Binary, Numeric, Similarity) for flexible scoring. - Adds a `tool_eval` decorator for easy integration with existing tools. 2. CLI Integration (`arcade/cli/main.py` and `arcade/cli/utils.py`): - Adds an `evals` command to run evaluation suites from the CLI. - Implements result display functionality for evaluation outcomes. 3. Toolkit Updates: - Adds evaluation scripts for Gmail ([toolkits/gmail/evals/eval_gmail_tools.py](file:///Users/spartee/Dropbox/Arcade/platform/Team/arcade-ai/toolkits/gmail/evals/eval_gmail_tools.py#1%2C1-1%2C1)) and Slack ([toolkits/slack/evals/eval_slack_messaging.py](file:///Users/spartee/Dropbox/Arcade/platform/Team/arcade-ai/toolkits/slack/evals/eval_slack_messaging.py#1%2C1-1%2C1)) toolkits. - Demonstrates practical usage of the Eval SDK with real-world scenarios. 4. Miscellaneous: - Updates `arcade/cli/new.py` to optionally generate an `evals` directory for new toolkits. --------- Co-authored-by: Nate Barbettini <nate@arcade-ai.com>
342 lines
11 KiB
Python
342 lines
11 KiB
Python
import pytest
|
|
|
|
from arcade.sdk.error import WeightError
|
|
from arcade.sdk.eval import (
|
|
BinaryCritic,
|
|
EvalRubric,
|
|
ExpectedToolCall,
|
|
NumericCritic,
|
|
SimilarityCritic,
|
|
)
|
|
from arcade.sdk.eval.eval import EvalCase, EvaluationResult
|
|
|
|
# Test BinaryCritic.evaluate()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expected, actual, weight, expected_match, expected_score",
|
|
[
|
|
("value", "value", 1.0, True, 1.0),
|
|
("value", "different", 1.0, False, 0.0),
|
|
(10, 10, 0.5, True, 0.5),
|
|
(10, 20, 0.5, False, 0.0),
|
|
],
|
|
)
|
|
def test_binary_critic_evaluate(expected, actual, weight, expected_match, expected_score):
|
|
"""
|
|
Test the BinaryCritic's evaluate method to ensure it correctly computes
|
|
the match and score based on expected and actual values.
|
|
"""
|
|
critic = BinaryCritic(critic_field="test_field", weight=weight)
|
|
result = critic.evaluate(expected=expected, actual=actual)
|
|
assert result["match"] == expected_match
|
|
assert result["score"] == expected_score
|
|
|
|
|
|
# Test NumericCritic.evaluate()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expected, actual, value_range, weight, match_threshold, expected_match, expected_score",
|
|
[
|
|
(5, 5, (0, 10), 1.0, 0.8, True, 1.0),
|
|
(5, 6, (0, 10), 1.0, 0.8, True, 0.9),
|
|
(0, 10, (0, 10), 1.0, 0.8, False, 0.0),
|
|
(2, 8, (0, 10), 1.0, 0.5, False, 0.4),
|
|
(50, 60, (0, 100), 0.5, 0.9, True, 0.45),
|
|
],
|
|
)
|
|
def test_numeric_critic_evaluate(
|
|
expected, actual, value_range, weight, match_threshold, expected_match, expected_score
|
|
):
|
|
"""
|
|
Test the NumericCritic's evaluate method to ensure it calculates
|
|
the correct score based on the proportion of the difference between
|
|
expected and actual values within a specified range.
|
|
"""
|
|
critic = NumericCritic(
|
|
critic_field="number",
|
|
weight=weight,
|
|
value_range=value_range,
|
|
match_threshold=match_threshold,
|
|
)
|
|
result = critic.evaluate(expected=expected, actual=actual)
|
|
assert result["match"] == expected_match
|
|
assert pytest.approx(result["score"], 0.01) == expected_score
|
|
|
|
|
|
# Test SimilarityCritic.evaluate()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expected, actual, weight, similarity_threshold, expected_match, min_expected_score",
|
|
[
|
|
("hello world", "hello world", 1.0, 0.8, True, 1.0),
|
|
("hello world", "hello", 1.0, 0.8, False, 0.0),
|
|
("The quick brown fox", "The quick brown fox jumps over the lazy dog", 1.0, 0.5, True, 0.5),
|
|
("data science", "machine learning", 0.5, 0.3, False, 0.0),
|
|
],
|
|
)
|
|
def test_similarity_critic_evaluate(
|
|
expected, actual, weight, similarity_threshold, expected_match, min_expected_score
|
|
):
|
|
"""
|
|
Test the SimilarityCritic's evaluate method to ensure it computes
|
|
the similarity score between expected and actual strings and determines
|
|
the match correctly based on the similarity threshold.
|
|
"""
|
|
critic = SimilarityCritic(
|
|
critic_field="text",
|
|
weight=weight,
|
|
similarity_threshold=similarity_threshold,
|
|
)
|
|
result = critic.evaluate(expected=expected, actual=actual)
|
|
assert result["match"] == expected_match
|
|
assert result["score"] >= min_expected_score
|
|
assert result["score"] >= 0.0
|
|
assert result["score"] <= weight + 1e-6 # Allow a small epsilon for floating-point comparison
|
|
|
|
|
|
# Test EvaluationResult accumulation and pass/fail logic
|
|
|
|
|
|
def test_evaluation_result_accumulation():
|
|
"""
|
|
Test that EvaluationResult correctly accumulates scores and determines
|
|
pass/fail status based on thresholds.
|
|
"""
|
|
evaluation = EvaluationResult()
|
|
evaluation.add(
|
|
field="field1",
|
|
result={"match": True, "score": 0.8},
|
|
weight=1.0,
|
|
expected="expected_value",
|
|
actual="actual_value",
|
|
)
|
|
evaluation.add(
|
|
field="field2",
|
|
result={"match": False, "score": 0.0},
|
|
weight=0.5,
|
|
expected="expected_value",
|
|
actual="actual_value",
|
|
)
|
|
total_weight = 1.5
|
|
expected_score = (0.8 * 1.0 + 0.0 * 0.5) / total_weight
|
|
evaluation.compute_final_score(total_weight)
|
|
assert evaluation.score == expected_score
|
|
|
|
|
|
# Test EvalCase.evaluate()
|
|
|
|
|
|
def test_eval_case_evaluate():
|
|
"""
|
|
Test EvalCase's evaluate method to ensure it calculates the overall score
|
|
correctly based on tool selection and critics, and applies the rubric's
|
|
thresholds to determine pass/fail/warning status.
|
|
"""
|
|
# Define expected tool calls and actual tool calls
|
|
expected_tool_calls = [
|
|
ExpectedToolCall(name="ToolA", args={"param": "value1"}),
|
|
ExpectedToolCall(name="ToolB", args={"param": "value2"}),
|
|
]
|
|
actual_tool_calls = [
|
|
("ToolA", {"param": "value1"}),
|
|
("ToolB", {"param": "wrong_value"}),
|
|
]
|
|
|
|
# Define critics
|
|
critics = [
|
|
BinaryCritic(critic_field="param", weight=1.0),
|
|
]
|
|
|
|
# Create EvalCase with a rubric
|
|
case = EvalCase(
|
|
name="TestCase",
|
|
system_message="System message",
|
|
user_message="User message",
|
|
expected_tool_calls=expected_tool_calls,
|
|
critics=critics,
|
|
rubric=EvalRubric(fail_threshold=0.75, warn_threshold=0.9, tool_selection_weight=1.0),
|
|
)
|
|
|
|
# Evaluate the case
|
|
result = case.evaluate(actual_tool_calls)
|
|
|
|
# Expected calculations:
|
|
# - Tool selection score should be 2 * 1.0 = 2.0 (both tools are correct)
|
|
# - First critic score: match (1.0)
|
|
# - Second critic score: no match (0.0)
|
|
# - Total critic score: 1.0 + 0.0 = 1.0
|
|
# - Total weight: tool selection (2.0) + critics (2.0) = 4.0
|
|
# - Total score: (2.0 + 1.0) / 4.0 = 0.75
|
|
|
|
assert result.score == 0.75
|
|
assert result.passed is True
|
|
|
|
|
|
# Test EvalCase with mismatched tool calls
|
|
|
|
|
|
def test_eval_case_evaluate_mismatched_tools():
|
|
"""
|
|
Test EvalCase's evaluate method when the actual tool calls do not match
|
|
the expected tool calls to ensure tool selection scoring is correct.
|
|
"""
|
|
expected_tool_calls = [
|
|
ExpectedToolCall(name="ToolA", args={"param": "value"}),
|
|
]
|
|
actual_tool_calls = [
|
|
("ToolB", {"param": "value"}),
|
|
]
|
|
|
|
critics = [BinaryCritic(critic_field="param", weight=1.0)]
|
|
|
|
case = EvalCase(
|
|
name="TestCase",
|
|
system_message="",
|
|
user_message="",
|
|
expected_tool_calls=expected_tool_calls,
|
|
critics=critics,
|
|
rubric=EvalRubric(tool_selection_weight=1.0),
|
|
)
|
|
|
|
result = case.evaluate(actual_tool_calls)
|
|
|
|
# Tool selection score should be 0.0 since the tools don't match
|
|
# Critic is not evaluated since the tool selection failed
|
|
# Total score: 0.0
|
|
|
|
assert result.score == 0.0
|
|
assert result.passed is False
|
|
|
|
|
|
# Test EvalCase with multiple critics and weights
|
|
|
|
|
|
def test_eval_case_multiple_critics():
|
|
"""
|
|
Test EvalCase's evaluate method with multiple critics having different weights
|
|
to ensure individual critic scores are correctly combined into the total score.
|
|
"""
|
|
expected_tool_calls = [
|
|
ExpectedToolCall(name="ToolA", args={"param1": "value1", "param2": "value2"}),
|
|
]
|
|
actual_tool_calls = [
|
|
("ToolA", {"param1": "value1", "param2": "wrong_value"}),
|
|
]
|
|
|
|
critics = [
|
|
BinaryCritic(critic_field="param1", weight=0.6),
|
|
SimilarityCritic(critic_field="param2", weight=0.4, similarity_threshold=0.8),
|
|
]
|
|
|
|
case = EvalCase(
|
|
name="TestCase",
|
|
system_message="",
|
|
user_message="",
|
|
expected_tool_calls=expected_tool_calls,
|
|
critics=critics,
|
|
rubric=EvalRubric(fail_threshold=0.7),
|
|
)
|
|
|
|
result = case.evaluate(actual_tool_calls)
|
|
|
|
# Tool selection score: 1.0
|
|
# Critic scores:
|
|
# - param1: match (score 0.6)
|
|
# - param2: likely not match (score ~0.0)
|
|
# Total score: (1.0 + 0.6 + 0.0) / (1.0 + 0.6 + 0.4) = 1.6 / 2.0 = 0.8
|
|
|
|
assert pytest.approx(result.score, 0.01) == 0.8
|
|
assert result.passed
|
|
|
|
|
|
# Test EvalCase with missing expected and actual values in args
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expected_args, actual_args, expected_score",
|
|
[
|
|
({"param": "value"}, {}, 1.0), # Missing actual value
|
|
({}, {"param": "value"}, 1.0), # Missing expected value
|
|
({"param": "value"}, {"param": "value"}, 2.0), # Both values present
|
|
],
|
|
)
|
|
def test_eval_case_missing_values(expected_args, actual_args, expected_score):
|
|
"""
|
|
Test that when either expected or actual values are missing for a critic,
|
|
the critic evaluation is skipped, and the total score is computed accordingly.
|
|
"""
|
|
expected_tool_calls = [ExpectedToolCall(name="ToolA", args=expected_args)]
|
|
actual_tool_calls = [("ToolA", actual_args)]
|
|
|
|
critics = [BinaryCritic(critic_field="param", weight=1.0)]
|
|
|
|
case = EvalCase(
|
|
name="TestCase",
|
|
system_message="",
|
|
user_message="",
|
|
expected_tool_calls=expected_tool_calls,
|
|
critics=critics,
|
|
rubric=EvalRubric(tool_selection_weight=1.0),
|
|
)
|
|
|
|
result = case.evaluate(actual_tool_calls)
|
|
|
|
# If critic is skipped, only tool selection score is counted
|
|
# Otherwise, tool selection + critic score
|
|
total_weight = 1.0 # At least tool selection weight
|
|
if "param" in expected_args and "param" in actual_args:
|
|
total_weight += 1.0 # Critic weight
|
|
|
|
expected_total_score = expected_score / total_weight
|
|
|
|
assert result.score == expected_total_score
|
|
|
|
|
|
# Test that WeightError is raised for invalid critic weights
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"critic_class, weight",
|
|
[
|
|
(BinaryCritic, -0.1),
|
|
(BinaryCritic, 1.1),
|
|
(NumericCritic, -0.5),
|
|
(SimilarityCritic, 1.5),
|
|
],
|
|
)
|
|
def test_critic_invalid_weight(critic_class, weight):
|
|
"""
|
|
Test that initializing a critic with an invalid weight raises a WeightError.
|
|
"""
|
|
with pytest.raises(WeightError):
|
|
if critic_class == NumericCritic:
|
|
critic_class(critic_field="test_field", weight=weight, value_range=(0, 1))
|
|
elif critic_class == SimilarityCritic:
|
|
critic_class(critic_field="test_field", weight=weight)
|
|
else:
|
|
critic_class(critic_field="test_field", weight=weight)
|
|
|
|
|
|
# Test NumericCritic with invalid value range
|
|
|
|
|
|
def test_numeric_critic_invalid_range():
|
|
"""
|
|
Test that initializing a NumericCritic with an invalid value range raises a ValueError.
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
NumericCritic(critic_field="number", weight=1.0, value_range=(10, 0)) # Invalid range
|
|
|
|
|
|
# Test SimilarityCritic with unsupported metric
|
|
|
|
|
|
def test_similarity_critic_unsupported_metric():
|
|
"""
|
|
Test that initializing a SimilarityCritic with an unsupported metric raises a ValueError.
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
SimilarityCritic(critic_field="text", weight=1.0, metric="unsupported_metric")
|