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,305 @@
1
+ """Reusable multi-role host orchestration for classification tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .classification import (
10
+ normalize_probability_distribution,
11
+ parse_json_object,
12
+ resolve_classification_prediction,
13
+ )
14
+
15
+
16
+ @dataclass
17
+ class RoleConfig:
18
+ """Configuration for one specialist role in the multi-role system."""
19
+
20
+ name: str
21
+ persona: str
22
+ model_name: str
23
+ temperature: float = 0.0
24
+ max_completion_tokens: int = 400
25
+
26
+
27
+ class MultiRoleHostOrchestrator:
28
+ """Orchestrates role-by-role reviews plus bounded host-led questioning."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ client: Any,
34
+ labels: list[str],
35
+ task_name: str,
36
+ task_instructions: str,
37
+ host_model_name: str,
38
+ role_configs: list[RoleConfig],
39
+ host_temperature: float = 0.0,
40
+ host_max_completion_tokens: int = 500,
41
+ max_iterations: int = 10,
42
+ ):
43
+ if max_iterations < 1:
44
+ raise ValueError("max_iterations must be >= 1")
45
+ if not role_configs:
46
+ raise ValueError("role_configs cannot be empty")
47
+
48
+ self.client = client
49
+ self.labels = labels
50
+ self.task_name = task_name
51
+ self.task_instructions = task_instructions
52
+ self.host_model_name = host_model_name
53
+ self.role_configs = role_configs
54
+ self.role_map = {role.name: role for role in role_configs}
55
+ self.host_temperature = host_temperature
56
+ self.host_max_completion_tokens = host_max_completion_tokens
57
+ self.max_iterations = max_iterations
58
+
59
+ def _call_json(
60
+ self,
61
+ *,
62
+ model_name: str,
63
+ messages: list[dict[str, str]],
64
+ temperature: float,
65
+ max_completion_tokens: int,
66
+ ) -> dict[str, Any]:
67
+ response = self.client.chat.completions.create(
68
+ model=model_name,
69
+ messages=messages,
70
+ temperature=temperature,
71
+ max_completion_tokens=max_completion_tokens,
72
+ stream=False,
73
+ )
74
+ raw_output = response.choices[0].message.content or ""
75
+ parsed = parse_json_object(raw_output) or {}
76
+
77
+ usage = getattr(response, "usage", None)
78
+ token_usage = {
79
+ "prompt_tokens": int(getattr(usage, "prompt_tokens", 0) or 0),
80
+ "completion_tokens": int(getattr(usage, "completion_tokens", 0) or 0),
81
+ "total_tokens": int(getattr(usage, "total_tokens", 0) or 0),
82
+ }
83
+ return {"raw_output": raw_output, "parsed": parsed, "token_usage": token_usage}
84
+
85
+ def _one_hot(self, label: str) -> dict[str, float]:
86
+ return {candidate: (1.0 if candidate == label else 0.0) for candidate in self.labels}
87
+
88
+ def _normalize_role_judgment(self, parsed: Mapping[str, Any]) -> dict[str, Any]:
89
+ class_scores = parsed.get("class_scores")
90
+ conclusion = str(parsed.get("conclusion", "")).strip()
91
+
92
+ if isinstance(class_scores, Mapping):
93
+ prediction = resolve_classification_prediction(class_scores, valid_labels=self.labels)
94
+ label = prediction.label or (self.labels[0] if self.labels else "")
95
+ scores = normalize_probability_distribution(
96
+ {k: float(v) for k, v in prediction.class_scores.items()}
97
+ if prediction.class_scores
98
+ else self._one_hot(label)
99
+ )
100
+ else:
101
+ prediction = resolve_classification_prediction(conclusion, valid_labels=self.labels)
102
+ label = prediction.label or (self.labels[0] if self.labels else "")
103
+ scores = self._one_hot(label)
104
+
105
+ return {
106
+ "analysis": str(parsed.get("analysis", "")).strip(),
107
+ "conclusion": label,
108
+ "class_scores": scores,
109
+ "confidence": parsed.get("confidence", None),
110
+ }
111
+
112
+ def _aggregate_scores(self, role_reviews: list[dict[str, Any]]) -> dict[str, float]:
113
+ sums = dict.fromkeys(self.labels, 0.0)
114
+ if not role_reviews:
115
+ return sums
116
+
117
+ for review in role_reviews:
118
+ scores = review.get("class_scores", {})
119
+ for label in self.labels:
120
+ sums[label] += float(scores.get(label, 0.0))
121
+
122
+ return normalize_probability_distribution(sums)
123
+
124
+ def run_case(self, input_text: str) -> dict[str, Any]:
125
+ role_reviews: list[dict[str, Any]] = []
126
+ host_dialogue: list[dict[str, Any]] = []
127
+ total_prompt_tokens = 0
128
+ total_completion_tokens = 0
129
+ total_tokens = 0
130
+
131
+ labels_text = ", ".join(self.labels)
132
+
133
+ # 1) Independent role evaluations.
134
+ for role in self.role_configs:
135
+ call = self._call_json(
136
+ model_name=role.model_name,
137
+ messages=[
138
+ {
139
+ "role": "system",
140
+ "content": (
141
+ f"You are role: {role.name}. Persona: {role.persona}\n"
142
+ f"Task: {self.task_name}\n"
143
+ f"Allowed labels: {labels_text}\n"
144
+ f"Instructions: {self.task_instructions}\n"
145
+ "Return ONLY JSON with keys: analysis, conclusion, class_scores, confidence."
146
+ ),
147
+ },
148
+ {"role": "user", "content": f"Case:\n\n{input_text}"},
149
+ ],
150
+ temperature=role.temperature,
151
+ max_completion_tokens=role.max_completion_tokens,
152
+ )
153
+ normalized = self._normalize_role_judgment(call["parsed"])
154
+ role_reviews.append(
155
+ {
156
+ "role": role.name,
157
+ "model": role.model_name,
158
+ "raw_output": call["raw_output"],
159
+ **normalized,
160
+ }
161
+ )
162
+
163
+ usage = call["token_usage"]
164
+ total_prompt_tokens += usage["prompt_tokens"]
165
+ total_completion_tokens += usage["completion_tokens"]
166
+ total_tokens += usage["total_tokens"]
167
+
168
+ # 2) Host iterative questioning (bounded).
169
+ for iteration in range(1, self.max_iterations + 1):
170
+ host_call = self._call_json(
171
+ model_name=self.host_model_name,
172
+ messages=[
173
+ {
174
+ "role": "system",
175
+ "content": (
176
+ "You are the host coordinator for a multi-role classifier.\n"
177
+ f"Allowed labels: {labels_text}.\n"
178
+ "Decide either:\n"
179
+ "1) ask one role one follow-up question, or\n"
180
+ "2) finalize with a conclusion.\n"
181
+ "Return ONLY JSON with either:\n"
182
+ '- {"action":"ask","target_role":"...","question":"..."}\n'
183
+ "or\n"
184
+ '- {"action":"finalize","conclusion":"...","class_scores":{...},"rationale":"..."}.'
185
+ ),
186
+ },
187
+ {
188
+ "role": "user",
189
+ "content": (
190
+ f"Case:\n{input_text}\n\n"
191
+ f"Role reviews JSON:\n{role_reviews}\n\n"
192
+ f"Dialogue so far JSON:\n{host_dialogue}\n\n"
193
+ f"Iteration: {iteration}/{self.max_iterations}"
194
+ ),
195
+ },
196
+ ],
197
+ temperature=self.host_temperature,
198
+ max_completion_tokens=self.host_max_completion_tokens,
199
+ )
200
+
201
+ usage = host_call["token_usage"]
202
+ total_prompt_tokens += usage["prompt_tokens"]
203
+ total_completion_tokens += usage["completion_tokens"]
204
+ total_tokens += usage["total_tokens"]
205
+
206
+ action = str(host_call["parsed"].get("action", "")).strip().lower()
207
+ if action == "finalize":
208
+ final_scores_raw = host_call["parsed"].get("class_scores")
209
+ if isinstance(final_scores_raw, Mapping):
210
+ normalized_scores = normalize_probability_distribution(
211
+ {label: float(final_scores_raw.get(label, 0.0)) for label in self.labels}
212
+ )
213
+ else:
214
+ label = (
215
+ resolve_classification_prediction(
216
+ host_call["parsed"].get("conclusion", ""),
217
+ valid_labels=self.labels,
218
+ ).label
219
+ or self.labels[0]
220
+ )
221
+ normalized_scores = self._one_hot(label)
222
+
223
+ final_prediction = resolve_classification_prediction(
224
+ normalized_scores, valid_labels=self.labels
225
+ )
226
+ return {
227
+ "final_label": final_prediction.label,
228
+ "class_scores": normalized_scores,
229
+ "rationale": str(host_call["parsed"].get("rationale", "")).strip(),
230
+ "iterations_used": iteration,
231
+ "role_reviews": role_reviews,
232
+ "host_dialogue": host_dialogue,
233
+ "token_usage": {
234
+ "prompt_tokens": total_prompt_tokens,
235
+ "completion_tokens": total_completion_tokens,
236
+ "total_tokens": total_tokens,
237
+ },
238
+ }
239
+
240
+ if action != "ask":
241
+ break
242
+
243
+ target_role = str(host_call["parsed"].get("target_role", "")).strip()
244
+ question = str(host_call["parsed"].get("question", "")).strip()
245
+ if not question or target_role not in self.role_map:
246
+ break
247
+
248
+ target_cfg = self.role_map[target_role]
249
+ role_reply = self._call_json(
250
+ model_name=target_cfg.model_name,
251
+ messages=[
252
+ {
253
+ "role": "system",
254
+ "content": (
255
+ f"You are role: {target_cfg.name}. Persona: {target_cfg.persona}\n"
256
+ f"Allowed labels: {labels_text}\n"
257
+ "Answer host questions. Return ONLY JSON with keys: answer, updated_conclusion, class_scores."
258
+ ),
259
+ },
260
+ {
261
+ "role": "user",
262
+ "content": (
263
+ f"Case:\n{input_text}\n\n"
264
+ f"Host question:\n{question}\n\n"
265
+ f"Your original review context:\n{role_reviews}"
266
+ ),
267
+ },
268
+ ],
269
+ temperature=target_cfg.temperature,
270
+ max_completion_tokens=target_cfg.max_completion_tokens,
271
+ )
272
+
273
+ usage = role_reply["token_usage"]
274
+ total_prompt_tokens += usage["prompt_tokens"]
275
+ total_completion_tokens += usage["completion_tokens"]
276
+ total_tokens += usage["total_tokens"]
277
+
278
+ host_dialogue.append(
279
+ {
280
+ "iteration": iteration,
281
+ "target_role": target_role,
282
+ "question": question,
283
+ "answer": role_reply["parsed"],
284
+ "raw_answer": role_reply["raw_output"],
285
+ }
286
+ )
287
+
288
+ # 3) Fallback finalize if host never finalized.
289
+ fallback_scores = self._aggregate_scores(role_reviews)
290
+ fallback_prediction = resolve_classification_prediction(
291
+ fallback_scores, valid_labels=self.labels
292
+ )
293
+ return {
294
+ "final_label": fallback_prediction.label,
295
+ "class_scores": fallback_scores,
296
+ "rationale": "Fallback finalize from averaged role distributions.",
297
+ "iterations_used": self.max_iterations,
298
+ "role_reviews": role_reviews,
299
+ "host_dialogue": host_dialogue,
300
+ "token_usage": {
301
+ "prompt_tokens": total_prompt_tokens,
302
+ "completion_tokens": total_completion_tokens,
303
+ "total_tokens": total_tokens,
304
+ },
305
+ }
EvalRing/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """
2
+ Command-line interface for EvalRing.
3
+
4
+ Installed as the ``evalring`` console script. Every subcommand is a thin
5
+ wrapper over the public Python API, so anything reachable here is also
6
+ reachable from code.
7
+
8
+ Run ``evalring --help`` for the full listing, or see ``docs/CLI.md``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import importlib.util
15
+ import json
16
+ import logging
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from pathlib import Path
20
+
21
+ from . import __version__
22
+ from .config import CREDENTIAL_ENV_VARS, resolve_credentials, resolve_model_name
23
+ from .dataset.base import BaseDataset
24
+ from .logging_utils import configure_logging, get_logger
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ SUPPORTED_DATASET_SUFFIXES = (".json", ".csv")
29
+
30
+
31
+ def _load_dataset(
32
+ path: Path,
33
+ text_field: str,
34
+ label_field: str,
35
+ id_field: str | None,
36
+ ) -> BaseDataset:
37
+ """Load a dataset from disk, picking the reader from the file extension.
38
+
39
+ Args:
40
+ path: Path to a ``.json`` or ``.csv`` dataset file.
41
+ text_field: Column or key holding the model input.
42
+ label_field: Column or key holding the ground-truth label.
43
+ id_field: Column or key holding a unique sample ID, or ``None`` to
44
+ fall back to the row index.
45
+
46
+ Returns:
47
+ A loaded :class:`~EvalRing.dataset.base.BaseDataset` subclass instance.
48
+
49
+ Raises:
50
+ ValueError: If the file extension has no registered reader.
51
+ """
52
+ from .dataset import CSVDataset, JSONDataset
53
+
54
+ suffix = path.suffix.lower()
55
+ dataset: BaseDataset
56
+ if suffix == ".json":
57
+ dataset = JSONDataset(name=path.stem)
58
+ elif suffix == ".csv":
59
+ dataset = CSVDataset(name=path.stem)
60
+ else:
61
+ raise ValueError(
62
+ f"Unsupported dataset extension {suffix!r}. "
63
+ f"Supported: {', '.join(SUPPORTED_DATASET_SUFFIXES)}"
64
+ )
65
+
66
+ dataset.load_data(
67
+ path,
68
+ text_field=text_field,
69
+ label_field=label_field,
70
+ id_field=id_field,
71
+ )
72
+ return dataset
73
+
74
+
75
+ def _cmd_info(args: argparse.Namespace) -> int:
76
+ """Print dataset statistics as JSON.
77
+
78
+ Args:
79
+ args: Parsed arguments for the ``info`` subcommand.
80
+
81
+ Returns:
82
+ Process exit code: 0 on success, 1 when the dataset cannot be read.
83
+ """
84
+ path = Path(args.dataset)
85
+ if not path.exists():
86
+ logger.error("Dataset not found: %s", path)
87
+ return 1
88
+
89
+ try:
90
+ dataset = _load_dataset(path, args.text_field, args.label_field, args.id_field)
91
+ except Exception as exc:
92
+ logger.error("Failed to load %s: %s", path, exc)
93
+ return 1
94
+
95
+ stats = dataset.get_statistics()
96
+ stats["valid"] = dataset.validate_data()
97
+ stats["path"] = str(path)
98
+ print(json.dumps(stats, indent=2, default=str))
99
+ return 0
100
+
101
+
102
+ def _cmd_check(args: argparse.Namespace) -> int:
103
+ """Report the resolved provider configuration without revealing secrets.
104
+
105
+ Args:
106
+ args: Parsed arguments for the ``check`` subcommand.
107
+
108
+ Returns:
109
+ Process exit code: 0 when a credential was resolved, 1 otherwise.
110
+ """
111
+ credentials = resolve_credentials()
112
+ report = {
113
+ "evalring_version": __version__,
114
+ "python": sys.version.split()[0],
115
+ "api_key_found": bool(credentials.api_key),
116
+ "api_key_source": credentials.source,
117
+ "provider": credentials.provider,
118
+ "base_url": credentials.base_url or "<provider default>",
119
+ "model": resolve_model_name(default="<unset>"),
120
+ "recognized_key_variables": list(CREDENTIAL_ENV_VARS),
121
+ }
122
+
123
+ # find_spec only inspects the import system, so this stays fast even when
124
+ # a heavyweight optional package such as litellm is installed.
125
+ report["optional_packages"] = {
126
+ module: importlib.util.find_spec(module) is not None
127
+ for module in ("litellm", "openai", "matplotlib", "nest_asyncio")
128
+ }
129
+
130
+ print(json.dumps(report, indent=2))
131
+ if not credentials.api_key:
132
+ logger.warning("No API key resolved. Set one of: %s", ", ".join(CREDENTIAL_ENV_VARS))
133
+ return 1
134
+ return 0
135
+
136
+
137
+ def _cmd_run_suite(args: argparse.Namespace) -> int:
138
+ """Run an evaluation script across every model in a model list.
139
+
140
+ Args:
141
+ args: Parsed arguments for the ``run-suite`` subcommand.
142
+
143
+ Returns:
144
+ Process exit code propagated from
145
+ :func:`EvalRing.utils.suite_runner.run_suite`.
146
+ """
147
+ from .utils.suite_runner import run_suite
148
+
149
+ return run_suite(
150
+ eval_script=args.eval_script,
151
+ model_list_path=args.models_file,
152
+ n_samples=args.n_samples,
153
+ max_workers=args.max_workers,
154
+ seed=args.seed,
155
+ agent_mode=args.agent_mode,
156
+ base_class=args.base_class,
157
+ host_model=args.host_model,
158
+ role_models_json=args.role_models_json,
159
+ max_host_iterations=args.max_host_iterations,
160
+ continue_runs=args.continue_runs,
161
+ cache=args.cache,
162
+ cache_mode=args.cache_mode,
163
+ ignore_errors=args.ignore_errors,
164
+ out_dir=args.out_dir,
165
+ data_path=args.data_path,
166
+ no_confirm=args.yes,
167
+ )
168
+
169
+
170
+ def _cmd_models(args: argparse.Namespace) -> int:
171
+ """Generate a model-list JSON file from the OpenRouter catalogue.
172
+
173
+ Args:
174
+ args: Parsed arguments for the ``models`` subcommand.
175
+
176
+ Returns:
177
+ Process exit code: 0 on success, 1 on failure.
178
+ """
179
+ from .utils.generate_model_list import generate_model_list
180
+
181
+ output = Path(args.output)
182
+ output.parent.mkdir(parents=True, exist_ok=True)
183
+ try:
184
+ generate_model_list(output)
185
+ except Exception as exc:
186
+ logger.error("Failed to generate model list: %s", exc)
187
+ return 1
188
+ return 0
189
+
190
+
191
+ def build_parser() -> argparse.ArgumentParser:
192
+ """Construct the top-level argument parser.
193
+
194
+ Returns:
195
+ The fully configured :class:`argparse.ArgumentParser`.
196
+ """
197
+ parser = argparse.ArgumentParser(
198
+ prog="evalring",
199
+ description="Evaluate agents and models across providers.",
200
+ )
201
+ parser.add_argument("--version", action="version", version=f"evalring {__version__}")
202
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging.")
203
+ parser.add_argument("-q", "--quiet", action="store_true", help="Only log warnings and errors.")
204
+
205
+ sub = parser.add_subparsers(dest="command", required=True)
206
+
207
+ info = sub.add_parser("info", help="Show statistics for a dataset file.")
208
+ info.add_argument("--dataset", required=True, help="Path to a .json or .csv dataset.")
209
+ info.add_argument("--text-field", default="text", help="Input text column/key.")
210
+ info.add_argument("--label-field", default="label", help="Ground-truth column/key.")
211
+ info.add_argument("--id-field", default=None, help="Unique sample ID column/key.")
212
+ info.set_defaults(func=_cmd_info)
213
+
214
+ check = sub.add_parser(
215
+ "check",
216
+ help="Report the resolved provider configuration (no secrets printed).",
217
+ )
218
+ check.set_defaults(func=_cmd_check)
219
+
220
+ models = sub.add_parser("models", help="Generate a model-list JSON file.")
221
+ models.add_argument(
222
+ "--output",
223
+ default="model_list.json",
224
+ help="Destination path for the model list.",
225
+ )
226
+ models.set_defaults(func=_cmd_models)
227
+
228
+ suite = sub.add_parser(
229
+ "run-suite",
230
+ help="Run an evaluation script across every model in a model list.",
231
+ )
232
+ suite.add_argument("--eval-script", required=True, help="Path to the evaluation script.")
233
+ suite.add_argument("--models-file", required=True, help="Path to the model list JSON.")
234
+ suite.add_argument("--n-samples", type=int, default=10)
235
+ suite.add_argument("--max-workers", type=int, default=5)
236
+ suite.add_argument("--seed", type=int, default=42)
237
+ suite.add_argument("--agent-mode", default="single-class")
238
+ suite.add_argument("--base-class", default=None)
239
+ suite.add_argument("--host-model", default=None)
240
+ suite.add_argument("--role-models-json", default=None)
241
+ suite.add_argument("--max-host-iterations", type=int, default=10)
242
+ suite.add_argument("--continue-runs", action="store_true")
243
+ suite.add_argument("--cache", default=None)
244
+ suite.add_argument(
245
+ "--cache-mode",
246
+ default="both",
247
+ choices=["runs_only", "cache_file", "both", "none"],
248
+ )
249
+ suite.add_argument("--ignore-errors", action="store_true")
250
+ suite.add_argument("--out-dir", default=None)
251
+ suite.add_argument("--data-path", default=None)
252
+ suite.add_argument(
253
+ "-y", "--yes", action="store_true", help="Skip the pre-run cache confirmation."
254
+ )
255
+ suite.set_defaults(func=_cmd_run_suite)
256
+
257
+ return parser
258
+
259
+
260
+ def main(argv: Sequence[str] | None = None) -> int:
261
+ """Entry point for the ``evalring`` console script.
262
+
263
+ Args:
264
+ argv: Argument vector to parse. Defaults to ``sys.argv[1:]``.
265
+
266
+ Returns:
267
+ Process exit code.
268
+ """
269
+ parser = build_parser()
270
+ args = parser.parse_args(argv)
271
+
272
+ if args.verbose:
273
+ level = logging.DEBUG
274
+ elif args.quiet:
275
+ level = logging.WARNING
276
+ else:
277
+ level = logging.INFO
278
+ configure_logging(level, verbose=args.verbose)
279
+
280
+ return int(args.func(args) or 0)
281
+
282
+
283
+ if __name__ == "__main__": # pragma: no cover
284
+ sys.exit(main())