Files
2026-07-13 13:22:34 +08:00

564 lines
19 KiB
Python

from unittest.mock import Mock, patch
import pytest
import mlflow
from mlflow.entities import TraceData, TraceInfo, TraceLocation, TraceState
from mlflow.entities.assessment import Feedback
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
from mlflow.entities.trace import Trace
from mlflow.exceptions import MlflowException
from mlflow.genai import scorer
from mlflow.genai.evaluation.entities import EvalItem
from mlflow.genai.evaluation.session_utils import (
classify_scorers,
evaluate_session_level_scorers,
get_first_trace_in_session,
group_traces_by_session,
validate_session_level_evaluation_inputs,
)
from mlflow.tracing.constant import TraceMetadataKey
class _MultiTurnTestScorer:
"""Helper class for testing multi-turn scorers."""
def __init__(self, name="test_multi_turn_scorer"):
self.name = name
self.is_session_level_scorer = True
self.aggregations = []
def run(self, session=None, **kwargs):
return True
def __call__(self, traces=None, **kwargs):
return 1.0
# ==================== Tests for classify_scorers ====================
def test_classify_scorers_all_single_turn():
@scorer
def custom_scorer1(outputs):
return 1.0
@scorer
def custom_scorer2(outputs):
return 2.0
scorers_list = [custom_scorer1, custom_scorer2]
single_turn, multi_turn = classify_scorers(scorers_list)
assert len(single_turn) == 2
assert len(multi_turn) == 0
assert single_turn == scorers_list
def test_classify_scorers_all_multi_turn():
multi_turn_scorer1 = _MultiTurnTestScorer(name="multi_turn_scorer1")
multi_turn_scorer2 = _MultiTurnTestScorer(name="multi_turn_scorer2")
scorers_list = [multi_turn_scorer1, multi_turn_scorer2]
single_turn, multi_turn = classify_scorers(scorers_list)
assert len(single_turn) == 0
assert len(multi_turn) == 2
assert multi_turn == scorers_list
# Verify they are actually multi-turn
assert multi_turn_scorer1.is_session_level_scorer is True
assert multi_turn_scorer2.is_session_level_scorer is True
def test_classify_scorers_mixed():
@scorer
def single_turn_scorer(outputs):
return 1.0
multi_turn_scorer = _MultiTurnTestScorer(name="multi_turn_scorer")
scorers_list = [single_turn_scorer, multi_turn_scorer]
single_turn, multi_turn = classify_scorers(scorers_list)
assert len(single_turn) == 1
assert len(multi_turn) == 1
assert single_turn[0] == single_turn_scorer
assert multi_turn[0] == multi_turn_scorer
# Verify properties
assert single_turn_scorer.is_session_level_scorer is False
assert multi_turn_scorer.is_session_level_scorer is True
def test_classify_scorers_empty_list():
single_turn, multi_turn = classify_scorers([])
assert len(single_turn) == 0
assert len(multi_turn) == 0
# ==================== Tests for group_traces_by_session ====================
def _create_mock_trace(trace_id: str, session_id: str | None, request_time: int):
"""Helper to create a mock trace with session_id and request_time."""
trace_metadata = {}
if session_id is not None:
trace_metadata[TraceMetadataKey.TRACE_SESSION] = session_id
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=TraceLocation.from_experiment_id("0"),
request_time=request_time,
execution_duration=1000,
state=TraceState.OK,
trace_metadata=trace_metadata,
tags={},
)
trace = Mock(spec=Trace)
trace.info = trace_info
trace.data = TraceData(spans=[])
return trace
def _create_mock_eval_item(trace):
"""Helper to create a mock EvalItem with a trace."""
eval_item = Mock(spec=EvalItem)
eval_item.trace = trace
eval_item.source = None # Explicitly set to None so it doesn't return a Mock
return eval_item
def test_group_traces_by_session_single_session():
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
trace2 = _create_mock_trace("trace-2", "session-1", 2000)
trace3 = _create_mock_trace("trace-3", "session-1", 3000)
eval_item1 = _create_mock_eval_item(trace1)
eval_item2 = _create_mock_eval_item(trace2)
eval_item3 = _create_mock_eval_item(trace3)
eval_items = [eval_item1, eval_item2, eval_item3]
session_groups = group_traces_by_session(eval_items)
assert len(session_groups) == 1
assert "session-1" in session_groups
assert len(session_groups["session-1"]) == 3
# Check that all traces are included
session_traces = [item.trace for item in session_groups["session-1"]]
assert trace1 in session_traces
assert trace2 in session_traces
assert trace3 in session_traces
def test_group_traces_by_session_multiple_sessions():
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
trace2 = _create_mock_trace("trace-2", "session-1", 2000)
trace3 = _create_mock_trace("trace-3", "session-2", 1500)
trace4 = _create_mock_trace("trace-4", "session-2", 2500)
eval_items = [
_create_mock_eval_item(trace1),
_create_mock_eval_item(trace2),
_create_mock_eval_item(trace3),
_create_mock_eval_item(trace4),
]
session_groups = group_traces_by_session(eval_items)
assert len(session_groups) == 2
assert "session-1" in session_groups
assert "session-2" in session_groups
assert len(session_groups["session-1"]) == 2
assert len(session_groups["session-2"]) == 2
def test_group_traces_by_session_excludes_no_session_id():
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
trace2 = _create_mock_trace("trace-2", None, 2000) # No session_id
trace3 = _create_mock_trace("trace-3", "session-1", 3000)
eval_items = [
_create_mock_eval_item(trace1),
_create_mock_eval_item(trace2),
_create_mock_eval_item(trace3),
]
session_groups = group_traces_by_session(eval_items)
assert len(session_groups) == 1
assert "session-1" in session_groups
assert len(session_groups["session-1"]) == 2
# trace2 should not be included
session_traces = [item.trace for item in session_groups["session-1"]]
assert trace1 in session_traces
assert trace2 not in session_traces
assert trace3 in session_traces
def test_group_traces_by_session_excludes_none_traces():
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
eval_item1 = _create_mock_eval_item(trace1)
eval_item2 = Mock()
eval_item2.trace = None # No trace
eval_item2.source = None # No source
eval_items = [eval_item1, eval_item2]
session_groups = group_traces_by_session(eval_items)
assert len(session_groups) == 1
assert "session-1" in session_groups
assert len(session_groups["session-1"]) == 1
def test_group_traces_by_session_empty_list():
session_groups = group_traces_by_session([])
assert len(session_groups) == 0
assert session_groups == {}
# ==================== Tests for get_first_trace_in_session ====================
def test_get_first_trace_in_session_chronological_order():
trace1 = _create_mock_trace("trace-1", "session-1", 3000)
trace2 = _create_mock_trace("trace-2", "session-1", 1000) # Earliest
trace3 = _create_mock_trace("trace-3", "session-1", 2000)
eval_item1 = _create_mock_eval_item(trace1)
eval_item2 = _create_mock_eval_item(trace2)
eval_item3 = _create_mock_eval_item(trace3)
session_items = [eval_item1, eval_item2, eval_item3]
first_item = get_first_trace_in_session(session_items)
assert first_item.trace == trace2
assert first_item == eval_item2
def test_get_first_trace_in_session_single_trace():
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
eval_item1 = _create_mock_eval_item(trace1)
session_items = [eval_item1]
first_item = get_first_trace_in_session(session_items)
assert first_item.trace == trace1
assert first_item == eval_item1
def test_get_first_trace_in_session_same_timestamp():
# When timestamps are equal, min() will return the first one in the list
trace1 = _create_mock_trace("trace-1", "session-1", 1000)
trace2 = _create_mock_trace("trace-2", "session-1", 1000)
trace3 = _create_mock_trace("trace-3", "session-1", 1000)
eval_item1 = _create_mock_eval_item(trace1)
eval_item2 = _create_mock_eval_item(trace2)
eval_item3 = _create_mock_eval_item(trace3)
session_items = [eval_item1, eval_item2, eval_item3]
first_item = get_first_trace_in_session(session_items)
# Should return one of the traces with timestamp 1000 (likely the first one)
assert first_item.trace.info.request_time == 1000
# ==================== Tests for validate_session_level_evaluation_inputs ====================
def test_validate_session_level_evaluation_inputs_no_session_level_scorers():
@scorer
def single_turn_scorer(outputs):
return 1.0
scorers_list = [single_turn_scorer]
# Should not raise any exceptions
validate_session_level_evaluation_inputs(
scorers=scorers_list,
predict_fn=None,
)
def test_validate_session_level_evaluation_inputs_with_predict_fn():
multi_turn_scorer = _MultiTurnTestScorer()
scorers_list = [multi_turn_scorer]
def dummy_predict_fn():
return "output"
with pytest.raises(
MlflowException,
match=r"Session-level scorers require traces with session IDs.*"
r"Either pass a ConversationSimulator to `data` with `predict_fn`",
):
validate_session_level_evaluation_inputs(
scorers=scorers_list,
predict_fn=dummy_predict_fn,
)
def test_validate_session_level_evaluation_inputs_mixed_scorers():
@scorer
def single_turn_scorer(outputs):
return 1.0
multi_turn_scorer = _MultiTurnTestScorer()
scorers_list = [single_turn_scorer, multi_turn_scorer]
# Should not raise any exceptions
validate_session_level_evaluation_inputs(
scorers=scorers_list,
predict_fn=None,
)
# ==================== Tests for evaluate_session_level_scorers ====================
def _create_test_trace(trace_id: str, request_time: int = 0) -> Trace:
"""Helper to create a minimal test trace"""
return Trace(
info=TraceInfo(
trace_id=trace_id,
trace_location=TraceLocation.from_experiment_id("0"),
request_time=request_time,
execution_duration=100,
state=TraceState.OK,
trace_metadata={},
tags={},
),
data=TraceData(spans=[]),
)
def _create_eval_item(trace_id: str, request_time: int = 0) -> EvalItem:
"""Helper to create a minimal EvalItem with a trace"""
trace = _create_test_trace(trace_id, request_time)
return EvalItem(
request_id=trace_id,
trace=trace,
inputs={},
outputs={},
expectations={},
)
def test_evaluate_session_level_scorers_success():
mock_scorer = Mock(spec=mlflow.genai.Scorer)
mock_scorer.name = "test_scorer"
mock_scorer.run.return_value = 0.8
# Test with a single session containing multiple traces
session_items = [
_create_eval_item("trace1", request_time=100),
_create_eval_item("trace2", request_time=200),
]
with patch(
"mlflow.genai.evaluation.session_utils.standardize_scorer_value"
) as mock_standardize:
# Return a new Feedback object each time to avoid metadata overwriting
def create_feedback(*args, **kwargs):
return [
Feedback(
name="test_scorer",
source=AssessmentSource(
source_type=AssessmentSourceType.CODE, source_id="test"
),
value=0.8,
)
]
mock_standardize.side_effect = create_feedback
result = evaluate_session_level_scorers("session1", session_items, [mock_scorer])
# Verify scorer was called once (for the single session)
assert mock_scorer.run.call_count == 1
# Verify scorer received session traces
call_args = mock_scorer.run.call_args
assert "session" in call_args.kwargs
assert len(call_args.kwargs["session"]) == 2 # session has 2 traces
# Verify result is for first item
assert result.eval_item.trace.info.trace_id == "trace1"
assert len(result.assessments) == 1
assert result.assessments[0].name == "test_scorer"
assert result.assessments[0].value == 0.8
# Verify session_id was added to metadata
assert result.assessments[0].metadata is not None
assert result.assessments[0].metadata[TraceMetadataKey.TRACE_SESSION] == "session1"
def test_evaluate_session_level_scorers_handles_scorer_error():
mock_scorer = Mock(spec=mlflow.genai.Scorer)
mock_scorer.name = "failing_scorer"
mock_scorer.run.side_effect = ValueError("Scorer failed!")
session_items = [_create_eval_item("trace1", 100)]
result = evaluate_session_level_scorers("session1", session_items, [mock_scorer])
# Verify error feedback was created
assert result.eval_item.trace.info.trace_id == "trace1"
assert len(result.assessments) == 1
feedback = result.assessments[0]
assert feedback.name == "failing_scorer"
assert feedback.error is not None
assert feedback.error.error_code == "SCORER_ERROR"
assert feedback.error.stack_trace is not None
assert feedback.error.to_proto().error_message == "Scorer failed!"
assert isinstance(feedback.error.error_message, str)
assert feedback.error.error_message == "Scorer failed!"
# Verify session_id metadata is present even on error feedbacks
assert feedback.metadata is not None
assert feedback.metadata[TraceMetadataKey.TRACE_SESSION] == "session1"
def test_evaluate_session_level_scorers_multiple_feedbacks_per_scorer():
mock_scorer = Mock(spec=mlflow.genai.Scorer)
mock_scorer.name = "multi_feedback_scorer"
mock_scorer.run.return_value = {"metric1": 0.7, "metric2": 0.9}
session_items = [_create_eval_item("trace1", 100)]
with patch(
"mlflow.genai.evaluation.session_utils.standardize_scorer_value"
) as mock_standardize:
feedbacks = [
Feedback(
name="multi_feedback_scorer/metric1",
source=AssessmentSource(source_type=AssessmentSourceType.CODE, source_id="test"),
value=0.7,
),
Feedback(
name="multi_feedback_scorer/metric2",
source=AssessmentSource(source_type=AssessmentSourceType.CODE, source_id="test"),
value=0.9,
),
]
mock_standardize.return_value = feedbacks
result = evaluate_session_level_scorers("session1", session_items, [mock_scorer])
# Verify both feedbacks are stored
assert result.eval_item.trace.info.trace_id == "trace1"
assert len(result.assessments) == 2
# Find feedbacks by name
feedback_by_name = {f.name: f for f in result.assessments}
assert "multi_feedback_scorer/metric1" in feedback_by_name
assert "multi_feedback_scorer/metric2" in feedback_by_name
assert feedback_by_name["multi_feedback_scorer/metric1"].value == 0.7
assert feedback_by_name["multi_feedback_scorer/metric2"].value == 0.9
def test_evaluate_session_level_scorers_first_trace_selection():
mock_scorer = Mock(spec=mlflow.genai.Scorer)
mock_scorer.name = "first_trace_scorer"
mock_scorer.run.return_value = 1.0
# Create session with traces in non-chronological order
session_items = [
_create_eval_item("trace2", request_time=200), # Second chronologically
_create_eval_item("trace1", request_time=100), # First chronologically
_create_eval_item("trace3", request_time=300), # Third chronologically
]
with patch(
"mlflow.genai.evaluation.session_utils.standardize_scorer_value"
) as mock_standardize:
feedback = Feedback(
name="first_trace_scorer",
source=AssessmentSource(source_type=AssessmentSourceType.CODE, source_id="test"),
value=1.0,
)
mock_standardize.return_value = [feedback]
result = evaluate_session_level_scorers("session1", session_items, [mock_scorer])
# Verify assessment is for trace1 (earliest request_time)
assert result.eval_item.trace.info.trace_id == "trace1"
assert len(result.assessments) == 1
assert result.assessments[0].name == "first_trace_scorer"
assert result.assessments[0].value == 1.0
def test_evaluate_session_level_scorers_multiple_scorers():
mock_scorer1 = Mock(spec=mlflow.genai.Scorer)
mock_scorer1.name = "scorer1"
mock_scorer1.run.return_value = 0.6
mock_scorer2 = Mock(spec=mlflow.genai.Scorer)
mock_scorer2.name = "scorer2"
mock_scorer2.run.return_value = 0.8
session_items = [_create_eval_item("trace1", 100)]
with patch(
"mlflow.genai.evaluation.session_utils.standardize_scorer_value"
) as mock_standardize:
def create_feedback(name, value):
return [
Feedback(
name=name,
source=AssessmentSource(
source_type=AssessmentSourceType.CODE, source_id="test"
),
value=value,
)
]
mock_standardize.side_effect = [
create_feedback("scorer1", 0.6),
create_feedback("scorer2", 0.8),
]
result = evaluate_session_level_scorers(
"session1", session_items, [mock_scorer1, mock_scorer2]
)
# Verify both scorers were evaluated (runs in parallel)
assert mock_scorer1.run.call_count == 1
assert mock_scorer2.run.call_count == 1
# Verify result contains assessments from both scorers
assert result.eval_item.trace.info.trace_id == "trace1"
assert len(result.assessments) == 2
# Find feedbacks by name
feedback_by_name = {f.name: f for f in result.assessments}
assert "scorer1" in feedback_by_name
assert "scorer2" in feedback_by_name
assert feedback_by_name["scorer1"].value == 0.6
assert feedback_by_name["scorer2"].value == 0.8
def test_evaluate_session_level_scorers_error_multiple_traces():
mock_scorer = Mock(spec=mlflow.genai.Scorer)
mock_scorer.name = "failing_scorer"
mock_scorer.run.side_effect = RuntimeError("boom")
session_items = [
_create_eval_item("trace1", request_time=100),
_create_eval_item("trace2", request_time=200),
]
result = evaluate_session_level_scorers("session-abc", session_items, [mock_scorer])
assert result.eval_item.trace.info.trace_id == "trace1"
feedback = result.assessments[0]
assert feedback.error is not None
assert feedback.metadata[TraceMetadataKey.TRACE_SESSION] == "session-abc"