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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 255e463739c832d6784d161c3f6b26b267dbaaf6e29b27268ac8b49bd3a3fa05
4
- data.tar.gz: 842364b8ea52fbae673bd53ebec870627481cac8a9214b98d92476daa609789c
3
+ metadata.gz: f3fd797bd764c1d47e8b95579fc37d9c4c3b3811c01fd858e2e83f9d76cc7b3a
4
+ data.tar.gz: 5f11f8a81f421a83b78ffbcf3518aff828719156cefe4f8f97399f35a7a92dbe
5
5
  SHA512:
6
- metadata.gz: dade52a1b23dc7415788d02c858ee3ca2a0c14739a45f2bcb172b72818cc003db215c2bda019cf68fe072fdc8f40d3aa0a1ae8628df98e5e9e52b435c14c0d0b
7
- data.tar.gz: e82d90fb63e88693df7f5c4e1c89dbb166ade6617bfa3980a0a7ba1cfdcc355b7d8abcc458882d60a84d701f98c21cb741c65b586399b1826701c061b3123936
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 should NOT be a port of ruby_llm-tribunal.** See the comparison:
141
+ **This gem is NOT a port of ruby_llm-tribunal.** See the comparison below:
142
142
 
143
- | ruby_llm-tribunal (~500 lines, 25+ files) | ask-eval (~300 lines, 10 files) |
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
- # CostTracker handles the recording via DSL methods
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
- # Accumulate in the configuration's cost tracker
171
- Ask::Eval.configuration._accumulate_cost(result.cost)
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
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module Eval
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
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.1.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