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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +332 -0
  5. data/app/models/ruby_llm/evaluations/application_record.rb +32 -0
  6. data/app/models/ruby_llm/evaluations/case_result.rb +39 -0
  7. data/app/models/ruby_llm/evaluations/metric_result.rb +22 -0
  8. data/app/models/ruby_llm/evaluations/run.rb +55 -0
  9. data/config/routes.rb +5 -0
  10. data/db/migrate/20261011000000_create_agent_evaluation_history.rb +145 -0
  11. data/db/migrate/20261011000100_rename_case_criteria_to_minimum_scores.rb +7 -0
  12. data/docs/images/dashboard-case.png +0 -0
  13. data/docs/images/dashboard-history.png +0 -0
  14. data/docs/images/dashboard-mobile.png +0 -0
  15. data/docs/images/dashboard-regression.png +0 -0
  16. data/docs/images/dashboard-without-judge.png +0 -0
  17. data/docs/images/html-report.png +0 -0
  18. data/examples/api_replay_eval.rb +41 -0
  19. data/examples/context_bloat_eval.rb +77 -0
  20. data/examples/fixtures/large_return_policy.json +8006 -0
  21. data/examples/fixtures/order-42.json +8 -0
  22. data/examples/ruby_llm_eval.rb +26 -0
  23. data/examples/semantic_actions_eval.rb +43 -0
  24. data/examples/support_eval.rb +52 -0
  25. data/exe/ruby_llm-agent-evaluations +5 -0
  26. data/lib/ruby_llm/evaluations/action_group.rb +21 -0
  27. data/lib/ruby_llm/evaluations/actions.rb +30 -0
  28. data/lib/ruby_llm/evaluations/adapters.rb +40 -0
  29. data/lib/ruby_llm/evaluations/api_replay.rb +426 -0
  30. data/lib/ruby_llm/evaluations/cli.rb +209 -0
  31. data/lib/ruby_llm/evaluations/context.rb +19 -0
  32. data/lib/ruby_llm/evaluations/dashboard/dashboard.css +939 -0
  33. data/lib/ruby_llm/evaluations/dashboard/error.erb +1 -0
  34. data/lib/ruby_llm/evaluations/dashboard/index.erb +46 -0
  35. data/lib/ruby_llm/evaluations/dashboard/layout.erb +18 -0
  36. data/lib/ruby_llm/evaluations/dashboard/run.erb +54 -0
  37. data/lib/ruby_llm/evaluations/dashboard.rb +270 -0
  38. data/lib/ruby_llm/evaluations/data.rb +55 -0
  39. data/lib/ruby_llm/evaluations/engine.rb +18 -0
  40. data/lib/ruby_llm/evaluations/evaluators.rb +65 -0
  41. data/lib/ruby_llm/evaluations/judge_metric.rb +42 -0
  42. data/lib/ruby_llm/evaluations/report.rb +248 -0
  43. data/lib/ruby_llm/evaluations/response.rb +50 -0
  44. data/lib/ruby_llm/evaluations/runner.rb +139 -0
  45. data/lib/ruby_llm/evaluations/suite.rb +171 -0
  46. data/lib/ruby_llm/evaluations/version.rb +7 -0
  47. data/lib/ruby_llm/evaluations.rb +48 -0
  48. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ VERSION = "0.2.0"
6
+ end
7
+ 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)