ask-eval 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +43 -150
- data/lib/ask/eval/recorder.rb +55 -1
- data/lib/ask/eval/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12e438efcef2038b328af35cdb14d6741a7730940f1d8f7014f4550c3b8ac543
|
|
4
|
+
data.tar.gz: c32aaf27235fdd33e206ebc4434ed647015c8fa6d81a8b16b88546c36597f4d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 015cd82d67ac7fcf88c1661d352c459845ffbee6dc373e2d9999db68d8256cb1e63988618e47263ee59eb887fa7381e42b30b0b3fcc4c1fac4cdc802891f08b2
|
|
7
|
+
data.tar.gz: 8daf2d3ccc31244b1e3748ee2f1b07970a9c1af12592e9e6bddfdafaea980816961f4099f4382f5704bb47554e8036185ce4131fe4494400b9ba0b59bec4b026
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.3.0] — 2026-08-03
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Tool execution recording/replay.** The Recorder now tapes tool executions
|
|
6
|
+
alongside provider calls (`record_tool_call` / `replay_tool_call`), so a
|
|
7
|
+
multi-turn agent run replays as a faithful tape — tool results are replayed
|
|
8
|
+
instead of re-executed, which keeps the loop deterministic even when a tool
|
|
9
|
+
would behave differently on a second run (transient failures, changing
|
|
10
|
+
files, temp paths). Provider entries are tagged `type: "provider"` and
|
|
11
|
+
replay raises a clear "replay diverged" error if the run takes a different
|
|
12
|
+
path than the recording.
|
|
13
|
+
|
|
1
14
|
## [0.2.0] — 2026-07-21
|
|
2
15
|
|
|
3
16
|
### Added
|
data/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/ask-eval)
|
|
4
4
|
|
|
5
|
-
LLM evaluation framework for Ruby. Minitest-native assertions for testing
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
LLM evaluation framework for Ruby. Minitest-native assertions for testing LLM
|
|
6
|
+
outputs: deterministic checks plus LLM-as-judge for faithfulness,
|
|
7
|
+
hallucination, bias, toxicity, and correctness. Includes session-level
|
|
8
|
+
evaluation for agents, regression recording, and CI-native reporters.
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
10
11
|
|
|
@@ -34,178 +35,70 @@ class MyEvalTest < Minitest::Test
|
|
|
34
35
|
end
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
Include `Ask::Eval::DSL` in test classes, or require `ask/eval/minitest` in
|
|
39
|
+
`test_helper.rb` to get the assertions in every test automatically.
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
assert_contains output, "substring"
|
|
41
|
-
assert_not_contains output, "bad word"
|
|
42
|
-
assert_regex output, /pattern/
|
|
43
|
-
assert_json output # valid JSON?
|
|
44
|
-
assert_max_tokens output, 500
|
|
45
|
-
assert_starts_with output, "Hello"
|
|
46
|
-
assert_ends_with output, "Goodbye"
|
|
47
|
-
assert_equals output, "exact string"
|
|
48
|
-
assert_min_length output, 10
|
|
49
|
-
assert_max_length output, 500
|
|
50
|
-
assert_url output
|
|
51
|
-
assert_email output
|
|
52
|
-
```
|
|
41
|
+
## Assertions
|
|
53
42
|
|
|
54
|
-
|
|
43
|
+
Deterministic: `assert_contains`, `assert_not_contains`, `assert_regex`,
|
|
44
|
+
`assert_json`, `assert_max_tokens`, `assert_starts_with`, `assert_ends_with`,
|
|
45
|
+
`assert_equals`, `assert_min_length`, `assert_max_length`, `assert_url`,
|
|
46
|
+
`assert_email`.
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
refute_bias response
|
|
60
|
-
refute_toxicity response
|
|
61
|
-
assert_correctness response, expected: expected
|
|
62
|
-
```
|
|
48
|
+
LLM-as-judge: `assert_faithful(output, context:)`,
|
|
49
|
+
`assert_not_hallucinating(output, context:)`, `refute_bias`,
|
|
50
|
+
`refute_toxicity`, `assert_correctness(output, expected:)`.
|
|
63
51
|
|
|
64
|
-
|
|
52
|
+
Judges need a model. Pass one per assertion (`model:`) or configure a default:
|
|
65
53
|
|
|
66
54
|
```ruby
|
|
67
|
-
# Configure a default judge model
|
|
68
55
|
Ask::Eval.configure do |c|
|
|
69
|
-
c.default_judge =
|
|
56
|
+
c.default_judge = "openai/gpt-4o-mini" # any callable, Ask::Provider, or model string
|
|
70
57
|
end
|
|
71
58
|
```
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```ruby
|
|
76
|
-
assert_faithful response, context: docs, model: my_model
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
The model can be:
|
|
80
|
-
- A **callable** (lambda/proc) that accepts messages and returns a response
|
|
81
|
-
- An **Ask::Provider** instance (e.g., `Ask::Providers::OpenAI.new`)
|
|
82
|
-
- A **model string** (e.g., `"openai/gpt-4o-mini"` — requires ask-llm-providers)
|
|
83
|
-
|
|
84
|
-
### Using a lambda for testing
|
|
85
|
-
|
|
86
|
-
```ruby
|
|
87
|
-
require "json"
|
|
88
|
-
|
|
89
|
-
model = ->(messages) {
|
|
90
|
-
{ content: JSON.generate({ passed: true, score: 0.95, reason: "OK" }) }
|
|
91
|
-
}
|
|
92
|
-
assert_faithful response, context: docs, model: model
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## Minitest Plugin
|
|
60
|
+
## Agent Evaluation
|
|
96
61
|
|
|
97
|
-
|
|
62
|
+
Evaluate an `Ask::Agent::Session` with the `eval_session` DSL:
|
|
98
63
|
|
|
99
64
|
```ruby
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
65
|
+
test "agent behavior" do
|
|
66
|
+
eval_session(model: "gpt-4o", tools: [Bash]) do |r|
|
|
67
|
+
r.run("Check health")
|
|
68
|
+
assert_tool_called "bash"
|
|
69
|
+
assert_cost_under 0.01
|
|
70
|
+
end
|
|
71
|
+
end
|
|
103
72
|
```
|
|
104
73
|
|
|
105
|
-
|
|
74
|
+
`eval_session` yields an `Ask::Eval::SessionEval` exposing `run(prompt)`,
|
|
75
|
+
`tool_called?(name)`, `tool_names`, `total_cost`, and `last_response`.
|
|
106
76
|
|
|
107
|
-
|
|
77
|
+
Interactions are recorded on first run and replayed instead of calling the LLM
|
|
78
|
+
when `ASK_EVAL_MODE=replay` is set, so regression tests run without a model
|
|
79
|
+
or API keys.
|
|
108
80
|
|
|
109
|
-
|
|
110
|
-
results = runner.summary[:results]
|
|
111
|
-
xml = Ask::Eval::Reporters::JUnit.new(results).to_xml
|
|
112
|
-
File.write("eval-results.xml", xml)
|
|
113
|
-
```
|
|
81
|
+
## Reporters and Custom Judges
|
|
114
82
|
|
|
115
|
-
|
|
83
|
+
Reporters consume `Ask::Eval::Runner` results: `Ask::Eval::Reporters::Console`
|
|
84
|
+
(dev), `JUnit` (Jenkins, CircleCI, GitLab CI), and `GitHub` (`::warning` and
|
|
85
|
+
`::error` annotations for pull requests).
|
|
116
86
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
reporter.report # prints ::warning and ::error annotations
|
|
120
|
-
```
|
|
87
|
+
Create your own judge by subclassing `Ask::Eval::Judge` and implementing
|
|
88
|
+
`call`, `system_prompt`, and `user_message`; no registration needed.
|
|
121
89
|
|
|
122
|
-
##
|
|
90
|
+
## Full documentation
|
|
123
91
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
# Access accumulated costs
|
|
129
|
-
puts Ask::Eval.cost_report
|
|
130
|
-
# => { total: 0.00015, by_judge: { faithful: { calls: 2, total_cost: 0.00015 } } }
|
|
131
|
-
```
|
|
92
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
|
93
|
+
https://ask-rb.github.io/ask-docs/production/evaluation covers ask-eval in
|
|
94
|
+
depth, including custom judges, cost tracking, and CI integration. API
|
|
95
|
+
reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
132
96
|
|
|
133
|
-
##
|
|
97
|
+
## Development
|
|
134
98
|
|
|
135
|
-
|
|
99
|
+
bundle install
|
|
136
100
|
bundle exec rake test
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
## Design Philosophy
|
|
140
|
-
|
|
141
|
-
**This gem is NOT a port of ruby_llm-tribunal.** See the comparison below:
|
|
142
|
-
|
|
143
|
-
| ruby_llm-tribunal | ask-eval |
|
|
144
|
-
|---|---|
|
|
145
|
-
| Standalone evaluator with its own API | **Minitest-native assertions** — drops into existing tests |
|
|
146
|
-
| 10 judges (including niche: jailbreak, PII, refusal) | **5 essential judges** — faithful, hallucination, bias, toxicity, correctness |
|
|
147
|
-
| 6 reporters (console, text, JSON, HTML, JUnit, GitHub) | **3 reporters** — console (dev), JUnit (CI), GitHub Actions (annotations) |
|
|
148
|
-
| Dataset management, red teaming, custom judges | **No datasets, no red teaming.** Focus on what matters for 80% of users. |
|
|
149
|
-
| Tied to RubyLLM for judge model | **Any model as judge** — cheap gpt-4o-mini, accurate claude, or local |
|
|
150
|
-
| Cost tracking: none | **Cost tracking per evaluation** |
|
|
151
|
-
| Snapshot testing: none | **Eval snapshots for regression detection** (v0.2.0) |
|
|
152
|
-
| Test framework integration: requires include | **Minitest plugin** — auto-loads with `require "ask/eval/minitest"` |
|
|
153
|
-
|
|
154
|
-
|
|
155
101
|
|
|
156
102
|
## License
|
|
157
103
|
|
|
158
104
|
MIT
|
|
159
|
-
</RUBY>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
## Custom Judges
|
|
163
|
-
|
|
164
|
-
The 5 built-in judges cover common cases, but you can create your own by
|
|
165
|
-
subclassing `Ask::Eval::Judge`:
|
|
166
|
-
|
|
167
|
-
```ruby
|
|
168
|
-
class BrandVoiceJudge < Ask::Eval::Judge
|
|
169
|
-
def call(tc)
|
|
170
|
-
query_judge(tc)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
private
|
|
174
|
-
|
|
175
|
-
def system_prompt
|
|
176
|
-
<<~PROMPT
|
|
177
|
-
You are a brand voice evaluator. Determine if the response matches our guidelines:
|
|
178
|
-
- Friendly but professional tone
|
|
179
|
-
- No jargon or technical terms
|
|
180
|
-
- Empathetic and helpful
|
|
181
|
-
|
|
182
|
-
Respond in JSON format:
|
|
183
|
-
{ "passed": true/false, "score": 0.0-1.0, "reason": "..." }
|
|
184
|
-
PROMPT
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def user_message(tc)
|
|
188
|
-
"Response to evaluate: " + tc.actual_output
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Use it directly
|
|
193
|
-
judge = BrandVoiceJudge.new(model: my_model)
|
|
194
|
-
result = judge.call(Ask::Eval::TestCase.new(actual_output: response))
|
|
195
|
-
puts result.reason if result.passed?
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Using a lambda for custom evaluation
|
|
199
|
-
|
|
200
|
-
For simple checks, pass a callable directly as the `model:` parameter --
|
|
201
|
-
you do not need a full judge class:
|
|
202
|
-
|
|
203
|
-
```ruby
|
|
204
|
-
assert_faithful response, context: docs, model: ->(messages) {
|
|
205
|
-
{ content: JSON.generate({ passed: true, score: 1.0, reason: "All good" }) }
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
No registration system needed. Subclassing `Judge` and implementing
|
|
210
|
-
`#call`, `#system_prompt`, and `#user_message` is the entire API.
|
|
211
|
-
|
data/lib/ask/eval/recorder.rb
CHANGED
|
@@ -67,13 +67,27 @@ module Ask
|
|
|
67
67
|
# Record a provider call with serialized result data.
|
|
68
68
|
def record_call(args:, kwargs:, result_data:)
|
|
69
69
|
@interactions << {
|
|
70
|
+
type: "provider",
|
|
70
71
|
messages: scrub_messages(args.first),
|
|
71
72
|
model: kwargs[:model],
|
|
72
73
|
result: result_data
|
|
73
74
|
}
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
#
|
|
77
|
+
# Record a tool execution with serialized result data. Tool calls are
|
|
78
|
+
# interleaved with provider calls in the same order they happened, so a
|
|
79
|
+
# multi-turn agent run replays faithfully — tool results are replayed,
|
|
80
|
+
# not re-executed.
|
|
81
|
+
def record_tool_call(name:, args:, result_data:)
|
|
82
|
+
@interactions << {
|
|
83
|
+
type: "tool",
|
|
84
|
+
name: name,
|
|
85
|
+
args: args,
|
|
86
|
+
result: result_data
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Replay the next recorded provider call as an Ask::Message.
|
|
77
91
|
def replay_as_message
|
|
78
92
|
load_recording_if_needed
|
|
79
93
|
|
|
@@ -82,6 +96,11 @@ module Ask
|
|
|
82
96
|
raise "No recorded interaction available. Delete #{recording_path} and re-record."
|
|
83
97
|
end
|
|
84
98
|
|
|
99
|
+
if entry["type"] == "tool"
|
|
100
|
+
raise "Replay diverged: expected a provider call, but the next recorded interaction is a tool call. " \
|
|
101
|
+
"The run took a different path than the recording. Delete #{recording_path} and re-record."
|
|
102
|
+
end
|
|
103
|
+
|
|
85
104
|
result = entry["result"]
|
|
86
105
|
|
|
87
106
|
if result["type"] == "stream"
|
|
@@ -119,6 +138,32 @@ module Ask
|
|
|
119
138
|
entry["result"]
|
|
120
139
|
end
|
|
121
140
|
|
|
141
|
+
# Replay the next recorded tool execution, returning its original
|
|
142
|
+
# Ask::Result without executing the tool again.
|
|
143
|
+
def replay_tool_call
|
|
144
|
+
load_recording_if_needed
|
|
145
|
+
|
|
146
|
+
entry = @replay_queue.shift
|
|
147
|
+
unless entry
|
|
148
|
+
raise "No recorded tool interaction available. Delete #{recording_path} and re-record."
|
|
149
|
+
end
|
|
150
|
+
unless entry["type"] == "tool"
|
|
151
|
+
raise "Replay diverged: expected a tool call, but the next recorded interaction is a provider call. " \
|
|
152
|
+
"The run took a different path than the recording. Delete #{recording_path} and re-record."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
result = entry["result"]
|
|
156
|
+
if result["type"] == "result"
|
|
157
|
+
if result["ok"]
|
|
158
|
+
Ask::Result.ok(data: result["output"], metadata: result["metadata"] || {})
|
|
159
|
+
else
|
|
160
|
+
Ask::Result.error(message: result["error"] || "", metadata: result["metadata"] || {})
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
result["data"]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
122
167
|
# Save recorded interactions to disk.
|
|
123
168
|
def save
|
|
124
169
|
return unless recording?
|
|
@@ -160,6 +205,15 @@ module Ask
|
|
|
160
205
|
@replay_queue = (data["interactions"] || []).dup
|
|
161
206
|
end
|
|
162
207
|
|
|
208
|
+
# Serialize a tool's Ask::Result so it can be replayed later.
|
|
209
|
+
def serialize_tool_result(result)
|
|
210
|
+
if result.is_a?(Ask::Result)
|
|
211
|
+
result.to_h.merge(type: "result")
|
|
212
|
+
else
|
|
213
|
+
{ type: "raw", data: result }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
163
217
|
def serialize(result)
|
|
164
218
|
if result.respond_to?(:chunks)
|
|
165
219
|
{
|
data/lib/ask/eval/version.rb
CHANGED