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,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../evaluations"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Evaluations
|
|
8
|
+
class CLI
|
|
9
|
+
FORMATS = %w[text markdown html json].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(out: $stdout, err: $stderr)
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start(argv)
|
|
17
|
+
args = argv.dup
|
|
18
|
+
command = args.shift
|
|
19
|
+
return help if command.nil? || %w[help --help -h].include?(command)
|
|
20
|
+
if %w[--version -v].include?(command)
|
|
21
|
+
@out.puts(VERSION)
|
|
22
|
+
return 0
|
|
23
|
+
end
|
|
24
|
+
unless %w[run history report dashboard db:migrate].include?(command)
|
|
25
|
+
raise ArgumentError, "unknown command #{command.inspect}; use --help"
|
|
26
|
+
end
|
|
27
|
+
options = { database_url: ENV.fetch("DATABASE_URL", DEFAULT_DATABASE_URL), format: "text", tolerance: 0.0,
|
|
28
|
+
host: "127.0.0.1", port: 4567, limit: 100, metadata: {} }
|
|
29
|
+
parser = parser_for(command, options)
|
|
30
|
+
parser.parse!(args)
|
|
31
|
+
return 0 if options[:help]
|
|
32
|
+
begin
|
|
33
|
+
::ActiveRecord::Base.establish_connection(options[:database_url])
|
|
34
|
+
rescue ArgumentError, URI::InvalidURIError
|
|
35
|
+
raise Error, "Invalid database configuration. Set DATABASE_URL to a PostgreSQL connection URL."
|
|
36
|
+
end
|
|
37
|
+
if command == "db:migrate"
|
|
38
|
+
raise ArgumentError, "db:migrate takes no positional arguments" unless args.empty?
|
|
39
|
+
Evaluations.migrate!
|
|
40
|
+
@out.puts("Evaluation database migrated.")
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
unless [Run, CaseResult, MetricResult].all?(&:table_exists?)
|
|
44
|
+
raise Error, "Evaluation tables are missing. Run ruby_llm-agent-evaluations db:migrate, or install the engine migrations and run bin/rails db:migrate."
|
|
45
|
+
end
|
|
46
|
+
case command
|
|
47
|
+
when "run" then execute(args, options)
|
|
48
|
+
when "history" then history(args, options)
|
|
49
|
+
when "report" then report(args, options)
|
|
50
|
+
when "dashboard" then dashboard(args, options)
|
|
51
|
+
end
|
|
52
|
+
rescue ::ActiveRecord::ActiveRecordError
|
|
53
|
+
@err.puts("Database configuration error. Check DATABASE_URL and PostgreSQL availability; run ruby_llm-agent-evaluations db:migrate to create evaluation tables.")
|
|
54
|
+
2
|
|
55
|
+
rescue OptionParser::ParseError, ArgumentError, Error, SystemCallError, JSON::ParserError, SyntaxError => exception
|
|
56
|
+
@err.puts("#{exception.class}: #{exception.message}")
|
|
57
|
+
2
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parser_for(command, options)
|
|
63
|
+
OptionParser.new do |parser|
|
|
64
|
+
usage = { "run" => "run SPEC.rb", "history" => "history", "report" => "report [RUN_ID]",
|
|
65
|
+
"dashboard" => "dashboard", "db:migrate" => "db:migrate" }.fetch(command)
|
|
66
|
+
parser.banner = "Usage: ruby_llm-agent-evaluations #{usage} [options]"
|
|
67
|
+
parser.on("--database-url URL", "PostgreSQL URL (default: DATABASE_URL or local database)") { |value| options[:database_url] = value }
|
|
68
|
+
if %w[run report].include?(command)
|
|
69
|
+
parser.on("--format FORMAT", FORMATS, "text, markdown, html, or json") { |value| options[:format] = value }
|
|
70
|
+
parser.on("--output PATH", "Write report to a file") { |value| options[:output] = value }
|
|
71
|
+
parser.on("--baseline RUN_ID", "Compare against a specific compatible run") { |value| options[:baseline] = value }
|
|
72
|
+
parser.on("--tolerance NUMBER", Float, "Ignore score drops up to this amount (0..1)") { |value| options[:tolerance] = value }
|
|
73
|
+
end
|
|
74
|
+
if command == "run"
|
|
75
|
+
parser.on("--fail-on-regression", "Exit 1 on quality regression, even if every case passes") { options[:fail_on_regression] = true }
|
|
76
|
+
parser.on("--metadata JSON", "Attach run metadata, e.g. revision or model") { |value| options[:metadata] = JSON.parse(value) }
|
|
77
|
+
end
|
|
78
|
+
if command == "history"
|
|
79
|
+
parser.on("--suite NAME", "Filter by suite") { |value| options[:suite] = value }
|
|
80
|
+
parser.on("--agent NAME", "Filter by agent") { |value| options[:agent] = value }
|
|
81
|
+
parser.on("--limit NUMBER", Integer, "Maximum runs (default: 100)") { |value| options[:limit] = value }
|
|
82
|
+
end
|
|
83
|
+
if command == "dashboard"
|
|
84
|
+
parser.on("--host HOST", "Bind address (default: 127.0.0.1)") { |value| options[:host] = value }
|
|
85
|
+
parser.on("--port PORT", Integer, "HTTP port (default: 4567)") { |value| options[:port] = value }
|
|
86
|
+
end
|
|
87
|
+
parser.on("-h", "--help", "Show this command's help") do
|
|
88
|
+
@out.puts(parser)
|
|
89
|
+
options[:help] = true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute(args, options)
|
|
95
|
+
raise ArgumentError, "run requires exactly one Ruby spec file" unless args.size == 1
|
|
96
|
+
path = File.expand_path(args.first)
|
|
97
|
+
suite = TOPLEVEL_BINDING.eval(File.read(path), path, 1)
|
|
98
|
+
raise DefinitionError, "spec file must return a RubyLLM::Evaluations::Suite" unless suite.is_a?(Suite)
|
|
99
|
+
explicit = explicit_baseline(options)
|
|
100
|
+
if explicit && (explicit["suite"] != suite.name || explicit["agent"] != suite.agent_name || explicit["suite_digest"] != suite.digest)
|
|
101
|
+
raise ArgumentError, "baseline has a different suite, agent, or evaluation definition"
|
|
102
|
+
end
|
|
103
|
+
if explicit && explicit.started_at >= Time.now.utc
|
|
104
|
+
raise ArgumentError, "baseline must be earlier than the current run"
|
|
105
|
+
end
|
|
106
|
+
validate_tolerance(options[:tolerance])
|
|
107
|
+
run = Evaluations.run(suite, metadata: options[:metadata])
|
|
108
|
+
result = build_report(run, options, explicit)
|
|
109
|
+
emit(result, options)
|
|
110
|
+
failed = run["summary"]["failed"].positive? || run["summary"]["errors"].positive?
|
|
111
|
+
regressed = options[:fail_on_regression] && result.comparison && result.comparison["regressed"]
|
|
112
|
+
failed || regressed ? 1 : 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def history(args, options)
|
|
116
|
+
raise ArgumentError, "history takes no positional arguments" unless args.empty?
|
|
117
|
+
raise ArgumentError, "limit must be between 1 and 1000" unless options[:limit].between?(1, 1000)
|
|
118
|
+
runs = Run.recent
|
|
119
|
+
runs = runs.for_suite(options[:suite]) if options[:suite]
|
|
120
|
+
runs = runs.for_agent(options[:agent]) if options[:agent]
|
|
121
|
+
runs = runs.limit(options[:limit])
|
|
122
|
+
if runs.empty?
|
|
123
|
+
@out.puts("No evaluation runs found.")
|
|
124
|
+
return 0
|
|
125
|
+
end
|
|
126
|
+
@out.puts("RUN ID SUITE / AGENT PASS RATE SCORE STARTED")
|
|
127
|
+
runs.each do |run|
|
|
128
|
+
summary = run.summary
|
|
129
|
+
pass_rate = summary["pass_rate"].nil? ? "—" : format("%.1f%%", summary["pass_rate"] * 100)
|
|
130
|
+
score = summary["score"].nil? ? "—" : format("%.3f", summary["score"])
|
|
131
|
+
@out.puts(format("%-36s %-28s %8s %5s %s", run["id"], "#{run['suite']} / #{run['agent']}",
|
|
132
|
+
pass_rate, score, run["started_at"]))
|
|
133
|
+
end
|
|
134
|
+
0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def report(args, options)
|
|
138
|
+
raise ArgumentError, "report takes at most one run ID" if args.size > 1
|
|
139
|
+
run = args.empty? ? Run.recent.with_results.first : Run.with_results.find_by(id: args.first)
|
|
140
|
+
raise ArgumentError, "evaluation run not found" unless run
|
|
141
|
+
validate_tolerance(options[:tolerance])
|
|
142
|
+
emit(build_report(run, options, explicit_baseline(options)), options)
|
|
143
|
+
0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def dashboard(args, options)
|
|
147
|
+
raise ArgumentError, "dashboard takes no positional arguments" unless args.empty?
|
|
148
|
+
unless options[:port].between?(1, 65_535)
|
|
149
|
+
raise ArgumentError, "port must be between 1 and 65535"
|
|
150
|
+
end
|
|
151
|
+
app = Dashboard.new
|
|
152
|
+
previous_int = Signal.trap("INT") { app.shutdown }
|
|
153
|
+
previous_term = Signal.trap("TERM") { app.shutdown }
|
|
154
|
+
@out.puts("Evaluation dashboard: http://#{options[:host]}:#{options[:port]}")
|
|
155
|
+
@out.flush
|
|
156
|
+
begin
|
|
157
|
+
app.start(host: options[:host], port: options[:port])
|
|
158
|
+
ensure
|
|
159
|
+
Signal.trap("INT", previous_int)
|
|
160
|
+
Signal.trap("TERM", previous_term)
|
|
161
|
+
end
|
|
162
|
+
0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def explicit_baseline(options)
|
|
166
|
+
return nil unless options[:baseline]
|
|
167
|
+
Run.with_results.find_by(id: options[:baseline]) || raise(ArgumentError, "baseline run not found: #{options[:baseline]}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_report(run, options, explicit)
|
|
171
|
+
baseline = explicit || run.previous
|
|
172
|
+
Report.new(run, baseline: baseline, tolerance: options[:tolerance])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def emit(report, options)
|
|
176
|
+
if options[:output]
|
|
177
|
+
report.export(options[:output], format: options[:format])
|
|
178
|
+
@out.puts("Report written to #{options[:output]}")
|
|
179
|
+
else
|
|
180
|
+
@out.puts(report.public_send("to_#{options[:format]}"))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_tolerance(value)
|
|
185
|
+
unless value.finite? && value.between?(0, 1)
|
|
186
|
+
raise ArgumentError, "tolerance must be a finite number between 0 and 1"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def help
|
|
191
|
+
@out.puts(<<~HELP)
|
|
192
|
+
ruby_llm-agent-evaluations #{VERSION}
|
|
193
|
+
Usage: ruby_llm-agent-evaluations COMMAND [options]
|
|
194
|
+
|
|
195
|
+
run SPEC.rb Execute a code-defined evaluation and persist its results
|
|
196
|
+
history List previous runs, newest first
|
|
197
|
+
report [RUN_ID] Generate a text, Markdown, HTML, or JSON report (latest by default)
|
|
198
|
+
dashboard Start the read-only local evaluation dashboard
|
|
199
|
+
db:migrate Apply evaluation database migrations
|
|
200
|
+
|
|
201
|
+
Use COMMAND --help for options.
|
|
202
|
+
Run exit codes: 0 passed, 1 failed/errored or requested regression gate, 2 configuration error.
|
|
203
|
+
Ruby spec files execute trusted code with your process's permissions.
|
|
204
|
+
HELP
|
|
205
|
+
0
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Evaluations
|
|
7
|
+
class Context
|
|
8
|
+
extend Forwardable
|
|
9
|
+
attr_reader :question_data, :response, :judge
|
|
10
|
+
def_delegators :@question_data, :id, :name, :question, :expected_answer, :expected_actions, :minimum_scores, :metadata
|
|
11
|
+
|
|
12
|
+
def initialize(question_data, response, judge: nil)
|
|
13
|
+
@question_data = question_data
|
|
14
|
+
@response = response
|
|
15
|
+
@judge = judge
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|