sonar-eval 0.3.0__tar.gz
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.
- sonar_eval-0.3.0/.gitignore +17 -0
- sonar_eval-0.3.0/LICENSE +21 -0
- sonar_eval-0.3.0/PKG-INFO +209 -0
- sonar_eval-0.3.0/README.md +184 -0
- sonar_eval-0.3.0/pyproject.toml +38 -0
- sonar_eval-0.3.0/src/sonar_eval/__init__.py +101 -0
- sonar_eval-0.3.0/src/sonar_eval/adapter.py +122 -0
- sonar_eval-0.3.0/src/sonar_eval/assertions.py +81 -0
- sonar_eval-0.3.0/src/sonar_eval/config.py +82 -0
- sonar_eval-0.3.0/src/sonar_eval/grader.py +80 -0
- sonar_eval-0.3.0/src/sonar_eval/interceptor.py +152 -0
- sonar_eval-0.3.0/src/sonar_eval/llm.py +30 -0
- sonar_eval-0.3.0/src/sonar_eval/metrics.py +97 -0
- sonar_eval-0.3.0/src/sonar_eval/orchestrator.py +148 -0
- sonar_eval-0.3.0/src/sonar_eval/py.typed +0 -0
- sonar_eval-0.3.0/src/sonar_eval/scheduler.py +16 -0
- sonar_eval-0.3.0/src/sonar_eval/simulator.py +87 -0
- sonar_eval-0.3.0/src/sonar_eval/store.py +43 -0
- sonar_eval-0.3.0/src/sonar_eval/transcript.py +104 -0
- sonar_eval-0.3.0/src/sonar_eval/version.py +1 -0
- sonar_eval-0.3.0/tests/test_adapter.py +41 -0
- sonar_eval-0.3.0/tests/test_interceptor.py +84 -0
- sonar_eval-0.3.0/tests/test_metrics.py +72 -0
- sonar_eval-0.3.0/tests/test_orchestrator.py +156 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.env
|
|
12
|
+
*.local.yaml
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# Collector persistent queue (runtime state)
|
|
16
|
+
collector/queue/*
|
|
17
|
+
!collector/queue/.gitkeep
|
sonar_eval-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Ahmon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sonar-eval
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Evaluation harness SDK for agentic AI chatbots, built on sonar-tracing.
|
|
5
|
+
Author-email: Matthew Ahmon <matthew.ahmon@gmail.com>
|
|
6
|
+
Maintainer-email: Matthew Ahmon <matthew.ahmon@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,ai,evaluation,llm,sonar,testing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: sonar-tracing>=0.1
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# sonar-eval
|
|
27
|
+
|
|
28
|
+
Evaluation harness SDK for agentic AI chatbots, built on
|
|
29
|
+
[`sonar-tracing`](https://pypi.org/project/sonar-tracing/).
|
|
30
|
+
|
|
31
|
+
The harness drives your bot through simulated conversations, optionally injects
|
|
32
|
+
faults into its tool calls, and grades the transcript it pulls back from
|
|
33
|
+
`sonar-ingest`. You write one small adapter for your bot; the trial loop,
|
|
34
|
+
fault injection, assertions, LLM grading, and pass@k / pass^k rollups are generic.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install sonar-eval
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The mental model
|
|
41
|
+
|
|
42
|
+
`conversation_id` is the spine of the whole design. For each trial the harness:
|
|
43
|
+
|
|
44
|
+
1. mints a `conversation_id` via `sonar_tracing.new_conversation()` — this is the
|
|
45
|
+
**trial id**, the **fault-injection scope key**, and the **join key** all at once;
|
|
46
|
+
2. hands it to your `BotAdapter`, which makes the bot trace under that id (in a
|
|
47
|
+
real service, by POSTing it to an app-internal endpoint the app threads into
|
|
48
|
+
`tracing.identity(conversation_id=...)`);
|
|
49
|
+
3. lets a **user simulator** (an LLM playing the human) drive turns until it emits
|
|
50
|
+
the termination signal or hits `max_turns`. The simulator sees *only* the
|
|
51
|
+
messages delivered back to it — never the bot's tools or spans;
|
|
52
|
+
4. pulls the whole conversation back out of ingest by that id
|
|
53
|
+
(`GET /v1/conversations/{id}`) and reconstructs the tool-call transcript;
|
|
54
|
+
5. runs programmatic **assertions** (tier 1) and an optional LLM **rubric grader**
|
|
55
|
+
(tier 2), then repeats `k` times and rolls the results up per assertion.
|
|
56
|
+
|
|
57
|
+
The grader scores what actually happened — the span tree in Postgres — not
|
|
58
|
+
whatever the adapter chose to return. That is why `send()` returns only delivered
|
|
59
|
+
messages and the transcript comes from ingest.
|
|
60
|
+
|
|
61
|
+
## What you provide
|
|
62
|
+
|
|
63
|
+
Two things are project-specific: an `LLMClient` (the harness never imports a
|
|
64
|
+
vendor SDK) and a `BotAdapter` for your bot.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sonar_eval import BaseBotAdapter, Delivered, LLMConfig, Message, Session, TrialContext
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MyLLM:
|
|
71
|
+
"""Wrap whatever model client you use. Return the completion text."""
|
|
72
|
+
|
|
73
|
+
async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
|
|
74
|
+
... # call your provider with `system` + `messages`, return the text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MyBotAdapter(BaseBotAdapter):
|
|
78
|
+
name = "support-bot"
|
|
79
|
+
|
|
80
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
81
|
+
# Isolate/seed state for this trial and make the bot trace under
|
|
82
|
+
# ctx.conversation_id. For a real service: POST ctx.conversation_id
|
|
83
|
+
# (and, for real fidelity, serialize_faults(ctx.faults)) to your bot's
|
|
84
|
+
# internal endpoint so it wraps its turns in identity(conversation_id=...).
|
|
85
|
+
handle = await open_session(ctx.conversation_id, seed=ctx.seed.data)
|
|
86
|
+
return Session(conversation_id=ctx.conversation_id, handle=handle)
|
|
87
|
+
|
|
88
|
+
async def send(self, session: Session, user_msg: str, media=None) -> Delivered:
|
|
89
|
+
reply = await session.handle.send(user_msg) # drive one user turn
|
|
90
|
+
return Delivered(messages=(reply,)) # only what the user sees
|
|
91
|
+
|
|
92
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
93
|
+
await session.handle.close() # safe after a failed setup
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Running a scenario
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import asyncio
|
|
100
|
+
|
|
101
|
+
from sonar_eval import (
|
|
102
|
+
Assertion, Fidelity, LLMConfig, Orchestrator, ScenarioConfig, Severity,
|
|
103
|
+
TaskConfig, no_tool_errors, tool_was_called,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
task = TaskConfig(
|
|
107
|
+
ingest_base_url="http://localhost:4319",
|
|
108
|
+
tenant="acme",
|
|
109
|
+
user_llm=LLMConfig(model="claude-sonnet-5"),
|
|
110
|
+
grader_llm=LLMConfig(model="claude-opus-5"),
|
|
111
|
+
# project defaults to "eval-fullcontent" so the grader sees unredacted tool I/O
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
scenario = ScenarioConfig(
|
|
115
|
+
name="order-a-coffee",
|
|
116
|
+
user_instructions="You want a large oat-milk latte. Order it, then stop.",
|
|
117
|
+
k=5,
|
|
118
|
+
assertions=[
|
|
119
|
+
# capability: passes if it held on ANY of the k trials (pass@k)
|
|
120
|
+
Assertion("looked-up-menu", Severity.CAPABILITY, tool_was_called("menu_lookup")),
|
|
121
|
+
# safety-critical: passes only if it held on EVERY trial (pass^k == 1.0)
|
|
122
|
+
Assertion("no-tool-errors", Severity.SAFETY_CRITICAL, no_tool_errors()),
|
|
123
|
+
],
|
|
124
|
+
grader_rubric="Did the bot confirm the exact order before charging? Score 0..1.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
orch = Orchestrator(task, MyBotAdapter(), MyLLM())
|
|
128
|
+
report = asyncio.run(orch.run_scenario(scenario))
|
|
129
|
+
|
|
130
|
+
print(report.passed) # True only if every assertion passed
|
|
131
|
+
for a in report.assertions:
|
|
132
|
+
print(a.name, a.severity, a.pass_at_k, a.pass_hat_k)
|
|
133
|
+
print(report.mean_grader_score) # None unless a grader ran
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Built-in checks: `tool_was_called(name)`, `no_tool_errors()`, `max_turns(n)`,
|
|
137
|
+
`max_total_tokens(n)`. A check is any `Callable[[Transcript, ConversationLog], bool]`,
|
|
138
|
+
so you can write your own over the pulled transcript.
|
|
139
|
+
|
|
140
|
+
Grading is opt-in: pass a `grader_factory` to the orchestrator and set
|
|
141
|
+
`grader_rubric` on the scenario.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from sonar_eval import Grader
|
|
145
|
+
|
|
146
|
+
orch = Orchestrator(
|
|
147
|
+
task, MyBotAdapter(), MyLLM(),
|
|
148
|
+
grader_factory=lambda s: Grader(MyLLM(), s.grader_llm, s.grader_rubric),
|
|
149
|
+
store=None, # defaults to NullStore; use JSONFileStore("results/") to persist
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Fault injection
|
|
154
|
+
|
|
155
|
+
Faults are declared once and applied by whichever mechanism the scenario's
|
|
156
|
+
fidelity selects. Rules are declarative so trials stay reproducible (pass^k needs
|
|
157
|
+
determinism).
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from sonar_eval import Action, FaultRule, FaultSpec
|
|
161
|
+
|
|
162
|
+
faults = FaultSpec(rules=(
|
|
163
|
+
# the 2nd call to payment_api raises, every trial
|
|
164
|
+
FaultRule(target="payment_api", action=Action.ERROR, when_call_index=1),
|
|
165
|
+
# a content-aware fault (mocked fidelity only): fail if it tried to overcharge
|
|
166
|
+
FaultRule(
|
|
167
|
+
target="payment_api", action=Action.ERROR,
|
|
168
|
+
when=lambda call: call.args.get("amount", 0) > 100,
|
|
169
|
+
),
|
|
170
|
+
))
|
|
171
|
+
scenario = ScenarioConfig(name="payment-outage", user_instructions="...", faults=faults)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- **`Fidelity.MOCKED`** (default): the adapter routes its mocked dependencies
|
|
175
|
+
through a harness-owned `ProxyInterceptor` built from the spec. Fully in-process;
|
|
176
|
+
the `when` content predicate works here.
|
|
177
|
+
- **`Fidelity.REAL`**: the adapter posts `serialize_faults(ctx.faults)` to your
|
|
178
|
+
app's internal endpoint and the app's own tool dispatch honours it. A `when`
|
|
179
|
+
predicate cannot cross a process boundary, so `serialize_faults()` raises on one
|
|
180
|
+
rather than silently dropping it — use `when_call_index` for real-fidelity runs.
|
|
181
|
+
|
|
182
|
+
Actions: `ERROR`, `TIMEOUT`, `REPLACE_OUTPUT` (returns `payload`), `LATENCY`
|
|
183
|
+
(sleeps `payload` seconds).
|
|
184
|
+
|
|
185
|
+
## Metrics
|
|
186
|
+
|
|
187
|
+
Results are rolled up **per assertion**, not just per scenario, so a scenario can
|
|
188
|
+
legitimately pass its capability bar while failing a safety gate:
|
|
189
|
+
|
|
190
|
+
- **capability → pass@k** — passes if the assertion held on at least one of `k` trials;
|
|
191
|
+
- **safety-critical → pass^k** — passes only if it held on every trial.
|
|
192
|
+
|
|
193
|
+
`ScenarioReport.passed` is true only when every assertion passes under its own rule.
|
|
194
|
+
Records are written through a pluggable `ResultStore` (`JSONFileStore` /
|
|
195
|
+
`NullStore` ship in the box).
|
|
196
|
+
|
|
197
|
+
## App-side integration
|
|
198
|
+
|
|
199
|
+
For the harness to drive your bot, the bot must accept a `conversation_id` per
|
|
200
|
+
conversation and thread it straight into `identity(conversation_id=...)` — and, to
|
|
201
|
+
be *drivable*, expose a way for the simulator to hand it that id. See the
|
|
202
|
+
"Grouping runs into a conversation" section of
|
|
203
|
+
[`docs/IMPLEMENTATION_STRANDS.md`](../../docs/IMPLEMENTATION_STRANDS.md) /
|
|
204
|
+
[`docs/IMPLEMENTATION_LANGCHAIN.md`](../../docs/IMPLEMENTATION_LANGCHAIN.md), and
|
|
205
|
+
[`DESIGN.md` §10](../../DESIGN.md) for the full rationale.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# sonar-eval
|
|
2
|
+
|
|
3
|
+
Evaluation harness SDK for agentic AI chatbots, built on
|
|
4
|
+
[`sonar-tracing`](https://pypi.org/project/sonar-tracing/).
|
|
5
|
+
|
|
6
|
+
The harness drives your bot through simulated conversations, optionally injects
|
|
7
|
+
faults into its tool calls, and grades the transcript it pulls back from
|
|
8
|
+
`sonar-ingest`. You write one small adapter for your bot; the trial loop,
|
|
9
|
+
fault injection, assertions, LLM grading, and pass@k / pass^k rollups are generic.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install sonar-eval
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The mental model
|
|
16
|
+
|
|
17
|
+
`conversation_id` is the spine of the whole design. For each trial the harness:
|
|
18
|
+
|
|
19
|
+
1. mints a `conversation_id` via `sonar_tracing.new_conversation()` — this is the
|
|
20
|
+
**trial id**, the **fault-injection scope key**, and the **join key** all at once;
|
|
21
|
+
2. hands it to your `BotAdapter`, which makes the bot trace under that id (in a
|
|
22
|
+
real service, by POSTing it to an app-internal endpoint the app threads into
|
|
23
|
+
`tracing.identity(conversation_id=...)`);
|
|
24
|
+
3. lets a **user simulator** (an LLM playing the human) drive turns until it emits
|
|
25
|
+
the termination signal or hits `max_turns`. The simulator sees *only* the
|
|
26
|
+
messages delivered back to it — never the bot's tools or spans;
|
|
27
|
+
4. pulls the whole conversation back out of ingest by that id
|
|
28
|
+
(`GET /v1/conversations/{id}`) and reconstructs the tool-call transcript;
|
|
29
|
+
5. runs programmatic **assertions** (tier 1) and an optional LLM **rubric grader**
|
|
30
|
+
(tier 2), then repeats `k` times and rolls the results up per assertion.
|
|
31
|
+
|
|
32
|
+
The grader scores what actually happened — the span tree in Postgres — not
|
|
33
|
+
whatever the adapter chose to return. That is why `send()` returns only delivered
|
|
34
|
+
messages and the transcript comes from ingest.
|
|
35
|
+
|
|
36
|
+
## What you provide
|
|
37
|
+
|
|
38
|
+
Two things are project-specific: an `LLMClient` (the harness never imports a
|
|
39
|
+
vendor SDK) and a `BotAdapter` for your bot.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from sonar_eval import BaseBotAdapter, Delivered, LLMConfig, Message, Session, TrialContext
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MyLLM:
|
|
46
|
+
"""Wrap whatever model client you use. Return the completion text."""
|
|
47
|
+
|
|
48
|
+
async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
|
|
49
|
+
... # call your provider with `system` + `messages`, return the text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MyBotAdapter(BaseBotAdapter):
|
|
53
|
+
name = "support-bot"
|
|
54
|
+
|
|
55
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
56
|
+
# Isolate/seed state for this trial and make the bot trace under
|
|
57
|
+
# ctx.conversation_id. For a real service: POST ctx.conversation_id
|
|
58
|
+
# (and, for real fidelity, serialize_faults(ctx.faults)) to your bot's
|
|
59
|
+
# internal endpoint so it wraps its turns in identity(conversation_id=...).
|
|
60
|
+
handle = await open_session(ctx.conversation_id, seed=ctx.seed.data)
|
|
61
|
+
return Session(conversation_id=ctx.conversation_id, handle=handle)
|
|
62
|
+
|
|
63
|
+
async def send(self, session: Session, user_msg: str, media=None) -> Delivered:
|
|
64
|
+
reply = await session.handle.send(user_msg) # drive one user turn
|
|
65
|
+
return Delivered(messages=(reply,)) # only what the user sees
|
|
66
|
+
|
|
67
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
68
|
+
await session.handle.close() # safe after a failed setup
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Running a scenario
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import asyncio
|
|
75
|
+
|
|
76
|
+
from sonar_eval import (
|
|
77
|
+
Assertion, Fidelity, LLMConfig, Orchestrator, ScenarioConfig, Severity,
|
|
78
|
+
TaskConfig, no_tool_errors, tool_was_called,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
task = TaskConfig(
|
|
82
|
+
ingest_base_url="http://localhost:4319",
|
|
83
|
+
tenant="acme",
|
|
84
|
+
user_llm=LLMConfig(model="claude-sonnet-5"),
|
|
85
|
+
grader_llm=LLMConfig(model="claude-opus-5"),
|
|
86
|
+
# project defaults to "eval-fullcontent" so the grader sees unredacted tool I/O
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
scenario = ScenarioConfig(
|
|
90
|
+
name="order-a-coffee",
|
|
91
|
+
user_instructions="You want a large oat-milk latte. Order it, then stop.",
|
|
92
|
+
k=5,
|
|
93
|
+
assertions=[
|
|
94
|
+
# capability: passes if it held on ANY of the k trials (pass@k)
|
|
95
|
+
Assertion("looked-up-menu", Severity.CAPABILITY, tool_was_called("menu_lookup")),
|
|
96
|
+
# safety-critical: passes only if it held on EVERY trial (pass^k == 1.0)
|
|
97
|
+
Assertion("no-tool-errors", Severity.SAFETY_CRITICAL, no_tool_errors()),
|
|
98
|
+
],
|
|
99
|
+
grader_rubric="Did the bot confirm the exact order before charging? Score 0..1.",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
orch = Orchestrator(task, MyBotAdapter(), MyLLM())
|
|
103
|
+
report = asyncio.run(orch.run_scenario(scenario))
|
|
104
|
+
|
|
105
|
+
print(report.passed) # True only if every assertion passed
|
|
106
|
+
for a in report.assertions:
|
|
107
|
+
print(a.name, a.severity, a.pass_at_k, a.pass_hat_k)
|
|
108
|
+
print(report.mean_grader_score) # None unless a grader ran
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Built-in checks: `tool_was_called(name)`, `no_tool_errors()`, `max_turns(n)`,
|
|
112
|
+
`max_total_tokens(n)`. A check is any `Callable[[Transcript, ConversationLog], bool]`,
|
|
113
|
+
so you can write your own over the pulled transcript.
|
|
114
|
+
|
|
115
|
+
Grading is opt-in: pass a `grader_factory` to the orchestrator and set
|
|
116
|
+
`grader_rubric` on the scenario.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from sonar_eval import Grader
|
|
120
|
+
|
|
121
|
+
orch = Orchestrator(
|
|
122
|
+
task, MyBotAdapter(), MyLLM(),
|
|
123
|
+
grader_factory=lambda s: Grader(MyLLM(), s.grader_llm, s.grader_rubric),
|
|
124
|
+
store=None, # defaults to NullStore; use JSONFileStore("results/") to persist
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Fault injection
|
|
129
|
+
|
|
130
|
+
Faults are declared once and applied by whichever mechanism the scenario's
|
|
131
|
+
fidelity selects. Rules are declarative so trials stay reproducible (pass^k needs
|
|
132
|
+
determinism).
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from sonar_eval import Action, FaultRule, FaultSpec
|
|
136
|
+
|
|
137
|
+
faults = FaultSpec(rules=(
|
|
138
|
+
# the 2nd call to payment_api raises, every trial
|
|
139
|
+
FaultRule(target="payment_api", action=Action.ERROR, when_call_index=1),
|
|
140
|
+
# a content-aware fault (mocked fidelity only): fail if it tried to overcharge
|
|
141
|
+
FaultRule(
|
|
142
|
+
target="payment_api", action=Action.ERROR,
|
|
143
|
+
when=lambda call: call.args.get("amount", 0) > 100,
|
|
144
|
+
),
|
|
145
|
+
))
|
|
146
|
+
scenario = ScenarioConfig(name="payment-outage", user_instructions="...", faults=faults)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **`Fidelity.MOCKED`** (default): the adapter routes its mocked dependencies
|
|
150
|
+
through a harness-owned `ProxyInterceptor` built from the spec. Fully in-process;
|
|
151
|
+
the `when` content predicate works here.
|
|
152
|
+
- **`Fidelity.REAL`**: the adapter posts `serialize_faults(ctx.faults)` to your
|
|
153
|
+
app's internal endpoint and the app's own tool dispatch honours it. A `when`
|
|
154
|
+
predicate cannot cross a process boundary, so `serialize_faults()` raises on one
|
|
155
|
+
rather than silently dropping it — use `when_call_index` for real-fidelity runs.
|
|
156
|
+
|
|
157
|
+
Actions: `ERROR`, `TIMEOUT`, `REPLACE_OUTPUT` (returns `payload`), `LATENCY`
|
|
158
|
+
(sleeps `payload` seconds).
|
|
159
|
+
|
|
160
|
+
## Metrics
|
|
161
|
+
|
|
162
|
+
Results are rolled up **per assertion**, not just per scenario, so a scenario can
|
|
163
|
+
legitimately pass its capability bar while failing a safety gate:
|
|
164
|
+
|
|
165
|
+
- **capability → pass@k** — passes if the assertion held on at least one of `k` trials;
|
|
166
|
+
- **safety-critical → pass^k** — passes only if it held on every trial.
|
|
167
|
+
|
|
168
|
+
`ScenarioReport.passed` is true only when every assertion passes under its own rule.
|
|
169
|
+
Records are written through a pluggable `ResultStore` (`JSONFileStore` /
|
|
170
|
+
`NullStore` ship in the box).
|
|
171
|
+
|
|
172
|
+
## App-side integration
|
|
173
|
+
|
|
174
|
+
For the harness to drive your bot, the bot must accept a `conversation_id` per
|
|
175
|
+
conversation and thread it straight into `identity(conversation_id=...)` — and, to
|
|
176
|
+
be *drivable*, expose a way for the simulator to hand it that id. See the
|
|
177
|
+
"Grouping runs into a conversation" section of
|
|
178
|
+
[`docs/IMPLEMENTATION_STRANDS.md`](../../docs/IMPLEMENTATION_STRANDS.md) /
|
|
179
|
+
[`docs/IMPLEMENTATION_LANGCHAIN.md`](../../docs/IMPLEMENTATION_LANGCHAIN.md), and
|
|
180
|
+
[`DESIGN.md` §10](../../DESIGN.md) for the full rationale.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sonar-eval"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Evaluation harness SDK for agentic AI chatbots, built on sonar-tracing."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [{ name = "Matthew Ahmon", email = "matthew.ahmon@gmail.com" }]
|
|
10
|
+
maintainers = [{ name = "Matthew Ahmon", email = "matthew.ahmon@gmail.com" }]
|
|
11
|
+
keywords = ["evaluation", "ai", "agents", "llm", "testing", "sonar"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"sonar-tracing>=0.1",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.version]
|
|
35
|
+
path = "src/sonar_eval/version.py"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/sonar_eval"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""sonar-eval - evaluation harness SDK for agentic AI chatbots.
|
|
2
|
+
|
|
3
|
+
Built on sonar-tracing: the harness drives an agent through simulated conversations,
|
|
4
|
+
injects faults into its tool calls, and grades the transcript it pulls back from
|
|
5
|
+
sonar-ingest by conversation_id.
|
|
6
|
+
|
|
7
|
+
from sonar_eval import Orchestrator, TaskConfig, ScenarioConfig
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .adapter import (
|
|
11
|
+
BaseBotAdapter,
|
|
12
|
+
BotAdapter,
|
|
13
|
+
Delivered,
|
|
14
|
+
Seed,
|
|
15
|
+
Session,
|
|
16
|
+
TrialContext,
|
|
17
|
+
load_adapter,
|
|
18
|
+
validate_bot_adapter,
|
|
19
|
+
)
|
|
20
|
+
from .assertions import (
|
|
21
|
+
Assertion,
|
|
22
|
+
AssertionEngine,
|
|
23
|
+
AssertionResult,
|
|
24
|
+
Severity,
|
|
25
|
+
max_total_tokens,
|
|
26
|
+
max_turns,
|
|
27
|
+
no_tool_errors,
|
|
28
|
+
tool_was_called,
|
|
29
|
+
)
|
|
30
|
+
from .config import Fidelity, Persona, ScenarioConfig, TaskConfig
|
|
31
|
+
from .grader import Grader, GraderResult
|
|
32
|
+
from .interceptor import (
|
|
33
|
+
Action,
|
|
34
|
+
FaultRule,
|
|
35
|
+
FaultSpec,
|
|
36
|
+
InjectedFault,
|
|
37
|
+
InterceptDecision,
|
|
38
|
+
ProxyInterceptor,
|
|
39
|
+
ToolCall,
|
|
40
|
+
ToolInterceptor,
|
|
41
|
+
serialize_faults,
|
|
42
|
+
)
|
|
43
|
+
from .llm import LLMClient, LLMConfig, Message
|
|
44
|
+
from .metrics import AssertionMetric, ScenarioReport, summarize
|
|
45
|
+
from .orchestrator import Orchestrator, TrialResult
|
|
46
|
+
from .simulator import ConversationLog, Turn, UserSimulator
|
|
47
|
+
from .store import JSONFileStore, NullStore, ResultStore
|
|
48
|
+
from .transcript import IngestClient, ToolInvocation, Transcript
|
|
49
|
+
from .version import __version__
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Action",
|
|
53
|
+
"Assertion",
|
|
54
|
+
"AssertionEngine",
|
|
55
|
+
"AssertionMetric",
|
|
56
|
+
"AssertionResult",
|
|
57
|
+
"BaseBotAdapter",
|
|
58
|
+
"BotAdapter",
|
|
59
|
+
"ConversationLog",
|
|
60
|
+
"Delivered",
|
|
61
|
+
"FaultRule",
|
|
62
|
+
"FaultSpec",
|
|
63
|
+
"Fidelity",
|
|
64
|
+
"Grader",
|
|
65
|
+
"GraderResult",
|
|
66
|
+
"IngestClient",
|
|
67
|
+
"InjectedFault",
|
|
68
|
+
"InterceptDecision",
|
|
69
|
+
"JSONFileStore",
|
|
70
|
+
"LLMClient",
|
|
71
|
+
"LLMConfig",
|
|
72
|
+
"Message",
|
|
73
|
+
"NullStore",
|
|
74
|
+
"Orchestrator",
|
|
75
|
+
"Persona",
|
|
76
|
+
"ProxyInterceptor",
|
|
77
|
+
"ResultStore",
|
|
78
|
+
"ScenarioConfig",
|
|
79
|
+
"ScenarioReport",
|
|
80
|
+
"Seed",
|
|
81
|
+
"Session",
|
|
82
|
+
"Severity",
|
|
83
|
+
"TaskConfig",
|
|
84
|
+
"ToolCall",
|
|
85
|
+
"ToolInterceptor",
|
|
86
|
+
"ToolInvocation",
|
|
87
|
+
"Transcript",
|
|
88
|
+
"TrialContext",
|
|
89
|
+
"TrialResult",
|
|
90
|
+
"Turn",
|
|
91
|
+
"UserSimulator",
|
|
92
|
+
"__version__",
|
|
93
|
+
"load_adapter",
|
|
94
|
+
"max_total_tokens",
|
|
95
|
+
"max_turns",
|
|
96
|
+
"no_tool_errors",
|
|
97
|
+
"serialize_faults",
|
|
98
|
+
"summarize",
|
|
99
|
+
"tool_was_called",
|
|
100
|
+
"validate_bot_adapter",
|
|
101
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The bot adapter contract.
|
|
2
|
+
|
|
3
|
+
One thin interface the developer implements once per bot. It absorbs the stack
|
|
4
|
+
differences (auth, DB, transport) so the rest of the harness stays generic, and it is
|
|
5
|
+
where per-trial isolation and seeding happen.
|
|
6
|
+
|
|
7
|
+
Two things are threaded through every trial:
|
|
8
|
+
|
|
9
|
+
conversation_id minted by the harness via ``sonar_tracing.new_conversation()``. The
|
|
10
|
+
adapter must make the bot stamp it on its spans - for a service like
|
|
11
|
+
Cafeina that means POSTing it to an app-internal endpoint so the app
|
|
12
|
+
wraps its turns in ``tracing.identity(conversation_id=...)``. It is the
|
|
13
|
+
join key the grader later uses to pull the transcript back from ingest.
|
|
14
|
+
faults a FaultSpec. In mocked fidelity the adapter routes its mocks through a
|
|
15
|
+
ProxyInterceptor built from it; in real fidelity it posts
|
|
16
|
+
``serialize_faults(faults)`` to the app alongside conversation_id.
|
|
17
|
+
|
|
18
|
+
``send()`` returns only what a human user would see. The tool-call transcript is NOT
|
|
19
|
+
returned here - it is pulled from sonar-ingest by conversation_id, which keeps the user
|
|
20
|
+
simulator honest (it can only ever see delivered messages).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
|
|
29
|
+
from .config import Fidelity
|
|
30
|
+
from .interceptor import FaultSpec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Seed:
|
|
35
|
+
"""Project-defined seed state: authorizations, DB rows, stored data.
|
|
36
|
+
|
|
37
|
+
Opaque to the harness; the adapter is the only thing that interprets ``data``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Session:
|
|
45
|
+
"""Opaque per-trial handle the adapter owns. Carries the join key for convenience."""
|
|
46
|
+
|
|
47
|
+
conversation_id: str
|
|
48
|
+
handle: Any = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Delivered:
|
|
53
|
+
"""Exactly what a human user would see for one turn."""
|
|
54
|
+
|
|
55
|
+
messages: tuple[str, ...] = ()
|
|
56
|
+
media: tuple[Any, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TrialContext:
|
|
61
|
+
conversation_id: str
|
|
62
|
+
seed: Seed
|
|
63
|
+
faults: FaultSpec
|
|
64
|
+
fidelity: Fidelity
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BotAdapter(Protocol):
|
|
68
|
+
name: str
|
|
69
|
+
|
|
70
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
71
|
+
"""Isolate + seed state for one trial, wire fault injection, return a handle."""
|
|
72
|
+
|
|
73
|
+
async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
|
|
74
|
+
"""Deliver one user turn; return only what the user would see back."""
|
|
75
|
+
|
|
76
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
77
|
+
"""Reset in lockstep. Must be safe to call after a failed setup."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BaseBotAdapter:
|
|
81
|
+
"""Optional convenience base so a minimal adapter is a few lines."""
|
|
82
|
+
|
|
83
|
+
name: str = "unnamed"
|
|
84
|
+
|
|
85
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
86
|
+
return Session(conversation_id=ctx.conversation_id)
|
|
87
|
+
|
|
88
|
+
async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
|
|
89
|
+
return Delivered()
|
|
90
|
+
|
|
91
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_REQUIRED_METHODS = ("setup_trial", "send", "teardown_trial")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_bot_adapter(candidate: Any) -> None:
|
|
99
|
+
"""Structural check with a useful message.
|
|
100
|
+
|
|
101
|
+
``runtime_checkable`` Protocols cannot verify the ``name`` attribute, and a missing
|
|
102
|
+
async method would otherwise surface deep inside the trial loop.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(getattr(candidate, "name", None), str):
|
|
105
|
+
raise TypeError(f"{candidate!r} is missing a string `name` attribute")
|
|
106
|
+
not_callable = [m for m in _REQUIRED_METHODS if not callable(getattr(candidate, m, None))]
|
|
107
|
+
if not_callable:
|
|
108
|
+
raise TypeError(f"{candidate!r} is missing methods: {not_callable}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_adapter(path: str) -> BotAdapter:
|
|
112
|
+
"""Load an adapter from a ``module.path:attribute`` string and validate it.
|
|
113
|
+
|
|
114
|
+
The attribute may be an instance or a zero-arg class/factory.
|
|
115
|
+
"""
|
|
116
|
+
module_path, _, attr = path.partition(":")
|
|
117
|
+
if not attr:
|
|
118
|
+
raise ValueError(f"adapter path must be 'module:attr', got {path!r}")
|
|
119
|
+
obj = getattr(importlib.import_module(module_path), attr)
|
|
120
|
+
candidate = obj() if isinstance(obj, type) else obj
|
|
121
|
+
validate_bot_adapter(candidate)
|
|
122
|
+
return candidate
|