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,588 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLMJudgeEvaluator: Orchestrates multi-metric LLM-as-a-Judge evaluation.
|
|
3
|
+
|
|
4
|
+
This evaluator takes agent responses, packages them as test cases, and runs
|
|
5
|
+
them through one or more JudgeMetrics. It supports parallel execution,
|
|
6
|
+
retry logic, and comprehensive reporting — following the same contract as
|
|
7
|
+
BaseEvaluator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import statistics
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ...agent.base import AgentResponse, BaseAgent
|
|
20
|
+
from ...dataset.base import BaseDataset, DataSample
|
|
21
|
+
from ..base import BaseEvaluator, EvaluationMetrics, EvaluationResult
|
|
22
|
+
from .judge import OpenAIJudge
|
|
23
|
+
from .metric import JudgeMetric
|
|
24
|
+
from .rubric import Rubric, ScoringCriteria
|
|
25
|
+
from .schema import JudgeVerdict
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LLMJudgeEvaluator(BaseEvaluator):
|
|
31
|
+
"""
|
|
32
|
+
Evaluator that uses LLM-as-a-Judge to score agent responses.
|
|
33
|
+
|
|
34
|
+
This is generic: it works for classification, generation, summarization,
|
|
35
|
+
or any task. The evaluation criteria are defined by JudgeMetric instances,
|
|
36
|
+
each with their own criteria/rubric/steps.
|
|
37
|
+
|
|
38
|
+
Workflow:
|
|
39
|
+
1. Run the agent on each dataset sample to get predictions
|
|
40
|
+
2. Package each (input, prediction, ground_truth) as a test case
|
|
41
|
+
3. Run each test case through all JudgeMetrics
|
|
42
|
+
4. Aggregate scores across samples and metrics
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
metrics: List of JudgeMetric instances defining what to evaluate.
|
|
46
|
+
agent_parameters: Which fields the agent provides for judging.
|
|
47
|
+
Default: ["input", "output", "ground_truth"].
|
|
48
|
+
max_workers: Parallelism for agent prediction + judging.
|
|
49
|
+
max_retries: Retry count for transient failures.
|
|
50
|
+
**kwargs: Passed to BaseEvaluator.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
metrics: list[JudgeMetric],
|
|
56
|
+
agent_parameters: list[str] | None = None,
|
|
57
|
+
max_workers: int = 5,
|
|
58
|
+
max_retries: int = 3,
|
|
59
|
+
**kwargs,
|
|
60
|
+
):
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
|
|
63
|
+
if not metrics:
|
|
64
|
+
raise ValueError("At least one JudgeMetric is required")
|
|
65
|
+
|
|
66
|
+
self.metrics = metrics
|
|
67
|
+
self.agent_parameters = agent_parameters or ["input", "output", "ground_truth"]
|
|
68
|
+
self.max_workers = max_workers
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
|
|
71
|
+
# ── factory methods ──────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_rubric(
|
|
75
|
+
cls,
|
|
76
|
+
rubric: str | Rubric | dict[str, str | Rubric],
|
|
77
|
+
criteria: str | dict[str, str] | None = None,
|
|
78
|
+
judge_model: str | None = None,
|
|
79
|
+
api_key: str | None = None,
|
|
80
|
+
base_url: str | None = None,
|
|
81
|
+
judge_temperature: float = 0.0,
|
|
82
|
+
judge_max_tokens: int = 512,
|
|
83
|
+
weights: dict[str, float] | None = None,
|
|
84
|
+
threshold: float = 0.5,
|
|
85
|
+
strict_mode: bool = False,
|
|
86
|
+
max_workers: int = 5,
|
|
87
|
+
max_retries: int = 3,
|
|
88
|
+
**kwargs,
|
|
89
|
+
) -> "LLMJudgeEvaluator":
|
|
90
|
+
"""
|
|
91
|
+
Create an LLMJudgeEvaluator in one call — just pass a rubric.
|
|
92
|
+
|
|
93
|
+
This factory handles creating the judge, criteria, and metrics
|
|
94
|
+
internally so you don't need to assemble them yourself.
|
|
95
|
+
|
|
96
|
+
Examples::
|
|
97
|
+
|
|
98
|
+
# Single metric with a string rubric
|
|
99
|
+
evaluator = LLMJudgeEvaluator.from_rubric(
|
|
100
|
+
rubric="Score 0 = wrong, 10 = perfect match.",
|
|
101
|
+
criteria="Is the classification correct?",
|
|
102
|
+
judge_model="gpt-5.2",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Single metric with a Rubric object
|
|
106
|
+
evaluator = LLMJudgeEvaluator.from_rubric(
|
|
107
|
+
rubric=my_rubric,
|
|
108
|
+
criteria="Is the classification correct?",
|
|
109
|
+
judge_model="gpt-5.2",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Multiple metrics — pass dicts keyed by metric name
|
|
113
|
+
evaluator = LLMJudgeEvaluator.from_rubric(
|
|
114
|
+
rubric={
|
|
115
|
+
"correctness": my_correctness_rubric,
|
|
116
|
+
"reasoning": "Score 0-10 for reasoning quality.",
|
|
117
|
+
},
|
|
118
|
+
criteria={
|
|
119
|
+
"correctness": "Is the answer correct?",
|
|
120
|
+
"reasoning": "Does the answer show good reasoning?",
|
|
121
|
+
},
|
|
122
|
+
weights={"correctness": 2.0, "reasoning": 1.0},
|
|
123
|
+
judge_model="gpt-5.2",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
rubric: A string, Rubric, or dict mapping metric names to rubrics.
|
|
128
|
+
criteria: Criteria description(s) matching rubric keys.
|
|
129
|
+
If None, defaults to rubric text or "Evaluate quality".
|
|
130
|
+
judge_model: Judge model identifier. Defaults to
|
|
131
|
+
``$EVALRING_MODEL``, then ``"gpt-4o"``.
|
|
132
|
+
api_key: API key. Resolved from the environment when omitted; see
|
|
133
|
+
:func:`EvalRing.config.resolve_credentials`.
|
|
134
|
+
base_url: OpenAI-compatible endpoint for the judge. Resolved
|
|
135
|
+
alongside the API key when omitted.
|
|
136
|
+
judge_temperature: Temperature for the judge model.
|
|
137
|
+
judge_max_tokens: Max completion tokens for the judge.
|
|
138
|
+
weights: Dict of metric-name → weight. Default 1.0 each.
|
|
139
|
+
threshold: Score threshold for pass/fail. Default 0.5.
|
|
140
|
+
strict_mode: If True, scoring is binary (0 or 1).
|
|
141
|
+
max_workers: Parallelism for evaluation.
|
|
142
|
+
max_retries: Retry count for transient failures.
|
|
143
|
+
**kwargs: Forwarded to the LLMJudgeEvaluator constructor.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A fully configured LLMJudgeEvaluator ready to call ``evaluate()``.
|
|
147
|
+
"""
|
|
148
|
+
# Normalize rubric(s) into a dict: {name: rubric_value}
|
|
149
|
+
if isinstance(rubric, dict):
|
|
150
|
+
rubric_map = rubric
|
|
151
|
+
else:
|
|
152
|
+
# Single rubric → single metric named "quality"
|
|
153
|
+
rubric_map = {"quality": rubric}
|
|
154
|
+
|
|
155
|
+
# Normalize criteria into a dict
|
|
156
|
+
if criteria is None:
|
|
157
|
+
criteria_map = {}
|
|
158
|
+
elif isinstance(criteria, str):
|
|
159
|
+
# Single criteria string → match the single rubric key
|
|
160
|
+
first_key = next(iter(rubric_map))
|
|
161
|
+
criteria_map = {first_key: criteria}
|
|
162
|
+
else:
|
|
163
|
+
criteria_map = criteria
|
|
164
|
+
|
|
165
|
+
weights = weights or {}
|
|
166
|
+
|
|
167
|
+
# Create the shared judge backend
|
|
168
|
+
judge = OpenAIJudge(
|
|
169
|
+
model_name=judge_model,
|
|
170
|
+
api_key=api_key,
|
|
171
|
+
base_url=base_url,
|
|
172
|
+
temperature=judge_temperature,
|
|
173
|
+
max_completion_tokens=judge_max_tokens,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Build one JudgeMetric per rubric entry
|
|
177
|
+
metrics = []
|
|
178
|
+
for name, rubric_val in rubric_map.items():
|
|
179
|
+
sc = ScoringCriteria(
|
|
180
|
+
name=name,
|
|
181
|
+
criteria=criteria_map.get(name, "Evaluate the quality of the output."),
|
|
182
|
+
rubric=rubric_val,
|
|
183
|
+
weight=weights.get(name, 1.0),
|
|
184
|
+
)
|
|
185
|
+
metric = JudgeMetric(
|
|
186
|
+
criteria=sc,
|
|
187
|
+
judge=judge,
|
|
188
|
+
parameters=["input", "output", "ground_truth"],
|
|
189
|
+
strict_mode=strict_mode,
|
|
190
|
+
threshold=threshold,
|
|
191
|
+
)
|
|
192
|
+
metrics.append(metric)
|
|
193
|
+
|
|
194
|
+
return cls(
|
|
195
|
+
metrics=metrics,
|
|
196
|
+
max_workers=max_workers,
|
|
197
|
+
max_retries=max_retries,
|
|
198
|
+
**kwargs,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _run_agent_on_sample(
|
|
202
|
+
self, sample: DataSample, agent: BaseAgent
|
|
203
|
+
) -> tuple[DataSample, AgentResponse | None, str | None]:
|
|
204
|
+
"""Run the agent on a single sample with retry logic."""
|
|
205
|
+
last_error = None
|
|
206
|
+
for attempt in range(self.max_retries):
|
|
207
|
+
try:
|
|
208
|
+
response = agent.predict(sample.input_text)
|
|
209
|
+
response.input_id = sample.id
|
|
210
|
+
return sample, response, None
|
|
211
|
+
except Exception as e:
|
|
212
|
+
last_error = str(e)
|
|
213
|
+
if attempt < self.max_retries - 1:
|
|
214
|
+
time.sleep(2**attempt)
|
|
215
|
+
return sample, None, f"Agent failed after {self.max_retries} attempts: {last_error}"
|
|
216
|
+
|
|
217
|
+
def _judge_sample(
|
|
218
|
+
self,
|
|
219
|
+
test_case: dict[str, str],
|
|
220
|
+
sample_id: str,
|
|
221
|
+
) -> tuple[str, dict[str, JudgeVerdict], str | None]:
|
|
222
|
+
"""
|
|
223
|
+
Run all judge metrics on a single test case with retries.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
(sample_id, {metric_name: verdict}, error_or_none)
|
|
227
|
+
"""
|
|
228
|
+
verdicts = {}
|
|
229
|
+
for metric in self.metrics:
|
|
230
|
+
last_error = None
|
|
231
|
+
for attempt in range(self.max_retries):
|
|
232
|
+
try:
|
|
233
|
+
verdict = metric.score(test_case)
|
|
234
|
+
verdicts[metric.name] = verdict
|
|
235
|
+
last_error = None
|
|
236
|
+
break
|
|
237
|
+
except Exception as e:
|
|
238
|
+
last_error = str(e)
|
|
239
|
+
if attempt < self.max_retries - 1:
|
|
240
|
+
time.sleep(2**attempt)
|
|
241
|
+
|
|
242
|
+
if last_error:
|
|
243
|
+
logger.error(
|
|
244
|
+
f"Judge metric '{metric.name}' failed for sample {sample_id}: {last_error}"
|
|
245
|
+
)
|
|
246
|
+
verdicts[metric.name] = JudgeVerdict(
|
|
247
|
+
score=0, reason=f"Judging error: {last_error}", metadata={"error": last_error}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return sample_id, verdicts, None
|
|
251
|
+
|
|
252
|
+
def _build_test_case(
|
|
253
|
+
self,
|
|
254
|
+
sample: DataSample,
|
|
255
|
+
response: AgentResponse,
|
|
256
|
+
) -> dict[str, str]:
|
|
257
|
+
"""
|
|
258
|
+
Build a test case dict from a sample + agent response.
|
|
259
|
+
|
|
260
|
+
Maps standard fields:
|
|
261
|
+
- "input" → sample.input_text
|
|
262
|
+
- "output" → response.output
|
|
263
|
+
- "ground_truth" → sample.target_output
|
|
264
|
+
|
|
265
|
+
Also includes agent metadata for rich judging context.
|
|
266
|
+
"""
|
|
267
|
+
test_case = {
|
|
268
|
+
"input": sample.input_text or "",
|
|
269
|
+
"output": str(response.output) if response.output else "",
|
|
270
|
+
"ground_truth": str(sample.target_output) if sample.target_output else "",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Include any extra metadata from the sample
|
|
274
|
+
if sample.metadata:
|
|
275
|
+
for key, value in sample.metadata.items():
|
|
276
|
+
if key not in test_case:
|
|
277
|
+
test_case[key] = str(value)
|
|
278
|
+
|
|
279
|
+
# Include agent response metadata (e.g., raw_output, confidence)
|
|
280
|
+
if response.metadata:
|
|
281
|
+
for key, value in response.metadata.items():
|
|
282
|
+
prefixed_key = f"agent_{key}"
|
|
283
|
+
if prefixed_key not in test_case:
|
|
284
|
+
test_case[prefixed_key] = str(value)
|
|
285
|
+
|
|
286
|
+
return test_case
|
|
287
|
+
|
|
288
|
+
def evaluate(
|
|
289
|
+
self,
|
|
290
|
+
agent: BaseAgent,
|
|
291
|
+
dataset: BaseDataset,
|
|
292
|
+
task_name: str,
|
|
293
|
+
version: str = "1.0",
|
|
294
|
+
max_workers: int | None = None,
|
|
295
|
+
max_retries: int | None = None,
|
|
296
|
+
**kwargs,
|
|
297
|
+
) -> EvaluationResult:
|
|
298
|
+
"""
|
|
299
|
+
Run the full LLM-as-a-Judge evaluation pipeline.
|
|
300
|
+
|
|
301
|
+
Phase 0: Initialize agent, pre-generate evaluation steps for each metric
|
|
302
|
+
Phase 1: Run agent on all samples (parallel)
|
|
303
|
+
Phase 2: Judge all responses with all metrics (parallel)
|
|
304
|
+
Phase 3: Aggregate scores and build result
|
|
305
|
+
"""
|
|
306
|
+
self.validate_inputs(agent, dataset)
|
|
307
|
+
workers = max_workers or self.max_workers
|
|
308
|
+
retries = max_retries or self.max_retries
|
|
309
|
+
|
|
310
|
+
if not agent._is_initialized:
|
|
311
|
+
agent.initialize()
|
|
312
|
+
|
|
313
|
+
start_time = time.time()
|
|
314
|
+
|
|
315
|
+
# Phase 0: Pre-generate evaluation steps for all metrics (sequential, cached)
|
|
316
|
+
logger.info("Phase 0: Generating evaluation steps for all metrics...")
|
|
317
|
+
for metric in self.metrics:
|
|
318
|
+
_ = metric.evaluation_steps # triggers generation if needed
|
|
319
|
+
logger.info(f" [{metric.name}] Steps: {metric.evaluation_steps}")
|
|
320
|
+
|
|
321
|
+
# Phase 1: Run agent on all samples
|
|
322
|
+
logger.info(f"Phase 1: Running agent on {len(dataset)} samples (workers={workers})...")
|
|
323
|
+
agent_results: list[tuple[DataSample, AgentResponse | None, str | None]] = []
|
|
324
|
+
execution_failures = []
|
|
325
|
+
|
|
326
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
327
|
+
futures = {
|
|
328
|
+
executor.submit(self._run_agent_on_sample, sample, agent): sample
|
|
329
|
+
for sample in dataset
|
|
330
|
+
}
|
|
331
|
+
for future in concurrent.futures.as_completed(futures):
|
|
332
|
+
sample, response, error = future.result()
|
|
333
|
+
if error:
|
|
334
|
+
execution_failures.append(
|
|
335
|
+
{"sample_id": sample.id, "phase": "agent", "error": error}
|
|
336
|
+
)
|
|
337
|
+
agent_results.append((sample, response, error))
|
|
338
|
+
|
|
339
|
+
logger.info(
|
|
340
|
+
f" Agent completed: {len(agent_results)} total, {len(execution_failures)} failures"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Phase 2: Judge all successful responses
|
|
344
|
+
logger.info(f"Phase 2: Judging responses with {len(self.metrics)} metrics...")
|
|
345
|
+
|
|
346
|
+
# Build test cases for successful predictions only
|
|
347
|
+
judgeable = []
|
|
348
|
+
for sample, response, error in agent_results:
|
|
349
|
+
if response and not error:
|
|
350
|
+
test_case = self._build_test_case(sample, response)
|
|
351
|
+
judgeable.append((sample, response, test_case))
|
|
352
|
+
|
|
353
|
+
# Run judge metrics in parallel
|
|
354
|
+
all_verdicts: list[tuple[DataSample, AgentResponse, dict[str, JudgeVerdict]]] = []
|
|
355
|
+
|
|
356
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
357
|
+
judge_futures: dict[
|
|
358
|
+
concurrent.futures.Future[tuple[str, dict[str, JudgeVerdict], str | None]],
|
|
359
|
+
tuple[DataSample, AgentResponse],
|
|
360
|
+
] = {}
|
|
361
|
+
for sample, response, test_case in judgeable:
|
|
362
|
+
judge_future = executor.submit(self._judge_sample, test_case, sample.id)
|
|
363
|
+
judge_futures[judge_future] = (sample, response)
|
|
364
|
+
|
|
365
|
+
for judge_future in concurrent.futures.as_completed(judge_futures):
|
|
366
|
+
judged_sample, judged_response = judge_futures[judge_future]
|
|
367
|
+
sample_id, verdicts, judge_error = judge_future.result()
|
|
368
|
+
if judge_error:
|
|
369
|
+
execution_failures.append(
|
|
370
|
+
{"sample_id": sample_id, "phase": "judge", "error": judge_error}
|
|
371
|
+
)
|
|
372
|
+
all_verdicts.append((judged_sample, judged_response, verdicts))
|
|
373
|
+
|
|
374
|
+
logger.info(f" Judging completed: {len(all_verdicts)} samples judged")
|
|
375
|
+
|
|
376
|
+
# Phase 3: Aggregate scores
|
|
377
|
+
duration = time.time() - start_time
|
|
378
|
+
eval_metrics = self._aggregate(all_verdicts, execution_failures, duration)
|
|
379
|
+
|
|
380
|
+
result = EvaluationResult(
|
|
381
|
+
agent_name=agent.name,
|
|
382
|
+
dataset_name=dataset.name,
|
|
383
|
+
metrics=eval_metrics,
|
|
384
|
+
duration=duration,
|
|
385
|
+
timestamp=datetime.now(),
|
|
386
|
+
task_name=task_name,
|
|
387
|
+
version=version,
|
|
388
|
+
metadata={
|
|
389
|
+
"max_workers": workers,
|
|
390
|
+
"max_retries": retries,
|
|
391
|
+
"num_metrics": len(self.metrics),
|
|
392
|
+
"metric_names": [m.name for m in self.metrics],
|
|
393
|
+
"metric_configs": [m.get_info() for m in self.metrics],
|
|
394
|
+
"execution_failures": execution_failures,
|
|
395
|
+
"total_samples": len(dataset),
|
|
396
|
+
"judged_samples": len(all_verdicts),
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
def _aggregate(
|
|
403
|
+
self,
|
|
404
|
+
all_verdicts: list[tuple[DataSample, AgentResponse, dict[str, JudgeVerdict]]],
|
|
405
|
+
execution_failures: list[dict],
|
|
406
|
+
duration: float,
|
|
407
|
+
) -> EvaluationMetrics:
|
|
408
|
+
"""
|
|
409
|
+
Aggregate per-sample verdicts into summary metrics.
|
|
410
|
+
|
|
411
|
+
Produces:
|
|
412
|
+
- Per-metric: avg score, std, pass rate
|
|
413
|
+
- Weighted composite score across all metrics
|
|
414
|
+
- Per-sample detail records
|
|
415
|
+
"""
|
|
416
|
+
metrics = EvaluationMetrics()
|
|
417
|
+
|
|
418
|
+
if not all_verdicts:
|
|
419
|
+
metrics.add_metric("composite_score", 0.0)
|
|
420
|
+
return metrics
|
|
421
|
+
|
|
422
|
+
# Collect per-metric scores
|
|
423
|
+
metric_scores: dict[str, list[float]] = {m.name: [] for m in self.metrics}
|
|
424
|
+
metric_pass_counts: dict[str, int] = {m.name: 0 for m in self.metrics}
|
|
425
|
+
|
|
426
|
+
per_sample_records: list[dict[str, Any]] = []
|
|
427
|
+
|
|
428
|
+
for sample, response, verdicts in all_verdicts:
|
|
429
|
+
sample_record = {
|
|
430
|
+
"sample_id": sample.id,
|
|
431
|
+
"input_text": sample.input_text[:200], # truncate for storage
|
|
432
|
+
"agent_output": str(response.output) if response.output else "",
|
|
433
|
+
"ground_truth": str(sample.target_output) if sample.target_output else "",
|
|
434
|
+
"agent_processing_time": response.processing_time,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
sample_weighted_sum = 0.0
|
|
438
|
+
sample_weight_total = 0.0
|
|
439
|
+
|
|
440
|
+
for metric in self.metrics:
|
|
441
|
+
verdict = verdicts.get(metric.name)
|
|
442
|
+
if verdict:
|
|
443
|
+
max_score = verdict.metadata.get("max_score", 10)
|
|
444
|
+
normalized = verdict.score / max_score if max_score > 0 else 0
|
|
445
|
+
|
|
446
|
+
metric_scores[metric.name].append(normalized)
|
|
447
|
+
|
|
448
|
+
if metric.is_successful(verdict):
|
|
449
|
+
metric_pass_counts[metric.name] += 1
|
|
450
|
+
|
|
451
|
+
sample_record[f"{metric.name}_score"] = verdict.score
|
|
452
|
+
sample_record[f"{metric.name}_normalized"] = round(normalized, 4)
|
|
453
|
+
sample_record[f"{metric.name}_reason"] = verdict.reason
|
|
454
|
+
sample_record[f"{metric.name}_pass"] = metric.is_successful(verdict)
|
|
455
|
+
|
|
456
|
+
sample_weighted_sum += normalized * metric.weight
|
|
457
|
+
sample_weight_total += metric.weight
|
|
458
|
+
|
|
459
|
+
# Composite score for this sample
|
|
460
|
+
if sample_weight_total > 0:
|
|
461
|
+
sample_record["composite_score"] = round(
|
|
462
|
+
sample_weighted_sum / sample_weight_total, 4
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
sample_record["composite_score"] = 0.0
|
|
466
|
+
|
|
467
|
+
per_sample_records.append(sample_record)
|
|
468
|
+
|
|
469
|
+
# Aggregate per-metric stats
|
|
470
|
+
for metric in self.metrics:
|
|
471
|
+
scores = metric_scores[metric.name]
|
|
472
|
+
if scores:
|
|
473
|
+
avg = statistics.mean(scores)
|
|
474
|
+
std = statistics.stdev(scores) if len(scores) > 1 else 0.0
|
|
475
|
+
pass_rate = metric_pass_counts[metric.name] / len(scores)
|
|
476
|
+
|
|
477
|
+
metrics.add_metric(f"{metric.name}_avg", round(avg, 4))
|
|
478
|
+
metrics.add_metric(f"{metric.name}_std", round(std, 4))
|
|
479
|
+
metrics.add_metric(f"{metric.name}_pass_rate", round(pass_rate, 4))
|
|
480
|
+
metrics.add_metric(f"{metric.name}_count", len(scores))
|
|
481
|
+
|
|
482
|
+
# Composite score across all metrics (weighted average of averages)
|
|
483
|
+
total_weight = sum(m.weight for m in self.metrics)
|
|
484
|
+
if total_weight > 0:
|
|
485
|
+
composite = (
|
|
486
|
+
sum(
|
|
487
|
+
statistics.mean(metric_scores[m.name]) * m.weight
|
|
488
|
+
for m in self.metrics
|
|
489
|
+
if metric_scores[m.name]
|
|
490
|
+
)
|
|
491
|
+
/ total_weight
|
|
492
|
+
)
|
|
493
|
+
metrics.add_metric("composite_score", round(composite, 4))
|
|
494
|
+
|
|
495
|
+
metrics.add_metric("total_samples_judged", len(all_verdicts))
|
|
496
|
+
metrics.add_metric("execution_failures", len(execution_failures))
|
|
497
|
+
metrics.add_metric("duration_seconds", round(duration, 4))
|
|
498
|
+
|
|
499
|
+
metrics.per_sample_metrics = per_sample_records
|
|
500
|
+
metrics.metadata = {
|
|
501
|
+
"metric_names": [m.name for m in self.metrics],
|
|
502
|
+
"execution_failures": execution_failures,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return metrics
|
|
506
|
+
|
|
507
|
+
# ── report saving ────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
def save_reports(
|
|
510
|
+
self,
|
|
511
|
+
result: EvaluationResult,
|
|
512
|
+
output_dir: str | Path,
|
|
513
|
+
run_prefix: str = "judge_run",
|
|
514
|
+
) -> Path:
|
|
515
|
+
"""
|
|
516
|
+
Save evaluation results to a timestamped directory.
|
|
517
|
+
|
|
518
|
+
Creates ``<output_dir>/<run_prefix>_<timestamp>/`` containing:
|
|
519
|
+
|
|
520
|
+
- **result.md** — human-readable Markdown report with aggregate
|
|
521
|
+
metrics and per-sample verdicts.
|
|
522
|
+
- **Meta.json** — machine-readable metadata (run config, metrics,
|
|
523
|
+
metric configs).
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
result: The ``EvaluationResult`` returned by :meth:`evaluate`.
|
|
527
|
+
output_dir: Parent directory (e.g., ``"_EvalRing"``).
|
|
528
|
+
run_prefix: Prefix for the timestamped sub-folder.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Path to the created run directory.
|
|
532
|
+
"""
|
|
533
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
534
|
+
run_dir = Path(output_dir) / f"{run_prefix}_{timestamp}"
|
|
535
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
|
|
537
|
+
metrics_dict = result.metrics.to_dict()["metrics"]
|
|
538
|
+
|
|
539
|
+
# ── result.md ─────────────────────────────────────
|
|
540
|
+
with open(run_dir / "result.md", "w", encoding="utf-8") as f:
|
|
541
|
+
f.write("# LLM-as-a-Judge Evaluation Results\n\n")
|
|
542
|
+
f.write(f"**Agent:** {result.agent_name}\n")
|
|
543
|
+
f.write(f"**Task:** {result.task_name}\n")
|
|
544
|
+
f.write(f"**Duration:** {result.duration:.2f} seconds\n")
|
|
545
|
+
f.write(f"**Samples:** {len(result.metrics.per_sample_metrics)}\n\n")
|
|
546
|
+
|
|
547
|
+
f.write("## Aggregate Metrics\n\n")
|
|
548
|
+
for key, value in metrics_dict.items():
|
|
549
|
+
f.write(f"- **{key}**: {value}\n")
|
|
550
|
+
|
|
551
|
+
f.write("\n## Per-Sample Verdicts\n\n")
|
|
552
|
+
for record in result.metrics.per_sample_metrics:
|
|
553
|
+
sid = record.get("sample_id", "?")
|
|
554
|
+
output = record.get("agent_output", "")
|
|
555
|
+
gt = record.get("ground_truth", "")
|
|
556
|
+
composite = record.get("composite_score", 0)
|
|
557
|
+
|
|
558
|
+
f.write(f"### Sample {sid}\n")
|
|
559
|
+
f.write(f"- **Prediction:** {output}\n")
|
|
560
|
+
f.write(f"- **Ground Truth:** {gt}\n")
|
|
561
|
+
f.write(f"- **Composite Score:** {composite}\n\n")
|
|
562
|
+
|
|
563
|
+
for m in self.metrics:
|
|
564
|
+
name = m.name
|
|
565
|
+
score = record.get(f"{name}_score", "")
|
|
566
|
+
normalized = record.get(f"{name}_normalized", "")
|
|
567
|
+
reason = record.get(f"{name}_reason", "")
|
|
568
|
+
passed = record.get(f"{name}_pass", "")
|
|
569
|
+
f.write(f"**{name}** (score={score}, norm={normalized}, pass={passed})\n")
|
|
570
|
+
f.write(f"> {reason}\n\n")
|
|
571
|
+
f.write("---\n\n")
|
|
572
|
+
|
|
573
|
+
# ── Meta.json ─────────────────────────────────────
|
|
574
|
+
meta = {
|
|
575
|
+
"run_id": f"{run_prefix}_{timestamp}",
|
|
576
|
+
"timestamp": datetime.now().isoformat(),
|
|
577
|
+
"agent": result.agent_name,
|
|
578
|
+
"task": result.task_name,
|
|
579
|
+
"num_samples": len(result.metrics.per_sample_metrics),
|
|
580
|
+
"duration": result.duration,
|
|
581
|
+
"metrics_summary": metrics_dict,
|
|
582
|
+
"metric_configs": [m.get_info() for m in self.metrics],
|
|
583
|
+
}
|
|
584
|
+
with open(run_dir / "Meta.json", "w", encoding="utf-8") as f:
|
|
585
|
+
json.dump(meta, f, indent=2, default=str)
|
|
586
|
+
|
|
587
|
+
logger.info(f"Reports saved to: {run_dir}")
|
|
588
|
+
return run_dir
|