ask-eval 0.2.0 → 0.4.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: f3fd797bd764c1d47e8b95579fc37d9c4c3b3811c01fd858e2e83f9d76cc7b3a
4
- data.tar.gz: 5f11f8a81f421a83b78ffbcf3518aff828719156cefe4f8f97399f35a7a92dbe
3
+ metadata.gz: d396e80eace74425011173335621532f318eb2921600aa8cfcdf1e79480f4df6
4
+ data.tar.gz: afc9c5a9873ba2b97766cd873211d4e2e94334c9adc8a39b372f4981a84e0fb1
5
5
  SHA512:
6
- metadata.gz: cdc1212c80cfec4eeded7812f6346e1b398bec946eef6b40b0207c18215dd0350a6ef5aa93e6441343fa7c1013072678d8d645b5e7d059eb076a9277faf27db9
7
- data.tar.gz: c5a3dfecc70f7d8d387279ff716ef5f9a5fd380058041daad08a0ab77a31602e451c87a7e6cbd844d5aa52634c0322c9390de21a0ff53404cb4737abbddaca76
6
+ metadata.gz: 88ca899713aecf0c9eec29f6470a7b0856e648f9cf54337d9252364c4ae01cd3df8f1040f5e534db7f2635e3ab8de365957858b01a11fd81d021acb81b82dde3
7
+ data.tar.gz: e05a44bd73f3f0236349cead4fa6f87bfe035a772f755b9ea379c3f165b01e78bb25f0cc28acbea443dc33d458031b62586bdeb61af7b35f14c897fa147998f6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.4.0] — 2026-08-07
2
+
3
+ ### Added
4
+
5
+ - **Datasets & experiment runs — the A/B-testing loop for agents.**
6
+ - `Ask::Eval::Dataset` — a pinned set of fixed inputs (like fixtures, but
7
+ the items are prompts/tasks fed to a live agent). `add` items with
8
+ optional `expected` output, `context`, `tags`, and `metadata`;
9
+ `Dataset.save` / `Dataset.load` persist to JSON files.
10
+ - `Ask::Eval::Experiment` — runs a dataset against a single variant: a
11
+ `runner:` callable turns each input into the agent's output, an
12
+ optional `scorer:` callable scores it (0..1), and per-item results
13
+ record output, score, error, and duration. Runner errors are captured
14
+ per item and the run continues.
15
+ - `Experiment#summary` (total/passed/failed/avg_score/duration) and
16
+ `Experiment#compare(other)` — side-by-side per-item deltas plus an
17
+ aggregate verdict ("a" / "b" / "tie"), so changing a prompt or model
18
+ and re-running the same dataset shows the improvement.
19
+
20
+ ## [0.3.0] — 2026-08-03
21
+
22
+ ### Added
23
+
24
+ - **Tool execution recording/replay.** The Recorder now tapes tool executions
25
+ alongside provider calls (`record_tool_call` / `replay_tool_call`), so a
26
+ multi-turn agent run replays as a faithful tape — tool results are replayed
27
+ instead of re-executed, which keeps the loop deterministic even when a tool
28
+ would behave differently on a second run (transient failures, changing
29
+ files, temp paths). Provider entries are tagged `type: "provider"` and
30
+ replay raises a clear "replay diverged" error if the run takes a different
31
+ path than the recording.
32
+
1
33
  ## [0.2.0] — 2026-07-21
2
34
 
