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,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