ask-eval 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3fd797bd764c1d47e8b95579fc37d9c4c3b3811c01fd858e2e83f9d76cc7b3a
4
- data.tar.gz: 5f11f8a81f421a83b78ffbcf3518aff828719156cefe4f8f97399f35a7a92dbe
3
+ metadata.gz: 12e438efcef2038b328af35cdb14d6741a7730940f1d8f7014f4550c3b8ac543
4
+ data.tar.gz: c32aaf27235fdd33e206ebc4434ed647015c8fa6d81a8b16b88546c36597f4d2
5
5
  SHA512:
6
- metadata.gz: cdc1212c80cfec4eeded7812f6346e1b398bec946eef6b40b0207c18215dd0350a6ef5aa93e6441343fa7c1013072678d8d645b5e7d059eb076a9277faf27db9
7
- data.tar.gz: c5a3dfecc70f7d8d387279ff716ef5f9a5fd380058041daad08a0ab77a31602e451c87a7e6cbd844d5aa52634c0322c9390de21a0ff53404cb4737abbddaca76
6
+ metadata.gz: 015cd82d67ac7fcf88c1661d352c459845ffbee6dc373e2d9999db68d8256cb1e63988618e47263ee59eb887fa7381e42b30b0b3fcc4c1fac4cdc802891f08b2
7
+ data.tar.gz: 8daf2d3ccc31244b1e3748ee2f1b07970a9c1af12592e9e6bddfdafaea980816961f4099f4382f5704bb47554e8036185ce4131fe4494400b9ba0b59bec4b026
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.3.0] — 2026-08-03
2
+
3
+ ### Added
4
+
5
+ - **Tool execution recording/replay.** The Recorder now tapes tool executions
6
+ alongside provider calls (`record_tool_call` / `replay_tool_call`), so a
7
+ multi-turn agent run replays as a faithful tape — tool results are replayed
8
+ instead of re-executed, which keeps the loop deterministic even when a tool
9
+ would behave differently on a second run (transient failures, changing
10
+ files, temp paths). Provider entries are tagged `type: "provider"` and
11
+ replay raises a clear "replay diverged" error if the run takes a different
12
+ path than the recording.
13
+
1
14
  ## [0.2.0] — 2026-07-21
2
15
 
3
16
  ### Added
data/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  [![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
-
@@ -67,13 +67,27 @@ module Ask
67
67
  # Record a provider call with serialized result data.
68
68
  def record_call(args:, kwargs:, result_data:)
69
69
  @interactions << {
70
+ type: "provider",
70
71
  messages: scrub_messages(args.first),
71
72
  model: kwargs[:model],
72
73
  result: result_data
73
74
  }
74
75
  end
75
76
 
76
- # Replay the next recorded call as an Ask::Message.
77
+ # Record a tool execution with serialized result data. Tool calls are
78
+ # interleaved with provider calls in the same order they happened, so a
79
+ # multi-turn agent run replays faithfully — tool results are replayed,
80
+ # not re-executed.
81
+ def record_tool_call(name:, args:, result_data:)
82
+ @interactions << {
83
+ type: "tool",
84
+ name: name,
85
+ args: args,
86
+ result: result_data
87
+ }
88
+ end
89
+
90
+ # Replay the next recorded provider call as an Ask::Message.
77
91
  def replay_as_message
78
92
  load_recording_if_needed
79
93
 
@@ -82,6 +96,11 @@ module Ask
82
96
  raise "No recorded interaction available. Delete #{recording_path} and re-record."
83
97
  end
84
98
 
99
+ if entry["type"] == "tool"
100
+ raise "Replay diverged: expected a provider call, but the next recorded interaction is a tool call. " \
101
+ "The run took a different path than the recording. Delete #{recording_path} and re-record."
102
+ end
103
+
85
104
  result = entry["result"]
86
105
 
87
106
  if result["type"] == "stream"
@@ -119,6 +138,32 @@ module Ask
119
138
  entry["result"]
120
139
  end
121
140
 
141
+ # Replay the next recorded tool execution, returning its original
142
+ # Ask::Result without executing the tool again.
143
+ def replay_tool_call
144
+ load_recording_if_needed
145
+
146
+ entry = @replay_queue.shift
147
+ unless entry
148
+ raise "No recorded tool interaction available. Delete #{recording_path} and re-record."
149
+ end
150
+ unless entry["type"] == "tool"
151
+ raise "Replay diverged: expected a tool call, but the next recorded interaction is a provider call. " \
152
+ "The run took a different path than the recording. Delete #{recording_path} and re-record."
153
+ end
154
+
155
+ result = entry["result"]
156
+ if result["type"] == "result"
157
+ if result["ok"]
158
+ Ask::Result.ok(data: result["output"], metadata: result["metadata"] || {})
159
+ else
160
+ Ask::Result.error(message: result["error"] || "", metadata: result["metadata"] || {})
161
+ end
162
+ else
163
+ result["data"]
164
+ end
165
+ end
166
+
122
167
  # Save recorded interactions to disk.
123
168
  def save
124
169
  return unless recording?
@@ -160,6 +205,15 @@ module Ask
160
205
  @replay_queue = (data["interactions"] || []).dup
161
206
  end
162
207
 
208
+ # Serialize a tool's Ask::Result so it can be replayed later.
209
+ def serialize_tool_result(result)
210
+ if result.is_a?(Ask::Result)
211
+ result.to_h.merge(type: "result")
212
+ else
213
+ { type: "raw", data: result }
214
+ end
215
+ end
216
+
163
217
  def serialize(result)
164
218
  if result.respond_to?(:chunks)
165
219
  {
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module Eval
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto