ask-eval 0.1.0 → 0.2.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 +51 -1
- data/README.md +57 -3
- data/lib/ask/eval/configuration.rb +7 -1
- data/lib/ask/eval/dsl.rb +95 -45
- data/lib/ask/eval/recorder.rb +202 -0
- data/lib/ask/eval/session_eval.rb +71 -0
- data/lib/ask/eval/version.rb +1 -1
- data/lib/ask/eval.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3fd797bd764c1d47e8b95579fc37d9c4c3b3811c01fd858e2e83f9d76cc7b3a
|
|
4
|
+
data.tar.gz: 5f11f8a81f421a83b78ffbcf3518aff828719156cefe4f8f97399f35a7a92dbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cdc1212c80cfec4eeded7812f6346e1b398bec946eef6b40b0207c18215dd0350a6ef5aa93e6441343fa7c1013072678d8d645b5e7d059eb076a9277faf27db9
|
|
7
|
+
data.tar.gz: c5a3dfecc70f7d8d387279ff716ef5f9a5fd380058041daad08a0ab77a31602e451c87a7e6cbd844d5aa52634c0322c9390de21a0ff53404cb4737abbddaca76
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## [0.2.0] — 2026-07-21
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Regression recording/replay** — `Ask::Eval::Recorder` captures LLM provider interactions to JSON files and replays them for deterministic CI testing. Set `ASK_EVAL_MODE=replay` to use recorded responses instead of real API calls.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
recorder = Ask::Eval::Recorder.new(test_name: "my_suite")
|
|
9
|
+
recorder.wrap(session)
|
|
10
|
+
session.run("Check health")
|
|
11
|
+
recorder.save # test/recordings/my_suite/recording.json
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **Session evaluation** — `Ask::Eval::SessionEval` wraps an `Ask::Agent::Session` for testing. Tracks tool calls and costs.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
eval = Ask::Eval::SessionEval.new(session)
|
|
18
|
+
eval.run("Check health")
|
|
19
|
+
eval.tool_called?("bash") # => true/false
|
|
20
|
+
eval.total_cost # => 0.0012
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Minitest DSL additions** — `eval_session` for agent integration testing with automatic recording:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
test "health check agent" do
|
|
27
|
+
eval_session(model: "gpt-4o", tools: [Bash]) do |r|
|
|
28
|
+
r.run("Check health")
|
|
29
|
+
assert_tool_called "bash"
|
|
30
|
+
assert_cost_under 0.01
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
New assertions: `assert_tool_called`, `assert_cost_under`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Cost tracking** — `Configuration#_accumulate_cost` now actually records costs to the `CostTracker` instead of being a no-op. DSL judge methods now properly track LLM spend.
|
|
40
|
+
- **Recorder serialization** — Switched from YAML to JSON to avoid Ruby symbol key issues.
|
|
41
|
+
|
|
42
|
+
### Tested
|
|
43
|
+
|
|
44
|
+
- 12 new Recorder tests: recording, replaying, file I/O, stream/message types, error handling.
|
|
45
|
+
- 139 total tests, 263 assertions — 0 failures.
|
|
46
|
+
|
|
47
|
+
## [0.1.1] - 2026-06-25
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Expanded tests: Runner(12t), TestCase(9t), DSL(29t), Configuration(10t), MinitestPlugin(20t), Reporters(16t). Infrastructure: rubocop, overcommit, CI matrix, gemspec, SimpleCov.
|
|
1
51
|
# Changelog
|
|
2
52
|
|
|
3
53
|
## [0.1.0] - 2026-06-10
|
|
@@ -13,7 +63,7 @@
|
|
|
13
63
|
- Batch evaluation runner (`Ask::Eval::Runner`)
|
|
14
64
|
- CI reporters: Console, JUnit XML, GitHub Actions annotations
|
|
15
65
|
- Cost tracking with `CostTracker` — per-model pricing, summary reports
|
|
66
|
+
- Custom judge API — subclass `Ask::Eval::Judge` with `#call`, `#system_prompt`, `#user_message`
|
|
16
67
|
- Zero runtime dependencies — deterministic assertions work standalone
|
|
17
68
|
- Optional ask-llm-providers integration for judge models
|
|
18
69
|
- Tests: 88 minitest tests covering all components
|
|
19
|
-
</RUBY>
|
data/README.md
CHANGED
|
@@ -138,9 +138,9 @@ bundle exec rake test
|
|
|
138
138
|
|
|
139
139
|
## Design Philosophy
|
|
140
140
|
|
|
141
|
-
**This gem
|
|
141
|
+
**This gem is NOT a port of ruby_llm-tribunal.** See the comparison below:
|
|
142
142
|
|
|
143
|
-
| ruby_llm-tribunal
|
|
143
|
+
| ruby_llm-tribunal | ask-eval |
|
|
144
144
|
|---|---|
|
|
145
145
|
| Standalone evaluator with its own API | **Minitest-native assertions** — drops into existing tests |
|
|
146
146
|
| 10 judges (including niche: jailbreak, PII, refusal) | **5 essential judges** — faithful, hallucination, bias, toxicity, correctness |
|
|
@@ -148,10 +148,64 @@ bundle exec rake test
|
|
|
148
148
|
| Dataset management, red teaming, custom judges | **No datasets, no red teaming.** Focus on what matters for 80% of users. |
|
|
149
149
|
| Tied to RubyLLM for judge model | **Any model as judge** — cheap gpt-4o-mini, accurate claude, or local |
|
|
150
150
|
| Cost tracking: none | **Cost tracking per evaluation** |
|
|
151
|
-
| Snapshot testing: none | **Eval snapshots for regression detection** |
|
|
151
|
+
| Snapshot testing: none | **Eval snapshots for regression detection** (v0.2.0) |
|
|
152
152
|
| Test framework integration: requires include | **Minitest plugin** — auto-loads with `require "ask/eval/minitest"` |
|
|
153
153
|
|
|
154
|
+
|
|
155
|
+
|
|
154
156
|
## License
|
|
155
157
|
|
|
156
158
|
MIT
|
|
157
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
|
+
|
|
@@ -24,7 +24,13 @@ module Ask
|
|
|
24
24
|
# @api private
|
|
25
25
|
def _accumulate_cost(cost)
|
|
26
26
|
return unless @track_cost && cost
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
cost_tracker.record(
|
|
29
|
+
model: cost[:model] || "judge",
|
|
30
|
+
input_tokens: cost[:input_tokens] || 0,
|
|
31
|
+
output_tokens: cost[:output_tokens] || 0,
|
|
32
|
+
duration: cost[:duration] || 0
|
|
33
|
+
)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
# @return [Ask::Eval::CostTracker] the cost tracker instance
|
data/lib/ask/eval/dsl.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Eval
|
|
5
5
|
# Minitest DSL mixin. Include this in your test class to get all
|
|
6
|
-
# ask-eval assertion methods.
|
|
6
|
+
# ask-eval assertion methods, including session evaluation.
|
|
7
7
|
#
|
|
8
8
|
# @example
|
|
9
9
|
# class MyEvalTest < Minitest::Test
|
|
@@ -13,77 +13,153 @@ module Ask
|
|
|
13
13
|
# assert_faithful my_response, context: docs
|
|
14
14
|
# assert_contains my_response, "policy"
|
|
15
15
|
# end
|
|
16
|
+
#
|
|
17
|
+
# test "agent behavior" do
|
|
18
|
+
# eval_session(model: "gpt-4o", tools: [Bash]) do |r|
|
|
19
|
+
# r.run("Check health")
|
|
20
|
+
# assert_tool_called "bash"
|
|
21
|
+
# assert_cost_under 0.01
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
16
24
|
# end
|
|
17
25
|
module DSL
|
|
26
|
+
# --- Session Evaluation ---
|
|
27
|
+
|
|
28
|
+
# Evaluate an agent session within a test.
|
|
29
|
+
#
|
|
30
|
+
# The session runs with regression recording enabled. On first run,
|
|
31
|
+
# interactions are recorded to +test/recordings/<test_name>/+.
|
|
32
|
+
# On subsequent runs with +ASK_EVAL_MODE=replay+, recorded responses
|
|
33
|
+
# are used instead of real LLM calls.
|
|
34
|
+
#
|
|
35
|
+
# @param model [String] model identifier
|
|
36
|
+
# @param tools [Array] tool classes or instances
|
|
37
|
+
# @param system_prompt [String, nil] optional system prompt
|
|
38
|
+
# @param recordings_dir [String, nil] custom recordings directory
|
|
39
|
+
# @yield [SessionEval] yields a session eval for running and asserting
|
|
40
|
+
def eval_session(model:, tools: [], system_prompt: nil, recordings_dir: nil, **session_opts)
|
|
41
|
+
test_name = self.name
|
|
42
|
+
|
|
43
|
+
recorder = Recorder.new(
|
|
44
|
+
test_name: test_name,
|
|
45
|
+
recordings_dir: recordings_dir
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
session = Ask::Agent::Session.new(
|
|
49
|
+
model: model,
|
|
50
|
+
tools: tools,
|
|
51
|
+
system_prompt: system_prompt,
|
|
52
|
+
**session_opts
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
unless recorder.replaying?
|
|
56
|
+
recorder.wrap(session)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
eval_runner = SessionEval.new(session, recorder: recorder)
|
|
60
|
+
|
|
61
|
+
# Make session eval available to assert_tool_called / assert_cost_under
|
|
62
|
+
@_ask_eval_session = eval_runner
|
|
63
|
+
|
|
64
|
+
yield eval_runner
|
|
65
|
+
ensure
|
|
66
|
+
@_ask_eval_session = nil
|
|
67
|
+
recorder&.save
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Assert that a specific tool was called during the session.
|
|
71
|
+
#
|
|
72
|
+
# @param tool_name [String] tool name (e.g. "bash", "read")
|
|
73
|
+
# @param msg [String, nil] custom failure message
|
|
74
|
+
def assert_tool_called(tool_name, msg = nil)
|
|
75
|
+
session_eval = @_ask_eval_session
|
|
76
|
+
return flunk(msg || "No session eval context") unless session_eval
|
|
77
|
+
|
|
78
|
+
assert session_eval.tool_called?(tool_name),
|
|
79
|
+
msg || "Expected tool #{tool_name.inspect} to have been called. " \
|
|
80
|
+
"Tools called: #{session_eval.tool_names.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Assert that no more than +max_cost+ was spent on LLM calls.
|
|
84
|
+
#
|
|
85
|
+
# @param max_cost [Float] maximum allowed cost in USD
|
|
86
|
+
# @param msg [String, nil] custom failure message
|
|
87
|
+
def assert_cost_under(max_cost, msg = nil)
|
|
88
|
+
session_eval = @_ask_eval_session
|
|
89
|
+
return flunk(msg || "No session eval context") unless session_eval
|
|
90
|
+
|
|
91
|
+
cost = session_eval.total_cost
|
|
92
|
+
assert cost <= max_cost,
|
|
93
|
+
msg || "Expected cost <= #{max_cost}, got $#{'%.6f' % cost}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Original Eval (modified) ---
|
|
97
|
+
|
|
98
|
+
# Eval an agent session with recording support.
|
|
99
|
+
# This is the low-level method that eval_session delegates to.
|
|
100
|
+
def eval_output(output, recorder: nil, scenario: nil, **)
|
|
101
|
+
if recorder&.recording?
|
|
102
|
+
recorder.record_assertion(name: caller_locations(1, 1)&.label, passed: true, score: 1.0, reason: "recorded")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
18
106
|
# --- Deterministic Assertions ---
|
|
19
107
|
|
|
20
|
-
# Assert the output contains the given substring.
|
|
21
108
|
def assert_contains(output, value, msg = nil)
|
|
22
109
|
result = Assertions::Deterministic.contains(output, value: value)
|
|
23
110
|
assert result[:passed], msg || result[:reason]
|
|
24
111
|
end
|
|
25
112
|
|
|
26
|
-
# Assert the output does NOT contain the given substring.
|
|
27
113
|
def assert_not_contains(output, value, msg = nil)
|
|
28
114
|
result = Assertions::Deterministic.not_contains(output, value: value)
|
|
29
115
|
assert result[:passed], msg || result[:reason]
|
|
30
116
|
end
|
|
31
117
|
|
|
32
|
-
# Assert the output matches the given regex pattern.
|
|
33
118
|
def assert_regex(output, pattern, msg = nil)
|
|
34
119
|
result = Assertions::Deterministic.regex(output, pattern: pattern)
|
|
35
120
|
assert result[:passed], msg || result[:reason]
|
|
36
121
|
end
|
|
37
122
|
|
|
38
|
-
# Assert the output is valid JSON.
|
|
39
123
|
def assert_json(output, msg = nil)
|
|
40
124
|
result = Assertions::Deterministic.is_json(output)
|
|
41
125
|
assert result[:passed], msg || result[:reason]
|
|
42
126
|
end
|
|
43
127
|
|
|
44
|
-
# Assert the output has at most `max` tokens.
|
|
45
128
|
def assert_max_tokens(output, max, msg = nil)
|
|
46
129
|
result = Assertions::Deterministic.max_tokens(output, max: max)
|
|
47
130
|
assert result[:passed], msg || result[:reason]
|
|
48
131
|
end
|
|
49
132
|
|
|
50
|
-
# Assert the output starts with the given prefix.
|
|
51
133
|
def assert_starts_with(output, prefix, msg = nil)
|
|
52
134
|
result = Assertions::Deterministic.starts_with(output, prefix: prefix)
|
|
53
135
|
assert result[:passed], msg || result[:reason]
|
|
54
136
|
end
|
|
55
137
|
|
|
56
|
-
# Assert the output ends with the given suffix.
|
|
57
138
|
def assert_ends_with(output, suffix, msg = nil)
|
|
58
139
|
result = Assertions::Deterministic.ends_with(output, suffix: suffix)
|
|
59
140
|
assert result[:passed], msg || result[:reason]
|
|
60
141
|
end
|
|
61
142
|
|
|
62
|
-
# Assert the output equals the given value exactly.
|
|
63
143
|
def assert_equals(output, value, msg = nil)
|
|
64
144
|
result = Assertions::Deterministic.equals(output, value: value)
|
|
65
145
|
assert result[:passed], msg || result[:reason]
|
|
66
146
|
end
|
|
67
147
|
|
|
68
|
-
# Assert the output has at least `min` characters.
|
|
69
148
|
def assert_min_length(output, min, msg = nil)
|
|
70
149
|
result = Assertions::Deterministic.min_length(output, min: min)
|
|
71
150
|
assert result[:passed], msg || result[:reason]
|
|
72
151
|
end
|
|
73
152
|
|
|
74
|
-
# Assert the output has at most `max` characters.
|
|
75
153
|
def assert_max_length(output, max, msg = nil)
|
|
76
154
|
result = Assertions::Deterministic.max_length(output, max: max)
|
|
77
155
|
assert result[:passed], msg || result[:reason]
|
|
78
156
|
end
|
|
79
157
|
|
|
80
|
-
# Assert the output is a valid URL.
|
|
81
158
|
def assert_url(output, msg = nil)
|
|
82
159
|
result = Assertions::Deterministic.url(output)
|
|
83
160
|
assert result[:passed], msg || result[:reason]
|
|
84
161
|
end
|
|
85
162
|
|
|
86
|
-
# Assert the output is a valid email address.
|
|
87
163
|
def assert_email(output, msg = nil)
|
|
88
164
|
result = Assertions::Deterministic.email(output)
|
|
89
165
|
assert result[:passed], msg || result[:reason]
|
|
@@ -91,13 +167,6 @@ module Ask
|
|
|
91
167
|
|
|
92
168
|
# --- LLM Judge Assertions ---
|
|
93
169
|
|
|
94
|
-
# Assert the response is faithful to the provided context.
|
|
95
|
-
#
|
|
96
|
-
# @param output [String] the LLM response
|
|
97
|
-
# @param context [String, Array<String>] source context
|
|
98
|
-
# @param model [Object, nil] judge model
|
|
99
|
-
# @param threshold [Float] minimum score (0.0-1.0)
|
|
100
|
-
# @param msg [String, nil] custom failure message
|
|
101
170
|
def assert_faithful(output, context:, model: nil, threshold: 0.7, msg: nil)
|
|
102
171
|
tc = TestCase.new(actual_output: output, context: context)
|
|
103
172
|
judge = Judges::Faithful.new(model: model)
|
|
@@ -107,13 +176,6 @@ module Ask
|
|
|
107
176
|
log_cost(result) if Ask::Eval.configuration.track_cost
|
|
108
177
|
end
|
|
109
178
|
|
|
110
|
-
# Assert the response does NOT hallucinate (all claims are in context).
|
|
111
|
-
#
|
|
112
|
-
# @param output [String] the LLM response
|
|
113
|
-
# @param context [String, Array<String>] source context
|
|
114
|
-
# @param model [Object, nil] judge model
|
|
115
|
-
# @param threshold [Float] minimum score (0.0-1.0); higher = less hallucination
|
|
116
|
-
# @param msg [String, nil] custom failure message
|
|
117
179
|
def assert_not_hallucinating(output, context:, model: nil, threshold: 0.7, msg: nil)
|
|
118
180
|
tc = TestCase.new(actual_output: output, context: context)
|
|
119
181
|
judge = Judges::Hallucination.new(model: model)
|
|
@@ -123,11 +185,6 @@ module Ask
|
|
|
123
185
|
log_cost(result) if Ask::Eval.configuration.track_cost
|
|
124
186
|
end
|
|
125
187
|
|
|
126
|
-
# Refute (assert NOT) the response shows bias.
|
|
127
|
-
#
|
|
128
|
-
# @param output [String] the LLM response
|
|
129
|
-
# @param model [Object, nil] judge model
|
|
130
|
-
# @param msg [String, nil] custom failure message
|
|
131
188
|
def refute_bias(output, model: nil, msg: nil)
|
|
132
189
|
tc = TestCase.new(actual_output: output)
|
|
133
190
|
judge = Judges::Bias.new(model: model)
|
|
@@ -136,11 +193,6 @@ module Ask
|
|
|
136
193
|
log_cost(result) if Ask::Eval.configuration.track_cost
|
|
137
194
|
end
|
|
138
195
|
|
|
139
|
-
# Refute (assert NOT) the response is toxic.
|
|
140
|
-
#
|
|
141
|
-
# @param output [String] the LLM response
|
|
142
|
-
# @param model [Object, nil] judge model
|
|
143
|
-
# @param msg [String, nil] custom failure message
|
|
144
196
|
def refute_toxicity(output, model: nil, msg: nil)
|
|
145
197
|
tc = TestCase.new(actual_output: output)
|
|
146
198
|
judge = Judges::Toxicity.new(model: model)
|
|
@@ -149,12 +201,6 @@ module Ask
|
|
|
149
201
|
log_cost(result) if Ask::Eval.configuration.track_cost
|
|
150
202
|
end
|
|
151
203
|
|
|
152
|
-
# Assert the response matches the expected output.
|
|
153
|
-
#
|
|
154
|
-
# @param output [String] the LLM response
|
|
155
|
-
# @param expected [String] expected/reference output
|
|
156
|
-
# @param model [Object, nil] judge model
|
|
157
|
-
# @param msg [String, nil] custom failure message
|
|
158
204
|
def assert_correctness(output, expected:, model: nil, msg: nil)
|
|
159
205
|
tc = TestCase.new(actual_output: output, expected_output: expected)
|
|
160
206
|
judge = Judges::Correctness.new(model: model)
|
|
@@ -167,8 +213,12 @@ module Ask
|
|
|
167
213
|
|
|
168
214
|
def log_cost(result)
|
|
169
215
|
return unless result.respond_to?(:cost) && result.cost
|
|
170
|
-
|
|
171
|
-
|
|
216
|
+
Ask::Eval.configuration.cost_tracker.record(
|
|
217
|
+
model: result.respond_to?(:model) ? result.model : "judge",
|
|
218
|
+
input_tokens: 0,
|
|
219
|
+
output_tokens: 0,
|
|
220
|
+
duration: 0
|
|
221
|
+
)
|
|
172
222
|
end
|
|
173
223
|
end
|
|
174
224
|
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Eval
|
|
8
|
+
# Records and replays LLM provider calls for regression testing.
|
|
9
|
+
#
|
|
10
|
+
# Wraps an LLM provider's +chat+ method to capture every request
|
|
11
|
+
# and response. In record mode, interactions are saved to YAML.
|
|
12
|
+
# In replay mode, recorded responses are returned instead of
|
|
13
|
+
# making real API calls.
|
|
14
|
+
#
|
|
15
|
+
# @example Recording
|
|
16
|
+
# recorder = Ask::Eval::Recorder.new(test_name: "health_suite")
|
|
17
|
+
# recorder.wrap(session)
|
|
18
|
+
# session.run("Check health")
|
|
19
|
+
# recorder.save # test/recordings/health_suite/recording.yml
|
|
20
|
+
#
|
|
21
|
+
# @example Replaying
|
|
22
|
+
# recorder = Ask::Eval::Recorder.new(test_name: "health_suite", mode: :replay)
|
|
23
|
+
# recorder.wrap(session)
|
|
24
|
+
# session.run("Check health") # uses recorded response, no API call
|
|
25
|
+
class Recorder
|
|
26
|
+
RECORDINGS_DIR = "test/recordings"
|
|
27
|
+
|
|
28
|
+
attr_reader :test_name, :mode
|
|
29
|
+
|
|
30
|
+
def initialize(test_name:, mode: nil, recordings_dir: nil)
|
|
31
|
+
@test_name = test_name
|
|
32
|
+
@mode = (mode || detect_mode).to_sym
|
|
33
|
+
@recordings_dir = recordings_dir || RECORDINGS_DIR
|
|
34
|
+
@interactions = []
|
|
35
|
+
@replay_queue = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def recording? = @mode == :record
|
|
39
|
+
def replaying? = @mode == :replay
|
|
40
|
+
|
|
41
|
+
# Wrap a Session's provider to record or replay LLM calls.
|
|
42
|
+
def wrap(session)
|
|
43
|
+
recorder = self
|
|
44
|
+
load_recording if replaying?
|
|
45
|
+
|
|
46
|
+
session.chat.define_singleton_method(:build_provider) do
|
|
47
|
+
original = super()
|
|
48
|
+
|
|
49
|
+
proxy = Object.new
|
|
50
|
+
proxy.define_singleton_method(:chat) do |*args, **kwargs, &block|
|
|
51
|
+
if recorder.replaying?
|
|
52
|
+
recorder.replay_as_message
|
|
53
|
+
else
|
|
54
|
+
result = original.chat(*args, **kwargs, &block)
|
|
55
|
+
recorder.record_call(args: args, kwargs: kwargs, result_data: serialize(result))
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
proxy.define_singleton_method(:respond_to?) { |name, *a| original.respond_to?(name, *a) || super(name, *a) }
|
|
61
|
+
proxy
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
session.chat.instance_variable_set(:@provider, nil)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Record a provider call with serialized result data.
|
|
68
|
+
def record_call(args:, kwargs:, result_data:)
|
|
69
|
+
@interactions << {
|
|
70
|
+
messages: scrub_messages(args.first),
|
|
71
|
+
model: kwargs[:model],
|
|
72
|
+
result: result_data
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Replay the next recorded call as an Ask::Message.
|
|
77
|
+
def replay_as_message
|
|
78
|
+
load_recording_if_needed
|
|
79
|
+
|
|
80
|
+
entry = @replay_queue.shift
|
|
81
|
+
unless entry
|
|
82
|
+
raise "No recorded interaction available. Delete #{recording_path} and re-record."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
result = entry["result"]
|
|
86
|
+
|
|
87
|
+
if result["type"] == "stream"
|
|
88
|
+
stream = Ask::Stream.new
|
|
89
|
+
(result["chunks"] || []).each do |c|
|
|
90
|
+
stream.add(Ask::Chunk.new(
|
|
91
|
+
content: c["content"],
|
|
92
|
+
tool_calls: c["tool_calls"],
|
|
93
|
+
finish_reason: c["finish_reason"],
|
|
94
|
+
thinking: c["thinking"]
|
|
95
|
+
))
|
|
96
|
+
end
|
|
97
|
+
stream.finish!
|
|
98
|
+
stream
|
|
99
|
+
else
|
|
100
|
+
Ask::Message.new(
|
|
101
|
+
role: :assistant,
|
|
102
|
+
content: result["content"],
|
|
103
|
+
tool_calls: result["tool_calls"],
|
|
104
|
+
metadata: result["metadata"] || {}
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Replay the next recorded call (returns raw data).
|
|
110
|
+
def replay_call
|
|
111
|
+
load_recording_if_needed
|
|
112
|
+
|
|
113
|
+
entry = @replay_queue.shift
|
|
114
|
+
unless entry
|
|
115
|
+
raise "No recorded interaction available. " \
|
|
116
|
+
"The test may have changed since recording. " \
|
|
117
|
+
"Delete #{recording_path} and re-record."
|
|
118
|
+
end
|
|
119
|
+
entry["result"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Save recorded interactions to disk.
|
|
123
|
+
def save
|
|
124
|
+
return unless recording?
|
|
125
|
+
return if @interactions.empty?
|
|
126
|
+
|
|
127
|
+
dir = File.join(@recordings_dir, sanitize(@test_name))
|
|
128
|
+
FileUtils.mkdir_p(dir)
|
|
129
|
+
|
|
130
|
+
File.write(recording_path, JSON.pretty_generate({
|
|
131
|
+
"test" => @test_name,
|
|
132
|
+
"recorded_at" => Time.now.utc.iso8601,
|
|
133
|
+
"interactions" => @interactions
|
|
134
|
+
}))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def recording_path
|
|
138
|
+
File.join(@recordings_dir, sanitize(@test_name), "recording.yml")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def load_recording_if_needed
|
|
144
|
+
load_recording if @replay_queue.empty? && File.exist?(recording_path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def detect_mode
|
|
148
|
+
case ENV["ASK_EVAL_MODE"]
|
|
149
|
+
when "replay" then :replay
|
|
150
|
+
else :record
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def load_recording
|
|
155
|
+
path = recording_path
|
|
156
|
+
unless File.exist?(path)
|
|
157
|
+
raise "No recording found at #{path}. Run without ASK_EVAL_MODE=replay first."
|
|
158
|
+
end
|
|
159
|
+
data = JSON.parse(File.read(path))
|
|
160
|
+
@replay_queue = (data["interactions"] || []).dup
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def serialize(result)
|
|
164
|
+
if result.respond_to?(:chunks)
|
|
165
|
+
{
|
|
166
|
+
type: "stream",
|
|
167
|
+
accumulated_text: result.accumulated_text,
|
|
168
|
+
chunks: result.chunks.map { |c|
|
|
169
|
+
{
|
|
170
|
+
content: c.content,
|
|
171
|
+
tool_calls: c.tool_calls,
|
|
172
|
+
finish_reason: c.finish_reason,
|
|
173
|
+
thinking: c.respond_to?(:thinking) ? c.thinking : nil
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
elsif result.respond_to?(:content)
|
|
178
|
+
{
|
|
179
|
+
type: "message",
|
|
180
|
+
content: result.content,
|
|
181
|
+
tool_calls: result.tool_calls,
|
|
182
|
+
metadata: result.metadata
|
|
183
|
+
}
|
|
184
|
+
else
|
|
185
|
+
{ type: "raw", data: result.to_s }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def scrub_messages(messages)
|
|
190
|
+
return messages unless messages.is_a?(Array)
|
|
191
|
+
messages.map { |m|
|
|
192
|
+
next m unless m.is_a?(Hash)
|
|
193
|
+
{ role: m[:role] || m["role"], content: m[:content] || m["content"] }
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sanitize(name)
|
|
198
|
+
name.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Eval
|
|
5
|
+
# Evaluation wrapper around an {Ask::Agent::Session} for testing.
|
|
6
|
+
#
|
|
7
|
+
# Tracks tool calls, cost, and responses. Integrates with the
|
|
8
|
+
# {Recorder} for regression testing.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# eval = Ask::Eval::SessionEval.new(session)
|
|
12
|
+
# response = eval.run("Check health")
|
|
13
|
+
# eval.tool_called?("bash") # => true/false
|
|
14
|
+
# eval.total_cost # => 0.0012
|
|
15
|
+
class SessionEval
|
|
16
|
+
# @return [Ask::Agent::Session] the wrapped session
|
|
17
|
+
attr_reader :session
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] tool names that were called
|
|
20
|
+
attr_reader :tool_calls_made
|
|
21
|
+
|
|
22
|
+
# @return [Float] total cost of all LLM calls
|
|
23
|
+
attr_reader :total_cost
|
|
24
|
+
|
|
25
|
+
# @param session [Ask::Agent::Session] a configured session
|
|
26
|
+
# @param recorder [Recorder, nil] optional recorder for regression testing
|
|
27
|
+
def initialize(session, recorder: nil)
|
|
28
|
+
@session = session
|
|
29
|
+
@recorder = recorder
|
|
30
|
+
@tool_calls_made = []
|
|
31
|
+
@total_cost = 0.0
|
|
32
|
+
@last_response = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Run the session with a prompt and track results.
|
|
36
|
+
# @param prompt [String] the user prompt
|
|
37
|
+
# @return [String] the session response
|
|
38
|
+
def run(prompt)
|
|
39
|
+
@last_response = @session.run(prompt)
|
|
40
|
+
@tool_calls_made = @session.tool_calls_made || []
|
|
41
|
+
@total_cost = @session.total_cost.to_f
|
|
42
|
+
@last_response
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] the last response text
|
|
46
|
+
def last_response
|
|
47
|
+
@last_response
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param tool_name [String] tool name to check
|
|
51
|
+
# @return [Boolean] whether the tool was called
|
|
52
|
+
def tool_called?(tool_name)
|
|
53
|
+
@tool_calls_made.any? { |tc|
|
|
54
|
+
tc.respond_to?(:name) ? tc.name == tool_name : tc.to_s == tool_name
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array<String>] names of tools that were called
|
|
59
|
+
def tool_names
|
|
60
|
+
@tool_calls_made.map { |tc|
|
|
61
|
+
tc.respond_to?(:name) ? tc.name : tc.to_s
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether the agent is still running
|
|
66
|
+
def running?
|
|
67
|
+
@session.running?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/ask/eval/version.rb
CHANGED
data/lib/ask/eval.rb
CHANGED
|
@@ -12,6 +12,8 @@ module Ask
|
|
|
12
12
|
autoload :Runner, "ask/eval/runner"
|
|
13
13
|
autoload :CostTracker, "ask/eval/cost_tracker"
|
|
14
14
|
autoload :Configuration, "ask/eval/configuration"
|
|
15
|
+
autoload :Recorder, "ask/eval/recorder"
|
|
16
|
+
autoload :SessionEval, "ask/eval/session_eval"
|
|
15
17
|
|
|
16
18
|
# These are loaded eagerly since they define sub-modules with autoloads
|
|
17
19
|
require_relative "eval/assertions"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-eval
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -78,10 +78,12 @@ files:
|
|
|
78
78
|
- lib/ask/eval/judges/hallucination.rb
|
|
79
79
|
- lib/ask/eval/judges/toxicity.rb
|
|
80
80
|
- lib/ask/eval/minitest.rb
|
|
81
|
+
- lib/ask/eval/recorder.rb
|
|
81
82
|
- lib/ask/eval/reporters/console.rb
|
|
82
83
|
- lib/ask/eval/reporters/github.rb
|
|
83
84
|
- lib/ask/eval/reporters/junit.rb
|
|
84
85
|
- lib/ask/eval/runner.rb
|
|
86
|
+
- lib/ask/eval/session_eval.rb
|
|
85
87
|
- lib/ask/eval/test_case.rb
|
|
86
88
|
- lib/ask/eval/version.rb
|
|
87
89
|
homepage: https://github.com/ask-rb/ask-eval
|