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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAgentEvaluationHistory < ActiveRecord::Migration[7.1]
4
+ RUNS = "ruby_llm_evaluations_runs"
5
+ CASES = "ruby_llm_evaluations_case_results"
6
+ METRICS = "ruby_llm_evaluations_metric_results"
7
+ LEGACY = "ruby_llm_evaluations_legacy_runs"
8
+ LEGACY_VERSIONS = "ruby_llm_evaluations_schema_versions"
9
+
10
+ class LegacyRun < ActiveRecord::Base
11
+ self.table_name = LEGACY
12
+ self.primary_key = "sequence"
13
+ end
14
+
15
+ class LegacyVersion < ActiveRecord::Base
16
+ self.table_name = LEGACY_VERSIONS
17
+ self.primary_key = "version"
18
+ end
19
+
20
+ class MigratedRun < ActiveRecord::Base
21
+ self.table_name = RUNS
22
+ end
23
+
24
+ class MigratedCase < ActiveRecord::Base
25
+ self.table_name = CASES
26
+ end
27
+
28
+ class MigratedMetric < ActiveRecord::Base
29
+ self.table_name = METRICS
30
+ end
31
+
32
+ def up
33
+ raise ActiveRecord::MigrationError, "evaluation history requires PostgreSQL" unless connection.adapter_name == "PostgreSQL"
34
+
35
+ legacy = table_exists?(RUNS) && column_exists?(RUNS, :payload)
36
+ if legacy
37
+ unless table_exists?(LEGACY_VERSIONS) && LegacyVersion.pluck(:version) == [1]
38
+ raise ActiveRecord::MigrationError, "unsupported legacy evaluation history version"
39
+ end
40
+ rename_table RUNS, LEGACY
41
+ elsif table_exists?(RUNS)
42
+ raise ActiveRecord::MigrationError, "evaluation model tables already exist without this migration"
43
+ end
44
+
45
+ create_table RUNS, id: :string do |table|
46
+ table.bigserial :sequence, null: false
47
+ table.string :suite, null: false
48
+ table.string :agent, null: false
49
+ table.string :version, null: false, default: "1"
50
+ table.string :suite_digest, null: false
51
+ table.column :started_at, :timestamptz, null: false, precision: 6
52
+ table.column :finished_at, :timestamptz, null: false, precision: 6
53
+ table.string :status, null: false
54
+ table.jsonb :metadata, null: false, default: {}
55
+ table.jsonb :summary, null: false
56
+ table.timestamps precision: 6
57
+ end
58
+ add_index RUNS, :sequence, unique: true
59
+ add_index RUNS, [:started_at, :sequence], order: { started_at: :desc, sequence: :desc }, name: "rle_runs_recent"
60
+ add_index RUNS, [:suite, :agent, :suite_digest, :started_at, :sequence],
61
+ order: { started_at: :desc, sequence: :desc }, name: "rle_runs_comparable"
62
+
63
+ create_table CASES do |table|
64
+ table.references :run, type: :string, null: false, foreign_key: { to_table: RUNS }
65
+ table.integer :position, null: false
66
+ table.string :case_id, null: false
67
+ table.text :name, null: false
68
+ table.jsonb :question
69
+ table.jsonb :expected_answer
70
+ table.jsonb :expected_actions
71
+ table.jsonb :criteria, null: false, default: {}
72
+ table.jsonb :metadata, null: false, default: {}
73
+ table.jsonb :output
74
+ table.string :status, null: false
75
+ table.float :duration_ms, null: false
76
+ table.bigint :input_tokens
77
+ table.bigint :output_tokens
78
+ table.float :cost
79
+ table.jsonb :actions
80
+ table.jsonb :response_metadata
81
+ table.jsonb :error_details
82
+ table.timestamps precision: 6
83
+ end
84
+ add_index CASES, [:run_id, :position], unique: true, name: "rle_case_order"
85
+ add_index CASES, [:run_id, :case_id], unique: true, name: "rle_case_identity"
86
+
87
+ create_table METRICS do |table|
88
+ table.references :case_result, null: false, foreign_key: { to_table: CASES }
89
+ table.integer :position, null: false
90
+ table.string :name, null: false
91
+ table.float :threshold, null: false
92
+ table.float :score
93
+ table.boolean :passed, null: false
94
+ table.text :reason
95
+ table.jsonb :error_details
96
+ table.timestamps precision: 6
97
+ end
98
+ add_index METRICS, [:case_result_id, :position], unique: true, name: "rle_metric_order"
99
+ add_index METRICS, [:case_result_id, :name], unique: true, name: "rle_metric_identity"
100
+
101
+ if legacy
102
+ copy_legacy_history
103
+ connection.reset_pk_sequence!(RUNS, "sequence", "#{RUNS}_sequence_seq")
104
+ drop_table LEGACY
105
+ end
106
+ drop_table LEGACY_VERSIONS if table_exists?(LEGACY_VERSIONS)
107
+ end
108
+
109
+ def down
110
+ raise ActiveRecord::IrreversibleMigration, "evaluation history migration preserves append-only evidence"
111
+ end
112
+
113
+ private
114
+
115
+ def copy_legacy_history
116
+ [LegacyRun, MigratedRun, MigratedCase, MigratedMetric].each(&:reset_column_information)
117
+ LegacyRun.find_each do |row|
118
+ value = row.payload
119
+ run = MigratedRun.create!(
120
+ id: value.fetch("id"), sequence: row.sequence, suite: value.fetch("suite"), agent: value.fetch("agent"),
121
+ version: value.fetch("version", "1"), suite_digest: value.fetch("suite_digest"),
122
+ started_at: value.fetch("started_at"), finished_at: value.fetch("finished_at"),
123
+ status: value.fetch("status"), metadata: value.fetch("metadata", {}), summary: value.fetch("summary")
124
+ )
125
+ value.fetch("cases").each_with_index do |item, position|
126
+ metrics = item.fetch("evaluations")
127
+ question = MigratedCase.create!(
128
+ run_id: run.id, position: position, case_id: item.fetch("id"), name: item.fetch("name"),
129
+ question: item["input"], expected_answer: item["expected"], expected_actions: item["expected_actions"],
130
+ criteria: metrics.to_h { |metric| [metric.fetch("name"), metric.fetch("threshold")] },
131
+ metadata: item.fetch("metadata", {}), output: item["output"], status: item.fetch("status"),
132
+ duration_ms: item.fetch("duration_ms"), input_tokens: item["input_tokens"], output_tokens: item["output_tokens"],
133
+ cost: item["cost"], actions: item["actions"], response_metadata: item["response_metadata"], error_details: item["error"]
134
+ )
135
+ metrics.each_with_index do |metric, metric_position|
136
+ MigratedMetric.create!(
137
+ case_result_id: question.id, position: metric_position, name: metric.fetch("name"),
138
+ threshold: metric.fetch("threshold"), score: metric["score"], passed: metric.fetch("passed"),
139
+ reason: metric["reason"], error_details: metric["error"]
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RenameCaseCriteriaToMinimumScores < ActiveRecord::Migration[7.1]
4
+ def change
5
+ rename_column :ruby_llm_evaluations_case_results, :criteria, :minimum_scores
6
+ end
7
+ end
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "ruby_llm/evaluations"
6
+
7
+ # Real HTTP request construction and parsing; the framework supplies the response.
8
+ agent = lambda do |question|
9
+ order_id = ENV["AGENT_REVISION"] == "regressed" ? "99" : question.fetch(:order_id)
10
+ uri = URI("https://shipping.example/orders/#{order_id}")
11
+ uri.query = URI.encode_www_form(include_events: "true")
12
+ response = Net::HTTP.get_response(uri)
13
+ data = JSON.parse(response.body)
14
+ RubyLLM::Evaluations::Response.new(
15
+ output: data.fetch("status"),
16
+ actions: [{ group: "shipping", name: "lookup_order", arguments: { order_id: order_id } }]
17
+ )
18
+ end
19
+
20
+ RubyLLM::Evaluations.define("Shipping API replay", agent: agent, agent_name: "ShippingAgent") do
21
+ evaluate :exact_match, using: RubyLLM::Evaluations::Evaluators.exact_match
22
+ question({ order_id: "42" }, name: "File-backed order", expected_answer: "in_transit",
23
+ minimum_scores: { exact_match: 1.0, action_group: 1.0 },
24
+ expected_actions: [{ group: "shipping", name: "lookup_order", arguments: { order_id: "42" } }]) do
25
+ api_interactions do
26
+ expect_request(method: :get, url: "https://shipping.example/orders/42",
27
+ query: { include_events: "true" }).respond_with(
28
+ body_file: "fixtures/order-42.json"
29
+ )
30
+ end
31
+ end
32
+
33
+ question({ order_id: "43" }, name: "Inline order", expected_answer: "delivered") do
34
+ api_interactions do
35
+ expect_request(method: :get, url: "https://shipping.example/orders/43",
36
+ query: { include_events: "true" }).respond_with(
37
+ json: { id: "43", status: "delivered" }
38
+ )
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "ruby_llm/evaluations"
5
+ require "net/http"
6
+ require "json"
7
+
8
+ module ContextBloatExample
9
+ E = RubyLLM::Evaluations
10
+ MAX_TOOL_RESULT_BYTES = 4_096
11
+
12
+ class ReturnPolicy < RubyLLM::Tool
13
+ description "Read the current product return deadline"
14
+ param :product, desc: "Product name"
15
+ attr_reader :api_response_bytes
16
+
17
+ def initialize(raw: false)
18
+ super()
19
+ @raw = raw
20
+ end
21
+
22
+ def name = "return_policy"
23
+
24
+ def execute(product:)
25
+ uri = URI("https://policy.example/returns")
26
+ uri.query = URI.encode_www_form(product: product)
27
+ body = Net::HTTP.get_response(uri).body
28
+ @api_response_bytes = body.bytesize
29
+ return body if @raw
30
+
31
+ data = JSON.parse(body)
32
+ # Preserve the relevant facts at the end, rather than truncating the beginning.
33
+ JSON.generate(product: data.fetch("product"), return_days: data.fetch("return_days"))
34
+ end
35
+ end
36
+
37
+ def self.suite(context:, model:, allow_host:, raw: false, expected_answer: "30")
38
+ agent = lambda do |question|
39
+ tool = ReturnPolicy.new(raw: raw)
40
+ chat = context.chat(model: model, provider: :openai, assume_model_exists: true)
41
+ .with_instructions("Use return_policy for the book. Reply with only the numeric return deadline.")
42
+ .with_tools(tool)
43
+ adapter = E::Adapters.ruby_llm_chat(action_groups: { return_policy: "returns" }) { chat }
44
+ response = adapter.call(question)
45
+ tool_bytes = chat.messages.select { |message| message.role == :tool }.map { |message| message.content.to_s.bytesize }
46
+ E::Response.new(output: response.output, actions: response.actions,
47
+ input_tokens: response.input_tokens, output_tokens: response.output_tokens, cost: response.cost,
48
+ metadata: response.metadata.merge("tool_result_bytes" => tool_bytes,
49
+ "api_response_bytes" => tool.api_response_bytes))
50
+ end
51
+
52
+ E.define("Large tool result context budget", agent: agent, agent_name: "ReturnPolicyAgent") do
53
+ evaluate :exact_match, using: E::Evaluators.exact_match
54
+ evaluate :tool_result_budget do |_output, context|
55
+ sizes = context.response.metadata.fetch("tool_result_bytes")
56
+ raise E::Error, "tool-result instrumentation missing" if sizes.empty?
57
+ sizes.all? { |bytes| bytes <= context.metadata.fetch(:max_tool_result_bytes) }
58
+ end
59
+ question "What is the return deadline for my book?", expected_answer: expected_answer,
60
+ minimum_scores: { exact_match: 1.0, action_group: 1.0, tool_result_budget: 1.0 },
61
+ metadata: { max_tool_result_bytes: MAX_TOOL_RESULT_BYTES },
62
+ expected_actions: [{ group: "returns", name: "return_policy", arguments: { product: "book" } }] do
63
+ api_interactions(allow_hosts: [allow_host]) do
64
+ expect_request(method: :get, url: "https://policy.example/returns", query: { product: "book" })
65
+ .respond_with(body_file: "fixtures/large_return_policy.json")
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Loading this file for a local transport test does not configure or call a provider.
73
+ if ENV["RUN_CONTEXT_BLOAT_EVAL"] == "1"
74
+ context = RubyLLM.context { |config| config.openai_api_key = ENV.fetch("OPENAI_API_KEY") }
75
+ ContextBloatExample.suite(context: context, model: ENV.fetch("OPENAI_MODEL", "gpt-4.1-mini"),
76
+ allow_host: "api.openai.com", raw: ENV["AGENT_REVISION"] == "regressed")
77
+ end