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.
Files changed (71) hide show
  1. agent_learning/__init__.py +160 -0
  2. agent_learning/_version.py +3 -0
  3. agent_learning/capture.py +271 -0
  4. agent_learning/classifiers/__init__.py +37 -0
  5. agent_learning/classifiers/base.py +185 -0
  6. agent_learning/classifiers/router.py +236 -0
  7. agent_learning/classifiers/scorers/__init__.py +34 -0
  8. agent_learning/classifiers/scorers/_base.py +162 -0
  9. agent_learning/classifiers/scorers/adherence.py +26 -0
  10. agent_learning/classifiers/scorers/completion.py +26 -0
  11. agent_learning/classifiers/scorers/intent.py +25 -0
  12. agent_learning/cli.py +386 -0
  13. agent_learning/config.py +524 -0
  14. agent_learning/learners/__init__.py +6 -0
  15. agent_learning/learners/base.py +38 -0
  16. agent_learning/learners/reinforce.py +153 -0
  17. agent_learning/metrics/__init__.py +22 -0
  18. agent_learning/metrics/base.py +233 -0
  19. agent_learning/metrics/intent_resolution.py +50 -0
  20. agent_learning/metrics/registry.py +42 -0
  21. agent_learning/metrics/task_adherence.py +42 -0
  22. agent_learning/metrics/task_completion.py +54 -0
  23. agent_learning/policy/__init__.py +7 -0
  24. agent_learning/policy/base.py +59 -0
  25. agent_learning/policy/contextual_softmax.py +243 -0
  26. agent_learning/policy/softmax_bandit.py +157 -0
  27. agent_learning/py.typed +1 -0
  28. agent_learning/rewards/__init__.py +6 -0
  29. agent_learning/rewards/shaping.py +121 -0
  30. agent_learning/rewards/writer.py +130 -0
  31. agent_learning/scorers/__init__.py +187 -0
  32. agent_learning/scorers/base.py +49 -0
  33. agent_learning/scorers/llm/__init__.py +19 -0
  34. agent_learning/scorers/llm/_base.py +126 -0
  35. agent_learning/scorers/llm/adherence.py +21 -0
  36. agent_learning/scorers/llm/completion.py +21 -0
  37. agent_learning/scorers/llm/intent.py +21 -0
  38. agent_learning/scorers/nlp/__init__.py +16 -0
  39. agent_learning/scorers/nlp/_base.py +99 -0
  40. agent_learning/scorers/nlp/adherence.py +17 -0
  41. agent_learning/scorers/nlp/completion.py +17 -0
  42. agent_learning/scorers/nlp/intent.py +17 -0
  43. agent_learning/scorers/nlp_text/__init__.py +26 -0
  44. agent_learning/scorers/nlp_text/_base.py +234 -0
  45. agent_learning/scorers/nlp_text/adherence.py +94 -0
  46. agent_learning/scorers/nlp_text/completion.py +91 -0
  47. agent_learning/scorers/nlp_text/intent.py +59 -0
  48. agent_learning/scorers/slm/__init__.py +25 -0
  49. agent_learning/scorers/slm/_base.py +292 -0
  50. agent_learning/scorers/slm/adherence.py +98 -0
  51. agent_learning/scorers/slm/completion.py +111 -0
  52. agent_learning/scorers/slm/intent.py +80 -0
  53. agent_learning/scorers/stdlib/__init__.py +38 -0
  54. agent_learning/scorers/stdlib/_text.py +87 -0
  55. agent_learning/scorers/stdlib/adherence.py +157 -0
  56. agent_learning/scorers/stdlib/completion.py +117 -0
  57. agent_learning/scorers/stdlib/intent.py +182 -0
  58. agent_learning/storage/__init__.py +14 -0
  59. agent_learning/storage/base.py +156 -0
  60. agent_learning/storage/cosmos.py +506 -0
  61. agent_learning/storage/local.py +353 -0
  62. agent_learning/storage/memory.py +209 -0
  63. agent_learning/training/__init__.py +5 -0
  64. agent_learning/training/runner.py +172 -0
  65. agent_learning/types.py +507 -0
  66. agent_learning-0.4.1.dist-info/METADATA +82 -0
  67. agent_learning-0.4.1.dist-info/RECORD +71 -0
  68. agent_learning-0.4.1.dist-info/WHEEL +5 -0
  69. agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
  70. agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
  71. 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
+ ]