Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion eval_protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@
from .resources import create_llm_resource
from .reward_function import RewardFunction
from .typed_interface import reward_function
from .quickstart import aha_judge, multi_turn_assistant_to_ground_truth, assistant_to_ground_truth
from .quickstart.aha_judge import aha_judge
from .utils.evaluation_row_utils import (
multi_turn_assistant_to_ground_truth,
assistant_to_ground_truth,
filter_longest_conversation,
)
from .pytest import evaluation_test, SingleTurnRolloutProcessor, RemoteRolloutProcessor, GithubActionRolloutProcessor
from .pytest.remote_rollout_processor import create_elasticsearch_config_from_env
from .pytest.parameterize import DefaultParameterIdGenerator
Expand Down Expand Up @@ -102,6 +107,7 @@
"aha_judge",
"multi_turn_assistant_to_ground_truth",
"assistant_to_ground_truth",
"filter_longest_conversation",
"evaluation_test",
"SingleTurnRolloutProcessor",
"OpenAIResponsesAdapter",
Expand Down
2 changes: 1 addition & 1 deletion eval_protocol/pytest/tracing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from eval_protocol.adapters.fireworks_tracing import FireworksTracingAdapter
from eval_protocol.data_loader.dynamic_data_loader import DynamicDataLoader
from eval_protocol.models import EvaluationRow, Status
from eval_protocol.quickstart.utils import filter_longest_conversation
from eval_protocol.utils.evaluation_row_utils import filter_longest_conversation
from eval_protocol.types.remote_rollout_processor import DataLoaderConfig, RolloutMetadata, InitRequest
from eval_protocol.pytest.types import RolloutProcessorConfig

Expand Down
8 changes: 6 additions & 2 deletions eval_protocol/quickstart/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from .llm_judge import aha_judge
from .utils import multi_turn_assistant_to_ground_truth, assistant_to_ground_truth
"""
Quickstart modules for various evaluation scenarios.
"""

from eval_protocol.quickstart.aha_judge.llm_judge import aha_judge
from eval_protocol.utils.evaluation_row_utils import multi_turn_assistant_to_ground_truth, assistant_to_ground_truth

__all__ = ["aha_judge", "multi_turn_assistant_to_ground_truth", "assistant_to_ground_truth"]
4 changes: 4 additions & 0 deletions eval_protocol/quickstart/aha_judge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from eval_protocol.quickstart.aha_judge.llm_judge import aha_judge
from eval_protocol.utils.evaluation_row_utils import multi_turn_assistant_to_ground_truth, assistant_to_ground_truth

__all__ = ["aha_judge", "multi_turn_assistant_to_ground_truth", "assistant_to_ground_truth"]
90 changes: 90 additions & 0 deletions eval_protocol/quickstart/aha_judge/llm_judge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
Default LLM judge for Eval Protocol. Inspired by Arena-Hard-Auto.
"""

from typing import Optional

from eval_protocol.models import EvaluationRow, EvaluateResult, MetricResult
from eval_protocol.adapters.base import BaseAdapter
from eval_protocol.quickstart.aha_judge.utils import (
JUDGE_CONFIGS,
LABEL_TO_SCORE,
run_single_judgment,
)
from eval_protocol.utils.evaluation_row_utils import serialize_message

from openai import AsyncOpenAI


async def aha_judge(
row: EvaluationRow, judge_name: str = "kimi-k2-instruct-0905", adapter: Optional[BaseAdapter] = None
) -> EvaluationRow:
"""
LLM Judge evaluation using Arena-Hard-Auto style pairwise comparisons for a single row.

Compares model response against ground truth using an LLM judge:
1. Extracts the question from messages[:-1]
2. Compares messages[-1] (new model response) vs ground_truth (baseline response)
3. Runs two judgment rounds (A vs B, B vs A) to reduce position bias
4. Returns individual scores for bootstrap aggregation

Args:
row: Single EvaluationRow object with messages, ground_truth, and tools
judge_name: Name of the judge configuration to use
adapter: Optional adapter to push scores back to (if provided)

