ask-eval 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bade74b9a66f3d955fea90e17535033015f504f0b6221c332a07c7c947a486c1
4
- data.tar.gz: 228d85c034b3f9f50fef305c0a5422179959e98906cc3262306bde76219bbabe
3
+ metadata.gz: 12e438efcef2038b328af35cdb14d6741a7730940f1d8f7014f4550c3b8ac543
4
+ data.tar.gz: c32aaf27235fdd33e206ebc4434ed647015c8fa6d81a8b16b88546c36597f4d2
5
5
  SHA512:
6
- metadata.gz: f393cad79fb781b4b76caa6e3bc036dd8b2787d6389dd9b59bdf23d01f025fbd5dbda0e4b4068ef3abd313e62f1f4c063abee67876b56c5c860a16ee775c3c5b
7
- data.tar.gz: e8b4d2cb025fbb53cf9d76db336b636c6583c759a76e4b90771e384064e7e57926b4e526dc78c7d0689f7d5b29289998ab4c789345a3e35d29373cf5ac433c19
6
+ metadata.gz: 015cd82d67ac7fcf88c1661d352c459845ffbee6dc373e2d9999db68d8256cb1e63988618e47263ee59eb887fa7381e42b30b0b3fcc4c1fac4cdc802891f08b2
7
+ data.tar.gz: 8daf2d3ccc31244b1e3748ee2f1b07970a9c1af12592e9e6bddfdafaea980816961f4099f4382f5704bb47554e8036185ce4131fe4494400b9ba0b59bec4b026
data/CHANGELOG.md CHANGED
@@ -1,3 +1,62 @@
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
+
14
+ ## [0.2.0] — 2026-07-21
15
+
16
+ ### Added
17
+
18
+ - **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.
19
+
20
+ ```ruby
21
+ recorder = Ask::Eval::Recorder.new(test_name: "my_suite")
22
+ recorder.wrap(session)
23
+ session.run("Check health")
24
+ recorder.save # test/recordings/my_suite/recording.json
25
+ ```
26
+
27
+ - **Session evaluation** — `Ask::Eval::SessionEval` wraps an `Ask::Agent::Session` for testing. Tracks tool calls and costs.
28
+
29
+ ```ruby
30
+ eval = Ask::Eval::SessionEval.new(session)
31
+ eval.run("Check health")
32
+ eval.tool_called?("bash") # => true/false
33
+ eval.total_cost # => 0.0012
34
+ ```
35
+
36
+ - **Minitest DSL additions** — `eval_session` for agent integration testing with automatic recording:
37
+
38
+ ```ruby
39
+ test "health check agent" do
40
+ eval_session(model: "gpt-4o", tools: [Bash]) do |r|
41
+ r.run("Check health")
42
+ assert_tool_called "bash"
43
+ assert_cost_under 0.01
44
+ end
45
+ end
46
+ ```
47
+
48
+ New assertions: `assert_tool_called`, `assert_cost_under`.
49
+
50
+ ### Fixed
51
+
52
+ - **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.
53
+ - **Recorder serialization** — Switched from YAML to JSON to avoid Ruby symbol key issues.
54
+
55
+ ### Tested
56
+
57
+ - 12 new Recorder tests: recording, replaying, file I/O, stream/message types, error handling.
58
+ - 139 total tests, 263 assertions — 0 failures.
59
+
1
60
  ## [0.1.1] - 2026-06-25
2
61
 
3
62
  ### Changed
