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

757 lines
26 KiB
Python

import json
import time
from unittest.mock import patch
import pytest
from mlflow.entities.assessment import (
Assessment,
AssessmentError,
AssessmentSource,
Expectation,
ExpectationValue,
Feedback,
FeedbackValue,
IssueReference,
IssueReferenceValue,
)
from mlflow.entities.assessment_error import _STACK_TRACE_TRUNCATION_LENGTH
from mlflow.exceptions import MlflowException
from mlflow.protos.assessments_pb2 import Assessment as ProtoAssessment
from mlflow.protos.assessments_pb2 import Expectation as ProtoExpectation
from mlflow.protos.assessments_pb2 import Feedback as ProtoFeedback
from mlflow.protos.assessments_pb2 import IssueReference as ProtoIssueReference
from mlflow.protos.databricks_tracing_pb2 import Assessment as ProtoAssessmentV4
from mlflow.protos.databricks_tracing_pb2 import TraceLocation, UCSchemaLocation
from mlflow.tracing.constant import AssessmentMetadataKey
from mlflow.utils.proto_json_utils import proto_timestamp_to_milliseconds
def test_assessment_creation():
default_params = {
"trace_id": "trace_id",
"name": "relevance",
"source": AssessmentSource(source_type="HUMAN", source_id="user_1"),
"create_time_ms": 123456789,
"last_update_time_ms": 123456789,
"expectation": None,
"feedback": FeedbackValue(0.9),
"rationale": "Rationale text",
"metadata": {"key1": "value1"},
"span_id": "span_id",
"assessment_id": "assessment_id",
}
assessment = Assessment(**default_params)
for key, value in default_params.items():
assert getattr(assessment, key) == value
assessment_with_error = Assessment(**{
**default_params,
"feedback": FeedbackValue(
None, AssessmentError(error_code="E001", error_message="An error occurred.")
),
})
assert assessment_with_error.feedback.error.error_code == "E001"
assert assessment_with_error.feedback.error.error_message == "An error occurred."
# Both feedback value and error can be set. For example, a default fallback value can
# be set when LLM judge fails to provide a value.
assessment_with_value_and_error = Assessment(**{
**default_params,
"feedback": FeedbackValue(value=1, error=AssessmentError(error_code="E001")),
})
assert assessment_with_value_and_error.feedback.value == 1
assert assessment_with_value_and_error.feedback.error.error_code == "E001"
# Backward compatibility. "error" was previously in the Assessment class.
assessment_legacy_error = Assessment(**{
**default_params,
"error": AssessmentError(error_code="E001", error_message="An error occurred."),
"feedback": FeedbackValue(None),
})
assert assessment_legacy_error.feedback.error.error_code == "E001"
assert assessment_legacy_error.feedback.error.error_message == "An error occurred."
def test_assessment_equality():
source_1 = AssessmentSource(source_type="HUMAN", source_id="user_1")
source_2 = AssessmentSource(source_type="HUMAN", source_id="user_1")
source_3 = AssessmentSource(source_type="LLM_JUDGE", source_id="llm_1")
common_args = {
"trace_id": "trace_id",
"name": "relevance",
"create_time_ms": 123456789,
"last_update_time_ms": 123456789,
}
# Valid assessments
assessment_1 = Assessment(
source=source_1,
feedback=FeedbackValue(0.9),
**common_args,
)
assessment_2 = Assessment(
source=source_2,
feedback=FeedbackValue(0.9),
**common_args,
)
assessment_3 = Assessment(
source=source_1,
feedback=FeedbackValue(0.8),
**common_args,
)
assessment_4 = Assessment(
source=source_3,
feedback=FeedbackValue(0.9),
**common_args,
)
assessment_5 = Assessment(
source=source_1,
feedback=FeedbackValue(
None,
AssessmentError(
error_code="E002",
error_message="A different error occurred.",
),
),
**common_args,
)
assessment_6 = Assessment(
source=source_1,
feedback=FeedbackValue(
None,
AssessmentError(
error_code="E001",
error_message="Another error message.",
),
),
**common_args,
)
# Same evaluation_id, name, source, timestamp, and numeric_value
assert assessment_1 == assessment_2
assert assessment_1 != assessment_3 # Different numeric_value
assert assessment_1 != assessment_4 # Different source
assert assessment_1 != assessment_5 # One has numeric_value, other has error_code
assert assessment_5 != assessment_6 # Different error_code
def test_assessment_value_validation():
common_args = {
"trace_id": "trace_id",
"name": "relevance",
"source": AssessmentSource(source_type="HUMAN", source_id="user_1"),
"create_time_ms": 123456789,
"last_update_time_ms": 123456789,
}
# Valid cases
Assessment(expectation=ExpectationValue("MLflow"), **common_args)
Assessment(feedback=FeedbackValue("This is correct."), **common_args)
Assessment(
feedback=FeedbackValue(None, error=AssessmentError(error_code="E001")), **common_args
)
Assessment(
feedback=FeedbackValue("This is correct.", AssessmentError(error_code="E001")),
**common_args,
)
Assessment(issue=IssueReferenceValue(issue_name="test_issue"), **common_args)
# Invalid case: no value specified
with pytest.raises(MlflowException, match=r"Exactly one of"):
Assessment(**common_args)
# Invalid case: both feedback and expectation specified
with pytest.raises(MlflowException, match=r"Exactly one of"):
Assessment(
expectation=ExpectationValue("MLflow"),
feedback=FeedbackValue("This is correct."),
**common_args,
)
# Invalid case: both feedback and issue specified
with pytest.raises(MlflowException, match=r"Exactly one of"):
Assessment(
feedback=FeedbackValue(1.0),
issue=IssueReferenceValue(issue_name="test_issue"),
**common_args,
)
# Invalid case: both expectation and issue specified
with pytest.raises(MlflowException, match=r"Exactly one of"):
Assessment(
expectation=ExpectationValue("test"),
issue=IssueReferenceValue(issue_name="test_issue"),
**common_args,
)
# Invalid case: all three specified
with pytest.raises(MlflowException, match=r"Exactly one of"):
Assessment(
expectation=ExpectationValue("MLflow"),
feedback=FeedbackValue("This is correct.", AssessmentError(error_code="E001")),
issue=IssueReferenceValue(issue_name="test_issue"),
**common_args,
)
@pytest.mark.parametrize(
("expectation", "feedback"),
[
(ExpectationValue("MLflow"), None),
(ExpectationValue({"complex": {"expectation": ["structure"]}}), None),
(None, FeedbackValue("This is correct.")),
(None, FeedbackValue(None, AssessmentError(error_code="E001"))),
(
None,
FeedbackValue(
None, AssessmentError(error_code="E001", error_message="An error occurred.")
),
),
],
)
@pytest.mark.parametrize(
"source",
[
AssessmentSource(source_type="HUMAN", source_id="user_1"),
AssessmentSource(source_type="CODE", source_id="code.py"),
],
)
@pytest.mark.parametrize(
"metadata",
[
{"key1": "value1"},
None,
],
)
def test_assessment_conversion(expectation, feedback, source, metadata):
timestamp_ms = int(time.time() * 1000)
if expectation:
assessment = Expectation(
trace_id="trace_id",
name="relevance",
source=source,
create_time_ms=timestamp_ms,
last_update_time_ms=timestamp_ms,
value=expectation.value,
metadata=metadata,
span_id="span_id",
)
elif feedback:
assessment = Feedback(
trace_id="trace_id",
name="relevance",
source=source,
create_time_ms=timestamp_ms,
last_update_time_ms=timestamp_ms,
value=feedback.value,
error=feedback.error,
rationale="Rationale text",
metadata=metadata,
span_id="span_id",
)
else:
raise ValueError("Either expectation or feedback must be provided")
proto = assessment.to_proto()
assert isinstance(proto, ProtoAssessment)
result = Assessment.from_proto(proto)
assert result == assessment
dict = assessment.to_dictionary()
assert dict.get("assessment_id") == assessment.assessment_id
assert dict["trace_id"] == assessment.trace_id
assert dict["assessment_name"] == assessment.name
assert dict["source"].get("source_type") == source.source_type
assert dict["source"].get("source_id") == source.source_id
assert proto_timestamp_to_milliseconds(dict["create_time"]) == assessment.create_time_ms
assert (
proto_timestamp_to_milliseconds(dict["last_update_time"]) == assessment.last_update_time_ms
)
assert dict.get("rationale") == assessment.rationale
assert dict.get("metadata") == metadata
if expectation:
if isinstance(expectation.value, str):
assert dict["expectation"] == {"value": expectation.value}
else:
assert dict["expectation"] == {
"serialized_value": {
"value": json.dumps(expectation.value),
"serialization_format": "JSON_FORMAT",
}
}
if feedback:
assert dict["feedback"] == feedback.to_dictionary()
@pytest.mark.parametrize(
"value",
[
"MLflow", # string
42, # integer
3.14, # float
True, # boolean
],
ids=["string", "integer", "float", "boolean"],
)
def test_expectation_proto_dict_conversion(value):
expectation = ExpectationValue(value)
proto = expectation.to_proto()
assert isinstance(proto, ProtoExpectation)
result = ExpectationValue.from_proto(proto)
assert result.value == expectation.value
expectation_dict = expectation.to_dictionary()
result = ExpectationValue.from_dictionary(expectation_dict)
assert result.value == expectation.value
@pytest.mark.parametrize(
"value",
[
{"key": "value"},
["a", "b", "c"],
{"nested": {"dict": {"with": ["mixed", "types", 1, 2.0, True]}}},
[1, "two", 3.0, False, {"mixed": "list"}],
[{"complex": "structure"}, [1, 2, 3], {"with": ["nested", "arrays"]}],
],
)
def test_expectation_value_serialization(value):
expectation = ExpectationValue(value)
proto = expectation.to_proto()
assert proto.HasField("serialized_value")
assert proto.serialized_value.serialization_format == "JSON_FORMAT"
result = ExpectationValue.from_proto(proto)
assert result.value == expectation.value
expectation_dict = expectation.to_dictionary()
result = ExpectationValue.from_dictionary(expectation_dict)
assert result.value == expectation.value
def test_expectation_invalid_values():
class CustomObject:
pass
with pytest.raises(MlflowException, match="Expectation value must be JSON-serializable"):
ExpectationValue(CustomObject()).to_proto()
# Test invalid serialization format
proto = ProtoExpectation()
proto.serialized_value.serialization_format = "INVALID_FORMAT"
proto.serialized_value.value = '{"key": "value"}'
with pytest.raises(MlflowException, match="Unknown serialization format"):
ExpectationValue.from_proto(proto)
@pytest.mark.parametrize(
("value", "error"),
[
(0.9, None),
(None, AssessmentError(error_code="E001", error_message="An error occurred.")),
(
"Error message",
AssessmentError(error_code="E002", error_message="Another error occurred."),
),
],
)
def test_feedback_value_proto_dict_conversion(value, error):
feedback = FeedbackValue(value, error)
proto = feedback.to_proto()
assert isinstance(proto, ProtoFeedback)
result = FeedbackValue.from_proto(proto)
assert result.value == feedback.value
assert result.error == feedback.error
feedback_dict = feedback.to_dictionary()
result = FeedbackValue.from_dictionary(feedback_dict)
assert result.value == feedback.value
assert result.error == feedback.error
@pytest.mark.parametrize("stack_trace_length", [500, 2000])
def test_feedback_from_exception(stack_trace_length):
err = None
try:
raise ValueError("An error occurred.")
except ValueError as e:
err = e
# Mock traceback.format_tb to simulate long stack trace
with patch(
"mlflow.entities.assessment.get_stacktrace",
return_value="A" * (stack_trace_length - 9) + "last line",
):
feedback = Feedback(error=err)
assert feedback.error.error_code == "ValueError"
assert feedback.error.error_message == "An error occurred."
assert feedback.error.stack_trace is not None
# Feedback should expose error_code and error_message for backward compatibility
assert feedback.error_code == "ValueError"
assert feedback.error_message == "An error occurred."
proto = feedback.to_proto()
assert len(proto.feedback.error.stack_trace) == min(
stack_trace_length, _STACK_TRACE_TRUNCATION_LENGTH
)
assert proto.feedback.error.stack_trace.endswith("last line")
if stack_trace_length > _STACK_TRACE_TRUNCATION_LENGTH:
assert proto.feedback.error.stack_trace.startswith("[Stack trace is truncated]\n...\n")
recovered = Feedback.from_proto(feedback.to_proto())
assert feedback.error.error_code == recovered.error.error_code
assert feedback.error.error_message == recovered.error.error_message
def test_assessment_value_assignment():
feedback = Feedback(name="relevance", value=1.0)
assert feedback.value == 1.0
feedback.value = 0.9
assert feedback.value == 0.9
expectation = Expectation(name="expected_value", value=1.0)
assert expectation.value == 1.0
expectation.value = 0.9
assert expectation.value == 0.9
@pytest.mark.parametrize(
("metadata", "explicit_run_id", "expected_run_id"),
[
({AssessmentMetadataKey.SOURCE_RUN_ID: "run123"}, None, "run123"),
({"other_key": "value"}, "explicit_run", "explicit_run"),
({"other_key": "value"}, None, None),
(None, None, None),
],
)
def test_run_id_handling(metadata, explicit_run_id, expected_run_id):
feedback = Feedback(name="test", value=True, metadata=metadata)
if explicit_run_id:
feedback.run_id = explicit_run_id
assert feedback.run_id == expected_run_id
assert not hasattr(feedback.to_proto(), "run_id")
if expected_run_id and not explicit_run_id:
recovered = Feedback.from_proto(feedback.to_proto())
assert recovered.run_id == expected_run_id
def test_feedback_from_proto_v4():
# Create v4 proto with all fields
proto_v4 = ProtoAssessmentV4()
proto_v4.assessment_id = "feedback123"
proto_v4.assessment_name = "accuracy"
proto_v4.trace_location.CopyFrom(
TraceLocation(
type=TraceLocation.TraceLocationType.UC_SCHEMA,
uc_schema=UCSchemaLocation(catalog_name="prod", schema_name="ml_data"),
)
)
proto_v4.trace_id = "123456"
proto_v4.span_id = "span789"
proto_v4.rationale = "Model output matches ground truth"
proto_v4.overrides = "prev_assessment"
proto_v4.valid = True
# Set source
source = AssessmentSource(source_type="CODE", source_id="scorer.py")
proto_v4.source.CopyFrom(source.to_proto())
# Set timestamps
proto_v4.create_time.FromMilliseconds(1700000000000)
proto_v4.last_update_time.FromMilliseconds(1700000001000)
# Set metadata
proto_v4.metadata["key1"] = "value1"
proto_v4.metadata["key2"] = "value2"
# Set feedback value with error
feedback_value = FeedbackValue(
value=0.85, error=AssessmentError(error_code="TIMEOUT", error_message="Request timed out")
)
proto_v4.feedback.CopyFrom(feedback_value.to_proto())
# Convert from proto
feedback = Feedback.from_proto(proto_v4)
# Validate all fields
assert feedback.assessment_id == "feedback123"
assert feedback.name == "accuracy"
assert feedback.trace_id == "trace:/prod.ml_data/123456"
assert feedback.span_id == "span789"
assert feedback.rationale == "Model output matches ground truth"
assert feedback.overrides == "prev_assessment"
assert feedback.valid is True
assert feedback.value == 0.85
assert feedback.error.error_code == "TIMEOUT"
assert feedback.error.error_message == "Request timed out"
assert feedback.source.source_type == "CODE"
assert feedback.source.source_id == "scorer.py"
assert feedback.create_time_ms == 1700000000000
assert feedback.last_update_time_ms == 1700000001000
assert feedback.metadata == {"key1": "value1", "key2": "value2"}
def test_expectation_from_proto_v4():
# Create v4 proto with all fields
proto_v4 = ProtoAssessmentV4()
proto_v4.assessment_id = "exp123"
proto_v4.assessment_name = "expected_output"
# Set TraceIdentifier with UC schema location
proto_v4.trace_location.CopyFrom(
TraceLocation(
type=TraceLocation.TraceLocationType.UC_SCHEMA,
uc_schema=UCSchemaLocation(catalog_name="dev", schema_name="experiments"),
)
)
proto_v4.trace_id = "123456"
proto_v4.span_id = "exp_span789"
# Set source
source = AssessmentSource(source_type="HUMAN", source_id="expert@company.com")
proto_v4.source.CopyFrom(source.to_proto())
# Set timestamps
proto_v4.create_time.FromMilliseconds(1700000002000)
proto_v4.last_update_time.FromMilliseconds(1700000003000)
# Set metadata
proto_v4.metadata["dataset"] = "test_set_v1"
proto_v4.metadata["version"] = "1.0"
# Set expectation value (complex structure)
expectation_value = ExpectationValue(
value={"expected_response": "The capital is Paris", "alternatives": ["Paris, France"]}
)
proto_v4.expectation.CopyFrom(expectation_value.to_proto())
# Convert from proto
expectation = Expectation.from_proto(proto_v4)
# Validate all fields
assert expectation.assessment_id == "exp123"
assert expectation.name == "expected_output"
assert expectation.trace_id == "trace:/dev.experiments/123456"
assert expectation.span_id == "exp_span789"
assert expectation.value == {
"expected_response": "The capital is Paris",
"alternatives": ["Paris, France"],
}
assert expectation.source.source_type == "HUMAN"
assert expectation.source.source_id == "expert@company.com"
assert expectation.create_time_ms == 1700000002000
assert expectation.last_update_time_ms == 1700000003000
assert expectation.metadata == {"dataset": "test_set_v1", "version": "1.0"}
def test_feedback_converts_string_error_to_assessment_error():
feedback = Feedback(
name="test_feedback",
error="This is a string error message",
)
# Verify error was converted to AssessmentError
assert isinstance(feedback.error, AssessmentError)
assert feedback.error.error_message == "This is a string error message"
assert feedback.error.error_code == "ASSESSMENT_ERROR"
# Verify it can be serialized to proto
proto = feedback.to_proto()
assert proto.feedback.HasField("error")
assert proto.feedback.error.error_message == "This is a string error message"
assert proto.feedback.error.error_code == "ASSESSMENT_ERROR"
def test_feedback_converts_exception_error_to_assessment_error():
feedback = Feedback(name="test_feedback", error=ValueError("Test exception message"))
# Verify error was converted to AssessmentError
assert isinstance(feedback.error, AssessmentError)
assert "Test exception message" in feedback.error.error_message
assert feedback.error.error_code == "ValueError"
assert feedback.error.stack_trace is not None
assert len(feedback.error.stack_trace) > 0
# Verify it can be serialized
proto = feedback.to_proto()
assert proto.feedback.HasField("error")
assert proto.feedback.error.error_code == "ValueError"
def test_feedback_passes_through_assessment_error():
error = AssessmentError(
error_message="Custom error message",
error_code="CUSTOM_ERROR_CODE",
stack_trace="Custom stack trace",
)
feedback = Feedback(
name="test_feedback",
error=error,
)
# Verify error was not modified
assert feedback.error is error
assert feedback.error.error_message == "Custom error message"
assert feedback.error.error_code == "CUSTOM_ERROR_CODE"
assert feedback.error.stack_trace == "Custom stack trace"
# Verify it can be serialized
proto = feedback.to_proto()
assert proto.feedback.HasField("error")
assert proto.feedback.error.error_message == "Custom error message"
assert proto.feedback.error.error_code == "CUSTOM_ERROR_CODE"
@pytest.mark.parametrize(
"invalid_error",
[123, ["error"], {"error": "message"}],
ids=["int", "list", "dict"],
)
def test_feedback_rejects_invalid_error_types(invalid_error):
with pytest.raises(
MlflowException, match="'error' must be an Exception, AssessmentError, or string"
):
Feedback(name="test", error=invalid_error)
def test_issue_reference_creation():
timestamp_ms = int(time.time() * 1000)
source = AssessmentSource(source_type="CODE", source_id="issue_detector.py")
issue_ref = IssueReference(
issue_id="iss-12345",
issue_name="timeout_error",
source=source,
trace_id="trace_123",
run_id="run_456",
metadata={"severity": "high"},
span_id="span_789",
create_time_ms=timestamp_ms,
last_update_time_ms=timestamp_ms,
)
assert issue_ref.name == "iss-12345"
assert issue_ref.issue_id == "iss-12345"
assert issue_ref.issue_name == "timeout_error"
assert issue_ref.source == source
assert issue_ref.trace_id == "trace_123"
assert issue_ref.run_id == "run_456"
assert issue_ref.metadata == {"severity": "high"}
assert issue_ref.span_id == "span_789"
assert issue_ref.create_time_ms == timestamp_ms
assert issue_ref.last_update_time_ms == timestamp_ms
# Test default source is LLM_JUDGE
issue_ref_default = IssueReference(issue_id="iss-999", issue_name="test_issue")
assert issue_ref_default.source.source_type == "LLM_JUDGE"
def test_issue_reference_requires_issue_id():
with pytest.raises(MlflowException, match="The `issue_id` field must be specified"):
IssueReference(issue_id=None, issue_name="test_issue")
with pytest.raises(MlflowException, match="The `issue_name` field must be specified"):
IssueReference(issue_id="iss-123", issue_name=None)
def test_issue_reference_value_assignment():
issue_ref = IssueReference(issue_id="iss-111", issue_name="test_issue")
assert issue_ref.issue_id == "iss-111"
assert issue_ref.issue_name == "test_issue"
issue_ref.issue_id = "iss-222"
assert issue_ref.issue_id == "iss-222"
issue_ref.issue_name = "updated_issue"
assert issue_ref.issue_name == "updated_issue"
def test_issue_reference_value_proto_dict_conversion():
issue_value = IssueReferenceValue(issue_name="timeout_error")
# Test proto conversion
proto = issue_value.to_proto()
assert isinstance(proto, ProtoIssueReference)
assert proto.issue_name == "timeout_error"
result = IssueReferenceValue.from_proto(proto)
assert result.issue_name == issue_value.issue_name
# Test dictionary conversion
issue_dict = issue_value.to_dictionary()
assert issue_dict == {"issue_name": "timeout_error"}
result = IssueReferenceValue.from_dictionary(issue_dict)
assert result.issue_name == issue_value.issue_name
@pytest.mark.parametrize(
"source",
[
AssessmentSource(source_type="CODE", source_id="detector.py"),
AssessmentSource(source_type="LLM_JUDGE", source_id="gpt-4"),
],
)
@pytest.mark.parametrize(
"metadata",
[
{"severity": "high", "category": "timeout"},
None,
],
)
def test_issue_reference_conversion(source, metadata):
timestamp_ms = int(time.time() * 1000)
issue_ref = IssueReference(
issue_id="iss-12345",
issue_name="timeout_error",
source=source,
trace_id="trace_123",
metadata=metadata,
span_id="span_789",
create_time_ms=timestamp_ms,
last_update_time_ms=timestamp_ms,
)
# Test proto conversion
proto = issue_ref.to_proto()
assert isinstance(proto, ProtoAssessment)
assert proto.WhichOneof("value") == "issue"
assert proto.issue.issue_name == "timeout_error"
result = Assessment.from_proto(proto)
assert isinstance(result, IssueReference)
assert result == issue_ref
# Test dictionary conversion
dict_repr = issue_ref.to_dictionary()
assert dict_repr.get("assessment_id") == issue_ref.assessment_id
assert dict_repr["trace_id"] == issue_ref.trace_id
assert dict_repr["assessment_name"] == "iss-12345"
assert dict_repr["source"].get("source_type") == source.source_type
assert dict_repr["source"].get("source_id") == source.source_id
assert proto_timestamp_to_milliseconds(dict_repr["create_time"]) == timestamp_ms
assert proto_timestamp_to_milliseconds(dict_repr["last_update_time"]) == timestamp_ms
assert dict_repr["issue"] == {"issue_name": "timeout_error"}
assert dict_repr.get("metadata") == metadata