ruby_llm-agent-evaluations 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +166 -275
- data/app/models/ruby_llm/evaluations/case_result.rb +5 -1
- data/examples/a2ui_eval.rb +52 -0
- data/examples/context_bloat_eval.rb +1 -1
- data/lib/ruby_llm/evaluations/adapters.rb +3 -2
- data/lib/ruby_llm/evaluations/dashboard/run.erb +1 -0
- data/lib/ruby_llm/evaluations/data.rb +4 -0
- data/lib/ruby_llm/evaluations/evaluators.rb +20 -0
- data/lib/ruby_llm/evaluations/report.rb +1 -0
- data/lib/ruby_llm/evaluations/response.rb +26 -2
- data/lib/ruby_llm/evaluations/response_component_capture.rb +52 -0
- data/lib/ruby_llm/evaluations/response_component_tool.rb +15 -0
- data/lib/ruby_llm/evaluations/response_components.rb +42 -0
- data/lib/ruby_llm/evaluations/runner.rb +4 -1
- data/lib/ruby_llm/evaluations/version.rb +1 -1
- data/lib/ruby_llm/evaluations.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 234cfcef798c2ce853af3d76742761998f034d50d0af7407e03a54cd2ee13d87
|
|
4
|
+
data.tar.gz: 294265acd962e070043475a9378532461ef286efe0c09c3e51bd3d35095479ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b263299d8e3ddfae288ffe8c273972f59c7760dd9e1f7aedc442e28a6e759b11471d5aaeb01be6b232e1d6a0d2a5b43bbd39f84b949c31298d82b2948cbcb467
|
|
7
|
+
data.tar.gz: 9db863b992be470f3584000147484a07072b290321aacb2632bf2cac6a76c3e4c56f7a44658c3e7a82b35af54610e6949ebd27ee7a4105b3c1236b052551026f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Evaluate structured response components independently from text, with protocol/version checks and application-defined validators. Preserve component evidence in history, exports, and the dashboard using existing tables.
|
|
6
|
+
- Add optional capture for actual tools without changing returns or callbacks; retain observed tool names and arguments. Plain response components do not fabricate tool provenance.
|
|
7
|
+
- Include a standalone A2UI 0.8 example with a plain Ruby renderer and no RubyLLM dependency. Correct text does not conceal incorrect or missing UI.
|
|
8
|
+
- Simplify component validation and capture, improve the README, and verify SQLite/PostgreSQL/MySQL regression flows. No new migrations or runtime dependencies.
|
|
9
|
+
|
|
3
10
|
## 0.2.2
|
|
4
11
|
|
|
5
12
|
- Support PostgreSQL, SQLite, and MySQL 8.0+ through the ActiveRecord-selected adapter and one migration creating exactly three evaluation tables. Preserve UUID run IDs, append-only evidence, foreign keys, and chronological baseline ordering. MySQL uses binary, non-padding collation to preserve exact agent, case, and metric identities.
|
data/README.md
CHANGED
|
@@ -1,370 +1,261 @@
|
|
|
1
1
|
# RubyLLM Agent Evaluations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/ruby_llm-agent-evaluations)
|
|
4
|
+
[](https://github.com/luisgsilva950/ruby_llm-evaluations/actions/workflows/ci.yml)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**Evaluate agent behavior. Replay external APIs. Catch regressions before shipping a model, prompt, or tool change.**
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
- **Any callable agent.** Your application owns models, credentials, and orchestration; RubyLLM is optional.
|
|
9
|
+
- **Question-first scoring.** Semantic accuracy, relevancy, deterministic checks, and observed tool-call correctness.
|
|
10
|
+
- **Strict API replay.** Assert request parameters before returning inline or file-backed JSON.
|
|
11
|
+
- **Durable evidence.** SQLite, PostgreSQL, or MySQL 8.0+; compatible baselines, exportable reports, and a read-only dashboard.
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
gem "ruby_llm-agent-evaluations", "~> 0.2", require: "ruby_llm/evaluations"
|
|
11
|
-
~~~
|
|
13
|
+
[Quick start](#quick-start) · [Rails](#rails) · [Scoring](#scoring) · [API replay](#api-replay) · [Reports](#reports-and-dashboard) · [Examples](#examples)
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Quick start
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Requires Ruby 3.2+, Bundler, and one database driver. **No Rails, PostgreSQL server, or API key required for this example.**
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
Add to your `Gemfile`:
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
expected_answer: "You can return it within 30 days.",
|
|
25
|
-
minimum_scores: { semantic_accuracy: 0.9, answer_relevancy: 0.9 },
|
|
26
|
-
metadata: { important_facts: ["The return deadline is 30 days."] }
|
|
27
|
-
end
|
|
28
|
-
~~~
|
|
29
|
-
|
|
30
|
-
| Spec field | Meaning |
|
|
31
|
-
| --- | --- |
|
|
32
|
-
| `question` | The actual request sent to your agent. |
|
|
33
|
-
| `expected_answer` | A reference answer string, not a numeric score; paraphrases can pass semantic scoring. |
|
|
34
|
-
| `minimum_scores` | A map of metric names to absolute, inclusive numeric passing thresholds from 0 to 1, applied on every run, including the first. |
|
|
35
|
-
| `metadata` | Evaluation context, such as the essential facts that the judge must check. |
|
|
36
|
-
|
|
37
|
-
`minimum_scores: { semantic_accuracy: 0.9 }` means that the calculated semantic-accuracy score must be at least 0.9, even on the first run when no baseline exists. It does not compare against a previous run, and 0.9 is not a promise of 90% string similarity with expected_answer: the semantic judge scores factual meaning against that reference.
|
|
38
|
-
|
|
39
|
-
The library creates the selected metrics per question. Semantic accuracy measures factual meaning, accepting paraphrases while penalizing contradictions, unsupported additions, and omitted essential facts. Answer relevancy independently measures responsiveness: a wrong but on-topic answer can be relevant. Metadata is supplied as untrusted evaluation context; important_facts identifies factual priorities, not executable prompt instructions.
|
|
40
|
-
|
|
41
|
-
Without explicit suite-level evaluate definitions, omitted minimum_scores defaults to semantic_accuracy and answer_relevancy at 0.9. These metrics run only when a judge is supplied. With no judge, LLM-as-judge metrics are not calculated; deterministic and action-group checks still run. No substitute or fabricated score is provided. Explicit minimum_scores chooses named standard/custom metrics per question. Thresholds are inclusive.
|
|
42
|
-
|
|
43
|
-
Questions accept optional name:, id:, and expected_actions:. Names default to question text (or a generated label for structured questions); IDs default to names and must be unique. Question/reference/metadata/output data must be JSON-portable. Symbol keys/values retain Ruby distinctions in fingerprints and agent calls; persisted JSON normalizes them. Unsupported objects fail explicitly. Specs execute trusted Ruby, not sandboxed code, and must return a Suite; define returns one.
|
|
44
|
-
|
|
45
|
-
## Strict API replay
|
|
21
|
+
```ruby
|
|
22
|
+
source "https://rubygems.org"
|
|
23
|
+
gem "ruby_llm-agent-evaluations", "~> 0.3.0", require: "ruby_llm/evaluations"
|
|
24
|
+
gem "sqlite3" # Or your application's pg/mysql2 driver.
|
|
25
|
+
```
|
|
46
26
|
|
|
47
|
-
|
|
27
|
+
Save as `support_eval.rb`. This offline lookup demonstrates the pipeline, not model reasoning:
|
|
48
28
|
|
|
49
|
-
|
|
50
|
-
require "net/http"
|
|
51
|
-
require "json"
|
|
29
|
+
```ruby
|
|
52
30
|
require "ruby_llm/evaluations"
|
|
53
31
|
|
|
54
32
|
E = RubyLLM::Evaluations
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
JSON.parse(response.body).fetch("status")
|
|
58
|
-
end
|
|
33
|
+
policy = { "How long do I have to return an item?" => ENV.fetch("RETURN_WINDOW", "30 days") }
|
|
34
|
+
agent = ->(question) { policy.fetch(question) }
|
|
59
35
|
|
|
60
|
-
E.define("
|
|
61
|
-
evaluate :
|
|
62
|
-
question "
|
|
63
|
-
|
|
64
|
-
expect_request(method: :get, url: "https://shipping.example/orders",
|
|
65
|
-
query: { id: "42" })
|
|
66
|
-
.respond_with(status: 200, headers: { "Content-Type" => "application/json" },
|
|
67
|
-
json: { status: "ready" })
|
|
68
|
-
end
|
|
69
|
-
end
|
|
36
|
+
E.define("Return policy", agent: agent, agent_name: "SupportAgent", version: "1") do
|
|
37
|
+
evaluate :exact_match, using: E::Evaluators.exact_match
|
|
38
|
+
question "How long do I have to return an item?",
|
|
39
|
+
expected_answer: "30 days", minimum_scores: { exact_match: 1.0 }
|
|
70
40
|
end
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Save it as `shipping_eval.rb`, then run `ruby_llm-agent-evaluations run shipping_eval.rb` after migrations. [examples/api_replay_eval.rb](examples/api_replay_eval.rb) also demonstrates file responses. Replay supports Net::HTTP and Faraday through standard WebMock-supported transports; it does not replace your agent or HTTP client. Only the agent call is wrapped, not evaluators or judges. Existing mock registrations, network configuration, and enabled adapters are restored after each case, including failures.
|
|
41
|
+
```
|
|
74
42
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
43
|
+
```sh
|
|
44
|
+
bundle install
|
|
45
|
+
bundle exec ruby_llm-agent-evaluations db:migrate
|
|
46
|
+
bundle exec ruby_llm-agent-evaluations run support_eval.rb
|
|
47
|
+
RETURN_WINDOW='7 days' bundle exec ruby_llm-agent-evaluations run support_eval.rb --fail-on-regression
|
|
48
|
+
bundle exec ruby_llm-agent-evaluations history
|
|
49
|
+
bundle exec ruby_llm-agent-evaluations dashboard
|
|
50
|
+
```
|
|
80
51
|
|
|
81
|
-
|
|
52
|
+
The reference passes; the changed answer exits **1**. Open **http://127.0.0.1:4567** to inspect both runs. Without `DATABASE_URL`, history persists in `ruby_llm_evaluations.sqlite3` in the current directory. Migrate once during setup, never per run.
|
|
82
53
|
|
|
83
|
-
|
|
54
|
+
## Rails
|
|
84
55
|
|
|
85
|
-
|
|
56
|
+
Keep your application's existing database driver, configuration, and connection pool:
|
|
86
57
|
|
|
87
|
-
|
|
58
|
+
```sh
|
|
59
|
+
bin/rails ruby_llm_evaluations:install:migrations
|
|
60
|
+
bin/rails db:migrate
|
|
61
|
+
```
|
|
88
62
|
|
|
89
|
-
|
|
63
|
+
```ruby
|
|
64
|
+
# config/routes.rb
|
|
65
|
+
mount RubyLLM::Evaluations::Engine => "/evaluations"
|
|
66
|
+
```
|
|
90
67
|
|
|
91
|
-
|
|
68
|
+
The engine creates **three tables**: runs, case results, and metric results. It uses `jsonb` on PostgreSQL and `json` elsewhere; MySQL's `utf8mb4_0900_bin` collation preserves exact identifiers. Execution never reconfigures the host pool. Existing PostgreSQL history needs no reset; gem updates do not rewrite copied migrations.
|
|
92
69
|
|
|
93
|
-
|
|
70
|
+
Standalone library callers configure ActiveRecord themselves. The CLI selects its connection using `DATABASE_URL` or `--database-url`.
|
|
94
71
|
|
|
95
|
-
|
|
72
|
+
**No built-in dashboard authentication.** Protect the Rails mount with application authorization.
|
|
96
73
|
|
|
97
|
-
|
|
98
|
-
# Requires DATABASE_URL, migrations, and your OPENAI_API_KEY.
|
|
99
|
-
RUN_CONTEXT_BLOAT_EVAL=1 OPENAI_MODEL=gpt-4.1-mini bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/context_bloat_eval.rb
|
|
74
|
+
## Scoring
|
|
100
75
|
|
|
101
|
-
|
|
102
|
-
RUN_CONTEXT_BLOAT_EVAL=1 AGENT_REVISION=regressed OPENAI_MODEL=gpt-4.1-mini bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/context_bloat_eval.rb --fail-on-regression
|
|
103
|
-
~~~
|
|
76
|
+
`expected_answer` is the reference; `minimum_scores` contains inclusive thresholds in **[0, 1]**, applied even on the first run. Semantic scores are not string-similarity percentages.
|
|
104
77
|
|
|
105
|
-
|
|
78
|
+
| Dimension | Checks |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `semantic_accuracy` | Factual meaning, contradictions, and omissions against the reference. |
|
|
81
|
+
| `answer_relevancy` | Responsiveness to the request; an on-topic answer can still be wrong. |
|
|
82
|
+
| `action_group` | Observed group, tool name, arguments, and order—including unexpected calls. |
|
|
106
83
|
|
|
107
|
-
|
|
84
|
+
Pass `judge: YourJudgeAgent`, a `RubyLLM::Agent` subclass, to `define` or `Suite.new` for semantic metrics. Each assessment uses a fresh instance; your application configures the model and credentials. See the complete [RubyLLM example](examples/ruby_llm_eval.rb). Provider calls incur charges.
|
|
108
85
|
|
|
109
|
-
|
|
86
|
+
Without a judge, LLM metrics are skipped. **No calculated metrics means `not_evaluated` with null quality/pass rate—not a passing substitute.** Unspecified metrics default to semantic accuracy/relevancy at 0.9 unless explicit evaluators are registered. Agent/evaluator failures remain errors; other checks continue.
|
|
110
87
|
|
|
111
|
-
|
|
112
|
-
require "ruby_llm"
|
|
113
|
-
require "ruby_llm/evaluations"
|
|
88
|
+
Custom evaluators receive `(output, context)` and return a Boolean, finite score, or `Assessment`. Context includes case data and the full response:
|
|
114
89
|
|
|
115
|
-
|
|
116
|
-
|
|
90
|
+
```ruby
|
|
91
|
+
# Inside a suite; give the question metadata: { max_words: 12 }.
|
|
92
|
+
evaluate(:conciseness, threshold: 1.0) do |output, context|
|
|
93
|
+
output.split.size <= context.metadata.fetch(:max_words)
|
|
117
94
|
end
|
|
95
|
+
```
|
|
118
96
|
|
|
119
|
-
|
|
120
|
-
"Customer support", agent: support_agent, judge: SupportJudgeAgent
|
|
121
|
-
)
|
|
122
|
-
suite.question "How long do I have to return an item?", expected_answer: "30 days",
|
|
123
|
-
minimum_scores: { semantic_accuracy: 0.9, answer_relevancy: 0.9 }
|
|
124
|
-
~~~
|
|
125
|
-
|
|
126
|
-
The define DSL forwards judge: to this same constructor. Provider credentials use your application's normal RubyLLM configuration; no separate evaluation-judge configuration is needed. Supplying a judge makes real model requests when the suite runs and can incur charges.
|
|
127
|
-
|
|
128
|
-
Omit judge: to skip semantic_accuracy, answer_relevancy, and custom llm_judge checks. Declared minimum_scores and agent outputs are still persisted. A question with no calculated metrics is not_evaluated, not passed; score and pass rate are null when no questions were evaluated. Mixed suites calculate quality only from executed metrics and evaluated questions. A different calculated metric set changes the fingerprint, so runs that skipped LLM metrics are never automatic regression baselines for runs that calculated them.
|
|
97
|
+
Built-ins include `exact_match`, `contains`, `matches`, and `valid_json`. Action checks require observed `Response.actions` or the RubyLLM adapter's `action_groups`: `nil` means missing instrumentation; `[]` means no observed calls. Never infer execution from an answer.
|
|
129
98
|
|
|
130
|
-
|
|
99
|
+
**Passing is not the same as avoiding regression.** A drop from 0.98 to 0.92 passes a 0.9 minimum but regresses beyond a 0.02 tolerance. Baselines match earlier suite/agent/definition fingerprints. Questions, references/actions, minimums, metadata, fixture bytes, and suite version affect compatibility. **Bump suite `version` when changing scoring code, rubrics, or judge configuration**; callable source is not hashed.
|
|
131
100
|
|
|
132
|
-
|
|
101
|
+
### Structured responses
|
|
133
102
|
|
|
134
|
-
|
|
103
|
+
Score UI/protocol payloads separately from text. **UI is response data, not a tool call.** Return your renderer's payload explicitly; no RubyLLM tool class or concern is needed:
|
|
135
104
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
~~~ruby
|
|
145
|
-
gem "sqlite3" # SQLite
|
|
146
|
-
# gem "pg" # PostgreSQL instead
|
|
147
|
-
# gem "mysql2" # MySQL instead
|
|
148
|
-
~~~
|
|
149
|
-
|
|
150
|
-
Rails uses its existing database configuration and normal connection pool; no separate evaluation connection or storage configuration is needed. MySQL tables use `utf8mb4_0900_bin` so case, accents, and trailing spaces remain distinct in identifiers and baseline matching.
|
|
151
|
-
|
|
152
|
-
Install the engine migrations into the host application, then migrate normally:
|
|
153
|
-
|
|
154
|
-
~~~sh
|
|
155
|
-
bin/rails ruby_llm_evaluations:install:migrations
|
|
156
|
-
bin/rails db:migrate
|
|
157
|
-
~~~
|
|
158
|
-
|
|
159
|
-
For standalone use, run `ruby_llm-agent-evaluations db:migrate` once before the first evaluation. Rails applications install the single engine migration and run `bin/rails db:migrate` as shown above.
|
|
160
|
-
|
|
161
|
-
The single migration creates exactly three evaluation tables: `ruby_llm_evaluations_runs`, `ruby_llm_evaluations_case_results`, and `ruby_llm_evaluations_metric_results`. It creates `minimum_scores` directly, with the foreign keys and indexes needed for history and baseline comparisons. No legacy tables, payload imports, or custom schema-version tables are required or inspected. ActiveRecord manages its ordinary migration bookkeeping. Execution uses the host connection pool and does not establish, migrate, or disconnect it.
|
|
105
|
+
```ruby
|
|
106
|
+
# Inside your agent; ui_payload comes from your application's renderer.
|
|
107
|
+
RubyLLM::Evaluations::Response.new(
|
|
108
|
+
output: "Returns within 30 days",
|
|
109
|
+
components: [{ protocol: "a2ui", version: "0.8", payload: ui_payload }]
|
|
110
|
+
)
|
|
111
|
+
```
|
|
162
112
|
|
|
163
|
-
|
|
113
|
+
Evaluators read `context.response.components`; history and reports retain the evidence without new tables or migrations.
|
|
164
114
|
|
|
165
|
-
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
115
|
+
```ruby
|
|
116
|
+
# Build the check separately; expected_ui_messages is parsed JSON in question metadata.
|
|
117
|
+
ui_check = RubyLLM::Evaluations::Evaluators.response_components(protocol: "a2ui", version: "0.8") do |components, context|
|
|
118
|
+
actual_messages = components.flat_map do |component|
|
|
119
|
+
component.fetch("payload").lines.map { |line| JSON.parse(line) }
|
|
120
|
+
end
|
|
121
|
+
actual_messages == context.metadata.fetch(:expected_ui_messages)
|
|
169
122
|
end
|
|
170
|
-
~~~
|
|
171
|
-
|
|
172
|
-
Visit /evaluations. Assets, filters, detail/baseline links, and downloads respect the mount path. The dashboard has **no built-in authentication**: protect it with application authorization or a trusted reverse proxy before exposing sensitive data. It accepts GET/HEAD only.
|
|
173
|
-
|
|
174
|
-
## Offline quick start: exact match, not semantic judging
|
|
175
|
-
|
|
176
|
-
Requirements: Ruby 3.2+, Bundler, and your chosen database driver (`sqlite3` for this quick start). Rails and RubyLLM are optional. This deterministic local demo runs without API keys and is deliberately separate from semantic judging. It performs a real local answer lookup and records that action; exact match is not a semantic judge.
|
|
177
|
-
|
|
178
|
-
From this checkout, the development bundle supplies the drivers. Standalone consumers add the gem and their chosen driver to their own Gemfile, then use `bundle exec ruby_llm-agent-evaluations` in place of the checkout command below.
|
|
179
|
-
|
|
180
|
-
~~~sh
|
|
181
|
-
bundle install
|
|
182
|
-
unset DATABASE_URL # Defaults to sqlite3:ruby_llm_evaluations.sqlite3 in the current directory.
|
|
183
|
-
|
|
184
|
-
# Migrate once before executing specs. Runs never create tables implicitly.
|
|
185
|
-
bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations db:migrate
|
|
186
|
-
bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --metadata '{"revision":"reference"}'
|
|
187
|
-
|
|
188
|
-
# Exit 1 is intentional: incorrect answer and wrong lookup arguments.
|
|
189
|
-
AGENT_REVISION=regressed bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --fail-on-regression --metadata '{"revision":"regressed"}'
|
|
190
123
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
124
|
+
# Inside your suite:
|
|
125
|
+
evaluate :a2ui, using: ui_check
|
|
126
|
+
```
|
|
194
127
|
|
|
195
|
-
|
|
128
|
+
The evaluator rejects missing components, an empty matching set, or a wrong declared version before your check runs. Your block validates the schema and application contract; the gem does **not** certify A2UI compliance or render supplied UI. Payloads preserve their original JSON-compatible shape; JSON/JSONL strings are not automatically parsed.
|
|
196
129
|
|
|
197
|
-
|
|
130
|
+
Text is optional (`output: nil`). Components `nil` means unreported, `[]` means explicitly none. Put expected UI data in question metadata and bump suite `version` when validators change. `response_components` in response metadata is reserved for persistence.
|
|
198
131
|
|
|
199
|
-
|
|
132
|
+
**Only for actual tools:** if an existing tool emits a response component, opt into capture inside that tool class:
|
|
200
133
|
|
|
201
|
-
|
|
202
|
-
|
|
134
|
+
```ruby
|
|
135
|
+
prepend RubyLLM::Evaluations::ResponseComponentTool
|
|
203
136
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
docker compose exec -T mysql mysql -uroot -pdev-only -e 'CREATE DATABASE IF NOT EXISTS ruby_llm_evaluations;'
|
|
209
|
-
export DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/ruby_llm_evaluations'
|
|
210
|
-
|
|
211
|
-
# With the selected URL, migrate once, then run the same demo.
|
|
212
|
-
bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations db:migrate
|
|
213
|
-
bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb
|
|
214
|
-
~~~
|
|
215
|
-
|
|
216
|
-
Compose binds passwordless PostgreSQL to loopback port 55432 and MySQL 8.4 with the development-only root password `dev-only` to loopback port 53306. **Do not use these credentials/configurations in production.** Named volumes preserve database history; `docker compose stop` does not delete it. For your own service, set DATABASE_URL to an existing database with an appropriate application account.
|
|
217
|
-
|
|
218
|
-
The offline example produces the following evidence:
|
|
137
|
+
def response_component
|
|
138
|
+
{ protocol: "a2ui", version: "0.8" }
|
|
139
|
+
end
|
|
140
|
+
```
|
|
219
141
|
|
|
220
|
-
|
|
221
|
-
| --- | --- | --- | --- |
|
|
222
|
-
| Reference | 3 of 3 | 1.000 | 0 |
|
|
223
|
-
| Regressed | 2 of 3 | 0.667 | 1 |
|
|
142
|
+
Marked tools keep their return values and callbacks. `Evaluations.run` and the RubyLLM chat adapter capture results in call order with protocol, declared version, tool name, arguments, and payload; explicit final-response components follow them. Outside a run, use `Response.capture { agent.call(question) }`. Tool capture is synchronous and fiber-local; worker-thread/streaming agents assemble components explicitly. Plain renderer output has no fabricated tool provenance.
|
|
224
143
|
|
|
225
|
-
|
|
144
|
+
See the [complete offline A2UI 0.8 example](examples/a2ui_eval.rb), which uses a plain renderer and needs no RubyLLM dependency: run normally, then with `AGENT_REVISION=regressed` or `AGENT_REVISION=missing_ui`. Correct text still passes; incorrect/missing UI fails.
|
|
226
145
|
|
|
227
|
-
|
|
146
|
+
## API replay
|
|
228
147
|
|
|
229
|
-
|
|
230
|
-
ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL", "sqlite3:ruby_llm_evaluations.sqlite3"))
|
|
231
|
-
RubyLLM::Evaluations.migrate! # Once during database setup, not per run.
|
|
232
|
-
~~~
|
|
148
|
+
Keep API data fixed while exercising the real HTTP client. Save as `shipping_eval.rb` and run with the same CLI:
|
|
233
149
|
|
|
234
|
-
|
|
150
|
+
```ruby
|
|
151
|
+
require "net/http"
|
|
152
|
+
require "json"
|
|
153
|
+
require "ruby_llm/evaluations"
|
|
235
154
|
|
|
236
|
-
|
|
155
|
+
E = RubyLLM::Evaluations
|
|
156
|
+
agent = ->(_question) do
|
|
157
|
+
response = Net::HTTP.get_response(URI("https://shipping.example/orders?id=42"))
|
|
158
|
+
JSON.parse(response.body).fetch("status")
|
|
159
|
+
end
|
|
237
160
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
161
|
+
E.define("Shipping", agent: agent, agent_name: "ShippingAgent") do
|
|
162
|
+
evaluate :exact_match, using: E::Evaluators.exact_match
|
|
163
|
+
question "Track order 42", expected_answer: "ready" do
|
|
164
|
+
api_interactions do
|
|
165
|
+
expect_request(method: :get, url: "https://shipping.example/orders", query: { id: "42" })
|
|
166
|
+
.respond_with(json: { status: "ready" })
|
|
167
|
+
end
|
|
243
168
|
end
|
|
244
|
-
question "Explain returns briefly", expected_answer: "Return within 30 days.",
|
|
245
|
-
minimum_scores: { conciseness: 0.8 }, metadata: { max_words: 12 }
|
|
246
169
|
end
|
|
247
|
-
|
|
170
|
+
```
|
|
248
171
|
|
|
249
|
-
|
|
172
|
+
For large responses, replace `json:` with `body_file: "fixtures/order.json"`. Files resolve relative to the spec; exact bytes are snapshotted and fingerprinted. Invalid/missing files fail before execution. See [inline and file fixtures](examples/api_replay_eval.rb).
|
|
250
173
|
|
|
251
|
-
|
|
174
|
+
Requests must match method, URL, query, typed JSON body, and declared headers **once, in order**. Unexpected, wrong, missing, or exhausted requests fail the case—even if rescued by the agent. JSON `1`, `"1"`, `false`, and `null` stay distinct; omitting request `json:` requires an empty body.
|
|
252
175
|
|
|
253
|
-
|
|
254
|
-
question "Track order 42", expected_answer: "Order 42 has shipped.",
|
|
255
|
-
minimum_scores: { action_group: 1.0 },
|
|
256
|
-
expected_actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }]
|
|
257
|
-
~~~
|
|
176
|
+
Replay wraps agent execution, supports WebMock-compatible clients, and persists redacted mismatch evidence. Seed database tools separately; there is no recording mode. Explicit `allow_hosts: ["api.openai.com"]` permits live model traffic without bypassing fixtures. Judges run outside replay.
|
|
258
177
|
|
|
259
|
-
|
|
178
|
+
**Use a dedicated process:** WebMock interception is process-global, although replay cases serialize and restore caller state.
|
|
260
179
|
|
|
261
|
-
|
|
262
|
-
RubyLLM::Evaluations::Response.new(
|
|
263
|
-
output: "Order 42 has shipped.",
|
|
264
|
-
actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }],
|
|
265
|
-
input_tokens: 120, output_tokens: 18, cost: 0.0002,
|
|
266
|
-
metadata: { model: "your-model" }
|
|
267
|
-
)
|
|
268
|
-
~~~
|
|
180
|
+
## Reports and dashboard
|
|
269
181
|
|
|
270
|
-
|
|
182
|
+
```sh
|
|
183
|
+
bundle exec ruby_llm-agent-evaluations run support_eval.rb --fail-on-regression --tolerance 0.02
|
|
184
|
+
bundle exec ruby_llm-agent-evaluations history --suite 'Return policy' --limit 50
|
|
185
|
+
bundle exec ruby_llm-agent-evaluations report --format html --output report.html
|
|
186
|
+
bundle exec ruby_llm-agent-evaluations report RUN_ID --baseline BASELINE_ID --format json
|
|
187
|
+
```
|
|
271
188
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
## Persisted evidence and compatible baselines
|
|
275
|
-
|
|
276
|
-
~~~ruby
|
|
189
|
+
```ruby
|
|
277
190
|
run = RubyLLM::Evaluations.run(suite, metadata: { revision: "candidate" })
|
|
278
|
-
# An actual RubyLLM::Evaluations::Run ActiveRecord record.
|
|
279
191
|
report = RubyLLM::Evaluations::Report.new(run, baseline: run.previous, tolerance: 0.02)
|
|
280
|
-
report.export("
|
|
281
|
-
|
|
282
|
-
RubyLLM::Evaluations::Run.recent.for_suite("Customer support").with_results
|
|
283
|
-
run.case_results.first.metric_results
|
|
284
|
-
snapshot = run.snapshot # Detached JSON-portable evidence, not a persistence API.
|
|
285
|
-
~~~
|
|
192
|
+
report.export("report.html") # Also .json, .md, or .txt; export writes the file.
|
|
193
|
+
```
|
|
286
194
|
|
|
287
|
-
|
|
195
|
+
Runs have immutable UUIDs and append-only model APIs. Baselines are chronological, never from the future. Invalid/incompatible explicit baselines fail before agent execution; tolerance never hides newly failed thresholds or errors.
|
|
288
196
|
|
|
289
|
-
|
|
197
|
+
CLI exits: **0** no failed checks/gated regression, **1** failed/errored checks or gated regression, **2** invalid input/configuration. Exit 0 does not establish quality when all metrics were skipped. JSON exports contain the run plus `comparison`; usage totals cover reported agent usage, not judge usage.
|
|
290
198
|
|
|
291
|
-
|
|
199
|
+
The dashboard exposes filters, scores, baseline comparisons, case evidence, and JSON downloads. HTML reports escape output and need no external assets. This capture shows persisted offline evaluations, not live model reasoning:
|
|
292
200
|
|
|
293
|
-
|
|
201
|
+

