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,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)