Returns:
Same row with updated evaluation_result containing individual judgment scores
"""

if not row.messages:
return row

judge_config = JUDGE_CONFIGS[judge_name]

# Extract question and answers
question_text = "\n".join([serialize_message(msg) for msg in row.messages[:-1]])
model_a_answer = str(row.ground_truth)
model_b_answer = serialize_message(row.messages[-1])

async with AsyncOpenAI(api_key=judge_config.get("api_key"), base_url=judge_config.get("base_url")) as client:
# Run two judgment rounds in sequence (A vs B, then B vs A)
result1 = await run_single_judgment(
question_text, model_a_answer, model_b_answer, row.tools, judge_config, client
)
result2 = await run_single_judgment(
question_text, model_b_answer, model_a_answer, row.tools, judge_config, client
)

if not result1 or not result2 or not result1.get("score") or not result2.get("score"):
# If either judgment failed, mark as invalid (don't include in distribution)
final_score = 0.0
reason = "Failed to get judgment scores"
metrics = {}
is_score_valid = False
else:
# Convert judgment scores to numerical scores
game1_score = 1 - LABEL_TO_SCORE[result1["score"]]
game2_score = LABEL_TO_SCORE[result2["score"]]
final_score = (game1_score + game2_score) / 2

reason = f"LLM Judge comparison: Round 1: {result1['score']}, Round 2: {result2['score']}"
metrics = {
"round1_judgment": MetricResult(score=game1_score, reason=result1["judgment"]),
"round2_judgment": MetricResult(score=game2_score, reason=result2["judgment"]),
}
is_score_valid = True

row.evaluation_result = EvaluateResult(
score=final_score,
reason=reason,
metrics=metrics,
is_score_valid=is_score_valid,
)

# Upload score to adapter if provided
if adapter and row.evaluation_result and row.evaluation_result.is_score_valid:
model_name = row.input_metadata.completion_params.get("model", "unknown_model")
adapter.upload_score(row, model_name)

return row
63 changes: 63 additions & 0 deletions eval_protocol/quickstart/aha_judge/llm_judge_braintrust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Example for using Braintrust with the aha judge.
"""

import os

import pytest

# Skip entire module in CI to prevent import-time side effects
if os.environ.get("CI") == "true":
pytest.skip("Skip quickstart in CI", allow_module_level=True)

from eval_protocol import (
evaluation_test,
aha_judge,
EvaluationRow,
SingleTurnRolloutProcessor,
DynamicDataLoader,
create_braintrust_adapter,
multi_turn_assistant_to_ground_truth,
)


# uncomment when dataloader is fixed
def braintrust_data_generator():
adapter = create_braintrust_adapter()
return adapter.get_evaluation_rows(
btql_query=f"""
select: *
from: project_logs('{os.getenv("BRAINTRUST_PROJECT_ID")}') traces
filter: is_root = true
limit: 10
"""
)


@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Skip in CI")
@pytest.mark.parametrize(
"completion_params",
[
{"model": "gpt-4.1"},
{
"max_tokens": 131000,
"extra_body": {"reasoning_effort": "medium"},
"model": "fireworks_ai/accounts/fireworks/models/gpt-oss-120b",
},
{
"max_tokens": 131000,
"extra_body": {"reasoning_effort": "low"},
"model": "fireworks_ai/accounts/fireworks/models/gpt-oss-20b",
},
],
)
@evaluation_test(
data_loaders=DynamicDataLoader(
generators=[braintrust_data_generator],
preprocess_fn=multi_turn_assistant_to_ground_truth,
),
rollout_processor=SingleTurnRolloutProcessor(),
max_concurrent_evaluations=2,
)
async def test_llm_judge(row: EvaluationRow) -> EvaluationRow:
return await aha_judge(row)
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@
from eval_protocol import (
evaluation_test,
aha_judge,
multi_turn_assistant_to_ground_truth,
EvaluationRow,
SingleTurnRolloutProcessor,
create_langfuse_adapter,
DynamicDataLoader,
multi_turn_assistant_to_ground_truth,
)

from eval_protocol.quickstart import aha_judge