|
|
294
202
|
|
|
295
|
-
|
|
203
|
+
<details>
|
|
204
|
+
<summary>More screenshots</summary>
|
|
296
205
|
|
|
297
|
-
|
|
206
|
+

|
|
207
|
+

|
|
208
|
+

|
|
209
|
+

|
|
210
|
+

|
|
298
211
|
|
|
299
|
-
|
|
300
|
-
ruby_llm-agent-evaluations run your_eval.rb --fail-on-regression --tolerance 0.02
|
|
301
|
-
ruby_llm-agent-evaluations run your_eval.rb --baseline RUN_ID --fail-on-regression
|
|
302
|
-
ruby_llm-agent-evaluations history --suite 'Customer support' --agent SupportAgent --limit 50
|
|
303
|
-
ruby_llm-agent-evaluations report --format html --output agent.html
|
|
304
|
-
ruby_llm-agent-evaluations report --format markdown --output agent.md
|
|
305
|
-
ruby_llm-agent-evaluations report RUN_ID --baseline BASELINE_ID --format json
|
|
306
|
-
~~~
|
|
212
|
+
</details>
|
|
307
213
|
|
|
308
|
-
|
|
214
|
+
## Examples
|
|
309
215
|
|
|
310
|
-
|
|
216
|
+
| Example | Purpose | Provider calls |
|
|
217
|
+
| --- | --- | --- |
|
|
218
|
+
| [Offline support](examples/support_eval.rb) | Answers, actions, regression gating. | None |
|
|
219
|
+
| [API replay](examples/api_replay_eval.rb) | Inline/file fixtures and request arguments. | None |
|
|
220
|
+
| [RubyLLM judging](examples/ruby_llm_eval.rb) | Semantic accuracy and relevancy. | Paid; opt-in |
|
|
221
|
+
| [Semantic + actions](examples/semantic_actions_eval.rb) | Answer and tool correctness together. | Paid; opt-in |
|
|
222
|
+
| [Context bloat](examples/context_bloat_eval.rb) | ~500 KB fixture, tool arguments, 4,096-byte tool-result budget. | Paid; opt-in |
|
|
311
223
|
|
|
312
|
-
|
|
224
|
+
The bloat example measures **bytes**, not tokens or universal context-window fit. Scripted-provider tests prove transport, not model reasoning. Bundled RubyLLM adapters/judges target **1.x (1.14.1+)**; 2.x adapter compatibility is not claimed. Generic callables do not require RubyLLM.
|
|
313
225
|
|
|
314
|
-
|
|
315
|
-

