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

479 lines
17 KiB
Python

from typing import Any
import pandas as pd
import pytest
import mlflow
from mlflow.entities.model_registry import PromptModelConfig
from mlflow.exceptions import MlflowException
from mlflow.genai.datasets import create_dataset
from mlflow.genai.optimize.optimize import optimize_prompts
from mlflow.genai.optimize.optimizers.base import BasePromptOptimizer
from mlflow.genai.optimize.types import EvaluationResultRecord, PromptOptimizerOutput
from mlflow.genai.prompts import register_prompt
from mlflow.genai.scorers import scorer
from mlflow.models.model import PromptVersion
from mlflow.utils.import_hooks import _post_import_hooks
class MockPromptOptimizer(BasePromptOptimizer):
def __init__(self, reflection_model="openai:/gpt-4o-mini"):
self.model_name = reflection_model
def optimize(
self,
eval_fn: Any,
train_data: list[dict[str, Any]],
target_prompts: dict[str, str],
enable_tracking: bool = True,
) -> PromptOptimizerOutput:
optimized_prompts = {}
for prompt_name, template in target_prompts.items():
# Simple optimization: add "Be precise and accurate. " prefix
optimized_prompts[prompt_name] = f"Be precise and accurate. {template}"
# Verify the optimization by calling eval_fn (only if provided)
if eval_fn is not None:
eval_fn(optimized_prompts, train_data)
return PromptOptimizerOutput(
optimized_prompts=optimized_prompts,
initial_eval_score=0.5,
final_eval_score=0.9,
)
@pytest.fixture
def sample_translation_prompt() -> PromptVersion:
return register_prompt(
name="test_translation_prompt",
template="Translate the following text to {{language}}: {{input_text}}",
)
@pytest.fixture
def sample_summarization_prompt() -> PromptVersion:
return register_prompt(
name="test_summarization_prompt",
template="Summarize this text: {{text}}",
)
@pytest.fixture
def sample_dataset() -> pd.DataFrame:
return pd.DataFrame({
"inputs": [
{"input_text": "Hello", "language": "Spanish"},
{"input_text": "World", "language": "French"},
{"input_text": "Goodbye", "language": "Spanish"},
],
"outputs": [
"Hola",
"Monde",
"Adiós",
],
})
@pytest.fixture
def sample_summarization_dataset() -> list[dict[str, Any]]:
return [
{
"inputs": {
"text": "This is a long document that needs to be summarized into key points."
},
"outputs": "Key points summary",
},
{
"inputs": {"text": "Another document with important information for summarization."},
"outputs": "Important info summary",
},
]
def sample_predict_fn(input_text: str, language: str) -> str:
mlflow.genai.load_prompt("prompts:/test_translation_prompt/1")
translations = {
("Hello", "Spanish"): "Hola",
("World", "French"): "Monde",
("Goodbye", "Spanish"): "Adiós",
}
# Verify that auto logging is enabled during the evaluation.
assert len(_post_import_hooks) > 0
return translations.get((input_text, language), f"translated_{input_text}")
def sample_summarization_fn(text: str) -> str:
return f"Summary of: {text[:20]}..."
@mlflow.genai.scorers.scorer(name="equivalence")
def equivalence(outputs, expectations):
return 1.0 if outputs == expectations["expected_response"] else 0.0
def test_optimize_prompts_single_prompt(
sample_translation_prompt: PromptVersion, sample_dataset: pd.DataFrame
):
mock_optimizer = MockPromptOptimizer()
result = optimize_prompts(
predict_fn=sample_predict_fn,
train_data=sample_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=mock_optimizer,
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 1
optimized_prompt = result.optimized_prompts[0]
assert optimized_prompt.name == sample_translation_prompt.name
assert optimized_prompt.version == sample_translation_prompt.version + 1
assert "Be precise and accurate." in optimized_prompt.template
expected_template = "Translate the following text to {{language}}: {{input_text}}"
assert expected_template in optimized_prompt.template
assert result.initial_eval_score == 0.5
assert result.final_eval_score == 0.9
def test_optimize_prompts_multiple_prompts(
sample_translation_prompt: PromptVersion,
sample_summarization_prompt: PromptVersion,
sample_dataset: pd.DataFrame,
):
mock_optimizer = MockPromptOptimizer()
result = optimize_prompts(
predict_fn=sample_predict_fn,
train_data=sample_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}",
f"prompts:/{sample_summarization_prompt.name}/{sample_summarization_prompt.version}",
],
optimizer=mock_optimizer,
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 2
prompt_names = {prompt.name for prompt in result.optimized_prompts}
assert sample_translation_prompt.name in prompt_names
assert sample_summarization_prompt.name in prompt_names
assert result.initial_eval_score == 0.5
assert result.final_eval_score == 0.9
for prompt in result.optimized_prompts:
assert "Be precise and accurate." in prompt.template
def test_optimize_prompts_eval_function_behavior(
sample_translation_prompt: PromptVersion, sample_dataset: pd.DataFrame
):
class TestingOptimizer(BasePromptOptimizer):
def __init__(self):
self.model_name = "openai:/gpt-4o-mini"
self.eval_fn_calls = []
def optimize(self, eval_fn, dataset, target_prompts, enable_tracking=True):
# Test that eval_fn works correctly
test_prompts = {
"test_translation_prompt": "Prompt Candidate: "
"Translate {{input_text}} to {{language}}"
}
results = eval_fn(test_prompts, dataset)
self.eval_fn_calls.append((test_prompts, results))
# Verify results structure
assert isinstance(results, list)
assert len(results) == len(dataset)
for i, result in enumerate(results):
assert isinstance(result, EvaluationResultRecord)
assert result.inputs == dataset[i]["inputs"]
assert result.outputs == dataset[i]["outputs"]
assert result.score == 1
assert result.trace is not None
return PromptOptimizerOutput(optimized_prompts=target_prompts)
predict_called_count = 0
def predict_fn(input_text, language):
prompt = mlflow.genai.load_prompt("prompts:/test_translation_prompt/1").format(
input_text=input_text, language=language
)
nonlocal predict_called_count
# the first call to the predict_fn is the model check
if predict_called_count > 0:
# validate the prompt is replaced with the candidate prompt
assert "Prompt Candidate" in prompt
predict_called_count += 1
return sample_predict_fn(input_text=input_text, language=language)
testing_optimizer = TestingOptimizer()
optimize_prompts(
predict_fn=predict_fn,
train_data=sample_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=testing_optimizer,
scorers=[equivalence],
)
assert len(testing_optimizer.eval_fn_calls) == 1
_, eval_results = testing_optimizer.eval_fn_calls[0]
assert len(eval_results) == 3 # Number of records in sample_dataset
assert predict_called_count == 4 # 3 records in sample_dataset + 1 for the prediction check
def test_optimize_prompts_with_list_dataset(
sample_translation_prompt: PromptVersion, sample_summarization_dataset: list[dict[str, Any]]
):
mock_optimizer = MockPromptOptimizer()
def summarization_predict_fn(text):
return f"Summary: {text[:10]}..."
result = optimize_prompts(
predict_fn=summarization_predict_fn,
train_data=sample_summarization_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=mock_optimizer,
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 1
assert result.initial_eval_score == 0.5
assert result.final_eval_score == 0.9
def test_optimize_prompts_with_model_name(
sample_translation_prompt: PromptVersion, sample_dataset: pd.DataFrame
):
class TestOptimizer(BasePromptOptimizer):
def __init__(self):
self.model_name = "test/custom-model"
def optimize(self, eval_fn, dataset, target_prompts, enable_tracking=True):
return PromptOptimizerOutput(optimized_prompts=target_prompts)
testing_optimizer = TestOptimizer()
result = optimize_prompts(
predict_fn=sample_predict_fn,
train_data=sample_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=testing_optimizer,
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 1
def test_optimize_prompts_warns_on_unused_prompt(
sample_translation_prompt: PromptVersion,
sample_summarization_prompt: PromptVersion,
sample_dataset: pd.DataFrame,
capsys,
):
mock_optimizer = MockPromptOptimizer()
# Create predict_fn that only uses translation prompt, not summarization prompt
def predict_fn_single_prompt(input_text, language):
prompt = mlflow.genai.load_prompt("prompts:/test_translation_prompt/1")
prompt.format(input_text=input_text, language=language)
return sample_predict_fn(input_text=input_text, language=language)
result = optimize_prompts(
predict_fn=predict_fn_single_prompt,
train_data=sample_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}",
f"prompts:/{sample_summarization_prompt.name}/{sample_summarization_prompt.version}",
],
optimizer=mock_optimizer,
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 2
captured = capsys.readouterr()
assert "prompts were not used during evaluation" in captured.err
assert "test_summarization_prompt" in captured.err
def test_optimize_prompts_with_custom_scorers(
sample_translation_prompt: PromptVersion, sample_dataset: pd.DataFrame
):
# Create a custom scorer for case-insensitive matching
@scorer(name="case_insensitive_match")
def case_insensitive_match(outputs, expectations):
# Extract expected_response if expectations is a dict
if isinstance(expectations, dict) and "expected_response" in expectations:
expected_value = expectations["expected_response"]
else:
expected_value = expectations
return 1.0 if str(outputs).lower() == str(expected_value).lower() else 0.5
class MetricTestOptimizer(BasePromptOptimizer):
def __init__(self):
self.model_name = "openai:/gpt-4o-mini"
self.captured_scores = []
def optimize(self, eval_fn, dataset, target_prompts, enable_tracking=True):
# Run eval_fn and capture the scores
results = eval_fn(target_prompts, dataset)
self.captured_scores = [r.score for r in results]
return PromptOptimizerOutput(optimized_prompts=target_prompts)
testing_optimizer = MetricTestOptimizer()
# Create dataset with outputs that will test custom scorer
test_dataset = pd.DataFrame({
"inputs": [
{"input_text": "Hello", "language": "Spanish"},
{"input_text": "World", "language": "French"},
],
"outputs": ["HOLA", "monde"], # Different cases to test custom scorer
})
def predict_fn(input_text, language):
mlflow.genai.load_prompt("prompts:/test_translation_prompt/1")
# Return lowercase outputs
return {"Hello": "hola", "World": "monde"}.get(input_text, "unknown")
result = optimize_prompts(
predict_fn=predict_fn,
train_data=test_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
scorers=[case_insensitive_match],
optimizer=testing_optimizer,
)
# Verify custom scorer was used
# "hola" vs "HOLA" (case insensitive match) -> 1.0
# "monde" vs "monde" (exact match) -> 1.0
assert testing_optimizer.captured_scores == [1.0, 1.0]
assert len(result.optimized_prompts) == 1
@pytest.mark.parametrize(
("train_data", "error_match"),
[
# Missing inputs validation (handled by _convert_eval_set_to_df)
([{"outputs": "Hola"}], "Either `inputs` or `trace` column is required"),
# Empty inputs validation
(
[{"inputs": {}, "outputs": "Hola"}],
"Record 0 is missing required 'inputs' field or it is empty",
),
],
)
def test_optimize_prompts_validation_errors(
sample_translation_prompt: PromptVersion,
train_data: list[dict[str, Any]],
error_match: str,
):
with pytest.raises(MlflowException, match=error_match):
optimize_prompts(
predict_fn=sample_predict_fn,
train_data=train_data,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=MockPromptOptimizer(),
scorers=[equivalence],
)
def test_optimize_prompts_with_chat_prompt(
sample_translation_prompt: PromptVersion, sample_dataset: pd.DataFrame
):
chat_prompt = register_prompt(
name="test_chat_prompt",
template=[{"role": "user", "content": "{{input_text}}"}],
)
with pytest.raises(MlflowException, match="Only text prompts can be optimized"):
optimize_prompts(
predict_fn=sample_predict_fn,
train_data=sample_dataset,
prompt_uris=[f"prompts:/{chat_prompt.name}/{chat_prompt.version}"],
optimizer=MockPromptOptimizer(),
scorers=[equivalence],
)
def test_optimize_prompts_with_managed_evaluation_dataset(
sample_translation_prompt: PromptVersion,
sample_dataset: pd.DataFrame,
):
# Create a `ManagedEvaluationDataset` and populate it with records from sample_dataset
managed_dataset = create_dataset(name="test_optimize_managed_dataset")
managed_dataset.merge_records(sample_dataset)
result = optimize_prompts(
predict_fn=sample_predict_fn,
train_data=managed_dataset,
prompt_uris=[
f"prompts:/{sample_translation_prompt.name}/{sample_translation_prompt.version}"
],
optimizer=MockPromptOptimizer(),
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 1
assert result.initial_eval_score == 0.5
assert result.final_eval_score == 0.9
def test_optimize_prompts_preserves_model_config(sample_dataset: pd.DataFrame):
source_model_config = PromptModelConfig(
provider="openai",
model_name="gpt-4o",
temperature=0.7,
max_tokens=1000,
)
prompt_with_config = register_prompt(
name="test_prompt_with_model_config",
template="Translate the following text to {{language}}: {{input_text}}",
model_config=source_model_config,
)
assert prompt_with_config.model_config is not None
def predict_fn(input_text: str, language: str) -> str:
mlflow.genai.load_prompt(f"prompts:/{prompt_with_config.name}/1")
translations = {
("Hello", "Spanish"): "Hola",
("World", "French"): "Monde",
("Goodbye", "Spanish"): "Adiós",
}
return translations.get((input_text, language), f"translated_{input_text}")
result = optimize_prompts(
predict_fn=predict_fn,
train_data=sample_dataset,
prompt_uris=[f"prompts:/{prompt_with_config.name}/{prompt_with_config.version}"],
optimizer=MockPromptOptimizer(),
scorers=[equivalence],
)
assert len(result.optimized_prompts) == 1
optimized_prompt = result.optimized_prompts[0]
assert optimized_prompt.model_config["provider"] == "openai"
assert optimized_prompt.model_config["model_name"] == "gpt-4o"
assert optimized_prompt.model_config["temperature"] == 0.7
assert optimized_prompt.model_config["max_tokens"] == 1000