Files
mlflow--mlflow/tests/cli/test_genai_eval_utils.py
2026-07-13 13:22:34 +08:00

478 lines
14 KiB
Python

from unittest import mock
import click
import pandas as pd
import pytest
from mlflow.cli.genai_eval_utils import (
NA_VALUE,
Assessment,
EvalResult,
extract_assessments_from_results,
format_table_output,
resolve_scorers,
)
from mlflow.exceptions import MlflowException
from mlflow.tracing.constant import AssessmentMetadataKey
def test_format_single_trace_with_result_and_rationale():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale="The answer is relevant",
)
],
)
]
table_output = format_table_output(output_data)
# Headers should use assessment names from output_data
assert table_output.headers == ["trace_id", "RelevanceToQuery"]
assert len(table_output.rows) == 1
assert table_output.rows[0][0].value == "tr-123"
assert "value: yes" in table_output.rows[0][1].value
assert "rationale: The answer is relevant" in table_output.rows[0][1].value
def test_format_multiple_traces_multiple_scorers():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale="Relevant",
),
Assessment(name="Safety", result="yes", rationale="Safe"),
],
),
EvalResult(
trace_id="tr-456",
assessments=[
Assessment(
name="RelevanceToQuery",
result="no",
rationale="Not relevant",
),
Assessment(name="Safety", result="yes", rationale="Safe"),
],
),
]
table_output = format_table_output(output_data)
# Assessment names should be sorted
assert table_output.headers == ["trace_id", "RelevanceToQuery", "Safety"]
assert len(table_output.rows) == 2
assert table_output.rows[0][0].value == "tr-123"
assert table_output.rows[1][0].value == "tr-456"
assert "value: yes" in table_output.rows[0][1].value
assert "value: no" in table_output.rows[1][1].value
def test_format_long_rationale_not_truncated():
long_rationale = "x" * 150
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale=long_rationale,
)
],
)
]
table_output = format_table_output(output_data)
assert long_rationale in table_output.rows[0][1].value
assert len(table_output.rows[0][1].value) >= len(long_rationale)
def test_format_error_message_formatting():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result=None,
rationale=None,
error="OpenAI API error",
)
],
)
]
table_output = format_table_output(output_data)
assert table_output.rows[0][1].value == "error: OpenAI API error"
def test_format_na_for_missing_results():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result=None,
rationale=None,
)
],
)
]
table_output = format_table_output(output_data)
assert table_output.rows[0][1].value == NA_VALUE
def test_format_result_only_without_rationale():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale=None,
)
],
)
]
table_output = format_table_output(output_data)
assert table_output.rows[0][1].value == "value: yes"
def test_format_rationale_only_without_result():
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="RelevanceToQuery",
result=None,
rationale="Some reasoning",
)
],
)
]
table_output = format_table_output(output_data)
assert table_output.rows[0][1].value == "rationale: Some reasoning"
def test_format_with_different_assessment_names():
# This test demonstrates that assessment names (e.g., "relevance_to_query")
# are used in headers, not scorer class names (e.g., "RelevanceToQuery")
output_data = [
EvalResult(
trace_id="tr-123",
assessments=[
Assessment(
name="relevance_to_query", # Different from scorer name
result="yes",
rationale="The answer is relevant",
),
Assessment(
name="safety_check", # Different from scorer name
result="safe",
rationale="Content is safe",
),
],
)
]
table_output = format_table_output(output_data)
# Headers should use actual assessment names from output_data (sorted)
assert table_output.headers == ["trace_id", "relevance_to_query", "safety_check"]
assert len(table_output.rows) == 1
assert table_output.rows[0][0].value == "tr-123"
assert "value: yes" in table_output.rows[0][1].value
assert "value: safe" in table_output.rows[0][2].value
# Tests for resolve_scorers function
def test_resolve_builtin_scorer():
# Test with real built-in scorer names
scorers = resolve_scorers(["Correctness"], "experiment_123")
assert len(scorers) == 1
assert scorers[0].__class__.__name__ == "Correctness"
def test_resolve_builtin_scorer_snake_case():
# Test with snake_case name
scorers = resolve_scorers(["correctness"], "experiment_123")
assert len(scorers) == 1
assert scorers[0].__class__.__name__ == "Correctness"
def test_resolve_registered_scorer():
mock_registered = mock.Mock()
with (
mock.patch(
"mlflow.cli.genai_eval_utils.get_all_scorers", return_value=[]
) as mock_get_all_scorers,
mock.patch(
"mlflow.cli.genai_eval_utils.get_scorer", return_value=mock_registered
) as mock_get_scorer,
):
scorers = resolve_scorers(["CustomScorer"], "experiment_123")
assert len(scorers) == 1
assert scorers[0] == mock_registered
# Verify mocks were called as expected
mock_get_all_scorers.assert_called_once()
mock_get_scorer.assert_called_once_with(name="CustomScorer", experiment_id="experiment_123")
def test_resolve_mixed_scorers():
# Setup built-in scorer
mock_builtin = mock.Mock()
mock_builtin.__class__.__name__ = "Safety"
mock_builtin.name = None
# Setup registered scorer
mock_registered = mock.Mock()
with (
mock.patch(
"mlflow.cli.genai_eval_utils.get_all_scorers", return_value=[mock_builtin]
) as mock_get_all_scorers,
mock.patch(
"mlflow.cli.genai_eval_utils.get_scorer", return_value=mock_registered
) as mock_get_scorer,
):
scorers = resolve_scorers(["Safety", "CustomScorer"], "experiment_123")
assert len(scorers) == 2
assert scorers[0] == mock_builtin
assert scorers[1] == mock_registered
# Verify mocks were called as expected
mock_get_all_scorers.assert_called_once()
mock_get_scorer.assert_called_once_with(name="CustomScorer", experiment_id="experiment_123")
def test_resolve_scorer_not_found_raises_error():
with (
mock.patch(
"mlflow.cli.genai_eval_utils.get_all_scorers", return_value=[]
) as mock_get_all_scorers,
mock.patch(
"mlflow.cli.genai_eval_utils.get_scorer",
side_effect=MlflowException("Not found"),
) as mock_get_scorer,
):
with pytest.raises(click.UsageError, match="Could not identify Scorer 'UnknownScorer'"):
resolve_scorers(["UnknownScorer"], "experiment_123")
# Verify mocks were called as expected
mock_get_all_scorers.assert_called_once()
mock_get_scorer.assert_called_once_with(
name="UnknownScorer", experiment_id="experiment_123"
)
def test_resolve_empty_scorers_raises_error():
with pytest.raises(click.UsageError, match="No valid scorers"):
resolve_scorers([], "experiment_123")
# Tests for extract_assessments_from_results function
def test_extract_with_matching_run_id():
results_df = pd.DataFrame([
{
"trace_id": "tr-abc123",
"assessments": [
{
"assessment_name": "RelevanceToQuery",
"feedback": {"value": "yes"},
"rationale": "The answer is relevant",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-123"},
}
],
}
])
result = extract_assessments_from_results(results_df, "run-123")
expected = [
EvalResult(
trace_id="tr-abc123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale="The answer is relevant",
)
],
)
]
assert result == expected
def test_extract_with_different_assessment_name():
results_df = pd.DataFrame([
{
"trace_id": "tr-abc123",
"assessments": [
{
"assessment_name": "relevance_to_query",
"feedback": {"value": "yes"},
"rationale": "Relevant answer",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-123"},
}
],
}
])
result = extract_assessments_from_results(results_df, "run-123")
expected = [
EvalResult(
trace_id="tr-abc123",
assessments=[
Assessment(
name="relevance_to_query",
result="yes",
rationale="Relevant answer",
)
],
)
]
assert result == expected
def test_extract_filter_out_assessments_with_different_run_id():
results_df = pd.DataFrame([
{
"trace_id": "tr-abc123",
"assessments": [
{
"assessment_name": "RelevanceToQuery",
"feedback": {"value": "yes"},
"rationale": "Current evaluation",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-123"},
},
{
"assessment_name": "Safety",
"feedback": {"value": "yes"},
"rationale": "Old evaluation",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-456"},
},
],
}
])
result = extract_assessments_from_results(results_df, "run-123")
expected = [
EvalResult(
trace_id="tr-abc123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale="Current evaluation",
)
],
)
]
assert result == expected
def test_extract_no_assessments_for_run_id():
results_df = pd.DataFrame([
{
"trace_id": "tr-abc123",
"assessments": [
{
"assessment_name": "RelevanceToQuery",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-456"},
}
],
}
])
result = extract_assessments_from_results(results_df, "run-123")
assert len(result) == 1
assert len(result[0].assessments) == 1
assert result[0].assessments[0].result is None
assert result[0].assessments[0].rationale is None
assert result[0].assessments[0].error is not None
def test_extract_multiple_assessments_from_same_run():
results_df = pd.DataFrame([
{
"trace_id": "tr-abc123",
"assessments": [
{
"assessment_name": "RelevanceToQuery",
"feedback": {"value": "yes"},
"rationale": "Relevant",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-123"},
},
{
"assessment_name": "Safety",
"feedback": {"value": "yes"},
"rationale": "Safe",
"metadata": {AssessmentMetadataKey.SOURCE_RUN_ID: "run-123"},
},
],
}
])
result = extract_assessments_from_results(results_df, "run-123")
expected = [
EvalResult(
trace_id="tr-abc123",
assessments=[
Assessment(
name="RelevanceToQuery",
result="yes",
rationale="Relevant",
),
Assessment(
name="Safety",
result="yes",
rationale="Safe",
),
],
)
]
assert result == expected
def test_extract_no_assessments_on_trace_shows_error():
results_df = pd.DataFrame([{"trace_id": "tr-abc123", "assessments": []}])
result = extract_assessments_from_results(results_df, "run-123")
assert len(result) == 1
assert len(result[0].assessments) == 1
assert result[0].assessments[0].error == "No assessments found on trace"