|
|
316
|
-

|
|
317
|
-

|
|
318
|
-

|
|
226
|
+
## Safety
|
|
319
227
|
|
|
320
|
-
|
|
228
|
+
Specs execute trusted Ruby. Evaluation data persists: use synthetic fixtures, redact sensitive information, and manage retention/backups. Never commit credentials, production data, databases, or sensitive reports. Judges are nondeterministic; calibrate against human review. Prompt injection remains a risk. The standalone dashboard is loopback-bound and GET/HEAD-only by default.
|
|
321
229
|
|
|
322
|
-
|
|
230
|
+
<details>
|
|
231
|
+
<summary>Development and publishing</summary>
|
|
323
232
|
|
|
324
|
-
|
|
233
|
+
Install native client headers required by `pg`/`mysql2`, then from this checkout:
|
|
325
234
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
~~~sh
|
|
235
|
+
```sh
|
|
329
236
|
bundle install
|
|
330
|
-
docker compose up -d postgres mysql
|
|
237
|
+
docker compose up -d --wait postgres mysql
|
|
238
|
+
# Once, if the test database does not exist:
|
|
331
239
|
docker compose exec -T postgres createdb -U postgres ruby_llm_evaluations_test
|
|
332
240
|
export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
|
|
333
241
|
export TEST_MYSQL_DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/mysql'
|
|
334
242
|
bundle exec rake integration
|
|
335
243
|
bundle exec rake test
|
|
244
|
+
```
|
|
336
245
|
|
|
337
|
-
|
|
338
|
-
gem install ./ruby_llm-agent-evaluations-0.2.2.gem
|
|
339
|
-
ruby_llm-agent-evaluations --help
|
|
340
|
-
~~~
|
|
341
|
-
|
|
342
|
-
Tests isolate temporary SQLite files, PostgreSQL schemas, and MySQL databases rather than clear development history. The PostgreSQL test role needs schema-creation privileges; the MySQL test account needs database-creation/deletion privileges on the test server. SQLite needs no service or test URL. Local HTTP provider fixtures verify real adapter transport/orchestration, not model reasoning. Default tests never call paid providers. For UI changes inspect desktop/narrow layouts, keyboard access, filters, chart navigation, and downloads. Shared engineering guidance and specialist skills live in [.agents/](.agents/AGENTS.md).
|
|
343
|
-
|
|
344
|
-
## Publishing
|
|
246
|
+
Tests isolate SQLite files, PostgreSQL schemas, and MySQL databases; test accounts need creation privileges. Default tests never call paid providers. Consumers need only their chosen driver. Compose credentials are **development-only**, bound to loopback. See [engineering guidance](.agents/AGENTS.md).
|
|
345
247
|
|
|
346
|
-
|
|
248
|
+
To publish, bump `lib/ruby_llm/evaluations/version.rb`, update [CHANGELOG](CHANGELOG.md), then:
|
|
347
249
|
|
|
348
|
-
|
|
349
|
-
export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
|
|
350
|
-
export TEST_MYSQL_DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/mysql'
|
|
351
|
-
bundle install
|
|
250
|
+
```sh
|
|
352
251
|
bundle exec ruby script/publish --dry-run
|
|
353
|
-
|
|
354
|
-
# Authenticate locally; never paste or commit an API key.
|
|
355
252
|
gem signin
|
|
356
|
-
# Commit
|
|
253
|
+
# Commit release changes before uploading.
|
|
357
254
|
bundle exec ruby script/publish
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
The script runs integration tests and the full suite, builds the gem, rejects private paths in its manifest, installs into a temporary GEM_HOME, and executes inline/file API replay against an isolated PostgreSQL schema outside the checkout. It verifies persisted evidence and argument-mismatch regression. Temporary package, installation, and schema are removed afterward. `--dry-run` runs these gates without credentials or upload. Normal publication requires a clean committed working tree and uploads only to `https://rubygems.org`; existing RubyGems credentials or `GEM_HOST_API_KEY` are supported. Credentials need permission to push this gem; RubyGems may additionally request MFA. No version bumps, git commits, tags, or pushes happen implicitly.
|
|
361
|
-
|
|
362
|
-
RubyLLM remains optional. The development bundle and provider-backed examples currently target RubyLLM 1.x; this release does not claim RubyLLM 2.x adapter compatibility. Default release checks do not call paid providers.
|
|
363
|
-
|
|
364
|
-
## Data and security
|
|
255
|
+
```
|
|
365
256
|
|
|
366
|
-
|
|
257
|
+
The script gates upload on tests, package checks, and installed-gem replay/persistence smoke. Dry runs upload nothing. Publication requires a clean committed tree and push-authorized RubyGems credentials (`GEM_HOST_API_KEY` supported); MFA may apply. Versions cannot be replaced. Git commits/tags/pushes are not automatic.
|
|
367
258
|
|
|
368
|
-
|
|
259
|
+
</details>
|
|
369
260
|
|
|
370
|
-
MIT.
|
|
261
|
+
[MIT license](LICENSE) · [Changelog](CHANGELOG.md) · [RubyGems](https://rubygems.org/gems/ruby_llm-agent-evaluations)
|
|
@@ -18,13 +18,17 @@ module RubyLLM
|
|
|
18
18
|
validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }
|
|
19
19
|
validate :validate_json
|
|
20
20
|
|
|
21
|
+
def components
|
|
22
|
+
response_metadata&.fetch("response_components", nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
21
25
|
def snapshot
|
|
22
26
|
Data.json_value(
|
|
23
27
|
id: case_id, name: name, question: question, expected_answer: expected_answer,
|
|
24
28
|
expected_actions: expected_actions, minimum_scores: minimum_scores,
|
|
25
29
|
metadata: metadata, output: output, status: status, duration_ms: duration_ms,
|
|
26
30
|
input_tokens: input_tokens, output_tokens: output_tokens, cost: cost, actions: actions,
|
|
27
|
-
response_metadata: response_metadata, error: error_details,
|
|
31
|
+
components: components, response_metadata: response_metadata&.except("response_components"), error: error_details,
|
|
28
32
|
metrics: metric_results.map(&:snapshot)
|
|
29
33
|
)
|
|
30
34
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Offline consumer example, not proof of model reasoning. No RubyLLM dependency.
|
|
4
|
+
require "ruby_llm/evaluations"
|
|
5
|
+
|
|
6
|
+
class RenderReturnPolicy
|
|
7
|
+
def call(days:)
|
|
8
|
+
[
|
|
9
|
+
JSON.generate(surfaceUpdate: { surfaceId: "returns", components: [
|
|
10
|
+
{ id: "root", component: { Text: { text: { literalString: "Returns within #{days} days" } } } }
|
|
11
|
+
] }),
|
|
12
|
+
JSON.generate(beginRendering: { surfaceId: "returns", root: "root" })
|
|
13
|
+
].join("\n")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
agent = lambda do |_question|
|
|
18
|
+
components = []
|
|
19
|
+
unless ENV["AGENT_REVISION"] == "missing_ui"
|
|
20
|
+
days = ENV["AGENT_REVISION"] == "regressed" ? 7 : 30
|
|
21
|
+
components << { protocol: "a2ui", version: "0.8", payload: RenderReturnPolicy.new.call(days: days) }
|
|
22
|
+
end
|
|
23
|
+
RubyLLM::Evaluations::Response.new(
|
|
24
|
+
output: "Returns within 30 days", # Correct text must not conceal incorrect/missing UI.
|
|
25
|
+
components: components
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Expected UI is question data, independent of the tool implementation.
|
|
30
|
+
expected_ui_messages = [
|
|
31
|
+
{ "surfaceUpdate" => { "surfaceId" => "returns", "components" => [
|
|
32
|
+
{ "id" => "root", "component" => { "Text" => { "text" => { "literalString" => "Returns within 30 days" } } } }
|
|
33
|
+
] } },
|
|
34
|
+
{ "beginRendering" => { "surfaceId" => "returns", "root" => "root" } }
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Application-specific contract, not a complete A2UI schema validator.
|
|
38
|
+
ui_check = RubyLLM::Evaluations::Evaluators.response_components(protocol: "a2ui", version: "0.8") do |components, context|
|
|
39
|
+
next false unless components.one?
|
|
40
|
+
|
|
41
|
+
actual_messages = components.first.fetch("payload").lines.map { |line| JSON.parse(line) }
|
|
42
|
+
actual_messages == context.metadata.fetch(:expected_ui_messages)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RubyLLM::Evaluations.define("A2UI return policy", agent: agent, agent_name: "ReturnPolicyAgent", version: "1") do
|
|
46
|
+
evaluate :text, using: RubyLLM::Evaluations::Evaluators.exact_match
|
|
47
|
+
evaluate :a2ui, using: ui_check
|
|
48
|
+
question "Show the return policy",
|
|
49
|
+
expected_answer: "Returns within 30 days",
|
|
50
|
+
minimum_scores: { text: 1.0, a2ui: 1.0 },
|
|
51
|
+
metadata: { protocol: "a2ui", protocol_version: "0.8", expected_ui_messages: expected_ui_messages }
|
|
52
|
+
end
|
|
@@ -43,7 +43,7 @@ module ContextBloatExample
|
|
|
43
43
|
adapter = E::Adapters.ruby_llm_chat(action_groups: { return_policy: "returns" }) { chat }
|
|
44
44
|
response = adapter.call(question)
|
|
45
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,
|
|
46
|
+
E::Response.new(output: response.output, actions: response.actions, components: response.components,
|
|
47
47
|
input_tokens: response.input_tokens, output_tokens: response.output_tokens, cost: response.cost,
|
|
48
48
|
metadata: response.metadata.merge("tool_result_bytes" => tool_bytes,
|
|
49
49
|
"api_response_bytes" => tool.api_response_bytes))
|
|
@@ -15,7 +15,7 @@ module RubyLLM
|
|
|
15
15
|
end
|
|
16
16
|
lambda do |input|
|
|
17
17
|
chat = factory.call
|
|
18
|
-
answer = Response.
|
|
18
|
+
answer = Response.capture { chat.ask(input) }
|
|
19
19
|
messages = chat.messages
|
|
20
20
|
actions = messages.flat_map do |message|
|
|
21
21
|
(message.tool_calls || {}).each_value.map do |call|
|
|
@@ -25,7 +25,8 @@ module RubyLLM
|
|
|
25
25
|
assistant_messages = messages.select { |message| message.role == :assistant }
|
|
26
26
|
Response.new(output: answer.output, metadata: answer.metadata, cost: answer.cost,
|
|
27
27
|
input_tokens: total_tokens(assistant_messages, :input_tokens),
|
|
28
|
-
output_tokens: total_tokens(assistant_messages, :output_tokens), actions: actions
|
|
28
|
+
output_tokens: total_tokens(assistant_messages, :output_tokens), actions: actions,
|
|
29
|
+
components: answer.components || [])
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<section><h4><%= label %></h4><% if item[key].nil? %><p class="muted"><%= missing %></p><% elsif item[key].empty? %><p class="muted"><%= empty %></p><% else %><p class="muted small">Ordered trace: group, name, and arguments.</p><ol class="evidence-list"><% item[key].each do |action| %><li><pre class="payload"><%= ui.h(ui.pretty(action)) %></pre></li><% end %></ol><% end %></section>
|
|
48
48
|
<% end %>
|
|
49
49
|
</div>
|
|
50
|
+
<details class="metadata-details"><summary>Response components</summary><pre class="payload"><%= ui.h(item["components"].nil? ? "Unavailable — response component instrumentation missing" : ui.pretty(item["components"])) %></pre></details>
|
|
50
51
|
<details class="metadata-details"><summary>Response metadata</summary><pre class="payload"><%= ui.h(item["response_metadata"].nil? ? "Unavailable — response metadata not reported" : ui.pretty(item["response_metadata"])) %></pre></details>
|
|
51
52
|
<% unless item["metadata"].empty? %><details class="metadata-details"><summary>Case metadata</summary><pre class="payload"><%= ui.h(ui.pretty(item["metadata"])) %></pre></details><% end %>
|
|
52
53
|
</article><% end %>
|
|
@@ -36,6 +36,26 @@ module RubyLLM
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def response_components(protocol:, version: nil, &check)
|
|
40
|
+
ResponseComponents.validate_label!(protocol, "component protocol")
|
|
41
|
+
ResponseComponents.validate_label!(version, "component version") unless version.nil?
|
|
42
|
+
raise ArgumentError, "provide a response component check block" unless check
|
|
43
|
+
lambda do |_output, context|
|
|
44
|
+
components = context.response.components
|
|
45
|
+
if components.nil?
|
|
46
|
+
next Assessment.new(score: false, reason: "Response component instrumentation missing")
|
|
47
|
+
end
|
|
48
|
+
matching = components.select { |item| item["protocol"] == protocol }
|
|
49
|
+
if matching.empty?
|
|
50
|
+
next Assessment.new(score: false, reason: "No #{protocol} response components")
|
|
51
|
+
end
|
|
52
|
+
if version && matching.any? { |item| item["version"] != version }
|
|
53
|
+
next Assessment.new(score: false, reason: "Unexpected #{protocol} protocol version")
|
|
54
|
+
end
|
|
55
|
+
check.call(matching, context)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
39
59
|
def semantic_accuracy
|
|
40
60
|
JudgeMetric.new(reference: true, rubric: <<~RUBRIC)
|
|
41
61
|
Score factual semantic agreement with the expected reference, not lexical overlap.
|
|
@@ -223,6 +223,7 @@ module RubyLLM
|
|
|
223
223
|
"Minimum passing scores" => item["minimum_scores"],
|
|
224
224
|
"Expected actions (ordered)" => item["expected_actions"] || "Unavailable — expected actions not specified",
|
|
225
225
|
"Observed actions (ordered)" => item["actions"] || "Unavailable — action instrumentation missing",
|
|
226
|
+
"Response components (ordered)" => item["components"] || "Unavailable — response component instrumentation missing",
|
|
226
227
|
"Response metadata" => item["response_metadata"].nil? ? "Unavailable — response metadata not reported" : item["response_metadata"],
|
|
227
228
|
"Metadata" => item["metadata"], "Duration (ms)" => item["duration_ms"],
|
|
228
229
|
"Input tokens" => item["input_tokens"], "Output tokens" => item["output_tokens"],
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module Evaluations
|
|
5
5
|
class Response
|
|
6
|
-
attr_reader :output, :input_tokens, :output_tokens, :cost, :metadata, :actions
|
|
6
|
+
attr_reader :output, :input_tokens, :output_tokens, :cost, :metadata, :actions, :components
|
|
7
7
|
|
|
8
|
-
def initialize(output:, input_tokens: nil, output_tokens: nil, cost: nil, metadata: {}, actions: nil)
|
|
8
|
+
def initialize(output:, input_tokens: nil, output_tokens: nil, cost: nil, metadata: {}, actions: nil, components: nil)
|
|
9
9
|
@output = output
|
|
10
10
|
@input_tokens = tokens(input_tokens)
|
|
11
11
|
@output_tokens = tokens(output_tokens)
|
|
@@ -13,9 +13,33 @@ module RubyLLM
|
|
|
13
13
|
raise ArgumentError, "cost must be a finite nonnegative number"
|
|
14
14
|
end
|
|
15
15
|
raise ArgumentError, "response metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
16
|
+
if metadata.key?(:response_components) || metadata.key?("response_components")
|
|
17
|
+
raise ArgumentError, "response_components is reserved; use components: instead"
|
|
18
|
+
end
|
|
16
19
|
@cost = cost&.to_f
|
|
17
20
|
@metadata = Data.json_value(metadata)
|
|
18
21
|
@actions = actions.nil? ? nil : Actions.normalize(actions)
|
|
22
|
+
@components = components.nil? ? nil : ResponseComponents.normalize(components)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.capture
|
|
26
|
+
capture = ResponseComponentCapture.new
|
|
27
|
+
capture.around do
|
|
28
|
+
response = normalize(yield)
|
|
29
|
+
if capture.components.empty?
|
|
30
|
+
response
|
|
31
|
+
else
|
|
32
|
+
new(
|
|
33
|
+
output: response.output,
|
|
34
|
+
input_tokens: response.input_tokens,
|
|
35
|
+
output_tokens: response.output_tokens,
|
|
36
|
+
cost: response.cost,
|
|
37
|
+
metadata: response.metadata,
|
|
38
|
+
actions: response.actions,
|
|
39
|
+
components: capture.components + (response.components || [])
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
19
43
|
end
|
|
20
44
|
|
|
21
45
|
def self.normalize(value)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
# Collects marked tool results during one synchronous agent invocation.
|
|
6
|
+
class ResponseComponentCapture
|
|
7
|
+
attr_reader :components
|
|
8
|
+
|
|
9
|
+
def self.current
|
|
10
|
+
Thread.current[:ruby_llm_evaluations_component_capture]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@components = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Thread#[] is fiber-local; nested evaluations restore their enclosing capture.
|
|
18
|
+
def around
|
|
19
|
+
previous_capture = self.class.current
|
|
20
|
+
Thread.current[:ruby_llm_evaluations_component_capture] = self
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
Thread.current[:ruby_llm_evaluations_component_capture] = previous_capture
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_tool_call(tool, arguments)
|
|
27
|
+
arguments_snapshot = Data.snapshot(arguments || {})
|
|
28
|
+
result = yield
|
|
29
|
+
|
|
30
|
+
component = tool.response_component.merge(
|
|
31
|
+
tool_name: tool.name,
|
|
32
|
+
arguments: arguments_snapshot,
|
|
33
|
+
payload: tool_payload(result)
|
|
34
|
+
)
|
|
35
|
+
component_snapshot = Data.snapshot(component)
|
|
36
|
+
@components << ResponseComponents.normalize_component(component_snapshot)
|
|
37
|
+
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def tool_payload(result)
|
|
44
|
+
if defined?(::RubyLLM::Tool::Halt) && result.is_a?(::RubyLLM::Tool::Halt)
|
|
45
|
+
result.content
|
|
46
|
+
else
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
# Prepend to a tool that defines response_component; preserve its call result.
|
|
6
|
+
module ResponseComponentTool
|
|
7
|
+
def call(arguments)
|
|
8
|
+
capture = ResponseComponentCapture.current
|
|
9
|
+
return super unless capture
|
|
10
|
+
|
|
11
|
+
capture.record_tool_call(self, arguments) { super }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
module ResponseComponents
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def normalize(components)
|
|
9
|
+
raise ArgumentError, "components must be an array" unless components.is_a?(Array)
|
|
10
|
+
|
|
11
|
+
components.map { |component| normalize_component(component) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def normalize_component(component)
|
|
15
|
+
raise ArgumentError, "each response component must be a Hash" unless component.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
component = Data.json_value(component)
|
|
18
|
+
unknown_fields = component.keys - %w[protocol version payload tool_name arguments]
|
|
19
|
+
unless unknown_fields.empty?
|
|
20
|
+
raise ArgumentError, "unknown response component fields: #{unknown_fields.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
validate_label!(component["protocol"], "component protocol")
|
|
24
|
+
validate_label!(component["version"], "component version") if component.key?("version")
|
|
25
|
+
validate_label!(component["tool_name"], "component tool_name") if component.key?("tool_name")
|
|
26
|
+
raise ArgumentError, "response component must include payload" unless component.key?("payload")
|
|
27
|
+
if component.key?("arguments") && !component["arguments"].is_a?(Hash)
|
|
28
|
+
raise ArgumentError, "component arguments must be a Hash"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
component
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_label!(value, name)
|
|
35
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
36
|
+
raise ArgumentError, "#{name} must be a nonempty string"
|
|
37
|
+
end
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -43,11 +43,14 @@ module RubyLLM
|
|
|
43
43
|
replay = ApiReplay.new(question.api_interactions) if question.api_interactions
|
|
44
44
|
begin
|
|
45
45
|
invoke = -> { suite.agent.call(mutable_copy(question.question)) }
|
|
46
|
-
response = Response.
|
|
46
|
+
response = Response.capture { replay ? replay.call(&invoke) : invoke.call }
|
|
47
47
|
result.merge!("output" => Data.json_value(response.output),
|
|
48
48
|
"input_tokens" => response.input_tokens, "output_tokens" => response.output_tokens,
|
|
49
49
|
"cost" => response.cost, "response_metadata" => response.metadata,
|
|
50
50
|
"actions" => Data.json_value(response.actions))
|
|
51
|
+
unless response.components.nil?
|
|
52
|
+
result["response_metadata"] = response.metadata.merge("response_components" => response.components)
|
|
53
|
+
end
|
|
51
54
|
rescue StandardError => exception
|
|
52
55
|
result["error"] = Data.error(exception)
|
|
53
56
|
result["duration_ms"] = milliseconds(started)
|
data/lib/ruby_llm/evaluations.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require_relative "evaluations/version"
|
|
4
4
|
require_relative "evaluations/data"
|
|
5
5
|
require_relative "evaluations/actions"
|
|
6
|
+
require_relative "evaluations/response_components"
|
|
7
|
+
require_relative "evaluations/response_component_capture"
|
|
8
|
+
require_relative "evaluations/response_component_tool"
|
|
6
9
|
require_relative "evaluations/judge_metric"
|
|
7
10
|
require_relative "evaluations/evaluators"
|
|
8
11
|
require_relative "evaluations/api_replay"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_llm-agent-evaluations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RubyLLM Evaluations contributors
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- docs/images/dashboard-regression.png
|
|
80
80
|
- docs/images/dashboard-without-judge.png
|
|
81
81
|
- docs/images/html-report.png
|
|
82
|
+
- examples/a2ui_eval.rb
|
|
82
83
|
- examples/api_replay_eval.rb
|
|
83
84
|
- examples/context_bloat_eval.rb
|
|
84
85
|
- examples/fixtures/large_return_policy.json
|
|
@@ -106,6 +107,9 @@ files:
|
|
|
106
107
|
- lib/ruby_llm/evaluations/judge_metric.rb
|
|
107
108
|
- lib/ruby_llm/evaluations/report.rb
|
|
108
109
|
- lib/ruby_llm/evaluations/response.rb
|
|
110
|
+
- lib/ruby_llm/evaluations/response_component_capture.rb
|
|
111
|
+
- lib/ruby_llm/evaluations/response_component_tool.rb
|
|
112
|
+
- lib/ruby_llm/evaluations/response_components.rb
|
|
109
113
|
- lib/ruby_llm/evaluations/runner.rb
|
|
110
114
|
- lib/ruby_llm/evaluations/suite.rb
|
|
111
115
|
- lib/ruby_llm/evaluations/version.rb
|