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,234 @@
|
|
|
1
|
+
"""Shared TF-IDF + scikit-learn logistic-regression pipeline for the
|
|
2
|
+
Tier 2 NLP text scorers.
|
|
3
|
+
|
|
4
|
+
The class wraps three concrete sub-classes (intent / adherence /
|
|
5
|
+
completion). It defers the scikit-learn import to first use so the
|
|
6
|
+
package stays importable without the ``[nlp]`` extra installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from ..base import ScoreResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SKLEARN_IMPORT_ERROR = (
|
|
20
|
+
"Tier 2 NLP text scorers require the [nlp] extra. Install with: "
|
|
21
|
+
"pip install agent-learning[nlp]"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require_sklearn() -> Tuple[Any, Any]:
|
|
26
|
+
"""Lazy-import scikit-learn. Raises a friendly error if missing."""
|
|
27
|
+
try:
|
|
28
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
29
|
+
from sklearn.linear_model import LogisticRegression
|
|
30
|
+
except ImportError as exc: # pragma: no cover - exercised via tests/mocks
|
|
31
|
+
raise ImportError(_SKLEARN_IMPORT_ERROR) from exc
|
|
32
|
+
return TfidfVectorizer, LogisticRegression
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_joblib() -> Any:
|
|
36
|
+
try:
|
|
37
|
+
import joblib
|
|
38
|
+
except ImportError as exc: # pragma: no cover
|
|
39
|
+
raise ImportError(_SKLEARN_IMPORT_ERROR) from exc
|
|
40
|
+
return joblib
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _join_text(*parts: Optional[str]) -> str:
|
|
44
|
+
"""Join non-empty text parts with ``" [SEP] "``."""
|
|
45
|
+
cleaned = [p.strip() for p in parts if p]
|
|
46
|
+
return " [SEP] ".join(cleaned)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _NlpTextScorerBase:
|
|
51
|
+
"""Base class for the three Tier 2 NLP text scorers.
|
|
52
|
+
|
|
53
|
+
Sub-classes override :attr:`name` and :meth:`_pair_text` to control
|
|
54
|
+
which inputs feed the vectorizer (intent uses query+response;
|
|
55
|
+
adherence and completion use response only).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str = "nlp_text"
|
|
59
|
+
pass_threshold: float = 0.5
|
|
60
|
+
max_features: int = 20000
|
|
61
|
+
ngram_min: int = 1
|
|
62
|
+
ngram_max: int = 2
|
|
63
|
+
C: float = 1.0
|
|
64
|
+
min_df: int = 1
|
|
65
|
+
vectorizer: Any = None # sklearn TfidfVectorizer or None
|
|
66
|
+
classifier: Any = None # sklearn LogisticRegression or None
|
|
67
|
+
|
|
68
|
+
# ----- inputs -----
|
|
69
|
+
def _pair_text(self, *, query: Optional[str], response: str) -> str:
|
|
70
|
+
return _join_text(query, response)
|
|
71
|
+
|
|
72
|
+
# ----- training -----
|
|
73
|
+
def fit(self, training_rows: Sequence[Dict[str, Any]]) -> "_NlpTextScorerBase":
|
|
74
|
+
"""Fit TF-IDF + logistic regression on labeled rows.
|
|
75
|
+
|
|
76
|
+
Each row must look like ``{"query": str?, "response": str,
|
|
77
|
+
"label": 0|1}``. Rows missing ``response`` or ``label`` are
|
|
78
|
+
skipped. When fewer than two distinct labels are seen the
|
|
79
|
+
scorer stays unfitted.
|
|
80
|
+
"""
|
|
81
|
+
TfidfVectorizer, LogisticRegression = _require_sklearn()
|
|
82
|
+
texts: List[str] = []
|
|
83
|
+
labels: List[int] = []
|
|
84
|
+
for row in training_rows:
|
|
85
|
+
if not isinstance(row, dict):
|
|
86
|
+
continue
|
|
87
|
+
response = row.get("response")
|
|
88
|
+
label = row.get("label")
|
|
89
|
+
if not response or label is None:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
lbl = int(label)
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
continue
|
|
95
|
+
texts.append(self._pair_text(query=row.get("query"), response=str(response)))
|
|
96
|
+
labels.append(1 if lbl > 0 else 0)
|
|
97
|
+
if len(set(labels)) < 2:
|
|
98
|
+
self.vectorizer = None
|
|
99
|
+
self.classifier = None
|
|
100
|
+
return self
|
|
101
|
+
self.vectorizer = TfidfVectorizer(
|
|
102
|
+
max_features=self.max_features,
|
|
103
|
+
ngram_range=(self.ngram_min, self.ngram_max),
|
|
104
|
+
min_df=self.min_df,
|
|
105
|
+
lowercase=True,
|
|
106
|
+
)
|
|
107
|
+
X = self.vectorizer.fit_transform(texts)
|
|
108
|
+
self.classifier = LogisticRegression(
|
|
109
|
+
C=self.C,
|
|
110
|
+
max_iter=1000,
|
|
111
|
+
solver="liblinear",
|
|
112
|
+
)
|
|
113
|
+
self.classifier.fit(X, labels)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
# ----- prediction -----
|
|
117
|
+
@property
|
|
118
|
+
def fitted(self) -> bool:
|
|
119
|
+
return self.vectorizer is not None and self.classifier is not None
|
|
120
|
+
|
|
121
|
+
def _predict_probability(
|
|
122
|
+
self, *, query: Optional[str], response: str
|
|
123
|
+
) -> float:
|
|
124
|
+
if not self.fitted:
|
|
125
|
+
return float(self.pass_threshold)
|
|
126
|
+
text = self._pair_text(query=query, response=response)
|
|
127
|
+
X = self.vectorizer.transform([text])
|
|
128
|
+
proba = self.classifier.predict_proba(X)[0]
|
|
129
|
+
# Find the index of the positive class (label == 1).
|
|
130
|
+
classes = list(self.classifier.classes_)
|
|
131
|
+
if 1 in classes:
|
|
132
|
+
idx = classes.index(1)
|
|
133
|
+
else:
|
|
134
|
+
# Degenerate single-class fit; fall back to the last column.
|
|
135
|
+
idx = len(classes) - 1
|
|
136
|
+
return float(proba[idx])
|
|
137
|
+
|
|
138
|
+
def _to_score(
|
|
139
|
+
self,
|
|
140
|
+
probability: float,
|
|
141
|
+
*,
|
|
142
|
+
features: Optional[Dict[str, Any]] = None,
|
|
143
|
+
) -> ScoreResult:
|
|
144
|
+
if probability >= self.pass_threshold:
|
|
145
|
+
label = "pass"
|
|
146
|
+
confidence = probability
|
|
147
|
+
else:
|
|
148
|
+
label = "fail"
|
|
149
|
+
confidence = 1.0 - probability
|
|
150
|
+
feat: Dict[str, Any] = {
|
|
151
|
+
"probability": probability,
|
|
152
|
+
"fitted": self.fitted,
|
|
153
|
+
}
|
|
154
|
+
if features:
|
|
155
|
+
feat.update(features)
|
|
156
|
+
return ScoreResult(
|
|
157
|
+
label=label,
|
|
158
|
+
confidence=confidence,
|
|
159
|
+
normalized=probability,
|
|
160
|
+
features=feat,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ----- snapshots -----
|
|
164
|
+
def to_snapshot(self) -> Dict[str, Any]:
|
|
165
|
+
"""Return a JSON-serialisable header. The fitted sklearn state
|
|
166
|
+
is dumped separately to a joblib file.
|
|
167
|
+
"""
|
|
168
|
+
return {
|
|
169
|
+
"name": self.name,
|
|
170
|
+
"pass_threshold": float(self.pass_threshold),
|
|
171
|
+
"max_features": int(self.max_features),
|
|
172
|
+
"ngram_min": int(self.ngram_min),
|
|
173
|
+
"ngram_max": int(self.ngram_max),
|
|
174
|
+
"C": float(self.C),
|
|
175
|
+
"min_df": int(self.min_df),
|
|
176
|
+
"fitted": self.fitted,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def save(self, snapshot_dir: str) -> str:
|
|
180
|
+
os.makedirs(snapshot_dir, exist_ok=True)
|
|
181
|
+
header_path = os.path.join(snapshot_dir, f"{self.name}.nlp_text.json")
|
|
182
|
+
with open(header_path, "w", encoding="utf-8") as fh:
|
|
183
|
+
json.dump(self.to_snapshot(), fh)
|
|
184
|
+
if self.fitted:
|
|
185
|
+
joblib = _require_joblib()
|
|
186
|
+
blob_path = os.path.join(
|
|
187
|
+
snapshot_dir, f"{self.name}.nlp_text.joblib"
|
|
188
|
+
)
|
|
189
|
+
joblib.dump(
|
|
190
|
+
{
|
|
191
|
+
"vectorizer": self.vectorizer,
|
|
192
|
+
"classifier": self.classifier,
|
|
193
|
+
},
|
|
194
|
+
blob_path,
|
|
195
|
+
)
|
|
196
|
+
return header_path
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _load_into(
|
|
200
|
+
cls,
|
|
201
|
+
instance: "_NlpTextScorerBase",
|
|
202
|
+
snapshot_dir: Optional[str],
|
|
203
|
+
) -> "_NlpTextScorerBase":
|
|
204
|
+
if not snapshot_dir:
|
|
205
|
+
return instance
|
|
206
|
+
header_path = os.path.join(
|
|
207
|
+
snapshot_dir, f"{instance.name}.nlp_text.json"
|
|
208
|
+
)
|
|
209
|
+
blob_path = os.path.join(
|
|
210
|
+
snapshot_dir, f"{instance.name}.nlp_text.joblib"
|
|
211
|
+
)
|
|
212
|
+
if not os.path.isfile(header_path):
|
|
213
|
+
return instance
|
|
214
|
+
with open(header_path, "r", encoding="utf-8") as fh:
|
|
215
|
+
header = json.load(fh)
|
|
216
|
+
instance.pass_threshold = float(
|
|
217
|
+
header.get("pass_threshold", instance.pass_threshold)
|
|
218
|
+
)
|
|
219
|
+
instance.max_features = int(
|
|
220
|
+
header.get("max_features", instance.max_features)
|
|
221
|
+
)
|
|
222
|
+
instance.ngram_min = int(header.get("ngram_min", instance.ngram_min))
|
|
223
|
+
instance.ngram_max = int(header.get("ngram_max", instance.ngram_max))
|
|
224
|
+
instance.C = float(header.get("C", instance.C))
|
|
225
|
+
instance.min_df = int(header.get("min_df", instance.min_df))
|
|
226
|
+
if header.get("fitted") and os.path.isfile(blob_path):
|
|
227
|
+
joblib = _require_joblib()
|
|
228
|
+
blob = joblib.load(blob_path)
|
|
229
|
+
instance.vectorizer = blob["vectorizer"]
|
|
230
|
+
instance.classifier = blob["classifier"]
|
|
231
|
+
return instance
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
__all__ = ["_NlpTextScorerBase", "_join_text", "_require_sklearn"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tier 2 NLP text adherence scorer.
|
|
2
|
+
|
|
3
|
+
Combines the deterministic rule engine that ships in
|
|
4
|
+
:class:`agent_learning.scorers.stdlib.StdlibAdherenceScorer` with a
|
|
5
|
+
TF-IDF + logistic-regression probability over the response text. The
|
|
6
|
+
final probability is the mean of:
|
|
7
|
+
|
|
8
|
+
- the rule-engine ratio (clauses satisfied / clauses total), and
|
|
9
|
+
- the classifier's predicted probability of the positive class.
|
|
10
|
+
|
|
11
|
+
When the classifier is unfitted the rule-engine ratio is used
|
|
12
|
+
unchanged. When no contract is supplied the rule-engine ratio is 1.0
|
|
13
|
+
and the score degenerates to the learned probability.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
from ..base import ScoreResult
|
|
22
|
+
from ..stdlib.adherence import StdlibAdherenceScorer
|
|
23
|
+
from ._base import _NlpTextScorerBase
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class NlpTextAdherenceScorer(_NlpTextScorerBase):
|
|
28
|
+
"""Adherence scorer backed by TF-IDF + a deterministic rule engine."""
|
|
29
|
+
|
|
30
|
+
name: str = "adherence"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load_or_default(
|
|
34
|
+
cls,
|
|
35
|
+
snapshot_dir: Optional[str] = None,
|
|
36
|
+
*,
|
|
37
|
+
pass_threshold: float = 0.5,
|
|
38
|
+
max_features: int = 20000,
|
|
39
|
+
ngram_min: int = 1,
|
|
40
|
+
ngram_max: int = 2,
|
|
41
|
+
) -> "NlpTextAdherenceScorer":
|
|
42
|
+
instance = cls(
|
|
43
|
+
pass_threshold=pass_threshold,
|
|
44
|
+
max_features=max_features,
|
|
45
|
+
ngram_min=ngram_min,
|
|
46
|
+
ngram_max=ngram_max,
|
|
47
|
+
)
|
|
48
|
+
cls._load_into(instance, snapshot_dir)
|
|
49
|
+
return instance # type: ignore[return-value]
|
|
50
|
+
|
|
51
|
+
def _pair_text(
|
|
52
|
+
self, *, query: Optional[str], response: str
|
|
53
|
+
) -> str:
|
|
54
|
+
# Adherence is about the response itself; ignore the query.
|
|
55
|
+
return response.strip()
|
|
56
|
+
|
|
57
|
+
def score(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
response: Optional[str] = None,
|
|
61
|
+
contract: Optional[Dict[str, Any]] = None,
|
|
62
|
+
query: Optional[str] = None,
|
|
63
|
+
**_: object,
|
|
64
|
+
) -> ScoreResult:
|
|
65
|
+
if response is None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"adherence NLP text scorer requires a response"
|
|
68
|
+
)
|
|
69
|
+
# Rule engine via the Tier 1 backend.
|
|
70
|
+
rule_scorer = StdlibAdherenceScorer(pass_threshold=self.pass_threshold)
|
|
71
|
+
rule_score = rule_scorer.score(response=response, contract=contract)
|
|
72
|
+
rule_probability = float(rule_score.normalized)
|
|
73
|
+
|
|
74
|
+
# Classifier signal.
|
|
75
|
+
learned_probability: Optional[float]
|
|
76
|
+
if self.fitted:
|
|
77
|
+
learned_probability = self._predict_probability(
|
|
78
|
+
query=query, response=response
|
|
79
|
+
)
|
|
80
|
+
combined = 0.5 * (rule_probability + learned_probability)
|
|
81
|
+
else:
|
|
82
|
+
learned_probability = None
|
|
83
|
+
combined = rule_probability
|
|
84
|
+
|
|
85
|
+
features: Dict[str, Any] = {
|
|
86
|
+
"rule_probability": rule_probability,
|
|
87
|
+
"learned_probability": learned_probability,
|
|
88
|
+
"violations": rule_score.features.get("violations", []),
|
|
89
|
+
"clauses_total": rule_score.features.get("clauses_total", 0),
|
|
90
|
+
}
|
|
91
|
+
return self._to_score(combined, features=features)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["NlpTextAdherenceScorer"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tier 2 NLP text completion scorer.
|
|
2
|
+
|
|
3
|
+
Combines the deterministic token-coverage scorer from
|
|
4
|
+
:class:`agent_learning.scorers.stdlib.StdlibCompletionScorer` with a
|
|
5
|
+
TF-IDF + logistic-regression probability over the response text. The
|
|
6
|
+
final probability is the mean of the two signals (rule + classifier)
|
|
7
|
+
when both are available; otherwise the available signal is used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from ..base import ScoreResult
|
|
16
|
+
from ..stdlib.completion import StdlibCompletionScorer
|
|
17
|
+
from ._base import _NlpTextScorerBase
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class NlpTextCompletionScorer(_NlpTextScorerBase):
|
|
22
|
+
"""Completion scorer backed by TF-IDF + token-coverage."""
|
|
23
|
+
|
|
24
|
+
name: str = "completion"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def load_or_default(
|
|
28
|
+
cls,
|
|
29
|
+
snapshot_dir: Optional[str] = None,
|
|
30
|
+
*,
|
|
31
|
+
pass_threshold: float = 0.5,
|
|
32
|
+
max_features: int = 20000,
|
|
33
|
+
ngram_min: int = 1,
|
|
34
|
+
ngram_max: int = 2,
|
|
35
|
+
) -> "NlpTextCompletionScorer":
|
|
36
|
+
instance = cls(
|
|
37
|
+
pass_threshold=pass_threshold,
|
|
38
|
+
max_features=max_features,
|
|
39
|
+
ngram_min=ngram_min,
|
|
40
|
+
ngram_max=ngram_max,
|
|
41
|
+
)
|
|
42
|
+
cls._load_into(instance, snapshot_dir)
|
|
43
|
+
return instance # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
def _pair_text(
|
|
46
|
+
self, *, query: Optional[str], response: str
|
|
47
|
+
) -> str:
|
|
48
|
+
return response.strip()
|
|
49
|
+
|
|
50
|
+
def score(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
response: Optional[str] = None,
|
|
54
|
+
expected_tokens: Optional[Sequence[str]] = None,
|
|
55
|
+
contract: Optional[Dict[str, Any]] = None,
|
|
56
|
+
query: Optional[str] = None,
|
|
57
|
+
**_: object,
|
|
58
|
+
) -> ScoreResult:
|
|
59
|
+
if response is None:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"completion NLP text scorer requires a response"
|
|
62
|
+
)
|
|
63
|
+
rule_scorer = StdlibCompletionScorer(pass_threshold=self.pass_threshold)
|
|
64
|
+
rule_score = rule_scorer.score(
|
|
65
|
+
response=response,
|
|
66
|
+
expected_tokens=expected_tokens,
|
|
67
|
+
contract=contract,
|
|
68
|
+
)
|
|
69
|
+
rule_probability = float(rule_score.normalized)
|
|
70
|
+
|
|
71
|
+
learned_probability: Optional[float]
|
|
72
|
+
if self.fitted:
|
|
73
|
+
learned_probability = self._predict_probability(
|
|
74
|
+
query=query, response=response
|
|
75
|
+
)
|
|
76
|
+
combined = 0.5 * (rule_probability + learned_probability)
|
|
77
|
+
else:
|
|
78
|
+
learned_probability = None
|
|
79
|
+
combined = rule_probability
|
|
80
|
+
|
|
81
|
+
features: Dict[str, Any] = {
|
|
82
|
+
"rule_probability": rule_probability,
|
|
83
|
+
"learned_probability": learned_probability,
|
|
84
|
+
"hits": rule_score.features.get("hits", 0),
|
|
85
|
+
"misses": rule_score.features.get("misses", []),
|
|
86
|
+
"total_targets": rule_score.features.get("total_targets", 0),
|
|
87
|
+
}
|
|
88
|
+
return self._to_score(combined, features=features)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["NlpTextCompletionScorer"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tier 2 NLP text intent scorer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..base import ScoreResult
|
|
9
|
+
from ._base import _NlpTextScorerBase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class NlpTextIntentScorer(_NlpTextScorerBase):
|
|
14
|
+
"""Predicts whether the response addresses the requester's intent.
|
|
15
|
+
|
|
16
|
+
Feeds TF-IDF a concatenation of ``query`` and ``response`` so the
|
|
17
|
+
classifier can learn topical alignment between the question and
|
|
18
|
+
the agent's answer.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str = "intent"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load_or_default(
|
|
25
|
+
cls,
|
|
26
|
+
snapshot_dir: Optional[str] = None,
|
|
27
|
+
*,
|
|
28
|
+
pass_threshold: float = 0.5,
|
|
29
|
+
max_features: int = 20000,
|
|
30
|
+
ngram_min: int = 1,
|
|
31
|
+
ngram_max: int = 2,
|
|
32
|
+
) -> "NlpTextIntentScorer":
|
|
33
|
+
instance = cls(
|
|
34
|
+
pass_threshold=pass_threshold,
|
|
35
|
+
max_features=max_features,
|
|
36
|
+
ngram_min=ngram_min,
|
|
37
|
+
ngram_max=ngram_max,
|
|
38
|
+
)
|
|
39
|
+
cls._load_into(instance, snapshot_dir)
|
|
40
|
+
return instance # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
def score(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
query: Optional[str] = None,
|
|
46
|
+
response: Optional[str] = None,
|
|
47
|
+
**_: object,
|
|
48
|
+
) -> ScoreResult:
|
|
49
|
+
if not query or not response:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"intent NLP text scorer requires both query and response"
|
|
52
|
+
)
|
|
53
|
+
probability = self._predict_probability(
|
|
54
|
+
query=query, response=response
|
|
55
|
+
)
|
|
56
|
+
return self._to_score(probability)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["NlpTextIntentScorer"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tier 3 small-language-model scorers.
|
|
2
|
+
|
|
3
|
+
The package wraps a locally-hosted instance of
|
|
4
|
+
`Microsoft Phi-4-mini-instruct
|
|
5
|
+
<https://huggingface.co/microsoft/Phi-4-mini-instruct-onnx>`_ (3.8 B
|
|
6
|
+
parameters, 4-bit ONNX) and exposes three pass/fail evaluators with the
|
|
7
|
+
same surface as the Tier 1 and Tier 2 scorers. The model is loaded once
|
|
8
|
+
per process via :class:`SlmRunner` and reused across calls.
|
|
9
|
+
|
|
10
|
+
Install:
|
|
11
|
+
|
|
12
|
+
.. code-block:: bash
|
|
13
|
+
|
|
14
|
+
pip install agent-learning[slm]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .adherence import SlmAdherenceScorer
|
|
18
|
+
from .completion import SlmCompletionScorer
|
|
19
|
+
from .intent import SlmIntentScorer
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SlmAdherenceScorer",
|
|
23
|
+
"SlmCompletionScorer",
|
|
24
|
+
"SlmIntentScorer",
|
|
25
|
+
]
|