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.
Files changed (81) hide show
  1. auditkit/README.md +99 -0
  2. auditkit/__init__.py +177 -0
  3. auditkit/__main__.py +3 -0
  4. auditkit/_bootstrap.py +77 -0
  5. auditkit/_identity_guard.py +99 -0
  6. auditkit/adapter.py +264 -0
  7. auditkit/annotator.py +339 -0
  8. auditkit/api.py +502 -0
  9. auditkit/assets/auditkit_logo.png +0 -0
  10. auditkit/cache.py +47 -0
  11. auditkit/cli.py +417 -0
  12. auditkit/comparison.py +563 -0
  13. auditkit/diff.py +265 -0
  14. auditkit/errors.py +54 -0
  15. auditkit/evaluator.py +20 -0
  16. auditkit/experiment.py +145 -0
  17. auditkit/hf_publish.py +262 -0
  18. auditkit/lmeval_engine.py +550 -0
  19. auditkit/loaders.py +121 -0
  20. auditkit/logs.py +18 -0
  21. auditkit/metric.py +199 -0
  22. auditkit/metrics/README.md +15 -0
  23. auditkit/metrics/__init__.py +0 -0
  24. auditkit/metrics/code.py +222 -0
  25. auditkit/metrics/embedding.py +131 -0
  26. auditkit/metrics/encoder_judge.py +423 -0
  27. auditkit/metrics/generation.py +331 -0
  28. auditkit/metrics/guard.py +412 -0
  29. auditkit/metrics/hallucination.py +45 -0
  30. auditkit/metrics/judge.py +547 -0
  31. auditkit/metrics/pairwise.py +153 -0
  32. auditkit/metrics/perf.py +53 -0
  33. auditkit/metrics/rag.py +149 -0
  34. auditkit/metrics/security.py +64 -0
  35. auditkit/metrics/toxicity.py +238 -0
  36. auditkit/model/README.md +16 -0
  37. auditkit/model/__init__.py +485 -0
  38. auditkit/model/anthropic.py +94 -0
  39. auditkit/model/api_gen.py +133 -0
  40. auditkit/model/groq_gen.py +121 -0
  41. auditkit/model/hf_gen.py +385 -0
  42. auditkit/model/lexsi.py +155 -0
  43. auditkit/model/litellm_gen.py +65 -0
  44. auditkit/model/openai.py +90 -0
  45. auditkit/model/openrouter_gen.py +152 -0
  46. auditkit/model/vllm_gen.py +316 -0
  47. auditkit/model_compare.py +655 -0
  48. auditkit/redteam/README.md +9 -0
  49. auditkit/redteam/__init__.py +26 -0
  50. auditkit/redteam/detector.py +37 -0
  51. auditkit/redteam/detectors/README.md +5 -0
  52. auditkit/redteam/detectors/builtin.py +126 -0
  53. auditkit/redteam/probe.py +39 -0
  54. auditkit/redteam/probes/README.md +5 -0
  55. auditkit/redteam/probes/builtin.py +85 -0
  56. auditkit/redteam/runner.py +206 -0
  57. auditkit/registry.py +65 -0
  58. auditkit/report.py +278 -0
  59. auditkit/report_format.py +52 -0
  60. auditkit/router.py +54 -0
  61. auditkit/runner.py +575 -0
  62. auditkit/runspec.py +159 -0
  63. auditkit/sample.py +40 -0
  64. auditkit/scenario.py +88 -0
  65. auditkit/scenarios/README.md +10 -0
  66. auditkit/scenarios/__init__.py +4 -0
  67. auditkit/scenarios/arc.py +33 -0
  68. auditkit/scenarios/gsm8k.py +32 -0
  69. auditkit/scenarios/hellaswag.py +33 -0
  70. auditkit/scenarios/humaneval.py +32 -0
  71. auditkit/scenarios/mmlu.py +34 -0
  72. auditkit/scenarios/truthfulqa.py +33 -0
  73. auditkit/score.py +165 -0
  74. auditkit/scorers.py +117 -0
  75. auditkit/scoring.py +79 -0
  76. auditkit/types.py +69 -0
  77. auditkit-1.0.0.dist-info/METADATA +396 -0
  78. auditkit-1.0.0.dist-info/RECORD +81 -0
  79. auditkit-1.0.0.dist-info/WHEEL +4 -0
  80. auditkit-1.0.0.dist-info/entry_points.txt +2 -0
  81. 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}