auditkit 1.0.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.
- auditkit/README.md +99 -0
- auditkit/__init__.py +177 -0
- auditkit/__main__.py +3 -0
- auditkit/_bootstrap.py +77 -0
- auditkit/_identity_guard.py +99 -0
- auditkit/adapter.py +264 -0
- auditkit/annotator.py +339 -0
- auditkit/api.py +502 -0
- auditkit/assets/auditkit_logo.png +0 -0
- auditkit/cache.py +47 -0
- auditkit/cli.py +417 -0
- auditkit/comparison.py +563 -0
- auditkit/diff.py +265 -0
- auditkit/errors.py +54 -0
- auditkit/evaluator.py +20 -0
- auditkit/experiment.py +145 -0
- auditkit/hf_publish.py +262 -0
- auditkit/lmeval_engine.py +550 -0
- auditkit/loaders.py +121 -0
- auditkit/logs.py +18 -0
- auditkit/metric.py +199 -0
- auditkit/metrics/README.md +15 -0
- auditkit/metrics/__init__.py +0 -0
- auditkit/metrics/code.py +222 -0
- auditkit/metrics/embedding.py +131 -0
- auditkit/metrics/encoder_judge.py +423 -0
- auditkit/metrics/generation.py +331 -0
- auditkit/metrics/guard.py +412 -0
- auditkit/metrics/hallucination.py +45 -0
- auditkit/metrics/judge.py +547 -0
- auditkit/metrics/pairwise.py +153 -0
- auditkit/metrics/perf.py +53 -0
- auditkit/metrics/rag.py +149 -0
- auditkit/metrics/security.py +64 -0
- auditkit/metrics/toxicity.py +238 -0
- auditkit/model/README.md +16 -0
- auditkit/model/__init__.py +485 -0
- auditkit/model/anthropic.py +94 -0
- auditkit/model/api_gen.py +133 -0
- auditkit/model/groq_gen.py +121 -0
- auditkit/model/hf_gen.py +385 -0
- auditkit/model/lexsi.py +155 -0
- auditkit/model/litellm_gen.py +65 -0
- auditkit/model/openai.py +90 -0
- auditkit/model/openrouter_gen.py +152 -0
- auditkit/model/vllm_gen.py +316 -0
- auditkit/model_compare.py +655 -0
- auditkit/redteam/README.md +9 -0
- auditkit/redteam/__init__.py +26 -0
- auditkit/redteam/detector.py +37 -0
- auditkit/redteam/detectors/README.md +5 -0
- auditkit/redteam/detectors/builtin.py +126 -0
- auditkit/redteam/probe.py +39 -0
- auditkit/redteam/probes/README.md +5 -0
- auditkit/redteam/probes/builtin.py +85 -0
- auditkit/redteam/runner.py +206 -0
- auditkit/registry.py +65 -0
- auditkit/report.py +278 -0
- auditkit/report_format.py +52 -0
- auditkit/router.py +54 -0
- auditkit/runner.py +575 -0
- auditkit/runspec.py +159 -0
- auditkit/sample.py +40 -0
- auditkit/scenario.py +88 -0
- auditkit/scenarios/README.md +10 -0
- auditkit/scenarios/__init__.py +4 -0
- auditkit/scenarios/arc.py +33 -0
- auditkit/scenarios/gsm8k.py +32 -0
- auditkit/scenarios/hellaswag.py +33 -0
- auditkit/scenarios/humaneval.py +32 -0
- auditkit/scenarios/mmlu.py +34 -0
- auditkit/scenarios/truthfulqa.py +33 -0
- auditkit/score.py +165 -0
- auditkit/scorers.py +117 -0
- auditkit/scoring.py +79 -0
- auditkit/types.py +69 -0
- auditkit-1.0.0.dist-info/METADATA +396 -0
- auditkit-1.0.0.dist-info/RECORD +81 -0
- auditkit-1.0.0.dist-info/WHEEL +4 -0
- auditkit-1.0.0.dist-info/entry_points.txt +2 -0
- auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
auditkit/adapter.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Adapters: how a :class:`Sample` becomes model :class:`Request` objects.
|
|
2
|
+
|
|
3
|
+
The adapter is the technique of *prompting* — the same sample can be elicited as
|
|
4
|
+
a plain generation, as multiple-choice by joint prompt or by loglikelihood, as a
|
|
5
|
+
chat turn, as an adversarial probe, and so on. Each ``method`` is a different way
|
|
6
|
+
to turn a sample into requests. :class:`GenerationAdapter` is the plainest: ask
|
|
7
|
+
the model to generate an answer to the input.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
from .model import Request
|
|
17
|
+
from .registry import ADAPTERS
|
|
18
|
+
from .runspec import RunConfig
|
|
19
|
+
from .sample import Sample
|
|
20
|
+
from ._identity_guard import warn_if_identity_incomplete
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The RunConfig fields that are *generation* settings. Every adapter forwards
|
|
24
|
+
# exactly this set into each Request's params, so which knob reaches the model
|
|
25
|
+
# never depends on which adapter you picked (previously GenerationAdapter
|
|
26
|
+
# forwarded the full set while the other five silently dropped presence_penalty/
|
|
27
|
+
# frequency_penalty/top_k/num_completions/best_of). timeout/max_retries are
|
|
28
|
+
# deliberately excluded — they're Runner-only, not generation params. Each
|
|
29
|
+
# backend's own key-map still decides which of these its API actually accepts;
|
|
30
|
+
# forwarding one a backend doesn't support is a safe no-op.
|
|
31
|
+
_GEN_PARAM_ATTRS = (
|
|
32
|
+
"temperature", "top_p", "top_k", "max_tokens", "stop_sequences",
|
|
33
|
+
"presence_penalty", "frequency_penalty", "num_completions", "best_of", "seed",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _gen_params(config: RunConfig) -> dict:
|
|
38
|
+
"""Generation settings from *config* to put on a Request (skips unset ones)."""
|
|
39
|
+
return {
|
|
40
|
+
attr: getattr(config, attr)
|
|
41
|
+
for attr in _GEN_PARAM_ATTRS
|
|
42
|
+
if getattr(config, attr, None) is not None
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Adapter(ABC):
|
|
47
|
+
"""Turn one sample into the requests that elicit an answer."""
|
|
48
|
+
|
|
49
|
+
method: str = "generation"
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
56
|
+
super().__init_subclass__(**kwargs)
|
|
57
|
+
warn_if_identity_incomplete(cls, Adapter, "adapt", name_attr="method")
|
|
58
|
+
|
|
59
|
+
def identity(self) -> dict:
|
|
60
|
+
"""The config that defines this adapter's prompts, for the run fingerprint.
|
|
61
|
+
|
|
62
|
+
Two adapters with the same identity produce the same prompts, so they are
|
|
63
|
+
the same "ruler". Prompt-bearing adapters override this to include their
|
|
64
|
+
template/system prompt — otherwise changing a prompt would leave the
|
|
65
|
+
fingerprint unchanged and a stale cached result would be returned.
|
|
66
|
+
``__init_subclass__`` above warns at class-definition time when a
|
|
67
|
+
subclass takes constructor arguments but skips this override (unless
|
|
68
|
+
it instead makes ``self.method`` itself parameter-derived, which
|
|
69
|
+
already protects the fingerprint — see ``_identity_guard.py``)."""
|
|
70
|
+
return {"method": self.method}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@ADAPTERS.register("generation")
|
|
74
|
+
class GenerationAdapter(Adapter):
|
|
75
|
+
"""One free-form generation request per sample."""
|
|
76
|
+
|
|
77
|
+
method = "generation"
|
|
78
|
+
|
|
79
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
80
|
+
params = _gen_params(config)
|
|
81
|
+
return [Request(prompt=sample.input, request_type="generate", params=params)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@ADAPTERS.register("mcq")
|
|
85
|
+
class MCQAdapter(Adapter):
|
|
86
|
+
"""One sample → one joint-prompt request (mcq_joint) or N loglikelihood requests (mcq_loglikelihood)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, method: str = "mcq_joint") -> None:
|
|
89
|
+
self.method = method
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def method(self) -> str:
|
|
93
|
+
return self._method
|
|
94
|
+
|
|
95
|
+
@method.setter
|
|
96
|
+
def method(self, value: str) -> None:
|
|
97
|
+
self._method = value
|
|
98
|
+
|
|
99
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
100
|
+
if not sample.choices:
|
|
101
|
+
raise ValueError("MCQAdapter requires choices")
|
|
102
|
+
if self._method == "mcq_joint":
|
|
103
|
+
# Numeric, 0-based labels: unlike chr(65+i) letters, these never run
|
|
104
|
+
# out (letters break past 26 choices — chr(91) is '[', not a
|
|
105
|
+
# letter — and collide once index >= 32 wraps into lowercase
|
|
106
|
+
# ASCII). 0-based to match the index convention used everywhere
|
|
107
|
+
# else a choice is identified (sample.target, _resolve_choice_index).
|
|
108
|
+
prompt = sample.input_text + "\n\n"
|
|
109
|
+
for i, choice in enumerate(sample.choices):
|
|
110
|
+
prompt += f"{i}. {choice}\n"
|
|
111
|
+
prompt += "\nAnswer:"
|
|
112
|
+
return [Request(prompt=prompt, request_type="generate")]
|
|
113
|
+
return [
|
|
114
|
+
Request(prompt=sample.input, request_type="loglikelihood", params={"target": c})
|
|
115
|
+
for c in sample.choices
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@ADAPTERS.register("chat")
|
|
120
|
+
class ChatAdapter(Adapter):
|
|
121
|
+
"""Formats samples as chat messages using a system prompt template."""
|
|
122
|
+
|
|
123
|
+
method = "chat"
|
|
124
|
+
|
|
125
|
+
def __init__(self, system_prompt: str = "You are a helpful assistant.") -> None:
|
|
126
|
+
self.system_prompt = system_prompt
|
|
127
|
+
|
|
128
|
+
def identity(self) -> dict:
|
|
129
|
+
return {"method": self.method, "system_prompt": self.system_prompt}
|
|
130
|
+
|
|
131
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
132
|
+
params = _gen_params(config)
|
|
133
|
+
messages = [{"role": "system", "content": self.system_prompt},
|
|
134
|
+
{"role": "user", "content": sample.input}]
|
|
135
|
+
# request.params["messages"] is now read by 5 real backends (via
|
|
136
|
+
# resolve_messages(): openai.py, anthropic.py, groq.py, litellm.py,
|
|
137
|
+
# chat-mode api.py) and hf_gen.py's own chat-template rendering --
|
|
138
|
+
# but EchoModel/CallableModel and any bare-callable model= still only
|
|
139
|
+
# ever read request.prompt, so the flattened "System: ...\n\nUser: ..."
|
|
140
|
+
# text stays the one guaranteed-reachable form; messages is kept
|
|
141
|
+
# alongside it for backends that prefer real chat turns.
|
|
142
|
+
prompt = f"System: {self.system_prompt}\n\nUser: {sample.input}"
|
|
143
|
+
return [Request(prompt=prompt, request_type="chat", params={**params, "messages": messages})]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@ADAPTERS.register("fewshot")
|
|
147
|
+
class FewShotAdapter(Adapter):
|
|
148
|
+
"""Prepends few-shot examples from the dataset before each sample.
|
|
149
|
+
|
|
150
|
+
``num_shots`` is this adapter's own default shot count. ``RunConfig.num_fewshot``
|
|
151
|
+
-- the run-level knob every other engine treats as authoritative (it's the
|
|
152
|
+
``lm-eval`` name too) -- overrides it when set, so ``evaluate(..., adapter=
|
|
153
|
+
FewShotAdapter(), config=RunConfig(num_fewshot=5))`` actually uses 5 shots
|
|
154
|
+
instead of silently keeping the adapter's own default.
|
|
155
|
+
|
|
156
|
+
``pool`` is a plain public attribute, not a private ``_pool`` -- unlike
|
|
157
|
+
every other adapter's config, it needs to be externally assignable:
|
|
158
|
+
``Runner.build_requests()`` auto-populates it from ``RunConfig.split``'s
|
|
159
|
+
train fold (``if hasattr(adapter, "pool"): adapter.pool = train``), which
|
|
160
|
+
silently never fired while this was named ``_pool``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
method = "fewshot"
|
|
164
|
+
|
|
165
|
+
def __init__(self, num_shots: int = 3, separator: str = "\n\n",
|
|
166
|
+
pool: list[Sample] | None = None) -> None:
|
|
167
|
+
self.num_shots = num_shots
|
|
168
|
+
self.separator = separator
|
|
169
|
+
self.pool = pool
|
|
170
|
+
|
|
171
|
+
def identity(self) -> dict:
|
|
172
|
+
pool_hash = None
|
|
173
|
+
if self.pool:
|
|
174
|
+
pool_hash = hashlib.sha256(json.dumps(
|
|
175
|
+
[(s.input, s.target) for s in self.pool],
|
|
176
|
+
default=str, sort_keys=True,
|
|
177
|
+
).encode()).hexdigest()[:12]
|
|
178
|
+
return {
|
|
179
|
+
"method": self.method,
|
|
180
|
+
"num_shots": self.num_shots,
|
|
181
|
+
"separator": self.separator,
|
|
182
|
+
"pool_size": len(self.pool) if self.pool else 0,
|
|
183
|
+
"pool_hash": pool_hash,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
187
|
+
params = _gen_params(config)
|
|
188
|
+
n = config.num_fewshot if config.num_fewshot is not None else self.num_shots
|
|
189
|
+
pool = self.pool or []
|
|
190
|
+
if n > 0 and len(pool) < n:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"FewShotAdapter needs a pool of at least {n} example(s) to build "
|
|
193
|
+
f"{n}-shot prompts, but the pool has only {len(pool)}. Pass a "
|
|
194
|
+
f"bigger pool=, or lower num_shots / RunConfig.num_fewshot."
|
|
195
|
+
)
|
|
196
|
+
prefix = ""
|
|
197
|
+
for fs in pool[:n]:
|
|
198
|
+
prefix += f"{fs.input}\n{fs.target or ''}{self.separator}"
|
|
199
|
+
return [Request(prompt=f"{prefix}{sample.input}", request_type="generate", params=params)]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@ADAPTERS.register("instruction")
|
|
203
|
+
class InstructionAdapter(Adapter):
|
|
204
|
+
"""Prepends an instruction/system prompt to each sample input."""
|
|
205
|
+
|
|
206
|
+
method = "instruction"
|
|
207
|
+
|
|
208
|
+
def __init__(self, instruction: str = "Answer the following question:") -> None:
|
|
209
|
+
self.instruction = instruction
|
|
210
|
+
|
|
211
|
+
def identity(self) -> dict:
|
|
212
|
+
return {"method": self.method, "instruction": self.instruction}
|
|
213
|
+
|
|
214
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
215
|
+
params = _gen_params(config)
|
|
216
|
+
return [Request(prompt=f"{self.instruction}\n{sample.input}", request_type="generate", params=params)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@ADAPTERS.register("rag")
|
|
220
|
+
class RAGAdapter(Adapter):
|
|
221
|
+
"""Prepends retrieval_context to the sample input."""
|
|
222
|
+
|
|
223
|
+
method = "rag"
|
|
224
|
+
|
|
225
|
+
def __init__(self, context_separator: str = "\nContext:\n", max_context_chars: int | None = None) -> None:
|
|
226
|
+
self.context_separator = context_separator
|
|
227
|
+
self.max_context_chars = max_context_chars
|
|
228
|
+
|
|
229
|
+
def identity(self) -> dict:
|
|
230
|
+
return {"method": self.method, "context_separator": self.context_separator,
|
|
231
|
+
"max_context_chars": self.max_context_chars}
|
|
232
|
+
|
|
233
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
234
|
+
params = _gen_params(config)
|
|
235
|
+
ctx = sample.retrieval_context or []
|
|
236
|
+
if not ctx:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"RAGAdapter requires sample.retrieval_context; this sample has "
|
|
239
|
+
"none. Pass samples with retrieval_context=[...], or use a "
|
|
240
|
+
"different adapter if retrieval isn't part of this task."
|
|
241
|
+
)
|
|
242
|
+
context_str = self.context_separator + "\n".join(ctx)
|
|
243
|
+
if self.max_context_chars and len(context_str) > self.max_context_chars:
|
|
244
|
+
context_str = context_str[:self.max_context_chars] + "..."
|
|
245
|
+
return [Request(prompt=f"{sample.input}{context_str}", request_type="generate", params=params)]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@ADAPTERS.register("template")
|
|
249
|
+
class TemplateAdapter(Adapter):
|
|
250
|
+
"""Uses a Python format string with {input}, {target}, {context} placeholders."""
|
|
251
|
+
|
|
252
|
+
method = "template"
|
|
253
|
+
|
|
254
|
+
def __init__(self, template: str = "{input}") -> None:
|
|
255
|
+
self.template = template
|
|
256
|
+
|
|
257
|
+
def identity(self) -> dict:
|
|
258
|
+
return {"method": self.method, "template": self.template}
|
|
259
|
+
|
|
260
|
+
def adapt(self, sample: Sample, config: RunConfig) -> list[Request]:
|
|
261
|
+
params = _gen_params(config)
|
|
262
|
+
ctx = "\n".join(sample.retrieval_context or [])
|
|
263
|
+
prompt = self.template.format(input=sample.input, target=sample.target or "", context=ctx)
|
|
264
|
+
return [Request(prompt=prompt, request_type="generate", params=params)]
|
auditkit/annotator.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .registry import ANNOTATORS
|
|
10
|
+
from ._identity_guard import warn_if_identity_incomplete
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .sample import Sample
|
|
14
|
+
from .model import Result_
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Annotator(ABC):
|
|
18
|
+
name: str = "annotator"
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def annotate(self, sample: Sample, results: list[Result_]) -> dict[str, Any]:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
25
|
+
super().__init_subclass__(**kwargs)
|
|
26
|
+
warn_if_identity_incomplete(cls, Annotator, "annotate")
|
|
27
|
+
|
|
28
|
+
def identity(self) -> dict:
|
|
29
|
+
"""The config that defines this annotator's behavior, for the run
|
|
30
|
+
fingerprint. Override when an annotator has real config (a pattern,
|
|
31
|
+
a prompt, ...) — otherwise two differently-configured instances of
|
|
32
|
+
the same annotator class would silently collide in the fingerprint,
|
|
33
|
+
same class of bug already fixed for adapters/judges elsewhere.
|
|
34
|
+
``__init_subclass__`` above warns at class-definition time when a
|
|
35
|
+
subclass takes constructor arguments but skips this override (unless
|
|
36
|
+
it instead makes ``self.name`` itself parameter-derived, which
|
|
37
|
+
already protects the fingerprint — see ``_identity_guard.py``)."""
|
|
38
|
+
return {"name": self.name}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# The default extraction pattern: everything, spanning newlines. It exists so
|
|
42
|
+
# `pattern` can be optional, which is what makes a bare registry name --
|
|
43
|
+
# `ANNOTATORS.get("regex")()`, the form `ak.evaluate(annotators="regex")` and the
|
|
44
|
+
# platform's `annotators: ["regex"]` both resolve to -- actually constructible.
|
|
45
|
+
# Before this, every registered annotator required a constructor argument, so a
|
|
46
|
+
# bare name raised TypeError partway through a run that had already been
|
|
47
|
+
# submitted and allocated compute.
|
|
48
|
+
#
|
|
49
|
+
# For ThinkingStripAnnotator this default is the useful case rather than a
|
|
50
|
+
# degenerate one: strip the <think> block, keep everything else.
|
|
51
|
+
_MATCH_ALL = r"(?s).+"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@ANNOTATORS.register("regex")
|
|
55
|
+
class RegexAnnotator(Annotator):
|
|
56
|
+
"""Extracts a substring from the model output via a user-supplied regex.
|
|
57
|
+
|
|
58
|
+
The extracted value lands in the run's ``context`` under this
|
|
59
|
+
annotator's ``name`` as ``{"extracted": ..., "matched": bool, "raw":
|
|
60
|
+
...}``. Pass this annotator's ``name`` as ``extract_with=`` on
|
|
61
|
+
``ak.evaluate()`` to actually score against the extracted value instead
|
|
62
|
+
of the raw output — without that, the extraction still happens but sits
|
|
63
|
+
unused in ``context``, available to any custom :class:`Metric` that
|
|
64
|
+
reads it directly.
|
|
65
|
+
|
|
66
|
+
Most useful on the generative and precomputed (``actual_output``)
|
|
67
|
+
paths, where output is free text. On the loglikelihood/MCQ path,
|
|
68
|
+
``extract_with`` is intentionally never honored by the ``Runner`` --
|
|
69
|
+
output there is already the exact, correct choice text picked by
|
|
70
|
+
comparing logprobs, and extracting a number from inside that text is a
|
|
71
|
+
different operation than the choice-index-based metrics (``Acc``/
|
|
72
|
+
``AccNorm``) expect from a bare digit-string, which can silently
|
|
73
|
+
misattribute a correct pick as wrong. This annotator still runs and
|
|
74
|
+
populates ``context`` there for inspection; only the extraction hookup
|
|
75
|
+
into scoring is disabled on that specific path.
|
|
76
|
+
|
|
77
|
+
``cast`` converts the extracted string (e.g. ``int``, ``float``, or any
|
|
78
|
+
``str -> Any`` callable) -- ``None`` (default) keeps it a plain string,
|
|
79
|
+
unchanged from before this existed. A failing cast degrades to the
|
|
80
|
+
original string plus ``"cast_failed": True`` in the returned dict rather
|
|
81
|
+
than raising, so one malformed sample can't crash a whole run; pass
|
|
82
|
+
``strict=True`` to raise instead, if you'd rather fail fast.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
pattern: str = _MATCH_ALL,
|
|
88
|
+
group: int | str = 0,
|
|
89
|
+
flags: int = 0,
|
|
90
|
+
on_no_match: str = "",
|
|
91
|
+
name: str = "regex",
|
|
92
|
+
cast: Optional[Callable[[str], Any]] = None,
|
|
93
|
+
strict: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
self.pattern = pattern
|
|
96
|
+
self.flags = flags
|
|
97
|
+
self._regex = re.compile(pattern, flags)
|
|
98
|
+
self._group = group
|
|
99
|
+
self._on_no_match = on_no_match
|
|
100
|
+
self.name = name
|
|
101
|
+
self._cast = cast
|
|
102
|
+
self._strict = strict
|
|
103
|
+
|
|
104
|
+
def identity(self) -> dict:
|
|
105
|
+
return {
|
|
106
|
+
"name": self.name, "pattern": self.pattern, "group": self._group,
|
|
107
|
+
"flags": self.flags, "on_no_match": self._on_no_match,
|
|
108
|
+
"cast": getattr(self._cast, "__name__", repr(self._cast)) if self._cast else None,
|
|
109
|
+
"strict": self._strict,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def annotate(self, sample: Sample, results: list[Result_]) -> dict[str, Any]:
|
|
113
|
+
text = results[0].text if results else ""
|
|
114
|
+
m = self._regex.search(text)
|
|
115
|
+
if m is None:
|
|
116
|
+
return {"extracted": self._on_no_match, "matched": False, "raw": text}
|
|
117
|
+
value = m.group(self._group)
|
|
118
|
+
if value is None: # matched, but this particular group didn't participate
|
|
119
|
+
value = self._on_no_match
|
|
120
|
+
|
|
121
|
+
if self._cast is None:
|
|
122
|
+
return {"extracted": value, "matched": True, "raw": text}
|
|
123
|
+
try:
|
|
124
|
+
return {"extracted": self._cast(value), "matched": True, "raw": text}
|
|
125
|
+
except (ValueError, TypeError) as e:
|
|
126
|
+
if self._strict:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{self.name}: cast {self._cast!r} failed on extracted value {value!r}: {e}"
|
|
129
|
+
) from e
|
|
130
|
+
return {"extracted": value, "matched": True, "raw": text, "cast_failed": True}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@ANNOTATORS.register("thinking_strip")
|
|
134
|
+
class ThinkingStripAnnotator(Annotator):
|
|
135
|
+
"""Extracts a value via regex, after first stripping ``<think>...</think>``
|
|
136
|
+
reasoning blocks some models embed directly inside their own output.
|
|
137
|
+
|
|
138
|
+
A few real hosted reasoning models (seen live against Groq's
|
|
139
|
+
``qwen/qwen3.6-27b``) put their entire draft reasoning process inline in
|
|
140
|
+
the same text as the final answer, wrapped in ``<think>`` tags — and
|
|
141
|
+
that draft often mentions the target pattern (e.g. ``"ANSWER: 42"``)
|
|
142
|
+
multiple times before reaching the real final line. A plain
|
|
143
|
+
:class:`RegexAnnotator`'s ``re.search()`` matches the *first*
|
|
144
|
+
occurrence, which is frequently still inside the reasoning, not the
|
|
145
|
+
real answer. This strips the ``<think>`` block(s) first, then takes the
|
|
146
|
+
*last* match in what remains, then (by default) trims trailing
|
|
147
|
+
backtick/parenthetical commentary some models append after the value
|
|
148
|
+
(seen live, e.g. ``"18` (or `$18`, but usually just the number is
|
|
149
|
+
fine.)"``).
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
pattern: str = _MATCH_ALL,
|
|
155
|
+
group: int | str = 0,
|
|
156
|
+
flags: int = 0,
|
|
157
|
+
on_no_match: str = "",
|
|
158
|
+
name: str = "thinking_strip",
|
|
159
|
+
strip_pattern: str = r"<think>.*?</think>",
|
|
160
|
+
trim_trailing_noise: bool = True,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.pattern = pattern
|
|
163
|
+
self.flags = flags
|
|
164
|
+
self._regex = re.compile(pattern, flags)
|
|
165
|
+
self._group = group
|
|
166
|
+
self._on_no_match = on_no_match
|
|
167
|
+
self.name = name
|
|
168
|
+
self.strip_pattern = strip_pattern
|
|
169
|
+
self._strip_regex = re.compile(strip_pattern, re.DOTALL)
|
|
170
|
+
self._trim_trailing_noise = trim_trailing_noise
|
|
171
|
+
|
|
172
|
+
def identity(self) -> dict:
|
|
173
|
+
return {
|
|
174
|
+
"name": self.name, "pattern": self.pattern, "group": self._group,
|
|
175
|
+
"flags": self.flags, "on_no_match": self._on_no_match,
|
|
176
|
+
"strip_pattern": self.strip_pattern,
|
|
177
|
+
"trim_trailing_noise": self._trim_trailing_noise,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def annotate(self, sample: Sample, results: list[Result_]) -> dict[str, Any]:
|
|
181
|
+
text = results[0].text if results else ""
|
|
182
|
+
stripped = self._strip_regex.sub("", text)
|
|
183
|
+
matches = list(self._regex.finditer(stripped))
|
|
184
|
+
if not matches:
|
|
185
|
+
return {"extracted": self._on_no_match, "matched": False, "raw": text}
|
|
186
|
+
value = matches[-1].group(self._group)
|
|
187
|
+
if value is None: # matched, but this particular group didn't participate
|
|
188
|
+
return {"extracted": self._on_no_match, "matched": False, "raw": text}
|
|
189
|
+
if self._trim_trailing_noise:
|
|
190
|
+
value = re.split(r"[`(]", value)[0].strip()
|
|
191
|
+
return {"extracted": value, "matched": True, "raw": text}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@ANNOTATORS.register("llm")
|
|
195
|
+
class LLMAnnotator(Annotator):
|
|
196
|
+
"""Extracts (or transforms) a value from the model output by asking a
|
|
197
|
+
second model to do it — the same model-resolution machinery as
|
|
198
|
+
``LLMJudge``/generation (a spec string like ``"openai:gpt-4o-mini"``,
|
|
199
|
+
resolved lazily, or any object with ``.generate``).
|
|
200
|
+
|
|
201
|
+
Useful where a regex can't do the job: pulling a value out of free-form
|
|
202
|
+
prose, normalizing an answer's phrasing, translating, summarizing, or
|
|
203
|
+
any other extraction that needs judgment rather than a fixed pattern.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
model
|
|
208
|
+
A model spec string or any object with ``generate(list[Request]) ->
|
|
209
|
+
list[Result_]`` — same contract as ``LLMJudge.judge_model``.
|
|
210
|
+
prompt
|
|
211
|
+
User-prompt template. ``{input}``, ``{output}`` (the sample's raw
|
|
212
|
+
model output), ``{expected}`` (alias ``{target}``), ``{context}``,
|
|
213
|
+
and any ``sample.metadata`` key are filled in.
|
|
214
|
+
system_prompt
|
|
215
|
+
Optional system-level instructions, prepended to every call.
|
|
216
|
+
model_args
|
|
217
|
+
Connection-level kwargs forwarded to ``AutoModel.resolve`` for a
|
|
218
|
+
string spec (``api_key``/``api_base``/``device``/``hf_token``) —
|
|
219
|
+
not generation settings, same split as ``LLMJudge``.
|
|
220
|
+
temperature, max_tokens, top_p
|
|
221
|
+
Generation settings for the annotator model's own call — like
|
|
222
|
+
``LLMJudge``, this call doesn't go through an ``Adapter``/
|
|
223
|
+
``RunConfig``, so this is the only way to control them.
|
|
224
|
+
cast, strict
|
|
225
|
+
Same contract as ``RegexAnnotator``: ``cast`` converts the model's
|
|
226
|
+
(stripped) reply via a ``str -> Any`` callable; a failing cast
|
|
227
|
+
degrades to the original string plus ``"cast_failed": True`` unless
|
|
228
|
+
``strict=True``, in which case it raises.
|
|
229
|
+
on_empty
|
|
230
|
+
Fallback value (default ``""``) when the model returns an empty
|
|
231
|
+
reply.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
model: Any = None,
|
|
238
|
+
prompt: str = "Extract the requested value from the OUTPUT below. "
|
|
239
|
+
"Respond with only that value, nothing else.\n"
|
|
240
|
+
"Input: {input}\nOutput: {output}",
|
|
241
|
+
system_prompt: Optional[str] = None,
|
|
242
|
+
name: str = "llm_annotator",
|
|
243
|
+
model_args: Optional[dict[str, Any]] = None,
|
|
244
|
+
temperature: Optional[float] = None,
|
|
245
|
+
max_tokens: Optional[int] = None,
|
|
246
|
+
top_p: Optional[float] = None,
|
|
247
|
+
cast: Optional[Callable[[str], Any]] = None,
|
|
248
|
+
strict: bool = False,
|
|
249
|
+
on_empty: str = "",
|
|
250
|
+
) -> None:
|
|
251
|
+
if model is None:
|
|
252
|
+
raise ValueError(f"{name}: requires a model")
|
|
253
|
+
self.name = name
|
|
254
|
+
self._model_spec = model
|
|
255
|
+
self._model_args = model_args or {}
|
|
256
|
+
self._resolved_model: Any = None
|
|
257
|
+
self.prompt = prompt
|
|
258
|
+
self.system_prompt = system_prompt
|
|
259
|
+
self._gen_params = {
|
|
260
|
+
k: v for k, v in
|
|
261
|
+
{"temperature": temperature, "max_tokens": max_tokens, "top_p": top_p}.items()
|
|
262
|
+
if v is not None
|
|
263
|
+
}
|
|
264
|
+
self._cast = cast
|
|
265
|
+
self._strict = strict
|
|
266
|
+
self._on_empty = on_empty
|
|
267
|
+
|
|
268
|
+
def _model(self) -> Any:
|
|
269
|
+
if self._resolved_model is None:
|
|
270
|
+
spec = self._model_spec
|
|
271
|
+
if isinstance(spec, str):
|
|
272
|
+
from .model import AutoModel
|
|
273
|
+
self._resolved_model = AutoModel.resolve(spec, **self._model_args)
|
|
274
|
+
else:
|
|
275
|
+
self._resolved_model = spec
|
|
276
|
+
return self._resolved_model
|
|
277
|
+
|
|
278
|
+
def _model_identity(self) -> Any:
|
|
279
|
+
spec = self._model_spec
|
|
280
|
+
if isinstance(spec, str):
|
|
281
|
+
return spec
|
|
282
|
+
fn = getattr(spec, "identity", None)
|
|
283
|
+
if callable(fn):
|
|
284
|
+
return fn()
|
|
285
|
+
return getattr(spec, "name", type(spec).__name__)
|
|
286
|
+
|
|
287
|
+
def _render(self, sample: Sample, output: str) -> str:
|
|
288
|
+
fields = {
|
|
289
|
+
"input": getattr(sample, "input_text", None) or str(sample.input),
|
|
290
|
+
"output": output,
|
|
291
|
+
"expected": sample.target or "",
|
|
292
|
+
"target": sample.target or "",
|
|
293
|
+
"context": "\n".join(sample.retrieval_context or []),
|
|
294
|
+
}
|
|
295
|
+
for k, v in (sample.metadata or {}).items():
|
|
296
|
+
fields.setdefault(k, v)
|
|
297
|
+
text = self.prompt
|
|
298
|
+
for key, val in fields.items():
|
|
299
|
+
text = text.replace("{" + key + "}", str(val))
|
|
300
|
+
return text
|
|
301
|
+
|
|
302
|
+
def _assemble(self, user_prompt: str) -> str:
|
|
303
|
+
if self.system_prompt:
|
|
304
|
+
return self.system_prompt + "\n\n" + user_prompt
|
|
305
|
+
return user_prompt
|
|
306
|
+
|
|
307
|
+
def identity(self) -> dict:
|
|
308
|
+
return {
|
|
309
|
+
"name": self.name, "kind": "llm_annotator", "model": self._model_identity(),
|
|
310
|
+
"prompt": self.prompt, "system_prompt": self.system_prompt,
|
|
311
|
+
"gen_params": self._gen_params,
|
|
312
|
+
"cast": getattr(self._cast, "__name__", repr(self._cast)) if self._cast else None,
|
|
313
|
+
"strict": self._strict, "on_empty": self._on_empty,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
def annotate(self, sample: Sample, results: list[Result_]) -> dict[str, Any]:
|
|
317
|
+
from .model import Request
|
|
318
|
+
|
|
319
|
+
text = results[0].text if results else ""
|
|
320
|
+
full = self._assemble(self._render(sample, text))
|
|
321
|
+
model = self._model()
|
|
322
|
+
gen_results = model.generate([Request(prompt=full, params=dict(self._gen_params))])
|
|
323
|
+
reply = ""
|
|
324
|
+
if gen_results and getattr(gen_results[0], "completions", None):
|
|
325
|
+
reply = gen_results[0].completions[0].text or ""
|
|
326
|
+
reply = reply.strip()
|
|
327
|
+
|
|
328
|
+
if not reply:
|
|
329
|
+
return {"extracted": self._on_empty, "matched": False, "raw": reply}
|
|
330
|
+
if self._cast is None:
|
|
331
|
+
return {"extracted": reply, "matched": True, "raw": reply}
|
|
332
|
+
try:
|
|
333
|
+
return {"extracted": self._cast(reply), "matched": True, "raw": reply}
|
|
334
|
+
except (ValueError, TypeError) as e:
|
|
335
|
+
if self._strict:
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"{self.name}: cast {self._cast!r} failed on reply {reply!r}: {e}"
|
|
338
|
+
) from e
|
|
339
|
+
return {"extracted": reply, "matched": True, "raw": reply, "cast_failed": True}
|