3
35
  ### Added
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
-
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Eval
7
+ # A pinned set of fixed inputs for repeatable agent evaluation.
8
+ #
9
+ # Like Rails fixtures, a dataset gives you a deterministic baseline —
10
+ # but the items are *questions* (prompts/tasks fed to a live agent),
11
+ # not database records, and the point is running the same items against
12
+ # multiple variants (a changed prompt, a different model) and comparing
13
+ # the results.
14
+ #
15
+ # @example
16
+ # dataset = Ask::Eval::Dataset.new("support-cases")
17
+ # dataset.add(input: "The API returns 401 on stale tokens — how do I fix my auth flow?", tags: ["auth"])
18
+ # dataset.add(input: "Summarize this thread and suggest next steps", expected: "A summary plus actions")
19
+ # dataset.save("support-cases.json")
20
+ #
21
+ # reloaded = Ask::Eval::Dataset.load("support-cases.json")
22
+ class Dataset
23
+ # A single dataset item: the input fed to the agent, optional expected
24
+ # output, optional context, and tags/metadata for filtering.
25
+ Item = Data.define(:id, :input, :expected, :context, :tags, :metadata) do
26
+ def to_h = { id: id, input: input, expected: expected, context: context, tags: tags, metadata: metadata }
27
+ end
28
+
29
+ # @param name [String] dataset name (used in reports)
30
+ # @param items [Array<Hash>] initial items with symbol or string keys
31
+ # ({input:, expected:, context:, tags:, metadata:})
32
+ def initialize(name, items: [])
33
+ @name = name.to_s
34
+ @items = []
35
+ items.each { |item| add(**symbolize(item)) }
36
+ end
37
+
38
+ # @return [String] the dataset name
39
+ attr_reader :name
40
+
41
+ # Add an item.
42
+ #
43
+ # @param input [String] the prompt/task fed to the agent (required)
44
+ # @param expected [String, nil] expected/reference output
45
+ # @param context [String, nil] source context
46
+ # @param tags [Array<String>] labels for filtering
47
+ # @param metadata [Hash] free-form
48
+ # @return [Item]
49
+ # @raise [ArgumentError] on empty input
50
+ def add(input:, id: nil, expected: nil, context: nil, tags: [], metadata: {})
51
+ raise ArgumentError, "input is required" if input.to_s.strip.empty?
52
+
53
+ item = Item.new(
54
+ id: id || "item_#{@items.size + 1}",
55
+ input: input.to_s,
56
+ expected: expected,
57
+ context: context,
58
+ tags: Array(tags),
59
+ metadata: metadata
60
+ )
61
+ @items << item
62
+ item
63
+ end
64
+
65
+ # @return [Array<Item>] all items, in order
66
+ def items = @items.dup
67
+
68
+ # @return [Integer] number of items
69
+ def size = @items.size
70
+
71
+ def each(&block) = @items.each(&block)
72
+
73
+ # Create an experiment over this dataset (see {Experiment}).
74
+ #
75
+ # @param runner [Proc] called with the item's input; returns the
76
+ # agent's output string
77
+ # @param scorer [Proc, nil] called with (input:, output:, expected:);
78
+ # returns a score (0..1)
79
+ # @return [Ask::Eval::Experiment]
80
+ def experiment(runner:, scorer: nil)
81
+ Experiment.new(self, runner: runner, scorer: scorer)
82
+ end
83
+
84
+ # @return [Hash] serialized form (for {Dataset.save})
85
+ def to_h
86
+ { name: @name, items: @items.map(&:to_h) }
87
+ end
88
+
89
+ # Write the dataset to a JSON file.
90
+ #
91
+ # @param path [String]
92
+ # @return [void]
93
+ def save(path)
94
+ File.write(path, JSON.pretty_generate(to_h))
95
+ nil
96
+ end
97
+
98
+ # Load a dataset from a JSON file written by {Dataset.save} (or any
99
+ # compatible {name:, items:} shape).
100
+ #
101
+ # @param path [String]
102
+ # @return [Ask::Eval::Dataset]
103
+ def self.load(path)
104
+ data = JSON.parse(File.read(path))
105
+ new(data["name"] || File.basename(path, ".json"), items: data["items"] || [])
106
+ end
107
+
108
+ private
109
+
110
+ def symbolize(obj)
111
+ case obj
112
+ when Hash
113
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
114
+ when Array
115
+ obj.map { |e| symbolize(e) }
116
+ else
117
+ obj
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Eval
5
+ # Runs a {Dataset} against a single variant and compares runs.
6
+ #
7
+ # An experiment executes every dataset item through a +runner+ (a
8
+ # callable that turns an input into the agent's output), optionally
9
+ # scores each output, and collects per-item results. Running the same
10
+ # dataset with two different runners (a changed prompt, a different
11
+ # model) and comparing the experiments is the A/B-testing loop of
12
+ # eval-driven development.
13
+ #
14
+ # @example
15
+ # run_a = dataset.experiment(runner: ->(input) { agent(input, prompt: old) },
16
+ # scorer: judge_scorer)
17
+ # run_b = dataset.experiment(runner: ->(input) { agent(input, prompt: new) },
18
+ # scorer: judge_scorer)
19
+ # run_a.run
20
+ # run_a.summary # => {total:, passed:, failed:, avg_score:, ...}
21
+ # run_a.compare(run_b) # => per-item side-by-side + aggregate verdict
22
+ class Experiment
23
+ # One item's outcome: the produced output, optional score (0..1),
24
+ # error (when the runner raised), and wall-clock duration.
25
+ Result = Data.define(:item, :output, :score, :error, :duration_ms) do
26
+ # @return [Boolean] no error and, when scored, at least 0.5
27
+ def passed?
28
+ error.nil? && (score.nil? || score >= 0.5)
29
+ end
30
+ end
31
+
32
+ # @param dataset [Ask::Eval::Dataset]
33
+ # @param runner [Proc] called with the item's input; returns the
34
+ # agent's output string
35
+ # @param scorer [Proc, nil] called with (input:, output:, expected:);
36
+ # returns a score (0..1) or nil to skip scoring
37
+ def initialize(dataset, runner:, scorer: nil)
38
+ @dataset = dataset
39
+ @runner = runner
40
+ @scorer = scorer
41
+ @results = nil
42
+ end
43
+
44
+ # @return [Ask::Eval::Dataset]
45
+ attr_reader :dataset
46
+
47
+ # Execute every dataset item through the runner. A runner error is
48
+ # recorded on that item and the run continues.
49
+ #
50
+ # @return [self]
51
+ def run
52
+ @results = @dataset.items.map do |item|
53
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ output = nil
55
+ error = nil
56
+ begin
57
+ output = @runner.call(item.input).to_s
58
+ rescue StandardError => e
59
+ error = e.message
60
+ end
61
+
62
+ score = nil
63
+ if error.nil? && @scorer
64
+ score = @scorer.call(input: item.input, output: output, expected: item.expected)
65
+ end
66
+
67
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
68
+ Result.new(item: item, output: output, score: score, error: error, duration_ms: duration_ms)
69
+ end
70
+ self
71
+ end
72
+
73
+ # @return [Array<Result>] per-item results (runs first if needed)
74
+ def results
75
+ @results || run.results
76
+ end
77
+
78
+ # @return [Hash] aggregate statistics
79
+ def summary
80
+ rs = results
81
+ scores = rs.filter_map(&:score)
82
+ {
83
+ total: rs.size,
84
+ passed: rs.count(&:passed?),
85
+ failed: rs.count { |r| !r.passed? },
86
+ avg_score: scores.empty? ? nil : (scores.sum.fdiv(scores.size)).round(3),
87
+ total_duration_ms: rs.sum(&:duration_ms)
88
+ }
89
+ end
90
+
91
+ # Side-by-side comparison with another experiment over the same (or
92
+ # overlapping) dataset items.
93
+ #
94
+ # @param other [Ask::Eval::Experiment]
95
+ # @return [Hash] {deltas: [{item_id:, input:, a:, b:, delta:}],
96
+ # a: summary, b: summary, verdict: "a"|"b"|"tie"}
97
+ def compare(other)
98
+ a = results
99
+ b = other.results
100
+ ha = a.to_h { |r| [r.item.id, r] }
101
+ hb = b.to_h { |r| [r.item.id, r] }
102
+
103
+ deltas = (ha.keys & hb.keys).map do |id|
104
+ ra = ha[id]
105
+ rb = hb[id]
106
+ {
107
+ item_id: id,
108
+ input: ra.item.input,
109
+ a: { output: ra.output, score: ra.score, error: ra.error },
110
+ b: { output: rb.output, score: rb.score, error: rb.error },
111
+ delta: (ra.score && rb.score) ? (rb.score - ra.score).round(3) : nil
112
+ }
113
+ end
114
+
115
+ { deltas: deltas, a: summary, b: other.summary, verdict: verdict(summary, other.summary) }
116
+ end
117
+
118
+ private
119
+
120
+ def verdict(a, b)
121
+ return nil if a[:avg_score].nil? || b[:avg_score].nil?
122
+ return "a" if a[:avg_score] > b[:avg_score]
123
+ return "b" if b[:avg_score] > a[:avg_score]
124
+
125
+ "tie"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require "time" # Time#iso8601 for recorded_at timestamps
5
6
 
