1754 lines
57 KiB
Python
1754 lines
57 KiB
Python
import asyncio
|
|
import json
|
|
from collections import OrderedDict
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
import httpx
|
|
import numpy as np
|
|
import openai
|
|
import pandas as pd
|
|
import pytest
|
|
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
|
|
|
|
import mlflow
|
|
from mlflow.entities.assessment import Expectation
|
|
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
|
|
from mlflow.entities.dataset_record_source import DatasetRecordSource, DatasetRecordSourceType
|
|
from mlflow.entities.span import Span, SpanType
|
|
from mlflow.entities.trace import Trace
|
|
from mlflow.entities.trace_data import TraceData
|
|
from mlflow.genai.evaluation.entities import EvalItem
|
|
from mlflow.genai.evaluation.utils import is_none_or_nan
|
|
from mlflow.genai.scorers.base import scorer
|
|
from mlflow.genai.utils.trace_utils import (
|
|
_does_store_support_trace_linking,
|
|
_extract_tool_name_from_span,
|
|
_parse_chunk,
|
|
_should_keep_trace,
|
|
_try_extract_available_tools_with_llm,
|
|
clean_up_extra_traces,
|
|
convert_predict_fn,
|
|
create_minimal_trace,
|
|
extract_available_tools_from_trace,
|
|
extract_expectations_from_trace,
|
|
extract_inputs_from_trace,
|
|
extract_outputs_from_trace,
|
|
extract_request_from_trace,
|
|
extract_response_from_trace,
|
|
extract_retrieval_context_from_trace,
|
|
parse_inputs_to_str,
|
|
parse_outputs_to_str,
|
|
parse_tool_call_messages_from_trace,
|
|
resolve_conversation_from_session,
|
|
resolve_expectations_from_session,
|
|
)
|
|
from mlflow.tracing import set_span_chat_tools
|
|
from mlflow.tracing.constant import TraceMetadataKey
|
|
from mlflow.tracing.utils import build_otel_context
|
|
from mlflow.types.chat import ChatTool, FunctionToolDefinition
|
|
|
|
from tests.tracing.helper import create_test_trace_info, get_traces, purge_traces
|
|
|
|
|
|
def httpx_send_patch(request, *args, **kwargs):
|
|
return httpx.Response(
|
|
status_code=200,
|
|
request=request,
|
|
json={
|
|
"id": "chatcmpl-Ax4UAd5xf32KjgLkS1SEEY9oorI9m",
|
|
"object": "chat.completion",
|
|
"created": 1738641958,
|
|
"model": "gpt-4o-2024-08-06",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "test",
|
|
"refusal": None,
|
|
},
|
|
"logprobs": None,
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def get_openai_predict_fn(with_tracing=False):
|
|
if with_tracing:
|
|
mlflow.openai.autolog()
|
|
|
|
def predict_fn(request):
|
|
with mock.patch("httpx.Client.send", side_effect=httpx_send_patch):
|
|
response = openai.OpenAI().chat.completions.create(
|
|
messages=request["messages"],
|
|
model="gpt-4o-mini",
|
|
)
|
|
return response.choices[0].message.content
|
|
|
|
return predict_fn
|
|
|
|
|
|
def get_dummy_predict_fn(with_tracing=False):
|
|
def predict_fn(request):
|
|
return "test"
|
|
|
|
if with_tracing:
|
|
return mlflow.trace(predict_fn)
|
|
|
|
return predict_fn
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_openai_env(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "fake_api_key")
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_openai_env")
|
|
@pytest.mark.parametrize(
|
|
("predict_fn_generator", "with_tracing", "should_be_wrapped"),
|
|
[
|
|
(get_dummy_predict_fn, False, True),
|
|
# If the function is already traced, it should not be wrapped with @mlflow.trace.
|
|
(get_dummy_predict_fn, True, False),
|
|
# OpenAI autologging is automatically enabled during evaluation,
|
|
# so we don't need to wrap the function with @mlflow.trace.
|
|
(get_openai_predict_fn, False, False),
|
|
(get_openai_predict_fn, True, False),
|
|
],
|
|
ids=[
|
|
"dummy predict_fn without tracing",
|
|
"dummy predict_fn with tracing",
|
|
"openai predict_fn without tracing",
|
|
"openai predict_fn with tracing",
|
|
],
|
|
)
|
|
def test_convert_predict_fn(predict_fn_generator, with_tracing, should_be_wrapped):
|
|
predict_fn = predict_fn_generator(with_tracing=with_tracing)
|
|
sample_input = {"request": {"messages": [{"role": "user", "content": "test"}]}}
|
|
|
|
# predict_fn is callable as is
|
|
result = predict_fn(**sample_input)
|
|
assert result == "test"
|
|
assert len(get_traces()) == (1 if with_tracing else 0)
|
|
purge_traces()
|
|
|
|
converted_fn = convert_predict_fn(predict_fn, sample_input)
|
|
|
|
# converted function takes a single 'request' argument
|
|
result = converted_fn(request=sample_input)
|
|
assert result == "test"
|
|
|
|
# Trace should be generated if decorated or wrapped with @mlflow.trace
|
|
assert len(get_traces()) == (1 if with_tracing or should_be_wrapped else 0)
|
|
purge_traces()
|
|
|
|
# All function should generate a trace when executed through mlflow.genai.evaluate
|
|
@scorer
|
|
def dummy_scorer(inputs, outputs):
|
|
return 0
|
|
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": sample_input}],
|
|
predict_fn=predict_fn,
|
|
scorers=[dummy_scorer],
|
|
)
|
|
assert len(get_traces()) == 1
|
|
|
|
|
|
def test_convert_predict_fn_skip_validation(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SKIP_TRACE_VALIDATION", "true")
|
|
|
|
call_count = 0
|
|
|
|
def dummy_predict_fn(question: str, context: str):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return question + context
|
|
|
|
sample_input = {"question": "test", "context": "test"}
|
|
converted_fn = convert_predict_fn(dummy_predict_fn, sample_input)
|
|
# Predict function should not be validated when the env var is set to True
|
|
assert call_count == 0
|
|
|
|
# converted function takes a single 'request' argument
|
|
result = converted_fn(request=sample_input)
|
|
assert result == "testtest"
|
|
|
|
|
|
def create_span(
|
|
span_id: int,
|
|
parent_id: int,
|
|
span_type: str,
|
|
inputs: dict[str, Any],
|
|
outputs: dict[str, Any],
|
|
) -> Span:
|
|
otel_span = OTelReadableSpan(
|
|
name="test",
|
|
context=build_otel_context(123, span_id),
|
|
parent=build_otel_context(123, parent_id) if parent_id else None,
|
|
start_time=100,
|
|
end_time=200,
|
|
attributes={
|
|
"mlflow.spanInputs": json.dumps(inputs),
|
|
"mlflow.spanOutputs": json.dumps(outputs),
|
|
"mlflow.spanType": json.dumps(span_type),
|
|
},
|
|
)
|
|
return Span(otel_span)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("spans", "expected_retrieval_context"),
|
|
[
|
|
# multiple retrieval steps - only take the last top-level one
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None, # root span
|
|
inputs="question",
|
|
outputs={"generations": [[{"text": "some text"}]]},
|
|
span_type=SpanType.LLM,
|
|
),
|
|
create_span(
|
|
span_id=2,
|
|
parent_id=1,
|
|
inputs="What is the capital of France?",
|
|
outputs=[
|
|
{
|
|
"page_content": "document content 3",
|
|
"metadata": {
|
|
"doc_uri": "uri3",
|
|
"chunk_id": "3",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
create_span(
|
|
span_id=3,
|
|
parent_id=1,
|
|
inputs="What is the capital of France?",
|
|
outputs=[
|
|
{
|
|
"page_content": "document content 1",
|
|
"metadata": {
|
|
"doc_uri": "uri1",
|
|
"chunk_id": "1",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
{
|
|
"page_content": "document content 2",
|
|
"metadata": {
|
|
"doc_uri": "uri2",
|
|
"chunk_id": "2",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
create_span(
|
|
span_id=4,
|
|
parent_id=3,
|
|
inputs="This should be ignored because it's not a top-level retrieval span",
|
|
outputs=[
|
|
{
|
|
"page_content": "document content 4",
|
|
"metadata": {
|
|
"doc_uri": "uri4",
|
|
"chunk_id": "4",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
],
|
|
{
|
|
"0000000000000002": [
|
|
{
|
|
"doc_uri": "uri3",
|
|
"content": "document content 3",
|
|
},
|
|
],
|
|
"0000000000000003": [
|
|
{
|
|
"doc_uri": "uri1",
|
|
"content": "document content 1",
|
|
},
|
|
{
|
|
"doc_uri": "uri2",
|
|
"content": "document content 2",
|
|
},
|
|
],
|
|
},
|
|
),
|
|
# one retrieval step
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None,
|
|
inputs="What is the capital of France?",
|
|
outputs=[
|
|
{
|
|
"page_content": "document content 1",
|
|
"metadata": {
|
|
"doc_uri": "uri1",
|
|
"chunk_id": "1",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
# missing doc_uri
|
|
{
|
|
"page_content": "document content 2",
|
|
"metadata": {
|
|
"chunk_id": "2",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
# missing content
|
|
{
|
|
"metadata": {
|
|
"doc_uri": "uri3",
|
|
"chunk_id": "3",
|
|
},
|
|
"type": "Document",
|
|
},
|
|
# missing metadata
|
|
{
|
|
"page_content": "document content 4",
|
|
"type": "Document",
|
|
},
|
|
],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
],
|
|
{
|
|
"0000000000000001": [
|
|
{
|
|
"doc_uri": "uri1",
|
|
"content": "document content 1",
|
|
},
|
|
{
|
|
"content": "document content 2",
|
|
},
|
|
{
|
|
"content": None,
|
|
"doc_uri": "uri3",
|
|
},
|
|
{
|
|
"content": "document content 4",
|
|
},
|
|
],
|
|
},
|
|
),
|
|
# one retrieval step - string outputs (UC schema casts attributes to MAP<STRING, STRING>)
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None,
|
|
inputs="What is the capital of France?",
|
|
outputs=json.dumps([
|
|
{
|
|
"page_content": "document content 1",
|
|
"metadata": {"doc_uri": "uri1"},
|
|
},
|
|
{
|
|
"page_content": "document content 2",
|
|
"metadata": {"doc_uri": "uri2"},
|
|
},
|
|
]),
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
],
|
|
{
|
|
"0000000000000001": [
|
|
{"doc_uri": "uri1", "content": "document content 1"},
|
|
{"doc_uri": "uri2", "content": "document content 2"},
|
|
],
|
|
},
|
|
),
|
|
# one retrieval step - empty retrieval span outputs
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None,
|
|
inputs="What is the capital of France?",
|
|
outputs=[],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
],
|
|
{"0000000000000001": []},
|
|
),
|
|
# one retrieval step - wrong format retrieval span outputs
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None,
|
|
inputs="What is the capital of France?",
|
|
outputs=["wrong output", "should be ignored"],
|
|
span_type=SpanType.RETRIEVER,
|
|
),
|
|
],
|
|
{"0000000000000001": []},
|
|
),
|
|
# no retrieval steps
|
|
(
|
|
[
|
|
create_span(
|
|
span_id=1,
|
|
parent_id=None,
|
|
inputs="What is the capital of France?",
|
|
outputs=[{"text": "some text"}],
|
|
span_type=SpanType.LLM,
|
|
),
|
|
],
|
|
{},
|
|
),
|
|
# None trace
|
|
(
|
|
None,
|
|
{},
|
|
),
|
|
],
|
|
)
|
|
def test_get_retrieval_context_from_trace(spans, expected_retrieval_context):
|
|
trace = Trace(info=create_test_trace_info(trace_id="tr-123"), data=TraceData(spans=spans))
|
|
assert extract_retrieval_context_from_trace(trace) == expected_retrieval_context
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_data", "expected"),
|
|
[
|
|
# String input
|
|
("Hello world", "Hello world"),
|
|
# Chat completion/ChatModel/ChatAgent request
|
|
(
|
|
{"messages": [{"role": "user", "content": "User message"}]},
|
|
"User message",
|
|
),
|
|
# Multi-turn messages
|
|
(
|
|
{
|
|
"messages": [
|
|
{"role": "assistant", "content": "First"},
|
|
{"role": "user", "content": "Second"},
|
|
]
|
|
},
|
|
'[{"role": "assistant", "content": "First"}, {"role": "user", "content": "Second"}]',
|
|
),
|
|
# Empty dict input
|
|
(
|
|
{},
|
|
"{}",
|
|
),
|
|
# Dict input
|
|
(
|
|
{"unsupported_key": "value"},
|
|
"{'unsupported_key': 'value'}",
|
|
),
|
|
# Non-standard messages
|
|
(
|
|
{
|
|
"messages": [
|
|
{"role": "assistant", "k": "First"},
|
|
{"role": "user", "k": "Second"},
|
|
]
|
|
},
|
|
"{'messages': [{'role': 'assistant', 'k': 'First'}, {'role': 'user', 'k': 'Second'}]}",
|
|
),
|
|
# Strands format - list of messages with role and content
|
|
(
|
|
[{"role": "user", "content": [{"text": "hello"}]}],
|
|
'[{"role": "user", "content": [{"text": "hello"}]}]',
|
|
),
|
|
# Strands format - multiple messages with simple string content
|
|
(
|
|
[
|
|
{"role": "user", "content": "First"},
|
|
{"role": "assistant", "content": "Second"},
|
|
],
|
|
'[{"role": "user", "content": "First"}, {"role": "assistant", "content": "Second"}]',
|
|
),
|
|
# Strands format - single message with string content
|
|
(
|
|
[{"role": "user", "content": "Single message"}],
|
|
'[{"role": "user", "content": "Single message"}]',
|
|
),
|
|
],
|
|
)
|
|
def test_parse_inputs_to_str(input_data, expected):
|
|
assert parse_inputs_to_str(input_data) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("output_data", "expected"),
|
|
[
|
|
# String output
|
|
("Output string", "Output string"),
|
|
# Chat completion/ChatModel response
|
|
(
|
|
{
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Output content",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
"Output content",
|
|
),
|
|
# ChatAgent response with multiple messages
|
|
(
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "Input content",
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Intermediate Output content",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "Intermediate Input content",
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Output content",
|
|
},
|
|
]
|
|
},
|
|
"Output content",
|
|
),
|
|
# List of strings
|
|
(["Response content"], "Response content"),
|
|
# ChatAgent response with multiple messages
|
|
(
|
|
[
|
|
{
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Output content",
|
|
},
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"Output content",
|
|
),
|
|
# List of direct string response
|
|
(
|
|
{"unsupported_key": "value"},
|
|
'{"unsupported_key": "value"}',
|
|
),
|
|
# Handle custom messages array format
|
|
(
|
|
{"messages": ["a", "b", "c"]},
|
|
'{"messages": ["a", "b", "c"]}',
|
|
),
|
|
# OpenAI Responses API format with output_text content type
|
|
(
|
|
{
|
|
"output": [
|
|
{
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Response from Responses API"}],
|
|
}
|
|
]
|
|
},
|
|
"Response from Responses API",
|
|
),
|
|
# OpenAI Responses API format with text content type
|
|
(
|
|
{
|
|
"output": [
|
|
{
|
|
"id": "msg_456",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Text type response"}],
|
|
}
|
|
]
|
|
},
|
|
"Text type response",
|
|
),
|
|
# OpenAI Responses API format with string content
|
|
(
|
|
{
|
|
"output": [
|
|
{
|
|
"id": "msg_789",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": "Direct string content",
|
|
}
|
|
]
|
|
},
|
|
"Direct string content",
|
|
),
|
|
# OpenAI Responses API format with multiple output items (gets last assistant message)
|
|
(
|
|
{
|
|
"output": [
|
|
{
|
|
"id": "item_1",
|
|
"type": "function_call",
|
|
"name": "get_weather",
|
|
},
|
|
{
|
|
"id": "msg_final",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Final response"}],
|
|
},
|
|
]
|
|
},
|
|
"Final response",
|
|
),
|
|
],
|
|
)
|
|
def test_parse_outputs_to_str(output_data, expected):
|
|
assert parse_outputs_to_str(output_data) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_value", "expected"),
|
|
[
|
|
(None, True),
|
|
(np.nan, True),
|
|
(float("nan"), True),
|
|
("Not NaN", False),
|
|
(123, False),
|
|
([], False),
|
|
({}, False),
|
|
(0.0, False),
|
|
(1.5, False),
|
|
],
|
|
)
|
|
def test_is_none_or_nan(input_value, expected):
|
|
assert is_none_or_nan(input_value) == expected
|
|
|
|
|
|
def test_parse_chunk_preserves_empty_page_content():
|
|
assert _parse_chunk({"page_content": ""}) == {"content": ""}
|
|
|
|
|
|
def test_parse_chunk_non_dict_metadata_does_not_drop_valid_content():
|
|
assert _parse_chunk({"page_content": "text", "metadata": "bad metadata"}) == {"content": "text"}
|
|
|
|
|
|
def test_parse_chunk_page_content_none_beats_populated_aliases():
|
|
chunk = {
|
|
"page_content": None,
|
|
"content": "Fallback content",
|
|
"text": "Fallback text",
|
|
}
|
|
|
|
assert _parse_chunk(chunk) == {"content": None}
|
|
|
|
|
|
def _reset_retriever_document_warning_cache(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils._WARNED_RETRIEVER_DOCUMENT_KEY_SETS",
|
|
OrderedDict(),
|
|
)
|
|
|
|
|
|
def test_parse_chunk_warns_once_per_unrecognized_key_set(monkeypatch):
|
|
_reset_retriever_document_warning_cache(monkeypatch)
|
|
logged_messages = []
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils._logger.warning",
|
|
lambda message, *args: logged_messages.append(message % args),
|
|
)
|
|
|
|
_parse_chunk({"body": "Body text", "metadata": {"doc_uri": "doc-1"}})
|
|
_parse_chunk({"body": "Another body text", "metadata": {"doc_uri": "doc-2"}})
|
|
|
|
assert len(logged_messages) == 1
|
|
assert "does not contain any recognized text field" in logged_messages[0]
|
|
assert "body" in logged_messages[0]
|
|
|
|
|
|
def test_parse_chunk_uses_page_content_by_default():
|
|
chunk = {
|
|
"page_content": "Page content text",
|
|
"metadata": {"doc_uri": "doc-1"},
|
|
}
|
|
|
|
assert _parse_chunk(chunk) == {
|
|
"content": "Page content text",
|
|
"doc_uri": "doc-1",
|
|
}
|
|
|
|
|
|
def test_parse_chunk_falls_back_to_content_field():
|
|
chunk = {
|
|
"content": "Content text",
|
|
"metadata": {"doc_uri": "doc-1"},
|
|
}
|
|
|
|
assert _parse_chunk(chunk) == {
|
|
"content": "Content text",
|
|
"doc_uri": "doc-1",
|
|
}
|
|
|
|
|
|
def test_parse_chunk_falls_back_to_text_field():
|
|
chunk = {
|
|
"text": "Text field content",
|
|
"metadata": {"doc_uri": "doc-1"},
|
|
}
|
|
|
|
assert _parse_chunk(chunk) == {
|
|
"content": "Text field content",
|
|
"doc_uri": "doc-1",
|
|
}
|
|
|
|
|
|
def test_parse_chunk_prefers_page_content_over_aliases():
|
|
chunk = {
|
|
"page_content": "Preferred text",
|
|
"content": "Fallback content",
|
|
"text": "Fallback text",
|
|
}
|
|
|
|
assert _parse_chunk(chunk) == {"content": "Preferred text"}
|
|
|
|
|
|
def test_parse_chunk_warns_for_unrecognized_text_field(monkeypatch):
|
|
_reset_retriever_document_warning_cache(monkeypatch)
|
|
logged_messages = []
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils._logger.warning",
|
|
lambda message, *args: logged_messages.append(message % args),
|
|
)
|
|
|
|
chunk = {
|
|
"body": "Body text",
|
|
"metadata": {"doc_uri": "doc-1"},
|
|
}
|
|
|
|
parsed_chunk = _parse_chunk(chunk)
|
|
|
|
assert parsed_chunk == {"content": None, "doc_uri": "doc-1"}
|
|
assert len(logged_messages) == 1
|
|
assert "does not contain any recognized text field" in logged_messages[0]
|
|
assert "body" in logged_messages[0]
|
|
|
|
|
|
def test_parse_chunk_does_not_warn_for_metadata_only_chunk(monkeypatch):
|
|
_reset_retriever_document_warning_cache(monkeypatch)
|
|
logged_messages = []
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils._logger.warning",
|
|
lambda message, *args: logged_messages.append(message % args),
|
|
)
|
|
|
|
chunk = {
|
|
"metadata": {"doc_uri": "doc-1"},
|
|
}
|
|
|
|
parsed_chunk = _parse_chunk(chunk)
|
|
|
|
assert parsed_chunk == {"content": None, "doc_uri": "doc-1"}
|
|
assert logged_messages == []
|
|
|
|
|
|
def test_parse_chunk_returns_none_for_non_dict_chunk():
|
|
assert _parse_chunk("not a chunk") is None
|
|
|
|
|
|
def test_extract_expectations_from_trace_with_source_filter():
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs({"question": "What is MLflow?"})
|
|
span.set_outputs({"answer": "MLflow is an open source platform"})
|
|
|
|
trace_id = span.trace_id
|
|
|
|
human_expectation = Expectation(
|
|
name="human_expectation",
|
|
value={"expected": "Answer from human"},
|
|
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN),
|
|
)
|
|
mlflow.log_assessment(trace_id=trace_id, assessment=human_expectation)
|
|
|
|
llm_expectation = Expectation(
|
|
name="llm_expectation",
|
|
value="LLM generated expectation",
|
|
source=AssessmentSource(source_type=AssessmentSourceType.LLM_JUDGE),
|
|
)
|
|
mlflow.log_assessment(trace_id=trace_id, assessment=llm_expectation)
|
|
|
|
code_expectation = Expectation(
|
|
name="code_expectation",
|
|
value=42,
|
|
source=AssessmentSource(source_type=AssessmentSourceType.CODE),
|
|
)
|
|
mlflow.log_assessment(trace_id=trace_id, assessment=code_expectation)
|
|
|
|
trace = mlflow.get_trace(trace_id)
|
|
|
|
result = extract_expectations_from_trace(trace, source_type=None)
|
|
assert result == {
|
|
"human_expectation": {"expected": "Answer from human"},
|
|
"llm_expectation": "LLM generated expectation",
|
|
"code_expectation": 42,
|
|
}
|
|
|
|
result = extract_expectations_from_trace(trace, source_type="HUMAN")
|
|
assert result == {"human_expectation": {"expected": "Answer from human"}}
|
|
|
|
result = extract_expectations_from_trace(trace, source_type="LLM_JUDGE")
|
|
assert result == {"llm_expectation": "LLM generated expectation"}
|
|
|
|
result = extract_expectations_from_trace(trace, source_type="CODE")
|
|
assert result == {"code_expectation": 42}
|
|
|
|
result = extract_expectations_from_trace(trace, source_type="human")
|
|
assert result == {"human_expectation": {"expected": "Answer from human"}}
|
|
|
|
with pytest.raises(mlflow.exceptions.MlflowException, match="Invalid assessment source type"):
|
|
extract_expectations_from_trace(trace, source_type="INVALID_SOURCE")
|
|
|
|
|
|
def test_extract_expectations_from_trace_returns_none_when_no_expectations():
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs({"question": "What is MLflow?"})
|
|
span.set_outputs({"answer": "MLflow is an open source platform"})
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
result = extract_expectations_from_trace(trace)
|
|
assert result is None
|
|
|
|
result = extract_expectations_from_trace(trace, source_type="HUMAN")
|
|
assert result is None
|
|
|
|
|
|
def test_extract_inputs_and_outputs_from_trace():
|
|
test_inputs = {"question": "What is MLflow?", "context": "MLflow is a tool"}
|
|
test_outputs = {"answer": "MLflow is an open source platform", "confidence": 0.95}
|
|
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs(test_inputs)
|
|
span.set_outputs(test_outputs)
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
assert extract_inputs_from_trace(trace) == test_inputs
|
|
assert extract_outputs_from_trace(trace) == test_outputs
|
|
|
|
trace_without_data = Trace(
|
|
info=create_test_trace_info(trace_id="tr-123"), data=TraceData(spans=[])
|
|
)
|
|
assert extract_inputs_from_trace(trace_without_data) is None
|
|
assert extract_outputs_from_trace(trace_without_data) is None
|
|
|
|
|
|
def test_extract_request_and_response_from_trace():
|
|
test_inputs = {"messages": [{"role": "user", "content": "What is MLflow?"}]}
|
|
test_outputs = {
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "MLflow is great"}}]
|
|
}
|
|
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs(test_inputs)
|
|
span.set_outputs(test_outputs)
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
assert extract_request_from_trace(trace) == "What is MLflow?"
|
|
assert extract_response_from_trace(trace) == "MLflow is great"
|
|
|
|
trace_without_data = Trace(
|
|
info=create_test_trace_info(trace_id="tr-123"), data=TraceData(spans=[])
|
|
)
|
|
assert extract_request_from_trace(trace_without_data) is None
|
|
assert extract_response_from_trace(trace_without_data) is None
|
|
|
|
|
|
def test_extract_request_and_response_with_string_inputs():
|
|
test_inputs = "Simple string input"
|
|
test_outputs = "Simple string output"
|
|
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs(test_inputs)
|
|
span.set_outputs(test_outputs)
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
assert extract_request_from_trace(trace) == "Simple string input"
|
|
assert extract_response_from_trace(trace) == "Simple string output"
|
|
|
|
|
|
def test_does_store_support_trace_linking():
|
|
test_trace = Trace(info=create_test_trace_info(trace_id="tr-123"), data=TraceData(spans=[]))
|
|
|
|
# Databricks backend support trace linking
|
|
assert _does_store_support_trace_linking(
|
|
tracking_uri="databricks",
|
|
trace=test_trace,
|
|
run_id="run-123",
|
|
)
|
|
|
|
assert _does_store_support_trace_linking(
|
|
tracking_uri="databricks://test",
|
|
trace=test_trace,
|
|
run_id="run-123",
|
|
)
|
|
|
|
mock_client = mock.MagicMock()
|
|
with mock.patch("mlflow.genai.utils.trace_utils.MlflowClient", return_value=mock_client):
|
|
# SQLAlchemy backend support trace linking
|
|
mock_client.link_traces_to_run.side_effect = None
|
|
|
|
assert _does_store_support_trace_linking(
|
|
tracking_uri="sqlalchemy://test",
|
|
trace=test_trace,
|
|
run_id="run-123",
|
|
)
|
|
|
|
# File store doesn't support trace linking
|
|
mock_client.link_traces_to_run.side_effect = Exception("Test error")
|
|
|
|
assert not _does_store_support_trace_linking(
|
|
tracking_uri="file://test",
|
|
trace=test_trace,
|
|
run_id="run-123",
|
|
)
|
|
|
|
# Result should be cached per tracking URI
|
|
mock_client.reset_mock()
|
|
mock_client.link_traces_to_run.side_effect = None
|
|
for _ in range(10):
|
|
assert _does_store_support_trace_linking(
|
|
tracking_uri="sqlalchemy://test2",
|
|
trace=test_trace,
|
|
run_id="run-123",
|
|
)
|
|
mock_client.link_traces_to_run.assert_called_once()
|
|
|
|
|
|
def test_create_minimal_trace_restores_session_metadata():
|
|
source = DatasetRecordSource(
|
|
source_type=DatasetRecordSourceType.TRACE,
|
|
source_data={"trace_id": "tr-original", "session_id": "session_1"},
|
|
)
|
|
|
|
eval_item = EvalItem(
|
|
request_id="req-123",
|
|
inputs={"question": "test"},
|
|
outputs="answer",
|
|
expectations={},
|
|
source=source,
|
|
)
|
|
|
|
trace = create_minimal_trace(eval_item)
|
|
|
|
# Verify session metadata was restored
|
|
assert trace.info.trace_metadata.get("mlflow.trace.session") == "session_1"
|
|
assert trace.data._get_root_span().inputs == {"question": "test"}
|
|
assert trace.data._get_root_span().outputs == "answer"
|
|
|
|
|
|
def test_create_minimal_trace_without_source():
|
|
eval_item = EvalItem(
|
|
request_id="req-123",
|
|
inputs={"question": "test"},
|
|
outputs="answer",
|
|
expectations={},
|
|
source=None,
|
|
)
|
|
|
|
trace = create_minimal_trace(eval_item)
|
|
|
|
# Should create trace successfully without session metadata
|
|
assert trace is not None
|
|
assert "mlflow.trace.session" not in trace.info.trace_metadata
|
|
assert trace.data._get_root_span().inputs == {"question": "test"}
|
|
assert trace.data._get_root_span().outputs == "answer"
|
|
|
|
|
|
def test_create_minimal_trace_with_source_but_no_session():
|
|
source = DatasetRecordSource(
|
|
source_type=DatasetRecordSourceType.TRACE,
|
|
source_data={"trace_id": "tr-original"}, # No session_id
|
|
)
|
|
|
|
eval_item = EvalItem(
|
|
request_id="req-123",
|
|
inputs={"question": "test"},
|
|
outputs="answer",
|
|
expectations={},
|
|
source=source,
|
|
)
|
|
|
|
trace = create_minimal_trace(eval_item)
|
|
|
|
# Should work without session metadata
|
|
assert trace is not None
|
|
assert "mlflow.trace.session" not in trace.info.trace_metadata
|
|
assert trace.data._get_root_span().inputs == {"question": "test"}
|
|
assert trace.data._get_root_span().outputs == "answer"
|
|
|
|
|
|
def test_parse_tool_call_messages_from_trace():
|
|
with mlflow.start_span(name="root") as root_span:
|
|
root_span.set_inputs({"question": "What is the stock price?"})
|
|
|
|
with mlflow.start_span(name="get_stock_price", span_type=SpanType.TOOL) as tool_span:
|
|
tool_span.set_inputs({"symbol": "AAPL"})
|
|
tool_span.set_outputs({"price": 150.0})
|
|
|
|
with mlflow.start_span(name="get_market_cap", span_type=SpanType.TOOL) as tool_span2:
|
|
tool_span2.set_inputs({"symbol": "AAPL"})
|
|
tool_span2.set_outputs({"market_cap": "2.5T"})
|
|
|
|
root_span.set_outputs("AAPL price is $150.")
|
|
|
|
trace = mlflow.get_trace(root_span.trace_id)
|
|
tool_messages = parse_tool_call_messages_from_trace(trace)
|
|
|
|
assert len(tool_messages) == 2
|
|
assert tool_messages[0] == {
|
|
"role": "tool",
|
|
"content": "Tool: get_stock_price\nInputs: {'symbol': 'AAPL'}\nOutputs: {'price': 150.0}",
|
|
}
|
|
assert tool_messages[1] == {
|
|
"role": "tool",
|
|
"content": (
|
|
"Tool: get_market_cap\nInputs: {'symbol': 'AAPL'}\nOutputs: {'market_cap': '2.5T'}"
|
|
),
|
|
}
|
|
|
|
|
|
def test_parse_tool_call_messages_from_trace_no_tools():
|
|
with mlflow.start_span(name="root") as span:
|
|
span.set_inputs({"question": "Hello"})
|
|
span.set_outputs("Hi there")
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
tool_messages = parse_tool_call_messages_from_trace(trace)
|
|
|
|
assert tool_messages == []
|
|
|
|
|
|
def test_parse_tool_call_messages_from_trace_tool_without_outputs():
|
|
with mlflow.start_span(name="root") as root_span:
|
|
root_span.set_inputs({"query": "test"})
|
|
|
|
with mlflow.start_span(name="my_tool", span_type=SpanType.TOOL) as tool_span:
|
|
tool_span.set_inputs({"param": "value"})
|
|
|
|
root_span.set_outputs("result")
|
|
|
|
trace = mlflow.get_trace(root_span.trace_id)
|
|
tool_messages = parse_tool_call_messages_from_trace(trace)
|
|
|
|
assert len(tool_messages) == 1
|
|
assert tool_messages[0] == {
|
|
"role": "tool",
|
|
"content": "Tool: my_tool\nInputs: {'param': 'value'}",
|
|
}
|
|
|
|
|
|
def test_extract_tool_name_from_span_uses_span_name_by_default():
|
|
with mlflow.start_span(name="root") as root_span:
|
|
root_span.set_inputs({"query": "test"})
|
|
|
|
with mlflow.start_span(name="my_tool", span_type=SpanType.TOOL) as tool_span:
|
|
tool_span.set_inputs({"arg": "value"})
|
|
|
|
root_span.set_outputs("result")
|
|
|
|
trace = mlflow.get_trace(root_span.trace_id)
|
|
tool_spans = trace.search_spans(span_type=SpanType.TOOL)
|
|
|
|
assert _extract_tool_name_from_span(tool_spans[0]) == "my_tool"
|
|
|
|
|
|
def test_extract_tool_name_from_span_extracts_from_call_tool_name():
|
|
with mlflow.start_span(name="root") as root_span:
|
|
root_span.set_inputs({"query": "test"})
|
|
|
|
with mlflow.start_span(
|
|
name="ToolManager.handle_call", span_type=SpanType.TOOL
|
|
) as tool_span:
|
|
tool_span.set_inputs({"call": {"tool_name": "list_client", "args": {"param": "value"}}})
|
|
|
|
root_span.set_outputs("result")
|
|
|
|
trace = mlflow.get_trace(root_span.trace_id)
|
|
tool_spans = trace.search_spans(span_type=SpanType.TOOL)
|
|
|
|
assert _extract_tool_name_from_span(tool_spans[0]) == "list_client"
|
|
|
|
|
|
def test_resolve_conversation_from_session():
|
|
session_id = "test_session_resolve"
|
|
traces = []
|
|
|
|
with mlflow.start_span(name="turn_0") as span:
|
|
span.set_inputs({"messages": [{"role": "user", "content": "What is AAPL price?"}]})
|
|
span.set_outputs("AAPL is $150.")
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
traces.append(mlflow.get_trace(span.trace_id))
|
|
|
|
with mlflow.start_span(name="turn_1") as span:
|
|
span.set_inputs({"messages": [{"role": "user", "content": "How about MSFT?"}]})
|
|
span.set_outputs("MSFT is $300.")
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
traces.append(mlflow.get_trace(span.trace_id))
|
|
|
|
conversation = resolve_conversation_from_session(traces)
|
|
|
|
assert len(conversation) == 4
|
|
assert conversation[0] == {"role": "user", "content": "What is AAPL price?"}
|
|
assert conversation[1] == {"role": "assistant", "content": "AAPL is $150."}
|
|
assert conversation[2] == {"role": "user", "content": "How about MSFT?"}
|
|
assert conversation[3] == {"role": "assistant", "content": "MSFT is $300."}
|
|
|
|
|
|
def test_resolve_conversation_from_session_with_tool_calls():
|
|
session_id = "test_session_with_tools"
|
|
traces = []
|
|
|
|
with mlflow.start_span(name="turn_0") as root_span:
|
|
root_span.set_inputs({"messages": [{"role": "user", "content": "Get AAPL price"}]})
|
|
|
|
with mlflow.start_span(name="get_stock_price", span_type=SpanType.TOOL) as tool_span:
|
|
tool_span.set_inputs({"symbol": "AAPL"})
|
|
tool_span.set_outputs({"price": 150})
|
|
|
|
root_span.set_outputs("AAPL is $150.")
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
traces.append(mlflow.get_trace(root_span.trace_id))
|
|
|
|
conversation = resolve_conversation_from_session(traces, include_tool_calls=False)
|
|
assert len(conversation) == 2
|
|
assert conversation[0]["role"] == "user"
|
|
assert conversation[1]["role"] == "assistant"
|
|
|
|
conversation_with_tools = resolve_conversation_from_session(traces, include_tool_calls=True)
|
|
assert len(conversation_with_tools) == 3
|
|
assert conversation_with_tools[0] == {"role": "user", "content": "Get AAPL price"}
|
|
assert conversation_with_tools[1] == {
|
|
"role": "tool",
|
|
"content": "Tool: get_stock_price\nInputs: {'symbol': 'AAPL'}\nOutputs: {'price': 150}",
|
|
}
|
|
assert conversation_with_tools[2] == {"role": "assistant", "content": "AAPL is $150."}
|
|
|
|
|
|
def test_resolve_conversation_from_session_empty():
|
|
assert resolve_conversation_from_session([]) == []
|
|
|
|
|
|
@pytest.mark.parametrize("include_timing", [True, False])
|
|
def test_resolve_conversation_from_session_with_timing_parameter(include_timing):
|
|
session_id = "test_session"
|
|
traces = []
|
|
|
|
with mlflow.start_span(name="turn_0") as span:
|
|
span.set_inputs({"messages": [{"role": "user", "content": "What is MLflow?"}]})
|
|
span.set_outputs("MLflow is an ML platform.")
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
traces.append(mlflow.get_trace(span.trace_id))
|
|
|
|
conversation = resolve_conversation_from_session(traces, include_timing=include_timing)
|
|
|
|
assert len(conversation) == 2
|
|
assert conversation[0] == {"role": "user", "content": "What is MLflow?"}
|
|
assert conversation[1]["role"] == "assistant"
|
|
assert "MLflow is an ML platform." in conversation[1]["content"]
|
|
assert ("[Response duration:" in conversation[1]["content"]) is include_timing
|
|
assert ("slowest spans:" in conversation[1]["content"]) is include_timing
|
|
|
|
|
|
def test_session_level_expectations_filtering():
|
|
session_id = "test-session"
|
|
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs({"question": "Test"})
|
|
span.set_outputs({"answer": "Test answer"})
|
|
|
|
trace_id = span.trace_id
|
|
|
|
session_exp = Expectation(
|
|
name="session_exp",
|
|
value="session_value",
|
|
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN),
|
|
metadata={TraceMetadataKey.TRACE_SESSION: session_id},
|
|
)
|
|
mlflow.log_assessment(trace_id=trace_id, assessment=session_exp)
|
|
|
|
trace_exp = Expectation(
|
|
name="trace_exp",
|
|
value="trace_value",
|
|
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN),
|
|
metadata={},
|
|
)
|
|
mlflow.log_assessment(trace_id=trace_id, assessment=trace_exp)
|
|
|
|
trace = mlflow.get_trace(trace_id)
|
|
|
|
session_result = resolve_expectations_from_session(None, [trace])
|
|
assert session_result == {"session_exp": "session_value"}
|
|
assert "trace_exp" not in session_result
|
|
|
|
|
|
def test_resolve_expectations_from_session_with_provided_expectations():
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs({"question": "Test"})
|
|
span.set_outputs({"answer": "Test answer"})
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
provided_expectations = {"provided": "value"}
|
|
|
|
result = resolve_expectations_from_session(provided_expectations, [trace])
|
|
assert result == provided_expectations
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("expectations", "has_session_exp", "expected"),
|
|
[
|
|
(None, False, None),
|
|
(None, True, {"session_exp": "session_value"}),
|
|
({"provided": "value"}, True, {"provided": "value"}),
|
|
],
|
|
)
|
|
def test_resolve_expectations_from_session_edge_cases(expectations, has_session_exp, expected):
|
|
session_id = "test-session"
|
|
|
|
with mlflow.start_span(name="test_span") as span:
|
|
span.set_inputs({"question": "Test"})
|
|
span.set_outputs({"answer": "Test answer"})
|
|
mlflow.update_current_trace(metadata={TraceMetadataKey.TRACE_SESSION: session_id})
|
|
|
|
if has_session_exp:
|
|
exp = Expectation(
|
|
name="session_exp",
|
|
value="session_value",
|
|
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN),
|
|
metadata={TraceMetadataKey.TRACE_SESSION: session_id},
|
|
)
|
|
mlflow.log_assessment(trace_id=span.trace_id, assessment=exp)
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
result = resolve_expectations_from_session(expectations, [trace])
|
|
assert result == expected
|
|
|
|
|
|
def test_convert_predict_fn_async_function():
|
|
async def async_predict_fn(request):
|
|
await asyncio.sleep(0.01)
|
|
return "async test response"
|
|
|
|
sample_input = {"request": {"messages": [{"role": "user", "content": "test"}]}}
|
|
|
|
converted_fn = convert_predict_fn(async_predict_fn, sample_input)
|
|
|
|
result = converted_fn(request=sample_input)
|
|
assert result == "async test response"
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
purge_traces()
|
|
|
|
|
|
def test_evaluate_with_async_predict_fn():
|
|
async def async_predict_fn(request):
|
|
await asyncio.sleep(0.01)
|
|
return "async test response"
|
|
|
|
sample_input = {"request": {"messages": [{"role": "user", "content": "test"}]}}
|
|
|
|
@scorer
|
|
def dummy_scorer(inputs, outputs):
|
|
return 0
|
|
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": sample_input}],
|
|
predict_fn=async_predict_fn,
|
|
scorers=[dummy_scorer],
|
|
)
|
|
assert len(get_traces()) == 1
|
|
purge_traces()
|
|
|
|
|
|
def test_convert_predict_fn_async_function_with_timeout(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_GENAI_EVAL_ASYNC_TIMEOUT", "1")
|
|
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SKIP_TRACE_VALIDATION", "true")
|
|
|
|
async def slow_async_predict_fn(request):
|
|
await asyncio.sleep(2)
|
|
return "should timeout"
|
|
|
|
sample_input = {"request": {"messages": [{"role": "user", "content": "test"}]}}
|
|
|
|
converted_fn = convert_predict_fn(slow_async_predict_fn, sample_input)
|
|
|
|
with pytest.raises(asyncio.TimeoutError): # noqa: PT011
|
|
converted_fn(request=sample_input)
|
|
|
|
assert len(get_traces()) == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("span_type", "use_attribute", "tool_name", "tool_description"),
|
|
[
|
|
("LLM", True, "get_weather", "Get current weather"),
|
|
("CHAT_MODEL", False, "search", "Search the web"),
|
|
],
|
|
)
|
|
def test_extract_available_tools_from_trace_basic(
|
|
span_type, use_attribute, tool_name, tool_description
|
|
):
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_name,
|
|
"description": tool_description,
|
|
"parameters": {"type": "object", "properties": {"param": {"type": "string"}}},
|
|
},
|
|
}
|
|
]
|
|
|
|
with mlflow.start_span(name="test_span", span_type=span_type) as span:
|
|
if use_attribute:
|
|
set_span_chat_tools(span, tools)
|
|
span.set_inputs({"prompt": "test"})
|
|
else:
|
|
span.set_inputs({"messages": [{"role": "user", "content": "test"}], "tools": tools})
|
|
span.set_outputs({"response": "result"})
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
extracted_tools = extract_available_tools_from_trace(trace)
|
|
|
|
assert len(extracted_tools) == 1
|
|
assert extracted_tools[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_name,
|
|
"description": tool_description,
|
|
"parameters": {"type": "object", "properties": {"param": {"type": "string"}}},
|
|
},
|
|
}
|
|
|
|
|
|
def test_extract_available_tools_from_trace_with_multiple_spans():
|
|
tool1 = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "add",
|
|
"description": "Add two numbers",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "number"},
|
|
"b": {"type": "number"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
tool2 = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "multiply",
|
|
"description": "Multiply two numbers",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"x": {"type": "number"},
|
|
"y": {"type": "number"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
with mlflow.start_span(name="parent") as parent:
|
|
with mlflow.start_span(name="llm1", span_type="LLM") as span1:
|
|
set_span_chat_tools(span1, tool1)
|
|
|
|
with mlflow.start_span(name="llm2", span_type="CHAT_MODEL") as span2:
|
|
set_span_chat_tools(span2, tool2)
|
|
|
|
trace = mlflow.get_trace(parent.trace_id)
|
|
extracted_tools = extract_available_tools_from_trace(trace)
|
|
|
|
assert len(extracted_tools) == 2
|
|
|
|
extracted_tools_sorted = sorted(extracted_tools, key=lambda t: t.function.name)
|
|
|
|
assert extracted_tools_sorted[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "add",
|
|
"description": "Add two numbers",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "number"},
|
|
"b": {"type": "number"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
assert extracted_tools_sorted[1].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "multiply",
|
|
"description": "Multiply two numbers",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"x": {"type": "number"},
|
|
"y": {"type": "number"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_extract_available_tools_from_trace_deduplication():
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get weather info",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
]
|
|
|
|
with mlflow.start_span(name="parent") as parent:
|
|
with mlflow.start_span(name="llm1", span_type="LLM") as span1:
|
|
set_span_chat_tools(span1, tools)
|
|
|
|
with mlflow.start_span(name="llm2", span_type="LLM") as span2:
|
|
set_span_chat_tools(span2, tools)
|
|
|
|
trace = mlflow.get_trace(parent.trace_id)
|
|
extracted_tools = extract_available_tools_from_trace(trace)
|
|
|
|
assert len(extracted_tools) == 1
|
|
assert extracted_tools[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get weather info",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
|
|
def test_extract_available_tools_from_trace_different_descriptions():
|
|
tool1 = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search the web",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
]
|
|
|
|
tool2 = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search the database",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
]
|
|
|
|
with mlflow.start_span(name="parent") as parent:
|
|
with mlflow.start_span(name="llm1", span_type="LLM") as span1:
|
|
set_span_chat_tools(span1, tool1)
|
|
|
|
with mlflow.start_span(name="llm2", span_type="LLM") as span2:
|
|
set_span_chat_tools(span2, tool2)
|
|
|
|
trace = mlflow.get_trace(parent.trace_id)
|
|
extracted_tools = extract_available_tools_from_trace(trace)
|
|
|
|
assert len(extracted_tools) == 2
|
|
|
|
extracted_tools_sorted = sorted(extracted_tools, key=lambda t: t.function.description)
|
|
|
|
assert extracted_tools_sorted[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search the database",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
assert extracted_tools_sorted[1].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search the web",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
|
|
def test_extract_available_tools_from_trace_returns_empty():
|
|
trace_fixture = Trace(info=create_test_trace_info(trace_id="tr-456"), data=TraceData(spans=[]))
|
|
result = extract_available_tools_from_trace(trace_fixture)
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("has_valid_tool", "expected_count"),
|
|
[
|
|
(False, 0), # Only invalid tools
|
|
(True, 1), # Mix of valid and invalid tools
|
|
],
|
|
)
|
|
def test_extract_available_tools_from_trace_with_invalid_tools(has_valid_tool, expected_count):
|
|
with mlflow.start_span(name="parent") as parent:
|
|
if has_valid_tool:
|
|
valid_tool = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "valid_tool",
|
|
"description": "A valid tool",
|
|
},
|
|
}
|
|
]
|
|
with mlflow.start_span(name="llm1", span_type="LLM") as span1:
|
|
set_span_chat_tools(span1, valid_tool)
|
|
|
|
with mlflow.start_span(name="llm2", span_type="LLM") as span2:
|
|
span2.set_inputs({
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
"tools": [
|
|
{"invalid": "tool"}, # Missing required fields
|
|
{"type": "function"}, # Missing function field
|
|
],
|
|
})
|
|
|
|
trace = mlflow.get_trace(parent.trace_id)
|
|
extracted_tools = extract_available_tools_from_trace(trace)
|
|
|
|
assert len(extracted_tools) == expected_count
|
|
if has_valid_tool:
|
|
assert extracted_tools[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "valid_tool",
|
|
"description": "A valid tool",
|
|
},
|
|
}
|
|
|
|
|
|
def test_extract_available_tools_llm_fallback_triggered_when_no_tools_found(monkeypatch):
|
|
with mlflow.start_span(name="llm_span", span_type=SpanType.LLM) as span:
|
|
span.set_inputs({
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
"tools": [
|
|
{
|
|
"tool_name": "hard_to_extract_tool",
|
|
"description": "A tool that is hard to extract",
|
|
}
|
|
],
|
|
})
|
|
span.set_outputs({"response": "result"})
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
mock_tools = [
|
|
ChatTool(
|
|
type="function",
|
|
function=FunctionToolDefinition(
|
|
name="hard_to_extract_tool",
|
|
description="A tool that is hard to extract",
|
|
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
|
),
|
|
)
|
|
]
|
|
|
|
mock_llm_fallback_called = []
|
|
|
|
def mock_llm_fallback(trace_arg, model_arg):
|
|
mock_llm_fallback_called.append({"trace": trace_arg, "model": model_arg})
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils._try_extract_available_tools_with_llm",
|
|
mock_llm_fallback,
|
|
)
|
|
|
|
extracted_tools = extract_available_tools_from_trace(trace, model="openai:/gpt-4")
|
|
|
|
assert len(mock_llm_fallback_called) == 1
|
|
assert mock_llm_fallback_called[0]["trace"] == trace
|
|
assert mock_llm_fallback_called[0]["model"] == "openai:/gpt-4"
|
|
assert len(extracted_tools) == 1
|
|
assert extracted_tools[0].model_dump(exclude_none=True) == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "hard_to_extract_tool",
|
|
"description": "A tool that is hard to extract",
|
|
"parameters": {"type": "object", "properties": {"x": {"type": "string"}}},
|
|
},
|
|
}
|
|
|
|
|
|
def test_try_extract_available_tools_with_llm_returns_empty_on_error(monkeypatch):
|
|
with mlflow.start_span(name="llm_span", span_type=SpanType.LLM) as span:
|
|
span.set_inputs({"messages": [{"role": "user", "content": "test"}]})
|
|
span.set_outputs({"response": "result"})
|
|
|
|
trace = mlflow.get_trace(span.trace_id)
|
|
|
|
def mock_raise_error(*args, **kwargs):
|
|
raise RuntimeError("LLM API error")
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.genai.utils.trace_utils.get_chat_completions_with_structured_output",
|
|
mock_raise_error,
|
|
)
|
|
|
|
result = _try_extract_available_tools_with_llm(trace, model="openai:/gpt-4")
|
|
assert result == []
|
|
|
|
|
|
def test_should_keep_trace_preserves_input_trace_ids():
|
|
trace_info = create_test_trace_info(
|
|
trace_id="tr-input-123",
|
|
request_time=2000,
|
|
)
|
|
trace = Trace(info=trace_info, data=TraceData(spans=[]))
|
|
|
|
eval_start_time = 1000
|
|
input_trace_ids = {"tr-input-123"}
|
|
|
|
result = _should_keep_trace(trace, eval_start_time, input_trace_ids)
|
|
assert result is True
|
|
|
|
|
|
def test_should_keep_trace_deletes_non_input_traces_after_eval_start():
|
|
trace_info = create_test_trace_info(
|
|
trace_id="tr-extra-456",
|
|
request_time=2000,
|
|
)
|
|
trace = Trace(info=trace_info, data=TraceData(spans=[]))
|
|
|
|
eval_start_time = 1000
|
|
input_trace_ids = {"tr-input-123"}
|
|
|
|
result = _should_keep_trace(trace, eval_start_time, input_trace_ids)
|
|
assert result is False
|
|
|
|
|
|
def test_clean_up_extra_traces_preserves_input_traces():
|
|
experiment_id = mlflow.set_experiment("test_experiment").experiment_id
|
|
|
|
with mlflow.start_span(name="input_trace_1") as span1:
|
|
span1.set_inputs({"question": "test1"})
|
|
span1.set_outputs({"answer": "answer1"})
|
|
trace1 = mlflow.get_trace(span1.trace_id)
|
|
|
|
with mlflow.start_span(name="input_trace_2") as span2:
|
|
span2.set_inputs({"question": "test2"})
|
|
span2.set_outputs({"answer": "answer2"})
|
|
trace2 = mlflow.get_trace(span2.trace_id)
|
|
|
|
eval_start_time = int(trace1.info.timestamp_ms - 1000)
|
|
|
|
input_trace_ids = {trace1.info.trace_id, trace2.info.trace_id}
|
|
all_traces = [trace1, trace2]
|
|
|
|
clean_up_extra_traces(all_traces, eval_start_time, experiment_id, input_trace_ids)
|
|
|
|
remaining_traces = get_traces()
|
|
remaining_trace_ids = {t.info.trace_id for t in remaining_traces}
|
|
assert trace1.info.trace_id in remaining_trace_ids
|
|
assert trace2.info.trace_id in remaining_trace_ids
|
|
|
|
|
|
def test_clean_up_extra_traces_uses_correct_experiment_id():
|
|
exp_1 = mlflow.set_experiment("cleanup_test_experiment").experiment_id
|
|
with mlflow.start_span(name="input_trace") as span1:
|
|
span1.set_inputs({"question": "test"})
|
|
span1.set_outputs({"answer": "answer"})
|
|
input_trace = mlflow.get_trace(span1.trace_id)
|
|
|
|
with mlflow.start_span(name="extra_trace") as span2:
|
|
span2.set_inputs({"question": "extra"})
|
|
span2.set_outputs({"answer": "extra_answer"})
|
|
extra_trace = mlflow.get_trace(span2.trace_id)
|
|
|
|
mlflow.set_experiment("cleanup_test_experiment_2")
|
|
clean_up_extra_traces([input_trace, extra_trace], 0, exp_1, {input_trace.info.trace_id})
|
|
|
|
remaining_traces = mlflow.search_traces(locations=[exp_1], return_type="list")
|
|
assert len(remaining_traces) == 1
|
|
assert remaining_traces[0].info.trace_id == input_trace.info.trace_id
|
|
|
|
|
|
def test_evaluate_with_trace_column_preserves_traces():
|
|
@scorer
|
|
def dummy_scorer(inputs, outputs):
|
|
return 1.0
|
|
|
|
with mlflow.start_span(name="original_trace") as span:
|
|
span.set_inputs({"question": "What is MLflow?"})
|
|
span.set_outputs({"answer": "MLflow is an ML platform"})
|
|
|
|
original_trace = mlflow.get_trace(span.trace_id)
|
|
original_trace_id = original_trace.info.trace_id
|
|
|
|
eval_df = pd.DataFrame([
|
|
{
|
|
"trace": original_trace,
|
|
"inputs": {"question": "What is MLflow?"},
|
|
"outputs": {"answer": "MLflow is an ML platform"},
|
|
}
|
|
])
|
|
|
|
mlflow.genai.evaluate(data=eval_df, scorers=[dummy_scorer])
|
|
|
|
remaining_traces = get_traces()
|
|
remaining_trace_ids = {t.info.trace_id for t in remaining_traces}
|
|
assert original_trace_id in remaining_trace_ids
|