I set this up as a module so that it's easy to import within tests and clear how to use it across both toolkit and browser code. Differential Revision: https://phabricator.services.mozilla.com/D289539
357 lines
12 KiB
Python
357 lines
12 KiB
Python
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
import json
|
|
import os
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any, Callable
|
|
|
|
|
|
class _Evaluation(ABC):
|
|
"""
|
|
The abstract base class for an evaluation that is run by mozperftest.
|
|
See python/mozperftest/mozperftest/metrics/eval.py
|
|
"""
|
|
|
|
# Include a list of requirements that will be pip installed via the test harness.
|
|
# Requirements should not require a build, and should have the appropriate .whl
|
|
# files for reproducibility.
|
|
requirements: list[str] = []
|
|
|
|
def __init__(self, log: Callable[[str], None], config: dict[str, Any]) -> None:
|
|
self.log = log
|
|
self.config = config
|
|
|
|
@abstractmethod
|
|
def run(self, payloads: list[dict[str, Any]]) -> dict:
|
|
"""Run the evaluation and return a perftest metric result."""
|
|
...
|
|
|
|
|
|
class _LlmJudge(_Evaluation):
|
|
"""
|
|
Use the Mozilla LLM Proxy Auth (MLPA) endpoint to run an LLM as a judge.
|
|
"""
|
|
|
|
requirements = [
|
|
"openai>=2.7.1",
|
|
]
|
|
|
|
def __init__(self, log: Callable[[str], None], config: dict[str, Any]) -> None:
|
|
super().__init__(log, config)
|
|
self.mlpa_url = "https://mlpa-nonprod-stage-mozilla.global.ssl.fastly.net"
|
|
self.model = config.get("model", "vertex_ai/mistral-small-2503")
|
|
self.fxa_token = os.environ.get("MOZ_FXA_BEARER_TOKEN")
|
|
self.mlpa_token = os.environ.get("MOZ_MLPA_AUTHORIZATION_TOKEN")
|
|
|
|
def query_llm(
|
|
self,
|
|
model: str,
|
|
messages: list[dict[str, str]],
|
|
response_format: dict[str, Any],
|
|
):
|
|
from openai import OpenAI
|
|
|
|
# check for tokens
|
|
if not self.fxa_token:
|
|
raise RuntimeError("Missing MOZ_FXA_BEARER_TOKEN for LLM evaluation.")
|
|
if not self.mlpa_token:
|
|
raise RuntimeError(
|
|
"Missing MOZ_MLPA_AUTHORIZATION_TOKEN for LLM evaluation."
|
|
)
|
|
|
|
# generate client
|
|
client = OpenAI(
|
|
api_key="unused",
|
|
base_url=f"{self.mlpa_url}/v1",
|
|
default_headers={
|
|
"Authorization": f"Bearer {self.fxa_token}",
|
|
"X-Dev-Authorization": self.mlpa_token,
|
|
"Service-Type": "mochi-dev",
|
|
},
|
|
)
|
|
|
|
# construct appropriate payload
|
|
payload = {
|
|
"model": model.lower(),
|
|
"messages": messages,
|
|
"stream": False,
|
|
"response_format": response_format,
|
|
}
|
|
|
|
# make the request
|
|
try:
|
|
result = client.chat.completions.create(**payload)
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"LLM query failed: {e}")
|
|
finally:
|
|
# close
|
|
client.close()
|
|
|
|
# parse the response
|
|
try:
|
|
content = result.choices[0].message.content
|
|
return json.loads(content)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
f"Failed to parse LLM response as JSON: {content}"
|
|
) from exc
|
|
|
|
|
|
class _TranslationsSacreBleu(_Evaluation):
|
|
"""
|
|
Compute the bleu or chrF (character level f-score) for a translation.
|
|
https://en.wikipedia.org/wiki/BLEU
|
|
https://en.wikipedia.org/wiki/F-score
|
|
|
|
Use TranslationsBleu and TranslationsChrf for the respective scores.
|
|
"""
|
|
|
|
requirements = [
|
|
"sacrebleu==2.4.2",
|
|
]
|
|
|
|
name = ""
|
|
|
|
def compute_score(self, trg: str, ref: str) -> float:
|
|
raise NotImplementedError()
|
|
|
|
def run(self, payloads: list[dict[str, Any]]) -> dict:
|
|
results: list[float] = []
|
|
for payload in payloads:
|
|
if "trg" not in payload or "ref" not in payload:
|
|
raise ValueError(f"Missing required translation fields in {payload}")
|
|
trg = payload["trg"]
|
|
ref = payload["ref"]
|
|
|
|
results.append(self.compute_score(trg, ref))
|
|
|
|
if not results:
|
|
raise ValueError(
|
|
"No evaluation results were produced for translation data."
|
|
)
|
|
|
|
return {
|
|
"name": self.name,
|
|
"values": results,
|
|
"lowerIsBetter": True,
|
|
}
|
|
|
|
|
|
class TranslationsBleu(_TranslationsSacreBleu):
|
|
"""See _TranslationsSacreBleu for documentation."""
|
|
|
|
name = "bleu"
|
|
|
|
def compute_score(self, trg: str, ref: str) -> float:
|
|
import sacrebleu
|
|
|
|
self.log("Computing the bleu score")
|
|
return sacrebleu.corpus_bleu([trg], [[ref]]).score
|
|
|
|
|
|
class TranslationsChrf(_TranslationsSacreBleu):
|
|
"""See _TranslationsSacreBleu for documentation."""
|
|
|
|
name = "chrF"
|
|
|
|
def compute_score(self, trg: str, ref: str) -> float:
|
|
import sacrebleu
|
|
|
|
self.log("Computing the chrF score")
|
|
return sacrebleu.corpus_chrf([trg], [[ref]]).score
|
|
|
|
|
|
class LlmJudge(_LlmJudge):
|
|
"""
|
|
A generic LLM judge that can be used for various evaluation tasks.
|
|
See TranslationsLlmJudge for an example of a specific implementation.
|
|
"""
|
|
|
|
def run(self, payloads: list[dict[str, Any]]) -> dict:
|
|
# This base implementation just returns the raw LLM response content for each payload.
|
|
# Subclasses can implement specific prompting and parsing logic as needed.
|
|
results = {}
|
|
eval_config = {}
|
|
for payload in payloads:
|
|
# Per-payload evalConfig takes precedence over the top-level eval_config.
|
|
eval_config = payload.get("eval_config", {})
|
|
messages = payload.get("messages")
|
|
if not messages:
|
|
raise ValueError(f"Missing 'messages' field in payload: {payload}")
|
|
response_format = payload.get("response_format")
|
|
if not response_format:
|
|
raise ValueError(
|
|
f"Missing 'response_format' field in payload: {payload}"
|
|
)
|
|
model = payload.get("model", self.model)
|
|
response = self.query_llm(
|
|
model=model,
|
|
messages=messages,
|
|
response_format=response_format,
|
|
)
|
|
errors = []
|
|
for key, value in response.items():
|
|
key_config = eval_config.get(key, {})
|
|
threshold_min = key_config.get("thresholdMin", None)
|
|
if (
|
|
threshold_min is not None
|
|
and isinstance(value, (int, float))
|
|
and value < threshold_min
|
|
):
|
|
errors.append(
|
|
f"Value for '{key}' is below the minimum threshold of {threshold_min}: {value}"
|
|
)
|
|
results.setdefault(key, []).append(value)
|
|
if errors:
|
|
raise AssertionError(
|
|
"LLM judge evaluation failed:\n"
|
|
+ "\n".join(errors)
|
|
+ f"\nJudge Model: {model}\nMessages: {messages}\n\nFull LLM response: {response}"
|
|
)
|
|
|
|
if not results:
|
|
raise ValueError("No evaluation results were produced for LLM judge data.")
|
|
|
|
# verify that numeric results meet any specified thresholds and determine if any alerts should be raised
|
|
for key, values in results.items():
|
|
key_config = eval_config.get(key, {})
|
|
threshold_min = key_config.get("thresholdMin", None)
|
|
if threshold_min is not None:
|
|
for value in values:
|
|
if not isinstance(value, (int, float)):
|
|
raise ValueError(
|
|
f"LLM judge result for key '{key}' is not numeric: {value}"
|
|
)
|
|
if value < threshold_min:
|
|
raise AssertionError(
|
|
f"LLM judge result for '{key}' is below the minimum threshold of {threshold_min}: {value}. "
|
|
)
|
|
|
|
# construct return values for perfherder
|
|
return_results = []
|
|
for key, values in results.items():
|
|
if isinstance(values[0], (int, float)):
|
|
return_results.append({
|
|
"name": key,
|
|
"values": values,
|
|
"lowerIsBetter": False,
|
|
"shouldAlert": eval_config.get(key, {}).get("shouldAlert", False),
|
|
"alertThreshold": eval_config.get(key, {}).get(
|
|
"alertThreshold", None
|
|
),
|
|
})
|
|
else:
|
|
self.log(
|
|
f"LLM judge result for key '{key}' is not numeric and will not be included in perfherder metrics: {values}"
|
|
)
|
|
|
|
return return_results
|
|
|
|
|
|
class TranslationsLlmJudge(_LlmJudge):
|
|
"""
|
|
Judge a translation based on an LLM's judgement.
|
|
|
|
Returns:
|
|
{
|
|
"score": int,
|
|
"verdict": str,
|
|
"explanation": str,
|
|
"model": str,
|
|
}
|
|
|
|
perfherder_metrics: [
|
|
{
|
|
name: "bleu",
|
|
unit: "bleu",
|
|
lowerIsBetter: false,
|
|
shouldAlert: false,
|
|
},
|
|
{
|
|
name: "chrF",
|
|
unit: "chrF",
|
|
lowerIsBetter: false,
|
|
shouldAlert: false,
|
|
},
|
|
]
|
|
"""
|
|
|
|
def run(self, payloads: list[dict[str, Any]]) -> dict:
|
|
results: list[dict[str, Any]] = []
|
|
for payload in payloads:
|
|
missing = [key for key in ("src", "trg", "ref") if key not in payload]
|
|
if missing:
|
|
raise ValueError(
|
|
f"Missing required translation fields {missing} in {payload}"
|
|
)
|
|
src = payload["src"]
|
|
trg = payload["trg"]
|
|
ref = payload["ref"]
|
|
|
|
user_prompt = (
|
|
f"Source: {src}Reference: {ref}\nHypothesis: {trg}\n"
|
|
'Return JSON with fields: score (0-100), verdict ("good"|"ok"|"bad"), explanation (short).'
|
|
)
|
|
|
|
response_format = {
|
|
"type": "json_schema",
|
|
"json_schema": {
|
|
"name": "basic_quality_eval",
|
|
"strict": True,
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"score": {"type": "integer"},
|
|
"verdict": {"type": "string"},
|
|
"explanation": {"type": "string"},
|
|
},
|
|
"required": ["score", "verdict", "explanation"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
model = payload.get("model", self.model)
|
|
response = self.query_llm(
|
|
model=model,
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": "You are a translation quality judge. Rate adequacy/fluency.",
|
|
},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
response_format=response_format,
|
|
)
|
|
|
|
score = response.get("score")
|
|
if score is None:
|
|
raise ValueError(f"Missing score in LLM judge response: {response}")
|
|
if isinstance(score, str):
|
|
try:
|
|
score = float(score)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"Invalid score value in LLM judge response: {response}"
|
|
) from exc
|
|
|
|
results.append({
|
|
"score": score,
|
|
"verdict": response.get("verdict"),
|
|
"explanation": response.get("explanation"),
|
|
"model": model,
|
|
})
|
|
|
|
if not results:
|
|
raise ValueError("No evaluation results were produced for LLM judge data.")
|
|
|
|
scores = [result.get("score", 0) for result in results]
|
|
return {
|
|
"name": "llm-judge",
|
|
"values": scores,
|
|
"lowerIsBetter": False,
|
|
}
|