6
7
  module Ask
7
8
  module Eval
@@ -67,13 +68,27 @@ module Ask
67
68
  # Record a provider call with serialized result data.
68
69
  def record_call(args:, kwargs:, result_data:)
69
70
  @interactions << {
71
+ type: "provider",
70
72
  messages: scrub_messages(args.first),
71
73
  model: kwargs[:model],
72
74
  result: result_data
73
75
  }
74
76
  end
75
77
 
76
- # Replay the next recorded call as an Ask::Message.
78
+ # Record a tool execution with serialized result data. Tool calls are
79
+ # interleaved with provider calls in the same order they happened, so a
80
+ # multi-turn agent run replays faithfully — tool results are replayed,
81
+ # not re-executed.
82
+ def record_tool_call(name:, args:, result_data:)
83
+ @interactions << {
84
+ type: "tool",
85
+ name: name,
86
+ args: args,
87
+ result: result_data
88
+ }
89
+ end
90
+
91
+ # Replay the next recorded provider call as an Ask::Message.
77
92
  def replay_as_message
78
93
  load_recording_if_needed
79
94
 
@@ -82,6 +97,11 @@ module Ask
82
97
  raise "No recorded interaction available. Delete #{recording_path} and re-record."
83
98
  end
84
99
 
100
+ if entry["type"] == "tool"
101
+ raise "Replay diverged: expected a provider call, but the next recorded interaction is a tool call. " \
102
+ "The run took a different path than the recording. Delete #{recording_path} and re-record."
103
+ end
104
+
85
105
  result = entry["result"]
