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