agent-learning 0.4.1__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.
- agent_learning/__init__.py +160 -0
- agent_learning/_version.py +3 -0
- agent_learning/capture.py +271 -0
- agent_learning/classifiers/__init__.py +37 -0
- agent_learning/classifiers/base.py +185 -0
- agent_learning/classifiers/router.py +236 -0
- agent_learning/classifiers/scorers/__init__.py +34 -0
- agent_learning/classifiers/scorers/_base.py +162 -0
- agent_learning/classifiers/scorers/adherence.py +26 -0
- agent_learning/classifiers/scorers/completion.py +26 -0
- agent_learning/classifiers/scorers/intent.py +25 -0
- agent_learning/cli.py +386 -0
- agent_learning/config.py +524 -0
- agent_learning/learners/__init__.py +6 -0
- agent_learning/learners/base.py +38 -0
- agent_learning/learners/reinforce.py +153 -0
- agent_learning/metrics/__init__.py +22 -0
- agent_learning/metrics/base.py +233 -0
- agent_learning/metrics/intent_resolution.py +50 -0
- agent_learning/metrics/registry.py +42 -0
- agent_learning/metrics/task_adherence.py +42 -0
- agent_learning/metrics/task_completion.py +54 -0
- agent_learning/policy/__init__.py +7 -0
- agent_learning/policy/base.py +59 -0
- agent_learning/policy/contextual_softmax.py +243 -0
- agent_learning/policy/softmax_bandit.py +157 -0
- agent_learning/py.typed +1 -0
- agent_learning/rewards/__init__.py +6 -0
- agent_learning/rewards/shaping.py +121 -0
- agent_learning/rewards/writer.py +130 -0
- agent_learning/scorers/__init__.py +187 -0
- agent_learning/scorers/base.py +49 -0
- agent_learning/scorers/llm/__init__.py +19 -0
- agent_learning/scorers/llm/_base.py +126 -0
- agent_learning/scorers/llm/adherence.py +21 -0
- agent_learning/scorers/llm/completion.py +21 -0
- agent_learning/scorers/llm/intent.py +21 -0
- agent_learning/scorers/nlp/__init__.py +16 -0
- agent_learning/scorers/nlp/_base.py +99 -0
- agent_learning/scorers/nlp/adherence.py +17 -0
- agent_learning/scorers/nlp/completion.py +17 -0
- agent_learning/scorers/nlp/intent.py +17 -0
- agent_learning/scorers/nlp_text/__init__.py +26 -0
- agent_learning/scorers/nlp_text/_base.py +234 -0
- agent_learning/scorers/nlp_text/adherence.py +94 -0
- agent_learning/scorers/nlp_text/completion.py +91 -0
- agent_learning/scorers/nlp_text/intent.py +59 -0
- agent_learning/scorers/slm/__init__.py +25 -0
- agent_learning/scorers/slm/_base.py +292 -0
- agent_learning/scorers/slm/adherence.py +98 -0
- agent_learning/scorers/slm/completion.py +111 -0
- agent_learning/scorers/slm/intent.py +80 -0
- agent_learning/scorers/stdlib/__init__.py +38 -0
- agent_learning/scorers/stdlib/_text.py +87 -0
- agent_learning/scorers/stdlib/adherence.py +157 -0
- agent_learning/scorers/stdlib/completion.py +117 -0
- agent_learning/scorers/stdlib/intent.py +182 -0
- agent_learning/storage/__init__.py +14 -0
- agent_learning/storage/base.py +156 -0
- agent_learning/storage/cosmos.py +506 -0
- agent_learning/storage/local.py +353 -0
- agent_learning/storage/memory.py +209 -0
- agent_learning/training/__init__.py +5 -0
- agent_learning/training/runner.py +172 -0
- agent_learning/types.py +507 -0
- agent_learning-0.4.1.dist-info/METADATA +82 -0
- agent_learning-0.4.1.dist-info/RECORD +71 -0
- agent_learning-0.4.1.dist-info/WHEEL +5 -0
- agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
- agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
- agent_learning-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Phi-4-mini-instruct ONNX runner + shared scorer base for Tier 3 SLM.
|
|
2
|
+
|
|
3
|
+
The runner lazily loads ``onnxruntime-genai`` and the Phi-4-mini-instruct
|
|
4
|
+
INT4 model from a local directory the first time :meth:`SlmRunner.generate`
|
|
5
|
+
is called. Subsequent calls reuse the cached model and tokenizer instances.
|
|
6
|
+
The runner abstraction also makes the per-scorer prompt building and JSON
|
|
7
|
+
parsing trivially mockable in unit tests via :meth:`set_runner`.
|
|
8
|
+
|
|
9
|
+
Prompt format
|
|
10
|
+
~~~~~~~~~~~~~
|
|
11
|
+
|
|
12
|
+
The runner applies the Phi-3.5 / Phi-4 chat template:
|
|
13
|
+
|
|
14
|
+
.. code-block:: text
|
|
15
|
+
|
|
16
|
+
<|system|>
|
|
17
|
+
{system}<|end|>
|
|
18
|
+
<|user|>
|
|
19
|
+
{user}<|end|>
|
|
20
|
+
<|assistant|>
|
|
21
|
+
|
|
22
|
+
Each subclass of :class:`SlmScorerBase` supplies its own ``system`` and
|
|
23
|
+
``user`` strings; the assistant reply is parsed by
|
|
24
|
+
:func:`parse_verdict` which expects a single JSON object of the form
|
|
25
|
+
``{"verdict": "pass"|"fail", "confidence": <number in [0,1]>}``. The
|
|
26
|
+
parser is permissive about leading or trailing prose because instruction-
|
|
27
|
+
tuned small models occasionally emit a sentence in front of the JSON.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import threading
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Dict, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
from ..base import ScoreResult
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_GENAI_IMPORT_ERROR = (
|
|
43
|
+
"Tier 3 SLM scorers require the [slm] extra. Install with: "
|
|
44
|
+
"pip install agent-learning[slm]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _require_genai() -> Any:
|
|
49
|
+
"""Lazy-import ``onnxruntime_genai``. Raises a friendly error if missing."""
|
|
50
|
+
try:
|
|
51
|
+
import onnxruntime_genai as og # noqa: F401
|
|
52
|
+
except ImportError as exc: # pragma: no cover - exercised via tests/mocks
|
|
53
|
+
raise ImportError(_GENAI_IMPORT_ERROR) from exc
|
|
54
|
+
return og
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ----- model runner --------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SlmRunner:
|
|
61
|
+
"""Thin wrapper around onnxruntime-genai's ``Model``+``Tokenizer``+``Generator``.
|
|
62
|
+
|
|
63
|
+
The wrapper hides the version-dependent generator loop behind a single
|
|
64
|
+
:meth:`generate` method. A test harness can replace the runner with any
|
|
65
|
+
object that implements ``generate(prompt: str, *, max_new_tokens: int,
|
|
66
|
+
temperature: float) -> str``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_shared_lock = threading.Lock()
|
|
70
|
+
_shared_runners: Dict[str, "SlmRunner"] = {}
|
|
71
|
+
|
|
72
|
+
def __init__(self, model_dir: str) -> None:
|
|
73
|
+
if not model_dir:
|
|
74
|
+
raise ValueError("SlmRunner requires a non-empty model_dir.")
|
|
75
|
+
if not os.path.isdir(model_dir):
|
|
76
|
+
raise FileNotFoundError(
|
|
77
|
+
f"SLM model directory not found: {model_dir!r}. "
|
|
78
|
+
"Download Phi-4-mini-instruct ONNX (INT4) to this path or "
|
|
79
|
+
"set AGENT_LEARNING_SLM_MODEL_DIR to its location."
|
|
80
|
+
)
|
|
81
|
+
og = _require_genai()
|
|
82
|
+
self._og = og
|
|
83
|
+
self._model = og.Model(model_dir)
|
|
84
|
+
self._tokenizer = og.Tokenizer(self._model)
|
|
85
|
+
self.model_dir = model_dir
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def get_shared(cls, model_dir: str) -> "SlmRunner":
|
|
89
|
+
"""Return a process-wide cached runner for ``model_dir``.
|
|
90
|
+
|
|
91
|
+
Loading the 3.8 B Phi-4-mini-instruct ONNX model from cold can take
|
|
92
|
+
several seconds and consume ~2.5 GB of RAM. Sharing one runner across
|
|
93
|
+
the three Tier 3 scorers keeps inference hot and avoids the
|
|
94
|
+
re-loading cost.
|
|
95
|
+
"""
|
|
96
|
+
with cls._shared_lock:
|
|
97
|
+
runner = cls._shared_runners.get(model_dir)
|
|
98
|
+
if runner is None:
|
|
99
|
+
runner = cls(model_dir)
|
|
100
|
+
cls._shared_runners[model_dir] = runner
|
|
101
|
+
return runner
|
|
102
|
+
|
|
103
|
+
def generate(
|
|
104
|
+
self,
|
|
105
|
+
prompt: str,
|
|
106
|
+
*,
|
|
107
|
+
max_new_tokens: int = 64,
|
|
108
|
+
temperature: float = 0.0,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Run greedy decoding on ``prompt`` and return the assistant text."""
|
|
111
|
+
og = self._og
|
|
112
|
+
tokenizer = self._tokenizer
|
|
113
|
+
input_tokens = tokenizer.encode(prompt)
|
|
114
|
+
input_length = len(input_tokens)
|
|
115
|
+
params = og.GeneratorParams(self._model)
|
|
116
|
+
params.set_search_options(
|
|
117
|
+
max_length=input_length + int(max_new_tokens),
|
|
118
|
+
do_sample=False,
|
|
119
|
+
temperature=float(max(temperature, 0.0)),
|
|
120
|
+
)
|
|
121
|
+
# API surface drift across onnxruntime-genai versions: 0.4 wants
|
|
122
|
+
# params.input_ids, 0.5+ uses generator.append_tokens. Try both.
|
|
123
|
+
try:
|
|
124
|
+
params.input_ids = input_tokens
|
|
125
|
+
except (AttributeError, TypeError): # pragma: no cover - version drift
|
|
126
|
+
pass
|
|
127
|
+
generator = og.Generator(self._model, params)
|
|
128
|
+
append = getattr(generator, "append_tokens", None)
|
|
129
|
+
if callable(append): # pragma: no cover - version drift
|
|
130
|
+
try:
|
|
131
|
+
append(input_tokens)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
# Greedy decoding loop.
|
|
135
|
+
while not generator.is_done():
|
|
136
|
+
compute_logits = getattr(generator, "compute_logits", None)
|
|
137
|
+
if callable(compute_logits): # pragma: no cover - version drift
|
|
138
|
+
compute_logits()
|
|
139
|
+
generator.generate_next_token()
|
|
140
|
+
get_sequence = getattr(generator, "get_sequence", None)
|
|
141
|
+
if callable(get_sequence):
|
|
142
|
+
full = get_sequence(0)
|
|
143
|
+
else: # pragma: no cover - very old version
|
|
144
|
+
full = generator.get_next_tokens()
|
|
145
|
+
# Strip the prompt prefix.
|
|
146
|
+
output_tokens = list(full)[input_length:]
|
|
147
|
+
text = tokenizer.decode(output_tokens)
|
|
148
|
+
return text
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ----- prompt rendering ----------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_CHAT_TEMPLATE = (
|
|
155
|
+
"<|system|>\n{system}<|end|>\n"
|
|
156
|
+
"<|user|>\n{user}<|end|>\n"
|
|
157
|
+
"<|assistant|>\n"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_chat_prompt(*, system: str, user: str) -> str:
|
|
162
|
+
"""Render a Phi-3.5 / Phi-4 chat-style prompt."""
|
|
163
|
+
return _CHAT_TEMPLATE.format(system=system.strip(), user=user.strip())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----- verdict parsing -----------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_JSON_RE = re.compile(r"\{[^{}]*\}", re.DOTALL)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_verdict(text: str) -> Tuple[float, Dict[str, Any]]:
|
|
173
|
+
"""Extract ``(probability_pass, raw)`` from the model's reply.
|
|
174
|
+
|
|
175
|
+
Looks for the first JSON object in ``text``. Falls back to a token
|
|
176
|
+
heuristic if no JSON is recoverable. The function never raises on a
|
|
177
|
+
malformed reply; an unrecoverable parse degrades to a neutral 0.5
|
|
178
|
+
probability so the caller can still emit a score.
|
|
179
|
+
"""
|
|
180
|
+
raw: Dict[str, Any] = {"text": text}
|
|
181
|
+
if not text:
|
|
182
|
+
return 0.5, raw
|
|
183
|
+
# First, try to parse as raw JSON.
|
|
184
|
+
candidate: Optional[Dict[str, Any]] = None
|
|
185
|
+
try:
|
|
186
|
+
loaded = json.loads(text)
|
|
187
|
+
if isinstance(loaded, dict):
|
|
188
|
+
candidate = loaded
|
|
189
|
+
except (ValueError, TypeError):
|
|
190
|
+
match = _JSON_RE.search(text)
|
|
191
|
+
if match is not None:
|
|
192
|
+
try:
|
|
193
|
+
loaded = json.loads(match.group(0))
|
|
194
|
+
if isinstance(loaded, dict):
|
|
195
|
+
candidate = loaded
|
|
196
|
+
except (ValueError, TypeError):
|
|
197
|
+
candidate = None
|
|
198
|
+
if candidate is not None:
|
|
199
|
+
verdict = str(candidate.get("verdict", "")).strip().lower()
|
|
200
|
+
try:
|
|
201
|
+
confidence = float(candidate.get("confidence", 0.5))
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
confidence = 0.5
|
|
204
|
+
confidence = max(0.0, min(1.0, confidence))
|
|
205
|
+
raw["verdict"] = verdict
|
|
206
|
+
raw["confidence"] = confidence
|
|
207
|
+
if verdict.startswith("pass"):
|
|
208
|
+
return confidence, raw
|
|
209
|
+
if verdict.startswith("fail"):
|
|
210
|
+
return 1.0 - confidence, raw
|
|
211
|
+
# Token-level fallback. Look for an isolated "pass" or "fail" word.
|
|
212
|
+
lowered = text.lower()
|
|
213
|
+
pass_hit = re.search(r"\bpass\b", lowered)
|
|
214
|
+
fail_hit = re.search(r"\bfail\b", lowered)
|
|
215
|
+
if pass_hit and (not fail_hit or pass_hit.start() < fail_hit.start()):
|
|
216
|
+
raw["heuristic"] = "pass"
|
|
217
|
+
return 0.75, raw
|
|
218
|
+
if fail_hit:
|
|
219
|
+
raw["heuristic"] = "fail"
|
|
220
|
+
return 0.25, raw
|
|
221
|
+
raw["heuristic"] = "neutral"
|
|
222
|
+
return 0.5, raw
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ----- shared base ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class SlmScorerBase:
|
|
230
|
+
"""Shared scoring scaffolding for Tier 3 small-language-model scorers."""
|
|
231
|
+
|
|
232
|
+
name: str = "slm"
|
|
233
|
+
pass_threshold: float = 0.5
|
|
234
|
+
max_new_tokens: int = 64
|
|
235
|
+
temperature: float = 0.0
|
|
236
|
+
model_dir: str = ""
|
|
237
|
+
runner: Optional[SlmRunner] = None
|
|
238
|
+
system_prompt: str = field(default="", repr=False)
|
|
239
|
+
|
|
240
|
+
def _get_runner(self) -> SlmRunner:
|
|
241
|
+
if self.runner is not None:
|
|
242
|
+
return self.runner
|
|
243
|
+
if not self.model_dir:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"{self.name}: model_dir must be set to load the SLM"
|
|
246
|
+
)
|
|
247
|
+
self.runner = SlmRunner.get_shared(self.model_dir)
|
|
248
|
+
return self.runner
|
|
249
|
+
|
|
250
|
+
def _build_user_prompt(self, **inputs: Any) -> str: # pragma: no cover - override
|
|
251
|
+
raise NotImplementedError
|
|
252
|
+
|
|
253
|
+
def _validate(self, **inputs: Any) -> None: # pragma: no cover - override
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
def _score_from_inputs(self, **inputs: Any) -> ScoreResult:
|
|
257
|
+
self._validate(**inputs)
|
|
258
|
+
prompt = render_chat_prompt(
|
|
259
|
+
system=self.system_prompt,
|
|
260
|
+
user=self._build_user_prompt(**inputs),
|
|
261
|
+
)
|
|
262
|
+
runner = self._get_runner()
|
|
263
|
+
text = runner.generate(
|
|
264
|
+
prompt,
|
|
265
|
+
max_new_tokens=self.max_new_tokens,
|
|
266
|
+
temperature=self.temperature,
|
|
267
|
+
)
|
|
268
|
+
probability, raw = parse_verdict(text)
|
|
269
|
+
if probability >= self.pass_threshold:
|
|
270
|
+
label = "pass"
|
|
271
|
+
confidence = probability
|
|
272
|
+
else:
|
|
273
|
+
label = "fail"
|
|
274
|
+
confidence = 1.0 - probability
|
|
275
|
+
return ScoreResult(
|
|
276
|
+
label=label,
|
|
277
|
+
confidence=confidence,
|
|
278
|
+
normalized=probability,
|
|
279
|
+
features={
|
|
280
|
+
"probability": probability,
|
|
281
|
+
"model_dir": self.model_dir,
|
|
282
|
+
"raw": raw,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = [
|
|
288
|
+
"SlmScorerBase",
|
|
289
|
+
"SlmRunner",
|
|
290
|
+
"parse_verdict",
|
|
291
|
+
"render_chat_prompt",
|
|
292
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tier 3 SLM adherence scorer.
|
|
2
|
+
|
|
3
|
+
Asks Phi-4-mini-instruct whether the response adheres to the supplied
|
|
4
|
+
contract (required substrings, forbidden substrings, length bounds, JSON
|
|
5
|
+
shape). The scorer is read-only: it never modifies the contract or the
|
|
6
|
+
response, only scores them against each other.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from ..base import ScoreResult
|
|
16
|
+
from ._base import SlmRunner, SlmScorerBase
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SYSTEM_PROMPT = (
|
|
20
|
+
"You are a strict evaluator that checks whether an agent's response "
|
|
21
|
+
"complies with a structural contract. Reply only with a single-line "
|
|
22
|
+
"JSON object: {\"verdict\": \"pass\" | \"fail\", \"confidence\": "
|
|
23
|
+
"<number in [0, 1]>}. Output no other text."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_contract(contract: Optional[Dict[str, Any]]) -> str:
|
|
28
|
+
if not contract:
|
|
29
|
+
return (
|
|
30
|
+
"(no explicit contract; the response is acceptable as long "
|
|
31
|
+
"as it is on-topic and free of obvious violations)"
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
return json.dumps(contract, ensure_ascii=False, sort_keys=True)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return str(contract)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_USER_PROMPT_TEMPLATE = (
|
|
40
|
+
"CONTRACT:\n{contract}\n\nRESPONSE:\n{response}\n\n"
|
|
41
|
+
"Does the response satisfy the contract? Reply with the JSON verdict "
|
|
42
|
+
"only."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SlmAdherenceScorer(SlmScorerBase):
|
|
48
|
+
"""Phi-4-mini-instruct adherence scorer."""
|
|
49
|
+
|
|
50
|
+
name: str = "adherence"
|
|
51
|
+
system_prompt: str = field(default=_SYSTEM_PROMPT, repr=False)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load_or_default(
|
|
55
|
+
cls,
|
|
56
|
+
model_dir: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
pass_threshold: float = 0.5,
|
|
59
|
+
max_new_tokens: int = 64,
|
|
60
|
+
temperature: float = 0.0,
|
|
61
|
+
runner: Optional[SlmRunner] = None,
|
|
62
|
+
) -> "SlmAdherenceScorer":
|
|
63
|
+
return cls(
|
|
64
|
+
model_dir=model_dir or "",
|
|
65
|
+
pass_threshold=pass_threshold,
|
|
66
|
+
max_new_tokens=max_new_tokens,
|
|
67
|
+
temperature=temperature,
|
|
68
|
+
runner=runner,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _validate(self, **inputs: object) -> None:
|
|
72
|
+
if inputs.get("response") is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"adherence SLM scorer requires a response"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _build_user_prompt(self, **inputs: object) -> str:
|
|
78
|
+
return _USER_PROMPT_TEMPLATE.format(
|
|
79
|
+
contract=_format_contract(inputs.get("contract")),
|
|
80
|
+
response=str(inputs["response"]).strip(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def score(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
response: Optional[str] = None,
|
|
87
|
+
contract: Optional[Dict[str, Any]] = None,
|
|
88
|
+
query: Optional[str] = None,
|
|
89
|
+
**_: object,
|
|
90
|
+
) -> ScoreResult:
|
|
91
|
+
return self._score_from_inputs(
|
|
92
|
+
response=response,
|
|
93
|
+
contract=contract,
|
|
94
|
+
query=query,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["SlmAdherenceScorer"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tier 3 SLM completion scorer.
|
|
2
|
+
|
|
3
|
+
Asks Phi-4-mini-instruct whether every required token / sub-task is
|
|
4
|
+
present in the response. The expected tokens can be supplied either
|
|
5
|
+
through ``expected_tokens`` directly or through ``contract.completion_tokens``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from ..base import ScoreResult
|
|
14
|
+
from ._base import SlmRunner, SlmScorerBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SYSTEM_PROMPT = (
|
|
18
|
+
"You are a strict evaluator that checks whether an agent's response "
|
|
19
|
+
"covers every required item. Reply only with a single-line JSON "
|
|
20
|
+
"object: {\"verdict\": \"pass\" | \"fail\", \"confidence\": <number "
|
|
21
|
+
"in [0, 1]>}. Output no other text."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_USER_PROMPT_TEMPLATE = (
|
|
26
|
+
"REQUIRED ITEMS (every one must be covered):\n{items}\n\n"
|
|
27
|
+
"RESPONSE:\n{response}\n\n"
|
|
28
|
+
"Does the response cover every required item? Reply with the JSON "
|
|
29
|
+
"verdict only."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_targets(
|
|
34
|
+
expected_tokens: Optional[Sequence[str]],
|
|
35
|
+
contract: Optional[Dict[str, Any]],
|
|
36
|
+
) -> List[str]:
|
|
37
|
+
if expected_tokens:
|
|
38
|
+
return [str(t).strip() for t in expected_tokens if str(t).strip()]
|
|
39
|
+
if contract:
|
|
40
|
+
tokens = contract.get("completion_tokens")
|
|
41
|
+
if isinstance(tokens, Sequence) and not isinstance(tokens, (str, bytes)):
|
|
42
|
+
return [str(t).strip() for t in tokens if str(t).strip()]
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SlmCompletionScorer(SlmScorerBase):
|
|
48
|
+
"""Phi-4-mini-instruct completion scorer."""
|
|
49
|
+
|
|
50
|
+
name: str = "completion"
|
|
51
|
+
system_prompt: str = field(default=_SYSTEM_PROMPT, repr=False)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load_or_default(
|
|
55
|
+
cls,
|
|
56
|
+
model_dir: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
pass_threshold: float = 0.5,
|
|
59
|
+
max_new_tokens: int = 64,
|
|
60
|
+
temperature: float = 0.0,
|
|
61
|
+
runner: Optional[SlmRunner] = None,
|
|
62
|
+
) -> "SlmCompletionScorer":
|
|
63
|
+
return cls(
|
|
64
|
+
model_dir=model_dir or "",
|
|
65
|
+
pass_threshold=pass_threshold,
|
|
66
|
+
max_new_tokens=max_new_tokens,
|
|
67
|
+
temperature=temperature,
|
|
68
|
+
runner=runner,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _validate(self, **inputs: object) -> None:
|
|
72
|
+
if inputs.get("response") is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"completion SLM scorer requires a response"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _build_user_prompt(self, **inputs: object) -> str:
|
|
78
|
+
targets = _resolve_targets(
|
|
79
|
+
inputs.get("expected_tokens"), # type: ignore[arg-type]
|
|
80
|
+
inputs.get("contract"), # type: ignore[arg-type]
|
|
81
|
+
)
|
|
82
|
+
if targets:
|
|
83
|
+
items_str = "\n".join(f"- {t}" for t in targets)
|
|
84
|
+
else:
|
|
85
|
+
items_str = (
|
|
86
|
+
"(no explicit checklist; treat the response as complete "
|
|
87
|
+
"if it answers the apparent task)"
|
|
88
|
+
)
|
|
89
|
+
return _USER_PROMPT_TEMPLATE.format(
|
|
90
|
+
items=items_str,
|
|
91
|
+
response=str(inputs["response"]).strip(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def score(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
response: Optional[str] = None,
|
|
98
|
+
expected_tokens: Optional[Sequence[str]] = None,
|
|
99
|
+
contract: Optional[Dict[str, Any]] = None,
|
|
100
|
+
query: Optional[str] = None,
|
|
101
|
+
**_: object,
|
|
102
|
+
) -> ScoreResult:
|
|
103
|
+
return self._score_from_inputs(
|
|
104
|
+
response=response,
|
|
105
|
+
expected_tokens=expected_tokens,
|
|
106
|
+
contract=contract,
|
|
107
|
+
query=query,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["SlmCompletionScorer"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tier 3 SLM intent scorer.
|
|
2
|
+
|
|
3
|
+
Asks Phi-4-mini-instruct whether the model's response actually addresses
|
|
4
|
+
the user's question. The scorer requires both ``query`` and ``response``;
|
|
5
|
+
this matches the intent-evaluator surface area used by Tiers 1, 2, and 4.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..base import ScoreResult
|
|
14
|
+
from ._base import SlmRunner, SlmScorerBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SYSTEM_PROMPT = (
|
|
18
|
+
"You are a strict evaluator that checks whether an agent's response "
|
|
19
|
+
"addresses the user's question. Reply only with a single-line JSON "
|
|
20
|
+
"object: {\"verdict\": \"pass\" | \"fail\", \"confidence\": <number "
|
|
21
|
+
"in [0, 1]>}. Output no other text."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_USER_PROMPT_TEMPLATE = (
|
|
25
|
+
"QUERY:\n{query}\n\nRESPONSE:\n{response}\n\n"
|
|
26
|
+
"Does the response actually address the query? Reply with the JSON "
|
|
27
|
+
"verdict only."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SlmIntentScorer(SlmScorerBase):
|
|
33
|
+
"""Phi-4-mini-instruct intent scorer."""
|
|
34
|
+
|
|
35
|
+
name: str = "intent"
|
|
36
|
+
system_prompt: str = field(default=_SYSTEM_PROMPT, repr=False)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def load_or_default(
|
|
40
|
+
cls,
|
|
41
|
+
model_dir: Optional[str] = None,
|
|
42
|
+
*,
|
|
43
|
+
pass_threshold: float = 0.5,
|
|
44
|
+
max_new_tokens: int = 64,
|
|
45
|
+
temperature: float = 0.0,
|
|
46
|
+
runner: Optional[SlmRunner] = None,
|
|
47
|
+
) -> "SlmIntentScorer":
|
|
48
|
+
return cls(
|
|
49
|
+
model_dir=model_dir or "",
|
|
50
|
+
pass_threshold=pass_threshold,
|
|
51
|
+
max_new_tokens=max_new_tokens,
|
|
52
|
+
temperature=temperature,
|
|
53
|
+
runner=runner,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _validate(self, **inputs: object) -> None:
|
|
57
|
+
query = inputs.get("query")
|
|
58
|
+
response = inputs.get("response")
|
|
59
|
+
if not query or not response:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"intent SLM scorer requires both query and response"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _build_user_prompt(self, **inputs: object) -> str:
|
|
65
|
+
return _USER_PROMPT_TEMPLATE.format(
|
|
66
|
+
query=str(inputs["query"]).strip(),
|
|
67
|
+
response=str(inputs["response"]).strip(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def score(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
query: Optional[str] = None,
|
|
74
|
+
response: Optional[str] = None,
|
|
75
|
+
**_: object,
|
|
76
|
+
) -> ScoreResult:
|
|
77
|
+
return self._score_from_inputs(query=query, response=response)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["SlmIntentScorer"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tier 1 stdlib scorers (zero external dependencies).
|
|
2
|
+
|
|
3
|
+
These scorers score the rendered response text directly, using only
|
|
4
|
+
the Python standard library:
|
|
5
|
+
|
|
6
|
+
- :class:`StdlibIntentScorer` is a bag-of-words binary classifier with
|
|
7
|
+
hashing-trick features and the SDK's pure-Python logistic regression
|
|
8
|
+
primitive. It returns an unfitted ``pass``-prior policy until fitted.
|
|
9
|
+
- :class:`StdlibAdherenceScorer` is a deterministic rule engine over
|
|
10
|
+
required substrings, forbidden substrings, length bounds, and an
|
|
11
|
+
optional JSON-shape requirement. No training is required.
|
|
12
|
+
- :class:`StdlibCompletionScorer` is a token-coverage scorer that
|
|
13
|
+
measures how many of the expected tokens the response contains.
|
|
14
|
+
No training is required.
|
|
15
|
+
|
|
16
|
+
The wire-up contract is the same as every other scorer backend in the
|
|
17
|
+
SDK: each scorer exposes ``score(**kwargs) -> ScoreResult`` and ignores
|
|
18
|
+
extra keyword arguments. The three scorers expect:
|
|
19
|
+
|
|
20
|
+
- intent: ``query: str``, ``response: str``
|
|
21
|
+
- adherence: ``response: str``, ``contract: dict``
|
|
22
|
+
- completion: ``response: str``, ``expected_tokens: list[str]``
|
|
23
|
+
|
|
24
|
+
The factory in :mod:`agent_learning.scorers` routes ``tier="stdlib"``
|
|
25
|
+
to this package.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from .adherence import StdlibAdherenceScorer
|
|
31
|
+
from .completion import StdlibCompletionScorer
|
|
32
|
+
from .intent import StdlibIntentScorer
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"StdlibAdherenceScorer",
|
|
36
|
+
"StdlibCompletionScorer",
|
|
37
|
+
"StdlibIntentScorer",
|
|
38
|
+
]
|