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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +43 -150
- data/lib/ask/eval/dataset.rb +122 -0
- data/lib/ask/eval/experiment.rb +129 -0
- data/lib/ask/eval/recorder.rb +56 -1
- data/lib/ask/eval/version.rb +1 -1
- data/lib/ask/eval.rb +2 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d396e80eace74425011173335621532f318eb2921600aa8cfcdf1e79480f4df6
|
|
4
|
+
data.tar.gz: afc9c5a9873ba2b97766cd873211d4e2e94334c9adc8a39b372f4981a84e0fb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/ask-eval)
|
|
4
4
|
|
|
5
|
-
LLM evaluation framework for Ruby. Minitest-native assertions for testing
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 =
|
|
56
|
+
c.default_judge = "openai/gpt-4o-mini" # any callable, Ask::Provider, or model string
|
|
70
57
|
end
|
|
71
58
|
```
|
|
72
59
|
|
|
73
|
-
|
|
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
|
-
|
|
62
|
+
Evaluate an `Ask::Agent::Session` with the `eval_session` DSL:
|
|
98
63
|
|
|
99
64
|
```ruby
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
##
|
|
90
|
+
## Full documentation
|
|
123
91
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
##
|
|
97
|
+
## Development
|
|
134
98
|
|
|
135
|
-
|
|
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
|
data/lib/ask/eval/recorder.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
{
|
data/lib/ask/eval/version.rb
CHANGED
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.
|
|
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
|