guidedbench 0.1.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.
- guidedbench/__init__.py +27 -0
- guidedbench/_version.py +2 -0
- guidedbench/cli.py +423 -0
- guidedbench/dataset.py +172 -0
- guidedbench/evaluator.py +29 -0
- guidedbench/judges/__init__.py +13 -0
- guidedbench/judges/anthropic.py +56 -0
- guidedbench/judges/base.py +15 -0
- guidedbench/judges/openai.py +108 -0
- guidedbench/judges/transformers.py +69 -0
- guidedbench/models.py +103 -0
- guidedbench/parsing.py +69 -0
- guidedbench/prompt.py +42 -0
- guidedbench-0.1.0.dist-info/METADATA +258 -0
- guidedbench-0.1.0.dist-info/RECORD +19 -0
- guidedbench-0.1.0.dist-info/WHEEL +5 -0
- guidedbench-0.1.0.dist-info/entry_points.txt +2 -0
- guidedbench-0.1.0.dist-info/licenses/LICENSE +201 -0
- guidedbench-0.1.0.dist-info/top_level.txt +1 -0
guidedbench/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from ._version import BENCHMARK_VERSION, __version__
|
|
2
|
+
from .dataset import (
|
|
3
|
+
DATASET_REPO_ID,
|
|
4
|
+
DATASET_REVISION,
|
|
5
|
+
export_jsonl,
|
|
6
|
+
get_case,
|
|
7
|
+
load_cases,
|
|
8
|
+
)
|
|
9
|
+
from .evaluator import GuidedBenchEvaluator
|
|
10
|
+
from .models import EvaluationResult, GuidedBenchCase, Guideline, ScoringPointResult
|
|
11
|
+
from .parsing import EvaluationParseError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BENCHMARK_VERSION",
|
|
15
|
+
"DATASET_REPO_ID",
|
|
16
|
+
"DATASET_REVISION",
|
|
17
|
+
"EvaluationParseError",
|
|
18
|
+
"EvaluationResult",
|
|
19
|
+
"GuidedBenchCase",
|
|
20
|
+
"GuidedBenchEvaluator",
|
|
21
|
+
"Guideline",
|
|
22
|
+
"ScoringPointResult",
|
|
23
|
+
"__version__",
|
|
24
|
+
"export_jsonl",
|
|
25
|
+
"get_case",
|
|
26
|
+
"load_cases",
|
|
27
|
+
]
|
guidedbench/_version.py
ADDED
guidedbench/cli.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterable
|
|
14
|
+
|
|
15
|
+
from ._version import BENCHMARK_VERSION, __version__
|
|
16
|
+
from .dataset import export_jsonl, get_case, load_cases
|
|
17
|
+
from .evaluator import GuidedBenchEvaluator
|
|
18
|
+
from .judges import (
|
|
19
|
+
AnthropicJudge,
|
|
20
|
+
MissingDependencyError,
|
|
21
|
+
OpenAICompatibleJudge,
|
|
22
|
+
OpenAIJudge,
|
|
23
|
+
TransformersJudge,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="guidedbench",
|
|
30
|
+
description="Run the GuidedBench guideline-grounded jailbreak evaluation.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"guidedbench {__version__}")
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
subparsers.add_parser("info", help="Show benchmark version and split counts.")
|
|
36
|
+
|
|
37
|
+
export = subparsers.add_parser(
|
|
38
|
+
"export", help="Export the benchmark from Hugging Face as JSON Lines."
|
|
39
|
+
)
|
|
40
|
+
export.add_argument("output", type=Path)
|
|
41
|
+
export.add_argument("--subset", choices=("all", "core", "additional"), default="all")
|
|
42
|
+
export.add_argument("--topic")
|
|
43
|
+
|
|
44
|
+
evaluate = subparsers.add_parser("evaluate", help="Score victim-model responses from JSON Lines.")
|
|
45
|
+
evaluate.add_argument("input", type=Path, help="JSONL with id/index/question and response fields.")
|
|
46
|
+
evaluate.add_argument("--output", type=Path, required=True)
|
|
47
|
+
evaluate.add_argument(
|
|
48
|
+
"--provider",
|
|
49
|
+
required=True,
|
|
50
|
+
choices=("openai", "openai-compatible", "anthropic", "transformers"),
|
|
51
|
+
)
|
|
52
|
+
evaluate.add_argument("--model", required=True)
|
|
53
|
+
evaluate.add_argument("--base-url", help="Base URL for an OpenAI-compatible endpoint.")
|
|
54
|
+
evaluate.add_argument(
|
|
55
|
+
"--api-key-env",
|
|
56
|
+
help="Environment variable containing the provider API key.",
|
|
57
|
+
)
|
|
58
|
+
evaluate.add_argument(
|
|
59
|
+
"--api-style",
|
|
60
|
+
choices=("chat_completions", "responses"),
|
|
61
|
+
default="chat_completions",
|
|
62
|
+
help="Protocol for an OpenAI-compatible endpoint.",
|
|
63
|
+
)
|
|
64
|
+
evaluate.add_argument("--max-tokens", type=int, default=800)
|
|
65
|
+
evaluate.add_argument(
|
|
66
|
+
"--workers",
|
|
67
|
+
type=int,
|
|
68
|
+
default=1,
|
|
69
|
+
help=(
|
|
70
|
+
"Concurrent remote-judge requests. Every completed evaluation is "
|
|
71
|
+
"persisted as an independent atomic checkpoint before final gather."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
evaluate.add_argument(
|
|
75
|
+
"--checkpoint-dir",
|
|
76
|
+
type=Path,
|
|
77
|
+
help="Checkpoint directory (default: <output>.parts).",
|
|
78
|
+
)
|
|
79
|
+
evaluate.add_argument(
|
|
80
|
+
"--keep-checkpoints",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Keep per-evaluation checkpoint files after a successful gather.",
|
|
83
|
+
)
|
|
84
|
+
evaluate.add_argument("--device-map", default="auto", help="Transformers device_map value.")
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_jsonl(path: Path) -> Iterable[tuple[int, dict[str, Any]]]:
|
|
89
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
90
|
+
for line_number, line in enumerate(handle, start=1):
|
|
91
|
+
if line.strip():
|
|
92
|
+
value = json.loads(line)
|
|
93
|
+
if not isinstance(value, dict):
|
|
94
|
+
raise ValueError(f"Line {line_number} must contain a JSON object")
|
|
95
|
+
yield line_number, value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _api_key(args: argparse.Namespace) -> str | None:
|
|
99
|
+
if not args.api_key_env:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
return os.environ[args.api_key_env]
|
|
103
|
+
except KeyError as exc:
|
|
104
|
+
raise ValueError(f"Environment variable {args.api_key_env!r} is not set") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_judge(args: argparse.Namespace):
|
|
108
|
+
key = _api_key(args)
|
|
109
|
+
if args.provider == "openai":
|
|
110
|
+
return OpenAIJudge(args.model, api_key=key)
|
|
111
|
+
if args.provider == "openai-compatible":
|
|
112
|
+
if not args.base_url:
|
|
113
|
+
raise ValueError("--base-url is required for --provider openai-compatible")
|
|
114
|
+
return OpenAICompatibleJudge(
|
|
115
|
+
args.model,
|
|
116
|
+
api_key=key,
|
|
117
|
+
base_url=args.base_url,
|
|
118
|
+
api_style=args.api_style,
|
|
119
|
+
)
|
|
120
|
+
if args.provider == "anthropic":
|
|
121
|
+
return AnthropicJudge(args.model, api_key=key)
|
|
122
|
+
return TransformersJudge(args.model, device_map=args.device_map)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _case_identifier(record: dict[str, Any], line_number: int) -> int | str:
|
|
126
|
+
for key in ("id", "index", "question"):
|
|
127
|
+
if key in record:
|
|
128
|
+
return record[key]
|
|
129
|
+
raise ValueError(f"Line {line_number} must contain one of: id, index, question")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _evaluate_record(
|
|
133
|
+
evaluator: GuidedBenchEvaluator,
|
|
134
|
+
line_number: int,
|
|
135
|
+
record: dict[str, Any],
|
|
136
|
+
) -> tuple[dict[str, Any], float]:
|
|
137
|
+
if not isinstance(record.get("response"), str):
|
|
138
|
+
raise ValueError(f"Line {line_number} must contain a string response")
|
|
139
|
+
case = get_case(_case_identifier(record, line_number))
|
|
140
|
+
result = evaluator.evaluate(case, record["response"])
|
|
141
|
+
value = result.to_dict()
|
|
142
|
+
value["status"] = "completed"
|
|
143
|
+
value["input_line"] = line_number
|
|
144
|
+
for optional_key in ("method", "victim_model", "metadata"):
|
|
145
|
+
if optional_key in record:
|
|
146
|
+
value[optional_key] = record[optional_key]
|
|
147
|
+
return value, result.score
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class _EvaluationTask:
|
|
152
|
+
task_id: str
|
|
153
|
+
line_number: int
|
|
154
|
+
record: dict[str, Any]
|
|
155
|
+
result_path: Path
|
|
156
|
+
error_path: Path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _checkpoint_directory(args: argparse.Namespace) -> Path:
|
|
160
|
+
if args.checkpoint_dir is not None:
|
|
161
|
+
return args.checkpoint_dir
|
|
162
|
+
return args.output.with_name(args.output.name + ".parts")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _task_id(
|
|
166
|
+
args: argparse.Namespace,
|
|
167
|
+
line_number: int,
|
|
168
|
+
record: dict[str, Any],
|
|
169
|
+
) -> str:
|
|
170
|
+
payload = {
|
|
171
|
+
"package_version": __version__,
|
|
172
|
+
"benchmark_version": BENCHMARK_VERSION,
|
|
173
|
+
"provider": args.provider,
|
|
174
|
+
"model": args.model,
|
|
175
|
+
"base_url": args.base_url,
|
|
176
|
+
"api_style": args.api_style,
|
|
177
|
+
"max_tokens": args.max_tokens,
|
|
178
|
+
"input_line": line_number,
|
|
179
|
+
"record": record,
|
|
180
|
+
}
|
|
181
|
+
encoded = json.dumps(
|
|
182
|
+
payload,
|
|
183
|
+
ensure_ascii=False,
|
|
184
|
+
sort_keys=True,
|
|
185
|
+
separators=(",", ":"),
|
|
186
|
+
).encode("utf-8")
|
|
187
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
193
|
+
try:
|
|
194
|
+
with temporary.open("x", encoding="utf-8") as handle:
|
|
195
|
+
handle.write(json.dumps(value, ensure_ascii=False) + "\n")
|
|
196
|
+
handle.flush()
|
|
197
|
+
os.fsync(handle.fileno())
|
|
198
|
+
os.replace(temporary, path)
|
|
199
|
+
finally:
|
|
200
|
+
if temporary.exists():
|
|
201
|
+
temporary.unlink()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _atomic_write_jsonl(path: Path, values: Iterable[dict[str, Any]]) -> None:
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
207
|
+
try:
|
|
208
|
+
with temporary.open("x", encoding="utf-8") as handle:
|
|
209
|
+
for value in values:
|
|
210
|
+
handle.write(json.dumps(value, ensure_ascii=False) + "\n")
|
|
211
|
+
handle.flush()
|
|
212
|
+
os.fsync(handle.fileno())
|
|
213
|
+
os.replace(temporary, path)
|
|
214
|
+
finally:
|
|
215
|
+
if temporary.exists():
|
|
216
|
+
temporary.unlink()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_completed_result(path: Path, task_id: str) -> dict[str, Any] | None:
|
|
220
|
+
if not path.exists():
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
224
|
+
except (json.JSONDecodeError, OSError):
|
|
225
|
+
return None
|
|
226
|
+
if not isinstance(value, dict):
|
|
227
|
+
return None
|
|
228
|
+
if value.get("task_id") != task_id or value.get("status") != "completed":
|
|
229
|
+
return None
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _read_gathered_results(path: Path) -> dict[str, dict[str, Any]]:
|
|
234
|
+
if not path.exists():
|
|
235
|
+
return {}
|
|
236
|
+
results: dict[str, dict[str, Any]] = {}
|
|
237
|
+
for _, value in _read_jsonl(path):
|
|
238
|
+
task_id = value.get("task_id")
|
|
239
|
+
if isinstance(task_id, str) and value.get("status") == "completed":
|
|
240
|
+
results[task_id] = value
|
|
241
|
+
return results
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _evaluate_and_checkpoint(
|
|
245
|
+
evaluator: GuidedBenchEvaluator,
|
|
246
|
+
task: _EvaluationTask,
|
|
247
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
248
|
+
try:
|
|
249
|
+
value, _ = _evaluate_record(evaluator, task.line_number, task.record)
|
|
250
|
+
value["task_id"] = task.task_id
|
|
251
|
+
_atomic_write_json(task.result_path, value)
|
|
252
|
+
if task.error_path.exists():
|
|
253
|
+
task.error_path.unlink()
|
|
254
|
+
return True, value
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
failure = {
|
|
257
|
+
"task_id": task.task_id,
|
|
258
|
+
"status": "failed",
|
|
259
|
+
"input_line": task.line_number,
|
|
260
|
+
"identifier": _case_identifier(task.record, task.line_number),
|
|
261
|
+
"error_type": exc.__class__.__name__,
|
|
262
|
+
"error": str(exc),
|
|
263
|
+
}
|
|
264
|
+
_atomic_write_json(task.error_path, failure)
|
|
265
|
+
return False, failure
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _clean_checkpoints(tasks: Iterable[_EvaluationTask], directory: Path) -> None:
|
|
269
|
+
for task in tasks:
|
|
270
|
+
for path in (task.result_path, task.error_path):
|
|
271
|
+
if path.exists():
|
|
272
|
+
path.unlink()
|
|
273
|
+
try:
|
|
274
|
+
directory.rmdir()
|
|
275
|
+
except OSError:
|
|
276
|
+
# Leave unrelated or crash-leftover files untouched.
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _run_evaluate(args: argparse.Namespace) -> int:
|
|
281
|
+
if args.workers < 1:
|
|
282
|
+
raise ValueError("--workers must be at least 1")
|
|
283
|
+
if args.provider == "transformers" and args.workers != 1:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"The local Transformers backend currently requires --workers 1; "
|
|
286
|
+
"serve the model behind an OpenAI-compatible endpoint for concurrent requests"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
records = list(_read_jsonl(args.input))
|
|
290
|
+
for line_number, record in records:
|
|
291
|
+
if not isinstance(record.get("response"), str):
|
|
292
|
+
raise ValueError(f"Line {line_number} must contain a string response")
|
|
293
|
+
get_case(_case_identifier(record, line_number))
|
|
294
|
+
_api_key(args) # Fail before scheduling work if an explicitly named key is missing.
|
|
295
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
checkpoint_dir = _checkpoint_directory(args)
|
|
297
|
+
tasks = tuple(
|
|
298
|
+
_EvaluationTask(
|
|
299
|
+
task_id=(task_id := _task_id(args, line_number, record)),
|
|
300
|
+
line_number=line_number,
|
|
301
|
+
record=record,
|
|
302
|
+
result_path=checkpoint_dir / f"{line_number:06d}-{task_id}.json",
|
|
303
|
+
error_path=checkpoint_dir / f"{line_number:06d}-{task_id}.error.json",
|
|
304
|
+
)
|
|
305
|
+
for line_number, record in records
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
gathered_cache = _read_gathered_results(args.output)
|
|
309
|
+
completed: dict[str, dict[str, Any]] = {}
|
|
310
|
+
pending: list[_EvaluationTask] = []
|
|
311
|
+
for task in tasks:
|
|
312
|
+
value = _read_completed_result(task.result_path, task.task_id)
|
|
313
|
+
if value is None:
|
|
314
|
+
value = gathered_cache.get(task.task_id)
|
|
315
|
+
if value is None:
|
|
316
|
+
pending.append(task)
|
|
317
|
+
else:
|
|
318
|
+
completed[task.task_id] = value
|
|
319
|
+
|
|
320
|
+
failures: list[dict[str, Any]] = []
|
|
321
|
+
if pending and args.workers == 1:
|
|
322
|
+
evaluator = GuidedBenchEvaluator(_make_judge(args), max_tokens=args.max_tokens)
|
|
323
|
+
for task in pending:
|
|
324
|
+
succeeded, value = _evaluate_and_checkpoint(evaluator, task)
|
|
325
|
+
if succeeded:
|
|
326
|
+
completed[task.task_id] = value
|
|
327
|
+
else:
|
|
328
|
+
failures.append(value)
|
|
329
|
+
elif pending:
|
|
330
|
+
worker_state = threading.local()
|
|
331
|
+
|
|
332
|
+
def evaluate_in_worker(
|
|
333
|
+
task: _EvaluationTask,
|
|
334
|
+
) -> tuple[_EvaluationTask, bool, dict[str, Any]]:
|
|
335
|
+
evaluator = getattr(worker_state, "evaluator", None)
|
|
336
|
+
if evaluator is None:
|
|
337
|
+
evaluator = GuidedBenchEvaluator(
|
|
338
|
+
_make_judge(args),
|
|
339
|
+
max_tokens=args.max_tokens,
|
|
340
|
+
)
|
|
341
|
+
worker_state.evaluator = evaluator
|
|
342
|
+
succeeded, value = _evaluate_and_checkpoint(evaluator, task)
|
|
343
|
+
return task, succeeded, value
|
|
344
|
+
|
|
345
|
+
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
|
346
|
+
futures = [executor.submit(evaluate_in_worker, task) for task in pending]
|
|
347
|
+
for future in as_completed(futures):
|
|
348
|
+
task, succeeded, value = future.result()
|
|
349
|
+
if succeeded:
|
|
350
|
+
completed[task.task_id] = value
|
|
351
|
+
else:
|
|
352
|
+
failures.append(value)
|
|
353
|
+
|
|
354
|
+
if failures:
|
|
355
|
+
print(
|
|
356
|
+
json.dumps(
|
|
357
|
+
{
|
|
358
|
+
"status": "incomplete",
|
|
359
|
+
"completed": len(completed),
|
|
360
|
+
"failed": len(failures),
|
|
361
|
+
"checkpoint_dir": str(checkpoint_dir),
|
|
362
|
+
}
|
|
363
|
+
),
|
|
364
|
+
file=sys.stderr,
|
|
365
|
+
)
|
|
366
|
+
return 1
|
|
367
|
+
|
|
368
|
+
missing = [task.task_id for task in tasks if task.task_id not in completed]
|
|
369
|
+
if missing:
|
|
370
|
+
raise RuntimeError(f"Missing {len(missing)} checkpoint results before gather")
|
|
371
|
+
|
|
372
|
+
ordered_results = [completed[task.task_id] for task in tasks]
|
|
373
|
+
_atomic_write_jsonl(args.output, ordered_results)
|
|
374
|
+
if not args.keep_checkpoints:
|
|
375
|
+
_clean_checkpoints(tasks, checkpoint_dir)
|
|
376
|
+
|
|
377
|
+
scores = [float(value["score"]) for value in ordered_results]
|
|
378
|
+
mean = sum(scores) / len(scores) if scores else 0.0
|
|
379
|
+
print(
|
|
380
|
+
json.dumps(
|
|
381
|
+
{
|
|
382
|
+
"status": "completed",
|
|
383
|
+
"evaluated": len(scores),
|
|
384
|
+
"recovered": len(tasks) - len(pending),
|
|
385
|
+
"mean_score": mean,
|
|
386
|
+
"workers": args.workers,
|
|
387
|
+
"output": str(args.output),
|
|
388
|
+
}
|
|
389
|
+
),
|
|
390
|
+
file=sys.stderr,
|
|
391
|
+
)
|
|
392
|
+
return 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main(argv: list[str] | None = None) -> int:
|
|
396
|
+
args = _parser().parse_args(argv)
|
|
397
|
+
try:
|
|
398
|
+
if args.command == "info":
|
|
399
|
+
all_cases = load_cases()
|
|
400
|
+
value = {
|
|
401
|
+
"package_version": __version__,
|
|
402
|
+
"benchmark_version": BENCHMARK_VERSION,
|
|
403
|
+
"cases": len(all_cases),
|
|
404
|
+
"core": len(load_cases(subset="core")),
|
|
405
|
+
"additional": len(load_cases(subset="additional")),
|
|
406
|
+
"topics": len({case.topic for case in all_cases}),
|
|
407
|
+
}
|
|
408
|
+
print(json.dumps(value, indent=2))
|
|
409
|
+
return 0
|
|
410
|
+
if args.command == "export":
|
|
411
|
+
path = export_jsonl(args.output, subset=args.subset, topic=args.topic)
|
|
412
|
+
print(path)
|
|
413
|
+
return 0
|
|
414
|
+
if args.command == "evaluate":
|
|
415
|
+
return _run_evaluate(args)
|
|
416
|
+
except (KeyError, MissingDependencyError, OSError, RuntimeError, ValueError) as exc:
|
|
417
|
+
print(f"guidedbench: error: {exc}", file=sys.stderr)
|
|
418
|
+
return 2
|
|
419
|
+
return 1
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
raise SystemExit(main())
|
guidedbench/dataset.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from huggingface_hub import hf_hub_download
|
|
9
|
+
from huggingface_hub.errors import GatedRepoError
|
|
10
|
+
|
|
11
|
+
from ._version import BENCHMARK_VERSION
|
|
12
|
+
from .models import GuidedBenchCase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Subset = Literal["all", "core", "additional"]
|
|
16
|
+
|
|
17
|
+
DATASET_REPO_ID = "HRXUST/GuidedBench"
|
|
18
|
+
DATASET_REVISION = "10cc683c8df8c07139d3f900e381fb52738123c3"
|
|
19
|
+
_DATA_FILES = (
|
|
20
|
+
("core", "core.jsonl", 180),
|
|
21
|
+
("additional", "additional.jsonl", 20),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache(maxsize=8)
|
|
26
|
+
def _all_cases(
|
|
27
|
+
repo_id: str,
|
|
28
|
+
revision: str,
|
|
29
|
+
cache_dir: str | None,
|
|
30
|
+
local_files_only: bool,
|
|
31
|
+
) -> tuple[GuidedBenchCase, ...]:
|
|
32
|
+
cases: list[GuidedBenchCase] = []
|
|
33
|
+
try:
|
|
34
|
+
for expected_subset, filename, expected_count in _DATA_FILES:
|
|
35
|
+
data_file = hf_hub_download(
|
|
36
|
+
repo_id=repo_id,
|
|
37
|
+
filename=filename,
|
|
38
|
+
repo_type="dataset",
|
|
39
|
+
revision=revision,
|
|
40
|
+
cache_dir=cache_dir,
|
|
41
|
+
local_files_only=local_files_only,
|
|
42
|
+
)
|
|
43
|
+
subset_cases: list[GuidedBenchCase] = []
|
|
44
|
+
with Path(data_file).open("r", encoding="utf-8") as handle:
|
|
45
|
+
for line_number, line in enumerate(handle, start=1):
|
|
46
|
+
if not line.strip():
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
case = GuidedBenchCase.from_dict(json.loads(line))
|
|
50
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Invalid GuidedBench record in {filename}:{line_number}"
|
|
53
|
+
) from exc
|
|
54
|
+
if case.subset != expected_subset:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"Expected subset {expected_subset!r} in {filename}, "
|
|
57
|
+
f"found {case.subset!r}"
|
|
58
|
+
)
|
|
59
|
+
subset_cases.append(case)
|
|
60
|
+
if len(subset_cases) != expected_count:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"Expected {expected_count} cases in {filename}, "
|
|
63
|
+
f"found {len(subset_cases)}"
|
|
64
|
+
)
|
|
65
|
+
cases.extend(subset_cases)
|
|
66
|
+
except GatedRepoError as exc:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"GuidedBench dataset access is gated. Request access at "
|
|
69
|
+
f"https://huggingface.co/datasets/{repo_id}, then authenticate with "
|
|
70
|
+
"`hf auth login` or set HF_TOKEN."
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
if len(cases) != 200:
|
|
74
|
+
raise RuntimeError(f"Expected 200 GuidedBench cases, found {len(cases)}")
|
|
75
|
+
if len({case.id for case in cases}) != len(cases):
|
|
76
|
+
raise RuntimeError("GuidedBench contains duplicate case ids")
|
|
77
|
+
if any(case.benchmark_version != BENCHMARK_VERSION for case in cases):
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Dataset revision {revision} does not match benchmark version "
|
|
80
|
+
f"{BENCHMARK_VERSION}"
|
|
81
|
+
)
|
|
82
|
+
return tuple(cases)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_source(
|
|
86
|
+
*,
|
|
87
|
+
repo_id: str,
|
|
88
|
+
revision: str,
|
|
89
|
+
cache_dir: str | Path | None,
|
|
90
|
+
local_files_only: bool,
|
|
91
|
+
) -> tuple[GuidedBenchCase, ...]:
|
|
92
|
+
normalized_cache = (
|
|
93
|
+
str(Path(cache_dir).expanduser()) if cache_dir is not None else None
|
|
94
|
+
)
|
|
95
|
+
return _all_cases(repo_id, revision, normalized_cache, local_files_only)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_cases(
|
|
99
|
+
*,
|
|
100
|
+
subset: Subset = "all",
|
|
101
|
+
topic: str | None = None,
|
|
102
|
+
repo_id: str = DATASET_REPO_ID,
|
|
103
|
+
revision: str = DATASET_REVISION,
|
|
104
|
+
cache_dir: str | Path | None = None,
|
|
105
|
+
local_files_only: bool = False,
|
|
106
|
+
) -> tuple[GuidedBenchCase, ...]:
|
|
107
|
+
"""Load a pinned GuidedBench release from the Hugging Face Hub cache."""
|
|
108
|
+
|
|
109
|
+
if subset not in {"all", "core", "additional"}:
|
|
110
|
+
raise ValueError("subset must be one of: all, core, additional")
|
|
111
|
+
cases = _load_source(
|
|
112
|
+
repo_id=repo_id,
|
|
113
|
+
revision=revision,
|
|
114
|
+
cache_dir=cache_dir,
|
|
115
|
+
local_files_only=local_files_only,
|
|
116
|
+
)
|
|
117
|
+
if subset != "all":
|
|
118
|
+
cases = tuple(case for case in cases if case.subset == subset)
|
|
119
|
+
if topic is not None:
|
|
120
|
+
cases = tuple(case for case in cases if case.topic == topic)
|
|
121
|
+
return cases
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_case(
|
|
125
|
+
identifier: int | str,
|
|
126
|
+
*,
|
|
127
|
+
repo_id: str = DATASET_REPO_ID,
|
|
128
|
+
revision: str = DATASET_REVISION,
|
|
129
|
+
cache_dir: str | Path | None = None,
|
|
130
|
+
local_files_only: bool = False,
|
|
131
|
+
) -> GuidedBenchCase:
|
|
132
|
+
"""Find a case by integer index, stable id, or exact question text."""
|
|
133
|
+
|
|
134
|
+
cases = _load_source(
|
|
135
|
+
repo_id=repo_id,
|
|
136
|
+
revision=revision,
|
|
137
|
+
cache_dir=cache_dir,
|
|
138
|
+
local_files_only=local_files_only,
|
|
139
|
+
)
|
|
140
|
+
for case in cases:
|
|
141
|
+
if isinstance(identifier, int) and case.index == identifier:
|
|
142
|
+
return case
|
|
143
|
+
if isinstance(identifier, str) and identifier in {case.id, case.question}:
|
|
144
|
+
return case
|
|
145
|
+
raise KeyError(f"No GuidedBench case matches {identifier!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def export_jsonl(
|
|
149
|
+
path: str | Path,
|
|
150
|
+
*,
|
|
151
|
+
subset: Subset = "all",
|
|
152
|
+
topic: str | None = None,
|
|
153
|
+
repo_id: str = DATASET_REPO_ID,
|
|
154
|
+
revision: str = DATASET_REVISION,
|
|
155
|
+
cache_dir: str | Path | None = None,
|
|
156
|
+
local_files_only: bool = False,
|
|
157
|
+
) -> Path:
|
|
158
|
+
"""Export records downloaded from Hugging Face as JSON Lines."""
|
|
159
|
+
|
|
160
|
+
destination = Path(path)
|
|
161
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
with destination.open("w", encoding="utf-8") as handle:
|
|
163
|
+
for case in load_cases(
|
|
164
|
+
subset=subset,
|
|
165
|
+
topic=topic,
|
|
166
|
+
repo_id=repo_id,
|
|
167
|
+
revision=revision,
|
|
168
|
+
cache_dir=cache_dir,
|
|
169
|
+
local_files_only=local_files_only,
|
|
170
|
+
):
|
|
171
|
+
handle.write(json.dumps(case.to_dict(), ensure_ascii=False) + "\n")
|
|
172
|
+
return destination
|
guidedbench/evaluator.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .dataset import get_case
|
|
4
|
+
from .judges.base import Judge
|
|
5
|
+
from .models import EvaluationResult, GuidedBenchCase
|
|
6
|
+
from .parsing import parse_guided_response
|
|
7
|
+
from .prompt import JUDGE_SYSTEM_PROMPT, build_guided_prompt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GuidedBenchEvaluator:
|
|
11
|
+
"""Evaluate responses using GuidedBench's verified case-specific guidelines."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, judge: Judge, *, max_tokens: int = 800) -> None:
|
|
14
|
+
self.judge = judge
|
|
15
|
+
self.max_tokens = max_tokens
|
|
16
|
+
|
|
17
|
+
def evaluate(
|
|
18
|
+
self,
|
|
19
|
+
case: GuidedBenchCase | int | str,
|
|
20
|
+
response: str,
|
|
21
|
+
) -> EvaluationResult:
|
|
22
|
+
resolved_case = case if isinstance(case, GuidedBenchCase) else get_case(case)
|
|
23
|
+
prompt = build_guided_prompt(resolved_case, response)
|
|
24
|
+
raw = self.judge.generate(
|
|
25
|
+
prompt,
|
|
26
|
+
system=JUDGE_SYSTEM_PROMPT,
|
|
27
|
+
max_tokens=self.max_tokens,
|
|
28
|
+
)
|
|
29
|
+
return parse_guided_response(raw, resolved_case, judge=self.judge.name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .anthropic import AnthropicJudge
|
|
2
|
+
from .base import Judge, MissingDependencyError
|
|
3
|
+
from .openai import OpenAICompatibleJudge, OpenAIJudge
|
|
4
|
+
from .transformers import TransformersJudge
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AnthropicJudge",
|
|
8
|
+
"Judge",
|
|
9
|
+
"MissingDependencyError",
|
|
10
|
+
"OpenAICompatibleJudge",
|
|
11
|
+
"OpenAIJudge",
|
|
12
|
+
"TransformersJudge",
|
|
13
|
+
]
|