evalring 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- EvalRing/__init__.py +106 -0
- EvalRing/agent/__init__.py +33 -0
- EvalRing/agent/base.py +122 -0
- EvalRing/agent/classification.py +191 -0
- EvalRing/agent/errors.py +109 -0
- EvalRing/agent/implementations.py +575 -0
- EvalRing/agent/multi_role.py +305 -0
- EvalRing/cli.py +284 -0
- EvalRing/config.py +216 -0
- EvalRing/dataset/__init__.py +8 -0
- EvalRing/dataset/ai_data_generator.py +217 -0
- EvalRing/dataset/base.py +238 -0
- EvalRing/dataset/implementations.py +179 -0
- EvalRing/evaluator/__init__.py +36 -0
- EvalRing/evaluator/base.py +165 -0
- EvalRing/evaluator/implementations.py +404 -0
- EvalRing/evaluator/llm_judge/__init__.py +32 -0
- EvalRing/evaluator/llm_judge/evaluator.py +588 -0
- EvalRing/evaluator/llm_judge/judge.py +138 -0
- EvalRing/evaluator/llm_judge/metric.py +194 -0
- EvalRing/evaluator/llm_judge/rubric.py +160 -0
- EvalRing/evaluator/llm_judge/schema.py +147 -0
- EvalRing/evaluator/llm_judge/template.py +144 -0
- EvalRing/logging_utils.py +66 -0
- EvalRing/py.typed +0 -0
- EvalRing/utils/__init__.py +16 -0
- EvalRing/utils/generate_model_list.py +155 -0
- EvalRing/utils/global_cache.py +531 -0
- EvalRing/utils/suite_runner.py +836 -0
- EvalRing/utils/timeout.py +48 -0
- EvalRing/utils/visualizations.py +560 -0
- evalring-0.2.0.dist-info/METADATA +454 -0
- evalring-0.2.0.dist-info/RECORD +37 -0
- evalring-0.2.0.dist-info/WHEEL +5 -0
- evalring-0.2.0.dist-info/entry_points.txt +2 -0
- evalring-0.2.0.dist-info/licenses/LICENSE +21 -0
- evalring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Judge backends.
|
|
3
|
+
|
|
4
|
+
An LLMJudge wraps an LLM client and exposes a simple `generate(prompt) -> str`
|
|
5
|
+
interface. The evaluator/metric layer calls the judge to get raw outputs,
|
|
6
|
+
which are then parsed via the schema module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ...config import resolve_credentials, resolve_model_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMJudge(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Abstract base class for an LLM judge backend.
|
|
18
|
+
|
|
19
|
+
Subclass this to integrate any LLM provider (OpenAI, Anthropic,
|
|
20
|
+
local models, etc.) as a judge.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, model_name: str, **kwargs):
|
|
24
|
+
self.model_name = model_name
|
|
25
|
+
self._config = kwargs
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def generate(self, prompt: str, **kwargs) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Generate a text response from the judge LLM.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
prompt: The fully-rendered judge prompt.
|
|
34
|
+
**kwargs: Provider-specific overrides.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Raw string output from the LLM.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_model_name(self) -> str:
|
|
43
|
+
"""Return the model identifier string."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def get_info(self) -> dict[str, Any]:
|
|
47
|
+
"""Return metadata about this judge for logging."""
|
|
48
|
+
return {
|
|
49
|
+
"model_name": self.model_name,
|
|
50
|
+
"type": self.__class__.__name__,
|
|
51
|
+
"config": {k: v for k, v in self._config.items() if k != "api_key"},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OpenAIJudge(LLMJudge):
|
|
56
|
+
"""
|
|
57
|
+
LLM judge backed by any OpenAI-compatible Chat Completions endpoint.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
model_name: Judge model identifier. Defaults to ``$EVALRING_MODEL``,
|
|
61
|
+
then ``"gpt-4o"``.
|
|
62
|
+
api_key: API key. Resolved from the environment when omitted; see
|
|
63
|
+
:func:`EvalRing.config.resolve_credentials`.
|
|
64
|
+
base_url: OpenAI-compatible endpoint. Resolved alongside the API key
|
|
65
|
+
when omitted; ``None`` means the OpenAI default.
|
|
66
|
+
temperature: Sampling temperature. Default 0.0 for deterministic judging.
|
|
67
|
+
max_completion_tokens: Max tokens in judge response. Default 512.
|
|
68
|
+
**kwargs: Additional parameters forwarded to the API call.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
model_name: str | None = None,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
base_url: str | None = None,
|
|
76
|
+
temperature: float = 0.0,
|
|
77
|
+
max_completion_tokens: int = 512,
|
|
78
|
+
**kwargs,
|
|
79
|
+
):
|
|
80
|
+
resolved_model = resolve_model_name(model_name, default="gpt-4o") or "gpt-4o"
|
|
81
|
+
super().__init__(resolved_model, **kwargs)
|
|
82
|
+
credentials = resolve_credentials(api_key, base_url)
|
|
83
|
+
self.credentials = credentials
|
|
84
|
+
self.api_key = credentials.api_key
|
|
85
|
+
self.base_url = credentials.base_url
|
|
86
|
+
self.temperature = temperature
|
|
87
|
+
self.max_completion_tokens = max_completion_tokens
|
|
88
|
+
self._client: Any = None
|
|
89
|
+
|
|
90
|
+
def _ensure_client(self):
|
|
91
|
+
"""Lazily initialize the OpenAI-compatible client."""
|
|
92
|
+
if self._client is None:
|
|
93
|
+
try:
|
|
94
|
+
from openai import OpenAI
|
|
95
|
+
except ImportError as exc:
|
|
96
|
+
raise ImportError(
|
|
97
|
+
"OpenAIJudge requires the 'openai' package. "
|
|
98
|
+
"Install it with: pip install evalring[llm]"
|
|
99
|
+
) from exc
|
|
100
|
+
client_kwargs: dict[str, Any] = {"api_key": self.credentials.require_key()}
|
|
101
|
+
if self.base_url:
|
|
102
|
+
client_kwargs["base_url"] = self.base_url
|
|
103
|
+
self._client = OpenAI(**client_kwargs)
|
|
104
|
+
|
|
105
|
+
def generate(self, prompt: str, **kwargs) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Call the OpenAI Chat Completions API.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
prompt: The judge prompt (sent as a user message).
|
|
111
|
+
**kwargs: Overrides for temperature, max_completion_tokens, etc.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The model's response text.
|
|
115
|
+
"""
|
|
116
|
+
self._ensure_client()
|
|
117
|
+
|
|
118
|
+
temperature = kwargs.pop("temperature", self.temperature)
|
|
119
|
+
max_tokens = kwargs.pop("max_completion_tokens", self.max_completion_tokens)
|
|
120
|
+
|
|
121
|
+
response = self._client.chat.completions.create(
|
|
122
|
+
model=self.model_name,
|
|
123
|
+
messages=[
|
|
124
|
+
{
|
|
125
|
+
"role": "system",
|
|
126
|
+
"content": "You are an expert evaluation judge. Always respond with valid JSON only.",
|
|
127
|
+
},
|
|
128
|
+
{"role": "user", "content": prompt},
|
|
129
|
+
],
|
|
130
|
+
temperature=temperature,
|
|
131
|
+
max_completion_tokens=max_tokens,
|
|
132
|
+
**kwargs,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return response.choices[0].message.content.strip()
|
|
136
|
+
|
|
137
|
+
def get_model_name(self) -> str:
|
|
138
|
+
return self.model_name
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JudgeMetric: The core evaluation unit in the LLM-as-a-Judge pipeline.
|
|
3
|
+
|
|
4
|
+
A JudgeMetric defines WHAT to measure. It follows the GEval two-phase pattern:
|
|
5
|
+
1. (Optional) Auto-generate evaluation steps from a criteria string
|
|
6
|
+
2. Score a test case using those steps + an optional rubric
|
|
7
|
+
|
|
8
|
+
Multiple JudgeMetrics can be composed to produce a multi-dimensional
|
|
9
|
+
evaluation (e.g., correctness + reasoning quality + safety).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .judge import LLMJudge
|
|
16
|
+
from .rubric import Rubric, ScoringCriteria
|
|
17
|
+
from .schema import JudgeVerdict, parse_eval_steps, parse_verdict
|
|
18
|
+
from .template import JudgeTemplate
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JudgeMetric:
|
|
24
|
+
"""
|
|
25
|
+
A single LLM-judged evaluation metric.
|
|
26
|
+
|
|
27
|
+
Follows the criteria → steps → score pipeline:
|
|
28
|
+
- User provides a ScoringCriteria (name, description, optional steps/rubric)
|
|
29
|
+
- If no steps provided, the judge LLM generates them (cached)
|
|
30
|
+
- For each test case, the steps + rubric are sent to the judge for scoring
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
criteria: A ScoringCriteria instance defining what to measure.
|
|
34
|
+
judge: The LLMJudge backend to use for evaluation.
|
|
35
|
+
parameters: Which test case fields the judge sees
|
|
36
|
+
(e.g., ["input", "output", "ground_truth"]).
|
|
37
|
+
template: JudgeTemplate class (or subclass) for prompt rendering.
|
|
38
|
+
strict_mode: If True, scoring is binary (0 or 1).
|
|
39
|
+
threshold: Score >= threshold is considered a "pass". Default 0.5 (on 0-1 scale).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
criteria: ScoringCriteria,
|
|
45
|
+
judge: LLMJudge,
|
|
46
|
+
parameters: list[str] | None = None,
|
|
47
|
+
template: type[JudgeTemplate] = JudgeTemplate,
|
|
48
|
+
strict_mode: bool = False,
|
|
49
|
+
threshold: float = 0.5,
|
|
50
|
+
):
|
|
51
|
+
self.criteria = criteria
|
|
52
|
+
self.judge = judge
|
|
53
|
+
self.parameters = parameters or ["input", "output", "ground_truth"]
|
|
54
|
+
self.template = template
|
|
55
|
+
self.strict_mode = strict_mode
|
|
56
|
+
self.threshold = threshold
|
|
57
|
+
|
|
58
|
+
# Cached evaluation steps (generated once, reused for all samples)
|
|
59
|
+
self._evaluation_steps: list[str] | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
return self.criteria.name
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def weight(self) -> float:
|
|
67
|
+
return self.criteria.weight
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def evaluation_steps(self) -> list[str]:
|
|
71
|
+
"""Get evaluation steps, generating them if needed."""
|
|
72
|
+
if self._evaluation_steps is None:
|
|
73
|
+
if self.criteria.evaluation_steps:
|
|
74
|
+
self._evaluation_steps = self.criteria.evaluation_steps
|
|
75
|
+
else:
|
|
76
|
+
self._generate_steps()
|
|
77
|
+
if self._evaluation_steps is None:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"[{self.name}] Judge returned no evaluation steps for the criteria."
|
|
80
|
+
)
|
|
81
|
+
return self._evaluation_steps
|
|
82
|
+
|
|
83
|
+
def _generate_steps(self) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Phase 1: Ask the judge LLM to generate evaluation steps from the criteria.
|
|
86
|
+
These steps are cached and reused for all subsequent scorings.
|
|
87
|
+
"""
|
|
88
|
+
logger.info(f"[{self.name}] Generating evaluation steps from criteria...")
|
|
89
|
+
|
|
90
|
+
prompt = self.template.generate_evaluation_steps(
|
|
91
|
+
criteria=self.criteria.criteria, parameters=self.parameters
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
raw_output = self.judge.generate(prompt)
|
|
95
|
+
eval_steps = parse_eval_steps(raw_output)
|
|
96
|
+
self._evaluation_steps = eval_steps.steps
|
|
97
|
+
|
|
98
|
+
logger.info(f"[{self.name}] Generated {len(self._evaluation_steps)} evaluation steps")
|
|
99
|
+
|
|
100
|
+
def score(self, test_case: dict[str, str]) -> JudgeVerdict:
|
|
101
|
+
"""
|
|
102
|
+
Phase 2: Score a single test case using the evaluation steps.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
test_case: Dict mapping parameter names to values.
|
|
106
|
+
e.g., {"input": "...", "output": "...", "ground_truth": "..."}
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
JudgeVerdict with score, reason, and metadata.
|
|
110
|
+
"""
|
|
111
|
+
steps = self.evaluation_steps # triggers generation if needed
|
|
112
|
+
|
|
113
|
+
# Build the scoring prompt
|
|
114
|
+
if self.strict_mode:
|
|
115
|
+
prompt = self.template.generate_strict_score(
|
|
116
|
+
evaluation_steps=steps,
|
|
117
|
+
test_case=test_case,
|
|
118
|
+
parameters=self.parameters,
|
|
119
|
+
)
|
|
120
|
+
max_score = 1
|
|
121
|
+
else:
|
|
122
|
+
score_range = self.criteria.rubric_score_range
|
|
123
|
+
rubric_text = self.criteria.rubric_text
|
|
124
|
+
|
|
125
|
+
prompt = self.template.generate_score(
|
|
126
|
+
evaluation_steps=steps,
|
|
127
|
+
test_case=test_case,
|
|
128
|
+
parameters=self.parameters,
|
|
129
|
+
rubric_text=rubric_text,
|
|
130
|
+
score_range=score_range,
|
|
131
|
+
)
|
|
132
|
+
max_score = score_range[1]
|
|
133
|
+
|
|
134
|
+
# Call the judge
|
|
135
|
+
raw_output = self.judge.generate(prompt)
|
|
136
|
+
verdict = parse_verdict(raw_output, max_score=max_score)
|
|
137
|
+
|
|
138
|
+
# Add metric metadata
|
|
139
|
+
verdict.metadata["metric_name"] = self.name
|
|
140
|
+
verdict.metadata["strict_mode"] = self.strict_mode
|
|
141
|
+
verdict.metadata["max_score"] = max_score
|
|
142
|
+
|
|
143
|
+
return verdict
|
|
144
|
+
|
|
145
|
+
def score_simple(
|
|
146
|
+
self,
|
|
147
|
+
input_text: str,
|
|
148
|
+
output: str,
|
|
149
|
+
ground_truth: str = "",
|
|
150
|
+
) -> JudgeVerdict:
|
|
151
|
+
"""
|
|
152
|
+
Score a single case without building a dict manually.
|
|
153
|
+
|
|
154
|
+
Convenience wrapper around :meth:`score` that constructs the
|
|
155
|
+
test-case dict for you.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
input_text: The original input.
|
|
159
|
+
output: The agent's output / prediction.
|
|
160
|
+
ground_truth: The expected correct answer (optional).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
JudgeVerdict with score, reason, and metadata.
|
|
164
|
+
"""
|
|
165
|
+
test_case = {
|
|
166
|
+
"input": input_text,
|
|
167
|
+
"output": output,
|
|
168
|
+
"ground_truth": ground_truth,
|
|
169
|
+
}
|
|
170
|
+
return self.score(test_case)
|
|
171
|
+
|
|
172
|
+
def is_successful(self, verdict: JudgeVerdict) -> bool:
|
|
173
|
+
"""Check if a verdict passes the threshold."""
|
|
174
|
+
max_score = verdict.metadata.get("max_score", 10)
|
|
175
|
+
normalized = verdict.score / max_score if max_score > 0 else 0
|
|
176
|
+
return normalized >= self.threshold
|
|
177
|
+
|
|
178
|
+
def get_info(self) -> dict[str, Any]:
|
|
179
|
+
"""Return metric configuration for logging/reproducibility."""
|
|
180
|
+
return {
|
|
181
|
+
"name": self.name,
|
|
182
|
+
"criteria": self.criteria.criteria,
|
|
183
|
+
"evaluation_steps": self._evaluation_steps,
|
|
184
|
+
"rubric": (
|
|
185
|
+
self.criteria.rubric.to_dict()
|
|
186
|
+
if isinstance(self.criteria.rubric, Rubric)
|
|
187
|
+
else self.criteria.rubric
|
|
188
|
+
),
|
|
189
|
+
"parameters": self.parameters,
|
|
190
|
+
"strict_mode": self.strict_mode,
|
|
191
|
+
"threshold": self.threshold,
|
|
192
|
+
"weight": self.weight,
|
|
193
|
+
"judge_model": self.judge.get_model_name(),
|
|
194
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rubric definitions for LLM-as-a-Judge scoring.
|
|
3
|
+
|
|
4
|
+
A Rubric anchors the judge's scoring by defining discrete grade bands,
|
|
5
|
+
each with a score range and a description of what that score means.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RubricLevel:
|
|
14
|
+
"""
|
|
15
|
+
A single level/band within a rubric.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
RubricLevel(score=5, label="Excellent", description="Perfectly matches ground truth...")
|
|
19
|
+
RubricLevel(score=1, label="Poor", description="Completely incorrect or irrelevant...")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
score: int
|
|
23
|
+
label: str
|
|
24
|
+
description: str
|
|
25
|
+
|
|
26
|
+
def to_prompt_string(self) -> str:
|
|
27
|
+
return f"Score {self.score} ({self.label}): {self.description}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Rubric:
|
|
32
|
+
"""
|
|
33
|
+
A complete scoring rubric composed of ordered levels.
|
|
34
|
+
|
|
35
|
+
The rubric is injected into the judge prompt to provide scoring anchors,
|
|
36
|
+
improving calibration and consistency across evaluations.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
name: Descriptive name for this rubric (e.g., "classification_accuracy")
|
|
40
|
+
levels: Ordered list of RubricLevel defining the scoring scale
|
|
41
|
+
score_range: (min, max) score bounds. Auto-derived from levels if not set.
|
|
42
|
+
description: Optional high-level description of what this rubric measures.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
levels: list[RubricLevel]
|
|
47
|
+
score_range: tuple[int, int] | None = None
|
|
48
|
+
description: str = ""
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if not self.levels:
|
|
52
|
+
raise ValueError("Rubric must have at least one level")
|
|
53
|
+
# Sort levels by score
|
|
54
|
+
self.levels = sorted(self.levels, key=lambda level: level.score)
|
|
55
|
+
# Auto-derive score range
|
|
56
|
+
if self.score_range is None:
|
|
57
|
+
self.score_range = (self.levels[0].score, self.levels[-1].score)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _range(self) -> tuple[int, int]:
|
|
61
|
+
"""Score range, always populated once ``__post_init__`` has run."""
|
|
62
|
+
assert self.score_range is not None
|
|
63
|
+
return self.score_range
|
|
64
|
+
|
|
65
|
+
def to_prompt_string(self) -> str:
|
|
66
|
+
"""Render the rubric as a string suitable for injection into a judge prompt."""
|
|
67
|
+
lines = []
|
|
68
|
+
if self.description:
|
|
69
|
+
lines.append(f"Rubric: {self.description}")
|
|
70
|
+
lines.append(f"Score range: {self._range[0]} to {self._range[1]}")
|
|
71
|
+
lines.append("")
|
|
72
|
+
for level in self.levels:
|
|
73
|
+
lines.append(level.to_prompt_string())
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def max_score(self) -> int:
|
|
78
|
+
return self._range[1]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def min_score(self) -> int:
|
|
82
|
+
return self._range[0]
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"name": self.name,
|
|
87
|
+
"description": self.description,
|
|
88
|
+
"score_range": list(self._range),
|
|
89
|
+
"levels": [
|
|
90
|
+
{"score": level.score, "label": level.label, "description": level.description}
|
|
91
|
+
for level in self.levels
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, data: dict[str, Any]) -> "Rubric":
|
|
97
|
+
levels = [RubricLevel(**level) for level in data["levels"]]
|
|
98
|
+
return cls(
|
|
99
|
+
name=data["name"],
|
|
100
|
+
levels=levels,
|
|
101
|
+
score_range=tuple(data["score_range"]) if "score_range" in data else None,
|
|
102
|
+
description=data.get("description", ""),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ScoringCriteria:
|
|
108
|
+
"""
|
|
109
|
+
A named evaluation criterion with an optional rubric.
|
|
110
|
+
|
|
111
|
+
This is the user-facing config object: you provide a criteria description
|
|
112
|
+
(and optionally explicit evaluation_steps), and the framework either uses
|
|
113
|
+
your steps or asks the judge LLM to generate them.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
name: Short name for the criterion (e.g., "correctness", "relevance")
|
|
117
|
+
criteria: Natural language description of what this criterion measures.
|
|
118
|
+
evaluation_steps: Optional pre-defined steps. If None, the judge LLM
|
|
119
|
+
will auto-generate them from the criteria string.
|
|
120
|
+
rubric: Optional Rubric to anchor scoring. If None, a default 0-10
|
|
121
|
+
scale is used.
|
|
122
|
+
weight: Weight for this criterion when combining multiple metrics.
|
|
123
|
+
Defaults to 1.0.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
criteria: str
|
|
128
|
+
evaluation_steps: list[str] | None = None
|
|
129
|
+
rubric: Rubric | str | None = None
|
|
130
|
+
weight: float = 1.0
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def rubric_text(self) -> str | None:
|
|
134
|
+
"""Get the rubric as a prompt-ready string, regardless of input type."""
|
|
135
|
+
if self.rubric is None:
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(self.rubric, str):
|
|
138
|
+
return self.rubric
|
|
139
|
+
return self.rubric.to_prompt_string()
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def rubric_score_range(self) -> tuple[int, int]:
|
|
143
|
+
"""Get the score range from the rubric, defaulting to (0, 10)."""
|
|
144
|
+
if isinstance(self.rubric, Rubric):
|
|
145
|
+
return (self.rubric.min_score, self.rubric.max_score)
|
|
146
|
+
return (0, 10)
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> dict[str, Any]:
|
|
149
|
+
rubric_val: dict[str, Any] | str | None = None
|
|
150
|
+
if isinstance(self.rubric, Rubric):
|
|
151
|
+
rubric_val = self.rubric.to_dict()
|
|
152
|
+
elif isinstance(self.rubric, str):
|
|
153
|
+
rubric_val = self.rubric
|
|
154
|
+
return {
|
|
155
|
+
"name": self.name,
|
|
156
|
+
"criteria": self.criteria,
|
|
157
|
+
"evaluation_steps": self.evaluation_steps,
|
|
158
|
+
"rubric": rubric_val,
|
|
159
|
+
"weight": self.weight,
|
|
160
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response schemas for structured LLM judge output.
|
|
3
|
+
|
|
4
|
+
These define the expected JSON shapes returned by the judge LLM.
|
|
5
|
+
We use simple dataclass parsing (no pydantic dependency) with
|
|
6
|
+
a robust JSON extraction fallback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class JudgeVerdict:
|
|
17
|
+
"""
|
|
18
|
+
The judge's verdict for a single evaluation.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
score: Numeric score (within the rubric's range, e.g., 0-10)
|
|
22
|
+
reason: The judge's chain-of-thought reasoning for the score.
|
|
23
|
+
metadata: Any additional data returned by the judge.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
score: float
|
|
27
|
+
reason: str
|
|
28
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def normalized_score(self) -> float:
|
|
32
|
+
"""Score normalized to 0-1 range (assuming 0-10 raw scale)."""
|
|
33
|
+
return self.score / 10.0
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {"score": self.score, "reason": self.reason, "metadata": self.metadata}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EvalSteps:
|
|
41
|
+
"""
|
|
42
|
+
Auto-generated evaluation steps from a criteria string.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
steps: Ordered list of evaluation step descriptions.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
steps: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {"steps": self.steps}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_json(text: str) -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Robustly extract the first JSON object from LLM output.
|
|
57
|
+
|
|
58
|
+
Handles:
|
|
59
|
+
- Clean JSON responses
|
|
60
|
+
- JSON wrapped in markdown code blocks (```json ... ```)
|
|
61
|
+
- JSON mixed with other text
|
|
62
|
+
- Trailing commas (common LLM error)
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
text: Raw LLM output string.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Parsed dictionary.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If no valid JSON object can be extracted.
|
|
72
|
+
"""
|
|
73
|
+
# Strip markdown code fences
|
|
74
|
+
text = re.sub(r"```(?:json)?\s*", "", text)
|
|
75
|
+
text = re.sub(r"```\s*$", "", text)
|
|
76
|
+
text = text.strip()
|
|
77
|
+
|
|
78
|
+
# Try direct parse first
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(text)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Find the first { ... } block
|
|
85
|
+
brace_depth = 0
|
|
86
|
+
start = None
|
|
87
|
+
for i, ch in enumerate(text):
|
|
88
|
+
if ch == "{":
|
|
89
|
+
if brace_depth == 0:
|
|
90
|
+
start = i
|
|
91
|
+
brace_depth += 1
|
|
92
|
+
elif ch == "}":
|
|
93
|
+
brace_depth -= 1
|
|
94
|
+
if brace_depth == 0 and start is not None:
|
|
95
|
+
candidate = text[start : i + 1]
|
|
96
|
+
# Fix trailing commas before } or ]
|
|
97
|
+
candidate = re.sub(r",\s*([}\]])", r"\1", candidate)
|
|
98
|
+
try:
|
|
99
|
+
return json.loads(candidate)
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
start = None
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
raise ValueError(f"Could not extract valid JSON from LLM output: {text[:200]}...")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_verdict(raw_output: str, max_score: int = 10) -> JudgeVerdict:
|
|
108
|
+
"""
|
|
109
|
+
Parse a JudgeVerdict from raw LLM judge output.
|
|
110
|
+
|
|
111
|
+
Expected JSON format:
|
|
112
|
+
{"score": <int>, "reason": "<string>"}
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
raw_output: Raw string from the judge LLM.
|
|
116
|
+
max_score: Maximum valid score (for clamping).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Parsed JudgeVerdict instance.
|
|
120
|
+
"""
|
|
121
|
+
data = extract_json(raw_output)
|
|
122
|
+
|
|
123
|
+
score = float(data.get("score", 0))
|
|
124
|
+
score = max(0, min(score, max_score)) # clamp
|
|
125
|
+
|
|
126
|
+
reason = str(data.get("reason", "No reason provided."))
|
|
127
|
+
|
|
128
|
+
# Capture any extra fields as metadata
|
|
129
|
+
metadata = {k: v for k, v in data.items() if k not in ("score", "reason")}
|
|
130
|
+
|
|
131
|
+
return JudgeVerdict(score=score, reason=reason, metadata=metadata)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_eval_steps(raw_output: str) -> EvalSteps:
|
|
135
|
+
"""
|
|
136
|
+
Parse auto-generated evaluation steps from LLM output.
|
|
137
|
+
|
|
138
|
+
Expected JSON format:
|
|
139
|
+
{"steps": ["step 1", "step 2", ...]}
|
|
140
|
+
"""
|
|
141
|
+
data = extract_json(raw_output)
|
|
142
|
+
steps = data.get("steps", [])
|
|
143
|
+
if isinstance(steps, list):
|
|
144
|
+
steps = [str(s) for s in steps]
|
|
145
|
+
else:
|
|
146
|
+
steps = [str(steps)]
|
|
147
|
+
return EvalSteps(steps=steps)
|