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

1088 lines
38 KiB
Python

import os
from unittest import mock
import pytest
import mlflow
from mlflow.entities.assessment import (
AssessmentError,
Expectation,
Feedback,
IssueReference,
)
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
from mlflow.entities.issue import IssueStatus
from mlflow.exceptions import MlflowException
from mlflow.tracing.distributed import (
get_tracing_context_headers_for_http_request,
set_tracing_context_from_http_request_headers,
)
from mlflow.tracing.trace_manager import InMemoryTraceManager
from mlflow.version import IS_TRACING_SDK_ONLY
_HUMAN_ASSESSMENT_SOURCE = AssessmentSource(
source_type=AssessmentSourceType.HUMAN,
source_id="bob@example.com",
)
_LLM_ASSESSMENT_SOURCE = AssessmentSource(
source_type=AssessmentSourceType.LLM_JUDGE,
source_id="gpt-4o-mini",
)
_CODE_ASSESSMENT_SOURCE = AssessmentSource(
source_type=AssessmentSourceType.CODE,
source_id="issue_detector.py",
)
@pytest.fixture
def trace_id():
with mlflow.start_span(name="test_span") as span:
pass
mlflow.flush_trace_async_logging()
return span.trace_id
@pytest.fixture(params=["file", "sqlalchemy"], autouse=True)
def tracking_uri(request, tmp_path, db_uri):
"""Set an MLflow Tracking URI with different type of backend."""
if request.param == "file":
pytest.skip("FileStore is no longer supported.")
if "MLFLOW_SKINNY" in os.environ and request.param == "sqlalchemy":
pytest.skip("SQLAlchemy store is not available in skinny.")
if IS_TRACING_SDK_ONLY and request.param == "sqlalchemy":
pytest.skip("SQLAlchemy store is not available in tracing SDK only mode.")
original_tracking_uri = mlflow.get_tracking_uri()
if request.param == "file":
tracking_uri = tmp_path.joinpath("file").as_uri()
elif request.param == "sqlalchemy":
tracking_uri = db_uri
# NB: MLflow tracer does not handle the change of tracking URI well,
# so we need to reset the tracer to switch the tracking URI during testing.
mlflow.tracing.disable()
mlflow.set_tracking_uri(tracking_uri)
mlflow.tracing.enable()
yield tracking_uri
# Reset tracking URI
mlflow.set_tracking_uri(original_tracking_uri)
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_expectation(trace_id, legacy_api):
if legacy_api:
mlflow.log_expectation(
trace_id=trace_id,
name="expected_answer",
value="MLflow",
source=_HUMAN_ASSESSMENT_SOURCE,
metadata={"key": "value"},
)
else:
feedback = Expectation(
name="expected_answer",
value="MLflow",
source=_HUMAN_ASSESSMENT_SOURCE,
metadata={"key": "value"},
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert isinstance(assessment, Expectation)
assert assessment.trace_id == trace_id
assert assessment.name == "expected_answer"
assert assessment.value == "MLflow"
assert assessment.trace_id == trace_id
assert assessment.span_id is None
assert assessment.source == _HUMAN_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.expectation.value == "MLflow"
assert assessment.rationale is None
assert assessment.metadata == {"key": "value"}
def test_log_expectation_invalid_parameters():
with pytest.raises(MlflowException, match=r"The `value` field must be specified."):
Expectation(
name="expected_answer",
value=None,
source=_HUMAN_ASSESSMENT_SOURCE,
)
def test_update_expectation(trace_id):
assessment_id = mlflow.log_expectation(
trace_id=trace_id,
name="expected_answer",
value="MLflow",
).assessment_id
updated_assessment = Expectation(
name="expected_answer",
value="Spark",
metadata={"reason": "human override"},
)
mlflow.update_assessment(
assessment_id=assessment_id,
trace_id=trace_id,
assessment=updated_assessment,
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.trace_id == trace_id
assert assessment.name == "expected_answer"
assert assessment.expectation.value == "Spark"
assert assessment.feedback is None
assert assessment.rationale is None
assert assessment.metadata == {"reason": "human override"}
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_feedback(trace_id, legacy_api):
if legacy_api:
mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=1.0,
source=_LLM_ASSESSMENT_SOURCE,
rationale="This answer is very faithful.",
metadata={"model": "gpt-4o-mini"},
)
else:
feedback = Feedback(
name="faithfulness",
value=1.0,
source=_LLM_ASSESSMENT_SOURCE,
rationale="This answer is very faithful.",
metadata={"model": "gpt-4o-mini"},
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert isinstance(assessment, Feedback)
assert assessment.trace_id == trace_id
assert assessment.name == "faithfulness"
assert assessment.span_id is None
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.feedback.value == 1.0
assert assessment.feedback.error is None
assert assessment.expectation is None
assert assessment.rationale == "This answer is very faithful."
assert assessment.metadata == {"model": "gpt-4o-mini"}
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_feedback_with_error(trace_id, legacy_api):
if legacy_api:
mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
source=_LLM_ASSESSMENT_SOURCE,
error=AssessmentError(
error_code="RATE_LIMIT_EXCEEDED",
error_message="Rate limit for the judge exceeded.",
),
)
else:
feedback = Feedback(
name="faithfulness",
value=None,
source=_LLM_ASSESSMENT_SOURCE,
error=AssessmentError(
error_code="RATE_LIMIT_EXCEEDED",
error_message="Rate limit for the judge exceeded.",
),
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "faithfulness"
assert assessment.trace_id == trace_id
assert assessment.span_id is None
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.expectation is None
assert assessment.feedback.value is None
assert assessment.feedback.error.error_code == "RATE_LIMIT_EXCEEDED"
assert assessment.feedback.error.error_message == "Rate limit for the judge exceeded."
assert assessment.rationale is None
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_feedback_with_exception_object(trace_id, legacy_api):
test_exception = ValueError("Test exception message")
if legacy_api:
mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
source=_LLM_ASSESSMENT_SOURCE,
error=test_exception,
)
else:
feedback = Feedback(
name="faithfulness",
value=None,
source=_LLM_ASSESSMENT_SOURCE,
error=test_exception,
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "faithfulness"
assert assessment.trace_id == trace_id
assert assessment.span_id is None
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.expectation is None
assert assessment.feedback.value is None
# Exception should be converted to AssessmentError
assert assessment.feedback.error.error_code == "ValueError"
assert assessment.feedback.error.error_message == "Test exception message"
assert assessment.feedback.error.stack_trace is not None
assert assessment.rationale is None
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_feedback_with_value_and_error(trace_id, legacy_api):
if legacy_api:
mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
source=_LLM_ASSESSMENT_SOURCE,
value=0.5,
error=AssessmentError(
error_code="RATE_LIMIT_EXCEEDED",
error_message="Rate limit for the judge exceeded.",
),
)
else:
feedback = Feedback(
name="faithfulness",
value=0.5,
source=_LLM_ASSESSMENT_SOURCE,
error=AssessmentError(
error_code="RATE_LIMIT_EXCEEDED",
error_message="Rate limit for the judge exceeded.",
),
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "faithfulness"
assert assessment.trace_id == trace_id
assert assessment.span_id is None
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.expectation is None
assert assessment.feedback.value == 0.5
assert assessment.feedback.error.error_code == "RATE_LIMIT_EXCEEDED"
assert assessment.feedback.error.error_message == "Rate limit for the judge exceeded."
assert assessment.rationale is None
def test_log_feedback_none_value(trace_id):
mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=None,
source=_LLM_ASSESSMENT_SOURCE,
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert isinstance(assessment, Feedback)
assert assessment.trace_id == trace_id
assert assessment.name == "faithfulness"
assert assessment.feedback.value is None
assert assessment.feedback.error is None
def test_log_feedback_invalid_parameters():
# Test with a non-AssessmentSource object that is not None
with pytest.raises(MlflowException, match=r"`source` must be an instance of"):
Feedback(
trace_id="1234",
name="faithfulness",
value=1.0,
source="invalid_source_type",
)
def test_update_feedback(trace_id):
assessment_id = mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=1.0,
rationale="This answer is very faithful.",
metadata={"model": "gpt-4o-mini"},
).assessment_id
updated_feedback = Feedback(
name="faithfulness",
value=0,
rationale="This answer is not faithful.",
metadata={"reason": "human override"},
)
mlflow.update_assessment(
assessment_id=assessment_id,
trace_id=trace_id,
assessment=updated_feedback,
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "faithfulness"
assert assessment.trace_id == trace_id
assert assessment.feedback.value == 0
assert assessment.feedback.error is None
assert assessment.rationale == "This answer is not faithful."
assert assessment.metadata == {
"model": "gpt-4o-mini",
"reason": "human override",
}
def test_override_feedback(trace_id):
assessment_id = mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=0.5,
source=_LLM_ASSESSMENT_SOURCE,
rationale="Original feedback",
metadata={"model": "gpt-3.5"},
).assessment_id
new_assessment_id = mlflow.override_feedback(
trace_id=trace_id,
assessment_id=assessment_id,
value=1.0,
source=_LLM_ASSESSMENT_SOURCE,
rationale="This answer is very faithful.",
metadata={"model": "gpt-4o-mini"},
).assessment_id
# New assessment should have the same trace_id as the original assessment
assessment = mlflow.get_assessment(trace_id, new_assessment_id)
assert assessment.trace_id == trace_id
assert assessment.name == "faithfulness"
assert assessment.span_id is None
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.value == 1.0
assert assessment.error is None
assert assessment.rationale == "This answer is very faithful."
assert assessment.metadata == {"model": "gpt-4o-mini"}
assert assessment.overrides == assessment_id
assert assessment.valid is True
# Original assessment should be invalidated
original_assessment = mlflow.get_assessment(trace_id, assessment_id)
assert original_assessment.valid is False
assert original_assessment.feedback.value == 0.5
def test_delete_assessment(trace_id):
assessment_id = mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=1.0,
).assessment_id
mlflow.delete_assessment(trace_id=trace_id, assessment_id=assessment_id)
with pytest.raises(MlflowException, match=r"Assessment with ID"):
assert mlflow.get_assessment(trace_id, assessment_id) is None
# Assessment should be deleted from the trace
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 0
def test_log_feedback_default_source(trace_id):
# Test that the default CODE source is used when no source is provided
feedback = Feedback(
trace_id=trace_id,
name="faithfulness",
value=1.0,
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "faithfulness"
assert assessment.trace_id == trace_id
assert assessment.source.source_type == AssessmentSourceType.CODE
assert assessment.source.source_id == "default"
assert assessment.feedback.value == 1.0
def test_log_expectation_default_source(trace_id):
# Test that the default HUMAN source is used when no source is provided
expectation = Expectation(
trace_id=trace_id,
name="expected_answer",
value="MLflow",
)
mlflow.log_assessment(trace_id=trace_id, assessment=expectation)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == "expected_answer"
assert assessment.trace_id == trace_id
assert assessment.source.source_type == AssessmentSourceType.HUMAN
assert assessment.source.source_id == "default"
assert assessment.expectation.value == "MLflow"
def test_log_issue(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="timeout_error",
description="Timeout errors in API calls",
status=IssueStatus.PENDING,
)
mlflow.log_issue(
trace_id=trace_id,
issue_id=issue.issue_id,
issue_name="timeout_error",
source=_CODE_ASSESSMENT_SOURCE,
rationale="Request exceeded 30 second timeout",
metadata={"severity": "high", "affected_count": "150"},
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert isinstance(assessment, IssueReference)
assert assessment.trace_id == trace_id
assert assessment.name == issue.issue_id
assert assessment.issue_id == issue.issue_id
assert assessment.issue_name == "timeout_error"
assert assessment.span_id is None
assert assessment.source == _CODE_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.issue.issue_name == "timeout_error"
assert assessment.rationale == "Request exceeded 30 second timeout"
assert assessment.metadata == {"severity": "high", "affected_count": "150"}
def test_log_issue_default_source(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="connection_issue",
description="Connection issues",
status=IssueStatus.PENDING,
)
# Test that the default LLM_JUDGE source is used when no source is provided
mlflow.log_issue(
trace_id=trace_id,
issue_id=issue.issue_id,
issue_name="connection_issue",
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == issue.issue_id
assert assessment.issue_id == issue.issue_id
assert assessment.issue_name == "connection_issue"
assert assessment.trace_id == trace_id
assert assessment.source.source_type == AssessmentSourceType.LLM_JUDGE
assert assessment.source.source_id == "default"
def test_log_issue_with_run_id_and_span_id(tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="data_quality_issue",
description="Data quality issues",
status=IssueStatus.PENDING,
)
with mlflow.start_span(name="test_span") as span:
mlflow.log_issue(
trace_id=span.trace_id,
issue_id=issue.issue_id,
issue_name="data_quality_issue",
source=_LLM_ASSESSMENT_SOURCE,
run_id="run-12345",
rationale="Input data contains missing values in critical fields",
metadata={"category": "data", "priority": "high"},
span_id=span.span_id,
)
mlflow.flush_trace_async_logging()
trace = mlflow.get_trace(span.trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.issue_id == issue.issue_id
assert assessment.issue_name == "data_quality_issue"
assert assessment.trace_id == span.trace_id
assert assessment.span_id == span.span_id
assert assessment.source == _LLM_ASSESSMENT_SOURCE
assert assessment.rationale == "Input data contains missing values in critical fields"
assert assessment.metadata == {"category": "data", "priority": "high"}
def test_log_issue_without_issue_name(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="timeout_error",
description="Request exceeded 30 second timeout",
status=IssueStatus.PENDING,
)
mlflow.log_issue(
trace_id=trace_id,
issue_id=issue.issue_id,
source=_CODE_ASSESSMENT_SOURCE,
rationale="Request exceeded 30 second timeout",
metadata={"severity": "high", "affected_count": "150"},
)
trace = mlflow.get_trace(trace_id)
assessment = trace.info.assessments[0]
assert assessment.issue_id == issue.issue_id
assert assessment.issue_name == "timeout_error"
fetched_issue = tracing_client._get_issue(issue.issue_id)
assert fetched_issue.name == issue.name
assert fetched_issue.description == issue.description
assert fetched_issue.status == issue.status
def test_log_issue_with_invalid_issue_name(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="timeout_error",
description="Timeout errors in API calls",
status=IssueStatus.PENDING,
)
with pytest.raises(
MlflowException, match=r"Provided issue name 'wrong_name' does not match the issue name"
):
mlflow.log_issue(
trace_id=trace_id,
issue_id=issue.issue_id,
issue_name="wrong_name",
)
def test_log_issue_with_invalid_issue_id(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
with pytest.raises(
MlflowException,
match=r"Issue with ID 'iss-nonexistent' not found",
):
mlflow.log_issue(
trace_id=trace_id,
issue_id="iss-nonexistent",
issue_name="some_issue",
)
def test_log_feedback_and_exception_blocks_positional_args():
with pytest.raises(TypeError, match=r"log_feedback\(\) takes 0 positional"):
mlflow.log_feedback("tr-1234", "faithfulness", 1.0)
with pytest.raises(TypeError, match=r"log_expectation\(\) takes 0 positional"):
mlflow.log_expectation("tr-1234", "expected_answer", "MLflow")
with pytest.raises(TypeError, match=r"log_issue\(\) takes 0 positional"):
mlflow.log_issue("tr-1234", "iss-12345", "timeout_error")
@pytest.mark.parametrize("legacy_api", [True, False])
def test_log_assessment_on_in_progress_trace(trace_id, legacy_api):
@mlflow.trace
def func(x: int, y: int) -> int:
active_trace_id = mlflow.get_active_trace_id()
if legacy_api:
mlflow.log_assessment(active_trace_id, Feedback(name="feedback", value=1.0))
mlflow.log_assessment(active_trace_id, Expectation(name="expectation", value="MLflow"))
mlflow.log_assessment(trace_id, Feedback(name="other", value=2.0))
else:
mlflow.log_feedback(trace_id=active_trace_id, name="feedback", value=1.0)
mlflow.log_expectation(trace_id=active_trace_id, name="expectation", value="MLflow")
mlflow.log_feedback(trace_id=trace_id, name="other", value=2.0)
return x + y
assert func(1, 2) == 3
mlflow.flush_trace_async_logging()
# Two assessments should be logged as a part of StartTraceV3 call
trace = mlflow.get_trace(mlflow.get_last_active_trace_id())
assert len(trace.info.assessments) == 2
assessments = {a.name: a for a in trace.info.assessments}
assert assessments["feedback"].value == 1.0
assert assessments["expectation"].value == "MLflow"
# Assessment on the other trace
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assert trace.info.assessments[0].name == "other"
assert trace.info.assessments[0].feedback.value == 2.0
@pytest.mark.asyncio
async def test_log_assessment_on_in_progress_trace_async():
@mlflow.trace
async def func(x: int, y: int) -> int:
trace_id = mlflow.get_active_trace_id()
mlflow.log_assessment(trace_id, Feedback(name="feedback", value=1.0))
mlflow.log_assessment(trace_id, Expectation(name="expectation", value="MLflow"))
return x + y
assert (await func(1, 2)) == 3
mlflow.flush_trace_async_logging()
trace = mlflow.get_trace(mlflow.get_last_active_trace_id())
trace_info = trace.info
assert len(trace_info.assessments) == 2
assessments = {a.name: a for a in trace_info.assessments}
assert assessments["feedback"].feedback.value == 1.0
assert assessments["expectation"].expectation.value == "MLflow"
def test_log_assessment_on_in_progress_with_span_id():
with mlflow.start_span(name="test_span") as span:
# Only proceed if we have a real span (not NO_OP)
if span.span_id is not None and span.trace_id != "MLFLOW_NO_OP_SPAN_TRACE_ID":
mlflow.log_assessment(
trace_id=span.trace_id,
assessment=Feedback(name="feedback", value=1.0, span_id=span.span_id),
)
mlflow.flush_trace_async_logging()
# Two assessments should be logged as a part of StartTraceV3 call
trace = mlflow.get_trace(mlflow.get_last_active_trace_id())
trace_info = trace.info
assert len(trace_info.assessments) == 1
assert trace_info.assessments[0].name == "feedback"
assert trace_info.assessments[0].feedback.value == 1.0
assert trace_info.assessments[0].span_id == span.span_id
def test_log_assessment_on_in_progress_trace_works_when_tracing_is_disabled():
# Calling log_assessment to an active trace should not fail when tracing is disabled.
mlflow.tracing.disable()
@mlflow.trace
def func(x: int, y: int):
trace_id = mlflow.get_active_trace_id()
mlflow.log_assessment(trace_id=trace_id, assessment=Feedback(name="feedback", value=1.0))
return x + y
assert func(1, 2) == 3
mlflow.flush_trace_async_logging()
def test_get_assessment(trace_id):
assessment_id = mlflow.log_feedback(
trace_id=trace_id,
name="faithfulness",
value=1.0,
).assessment_id
result = mlflow.get_assessment(trace_id, assessment_id)
assert isinstance(result, Feedback)
assert result.name == "faithfulness"
assert result.trace_id == trace_id
assert result.value == 1.0
assert result.error is None
assert result.source.source_type == AssessmentSourceType.CODE
assert result.source.source_id == "default"
assert result.create_time_ms is not None
assert result.last_update_time_ms is not None
assert result.valid is True
assert result.overrides is None
def test_search_traces_with_assessments():
# Create traces with assessments
with mlflow.start_span(name="trace_1") as span_1:
mlflow.log_feedback(
trace_id=span_1.trace_id,
name="feedback_1",
value=1.0,
)
mlflow.log_expectation(
trace_id=span_1.trace_id,
name="expectation_1",
value="test",
source=AssessmentSource(source_id="test", source_type=AssessmentSourceType.LLM_JUDGE),
)
with mlflow.start_span(name="child") as span_1_child:
mlflow.log_feedback(
trace_id=span_1_child.trace_id,
name="feedback_2",
value=1.0,
span_id=span_1_child.span_id,
)
with mlflow.start_span(name="trace_2") as span_2:
mlflow.log_feedback(
trace_id=span_2.trace_id,
name="feedback_3",
value=1.0,
)
mlflow.flush_trace_async_logging()
traces = mlflow.search_traces(
locations=["0"],
max_results=2,
return_type="list",
order_by=["timestamp_ms"],
)
# Verify the results
assert len(traces) == 2
assert len(traces[0].info.assessments) == 3
assessments = {a.name: a for a in traces[0].info.assessments}
assert assessments["feedback_1"].trace_id == span_1.trace_id
assert assessments["feedback_1"].name == "feedback_1"
assert assessments["feedback_1"].value == 1.0
assert assessments["expectation_1"].trace_id == span_1.trace_id
assert assessments["expectation_1"].name == "expectation_1"
assert assessments["expectation_1"].value == "test"
assert assessments["feedback_2"].trace_id == span_1_child.trace_id
assert assessments["feedback_2"].name == "feedback_2"
assert assessments["feedback_2"].value == 1.0
assert len(traces[1].info.assessments) == 1
assessment = traces[1].info.assessments[0]
assert assessment.trace_id == span_2.trace_id
assert assessment.name == "feedback_3"
assert assessment.value == 1.0
@pytest.mark.parametrize("source_type", ["AI_JUDGE", AssessmentSourceType.AI_JUDGE])
def test_log_feedback_ai_judge_deprecation_warning(trace_id, source_type):
with pytest.warns(FutureWarning, match="AI_JUDGE is deprecated. Use LLM_JUDGE instead."):
ai_judge_source = AssessmentSource(source_type=source_type, source_id="gpt-4")
mlflow.log_feedback(
trace_id=trace_id,
name="quality",
value=0.8,
source=ai_judge_source,
rationale="AI evaluation",
)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.source.source_type == AssessmentSourceType.LLM_JUDGE
assert assessment.source.source_id == "gpt-4"
assert assessment.name == "quality"
assert assessment.feedback.value == 0.8
assert assessment.rationale == "AI evaluation"
def test_log_issue_reference(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="timeout_error",
description="Timeout errors in API calls",
status=IssueStatus.PENDING,
)
issue_ref = IssueReference(
issue_id=issue.issue_id,
issue_name="timeout_error",
source=_CODE_ASSESSMENT_SOURCE,
metadata={"severity": "high", "affected_count": "150"},
)
mlflow.log_assessment(trace_id=trace_id, assessment=issue_ref)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert isinstance(assessment, IssueReference)
assert assessment.trace_id == trace_id
assert assessment.name == issue.issue_id
assert assessment.issue_id == issue.issue_id
assert assessment.issue_name == "timeout_error"
assert assessment.span_id is None
assert assessment.source == _CODE_ASSESSMENT_SOURCE
assert assessment.create_time_ms is not None
assert assessment.last_update_time_ms is not None
assert assessment.issue.issue_name == "timeout_error"
assert assessment.expectation is None
assert assessment.feedback is None
assert assessment.metadata == {"severity": "high", "affected_count": "150"}
def test_log_issue_reference_invalid_parameters():
with pytest.raises(MlflowException, match=r"The `issue_id` field must be specified"):
IssueReference(
issue_id=None,
issue_name="test_issue",
source=_CODE_ASSESSMENT_SOURCE,
)
def test_log_issue_reference_default_source(trace_id, tracking_uri):
if tracking_uri.startswith("file:"):
pytest.skip("Issue APIs are not supported with file-based tracking URI")
tracing_client = mlflow.MlflowClient()._tracing_client
issue = tracing_client._create_issue(
experiment_id="0",
name="connection_issue",
description="Connection issues",
status=IssueStatus.PENDING,
)
issue_ref = IssueReference(
issue_id=issue.issue_id,
issue_name="connection_issue",
)
mlflow.log_assessment(trace_id=trace_id, assessment=issue_ref)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 1
assessment = trace.info.assessments[0]
assert assessment.name == issue.issue_id
assert assessment.issue_name == "connection_issue"
assert assessment.trace_id == trace_id
assert assessment.issue_id == issue.issue_id
assert assessment.source.source_type == AssessmentSourceType.LLM_JUDGE
assert assessment.source.source_id == "default"
def test_get_issue_reference_assessment(trace_id):
issue_ref = IssueReference(
issue_id="iss-55555",
issue_name="performance_issue",
metadata={"category": "latency"},
)
assessment_id = mlflow.log_assessment(trace_id=trace_id, assessment=issue_ref).assessment_id
result = mlflow.get_assessment(trace_id, assessment_id)
assert isinstance(result, IssueReference)
assert result.name == "iss-55555"
assert result.issue_name == "performance_issue"
assert result.trace_id == trace_id
assert result.issue_id == "iss-55555"
assert result.source.source_type == AssessmentSourceType.LLM_JUDGE
assert result.source.source_id == "default"
assert result.create_time_ms is not None
assert result.last_update_time_ms is not None
assert result.metadata == {"category": "latency"}
def test_log_multiple_assessment_types(trace_id):
feedback = Feedback(
name="accuracy",
value=0.95,
source=_LLM_ASSESSMENT_SOURCE,
)
mlflow.log_assessment(trace_id=trace_id, assessment=feedback)
expectation = Expectation(
name="expected_output",
value="MLflow",
source=_HUMAN_ASSESSMENT_SOURCE,
)
mlflow.log_assessment(trace_id=trace_id, assessment=expectation)
issue_ref = IssueReference(
issue_id="iss-11111",
issue_name="data_quality_issue",
source=_CODE_ASSESSMENT_SOURCE,
)
mlflow.log_assessment(trace_id=trace_id, assessment=issue_ref)
trace = mlflow.get_trace(trace_id)
assert len(trace.info.assessments) == 3
assessments_by_type = {}
for a in trace.info.assessments:
if isinstance(a, Feedback):
assessments_by_type["feedback"] = a
elif isinstance(a, Expectation):
assessments_by_type["expectation"] = a
elif isinstance(a, IssueReference):
assessments_by_type["issue"] = a
assert assessments_by_type["feedback"].name == "accuracy"
assert assessments_by_type["feedback"].value == 0.95
assert assessments_by_type["expectation"].name == "expected_output"
assert assessments_by_type["expectation"].value == "MLflow"
assert assessments_by_type["issue"].name == "iss-11111"
assert assessments_by_type["issue"].issue_name == "data_quality_issue"
assert assessments_by_type["issue"].issue_id == "iss-11111"
def test_log_feedback_in_distributed_trace_same_process():
captured_headers = {}
tm = InMemoryTraceManager.get_instance()
@mlflow.trace(name="root")
def root():
nonlocal captured_headers
captured_headers = get_tracing_context_headers_for_http_request()
trace_id = mlflow.get_active_trace_id()
with tm.get_trace(trace_id) as t:
assert t is not None
assert not t.is_remote_trace
root()
root_trace_id = mlflow.get_last_active_trace_id()
assert root_trace_id is not None
with set_tracing_context_from_http_request_headers(captured_headers):
@mlflow.trace(name="child")
def child():
trace_id = mlflow.get_active_trace_id()
return mlflow.log_feedback(
trace_id=trace_id,
name="child_was_called",
value="yes",
)
child()
mlflow.flush_trace_async_logging()
trace = mlflow.get_trace(root_trace_id)
assert len(trace.info.assessments) == 1
assert trace.info.assessments[0].name == "child_was_called"
assert trace.info.assessments[0].feedback.value == "yes"
def test_log_feedback_in_distributed_trace_cross_process():
captured_headers = {}
@mlflow.trace(name="root")
def root():
nonlocal captured_headers
captured_headers = get_tracing_context_headers_for_http_request()
root()
mlflow.flush_trace_async_logging()
root_trace_id = mlflow.get_last_active_trace_id()
tm = InMemoryTraceManager.get_instance()
with tm.get_trace(root_trace_id) as t:
assert t is None
mock_store = mock.MagicMock()
mock_assessment = mock.MagicMock()
mock_assessment.assessment_id = "a-test-assessment-id"
mock_store.create_assessment.return_value = mock_assessment
with mock.patch("mlflow.tracing.client._get_store", return_value=mock_store):
with set_tracing_context_from_http_request_headers(captured_headers):
@mlflow.trace(name="child")
def child():
trace_id = mlflow.get_active_trace_id()
return mlflow.log_feedback(
trace_id=trace_id,
name="child_was_called",
value="yes",
)
result = child()
mock_store.create_assessment.assert_called_once()
called_assessment = mock_store.create_assessment.call_args[0][0]
assert called_assessment.name == "child_was_called"
assert called_assessment.feedback.value == "yes"
assert result.assessment_id == "a-test-assessment-id"
def test_log_assessment_active_trace_not_in_memory():
null_cm = mock.MagicMock()
null_cm.__enter__.return_value = None
null_cm.__exit__.return_value = False
mock_store = mock.MagicMock()
with mlflow.start_span(name="root"):
with (
mock.patch.object(
InMemoryTraceManager.get_instance(), "get_trace", return_value=null_cm
),
mock.patch("mlflow.tracing.client._get_store", return_value=mock_store),
):
result = mlflow.log_feedback(
trace_id=mlflow.get_active_trace_id(),
name="test_feedback",
value=1.0,
)
mock_store.create_assessment.assert_not_called()
assert result is not None
assert result.assessment_id is None