data/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/ask-eval.svg)](https://badge.fury.io/rb/ask-eval)
4
4
 
5
- LLM evaluation framework for Ruby. Minitest-native assertions for testing
6
- LLM outputs. LLM-as-judge for faithfulness, hallucination, bias, and toxicity.
7
- Deterministic assertions for basic checks. CI-native output.
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
- ## Deterministic Assertions
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
- ```ruby
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
- ## LLM-as-Judge Assertions
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
- ```ruby
57
- assert_faithful response, context: docs # faithful to source?
58
- assert_not_hallucinating response, context: docs # made-up info?
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
- These require a judge model. Pass one per assertion or configure globally:
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 = model # any callable, Ask::Provider instance, or model string
56
+ c.default_judge = "openai/gpt-4o-mini" # any callable, Ask::Provider, or model string
70
57
  end
71
58
  ```
72
59
 
73
- Or pass a model directly to each assertion:
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
- For automatic inclusion in all Minitest tests, use the plugin:
62
+ Evaluate an `Ask::Agent::Session` with the `eval_session` DSL:
98
63
 
99
64
  ```ruby
100
- # test/test_helper.rb
101
- require "ask/eval/minitest"
102
- # Now ALL test classes have assert_faithful, assert_contains, etc.
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
- ## CI Integration
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
- **JUnit XML** (works with Jenkins, CircleCI, GitLab CI):
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
- ```ruby
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
- **GitHub Actions** annotations on PRs:
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
- ```ruby
118
- reporter = Ask::Eval::Reporters::GitHub.new(results)
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
- ## Cost Tracking
90
+ ## Full documentation
123
91
 
124
- ```ruby
125
- Ask::Eval.configure do |c|
126
- c.track_cost = true
127
- end
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
- ## Running Tests
97
+ ## Development
134
98
 
135
- ```bash
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
-
@@ -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,256 @@
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
+ type: "provider",
71
+ messages: scrub_messages(args.first),
72
+ model: kwargs[:model],
73
+ result: result_data
74
+ }
75
+ end
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.
91
+ def replay_as_message
92
+ load_recording_if_needed
93
+
94
+ entry = @replay_queue.shift
95
+ unless entry
96
+ raise "No recorded interaction available. Delete #{recording_path} and re-record."
97
+ end
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
+
104
+ result = entry["result"]
105
+
106
+ if result["type"] == "stream"
107
+ stream = Ask::Stream.new
108
+ (result["chunks"] || []).each do |c|
109
+ stream.add(Ask::Chunk.new(
110
+ content: c["content"],
111
+ tool_calls: c["tool_calls"],
112
+ finish_reason: c["finish_reason"],
113
+ thinking: c["thinking"]
114
+ ))
115
+ end
116
+ stream.finish!
117
+ stream
118
+ else
119
+ Ask::Message.new(
120
+ role: :assistant,
121
+ content: result["content"],
122
+ tool_calls: result["tool_calls"],
123
+ metadata: result["metadata"] || {}
124
+ )
125
+ end
126
+ end
127
+
128
+ # Replay the next recorded call (returns raw data).
129
+ def replay_call
130
+ load_recording_if_needed
131
+
132
+ entry = @replay_queue.shift
133
+ unless entry
134
+ raise "No recorded interaction available. " \
135
+ "The test may have changed since recording. " \
136
+ "Delete #{recording_path} and re-record."
137
+ end
138
+ entry["result"]
139
+ end
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
+
167
+ # Save recorded interactions to disk.
168
+ def save
169
+ return unless recording?
170
+ return if @interactions.empty?
171
+
172
+ dir = File.join(@recordings_dir, sanitize(@test_name))
173
+ FileUtils.mkdir_p(dir)
174
+
175
+ File.write(recording_path, JSON.pretty_generate({
176
+ "test" => @test_name,
177
+ "recorded_at" => Time.now.utc.iso8601,
178
+ "interactions" => @interactions
179
+ }))
180
+ end
181
+
182
+ def recording_path
183
+ File.join(@recordings_dir, sanitize(@test_name), "recording.yml")
184
+ end
185
+
186
+ private
187
+
188
+ def load_recording_if_needed
189
+ load_recording if @replay_queue.empty? && File.exist?(recording_path)
190
+ end
191
+
192
+ def detect_mode
193
+ case ENV["ASK_EVAL_MODE"]
194
+ when "replay" then :replay
195
+ else :record
196
+ end
197
+ end
198
+
199
+ def load_recording
200
+ path = recording_path
201
+ unless File.exist?(path)
202
+ raise "No recording found at #{path}. Run without ASK_EVAL_MODE=replay first."
203
+ end
204
+ data = JSON.parse(File.read(path))
205
+ @replay_queue = (data["interactions"] || []).dup
206
+ end
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
+
217
+ def serialize(result)
218
+ if result.respond_to?(:chunks)
219
+ {
220
+ type: "stream",
221
+ accumulated_text: result.accumulated_text,
222
+ chunks: result.chunks.map { |c|
223
+ {
224
+ content: c.content,
225
+ tool_calls: c.tool_calls,
226
+ finish_reason: c.finish_reason,
227
+ thinking: c.respond_to?(:thinking) ? c.thinking : nil
228
+ }
229
+ }
230
+ }
231
+ elsif result.respond_to?(:content)
232
+ {
233
+ type: "message",
234
+ content: result.content,
235
+ tool_calls: result.tool_calls,
236
+ metadata: result.metadata
237
+ }
238
+ else
239
+ { type: "raw", data: result.to_s }
240
+ end
241
+ end
242
+
243
+ def scrub_messages(messages)
244
+ return messages unless messages.is_a?(Array)
245
+ messages.map { |m|
246
+ next m unless m.is_a?(Hash)
247
+ { role: m[:role] || m["role"], content: m[:content] || m["content"] }
248
+ }
249
+ end
250
+
251
+ def sanitize(name)
252
+ name.gsub(/[^a-zA-Z0-9_-]/, "_")
253
+ end
254
+ end
255
+ end
256
+ 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.1"
3
+ VERSION = "0.3.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.1
4
+ version: 0.3.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