5a558eb09e
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
1093 lines
44 KiB
Python
1093 lines
44 KiB
Python
"""Tests for MetricFactory in Optimization Studio."""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
from opik_backend.studio.metrics import MetricFactory
|
|
from opik_backend.studio.exceptions import InvalidMetricError
|
|
from opik_backend.studio.types import _convert_template_syntax, OptimizationConfig
|
|
|
|
|
|
class TestMetricFactory:
|
|
"""Tests for MetricFactory.build() and metric builders."""
|
|
|
|
def test_build_unknown_metric_raises_error(self):
|
|
"""Test that building an unknown metric type raises InvalidMetricError."""
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("unknown_metric", {}, "openai/gpt-4o")
|
|
|
|
assert "unknown_metric" in str(exc_info.value)
|
|
assert "Available metrics:" in str(exc_info.value)
|
|
|
|
def test_build_equals_metric(self):
|
|
"""Test building an equals metric."""
|
|
metric_fn = MetricFactory.build("equals", {}, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "equals"
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_equals_metric_with_params(self):
|
|
"""Test building an equals metric with custom parameters."""
|
|
params = {
|
|
"case_sensitive": False,
|
|
"reference_key": "expected_output"
|
|
}
|
|
metric_fn = MetricFactory.build("equals", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "equals"
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_levenshtein_metric(self):
|
|
"""Test building a levenshtein_ratio metric."""
|
|
metric_fn = MetricFactory.build("levenshtein_ratio", {}, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "levenshtein_ratio"
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_geval_metric(self):
|
|
"""Test building a geval metric."""
|
|
params = {
|
|
"task_introduction": "Evaluate the response quality",
|
|
"evaluation_criteria": "Is the response helpful?"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_json_schema_validator_metric(self):
|
|
"""Test building a json_schema_validator metric."""
|
|
# The metric reads schema from dataset items via schema_key parameter
|
|
metric_fn = MetricFactory.build("json_schema_validator", {}, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "json_schema_validator"
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_json_schema_validator_metric_with_custom_schema_key(self):
|
|
"""Test building a json_schema_validator metric with custom schema_key."""
|
|
params = {"schema_key": "my_schema"}
|
|
metric_fn = MetricFactory.build("json_schema_validator", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "json_schema_validator"
|
|
assert callable(metric_fn)
|
|
|
|
def test_json_schema_validator_missing_schema_returns_zero(self):
|
|
"""Test that json_schema_validator returns 0.0 when schema is missing from dataset item."""
|
|
metric_fn = MetricFactory.build("json_schema_validator", {}, "openai/gpt-4o")
|
|
|
|
# Dataset item without json_schema key
|
|
dataset_item = {"other_field": "value"}
|
|
result = metric_fn(dataset_item, '{"name": "test"}')
|
|
|
|
assert result.value == 0.0
|
|
assert "Missing schema" in result.reason
|
|
|
|
|
|
class TestEqualsMetricExecution:
|
|
"""Tests for equals metric function execution."""
|
|
|
|
def test_equals_metric_exact_match(self):
|
|
"""Test equals metric with exact match."""
|
|
metric_fn = MetricFactory.build("equals", {"case_sensitive": True}, "model")
|
|
|
|
# Default reference key is "answer"
|
|
dataset_item = {"answer": "hello world"}
|
|
result = metric_fn(dataset_item, "hello world")
|
|
|
|
assert result.value == 1.0
|
|
|
|
def test_equals_metric_no_match(self):
|
|
"""Test equals metric with no match."""
|
|
metric_fn = MetricFactory.build("equals", {"case_sensitive": True}, "model")
|
|
|
|
dataset_item = {"answer": "hello world"}
|
|
result = metric_fn(dataset_item, "goodbye world")
|
|
|
|
assert result.value == 0.0
|
|
|
|
def test_equals_metric_case_insensitive(self):
|
|
"""Test equals metric with case insensitive comparison."""
|
|
metric_fn = MetricFactory.build("equals", {"case_sensitive": False}, "model")
|
|
|
|
dataset_item = {"answer": "Hello World"}
|
|
result = metric_fn(dataset_item, "hello world")
|
|
|
|
assert result.value == 1.0
|
|
|
|
def test_equals_metric_custom_reference_key(self):
|
|
"""Test equals metric with custom reference key."""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "expected"},
|
|
"model"
|
|
)
|
|
|
|
dataset_item = {"expected": "test value"}
|
|
result = metric_fn(dataset_item, "test value")
|
|
|
|
assert result.value == 1.0
|
|
|
|
|
|
class TestLevenshteinMetricExecution:
|
|
"""Tests for levenshtein_ratio metric function execution."""
|
|
|
|
def test_levenshtein_metric_exact_match(self):
|
|
"""Test levenshtein metric with exact match."""
|
|
metric_fn = MetricFactory.build("levenshtein_ratio", {}, "model")
|
|
|
|
# Default reference key is "answer"
|
|
dataset_item = {"answer": "hello"}
|
|
result = metric_fn(dataset_item, "hello")
|
|
|
|
assert result.value == 1.0
|
|
|
|
def test_levenshtein_metric_partial_match(self):
|
|
"""Test levenshtein metric with partial match."""
|
|
metric_fn = MetricFactory.build("levenshtein_ratio", {}, "model")
|
|
|
|
dataset_item = {"answer": "hello"}
|
|
result = metric_fn(dataset_item, "hallo")
|
|
|
|
# "hello" vs "hallo" - 1 character difference out of 5
|
|
assert 0.0 < result.value < 1.0
|
|
|
|
def test_levenshtein_metric_no_match(self):
|
|
"""Test levenshtein metric with completely different strings."""
|
|
metric_fn = MetricFactory.build("levenshtein_ratio", {}, "model")
|
|
|
|
dataset_item = {"answer": "abc"}
|
|
result = metric_fn(dataset_item, "xyz")
|
|
|
|
assert result.value == 0.0
|
|
|
|
|
|
class TestMetricReasons:
|
|
"""Tests for metric reason fields (required for hierarchical_reflective optimizer)."""
|
|
|
|
def test_equals_metric_includes_reason_on_match(self):
|
|
"""Test equals metric includes reason field on match."""
|
|
metric_fn = MetricFactory.build("equals", {}, "model")
|
|
dataset_item = {"answer": "test"}
|
|
result = metric_fn(dataset_item, "test")
|
|
|
|
assert result.reason is not None
|
|
assert "match" in result.reason.lower()
|
|
|
|
def test_equals_metric_includes_reason_on_no_match(self):
|
|
"""Test equals metric includes reason field on no match."""
|
|
metric_fn = MetricFactory.build("equals", {}, "model")
|
|
dataset_item = {"answer": "test"}
|
|
result = metric_fn(dataset_item, "different")
|
|
|
|
assert result.reason is not None
|
|
assert "no match" in result.reason.lower()
|
|
|
|
def test_levenshtein_metric_includes_reason(self):
|
|
"""Test levenshtein metric includes reason field with similarity percentage."""
|
|
metric_fn = MetricFactory.build("levenshtein_ratio", {}, "model")
|
|
dataset_item = {"answer": "hello"}
|
|
result = metric_fn(dataset_item, "hallo")
|
|
|
|
assert result.reason is not None
|
|
assert "similarity" in result.reason.lower()
|
|
assert "%" in result.reason
|
|
|
|
|
|
class TestCodeMetric:
|
|
"""Tests for code metric functionality.
|
|
|
|
Code metrics use the same executor infrastructure as automations (evaluation metrics),
|
|
executed via ProcessExecutor or DockerExecutor based on PYTHON_CODE_EXECUTOR_STRATEGY.
|
|
|
|
Only BaseMetric class pattern is supported (same as automations).
|
|
"""
|
|
|
|
def test_code_metric_basic_class_works(self):
|
|
"""Test that a basic class metric works."""
|
|
code = '''
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class MyMetric(BaseMetric):
|
|
def __init__(self, name: str = "test"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
return ScoreResult(name=self.name, value=0.5, reason="Class metric")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
|
|
result = metric_fn({}, "test output")
|
|
assert result.value == 0.5
|
|
assert result.name == "test"
|
|
|
|
def test_code_metric_uses_json(self):
|
|
"""Test that json module can be used."""
|
|
code = '''
|
|
import json
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class JsonMetric(BaseMetric):
|
|
def __init__(self, name: str = "json_test"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
data = json.loads(output) if output.startswith("{") else {}
|
|
return ScoreResult(name=self.name, value=1.0, reason="Used json")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
result = metric_fn({}, '{"key": "value"}')
|
|
assert result.value == 1.0
|
|
|
|
def test_code_metric_uses_re(self):
|
|
"""Test that re module can be used."""
|
|
code = '''
|
|
import re
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class RegexMetric(BaseMetric):
|
|
def __init__(self, name: str = "regex_test"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
match = re.search(r"\\d+", output)
|
|
return ScoreResult(name=self.name, value=1.0 if match else 0.0, reason="Used re")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
result = metric_fn({}, "test 123")
|
|
assert result.value == 1.0
|
|
|
|
def test_code_metric_uses_math(self):
|
|
"""Test that math module can be used."""
|
|
code = '''
|
|
import math
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class MathMetric(BaseMetric):
|
|
def __init__(self, name: str = "math_test"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
return ScoreResult(name=self.name, value=math.sqrt(0.25), reason="Used math")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
result = metric_fn({}, "test")
|
|
assert result.value == 0.5
|
|
|
|
def test_code_metric_receives_dataset_fields_as_kwargs(self):
|
|
"""Test that dataset_item fields are passed as kwargs to score method."""
|
|
code = '''
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class KwargsMetric(BaseMetric):
|
|
def __init__(self, name: str = "kwargs_test"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
expected = kwargs.get("expected_value", "")
|
|
score = 1.0 if output == expected else 0.0
|
|
return ScoreResult(name=self.name, value=score, reason=f"Expected: {expected}")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
|
|
# Test with matching expected_value
|
|
result = metric_fn({"expected_value": "correct"}, "correct")
|
|
assert result.value == 1.0
|
|
|
|
# Test with non-matching expected_value
|
|
result = metric_fn({"expected_value": "correct"}, "wrong")
|
|
assert result.value == 0.0
|
|
|
|
def test_code_metric_preserves_custom_name(self):
|
|
"""Test that the metric name defined by user is preserved."""
|
|
code = '''
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class CustomNamedMetric(BaseMetric):
|
|
def __init__(self, name: str = "my_custom_metric_name"):
|
|
super().__init__(name=name)
|
|
|
|
def score(self, output, **kwargs):
|
|
return ScoreResult(name=self.name, value=1.0, reason="Test")
|
|
'''
|
|
metric_fn = MetricFactory.build("code", {"code": code}, "model")
|
|
result = metric_fn({}, "test output")
|
|
|
|
assert result.name == "my_custom_metric_name"
|
|
|
|
def test_code_metric_missing_code_raises_error(self):
|
|
"""Test that missing code parameter raises error."""
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("code", {}, "model")
|
|
|
|
assert "Missing 'code' parameter" in str(exc_info.value)
|
|
|
|
def test_code_metric_empty_code_raises_error(self):
|
|
"""Test that empty code raises error."""
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("code", {"code": ""}, "model")
|
|
|
|
assert "Missing 'code' parameter" in str(exc_info.value)
|
|
|
|
def test_code_metric_invalid_syntax_raises_error(self):
|
|
"""Test that invalid Python syntax raises error."""
|
|
code = '''
|
|
class MyMetric(BaseMetric)
|
|
def score(self, output, **kwargs):
|
|
return ScoreResult(name="test", value=1.0, reason="OK")
|
|
'''
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("code", {"code": code}, "model")
|
|
|
|
assert "Invalid Python code" in str(exc_info.value)
|
|
|
|
def test_code_metric_no_basemetric_class_raises_error(self):
|
|
"""Test that code without a BaseMetric subclass raises error at build time.
|
|
|
|
With executor infrastructure, code must define a BaseMetric subclass.
|
|
Validation at build time provides fail-fast behavior.
|
|
"""
|
|
code = '''
|
|
# Just a comment, no BaseMetric class
|
|
x = 1
|
|
'''
|
|
# Should raise InvalidMetricError during build (validation step)
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("code", {"code": code}, "model")
|
|
|
|
assert "BaseMetric" in str(exc_info.value)
|
|
|
|
def test_code_metric_function_only_raises_error(self):
|
|
"""Test that function-only code (no BaseMetric class) raises error at build time.
|
|
|
|
Function-based metrics are not supported - only BaseMetric class pattern.
|
|
Validation at build time provides fail-fast behavior.
|
|
"""
|
|
code = '''
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
def my_metric(dataset_item, llm_output):
|
|
return ScoreResult(name="test", value=1.0, reason="Function")
|
|
'''
|
|
# Should raise InvalidMetricError during build (validation step)
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build("code", {"code": code}, "model")
|
|
|
|
assert "BaseMetric" in str(exc_info.value)
|
|
|
|
|
|
class TestJsonPathReferenceKey:
|
|
"""Tests for JSONPath support in reference_key for equals and levenshtein metrics."""
|
|
|
|
def test_equals_jsonpath_filter_expression(self):
|
|
"""Test equals metric with a JSONPath filter to extract a value from a JSON array."""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'Useful')].value"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {
|
|
"feedback_scores": [
|
|
{"name": "Usefulness", "value": 0.8, "source": "ONLINE_SCORING"},
|
|
{"name": "Useful", "category_name": "Fail", "value": 0, "source": "UI"},
|
|
{"name": "Useful-Numerical", "value": 2, "source": "UI"},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "0")
|
|
assert result.value == 1.0
|
|
|
|
def test_levenshtein_jsonpath_filter_expression(self):
|
|
"""Test levenshtein metric with a JSONPath filter expression."""
|
|
metric_fn = MetricFactory.build(
|
|
"levenshtein_ratio",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'Useful')].category_name"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {
|
|
"feedback_scores": [
|
|
{"name": "Usefulness", "category_name": "Pass", "value": 0.8},
|
|
{"name": "Useful", "category_name": "Fail", "value": 0},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "Fail")
|
|
assert result.value == 1.0
|
|
|
|
def test_jsonpath_index_access(self):
|
|
"""Test reference_key with a JSONPath array index."""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.scores[0].value"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {
|
|
"scores": [
|
|
{"name": "first", "value": "42"},
|
|
{"name": "second", "value": "99"},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "42")
|
|
assert result.value == 1.0
|
|
|
|
def test_jsonpath_no_match_scores_zero_with_reason(self):
|
|
"""A JSONPath that resolves nothing must not silently match empty output.
|
|
|
|
Regression guard for OPIK-7160: the old behavior defaulted an
|
|
unresolvable reference to "" and reported a perfect 1.0 against empty
|
|
output, hiding the misconfiguration. It now scores 0.0 and explains why.
|
|
"""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'NonExistent')].value"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {
|
|
"feedback_scores": [
|
|
{"name": "Useful", "value": 0},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "")
|
|
assert result.value == 0.0
|
|
assert "Missing reference value" in result.reason
|
|
|
|
def test_jsonpath_no_match_against_nonempty_output(self):
|
|
"""A JSONPath with no matches scores 0 against non-empty output."""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'NonExistent')].value"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {
|
|
"feedback_scores": [
|
|
{"name": "Useful", "value": 0},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "something")
|
|
assert result.value == 0.0
|
|
|
|
def test_plain_key_still_works(self):
|
|
"""Test that plain field names continue to work as before."""
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "answer"},
|
|
"model",
|
|
)
|
|
|
|
dataset_item = {"answer": "hello"}
|
|
result = metric_fn(dataset_item, "hello")
|
|
assert result.value == 1.0
|
|
|
|
def test_is_jsonpath_detection(self):
|
|
"""Test the heuristic that distinguishes plain keys from JSONPath expressions."""
|
|
from opik_backend.studio.metrics import _is_jsonpath
|
|
|
|
assert _is_jsonpath("answer") is False
|
|
assert _is_jsonpath("expected_output") is False
|
|
assert _is_jsonpath("my-field") is False
|
|
assert _is_jsonpath("my.field") is False
|
|
|
|
assert _is_jsonpath("$.answer") is True
|
|
assert _is_jsonpath("scores[0].value") is True
|
|
assert _is_jsonpath("$.scores[?(@.name == 'x')].value") is True
|
|
assert _is_jsonpath("items..value") is True
|
|
|
|
|
|
class TestReferenceKeyValidation:
|
|
"""Build-time validation that a reference_key resolves against the dataset.
|
|
|
|
Guards OPIK-7160: a reference_key matching no dataset field silently scored
|
|
every item 0, so no candidate could beat the baseline and the optimizer
|
|
returned the seed prompt while the run reported "completed". Building the
|
|
metric now fails loudly instead, keeping that failure distinguishable from a
|
|
legitimate "no improvement over baseline" run (OPIK-7038).
|
|
"""
|
|
|
|
def test_equals_build_raises_when_key_resolves_no_item(self):
|
|
dataset_items = [{"answer": "a"}, {"answer": "b"}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "label"}, # not a dataset field
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
message = str(exc_info.value)
|
|
assert "label" in message
|
|
assert "did not resolve" in message
|
|
# Available fields are surfaced to make the fix obvious.
|
|
assert "answer" in message
|
|
|
|
def test_levenshtein_build_raises_when_key_resolves_no_item(self):
|
|
dataset_items = [{"answer": "a"}, {"answer": "b"}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"levenshtein_ratio",
|
|
{"reference_key": "typo"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "did not resolve" in str(exc_info.value)
|
|
|
|
def test_numerical_similarity_build_raises_when_key_resolves_no_item(self):
|
|
dataset_items = [{"score": 1}, {"score": 2}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"numerical_similarity",
|
|
{"reference_key": "value"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "did not resolve" in str(exc_info.value)
|
|
|
|
def test_build_passes_when_key_resolves_for_some_items(self):
|
|
# Sparse data: the key is present on only one item. Validation passes;
|
|
# missing items are handled per-item at scoring time.
|
|
dataset_items = [{"answer": "a"}, {"other": "b"}]
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "answer"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_skips_validation_without_provider(self):
|
|
# No dataset available (e.g. config validation) -> do not guess, skip.
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "anything"},
|
|
"model",
|
|
)
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_skips_validation_for_empty_dataset(self):
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "anything"},
|
|
"model",
|
|
dataset_items_provider=lambda: [],
|
|
)
|
|
assert callable(metric_fn)
|
|
|
|
def test_equals_jsonpath_build_raises_when_no_item_matches(self):
|
|
dataset_items = [
|
|
{"feedback_scores": [{"name": "Useful", "value": 0}]},
|
|
]
|
|
with pytest.raises(InvalidMetricError):
|
|
MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'Missing')].value"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
|
|
def test_malformed_jsonpath_build_raises_with_syntax_error(self):
|
|
# A JSONPath-shaped key with invalid syntax that matches no literal field
|
|
# must fail with a JSONPath-specific message, not the generic
|
|
# "did not resolve" one (which hides the real cause). The literal
|
|
# fallback in _resolve_reference otherwise swallows the parse error.
|
|
dataset_items = [{"answer": "42"}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "$.foo["},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "not a valid JSONPath expression" in str(exc_info.value)
|
|
|
|
def test_numerical_similarity_malformed_jsonpath_build_raises(self):
|
|
# Same guard on the numerical_similarity validation path, which infers
|
|
# scale from a separate resolution loop.
|
|
dataset_items = [{"score": 3}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"numerical_similarity",
|
|
{"reference_key": "$.foo["},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "not a valid JSONPath expression" in str(exc_info.value)
|
|
|
|
def test_build_passes_when_field_present_but_null(self):
|
|
# A field that exists on every item but holds null is a data-quality
|
|
# issue, not a key misconfiguration -> the metric must still build
|
|
# (regression guard: this previously hard-failed the run with a
|
|
# self-contradictory "did not resolve ... available fields: answer"
|
|
# message).
|
|
dataset_items = [{"answer": None}, {"answer": None}]
|
|
metric_fn = MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "answer"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert callable(metric_fn)
|
|
|
|
def test_build_does_not_crash_on_non_dict_items(self):
|
|
# Malformed dataset items must yield a clean InvalidMetricError, not an
|
|
# AttributeError from .get()/.keys() on a non-dict.
|
|
dataset_items = [None, "scalar", 42]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"equals",
|
|
{"reference_key": "answer"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "did not resolve" in str(exc_info.value)
|
|
|
|
def test_numerical_similarity_raises_when_references_non_numeric(self):
|
|
# The key resolves for every item, but to non-numeric text -> every item
|
|
# would score 0 (silent flat-0). numerical_similarity must fail loudly
|
|
# (OPIK-7160), unlike equals/levenshtein for which any resolved value is
|
|
# scoreable.
|
|
dataset_items = [{"answer": "positive"}, {"answer": "negative"}]
|
|
with pytest.raises(InvalidMetricError) as exc_info:
|
|
MetricFactory.build(
|
|
"numerical_similarity",
|
|
{"reference_key": "answer"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert "no dataset item held a numeric value" in str(exc_info.value)
|
|
|
|
def test_numerical_similarity_builds_with_one_numeric_reference(self):
|
|
# Sparse numeric data: only some items are numeric. Build succeeds
|
|
# (there is at least one numeric reference to work with) and the numeric
|
|
# scoring path is actually live -- not a silent flat-0.
|
|
dataset_items = [{"score": 3}, {"score": "n/a"}]
|
|
metric_fn = MetricFactory.build(
|
|
"numerical_similarity",
|
|
{"reference_key": "score"},
|
|
"model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
assert callable(metric_fn)
|
|
# A single numeric reference means scale_range falls back to 1.0, so an
|
|
# exact match scores 1.0 -- exercising the similarity math, not just the
|
|
# constructor.
|
|
exact = metric_fn({"score": 3}, "3")
|
|
assert exact.value == 1.0
|
|
assert exact.name == "numerical_similarity"
|
|
# A unit-off output is normalized against scale_range=1.0 -> 0.0.
|
|
off_by_one = metric_fn({"score": 3}, "2")
|
|
assert off_by_one.value == 0.0
|
|
# The non-numeric sibling still yields a clean 0 with an explanatory reason.
|
|
non_numeric = metric_fn({"score": "n/a"}, "3")
|
|
assert non_numeric.value == 0.0
|
|
assert "not numeric" in non_numeric.reason
|
|
|
|
|
|
class TestMissingReferencePerItem:
|
|
"""Per-item feedback when a reference key is absent on a specific item."""
|
|
|
|
def test_equals_missing_reference_scores_zero_with_reason(self):
|
|
metric_fn = MetricFactory.build("equals", {"reference_key": "answer"}, "model")
|
|
result = metric_fn({"other": "x"}, "x")
|
|
assert result.value == 0.0
|
|
assert "Missing reference value" in result.reason
|
|
assert "answer" in result.reason
|
|
|
|
def test_levenshtein_missing_reference_scores_zero_with_reason(self):
|
|
metric_fn = MetricFactory.build(
|
|
"levenshtein_ratio", {"reference_key": "answer"}, "model"
|
|
)
|
|
result = metric_fn({"other": "x"}, "x")
|
|
assert result.value == 0.0
|
|
assert "Missing reference value" in result.reason
|
|
|
|
def test_present_empty_string_reference_matches_empty_output(self):
|
|
# A field that is present but holds "" is a real reference value, not a
|
|
# missing one: empty output should still score a perfect match. Only a
|
|
# genuinely absent field short-circuits to the missing-reference result.
|
|
metric_fn = MetricFactory.build("equals", {"reference_key": "answer"}, "model")
|
|
result = metric_fn({"answer": ""}, "")
|
|
assert result.value == 1.0
|
|
|
|
|
|
class TestNumericalSimilarityMetric:
|
|
|
|
def test_exact_match(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": 0.7}, "0.7")
|
|
assert result.value == 1.0
|
|
assert result.name == "numerical_similarity"
|
|
|
|
def test_close_values(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": 0.7}, "0.85")
|
|
# scale_range=1.0 (no dataset), diff=0.15 -> max(0, 1 - 0.15) = 0.85
|
|
assert abs(result.value - 0.85) < 1e-6
|
|
|
|
def test_far_values_clamps_to_zero(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": 0.0}, "5.0")
|
|
# scale_range=1.0 (no dataset), diff=5.0 -> max(0, 1 - 5.0) = 0.0
|
|
assert result.value == 0.0
|
|
|
|
def test_non_numeric_output(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": 0.7}, "not a number")
|
|
assert result.value == 0.0
|
|
assert "Could not parse" in result.reason
|
|
|
|
def test_missing_reference(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "missing"}, "model")
|
|
result = metric_fn({"score": 0.7}, "0.7")
|
|
assert result.value == 0.0
|
|
assert "Missing reference" in result.reason
|
|
|
|
def test_non_numeric_reference(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": "not a number"}, "0.7")
|
|
assert result.value == 0.0
|
|
assert "not numeric" in result.reason
|
|
|
|
def test_with_jsonpath_reference(self):
|
|
metric_fn = MetricFactory.build(
|
|
"numerical_similarity",
|
|
{"reference_key": "$.feedback_scores[?(@.name == 'Useful')].value"},
|
|
"model",
|
|
)
|
|
dataset_item = {
|
|
"feedback_scores": [
|
|
{"name": "Useful", "value": 0.7},
|
|
{"name": "Usefulness", "value": 0.8},
|
|
]
|
|
}
|
|
result = metric_fn(dataset_item, "0.85")
|
|
# scale_range=1.0 (no dataset), diff=0.15 -> max(0, 1 - 0.15) = 0.85
|
|
assert abs(result.value - 0.85) < 1e-6
|
|
|
|
def test_integer_values(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
result = metric_fn({"score": 1}, "1")
|
|
assert result.value == 1.0
|
|
|
|
def test_scale_range_inferred_from_dataset(self):
|
|
dataset_items = [{"score": 0}, {"score": 1}, {"score": 2}, {"score": 3}, {"score": 4}, {"score": 5}]
|
|
metric_fn = MetricFactory.build(
|
|
"numerical_similarity", {"reference_key": "score"}, "model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
# scale_range = 5 - 0 = 5
|
|
# ref=4.5, output=4 -> normalized_error = 0.5/5 = 0.1 -> max(0, 1 - 0.1) = 0.9
|
|
result = metric_fn({"score": 4.5}, "4")
|
|
assert abs(result.value - 0.9) < 1e-6
|
|
|
|
def test_scale_range_max_error_gives_zero(self):
|
|
dataset_items = [{"score": 0}, {"score": 5}]
|
|
metric_fn = MetricFactory.build(
|
|
"numerical_similarity", {"reference_key": "score"}, "model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
# scale_range=5, diff=5 -> normalized_error=1.0 -> max(0, 1-1) = 0.0
|
|
result = metric_fn({"score": 0}, "5")
|
|
assert result.value == 0.0
|
|
|
|
def test_scale_range_fallback_without_dataset(self):
|
|
metric_fn = MetricFactory.build("numerical_similarity", {"reference_key": "score"}, "model")
|
|
# No provider -> scale_range=1.0 -> max(0, 1 - 0.5) = 0.5
|
|
result = metric_fn({"score": 4.5}, "4")
|
|
assert abs(result.value - 0.5) < 1e-6
|
|
|
|
def test_scale_range_single_value_no_range(self):
|
|
dataset_items = [{"score": 3}, {"score": 3}, {"score": 3}]
|
|
metric_fn = MetricFactory.build(
|
|
"numerical_similarity", {"reference_key": "score"}, "model",
|
|
dataset_items_provider=lambda: dataset_items,
|
|
)
|
|
# All same value -> range=0 -> falls back to scale_range=1.0
|
|
# diff=1 -> max(0, 1 - 1.0) = 0.0
|
|
result = metric_fn({"score": 3}, "4")
|
|
assert result.value == 0.0
|
|
|
|
|
|
class TestGEvalTemplateInterpolation:
|
|
"""Tests for GEval metric template interpolation with dataset item fields."""
|
|
|
|
def test_geval_with_none_params_uses_defaults(self):
|
|
"""Test that GEval handles explicit None params without crashing."""
|
|
# Callers may explicitly pass None for optional fields
|
|
params = {
|
|
"task_introduction": None,
|
|
"evaluation_criteria": None
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
def test_geval_with_missing_params_uses_defaults(self):
|
|
"""Test that GEval handles missing params using defaults."""
|
|
params = {}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
def test_geval_with_placeholders_handles_none_dataset_item(self):
|
|
"""Test that GEval with placeholders handles None dataset_item without crashing."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
params = {
|
|
"task_introduction": "Evaluate the {{topic}} response",
|
|
"evaluation_criteria": "Check if output matches {{answer}}"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
# Verify interpolation with None returns template unchanged (placeholders preserved)
|
|
result = _interpolate_template(params["evaluation_criteria"], {})
|
|
assert result == "Check if output matches {{answer}}"
|
|
|
|
def test_geval_without_placeholders_creates_single_instance(self):
|
|
"""Test that GEval without placeholders creates a single reusable instance."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
params = {
|
|
"task_introduction": "Evaluate the response quality",
|
|
"evaluation_criteria": "Is the response helpful and accurate?"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
# Verify no interpolation changes static text
|
|
dataset_item = {"answer": "42"}
|
|
assert _interpolate_template(params["task_introduction"], dataset_item) == "Evaluate the response quality"
|
|
assert _interpolate_template(params["evaluation_criteria"], dataset_item) == "Is the response helpful and accurate?"
|
|
|
|
def test_geval_with_placeholders_in_criteria(self):
|
|
"""Test that GEval with {{field}} placeholders in criteria works."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
params = {
|
|
"task_introduction": "Evaluate the response",
|
|
"evaluation_criteria": "Check if the output matches the expected answer: {{answer}}"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
# Verify placeholder interpolation in criteria
|
|
dataset_item = {"answer": "42"}
|
|
result = _interpolate_template(params["evaluation_criteria"], dataset_item)
|
|
assert result == "Check if the output matches the expected answer: 42"
|
|
|
|
def test_geval_with_placeholders_in_task_introduction(self):
|
|
"""Test that GEval with {{field}} placeholders in task_introduction works."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
params = {
|
|
"task_introduction": "You are evaluating a {{topic}} question",
|
|
"evaluation_criteria": "Is the response accurate?"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
# Verify placeholder interpolation in task_introduction
|
|
dataset_item = {"topic": "math"}
|
|
result = _interpolate_template(params["task_introduction"], dataset_item)
|
|
assert result == "You are evaluating a math question"
|
|
|
|
def test_geval_with_multiple_placeholders(self):
|
|
"""Test that GEval with multiple {{field}} placeholders works."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
params = {
|
|
"task_introduction": "Evaluate the {{task_type}} response for {{domain}}",
|
|
"evaluation_criteria": "Expected answer is {{answer}}. Context: {{context}}"
|
|
}
|
|
metric_fn = MetricFactory.build("geval", params, "openai/gpt-4o")
|
|
|
|
assert metric_fn.__name__ == "geval"
|
|
assert callable(metric_fn)
|
|
|
|
# Verify multiple placeholder interpolation
|
|
dataset_item = {
|
|
"task_type": "homework",
|
|
"domain": "algebra",
|
|
"answer": "x=5",
|
|
"context": "solving equations"
|
|
}
|
|
intro_result = _interpolate_template(params["task_introduction"], dataset_item)
|
|
criteria_result = _interpolate_template(params["evaluation_criteria"], dataset_item)
|
|
|
|
assert intro_result == "Evaluate the homework response for algebra"
|
|
assert criteria_result == "Expected answer is x=5. Context: solving equations"
|
|
|
|
|
|
class TestGEvalInterpolationHelpers:
|
|
"""Tests for GEval template interpolation helper functions."""
|
|
|
|
def test_interpolate_template_single_field(self):
|
|
"""Test interpolating a single field."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Expected: {{answer}}"
|
|
dataset_item = {"answer": "42"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Expected: 42"
|
|
|
|
def test_interpolate_template_multiple_fields(self):
|
|
"""Test interpolating multiple fields."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Question: {{question}}, Expected: {{answer}}"
|
|
dataset_item = {"question": "What is 6*7?", "answer": "42"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Question: What is 6*7?, Expected: 42"
|
|
|
|
def test_interpolate_template_missing_field_unchanged(self):
|
|
"""Test that missing fields leave placeholder unchanged."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Expected: {{missing_field}}"
|
|
dataset_item = {"answer": "42"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Expected: {{missing_field}}"
|
|
|
|
def test_interpolate_template_no_placeholders(self):
|
|
"""Test template without placeholders returns unchanged."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "No placeholders here"
|
|
dataset_item = {"answer": "42"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "No placeholders here"
|
|
|
|
def test_interpolate_template_non_string_values(self):
|
|
"""Test that non-string values are converted to strings."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Count: {{count}}, Active: {{active}}"
|
|
dataset_item = {"count": 123, "active": True}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Count: 123, Active: True"
|
|
|
|
def test_interpolate_template_dotted_keys(self):
|
|
"""Test interpolating keys with dots (e.g., user.name)."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "User: {{user.name}}, ID: {{user.id}}"
|
|
dataset_item = {"user.name": "Alice", "user.id": "12345"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "User: Alice, ID: 12345"
|
|
|
|
def test_interpolate_template_hyphenated_keys(self):
|
|
"""Test interpolating keys with hyphens (e.g., answer-key)."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Answer: {{answer-key}}, Type: {{response-type}}"
|
|
dataset_item = {"answer-key": "correct", "response-type": "multiple-choice"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Answer: correct, Type: multiple-choice"
|
|
|
|
def test_interpolate_template_mixed_special_chars(self):
|
|
"""Test interpolating keys with mixed dots, hyphens, and underscores."""
|
|
from opik_backend.studio.metrics import _interpolate_template
|
|
|
|
template = "Value: {{var_with-special.chars}}"
|
|
dataset_item = {"var_with-special.chars": "complex_value"}
|
|
result = _interpolate_template(template, dataset_item)
|
|
|
|
assert result == "Value: complex_value"
|
|
|
|
def test_has_template_placeholders_true(self):
|
|
"""Test detecting placeholders in text."""
|
|
from opik_backend.studio.metrics import _has_template_placeholders
|
|
|
|
assert _has_template_placeholders("Contains {{field}}") is True
|
|
assert _has_template_placeholders("Multiple {{a}} and {{b}}") is True
|
|
|
|
def test_has_template_placeholders_false(self):
|
|
"""Test detecting no placeholders in text."""
|
|
from opik_backend.studio.metrics import _has_template_placeholders
|
|
|
|
assert _has_template_placeholders("No placeholders") is False
|
|
assert _has_template_placeholders("Single braces {field}") is False
|
|
assert _has_template_placeholders("") is False
|
|
|
|
|
|
class TestTemplateSyntaxConversion:
|
|
"""Tests for template syntax conversion from {{var}} to {var}."""
|
|
|
|
def test_convert_single_variable(self):
|
|
"""Test converting single variable."""
|
|
result = _convert_template_syntax("Hello {{name}}")
|
|
assert result == "Hello {name}"
|
|
|
|
def test_convert_multiple_variables(self):
|
|
"""Test converting multiple variables."""
|
|
result = _convert_template_syntax("{{greeting}} {{name}}!")
|
|
assert result == "{greeting} {name}!"
|
|
|
|
def test_preserve_single_braces(self):
|
|
"""Test that single braces are preserved."""
|
|
result = _convert_template_syntax("Already {converted}")
|
|
assert result == "Already {converted}"
|
|
|
|
def test_no_variables(self):
|
|
"""Test string without variables."""
|
|
result = _convert_template_syntax("No variables here")
|
|
assert result == "No variables here"
|
|
|
|
def test_empty_string(self):
|
|
"""Test empty string."""
|
|
result = _convert_template_syntax("")
|
|
assert result == ""
|
|
|
|
def test_variable_in_sentence(self):
|
|
"""Test variable embedded in sentence."""
|
|
result = _convert_template_syntax("What is the mime type for {{url}}?")
|
|
assert result == "What is the mime type for {url}?"
|
|
|
|
def test_optimization_config_converts_templates(self):
|
|
"""Test OptimizationConfig.from_dict converts template syntax in prompt messages."""
|
|
config = {
|
|
"dataset_name": "test_dataset",
|
|
"prompt": {
|
|
"messages": [
|
|
{"role": "system", "content": "Be helpful"},
|
|
{"role": "user", "content": "What is {{question}}? Answer: {{answer}}"}
|
|
]
|
|
},
|
|
"llm_model": {"model": "gpt-4o-mini", "parameters": {}},
|
|
"evaluation": {"metrics": [{"type": "equals", "parameters": {}}]},
|
|
"optimizer": {"type": "gepa", "parameters": {}}
|
|
}
|
|
|
|
opt_config = OptimizationConfig.from_dict(config)
|
|
|
|
# System message should be unchanged (no variables)
|
|
assert opt_config.prompt_messages[0]["content"] == "Be helpful"
|
|
# User message should have converted variables
|
|
assert opt_config.prompt_messages[1]["content"] == "What is {question}? Answer: {answer}"
|
|
|
|
|