ruby_llm-agent-evaluations 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +332 -0
- data/app/models/ruby_llm/evaluations/application_record.rb +32 -0
- data/app/models/ruby_llm/evaluations/case_result.rb +39 -0
- data/app/models/ruby_llm/evaluations/metric_result.rb +22 -0
- data/app/models/ruby_llm/evaluations/run.rb +55 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261011000000_create_agent_evaluation_history.rb +145 -0
- data/db/migrate/20261011000100_rename_case_criteria_to_minimum_scores.rb +7 -0
- data/docs/images/dashboard-case.png +0 -0
- data/docs/images/dashboard-history.png +0 -0
- data/docs/images/dashboard-mobile.png +0 -0
- data/docs/images/dashboard-regression.png +0 -0
- data/docs/images/dashboard-without-judge.png +0 -0
- data/docs/images/html-report.png +0 -0
- data/examples/api_replay_eval.rb +41 -0
- data/examples/context_bloat_eval.rb +77 -0
- data/examples/fixtures/large_return_policy.json +8006 -0
- data/examples/fixtures/order-42.json +8 -0
- data/examples/ruby_llm_eval.rb +26 -0
- data/examples/semantic_actions_eval.rb +43 -0
- data/examples/support_eval.rb +52 -0
- data/exe/ruby_llm-agent-evaluations +5 -0
- data/lib/ruby_llm/evaluations/action_group.rb +21 -0
- data/lib/ruby_llm/evaluations/actions.rb +30 -0
- data/lib/ruby_llm/evaluations/adapters.rb +40 -0
- data/lib/ruby_llm/evaluations/api_replay.rb +426 -0
- data/lib/ruby_llm/evaluations/cli.rb +209 -0
- data/lib/ruby_llm/evaluations/context.rb +19 -0
- data/lib/ruby_llm/evaluations/dashboard/dashboard.css +939 -0
- data/lib/ruby_llm/evaluations/dashboard/error.erb +1 -0
- data/lib/ruby_llm/evaluations/dashboard/index.erb +46 -0
- data/lib/ruby_llm/evaluations/dashboard/layout.erb +18 -0
- data/lib/ruby_llm/evaluations/dashboard/run.erb +54 -0
- data/lib/ruby_llm/evaluations/dashboard.rb +270 -0
- data/lib/ruby_llm/evaluations/data.rb +55 -0
- data/lib/ruby_llm/evaluations/engine.rb +18 -0
- data/lib/ruby_llm/evaluations/evaluators.rb +65 -0
- data/lib/ruby_llm/evaluations/judge_metric.rb +42 -0
- data/lib/ruby_llm/evaluations/report.rb +248 -0
- data/lib/ruby_llm/evaluations/response.rb +50 -0
- data/lib/ruby_llm/evaluations/runner.rb +139 -0
- data/lib/ruby_llm/evaluations/suite.rb +171 -0
- data/lib/ruby_llm/evaluations/version.rb +7 -0
- data/lib/ruby_llm/evaluations.rb +48 -0
- metadata +156 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Evaluations
|
|
8
|
+
class Report
|
|
9
|
+
def initialize(run, baseline: nil, tolerance: 0.0)
|
|
10
|
+
unless tolerance.is_a?(Numeric) && tolerance.finite? && tolerance >= 0
|
|
11
|
+
raise ArgumentError, "tolerance must be a finite nonnegative number"
|
|
12
|
+
end
|
|
13
|
+
raise ArgumentError, "run must be a RubyLLM::Evaluations::Run" unless run.is_a?(Run)
|
|
14
|
+
raise ArgumentError, "baseline must be a RubyLLM::Evaluations::Run" if baseline && !baseline.is_a?(Run)
|
|
15
|
+
@run = run.snapshot
|
|
16
|
+
@baseline = baseline&.snapshot
|
|
17
|
+
@tolerance = tolerance.to_f
|
|
18
|
+
if @baseline && %w[suite agent suite_digest].any? { |key| @run.fetch(key) != @baseline.fetch(key) }
|
|
19
|
+
raise ArgumentError, "baseline must have the same suite, agent, and suite digest"
|
|
20
|
+
end
|
|
21
|
+
if baseline && ([baseline.started_at, baseline.sequence] <=> [run.started_at, run.sequence]) >= 0
|
|
22
|
+
raise ArgumentError, "baseline must be earlier than the current run"
|
|
23
|
+
end
|
|
24
|
+
@comparison = compare if @baseline
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def comparison
|
|
28
|
+
@comparison
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :run
|
|
32
|
+
def export(path, format: nil)
|
|
33
|
+
path = File.path(path)
|
|
34
|
+
format ||= { ".html" => "html", ".htm" => "html", ".json" => "json", ".md" => "markdown",
|
|
35
|
+
".markdown" => "markdown", ".txt" => "text", ".text" => "text" }[File.extname(path).downcase]
|
|
36
|
+
unless %w[text markdown html json].include?(format.to_s)
|
|
37
|
+
raise ArgumentError, "report format must be text, markdown, html, or json"
|
|
38
|
+
end
|
|
39
|
+
File.write(path, public_send("to_#{format}"))
|
|
40
|
+
path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_json(*args)
|
|
44
|
+
@run.merge("comparison" => comparison).to_json(*args)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_text
|
|
48
|
+
sections.map do |title, values|
|
|
49
|
+
([title, "=" * title.length] + values.map do |label, value|
|
|
50
|
+
"#{label}: #{display_value(value).gsub("\n", "\n ")}"
|
|
51
|
+
end).join("\n")
|
|
52
|
+
end.join("\n\n") + "\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_markdown
|
|
56
|
+
sections.map.with_index do |(title, values), index|
|
|
57
|
+
heading = "#{index.zero? ? '#' : '##'} #{markdown_escape(title)}"
|
|
58
|
+
body = values.map do |label, value|
|
|
59
|
+
text = display_value(value)
|
|
60
|
+
if value.is_a?(Hash) || value.is_a?(Array) || text.include?("\n")
|
|
61
|
+
fence = "`" * [3, (text.scan(/`+/).map(&:length).max || 0) + 1].max
|
|
62
|
+
"**#{markdown_escape(label)}**\n\n#{fence}\n#{text}\n#{fence}"
|
|
63
|
+
else
|
|
64
|
+
"**#{markdown_escape(label)}:** #{markdown_escape(text)}"
|
|
65
|
+
end
|
|
66
|
+
end.join("\n\n")
|
|
67
|
+
"#{heading}\n\n#{body}"
|
|
68
|
+
end.join("\n\n") + "\n"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_html
|
|
72
|
+
body = sections.map.with_index do |(title, values), index|
|
|
73
|
+
level = index.zero? ? 1 : 2
|
|
74
|
+
entries = values.map do |label, value|
|
|
75
|
+
"<dt>#{html_escape(label)}</dt><dd><pre>#{html_escape(display_value(value))}</pre></dd>"
|
|
76
|
+
end.join
|
|
77
|
+
"<section><h#{level}>#{html_escape(title)}</h#{level}><dl>#{entries}</dl></section>"
|
|
78
|
+
end.join("\n")
|
|
79
|
+
<<~HTML
|
|
80
|
+
<!doctype html>
|
|
81
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
82
|
+
<title>#{html_escape(@run.fetch("suite"))} — Evaluation report</title>
|
|
83
|
+
<style>
|
|
84
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
85
|
+
body { max-width: 960px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
86
|
+
section { border-bottom: 1px solid #888; padding-bottom: 1rem; }
|
|
87
|
+
dt { font-weight: 650; margin-top: 1rem; }
|
|
88
|
+
dd { margin: .25rem 0 0; }
|
|
89
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; font: inherit; }
|
|
90
|
+
</style></head><body>#{body}</body></html>
|
|
91
|
+
HTML
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def compare
|
|
97
|
+
regressions = []
|
|
98
|
+
improvements = []
|
|
99
|
+
previous = @baseline.fetch("cases").to_h { |item| [item.fetch("id"), item] }
|
|
100
|
+
current = @run.fetch("cases").to_h { |item| [item.fetch("id"), item] }
|
|
101
|
+
(previous.keys | current.keys).each do |id|
|
|
102
|
+
before = previous[id]
|
|
103
|
+
after = current[id]
|
|
104
|
+
if after.nil?
|
|
105
|
+
regressions << change(before, nil, nil, case_score(before), nil, "Case result is missing")
|
|
106
|
+
elsif before.nil?
|
|
107
|
+
improvements << change(nil, after, nil, nil, case_score(after), "Case result was added")
|
|
108
|
+
else
|
|
109
|
+
compare_case(before, after, regressions, improvements)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
baseline_summary = @baseline.fetch("summary")
|
|
113
|
+
current_summary = @run.fetch("summary")
|
|
114
|
+
{
|
|
115
|
+
"baseline_id" => @baseline.fetch("id"), "current_id" => @run.fetch("id"),
|
|
116
|
+
"compatible" => true, "regressed" => !regressions.empty?,
|
|
117
|
+
"pass_rate_delta" => score_delta(baseline_summary["pass_rate"], current_summary["pass_rate"]),
|
|
118
|
+
"score_delta" => score_delta(baseline_summary["score"], current_summary["score"]),
|
|
119
|
+
"regressions" => regressions, "improvements" => improvements
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def compare_case(before, after, regressions, improvements)
|
|
124
|
+
if agent_error?(after) && !agent_error?(before)
|
|
125
|
+
regressions << change(before, after, nil, case_score(before), 0.0, "Agent execution began raising an error")
|
|
126
|
+
return
|
|
127
|
+
elsif agent_error?(before) && !agent_error?(after)
|
|
128
|
+
improvements << change(before, after, nil, 0.0, case_score(after), "Agent execution recovered from an error")
|
|
129
|
+
return
|
|
130
|
+
elsif agent_error?(before) && agent_error?(after)
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
old_evaluations = before.fetch("metrics").to_h { |item| [item.fetch("name"), item] }
|
|
135
|
+
new_evaluations = after.fetch("metrics").to_h { |item| [item.fetch("name"), item] }
|
|
136
|
+
regression_count = regressions.length
|
|
137
|
+
improvement_count = improvements.length
|
|
138
|
+
(old_evaluations.keys | new_evaluations.keys).each do |name|
|
|
139
|
+
old_result = old_evaluations[name]
|
|
140
|
+
new_result = new_evaluations[name]
|
|
141
|
+
if new_result.nil?
|
|
142
|
+
regressions << change(before, after, name, evaluation_score(old_result), nil, "Evaluator result is missing")
|
|
143
|
+
next
|
|
144
|
+
elsif old_result.nil?
|
|
145
|
+
target = new_result["passed"] ? improvements : regressions
|
|
146
|
+
target << change(before, after, name, nil, evaluation_score(new_result), "Evaluator result was added #{new_result['passed'] ? 'and passed' : 'without passing'}")
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
old_score = evaluation_score(old_result)
|
|
151
|
+
new_score = evaluation_score(new_result)
|
|
152
|
+
reason = nil
|
|
153
|
+
target = nil
|
|
154
|
+
if new_result["error"] && !old_result["error"]
|
|
155
|
+
reason, target = "Evaluator began raising an error", regressions
|
|
156
|
+
elsif old_result["error"] && !new_result["error"]
|
|
157
|
+
reason, target = "Evaluator recovered from an error", improvements
|
|
158
|
+
elsif old_result["passed"] && !new_result["passed"]
|
|
159
|
+
reason, target = "Evaluator changed from passing to failing", regressions
|
|
160
|
+
elsif !old_result["passed"] && new_result["passed"]
|
|
161
|
+
reason, target = "Evaluator changed from failing to passing", improvements
|
|
162
|
+
elsif old_score && new_score && old_score - new_score > @tolerance
|
|
163
|
+
reason, target = "Evaluator score decreased beyond tolerance", regressions
|
|
164
|
+
elsif old_score && new_score && new_score - old_score > @tolerance
|
|
165
|
+
reason, target = "Evaluator score increased beyond tolerance", improvements
|
|
166
|
+
end
|
|
167
|
+
target << change(before, after, name, old_score, new_score, reason) if target
|
|
168
|
+
end
|
|
169
|
+
# A case outcome without a changed evaluator is still evidence; do not duplicate evaluator failures.
|
|
170
|
+
if before["status"] == "passed" && after["status"] != "passed" && regressions.length == regression_count
|
|
171
|
+
regressions << change(before, after, nil, case_score(before), case_score(after), "Case changed from passing to #{after['status']}")
|
|
172
|
+
elsif before["status"] != "passed" && after["status"] == "passed" && improvements.length == improvement_count
|
|
173
|
+
improvements << change(before, after, nil, case_score(before), case_score(after), "Case changed from #{before['status']} to passing")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def agent_error?(item)
|
|
178
|
+
!item["error"].nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def evaluation_score(item)
|
|
182
|
+
item["error"] ? 0.0 : item["score"]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def case_score(item)
|
|
186
|
+
return 0.0 if agent_error?(item)
|
|
187
|
+
return nil if item["status"] == "not_evaluated"
|
|
188
|
+
|
|
189
|
+
scores = item.fetch("metrics").map { |evaluation| evaluation_score(evaluation) || 0.0 }
|
|
190
|
+
scores.empty? ? (item["status"] == "passed" ? 1.0 : 0.0) : scores.sum.to_f / scores.length
|
|
191
|
+
end
|
|
192
|
+
def score_delta(before, after)
|
|
193
|
+
after - before unless before.nil? || after.nil?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def change(before, after, evaluator, old_score, new_score, reason)
|
|
197
|
+
item = after || before
|
|
198
|
+
{
|
|
199
|
+
"case_id" => item.fetch("id"), "case_name" => item.fetch("name"), "evaluator" => evaluator,
|
|
200
|
+
"baseline_score" => old_score, "current_score" => new_score,
|
|
201
|
+
"delta" => old_score && new_score ? new_score - old_score : nil, "reason" => reason
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def sections
|
|
206
|
+
result = [["#{@run.fetch('suite')} — Evaluation report", {
|
|
207
|
+
"Run" => @run.fetch("id"), "Agent" => @run.fetch("agent"),
|
|
208
|
+
"Suite digest" => @run.fetch("suite_digest"), "Status" => @run.fetch("status"),
|
|
209
|
+
"Started at" => @run.fetch("started_at"), "Finished at" => @run.fetch("finished_at"),
|
|
210
|
+
"Metadata" => @run.fetch("metadata")
|
|
211
|
+
}], ["Summary", @run.fetch("summary")]]
|
|
212
|
+
if comparison
|
|
213
|
+
result << ["Baseline comparison", {
|
|
214
|
+
"Baseline run" => comparison.fetch("baseline_id"), "Regressed" => comparison.fetch("regressed"),
|
|
215
|
+
"Pass rate delta" => comparison.fetch("pass_rate_delta"), "Score delta" => comparison.fetch("score_delta"),
|
|
216
|
+
"Regressions" => comparison.fetch("regressions"), "Improvements" => comparison.fetch("improvements")
|
|
217
|
+
}]
|
|
218
|
+
end
|
|
219
|
+
@run.fetch("cases").each do |item|
|
|
220
|
+
result << ["Case: #{item.fetch('name')}", {
|
|
221
|
+
"ID" => item.fetch("id"), "Status" => item.fetch("status"),
|
|
222
|
+
"Question" => item["question"], "Expected answer" => item["expected_answer"], "Output" => item["output"],
|
|
223
|
+
"Minimum passing scores" => item["minimum_scores"],
|
|
224
|
+
"Expected actions (ordered)" => item["expected_actions"] || "Unavailable — expected actions not specified",
|
|
225
|
+
"Observed actions (ordered)" => item["actions"] || "Unavailable — action instrumentation missing",
|
|
226
|
+
"Response metadata" => item["response_metadata"].nil? ? "Unavailable — response metadata not reported" : item["response_metadata"],
|
|
227
|
+
"Metadata" => item["metadata"], "Duration (ms)" => item["duration_ms"],
|
|
228
|
+
"Input tokens" => item["input_tokens"], "Output tokens" => item["output_tokens"],
|
|
229
|
+
"Cost" => item["cost"], "Error" => item["error"], "Metrics" => item.fetch("metrics")
|
|
230
|
+
}]
|
|
231
|
+
end
|
|
232
|
+
result
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def display_value(value)
|
|
236
|
+
value.is_a?(String) ? value : JSON.pretty_generate(value)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def html_escape(value)
|
|
240
|
+
CGI.escapeHTML(value.to_s)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def markdown_escape(value)
|
|
244
|
+
html_escape(value).gsub(/([\\`*_{}\[\]()#+.!|>~-])/) { |character| "\\#{character}" }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
class Response
|
|
6
|
+
attr_reader :output, :input_tokens, :output_tokens, :cost, :metadata, :actions
|
|
7
|
+
|
|
8
|
+
def initialize(output:, input_tokens: nil, output_tokens: nil, cost: nil, metadata: {}, actions: nil)
|
|
9
|
+
@output = output
|
|
10
|
+
@input_tokens = tokens(input_tokens)
|
|
11
|
+
@output_tokens = tokens(output_tokens)
|
|
12
|
+
unless cost.nil? || (cost.is_a?(Numeric) && cost.finite? && cost >= 0)
|
|
13
|
+
raise ArgumentError, "cost must be a finite nonnegative number"
|
|
14
|
+
end
|
|
15
|
+
raise ArgumentError, "response metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
16
|
+
@cost = cost&.to_f
|
|
17
|
+
@metadata = Data.json_value(metadata)
|
|
18
|
+
@actions = actions.nil? ? nil : Actions.normalize(actions)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.normalize(value)
|
|
22
|
+
return value if value.is_a?(self)
|
|
23
|
+
return new(output: value) unless value.respond_to?(:content)
|
|
24
|
+
|
|
25
|
+
new(output: value.content,
|
|
26
|
+
input_tokens: value.respond_to?(:input_tokens) ? value.input_tokens : nil,
|
|
27
|
+
output_tokens: value.respond_to?(:output_tokens) ? value.output_tokens : nil,
|
|
28
|
+
metadata: message_metadata(value))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.message_metadata(value)
|
|
32
|
+
%i[model_id role cached_tokens cache_creation_tokens thinking_tokens].each_with_object({}) do |field, metadata|
|
|
33
|
+
next unless value.respond_to?(field)
|
|
34
|
+
item = value.public_send(field)
|
|
35
|
+
metadata[field] = item unless item.nil?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def tokens(value)
|
|
42
|
+
unless value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
43
|
+
raise ArgumentError, "token counts must be nonnegative integers"
|
|
44
|
+
end
|
|
45
|
+
value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
class Runner
|
|
6
|
+
def run(suite, metadata: {})
|
|
7
|
+
suite.validate!
|
|
8
|
+
Run.require_postgresql!
|
|
9
|
+
raise Error, "Apply evaluation migrations before running a suite" unless Run.table_exists?
|
|
10
|
+
raise ArgumentError, "run metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
11
|
+
run_metadata = Data.json_value(metadata)
|
|
12
|
+
digest = suite.digest
|
|
13
|
+
started_at = Time.now.utc
|
|
14
|
+
started = clock
|
|
15
|
+
# Finish all agent and evaluator calls before building/saving the database graph.
|
|
16
|
+
results = suite.cases.map { |question| run_case(suite, question) }
|
|
17
|
+
run = Run.new(suite: suite.name, agent: suite.agent_name, version: suite.version,
|
|
18
|
+
suite_digest: digest, started_at: started_at, finished_at: Time.now.utc,
|
|
19
|
+
status: "completed", metadata: run_metadata,
|
|
20
|
+
summary: summarize(results, milliseconds(started)))
|
|
21
|
+
results.each_with_index do |result, position|
|
|
22
|
+
attributes = result.except("id", "metrics", "error")
|
|
23
|
+
record = run.case_results.build(attributes.merge("case_id" => result.fetch("id"),
|
|
24
|
+
"error_details" => result["error"],
|
|
25
|
+
"position" => position))
|
|
26
|
+
result.fetch("metrics").each_with_index do |metric, metric_position|
|
|
27
|
+
record.metric_results.build(metric.except("error").merge("error_details" => metric["error"],
|
|
28
|
+
"position" => metric_position))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
# ActiveRecord save! atomically saves the autosaved associations in its short transaction.
|
|
32
|
+
run.save!
|
|
33
|
+
run.reload
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def run_case(suite, question)
|
|
39
|
+
result = Data.json_value(question.to_h).merge(
|
|
40
|
+
"output" => nil, "status" => "error", "input_tokens" => nil,
|
|
41
|
+
"output_tokens" => nil, "cost" => nil, "actions" => nil, "error" => nil, "metrics" => []
|
|
42
|
+
)
|
|
43
|
+
started = clock
|
|
44
|
+
replay = ApiReplay.new(question.api_interactions) if question.api_interactions
|
|
45
|
+
begin
|
|
46
|
+
invoke = -> { suite.agent.call(mutable_copy(question.question)) }
|
|
47
|
+
response = Response.normalize(replay ? replay.call(&invoke) : invoke.call)
|
|
48
|
+
result.merge!("output" => Data.json_value(response.output),
|
|
49
|
+
"input_tokens" => response.input_tokens, "output_tokens" => response.output_tokens,
|
|
50
|
+
"cost" => response.cost, "response_metadata" => response.metadata,
|
|
51
|
+
"actions" => Data.json_value(response.actions))
|
|
52
|
+
rescue StandardError => exception
|
|
53
|
+
result["error"] = Data.error(exception)
|
|
54
|
+
result["duration_ms"] = milliseconds(started)
|
|
55
|
+
return result
|
|
56
|
+
ensure
|
|
57
|
+
if replay
|
|
58
|
+
result["response_metadata"] = (result["response_metadata"] || {}).merge("api_replay" => replay.evidence)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
result["duration_ms"] = milliseconds(started)
|
|
62
|
+
context = Context.new(question, response, judge: suite.judge)
|
|
63
|
+
result["metrics"] = question.evaluators.map { |evaluator| assess(evaluator, response.output, context) }
|
|
64
|
+
result["status"] = if result["metrics"].empty?
|
|
65
|
+
"not_evaluated"
|
|
66
|
+
elsif result["metrics"].any? { |item| item["error"] }
|
|
67
|
+
"error"
|
|
68
|
+
elsif result["metrics"].all? { |item| item["passed"] }
|
|
69
|
+
"passed"
|
|
70
|
+
else
|
|
71
|
+
"failed"
|
|
72
|
+
end
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def assess(evaluator, output, context)
|
|
77
|
+
result = { "name" => evaluator.name, "threshold" => evaluator.threshold,
|
|
78
|
+
"score" => nil, "passed" => false, "reason" => nil, "error" => nil }
|
|
79
|
+
begin
|
|
80
|
+
value = evaluator.callable.call(output, context)
|
|
81
|
+
assessment = value.is_a?(Assessment) ? value : Assessment.new(score: value)
|
|
82
|
+
score = case assessment.score
|
|
83
|
+
when true then 1.0
|
|
84
|
+
when false then 0.0
|
|
85
|
+
else assessment.score
|
|
86
|
+
end
|
|
87
|
+
unless score.is_a?(Numeric) && score.finite? && score.between?(0, 1)
|
|
88
|
+
raise ArgumentError, "evaluator must return a boolean or a finite score between 0 and 1"
|
|
89
|
+
end
|
|
90
|
+
unless assessment.reason.nil? || assessment.reason.is_a?(String)
|
|
91
|
+
raise ArgumentError, "assessment reason must be a string"
|
|
92
|
+
end
|
|
93
|
+
result.merge!("score" => score.to_f, "passed" => score >= evaluator.threshold,
|
|
94
|
+
"reason" => assessment.reason)
|
|
95
|
+
rescue StandardError => exception
|
|
96
|
+
result["error"] = Data.error(exception)
|
|
97
|
+
end
|
|
98
|
+
result
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def summarize(results, duration)
|
|
102
|
+
counts = results.group_by { |item| item["status"] }.transform_values(&:size)
|
|
103
|
+
evaluated = results.reject { |item| item["status"] == "not_evaluated" }
|
|
104
|
+
# An agent error has no assessments; its case quality is zero, not missing data.
|
|
105
|
+
scores = evaluated.map do |item|
|
|
106
|
+
metrics = item["metrics"]
|
|
107
|
+
metrics.empty? ? 0.0 : metrics.sum { |evaluation| evaluation["score"] || 0.0 } / metrics.size
|
|
108
|
+
end
|
|
109
|
+
{
|
|
110
|
+
"cases" => results.size, "passed" => counts.fetch("passed", 0),
|
|
111
|
+
"failed" => counts.fetch("failed", 0), "errors" => counts.fetch("error", 0),
|
|
112
|
+
"not_evaluated" => counts.fetch("not_evaluated", 0), "evaluated_cases" => evaluated.size,
|
|
113
|
+
"pass_rate" => evaluated.empty? ? nil : counts.fetch("passed", 0).fdiv(evaluated.size),
|
|
114
|
+
"score" => scores.empty? ? nil : scores.sum / scores.size, "duration_ms" => duration,
|
|
115
|
+
"input_tokens" => results.sum { |item| item["input_tokens"] || 0 },
|
|
116
|
+
"output_tokens" => results.sum { |item| item["output_tokens"] || 0 },
|
|
117
|
+
"cost" => results.sum { |item| item["cost"] || 0.0 }
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def mutable_copy(value)
|
|
122
|
+
case value
|
|
123
|
+
when Hash then value.to_h { |key, item| [key, mutable_copy(item)] }
|
|
124
|
+
when Array then value.map { |item| mutable_copy(item) }
|
|
125
|
+
when String then value.dup
|
|
126
|
+
else value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def clock
|
|
131
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def milliseconds(started)
|
|
135
|
+
(clock - started) * 1000.0
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Evaluations
|
|
7
|
+
Question = Struct.new(:id, :name, :question, :expected_answer, :expected_actions, :minimum_scores, :metadata,
|
|
8
|
+
:evaluators, :api_interactions, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
super.reject { |key, _value| %i[evaluators api_interactions].include?(key) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
Evaluator = Struct.new(:name, :callable, :threshold, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class Suite
|
|
16
|
+
SOURCE_DIRECTORY = Dir.pwd.freeze
|
|
17
|
+
private_constant :SOURCE_DIRECTORY
|
|
18
|
+
|
|
19
|
+
attr_reader :name, :agent, :agent_name, :version, :cases, :evaluators, :judge
|
|
20
|
+
|
|
21
|
+
def initialize(name, agent:, judge: nil, agent_name: nil, version: "1")
|
|
22
|
+
@name = nonempty(name, "suite name")
|
|
23
|
+
@agent_name = nonempty(agent_name || name, "agent name")
|
|
24
|
+
@version = nonempty(version, "version")
|
|
25
|
+
raise DefinitionError, "agent must respond to call" unless agent.respond_to?(:call)
|
|
26
|
+
unless judge.nil? || (judge.is_a?(Class) && defined?(::RubyLLM::Agent) && judge <= ::RubyLLM::Agent)
|
|
27
|
+
raise DefinitionError, "judge must be a RubyLLM::Agent class"
|
|
28
|
+
end
|
|
29
|
+
@agent = agent
|
|
30
|
+
@judge = judge
|
|
31
|
+
@cases = []
|
|
32
|
+
@evaluators = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def question(value, expected_answer: nil, minimum_scores: nil, metadata: {}, name: nil, id: nil, expected_actions: nil, &block)
|
|
36
|
+
name = nonempty(name || (value.is_a?(String) ? value : "Question #{cases.size + 1}"), "question name")
|
|
37
|
+
id = nonempty(id || name, "question id")
|
|
38
|
+
raise DefinitionError, "duplicate question id: #{id}" if cases.any? { |item| item.id == id }
|
|
39
|
+
raise DefinitionError, "question metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
40
|
+
[value, expected_answer, metadata].each { |item| Data.json_value(item) }
|
|
41
|
+
minimum_scores = normalize_minimum_scores(minimum_scores) unless minimum_scores.nil?
|
|
42
|
+
unless expected_actions.nil?
|
|
43
|
+
expected_actions = Actions.normalize(expected_actions)
|
|
44
|
+
if expected_actions.any? { |action| action["group"].nil? }
|
|
45
|
+
raise DefinitionError, "expected actions must specify an action group"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
if block
|
|
49
|
+
source = block.source_location.first
|
|
50
|
+
# Ruby main scripts can retain relative source locations after Dir.chdir.
|
|
51
|
+
source = File.expand_path(source, File.file?(source) ? Dir.pwd : SOURCE_DIRECTORY)
|
|
52
|
+
replay = ApiReplay::Definition.new(base_dir: File.dirname(source))
|
|
53
|
+
replay.instance_eval(&block)
|
|
54
|
+
replay.validate!
|
|
55
|
+
end
|
|
56
|
+
cases << Question.new(id: id, name: name, question: immutable_copy(value),
|
|
57
|
+
expected_answer: immutable_copy(expected_answer),
|
|
58
|
+
expected_actions: immutable_copy(expected_actions), api_interactions: replay,
|
|
59
|
+
minimum_scores: minimum_scores, metadata: immutable_copy(metadata)).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def evaluate(name, using: nil, threshold: 1.0, &block)
|
|
63
|
+
name = nonempty(name, "evaluator name")
|
|
64
|
+
raise DefinitionError, "duplicate evaluator: #{name}" if evaluators.any? { |item| item.name == name }
|
|
65
|
+
raise DefinitionError, "choose using: or a block, not both" if using && block
|
|
66
|
+
callable = using || block
|
|
67
|
+
raise DefinitionError, "evaluator must respond to call" unless callable.respond_to?(:call)
|
|
68
|
+
validate_threshold(threshold)
|
|
69
|
+
evaluators << Evaluator.new(name: name, callable: callable, threshold: threshold.to_f).freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate!
|
|
73
|
+
raise DefinitionError, "suite must contain at least one question" if cases.empty?
|
|
74
|
+
cases.map! do |item|
|
|
75
|
+
minimum_scores = item.minimum_scores || if evaluators.empty?
|
|
76
|
+
{ "semantic_accuracy" => 0.9, "answer_relevancy" => 0.9 }
|
|
77
|
+
else
|
|
78
|
+
evaluators.to_h { |definition| [definition.name, definition.threshold] }
|
|
79
|
+
end
|
|
80
|
+
definitions = minimum_scores.filter_map do |metric_name, threshold|
|
|
81
|
+
custom = evaluators.find { |definition| definition.name == metric_name }
|
|
82
|
+
callable = custom ? custom.callable : standard_metric(metric_name)
|
|
83
|
+
next if judge.nil? && callable.is_a?(JudgeMetric)
|
|
84
|
+
Evaluator.new(name: metric_name, callable: callable, threshold: threshold).freeze
|
|
85
|
+
end.freeze
|
|
86
|
+
Question.new(**item.to_h, minimum_scores: immutable_copy(minimum_scores),
|
|
87
|
+
api_interactions: item.api_interactions, evaluators: definitions).freeze
|
|
88
|
+
end
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def digest
|
|
93
|
+
validate!
|
|
94
|
+
definition = { "name" => name, "version" => version,
|
|
95
|
+
"cases" => cases.map do |item|
|
|
96
|
+
# Persistent fingerprint labels are stable across public API renames.
|
|
97
|
+
data = item.to_h.transform_keys { |key| key == :minimum_scores ? :criteria : key }
|
|
98
|
+
data = data.merge(api_interactions: item.api_interactions.to_h) if item.api_interactions
|
|
99
|
+
skipped = item.minimum_scores.keys - item.evaluators.map(&:name)
|
|
100
|
+
data = data.merge(skipped_metrics: skipped) unless skipped.empty?
|
|
101
|
+
definition_value(data)
|
|
102
|
+
end }
|
|
103
|
+
Digest::SHA256.hexdigest(JSON.generate(Data.canonical(definition)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def standard_metric(name)
|
|
109
|
+
case name
|
|
110
|
+
when "semantic_accuracy", "answer_relevancy"
|
|
111
|
+
Evaluators.public_send(name)
|
|
112
|
+
when "action_group"
|
|
113
|
+
Evaluators.action_group
|
|
114
|
+
else
|
|
115
|
+
raise DefinitionError, "unknown metric: #{name}; register it with evaluate"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_minimum_scores(minimum_scores)
|
|
120
|
+
unless minimum_scores.is_a?(Hash) && !minimum_scores.empty?
|
|
121
|
+
raise DefinitionError, "minimum_scores must be a nonempty Hash of metric names and minimum passing scores"
|
|
122
|
+
end
|
|
123
|
+
minimum_scores.each_with_object({}) do |(name, threshold), normalized|
|
|
124
|
+
name = nonempty(name, "metric name")
|
|
125
|
+
raise DefinitionError, "duplicate metric: #{name}" if normalized.key?(name)
|
|
126
|
+
validate_threshold(threshold)
|
|
127
|
+
normalized[name] = threshold.to_f
|
|
128
|
+
end.freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_threshold(threshold)
|
|
132
|
+
unless threshold.is_a?(Numeric) && threshold.finite? && threshold.between?(0, 1)
|
|
133
|
+
raise DefinitionError, "threshold must be a finite number between 0 and 1"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# JSON snapshots normalize symbols, but Ruby agents distinguish key/value types.
|
|
138
|
+
def definition_value(value)
|
|
139
|
+
case value
|
|
140
|
+
when Hash
|
|
141
|
+
entries = value.map { |key, item| [key.class.name, key.to_s, definition_value(item)] }
|
|
142
|
+
{ "hash" => entries.sort_by { |type, name, _item| [type, name] } }
|
|
143
|
+
when Array
|
|
144
|
+
value.map { |item| definition_value(item) }
|
|
145
|
+
when Symbol
|
|
146
|
+
{ "symbol" => value.to_s }
|
|
147
|
+
else
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def nonempty(value, label)
|
|
153
|
+
raise DefinitionError, "#{label} must not be empty" if value.to_s.strip.empty?
|
|
154
|
+
value.to_s.dup.freeze
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def immutable_copy(value)
|
|
158
|
+
case value
|
|
159
|
+
when Hash
|
|
160
|
+
value.to_h { |key, item| [immutable_copy(key), immutable_copy(item)] }.freeze
|
|
161
|
+
when Array
|
|
162
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
163
|
+
when String
|
|
164
|
+
value.dup.freeze
|
|
165
|
+
else
|
|
166
|
+
value
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "evaluations/version"
|
|
4
|
+
require_relative "evaluations/data"
|
|
5
|
+
require_relative "evaluations/actions"
|
|
6
|
+
require_relative "evaluations/judge_metric"
|
|
7
|
+
require_relative "evaluations/evaluators"
|
|
8
|
+
require_relative "evaluations/api_replay"
|
|
9
|
+
require_relative "evaluations/suite"
|
|
10
|
+
require_relative "evaluations/response"
|
|
11
|
+
require_relative "evaluations/adapters"
|
|
12
|
+
require_relative "evaluations/context"
|
|
13
|
+
require_relative "evaluations/action_group"
|
|
14
|
+
require_relative "evaluations/runner"
|
|
15
|
+
require_relative "../../app/models/ruby_llm/evaluations/application_record"
|
|
16
|
+
require_relative "../../app/models/ruby_llm/evaluations/run"
|
|
17
|
+
require_relative "../../app/models/ruby_llm/evaluations/case_result"
|
|
18
|
+
require_relative "../../app/models/ruby_llm/evaluations/metric_result"
|
|
19
|
+
require_relative "evaluations/report"
|
|
20
|
+
require_relative "evaluations/dashboard"
|
|
21
|
+
|
|
22
|
+
module RubyLLM
|
|
23
|
+
module Evaluations
|
|
24
|
+
DEFAULT_DATABASE_URL = "postgresql://localhost/ruby_llm_evaluations"
|
|
25
|
+
MIGRATIONS_PATH = File.expand_path("../../db/migrate", __dir__).freeze
|
|
26
|
+
autoload :Engine, File.expand_path("evaluations/engine", __dir__)
|
|
27
|
+
|
|
28
|
+
def self.define(name, agent:, judge: nil, agent_name: nil, version: "1", &block)
|
|
29
|
+
Suite.new(name, agent: agent, judge: judge, agent_name: agent_name, version: version).tap do |suite|
|
|
30
|
+
suite.instance_eval(&block) if block
|
|
31
|
+
suite.validate!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.run(suite, metadata: {})
|
|
36
|
+
Runner.new.run(suite, metadata: metadata)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Standalone setup uses Rails migrations; a host application uses db:migrate.
|
|
40
|
+
def self.migrate!
|
|
41
|
+
ApplicationRecord.require_postgresql!
|
|
42
|
+
::ActiveRecord::MigrationContext.new([MIGRATIONS_PATH]).migrate
|
|
43
|
+
[Run, CaseResult, MetricResult].each(&:reset_column_information)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
require_relative "evaluations/engine" if defined?(::Rails::Engine)
|