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,153 @@
|
|
|
1
|
+
"""REINFORCE-style policy gradient learner with an EMA value baseline.
|
|
2
|
+
|
|
3
|
+
The native learner consumes (episode, aggregate reward) pairs and
|
|
4
|
+
applies one batched gradient step per call. The gradient of a
|
|
5
|
+
softmax bandit's logit ``z_a`` w.r.t. the log-likelihood of action
|
|
6
|
+
``a`` is ``1_{action == a} - π(a)``, so the policy-gradient update
|
|
7
|
+
for one episode is:
|
|
8
|
+
|
|
9
|
+
Δz_a = lr * (R - b) * (1_{action == a} - π(a))
|
|
10
|
+
|
|
11
|
+
We accumulate that delta over the batch, average by the batch size,
|
|
12
|
+
add a small entropy bonus to keep exploration alive, and update the
|
|
13
|
+
EMA baseline ``b`` afterwards. Importance sampling weights are
|
|
14
|
+
applied when the snapshot's policy version is older than the
|
|
15
|
+
in-memory policy version, to keep offline updates unbiased without
|
|
16
|
+
exploding gradients (the weight is clipped).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from typing import Dict, Iterable, List, Optional
|
|
23
|
+
|
|
24
|
+
from ..config import LearnerConfig
|
|
25
|
+
from ..policy.base import Policy
|
|
26
|
+
from ..policy.softmax_bandit import SoftmaxPolicy
|
|
27
|
+
from ..types import Episode, Reward, RewardSource
|
|
28
|
+
from .base import Learner, LearnerResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReinforceLearner(Learner):
|
|
32
|
+
"""REINFORCE-with-baseline learner for :class:`SoftmaxPolicy`."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: Optional[LearnerConfig] = None) -> None:
|
|
35
|
+
self._config = config or LearnerConfig()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def config(self) -> LearnerConfig:
|
|
39
|
+
return self._config
|
|
40
|
+
|
|
41
|
+
def update(
|
|
42
|
+
self,
|
|
43
|
+
policy: Policy,
|
|
44
|
+
episodes: Iterable[Episode],
|
|
45
|
+
rewards: Iterable[Reward],
|
|
46
|
+
) -> LearnerResult:
|
|
47
|
+
if not isinstance(policy, SoftmaxPolicy):
|
|
48
|
+
raise TypeError("ReinforceLearner requires a SoftmaxPolicy")
|
|
49
|
+
|
|
50
|
+
# Index rewards by episode id - we only consume aggregate rewards
|
|
51
|
+
episode_list = list(episodes)
|
|
52
|
+
aggregate_rewards: Dict[str, float] = {}
|
|
53
|
+
for r in rewards:
|
|
54
|
+
if r.source != RewardSource.AGGREGATE:
|
|
55
|
+
continue
|
|
56
|
+
# Keep the most recent aggregate per episode (sorted by created_at desc)
|
|
57
|
+
current = aggregate_rewards.get(r.episode_id)
|
|
58
|
+
if current is None:
|
|
59
|
+
aggregate_rewards[r.episode_id] = r.value
|
|
60
|
+
|
|
61
|
+
snapshot = policy.snapshot()
|
|
62
|
+
baseline_before = snapshot.baseline
|
|
63
|
+
probs_now = policy.probabilities()
|
|
64
|
+
action_ids = [a.id for a in snapshot.actions]
|
|
65
|
+
action_index = {aid: idx for idx, aid in enumerate(action_ids)}
|
|
66
|
+
|
|
67
|
+
deltas: Dict[str, float] = {aid: 0.0 for aid in action_ids}
|
|
68
|
+
used = 0
|
|
69
|
+
reward_sum = 0.0
|
|
70
|
+
entropy = -sum(p * math.log(max(p, 1e-12)) for p in probs_now)
|
|
71
|
+
|
|
72
|
+
for episode in episode_list:
|
|
73
|
+
if episode.action_id is None or episode.action_id not in action_index:
|
|
74
|
+
continue
|
|
75
|
+
reward_value = aggregate_rewards.get(episode.id)
|
|
76
|
+
if reward_value is None:
|
|
77
|
+
continue
|
|
78
|
+
advantage = reward_value - baseline_before
|
|
79
|
+
|
|
80
|
+
# Importance sampling weight when the episode was logged under a
|
|
81
|
+
# behaviour policy that differs from the target policy.
|
|
82
|
+
iw = self._importance_weight(episode, probs_now, action_index)
|
|
83
|
+
advantage *= iw
|
|
84
|
+
|
|
85
|
+
chosen_idx = action_index[episode.action_id]
|
|
86
|
+
for idx, aid in enumerate(action_ids):
|
|
87
|
+
indicator = 1.0 if idx == chosen_idx else 0.0
|
|
88
|
+
grad = indicator - probs_now[idx]
|
|
89
|
+
deltas[aid] += advantage * grad
|
|
90
|
+
|
|
91
|
+
# Entropy regulariser pushes towards the maximum-entropy
|
|
92
|
+
# distribution: its gradient w.r.t. logit i is -log p_i - H.
|
|
93
|
+
for idx, aid in enumerate(action_ids):
|
|
94
|
+
deltas[aid] += self._config.entropy_bonus * (-math.log(max(probs_now[idx], 1e-12)) - entropy)
|
|
95
|
+
|
|
96
|
+
used += 1
|
|
97
|
+
reward_sum += reward_value
|
|
98
|
+
|
|
99
|
+
if used == 0:
|
|
100
|
+
return LearnerResult(
|
|
101
|
+
episodes_used=0,
|
|
102
|
+
mean_reward=0.0,
|
|
103
|
+
baseline_before=baseline_before,
|
|
104
|
+
baseline_after=baseline_before,
|
|
105
|
+
logit_deltas={aid: 0.0 for aid in action_ids},
|
|
106
|
+
extra={"reason": "no actionable episodes"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Average gradients across the batch and scale by learning rate
|
|
110
|
+
for aid in deltas:
|
|
111
|
+
deltas[aid] = self._config.learning_rate * deltas[aid] / used
|
|
112
|
+
|
|
113
|
+
# Update EMA baseline using the mean batch reward
|
|
114
|
+
mean_reward = reward_sum / used
|
|
115
|
+
decay = self._config.baseline_decay
|
|
116
|
+
baseline_after = decay * baseline_before + (1.0 - decay) * mean_reward
|
|
117
|
+
|
|
118
|
+
policy.apply_update(
|
|
119
|
+
deltas,
|
|
120
|
+
baseline=baseline_after,
|
|
121
|
+
episodes_seen=snapshot.episodes_seen + used,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return LearnerResult(
|
|
125
|
+
episodes_used=used,
|
|
126
|
+
mean_reward=mean_reward,
|
|
127
|
+
baseline_before=baseline_before,
|
|
128
|
+
baseline_after=baseline_after,
|
|
129
|
+
logit_deltas=deltas,
|
|
130
|
+
extra={"entropy": entropy},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
# Helpers
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _importance_weight(
|
|
138
|
+
self,
|
|
139
|
+
episode: Episode,
|
|
140
|
+
probs_now: List[float],
|
|
141
|
+
action_index: Dict[str, int],
|
|
142
|
+
) -> float:
|
|
143
|
+
"""Return clipped π_target / π_behaviour for an episode."""
|
|
144
|
+
if episode.action_logprob is None or episode.action_id is None:
|
|
145
|
+
return 1.0
|
|
146
|
+
idx = action_index[episode.action_id]
|
|
147
|
+
p_target = max(probs_now[idx], 1e-12)
|
|
148
|
+
p_behaviour = max(math.exp(episode.action_logprob), 1e-12)
|
|
149
|
+
ratio = p_target / p_behaviour
|
|
150
|
+
return min(ratio, self._config.importance_clip)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["ReinforceLearner"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Score-based evaluation metrics for native RL reward shaping.
|
|
2
|
+
|
|
3
|
+
Each metric is a thin wrapper around an evaluator from
|
|
4
|
+
``azure-ai-evaluation``. The wrapper normalises the raw evaluator score
|
|
5
|
+
into the ``[0, 1]`` range expected by the reward shaper.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import MetricEvaluator, MetricRequest
|
|
9
|
+
from .intent_resolution import IntentResolutionMetric
|
|
10
|
+
from .task_adherence import TaskAdherenceMetric
|
|
11
|
+
from .task_completion import TaskCompletionMetric
|
|
12
|
+
from .registry import default_metrics, evaluate_all
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"IntentResolutionMetric",
|
|
16
|
+
"MetricEvaluator",
|
|
17
|
+
"MetricRequest",
|
|
18
|
+
"TaskAdherenceMetric",
|
|
19
|
+
"TaskCompletionMetric",
|
|
20
|
+
"default_metrics",
|
|
21
|
+
"evaluate_all",
|
|
22
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Base classes for native score-based metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from ..config import ScoreConfig
|
|
12
|
+
from ..types import Episode, MetricName, MetricResult
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MetricRequest:
|
|
19
|
+
"""Input passed to a metric evaluator.
|
|
20
|
+
|
|
21
|
+
The fields mirror the prompty schemas used by azure-ai-evaluation:
|
|
22
|
+
``query`` is the user message (or the full conversation history
|
|
23
|
+
formatted as a string), ``response`` is the agent reply, and
|
|
24
|
+
``tool_calls`` / ``system_message`` are populated when required by
|
|
25
|
+
the underlying evaluator.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
query: str
|
|
29
|
+
response: str
|
|
30
|
+
system_message: Optional[str] = None
|
|
31
|
+
tool_calls: Optional[str] = None
|
|
32
|
+
tool_definitions: Optional[str] = None
|
|
33
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_episode(cls, episode: Episode) -> "MetricRequest":
|
|
37
|
+
"""Build a metric request from an :class:`Episode`."""
|
|
38
|
+
query = _format_query(episode)
|
|
39
|
+
return cls(
|
|
40
|
+
query=query,
|
|
41
|
+
response=episode.assistant_output or "",
|
|
42
|
+
system_message=episode.system_message,
|
|
43
|
+
tool_calls=_format_tool_calls(episode),
|
|
44
|
+
tool_definitions=episode.metadata.get("tool_definitions"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_query(episode: Episode) -> str:
|
|
49
|
+
"""Render the conversation history as the ``query`` field.
|
|
50
|
+
|
|
51
|
+
The Azure prompts accept a free-form ``query`` string. We prefer
|
|
52
|
+
the structured conversation history when present, falling back to
|
|
53
|
+
the single ``user_input`` field.
|
|
54
|
+
"""
|
|
55
|
+
if episode.conversation_history:
|
|
56
|
+
lines: List[str] = []
|
|
57
|
+
for msg in episode.conversation_history:
|
|
58
|
+
role = msg.get("role", "user").upper()
|
|
59
|
+
content = msg.get("content", "")
|
|
60
|
+
lines.append(f"{role}: {content}")
|
|
61
|
+
if episode.user_input and (
|
|
62
|
+
not lines or not lines[-1].lower().startswith("user:")
|
|
63
|
+
):
|
|
64
|
+
lines.append(f"USER: {episode.user_input}")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
return episode.user_input or ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_tool_calls(episode: Episode) -> str:
|
|
70
|
+
"""Render tool calls as a JSON string consumable by the scorer."""
|
|
71
|
+
if not episode.tool_calls:
|
|
72
|
+
return "[]"
|
|
73
|
+
payload = [
|
|
74
|
+
{
|
|
75
|
+
"name": tc.name,
|
|
76
|
+
"arguments": tc.arguments,
|
|
77
|
+
"result": tc.result,
|
|
78
|
+
"error": tc.error,
|
|
79
|
+
}
|
|
80
|
+
for tc in episode.tool_calls
|
|
81
|
+
]
|
|
82
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MetricEvaluator(ABC):
|
|
86
|
+
"""Abstract base for all native scoring metrics."""
|
|
87
|
+
|
|
88
|
+
#: Stable identifier - subclasses must override.
|
|
89
|
+
NAME: MetricName
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
score_config: Optional[ScoreConfig] = None,
|
|
94
|
+
*,
|
|
95
|
+
evaluator: Optional[Any] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._score_config = score_config or ScoreConfig()
|
|
98
|
+
self._evaluator = evaluator # Allow injection for tests
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Construction hooks (subclasses override _build_evaluator)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def _build_evaluator(self) -> Any:
|
|
106
|
+
"""Instantiate the underlying azure-ai-evaluation evaluator."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
|
|
110
|
+
"""Map the evaluator's raw output to a ``[0, 1]`` score."""
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
|
|
114
|
+
"""Build the call kwargs for the underlying evaluator."""
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Public API
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def evaluator(self) -> Any:
|
|
122
|
+
"""The lazily-constructed underlying evaluator."""
|
|
123
|
+
if self._evaluator is None:
|
|
124
|
+
if not self._score_config.enabled:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
"Score model is not configured. Set AGENT_LEARNING_SCORE_ENDPOINT "
|
|
127
|
+
"and AGENT_LEARNING_SCORE_DEPLOYMENT (or pass an explicit "
|
|
128
|
+
"ScoreConfig) before evaluating metrics."
|
|
129
|
+
)
|
|
130
|
+
self._evaluator = self._build_evaluator()
|
|
131
|
+
return self._evaluator
|
|
132
|
+
|
|
133
|
+
def evaluate(self, request: MetricRequest) -> MetricResult:
|
|
134
|
+
"""Run the underlying scorer and return a normalised result.
|
|
135
|
+
|
|
136
|
+
Errors are caught and returned as ``status=skipped`` so that a
|
|
137
|
+
flaky scorer does not poison the learner. Callers that want
|
|
138
|
+
strict semantics can inspect ``result.status``.
|
|
139
|
+
"""
|
|
140
|
+
if not (request.query and request.response):
|
|
141
|
+
return MetricResult(
|
|
142
|
+
metric=self.NAME,
|
|
143
|
+
score=None,
|
|
144
|
+
normalized=None,
|
|
145
|
+
status="skipped",
|
|
146
|
+
reason="query or response is empty",
|
|
147
|
+
evaluator=self._score_config.azure_deployment or None,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
kwargs = self._build_kwargs(request)
|
|
152
|
+
raw = self.evaluator(**kwargs)
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
raw = dict(raw)
|
|
155
|
+
except Exception as exc: # pragma: no cover - scoring runtime errors
|
|
156
|
+
logger.warning("Metric %s failed: %s", self.NAME.value, exc)
|
|
157
|
+
return MetricResult(
|
|
158
|
+
metric=self.NAME,
|
|
159
|
+
score=None,
|
|
160
|
+
normalized=None,
|
|
161
|
+
status="skipped",
|
|
162
|
+
reason=f"evaluator error: {exc}",
|
|
163
|
+
evaluator=self._score_config.azure_deployment or None,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return self._build_result(raw)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Result extraction
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _build_result(self, raw: Dict[str, Any]) -> MetricResult:
|
|
173
|
+
score = _extract_score(raw, self.NAME)
|
|
174
|
+
normalized = self._normalize(raw)
|
|
175
|
+
status = "skipped" if score is None else "completed"
|
|
176
|
+
|
|
177
|
+
properties = raw.get("properties") if isinstance(raw.get("properties"), dict) else None
|
|
178
|
+
reason_keys = (
|
|
179
|
+
f"{self.NAME.value}_reason",
|
|
180
|
+
"reason",
|
|
181
|
+
)
|
|
182
|
+
reason: Optional[str] = None
|
|
183
|
+
for key in reason_keys:
|
|
184
|
+
if isinstance(raw.get(key), str):
|
|
185
|
+
reason = raw[key]
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
return MetricResult(
|
|
189
|
+
metric=self.NAME,
|
|
190
|
+
score=score,
|
|
191
|
+
normalized=normalized,
|
|
192
|
+
status=status,
|
|
193
|
+
reason=reason,
|
|
194
|
+
properties=properties,
|
|
195
|
+
evaluator=self._score_config.azure_deployment or None,
|
|
196
|
+
metadata={"raw": _safe_subset(raw)},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_score(raw: Dict[str, Any], name: MetricName) -> Optional[float]:
|
|
201
|
+
"""Return the numeric score from a scorer response, regardless of key."""
|
|
202
|
+
keys = (
|
|
203
|
+
"score",
|
|
204
|
+
f"{name.value}_score",
|
|
205
|
+
f"{name.value}",
|
|
206
|
+
)
|
|
207
|
+
for key in keys:
|
|
208
|
+
if key in raw and raw[key] is not None:
|
|
209
|
+
try:
|
|
210
|
+
return float(raw[key])
|
|
211
|
+
except (TypeError, ValueError):
|
|
212
|
+
continue
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _safe_subset(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
217
|
+
"""Trim the raw score dict to a Cosmos-safe size for persistence."""
|
|
218
|
+
keep_keys = {
|
|
219
|
+
"score",
|
|
220
|
+
"status",
|
|
221
|
+
"reason",
|
|
222
|
+
"properties",
|
|
223
|
+
"intent_resolution_score",
|
|
224
|
+
"intent_resolution_reason",
|
|
225
|
+
"task_adherence_score",
|
|
226
|
+
"task_adherence_reason",
|
|
227
|
+
"task_completion_score",
|
|
228
|
+
"task_completion_reason",
|
|
229
|
+
}
|
|
230
|
+
return {k: v for k, v in raw.items() if k in keep_keys}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = ["MetricEvaluator", "MetricRequest"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Intent resolution metric wrapper.
|
|
2
|
+
|
|
3
|
+
Score range from the underlying scorer is ``1`` (very poor) to ``5``
|
|
4
|
+
(excellent). We normalise to ``[0, 1]`` via ``(score - 1) / 4`` so
|
|
5
|
+
that a perfect intent resolution becomes a maximum positive reward
|
|
6
|
+
signal after shaping.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from ..types import MetricName
|
|
14
|
+
from .base import MetricEvaluator, MetricRequest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IntentResolutionMetric(MetricEvaluator):
|
|
18
|
+
"""Wraps :class:`azure.ai.evaluation.IntentResolutionEvaluator`."""
|
|
19
|
+
|
|
20
|
+
NAME = MetricName.INTENT_RESOLUTION
|
|
21
|
+
|
|
22
|
+
def _build_evaluator(self) -> Any:
|
|
23
|
+
from azure.ai.evaluation import IntentResolutionEvaluator # type: ignore
|
|
24
|
+
|
|
25
|
+
return IntentResolutionEvaluator(
|
|
26
|
+
model_config=self._score_config.to_model_config(),
|
|
27
|
+
threshold=self._score_config.threshold,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
|
|
31
|
+
kwargs: Dict[str, Any] = {
|
|
32
|
+
"query": request.query,
|
|
33
|
+
"response": request.response,
|
|
34
|
+
}
|
|
35
|
+
if request.tool_definitions is not None:
|
|
36
|
+
kwargs["tool_definitions"] = request.tool_definitions
|
|
37
|
+
return kwargs
|
|
38
|
+
|
|
39
|
+
def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
|
|
40
|
+
# The scorer produces an integer 1..5
|
|
41
|
+
from .base import _extract_score # type: ignore
|
|
42
|
+
|
|
43
|
+
score = _extract_score(raw, self.NAME)
|
|
44
|
+
if score is None:
|
|
45
|
+
return None
|
|
46
|
+
clamped = max(1.0, min(5.0, score))
|
|
47
|
+
return (clamped - 1.0) / 4.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["IntentResolutionMetric"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Convenience helpers to evaluate an episode across all default metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..config import ScoreConfig
|
|
8
|
+
from ..types import Episode, MetricResult
|
|
9
|
+
from .base import MetricEvaluator, MetricRequest
|
|
10
|
+
from .intent_resolution import IntentResolutionMetric
|
|
11
|
+
from .task_adherence import TaskAdherenceMetric
|
|
12
|
+
from .task_completion import TaskCompletionMetric
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_metrics(score_config: Optional[ScoreConfig] = None) -> List[MetricEvaluator]:
|
|
16
|
+
"""Return the three native metrics wired with the same score config."""
|
|
17
|
+
cfg = score_config or ScoreConfig()
|
|
18
|
+
return [
|
|
19
|
+
IntentResolutionMetric(cfg),
|
|
20
|
+
TaskAdherenceMetric(cfg),
|
|
21
|
+
TaskCompletionMetric(cfg),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate_all(
|
|
26
|
+
episode: Episode,
|
|
27
|
+
metrics: Optional[Iterable[MetricEvaluator]] = None,
|
|
28
|
+
*,
|
|
29
|
+
score_config: Optional[ScoreConfig] = None,
|
|
30
|
+
) -> List[MetricResult]:
|
|
31
|
+
"""Evaluate one episode against every supplied metric.
|
|
32
|
+
|
|
33
|
+
Each metric is given its own try/except inside :meth:`evaluate`,
|
|
34
|
+
so a single failing scorer will not prevent the others from
|
|
35
|
+
producing scores.
|
|
36
|
+
"""
|
|
37
|
+
metric_list = list(metrics) if metrics is not None else default_metrics(score_config)
|
|
38
|
+
request = MetricRequest.from_episode(episode)
|
|
39
|
+
return [m.evaluate(request) for m in metric_list]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["default_metrics", "evaluate_all"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Task adherence metric wrapper.
|
|
2
|
+
|
|
3
|
+
The underlying scorer emits a binary score ``0`` (material failure) or
|
|
4
|
+
``1`` (pass). It is already in ``[0, 1]`` so normalisation is a no-op.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from ..types import MetricName
|
|
12
|
+
from .base import MetricEvaluator, MetricRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskAdherenceMetric(MetricEvaluator):
|
|
16
|
+
"""Wraps :class:`azure.ai.evaluation.TaskAdherenceEvaluator`."""
|
|
17
|
+
|
|
18
|
+
NAME = MetricName.TASK_ADHERENCE
|
|
19
|
+
|
|
20
|
+
def _build_evaluator(self) -> Any:
|
|
21
|
+
from azure.ai.evaluation import TaskAdherenceEvaluator # type: ignore
|
|
22
|
+
|
|
23
|
+
return TaskAdherenceEvaluator(model_config=self._score_config.to_model_config())
|
|
24
|
+
|
|
25
|
+
def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"query": request.query,
|
|
28
|
+
"response": request.response,
|
|
29
|
+
"system_message": request.system_message or "",
|
|
30
|
+
"tool_calls": request.tool_calls or "[]",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
|
|
34
|
+
from .base import _extract_score # type: ignore
|
|
35
|
+
|
|
36
|
+
score = _extract_score(raw, self.NAME)
|
|
37
|
+
if score is None:
|
|
38
|
+
return None
|
|
39
|
+
return max(0.0, min(1.0, score))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["TaskAdherenceMetric"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Task completion metric wrapper.
|
|
2
|
+
|
|
3
|
+
The underlying scorer emits ``1`` (task completed) or ``0`` (task
|
|
4
|
+
failed or incomplete). It is already in ``[0, 1]``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from ..types import MetricName
|
|
12
|
+
from .base import MetricEvaluator, MetricRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskCompletionMetric(MetricEvaluator):
|
|
16
|
+
"""Wraps the (experimental) ``TaskCompletionEvaluator`` if available.
|
|
17
|
+
|
|
18
|
+
Some releases of ``azure-ai-evaluation`` ship ``TaskCompletionEvaluator``
|
|
19
|
+
and others ship ``CompletenessEvaluator``. We try the former first
|
|
20
|
+
and fall back to the latter so the SDK stays compatible across
|
|
21
|
+
minor versions.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
NAME = MetricName.TASK_COMPLETION
|
|
25
|
+
|
|
26
|
+
def _build_evaluator(self) -> Any:
|
|
27
|
+
try: # pragma: no cover - both paths are exercised in CI matrix
|
|
28
|
+
from azure.ai.evaluation import TaskCompletionEvaluator # type: ignore
|
|
29
|
+
|
|
30
|
+
return TaskCompletionEvaluator(model_config=self._score_config.to_model_config())
|
|
31
|
+
except ImportError:
|
|
32
|
+
from azure.ai.evaluation import CompletenessEvaluator # type: ignore
|
|
33
|
+
|
|
34
|
+
return CompletenessEvaluator(model_config=self._score_config.to_model_config())
|
|
35
|
+
|
|
36
|
+
def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
|
|
37
|
+
kwargs: Dict[str, Any] = {
|
|
38
|
+
"query": request.query,
|
|
39
|
+
"response": request.response,
|
|
40
|
+
}
|
|
41
|
+
if request.tool_definitions is not None:
|
|
42
|
+
kwargs["tool_definitions"] = request.tool_definitions
|
|
43
|
+
return kwargs
|
|
44
|
+
|
|
45
|
+
def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
|
|
46
|
+
from .base import _extract_score # type: ignore
|
|
47
|
+
|
|
48
|
+
score = _extract_score(raw, self.NAME)
|
|
49
|
+
if score is None:
|
|
50
|
+
return None
|
|
51
|
+
return max(0.0, min(1.0, score))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["TaskCompletionMetric"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Abstract policy interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from ..types import Action, PolicySnapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Decision:
|
|
14
|
+
"""Output of :meth:`Policy.choose`: which action and with what probability."""
|
|
15
|
+
|
|
16
|
+
action: Action
|
|
17
|
+
logprob: float
|
|
18
|
+
probabilities: List[float] # Probability assigned to every action (same order as snapshot.actions)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Policy(ABC):
|
|
22
|
+
"""Abstract base class for native RL policies.
|
|
23
|
+
|
|
24
|
+
The Policy is stateless from the caller's perspective: the durable
|
|
25
|
+
state lives in the :class:`PolicySnapshot` returned by
|
|
26
|
+
:meth:`snapshot`. Callers persist the snapshot themselves; the
|
|
27
|
+
Policy never talks to the store directly.
|
|
28
|
+
|
|
29
|
+
Concrete policies may use an optional ``state`` argument on
|
|
30
|
+
:meth:`choose` to condition action selection on contextual features
|
|
31
|
+
(a request embedding, a session summary, etc.). Marginal policies
|
|
32
|
+
such as :class:`SoftmaxPolicy` ignore the argument.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def choose(self, state: Optional[Any] = None) -> Decision:
|
|
37
|
+
"""Sample an action according to the current parameters.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
state: Optional contextual feature vector. Contextual
|
|
41
|
+
policies (e.g. :class:`ContextualSoftmaxPolicy`) require
|
|
42
|
+
a 1-D array of length ``feature_dim``. Marginal policies
|
|
43
|
+
ignore the argument.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def actions(self) -> Sequence[Action]:
|
|
48
|
+
"""Return the action space."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def snapshot(self) -> PolicySnapshot:
|
|
52
|
+
"""Return a deep-copyable snapshot of the policy state."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def apply_update(self, deltas: dict, *, baseline: float, episodes_seen: int) -> None:
|
|
56
|
+
"""Apply per-action logit deltas and update bookkeeping."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["Decision", "Policy"]
|