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.
@@ -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