86
106
 
87
107
  if result["type"] == "stream"
@@ -119,6 +139,32 @@ module Ask
119
139
  entry["result"]
120
140
  end
121
141
 
142
+ # Replay the next recorded tool execution, returning its original
143
+ # Ask::Result without executing the tool again.
144
+ def replay_tool_call
145
+ load_recording_if_needed
146
+
147
+ entry = @replay_queue.shift
148
+ unless entry
149
+ raise "No recorded tool interaction available. Delete #{recording_path} and re-record."
150
+ end
151
+ unless entry["type"] == "tool"
152
+ raise "Replay diverged: expected a tool call, but the next recorded interaction is a provider call. " \
153
+ "The run took a different path than the recording. Delete #{recording_path} and re-record."
154
+ end
155
+
156
+ result = entry["result"]
157
+ if result["type"] == "result"
158
+ if result["ok"]
159
+ Ask::Result.ok(data: result["output"], metadata: result["metadata"] || {})
160
+ else
161
+ Ask::Result.error(message: result["error"] || "", metadata: result["metadata"] || {})
162
+ end
163
+ else
164
+ result["data"]
165
+ end
166
+ end
167
+
122
168
  # Save recorded interactions to disk.
123
169
  def save
124
170
  return unless recording?
@@ -160,6 +206,15 @@ module Ask
160
206
  @replay_queue = (data["interactions"] || []).dup
161
207
  end
162
208
 
209
+ # Serialize a tool's Ask::Result so it can be replayed later.
210
+ def serialize_tool_result(result)
211
+ if result.is_a?(Ask::Result)
212
+ result.to_h.merge(type: "result")
213
+ else
214
+ { type: "raw", data: result }
215
+ end
216
+ end
217
+
163
218
  def serialize(result)
164
219
  if result.respond_to?(:chunks)
165
220
  {
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module Eval
3
- VERSION = "0.2.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
data/lib/ask/eval.rb CHANGED
@@ -14,6 +14,8 @@ module Ask
14
14
  autoload :Configuration, "ask/eval/configuration"
15
15
  autoload :Recorder, "ask/eval/recorder"
16
16
  autoload :SessionEval, "ask/eval/session_eval"
17
+ autoload :Dataset, "ask/eval/dataset"
18
+ autoload :Experiment, "ask/eval/experiment"
17
19
 
18
20
  # These are loaded eagerly since they define sub-modules with autoloads
19
21
  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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ask-core
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
54
68
  description: 'Test LLM outputs with Minitest-native assertions. LLM-as-judge for faithfulness,
55
69
  hallucination, bias, toxicity. Deterministic assertions (contains, regex, JSON).
56
70
  CI-native: GitHub annotations, JUnit output, cost tracking.'
@@ -70,7 +84,9 @@ files:
70
84
  - lib/ask/eval/assertions/judge.rb
71
85
  - lib/ask/eval/configuration.rb
72
86
  - lib/ask/eval/cost_tracker.rb
87
+ - lib/ask/eval/dataset.rb
73
88
  - lib/ask/eval/dsl.rb
89
+ - lib/ask/eval/experiment.rb
74
90
  - lib/ask/eval/judge.rb
75
91
  - lib/ask/eval/judges/bias.rb
76
92
  - lib/ask/eval/judges/correctness.rb