def langfuse_data_generator():
adapter = create_langfuse_adapter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
from eval_protocol import (
evaluation_test,
aha_judge,
multi_turn_assistant_to_ground_truth,
EvaluationRow,
SingleTurnRolloutProcessor,
LangSmithAdapter,
DynamicDataLoader,
multi_turn_assistant_to_ground_truth,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
from eval_protocol import (
evaluation_test,
aha_judge,
multi_turn_assistant_to_ground_truth,
EvaluationRow,
SingleTurnRolloutProcessor,
OpenAIResponsesAdapter,
DynamicDataLoader,
multi_turn_assistant_to_ground_truth,
)


Expand Down
133 changes: 133 additions & 0 deletions eval_protocol/quickstart/aha_judge/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Arena-Hard-Auto utility functions adapted for Eval Protocol.
"""

import os
import re
from typing import Dict, Any, Optional

OG_ARENA_HARD_PROMPT = """Please act as an impartial judge and evaluate the quality of the responses provided by two AI assistants to the user prompt displayed below. You will be given assistant A's answer and assistant B's answer. Your job is to evaluate which assistant's answer is better.

Begin your evaluation by generating your own answer to the prompt. You must provide your answers before judging any answers.

When evaluating the assistants' answers, compare both assistants' answers with your answer. You must identify and correct any mistakes or inaccurate information.

Then consider if the assistant's answers are helpful, relevant, and concise. Helpful means the answer correctly responds to the prompt or follows the instructions. Note when user prompt has any ambiguity or more than one interpretation, it is more helpful and appropriate to ask for clarifications or more information from the user than providing an answer based on assumptions. Relevant means all parts of the response closely connect or are appropriate to what is being asked. Concise means the response is clear and not verbose or excessive.

Then consider the creativity and novelty of the assistant's answers when needed. Finally, identify any missing important information in the assistants' answers that would be beneficial to include when responding to the user prompt.

After providing your explanation, you must output only one of the following choices as your final verdict with a label:

1. Assistant A is significantly better: [[A>>B]]
2. Assistant A is slightly better: [[A>B]]
3. Tie, relatively the same: [[A=B]]
4. Assistant B is slightly better: [[B>A]]
5. Assistant B is significantly better: [[B>>A]]

Example output: "My final verdict is tie: [[A=B]]"."""


# Judge model configurations for Arena-Hard-Auto style evaluation
# Each config specifies the model, parameters, and concurrency limits for LLM judges
JUDGE_CONFIGS = {
"gpt-4.1": {
"model": "gpt-4.1",
"temperature": 0.0,
"max_tokens": 16000,
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"temperature": 1.0,
"max_tokens": 32000,
"api_key": os.getenv("GEMINI_API_KEY"),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
},
"gemini-2.5-flash": {
"model": "gemini-2.5-flash",
"temperature": 1.0,
"max_tokens": 32000,
"api_key": os.getenv("GEMINI_API_KEY"),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
},
"kimi-k2-instruct-0905": {
"model": "accounts/fireworks/models/kimi-k2-instruct-0905",
"temperature": 0.6, # Kimi recommended temperature
"max_tokens": 131000,
"api_key": os.getenv("FIREWORKS_API_KEY"),
"base_url": "https://api.fireworks.ai/inference/v1",
},
}

LABEL_TO_SCORE = {
"A>>B": 1.0,
"B<<A": 1.0,
"A>B": 6 / 7,
"B<A": 6 / 7,
"A=B": 0.5,
"B=A": 0.5,
"A<B": 1 / 7,
"B>A": 1 / 7,
"A<<B": 0.0,
"B>>A": 0.0,
}


def get_score(judgment, patterns):
"""Extract judgment score from text. From arena-hard-auto/gen_judgment.py"""
for pattern in patterns:
pattern = re.compile(pattern)

matches = pattern.findall(judgment.upper())
matches = [m for m in matches if m != ""]

if len(set(matches)) > 0:
return matches[-1].strip("\n")
return None


async def run_single_judgment(
question_text: str, answer_a: str, answer_b: str, tools, judge_config, client
) -> Optional[Dict[str, Any]]:
"""Run a single pairwise judgment between two answers."""
user_prompt = f"""<|User Prompt|>
{question_text}

<|The Start of Assistant A's Answer|>
{answer_a}
<|The End of Assistant A's Answer|>

<|The Start of Assistant B's Answer|>
{answer_b}
<|The End of Assistant B's Answer|>

<|Available Tools|>
{tools}
<|End of Available Tools|>

{OG_ARENA_HARD_PROMPT}"""

messages = [{"role": "user", "content": user_prompt}]

try:
api_params = {
"model": judge_config["model"],
"messages": messages,
"temperature": judge_config["temperature"],
"max_tokens": judge_config["max_tokens"],
}

if tools:
api_params["tools"] = tools
api_params["tool_choice"] = "none"

response = await client.chat.completions.create(**api_params)
judgment_text = response.choices[0].message.content
if not judgment_text:
return None

except Exception as e:
print(f"Error getting judgment from OpenAI: {e}")
return None

score = get_score(judgment_text, [r"\[\[([AB<>=]+)\]\]", r"\[([AB<>=]+)\]"])
return {"score": score, "judgment": judgment_text, "prompt": messages}
Loading
Loading