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 ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ EvalRing: a unified evaluation framework for agents across LLMs and agent versions.
3
+
4
+ The public API is re-exported here. Three abstractions carry the framework:
5
+
6
+ - a :class:`~EvalRing.dataset.base.BaseDataset` supplies
7
+ :class:`~EvalRing.dataset.base.DataSample` records,
8
+ - a :class:`~EvalRing.agent.base.BaseAgent` turns each sample into an
9
+ :class:`~EvalRing.agent.base.AgentResponse`,
10
+ - a :class:`~EvalRing.evaluator.base.BaseEvaluator` scores the responses and
11
+ returns an :class:`~EvalRing.evaluator.base.EvaluationResult`.
12
+
13
+ Provider credentials are resolved centrally by
14
+ :func:`EvalRing.config.resolve_credentials`; see ``docs/CONFIGURATION.md``.
15
+ """
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ from .agent import (
20
+ AgentResponse,
21
+ BaseAgent,
22
+ ClassificationPrediction,
23
+ ErrorClass,
24
+ MockAgent,
25
+ MultiRoleHostOrchestrator,
26
+ OpenAIAgent,
27
+ RoleConfig,
28
+ RuleBasedAgent,
29
+ classify_error,
30
+ resolve_classification_prediction,
31
+ )
32
+ from .config import (
33
+ MissingCredentialsError,
34
+ ProviderCredentials,
35
+ resolve_credentials,
36
+ resolve_model_name,
37
+ )
38
+ from .dataset import BaseDataset, CSVDataset, DataFrameDataset, DataSample, JSONDataset
39
+ from .evaluator import (
40
+ BaseEvaluator,
41
+ ClassificationEvaluator,
42
+ EvalSteps,
43
+ EvaluationMetrics,
44
+ EvaluationResult,
45
+ JudgeMetric,
46
+ JudgeTemplate,
47
+ JudgeVerdict,
48
+ LLMJudge,
49
+ LLMJudgeEvaluator,
50
+ OpenAIJudge,
51
+ Rubric,
52
+ RubricLevel,
53
+ ScoringCriteria,
54
+ )
55
+ from .logging_utils import configure_logging, get_logger
56
+ from .utils import GlobalCache, generate_model_list, generate_suite_visuals, run_suite
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ # Configuration
61
+ "MissingCredentialsError",
62
+ "ProviderCredentials",
63
+ "resolve_credentials",
64
+ "resolve_model_name",
65
+ "configure_logging",
66
+ "get_logger",
67
+ # Datasets
68
+ "BaseDataset",
69
+ "DataSample",
70
+ "JSONDataset",
71
+ "CSVDataset",
72
+ "DataFrameDataset",
73
+ # Agents
74
+ "BaseAgent",
75
+ "AgentResponse",
76
+ "ClassificationPrediction",
77
+ "MockAgent",
78
+ "RuleBasedAgent",
79
+ "OpenAIAgent",
80
+ "MultiRoleHostOrchestrator",
81
+ "RoleConfig",
82
+ "ErrorClass",
83
+ "classify_error",
84
+ "resolve_classification_prediction",
85
+ # Evaluators
86
+ "BaseEvaluator",
87
+ "EvaluationResult",
88
+ "EvaluationMetrics",
89
+ "ClassificationEvaluator",
90
+ # LLM-as-a-judge
91
+ "Rubric",
92
+ "RubricLevel",
93
+ "ScoringCriteria",
94
+ "JudgeVerdict",
95
+ "EvalSteps",
96
+ "JudgeTemplate",
97
+ "JudgeMetric",
98
+ "LLMJudge",
99
+ "OpenAIJudge",
100
+ "LLMJudgeEvaluator",
101
+ # Suite tooling
102
+ "GlobalCache",
103
+ "generate_model_list",
104
+ "generate_suite_visuals",
105
+ "run_suite",
106
+ ]
@@ -0,0 +1,33 @@
1
+ """
2
+ Agent module for EvalRing.
3
+ """
4
+
5
+ from .base import AgentResponse, BaseAgent
6
+ from .classification import (
7
+ ClassificationPrediction,
8
+ aggregate_base_vs_rest_probabilities,
9
+ normalize_probability_distribution,
10
+ parse_json_object,
11
+ resolve_classification_prediction,
12
+ )
13
+ from .errors import ErrorClass, classify_error, format_exception
14
+ from .implementations import MockAgent, OpenAIAgent, RuleBasedAgent
15
+ from .multi_role import MultiRoleHostOrchestrator, RoleConfig
16
+
17
+ __all__ = [
18
+ "BaseAgent",
19
+ "AgentResponse",
20
+ "ClassificationPrediction",
21
+ "aggregate_base_vs_rest_probabilities",
22
+ "normalize_probability_distribution",
23
+ "parse_json_object",
24
+ "resolve_classification_prediction",
25
+ "MultiRoleHostOrchestrator",
26
+ "RoleConfig",
27
+ "MockAgent",
28
+ "RuleBasedAgent",
29
+ "OpenAIAgent",
30
+ "ErrorClass",
31
+ "classify_error",
32
+ "format_exception",
33
+ ]
EvalRing/agent/base.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ Base classes for agents in the EvalRing framework.
3
+ """
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class AgentResponse:
14
+ """Represents an agent's response to an input."""
15
+
16
+ input_id: str
17
+ input_text: str
18
+ output: Any
19
+ confidence: float | None = None
20
+ metadata: dict[str, Any] = field(default_factory=dict)
21
+ processing_time: float | None = None
22
+ error: str | None = None
23
+
24
+ def is_successful(self) -> bool:
25
+ """Check if the response was successful."""
26
+ return self.error is None
27
+
28
+
29
+ class BaseAgent(ABC):
30
+ """
31
+ Abstract base class for all agents in EvalRing.
32
+
33
+ This class provides a standardized interface for different types of agents
34
+ (LLM-based, rule-based, ensemble, etc.) to ensure compatibility with the
35
+ evaluation framework.
36
+ """
37
+
38
+ def __init__(self, name: str, version: str = "1.0", description: str | None = None, **kwargs):
39
+ self.name = name
40
+ self.version = version
41
+ self.description = description or f"Agent: {name}"
42
+ self._metadata = kwargs
43
+ self._is_initialized = False
44
+
45
+ @abstractmethod
46
+ def initialize(self, **kwargs) -> None:
47
+ """
48
+ Initialize the agent with required resources.
49
+
50
+ This method should be called before making predictions.
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ def predict(self, input_text: str, **kwargs) -> AgentResponse:
56
+ """
57
+ Make a prediction for a single input.
58
+
59
+ Args:
60
+ input_text: The input text to process
61
+ **kwargs: Additional parameters for prediction
62
+
63
+ Returns:
64
+ AgentResponse containing the prediction and metadata
65
+ """
66
+ pass
67
+
68
+ def predict_batch(self, inputs: list[str], **kwargs) -> list[AgentResponse]:
69
+ """
70
+ Make predictions for multiple inputs.
71
+
72
+ Default implementation processes inputs sequentially.
73
+ Override this method for batch processing optimization.
74
+
75
+ Args:
76
+ inputs: List of input texts to process
77
+ **kwargs: Additional parameters for prediction
78
+
79
+ Returns:
80
+ List of AgentResponse objects
81
+ """
82
+ responses = []
83
+ for i, input_text in enumerate(inputs):
84
+ response = self.predict(input_text, **kwargs)
85
+ response.input_id = str(i)
86
+ responses.append(response)
87
+ return responses
88
+
89
+ def validate_input(self, input_text: str) -> bool:
90
+ """
91
+ Validate input before processing.
92
+
93
+ Args:
94
+ input_text: Input text to validate
95
+
96
+ Returns:
97
+ True if input is valid, False otherwise
98
+ """
99
+ return isinstance(input_text, str) and len(input_text.strip()) > 0
100
+
101
+ def get_info(self) -> dict[str, Any]:
102
+ """Get agent information."""
103
+ return {
104
+ "name": self.name,
105
+ "version": self.version,
106
+ "description": self.description,
107
+ "type": self.__class__.__name__,
108
+ "metadata": self._metadata,
109
+ "is_initialized": self._is_initialized,
110
+ }
111
+
112
+ def save_config(self, filepath: str | Path) -> None:
113
+ """Save agent configuration to file."""
114
+ config = self.get_info()
115
+ with open(filepath, "w") as f:
116
+ json.dump(config, f, indent=2)
117
+
118
+ def __str__(self) -> str:
119
+ return f"{self.__class__.__name__}(name='{self.name}', version='{self.version}')"
120
+
121
+ def __repr__(self) -> str:
122
+ return self.__str__()
@@ -0,0 +1,191 @@
1
+ """
2
+ Reusable utilities for classification-style outputs.
3
+
4
+ These helpers let agents return either:
5
+ - a plain class label string, or
6
+ - a structured mapping of class -> confidence score.
7
+
8
+ The evaluator can then resolve the top class consistently.
9
+ """
10
+
11
+ import json
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+
17
+ @dataclass
18
+ class ClassificationPrediction:
19
+ """Normalized prediction object used by evaluators."""
20
+
21
+ label: str | None
22
+ confidence: float | None = None
23
+ class_scores: dict[str, float] | None = None
24
+
25
+
26
+ def parse_json_object(raw_text: str) -> dict[str, Any] | None:
27
+ """
28
+ Parse a JSON object from raw model text.
29
+
30
+ Supports either:
31
+ - exact JSON object text, or
32
+ - text that contains one object block.
33
+ """
34
+ text = (raw_text or "").strip()
35
+ if not text:
36
+ return None
37
+
38
+ try:
39
+ parsed = json.loads(text)
40
+ return parsed if isinstance(parsed, dict) else None
41
+ except Exception:
42
+ pass
43
+
44
+ start = text.find("{")
45
+ end = text.rfind("}")
46
+ if start == -1 or end == -1 or end <= start:
47
+ return None
48
+
49
+ try:
50
+ parsed = json.loads(text[start : end + 1])
51
+ return parsed if isinstance(parsed, dict) else None
52
+ except Exception:
53
+ return None
54
+
55
+
56
+ def canonicalize_label(raw_label: Any, label_aliases: Mapping[str, str] | None = None) -> str:
57
+ """Map a raw label to its canonical form (case-insensitive aliases)."""
58
+ text = str(raw_label).strip()
59
+ if not text:
60
+ return ""
61
+
62
+ if not label_aliases:
63
+ return text
64
+
65
+ alias_map = {k.lower(): v for k, v in label_aliases.items()}
66
+ return alias_map.get(text.lower(), text)
67
+
68
+
69
+ def normalize_class_scores(
70
+ output: Mapping[Any, Any],
71
+ *,
72
+ label_aliases: Mapping[str, str] | None = None,
73
+ ) -> dict[str, float]:
74
+ """Convert a raw mapping into a canonical class-score dict."""
75
+ merged: dict[str, float] = {}
76
+ for raw_label, raw_score in output.items():
77
+ try:
78
+ score = float(raw_score)
79
+ except (TypeError, ValueError):
80
+ continue
81
+
82
+ label = canonicalize_label(raw_label, label_aliases=label_aliases)
83
+ if not label:
84
+ continue
85
+
86
+ merged[label] = merged.get(label, 0.0) + score
87
+ return merged
88
+
89
+
90
+ def normalize_probability_distribution(scores: Mapping[str, float]) -> dict[str, float]:
91
+ """Normalize non-negative class scores into a probability distribution."""
92
+ normalized: dict[str, float] = {}
93
+ for label, value in scores.items():
94
+ try:
95
+ v = float(value)
96
+ except (TypeError, ValueError):
97
+ continue
98
+ normalized[label] = max(0.0, v)
99
+
100
+ total = sum(normalized.values())
101
+ if total <= 0:
102
+ return dict.fromkeys(normalized, 0.0)
103
+
104
+ return {label: value / total for label, value in normalized.items()}
105
+
106
+
107
+ def aggregate_base_vs_rest_probabilities(
108
+ *,
109
+ base_label: str,
110
+ target_vs_base_probs: Mapping[str, float],
111
+ all_labels: list[str] | None = None,
112
+ epsilon: float = 1e-6,
113
+ ) -> dict[str, float]:
114
+ """
115
+ Convert pairwise binary probabilities into a full multi-class distribution.
116
+
117
+ Expected input per target class is p(target | target vs base).
118
+ We convert each pairwise probability into odds r_t = p_t / (1-p_t), and use:
119
+ P(base) = 1 / (1 + sum_t r_t)
120
+ P(target_t) = r_t * P(base)
121
+
122
+ This makes a reusable bridge from base-vs-rest binary runs to multi-class output.
123
+ """
124
+ if not base_label:
125
+ raise ValueError("base_label must be a non-empty string")
126
+
127
+ ratios: dict[str, float] = {}
128
+ for label, probability in target_vs_base_probs.items():
129
+ if label == base_label:
130
+ continue
131
+
132
+ try:
133
+ p_target = float(probability)
134
+ except (TypeError, ValueError):
135
+ continue
136
+
137
+ p_target = min(max(p_target, epsilon), 1.0 - epsilon)
138
+ ratios[label] = p_target / (1.0 - p_target)
139
+
140
+ base_prob = 1.0 / (1.0 + sum(ratios.values()))
141
+ scores: dict[str, float] = {base_label: base_prob}
142
+ for label, ratio in ratios.items():
143
+ scores[label] = ratio * base_prob
144
+
145
+ if all_labels:
146
+ for label in all_labels:
147
+ scores.setdefault(label, 0.0)
148
+
149
+ return normalize_probability_distribution(scores)
150
+
151
+
152
+ def resolve_classification_prediction(
153
+ output: Any,
154
+ *,
155
+ valid_labels: list[str] | None = None,
156
+ label_aliases: Mapping[str, str] | None = None,
157
+ ) -> ClassificationPrediction:
158
+ """
159
+ Resolve top-class prediction from string or class-score mapping output.
160
+
161
+ If output is a mapping, the highest-scoring class is selected.
162
+ Ties are resolved by valid_labels order (if provided), then alphabetically.
163
+ """
164
+ if isinstance(output, Mapping):
165
+ scores = normalize_class_scores(output, label_aliases=label_aliases)
166
+
167
+ if valid_labels:
168
+ valid_set = set(valid_labels)
169
+ scores = {k: v for k, v in scores.items() if k in valid_set}
170
+
171
+ if not scores:
172
+ return ClassificationPrediction(label=None, confidence=None, class_scores={})
173
+
174
+ order = {label: idx for idx, label in enumerate(valid_labels or [])}
175
+ best_label, best_score = min(
176
+ scores.items(),
177
+ key=lambda kv: (
178
+ -kv[1],
179
+ order.get(kv[0], 10**9),
180
+ kv[0].lower(),
181
+ ),
182
+ )
183
+ return ClassificationPrediction(
184
+ label=best_label, confidence=float(best_score), class_scores=scores
185
+ )
186
+
187
+ if output is None:
188
+ return ClassificationPrediction(label=None, confidence=None, class_scores=None)
189
+
190
+ label = canonicalize_label(str(output), label_aliases=label_aliases)
191
+ return ClassificationPrediction(label=label, confidence=None, class_scores=None)
@@ -0,0 +1,109 @@
1
+ """Reusable error formatting and classification for model-backed agents.
2
+
3
+ Two concerns that every LLM task hits, factored out so tasks share one policy:
4
+
5
+ - :func:`format_exception` turns a raised exception (often from LiteLLM / the
6
+ OpenAI SDK) into a *complete* diagnostic string — exception type, full
7
+ message, and useful provider attributes — instead of just the class name.
8
+ - :func:`classify_error` labels an error message as rate-limited, transient
9
+ (worth patient retries), and/or terminal (retrying only wastes tokens) so
10
+ evaluators can share one retry policy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+
18
+ def format_exception(e: BaseException) -> str:
19
+ """Build a complete, diagnosable error string from an exception.
20
+
21
+ Captures the exception type and its full message (falling back to ``repr``
22
+ when the message is empty), plus common LiteLLM/OpenAI attributes
23
+ (``status_code``, ``llm_provider``, ``model``) when present.
24
+ """
25
+ detail = str(e).strip()
26
+ if not detail:
27
+ detail = repr(e)
28
+ parts = [f"{type(e).__name__}: {detail}"]
29
+ for attr in ("status_code", "llm_provider", "model"):
30
+ val = getattr(e, attr, None)
31
+ if val not in (None, ""):
32
+ parts.append(f"{attr}={val}")
33
+ return " | ".join(parts)
34
+
35
+
36
+ # Substrings (matched against a lower-cased message) for each class.
37
+ _RATE_LIMIT_MARKERS = ("429", "rate limit", "rate_limit", "too many requests")
38
+ _TRANSIENT_MARKERS = (
39
+ "connection error",
40
+ "internalservererror",
41
+ "internal server error",
42
+ "overloaded",
43
+ "timeout",
44
+ "timed out",
45
+ "temporarily unavailable",
46
+ "service unavailable",
47
+ "bad gateway",
48
+ "gateway timeout",
49
+ " 500",
50
+ " 502",
51
+ " 503",
52
+ " 504",
53
+ "502 ",
54
+ "503 ",
55
+ "504 ",
56
+ )
57
+ _TERMINAL_MARKERS = (
58
+ "empty response",
59
+ "content filter",
60
+ "content_filter",
61
+ "invalid request",
62
+ "invalid_request",
63
+ "badrequest",
64
+ "bad request",
65
+ "context length",
66
+ "maximum context",
67
+ "unsupported",
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ErrorClass:
73
+ """Retry-relevant classification of an error message."""
74
+
75
+ is_rate_limit: bool
76
+ is_transient: bool
77
+ is_terminal: bool
78
+
79
+
80
+ def classify_error(message: str) -> ErrorClass:
81
+ """Classify an error message for retry decisions.
82
+
83
+ Precedence: an **empty response** (or other terminal condition) is terminal
84
+ even if its message also mentions "timed out" — retrying such a case only
85
+ burns another full timeout window of tokens. Rate limits take precedence
86
+ over generic transient handling so callers can apply a dedicated backoff.
87
+ """
88
+ el = (message or "").lower()
89
+ is_rate_limit = any(m in el for m in _RATE_LIMIT_MARKERS)
90
+ is_transient = any(m in el for m in _TRANSIENT_MARKERS)
91
+ # Always-terminal conditions win over transient-looking text: these all
92
+ # mention "timeout"/"timed out" but retrying only wastes another full
93
+ # deadline — an empty response, a hard client timeout, and a soft
94
+ # (in-stream) timeout where the model was simply too slow for the limit.
95
+ forced_terminal = (
96
+ "empty response" in el
97
+ or "hard client timeout" in el
98
+ or "request timeout and was stopped" in el
99
+ )
100
+ is_terminal = forced_terminal or (
101
+ any(m in el for m in _TERMINAL_MARKERS) and not is_rate_limit and not is_transient
102
+ )
103
+ if is_terminal:
104
+ # A terminal error should never be treated as retryable.
105
+ is_transient = False
106
+ is_rate_limit = False
107
+ return ErrorClass(
108
+ is_rate_limit=is_rate_limit, is_transient=is_transient, is_terminal=is_terminal
109
+ )