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

205 lines
8.8 KiB
Python

import os
from unittest import mock
import pytest
from mlflow.entities.run_status import RunStatus
from mlflow.genai.evaluation.job import invoke_genai_evaluate_job
def _serialized_scorer(name: str = "scorer") -> str:
return f'{{"name": "{name}"}}'
def test_invoke_genai_evaluate_job_has_metadata():
"""Without ``_job_fn_metadata`` the function isn't registered with the
job runner and POSTs to the endpoint will silently fail to submit.
"""
assert hasattr(invoke_genai_evaluate_job, "_job_fn_metadata")
assert invoke_genai_evaluate_job._job_fn_metadata.name == "invoke_genai_evaluate"
def test_invoke_genai_evaluate_job_success():
mock_client = mock.MagicMock()
mock_trace = mock.MagicMock()
mock_client._tracing_client.batch_get_traces.return_value = [mock_trace, mock_trace]
mock_scorer = mock.MagicMock()
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch(
"mlflow.genai.evaluation.job.Scorer.model_validate_json", return_value=mock_scorer
) as mock_validate,
mock.patch("mlflow.start_run") as mock_start_run,
mock.patch("mlflow.genai.evaluate") as mock_evaluate,
):
result = invoke_genai_evaluate_job(
trace_ids=["trace-1", "trace-2"],
serialized_scorers=[_serialized_scorer("a"), _serialized_scorer("b")],
run_id="run-123",
)
mock_client.link_traces_to_run.assert_called_once_with(["trace-1", "trace-2"], "run-123")
mock_client._tracing_client.batch_get_traces.assert_called_once_with(["trace-1", "trace-2"])
assert mock_validate.call_count == 2
# Run reuse is the whole reason the handler creates the run upfront —
# if we forget ``run_id=...`` mlflow.genai.evaluate will start a fresh
# nested run and the UI's navigate-to-run-page link will be wrong.
mock_start_run.assert_called_once_with(run_id="run-123")
mock_evaluate.assert_called_once()
evaluate_kwargs = mock_evaluate.call_args.kwargs
assert evaluate_kwargs["data"] == [mock_trace, mock_trace]
assert evaluate_kwargs["scorers"] == [mock_scorer, mock_scorer]
# On the happy path the ActiveRun context manager's __exit__ writes
# FINISHED for us; the job must NOT also call set_terminated explicitly
# (that would be a duplicate write to the same run). End-to-end FINISHED
# transition is verified in tests/server/jobs/test_genai_evaluate_invocation.py.
mock_client.set_terminated.assert_not_called()
assert result == {"run_id": "run-123", "total_traces": 2, "scorer_count": 2}
def test_invoke_genai_evaluate_job_batches_large_trace_list():
"""``link_traces_to_run`` has a per-call cap of ``MAX_TRACE_LINKS_PER_REQUEST``
(100), so we must batch large trace lists or the call will be rejected.
"""
mock_client = mock.MagicMock()
mock_traces = [mock.MagicMock() for _ in range(250)]
mock_client._tracing_client.batch_get_traces.return_value = mock_traces
trace_ids = [f"trace-{i}" for i in range(250)]
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch("mlflow.genai.evaluation.job.Scorer.model_validate_json"),
mock.patch("mlflow.start_run"),
mock.patch("mlflow.genai.evaluate"),
):
invoke_genai_evaluate_job(
trace_ids=trace_ids,
serialized_scorers=[_serialized_scorer()],
run_id="run-123",
)
# 250 traces with a cap of 100 = three batches of (100, 100, 50).
assert mock_client.link_traces_to_run.call_count == 3
batch_sizes = [len(call.args[0]) for call in mock_client.link_traces_to_run.call_args_list]
assert batch_sizes == [100, 100, 50]
def test_invoke_genai_evaluate_job_setup_failure_marks_run_failed():
"""If setup fails BEFORE the active-run context manager is entered (here:
``batch_get_traces`` raising), the context manager's __exit__ never runs,
so the job itself must flip the run to FAILED — otherwise it'd stay
RUNNING forever in /evaluation-runs.
"""
mock_client = mock.MagicMock()
mock_client._tracing_client.batch_get_traces.side_effect = Exception("trace fetch failed")
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch("mlflow.start_run") as mock_start_run,
mock.patch("mlflow.genai.evaluate") as mock_evaluate,
):
with pytest.raises(Exception, match="trace fetch failed"):
invoke_genai_evaluate_job(
trace_ids=["trace-1"],
serialized_scorers=[_serialized_scorer()],
run_id="run-123",
)
# Setup blew up *before* we reached the with block, so neither
# mlflow.start_run nor mlflow.genai.evaluate should have been touched.
mock_start_run.assert_not_called()
mock_evaluate.assert_not_called()
# This is the only path where the job code itself writes the terminal
# status — the context manager can't help here because we never entered it.
mock_client.set_terminated.assert_called_once_with(
"run-123", RunStatus.to_string(RunStatus.FAILED)
)
def test_invoke_genai_evaluate_job_evaluate_failure_defers_to_context_manager():
"""When ``mlflow.genai.evaluate`` raises INSIDE the active-run context
manager, terminal status is owned by ``ActiveRun.__exit__`` (see
mlflow/tracking/fluent.py:380), so the job code itself must NOT also call
set_terminated. The exception must still propagate so huey records the
job as FAILED with the original traceback. End-to-end FAILED transition
is covered by tests/server/jobs/test_genai_evaluate_invocation.py
(``test_invoke_genai_evaluate_missing_trace_marks_run_failed``).
"""
mock_client = mock.MagicMock()
mock_client._tracing_client.batch_get_traces.return_value = [mock.MagicMock()]
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch("mlflow.genai.evaluation.job.Scorer.model_validate_json"),
mock.patch("mlflow.start_run"),
mock.patch("mlflow.genai.evaluate", side_effect=Exception("harness boom")),
):
with pytest.raises(Exception, match="harness boom"):
invoke_genai_evaluate_job(
trace_ids=["trace-1"],
serialized_scorers=[_serialized_scorer()],
run_id="run-123",
)
# Critical: must NOT call set_terminated explicitly here. Doing so
# would be a duplicate write on top of the context manager's __exit__.
mock_client.set_terminated.assert_not_called()
def test_invoke_genai_evaluate_job_propagates_username_via_env(monkeypatch):
"""``username`` is propagated to subprocess env vars so judge LLM calls
are authorised as the original caller, not the server admin. Same
pattern as ``invoke_scorer_job``.
"""
mock_client = mock.MagicMock()
mock_client._tracing_client.batch_get_traces.return_value = [mock.MagicMock()]
monkeypatch.delenv("MLFLOW_TRACKING_USERNAME", raising=False)
captured_env: dict[str, str] = {}
def _capture_env(*args, **kwargs):
captured_env["MLFLOW_TRACKING_USERNAME"] = os.environ.get("MLFLOW_TRACKING_USERNAME", "")
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch("mlflow.genai.evaluation.job.Scorer.model_validate_json"),
mock.patch("mlflow.start_run"),
mock.patch("mlflow.genai.evaluate", side_effect=_capture_env) as mock_evaluate,
):
invoke_genai_evaluate_job(
trace_ids=["trace-1"],
serialized_scorers=[_serialized_scorer()],
run_id="run-123",
username="alice",
)
mock_evaluate.assert_called_once()
assert captured_env["MLFLOW_TRACKING_USERNAME"] == "alice"
def test_invoke_genai_evaluate_job_skips_username_propagation_when_none(monkeypatch):
mock_client = mock.MagicMock()
mock_client._tracing_client.batch_get_traces.return_value = [mock.MagicMock()]
# Pre-populate the env so we can detect a write where we shouldn't see one.
monkeypatch.setenv("MLFLOW_TRACKING_USERNAME", "unchanged")
with (
mock.patch("mlflow.genai.evaluation.job.MlflowClient", return_value=mock_client),
mock.patch("mlflow.genai.evaluation.job.Scorer.model_validate_json"),
mock.patch("mlflow.start_run"),
mock.patch("mlflow.genai.evaluate") as mock_evaluate,
):
invoke_genai_evaluate_job(
trace_ids=["trace-1"],
serialized_scorers=[_serialized_scorer()],
run_id="run-123",
username=None,
)
mock_evaluate.assert_called_once()
assert os.environ["MLFLOW_TRACKING_USERNAME"] == "unchanged"