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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 58b4f6e5d15e47010525588cb3a6f57dd75b109aba1e6656a92f8b1d516632cb
4
+ data.tar.gz: 2422a756f17dc0719e86d56df65726a3162bfd66bf4e4cf11bff61b4b2a0387b
5
+ SHA512:
6
+ metadata.gz: 9533ace90d7b3fb21644ba6322826e7a8742cb070a7b0f281883e1184337cd1223be1a79d709f31144898cf3816511c9ab5106e2f00255193657b7b1281cf7eb
7
+ data.tar.gz: 2310dbd7d273e38c3812410db5b7660ec4843f21673bf9b8754040d0b9d86dcdcd57e3a1765fadebb67415393c325eaaeec6f84f61c9ce25aeb9eb9ad9aada15
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Publish this project as `ruby_llm-agent-evaluations` to avoid the separately owned RubyGems package. Rename the executable and gemspec; preserve `RubyLLM::Evaluations`, `ruby_llm/evaluations`, Rails migration tasks, and PostgreSQL history. Version 0.2.0 is the first RubyGems release under the new name; the earlier section records this project's development history.
6
+ - Add `script/publish` with integration/full-suite gates, isolated installed-package CLI/PostgreSQL smoke, manifest checks, and an upload-free `--dry-run`. Publication requires a clean committed tree and RubyGems credentials.
7
+ - Add strict question-scoped API replay for Net::HTTP and Faraday: ordered one-use request assertions, inline JSON or exact-byte JSON files, explicit live-host allowances, and persisted redacted mismatch evidence. Fixture definitions and response-byte hashes participate in compatible-baseline fingerprints; suites without replay keep their fingerprints.
8
+ - Add a large-response evaluation with independent answer, action-argument, and model-facing tool-result budget checks, a bounded/raw-output regression, and an opt-in OpenAI example.
9
+ - Rename the question keyword and evaluation context field from `criteria` to `minimum_scores`, with no compatibility alias. Update specifications and custom evaluators to use the new name. Scores use absolute inclusive per-metric minimums on every run, including the first; previous-run regression comparison remains separate. Equivalent definitions retain their historical fingerprints, while changing metric names or thresholds changes the fingerprint.
10
+ - Keep Ruby 3.2 CI dependencies compatible by constraining development RBS below 4; use the Node 24 checkout action.
11
+ - Preserve existing PostgreSQL history while migrating stored minimum-score names. Rails hosts upgrade with `bin/rails ruby_llm_evaluations:install:migrations` followed by `bin/rails db:migrate`; standalone users run `ruby_llm-agent-evaluations db:migrate`.
12
+
13
+ ## 0.1.0
14
+
15
+ - Question-first Ruby specifications with expected answers, declarative minimum metric scores, important-fact metadata, and optional custom scored checks.
16
+ - Semantic accuracy and answer relevancy use an optional RubyLLM::Agent class passed to the suite constructor, with fresh conversations and strict score/reason validation. No global judge setup; absent judges skip LLM metrics, leaving unassessed quality null rather than fabricating a pass.
17
+ - Ordered action-group evaluation covering group, name, arguments, missing calls, and unexpected calls; absent instrumentation remains distinct from an empty trace.
18
+ - Optional RubyLLM chat adapter with fresh case state, chronological tool traces, and reported usage across turns.
19
+ - PostgreSQL history through named Run, CaseResult, and MetricResult ActiveRecord models sharing the host pool; normal Rails engine migrations preserve legacy evidence, with explicit standalone db:migrate.
20
+ - Compatible-baseline regression comparisons covering score changes, threshold failures, and agent/evaluator error transitions.
21
+ - CLI execution, history, regression gates, and text/Markdown/standalone HTML/JSON reports; Report#export owns file output with format inference.
22
+ - Read-only local dashboard and Rails engine with filters, quality trends, dimension scores, baseline evidence, question/action details, and JSON downloads; standard Rails routes and mount prefixes.
23
+ - Real PostgreSQL, CLI, Rails, and local RubyLLM HTTP integration tests; PostgreSQL-backed CI and an integration-tests-always directive.
24
+ - Vendor-agnostic `.agents` harness with GitHub and OMP pointers and shared Ruby/RubyLLM/persistence/dashboard/review guidance.
25
+ - Illustrated README with screenshots of actual persisted runs and generated reports.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RubyLLM Evaluations contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # RubyLLM Agent Evaluations
2
+
3
+ Write questions and expected answers in Ruby, declare what matters, and keep evaluation evidence in PostgreSQL. Compare compatible runs and inspect answers, metrics, errors, and action traces in a read-only dashboard.
4
+
5
+ Works with any callable agent. RubyLLM is optional; Rails applications use named ActiveRecord models sharing their normal host connection pool.
6
+
7
+ Install the package as `ruby_llm-agent-evaluations`:
8
+
9
+ ~~~ruby
10
+ gem "ruby_llm-agent-evaluations", "~> 0.2", require: "ruby_llm/evaluations"
11
+ ~~~
12
+
13
+ The executable is `ruby_llm-agent-evaluations`. The Ruby API remains `RubyLLM::Evaluations` with `require "ruby_llm/evaluations"`; Rails migration tasks and PostgreSQL table names are unchanged, preserving existing history. The separately owned `ruby_llm-evaluations` package on RubyGems is not this project. The GitHub repository keeps its existing name.
14
+
15
+ ## Question-first specifications
16
+
17
+ Pass an optional RubyLLM judge agent class directly to the suite constructor. There is no global judge configuration or judge wrapper to build. Your application supplies the agent being evaluated and, when desired, its judge class:
18
+
19
+ ~~~ruby
20
+ require "ruby_llm/evaluations"
21
+
22
+ suite = RubyLLM::Evaluations.define("Customer support", agent: support_agent, judge: SupportJudgeAgent) do
23
+ question "How long do I have to return an item?",
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
46
+
47
+ Declare fixtures inside a question block to exercise real HTTP clients without contacting the API. This complete spec needs the ordinary PostgreSQL setup below, but no provider credentials:
48
+
49
+ ~~~ruby
50
+ require "net/http"
51
+ require "json"
52
+ require "ruby_llm/evaluations"
53
+
54
+ E = RubyLLM::Evaluations
55
+ shipping_agent = ->(_question) do
56
+ response = Net::HTTP.get_response(URI("https://shipping.example/orders?id=42"))
57
+ JSON.parse(response.body).fetch("status")
58
+ end
59
+
60
+ E.define("Shipping API", agent: shipping_agent, agent_name: "ShippingAgent") do
61
+ evaluate :exact, using: E::Evaluators.exact_match
62
+ question "Track order 42", expected_answer: "ready" do
63
+ api_interactions do
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
70
+ 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.
74
+
75
+ Run the bundled two-source example with `bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/api_replay_eval.rb`. Then run `AGENT_REVISION=regressed bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/api_replay_eval.rb --fail-on-regression` to send deliberately wrong order arguments; strict replay records the mismatch and the regression gate exits nonzero.
76
+
77
+ Each declaration must be consumed **once, in declared order**. Declare a repeated call twice. Unexpected requests, parameter mismatches, exhausted fixtures, and missing calls produce an agent error even if the agent rescues the request exception or returns the expected answer. Query order is insignificant; repeated values and their multiplicity are preserved. `expect_request(json: { count: 1, enabled: false }, headers: { "X-Region" => "eu" })` asserts typed JSON and only declared headers (header names are case-insensitive). String `"1"` is not number `1`, and `null` is not `false`. Omitting request `json:` requires an empty body.
78
+
79
+ `respond_with` requires exactly one source: inline `json:` (including `json: nil` for JSON null), or `body_file: "fixtures/order.json"`. Files resolve relative to the spec file where the question was declared, not the process working directory. Missing files, invalid JSON, and invalid UTF-8 fail before agent execution. Exact file bytes, including whitespace and final newline, are snapshotted and returned unchanged. Optional `status:` defaults to 200; `headers:` defaults to an empty map.
80
+
81
+ Absolute `body_file:` paths are also accepted; prefer spec-relative paths for portable, shareable fixtures.
82
+
83
+ Replay is strict/offline by default: undeclared network requests fail. For live model requests made by the agent itself, explicitly use `api_interactions(allow_hosts: ["api.openai.com"]) do ... end`. Allowances match exact hosts, not suffixes or wildcards, and may incur provider charges. An allowed host never bypasses a declared fixture request. Judge calls need no replay allowance because judging runs outside replay. There is no recording mode.
84
+
85
+ PostgreSQL case results persist `response_metadata["api_replay"]` on success and failure: expected interactions, observed match statuses, readable mismatch differences, and missing requests. Authentication/credential fields are redacted from replay evidence. Still use synthetic fixtures: never commit credentials or sensitive payloads, and protect persisted prompts, answers, and reports.
86
+
87
+ Replay evidence uses the existing response-metadata display in reports and the dashboard; JSON reports include the persisted `response_metadata.api_replay` payload. No separate evidence store or migration is required.
88
+
89
+ Request assertions, response status/headers, allowed hosts, and response-byte hashes participate in suite fingerprints. Changing fixture bytes (even JSON whitespace) creates an incompatible baseline, not a regression against the old fixture. Existing definitions retain snapshotted bytes; reload/redefine the spec to adopt file changes. Suites without replay keep their existing fingerprints.
90
+
91
+ ## Large tool-result evaluation
92
+
93
+ [examples/context_bloat_eval.rb](examples/context_bloat_eval.rb) replays a roughly 500 KB JSON API response with the relevant current policy after a long history. It evaluates three independent requirements: the correct answer, the expected tool name/arguments, and a maximum 4,096-byte model-facing tool result. The normal tool extracts the relevant facts; `AGENT_REVISION=regressed` sends the entire response. A correct answer does not excuse exceeding the declared tool-result budget. Actual API and tool-result byte counts are saved in response metadata.
94
+
95
+ This is a byte budget for tool output, not a tokenizer or a claim that the complete prompt fits every model window. The local integration test exercises actual RubyLLM HTTP/tool transport with scripted model responses; it does not prove model reasoning. Use the opt-in provider example to evaluate real model behavior. It requires your credentials and incurs charges; the external policy API remains replayed. A real provider context-limit failure is recorded as an agent error, never hidden.
96
+
97
+ ~~~sh
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
100
+
101
+ # Intentionally expose oversized tool output; expect a failure or provider limit error.
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
+ ~~~
104
+
105
+ WebMock interception is process-global. Replay cases are serialized and restore caller state, but run evaluations in a dedicated process rather than alongside unrelated application HTTP traffic. Custom transports outside WebMock support are not intercepted.
106
+
107
+ ## Optional judge agent in the suite constructor
108
+
109
+ Use a class inheriting from RubyLLM::Agent (RubyLLM 1.14.1+). The evaluation suite creates a fresh instance for each assessment and calls its ask method, so judge conversations do not leak between metrics or questions:
110
+
111
+ ~~~ruby
112
+ require "ruby_llm"
113
+ require "ruby_llm/evaluations"
114
+
115
+ class SupportJudgeAgent < RubyLLM::Agent
116
+ model ENV.fetch("EVAL_JUDGE_MODEL")
117
+ end
118
+
119
+ suite = RubyLLM::Evaluations::Suite.new(
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.
129
+
130
+ Judge replies must contain a finite numeric score in [0,1] and a nonempty string reason. Invalid replies and provider failures remain metric errors, never passing defaults. Model judgments are nondeterministic: calibrate thresholds against human-reviewed examples. Marking data untrusted does not eliminate prompt injection.
131
+
132
+ [examples/ruby_llm_eval.rb](examples/ruby_llm_eval.rb) supplies a RubyLLM judge agent class to the suite. [examples/semantic_actions_eval.rb](examples/semantic_actions_eval.rb) combines real agent/tool/judge calls and all three dimensions. Configure provider credentials and EVAL_MODEL/EVAL_JUDGE_MODEL before running these opt-in examples; neither runs automatically.
133
+
134
+ ## Rails: named models and standard migrations
135
+
136
+ Add the gem to your application's Gemfile. During local development, add `path: "/path/to/this/checkout"` to the declaration:
137
+
138
+ ~~~ruby
139
+ gem "ruby_llm-agent-evaluations", "~> 0.2", require: "ruby_llm/evaluations"
140
+ ~~~
141
+
142
+ Install the engine migrations into the host application, then migrate normally:
143
+
144
+ ~~~sh
145
+ bin/rails ruby_llm_evaluations:install:migrations
146
+ bin/rails db:migrate
147
+ ~~~
148
+
149
+ When upgrading, repeat the install:migrations and db:migrate commands to install and apply new engine migrations; keep existing migrations and history. Standalone users instead run ruby_llm-agent-evaluations db:migrate before their next run.
150
+
151
+ PostgreSQL is required. Tables are ruby_llm_evaluations_runs, ruby_llm_evaluations_case_results, and ruby_llm_evaluations_metric_results. Existing legacy payload history is migrated, not discarded. There are no Store objects, dynamically allocated models, or separate connection pools. Execution does not establish, migrate, or disconnect the host pool.
152
+
153
+ ~~~ruby
154
+ # config/routes.rb
155
+ Rails.application.routes.draw do
156
+ mount RubyLLM::Evaluations::Engine => "/evaluations"
157
+ end
158
+ ~~~
159
+
160
+ 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.
161
+
162
+ ## Offline quick start: exact match, not semantic judging
163
+
164
+ Requirements: Ruby 3.2+, PostgreSQL, Bundler. 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.
165
+
166
+ ~~~sh
167
+ bundle install
168
+ docker compose up -d --wait
169
+ export DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations'
170
+
171
+ # Migrate once before executing specs. Runs never create tables implicitly.
172
+ bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations db:migrate
173
+ bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --metadata '{"revision":"reference"}'
174
+
175
+ # Exit 1 is intentional: incorrect answer and wrong lookup arguments.
176
+ AGENT_REVISION=regressed bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --fail-on-regression --metadata '{"revision":"regressed"}'
177
+
178
+ bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations history
179
+ bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations dashboard
180
+ ~~~
181
+
182
+ Open **http://127.0.0.1:4567**; Ctrl-C stops the server. PostgreSQL evidence survives restarts. Compose binds passwordless development PostgreSQL to loopback port 55432; **do not use this configuration in production**. Its named volume preserves history; docker compose stop does not delete it. For your own service, set DATABASE_URL to an existing database.
183
+
184
+ The offline example produces the following evidence:
185
+
186
+ | Revision | Passing questions | Quality score | CLI exit |
187
+ | --- | --- | --- | --- |
188
+ | Reference | 3 of 3 | 1.000 | 0 |
189
+ | Regressed | 2 of 3 | 0.667 | 1 |
190
+
191
+ The regressed agent answers the return question with 7 days instead of 30 and looks up shipping rather than returns. Both the answer check and observed-action check fail.
192
+
193
+ The standalone CLI alone establishes ActiveRecord::Base's connection. Standalone library callers configure it themselves:
194
+
195
+ ~~~ruby
196
+ ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL"))
197
+ RubyLLM::Evaluations.migrate! # Once during database setup, not per run.
198
+ ~~~
199
+
200
+ ## Custom checks and action traces
201
+
202
+ Explicit suite-level evaluators remain available for custom scoring and become defaults for questions without minimum_scores:
203
+
204
+ ~~~ruby
205
+ RubyLLM::Evaluations.define("Concise answers", agent: your_agent) do
206
+ evaluate :conciseness, threshold: 0.8 do |output, context|
207
+ score = output.split.size <= context.metadata.fetch(:max_words) ? 1.0 : 0.0
208
+ RubyLLM::Evaluations::Assessment.new(score: score, reason: "Checked word budget")
209
+ end
210
+ question "Explain returns briefly", expected_answer: "Return within 30 days.",
211
+ minimum_scores: { conciseness: 0.8 }, metadata: { max_words: 12 }
212
+ end
213
+ ~~~
214
+
215
+ Evaluators receive (output, context). Context exposes id, name, question, expected_answer, expected_actions, minimum_scores, metadata, question_data, response, and the suite's judge agent class. Return Boolean, a finite score in [0,1], or an Assessment. Deterministic built-ins include exact_match, contains, matches(/pattern/), and valid_json; register them with evaluate(name, using:, threshold:). Evaluators.llm_judge(rubric:) supports custom rubrics and uses the constructor judge; without a judge it is skipped.
216
+
217
+ Select action_group in question minimum_scores and declare the expected invocation trace:
218
+
219
+ ~~~ruby
220
+ question "Track order 42", expected_answer: "Order 42 has shipped.",
221
+ minimum_scores: { action_group: 1.0 },
222
+ expected_actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }]
223
+ ~~~
224
+
225
+ The agent must return **observed**, not assumed, calls:
226
+
227
+ ~~~ruby
228
+ RubyLLM::Evaluations::Response.new(
229
+ output: "Order 42 has shipped.",
230
+ actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }],
231
+ input_tokens: 120, output_tokens: 18, cost: 0.0002,
232
+ metadata: { model: "your-model" }
233
+ )
234
+ ~~~
235
+
236
+ Group, name, arguments, and order must match exactly. Missing/incorrect/extra calls reduce the score. Expected actions require groups. actions: nil means missing instrumentation and produces an error; actions: [] honestly means observed no actions. A narrative promise is never evidence of execution.
237
+
238
+ The RubyLLM adapter captures calls with action_groups: { "lookup_order" => "orders" }; unknown groups remain nil, not invented. Its factory must return a fresh chat for each call. Plain RubyLLM messages normalize output/usage but do not contain preceding action traces. Generic callables are reused: create fresh state inside the callable when needed. Runner copies question data so agent mutation cannot alter the suite.
239
+
240
+ ## Persisted evidence and compatible baselines
241
+
242
+ ~~~ruby
243
+ run = RubyLLM::Evaluations.run(suite, metadata: { revision: "candidate" })
244
+ # An actual RubyLLM::Evaluations::Run ActiveRecord record.
245
+ report = RubyLLM::Evaluations::Report.new(run, baseline: run.previous, tolerance: 0.02)
246
+ report.export("agent-report.html")
247
+
248
+ RubyLLM::Evaluations::Run.recent.for_suite("Customer support").with_results
249
+ run.case_results.first.metric_results
250
+ snapshot = run.snapshot # Detached JSON-portable evidence, not a persistence API.
251
+ ~~~
252
+
253
+ Report#export writes the file itself and returns its path. The extension chooses HTML (.html/.htm), JSON (.json), Markdown (.md/.markdown), or text (.txt/.text). Use report.export("agent-report", format: :html) when the filename has no recognized extension. Unknown formats fail before writing; file-system errors propagate. String-returning to_html/to_json/to_markdown/to_text remain available for in-memory use.
254
+
255
+ Run, CaseResult, and MetricResult records are readonly after persistence through normal ActiveRecord APIs. Runner finishes agent/judge calls before the short atomic save transaction; errors are recorded and other questions/checks continue. Runner.new.run(suite, metadata: {}) is also available.
256
+
257
+ Baselines are earlier runs matching suite, agent, and typed definition fingerprint, with database-generated sequence breaking timestamp ties. Fingerprints cover question data, expected answers/actions, metric names and thresholds in minimum_scores, metadata, and explicit suite version. Changing minimum_scores changes the definition fingerprint, so different threshold sets are not automatically compared. Renaming the API alone preserves existing definition fingerprints and baseline history. **Bump version when changing evaluator implementations, rubrics, or judge configuration**; callable source is not hashed. Keep agent identity stable across revisions and record revision/model/prompt information in run metadata.
258
+
259
+ A question passes only when at least one metric was calculated and every calculated metric passes without error. With no calculated metrics it is not_evaluated. Case quality averages calculated metric scores (metric errors contribute zero); agent errors yield zero. Run quality and pass rate exclude not-evaluated questions and are null when no questions were evaluated. Completed status means execution finished, not success. Token/cost totals include only reported agent usage, not judge usage; zero totals do not prove zero cost. Response costs are USD for dashboard display.
260
+
261
+ Regression comparison is separate from minimum-score passing: it compares later observed scores against a previous run of the same definition. Use --fail-on-regression to make regressions fail the CLI gate and --tolerance to allow small score decreases. Regressions include score decreases beyond tolerance, newly failed thresholds, and new errors. Tolerance does not hide new failures/errors. Missing/incompatible explicit baselines are rejected before agent execution; automatic historical baselines never come from the future.
262
+
263
+ For example, with a semantic-accuracy minimum of 0.9, a previous score of 0.98 and a later score of 0.92 both pass the minimum. The later run nevertheless regresses by 0.06 when tolerance is 0.02, so --fail-on-regression fails the gate. On the first run, the same minimum applies but there is no previous score to compare.
264
+
265
+ ~~~sh
266
+ ruby_llm-agent-evaluations run your_eval.rb --fail-on-regression --tolerance 0.02
267
+ ruby_llm-agent-evaluations run your_eval.rb --baseline RUN_ID --fail-on-regression
268
+ ruby_llm-agent-evaluations history --suite 'Customer support' --agent SupportAgent --limit 50
269
+ ruby_llm-agent-evaluations report --format html --output agent.html
270
+ ruby_llm-agent-evaluations report --format markdown --output agent.md
271
+ ruby_llm-agent-evaluations report RUN_ID --baseline BASELINE_ID --format json
272
+ ~~~
273
+
274
+ CLI exits: **0** no failed/errored calculated checks or regression gate, **1** failed/errored checks or detected gated regression, **2** invalid specification/options/configuration/baseline. Exit 0 does not claim quality when all checks were skipped; inspect not_evaluated and null scores. HTML reports escape output and need no external assets. JSON contains the top-level run snapshot plus comparison, with per-case metrics (not a run wrapper). The dashboard offers JSON downloads.
275
+
276
+ ## Dashboard evidence
277
+
278
+ These current screenshots were captured from persisted offline PostgreSQL runs and generated reports. They show deterministic exact-match/action checks, real history, and regression evidence—not live model reasoning.
279
+
280
+ ![Evaluation history and quality trends](docs/images/dashboard-history.png)
281
+ ![Regression report](docs/images/dashboard-regression.png)
282
+ ![Case answer and observed actions](docs/images/dashboard-case.png)
283
+ ![Standalone HTML report](docs/images/html-report.png)
284
+ ![Narrow dashboard](docs/images/dashboard-mobile.png)
285
+
286
+ With no judge supplied, the actual dashboard records answers but leaves quality unavailable and draws no invented trend. This capture uses RubyLLM against a local scripted HTTP provider, not live model reasoning:
287
+
288
+ ![Persisted answers without a judge: unavailable quality and no fabricated scores](docs/images/dashboard-without-judge.png)
289
+
290
+ ## Development and packaging
291
+
292
+ Integration tests always: behavior changes must update end-to-end coverage. PostgreSQL is required; integration tests must never silently skip.
293
+
294
+ ~~~sh
295
+ docker compose exec -T postgres createdb -U postgres ruby_llm_evaluations_test
296
+ export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
297
+ bundle exec rake integration
298
+ bundle exec rake test
299
+
300
+ gem build ruby_llm-agent-evaluations.gemspec
301
+ gem install ./ruby_llm-agent-evaluations-0.2.0.gem
302
+ ruby_llm-agent-evaluations --help
303
+ ~~~
304
+
305
+ Tests isolate PostgreSQL schemas rather than clear development history; the test role needs schema-creation privileges. 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).
306
+
307
+ ## Publishing
308
+
309
+ The release version is defined in `lib/ruby_llm/evaluations/version.rb`. Bump it and update CHANGELOG before publishing; RubyGems does not allow replacing an existing version.
310
+
311
+ ~~~sh
312
+ export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
313
+ bundle install
314
+ bundle exec ruby script/publish --dry-run
315
+
316
+ # Authenticate locally; never paste or commit an API key.
317
+ gem signin
318
+ # Commit the version, changelog, and release changes before upload.
319
+ bundle exec ruby script/publish
320
+ ~~~
321
+
322
+ 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.
323
+
324
+ 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.
325
+
326
+ ## Data and security
327
+
328
+ Specs execute trusted code. Answers and judge data are untrusted. Prompts, references, traces, errors, and metadata persist in the database and exports: redact sensitive information and manage retention/backups. Never commit credentials or production data. No hosted backend, external dashboard assets, framework provider retries, or automatic-success fallbacks are supplied.
329
+
330
+ ## License
331
+
332
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module RubyLLM
6
+ module Evaluations
7
+ class ApplicationRecord < ::ActiveRecord::Base
8
+ self.abstract_class = true
9
+
10
+ def self.require_postgresql!
11
+ unless connection_pool.db_config.adapter == "postgresql"
12
+ raise Error, "evaluation history requires PostgreSQL"
13
+ end
14
+ end
15
+
16
+ # Completed evidence is append-only through the model API.
17
+ def readonly?
18
+ persisted?
19
+ end
20
+
21
+ private
22
+
23
+ def normalize_json_attributes(*names)
24
+ names.each do |name|
25
+ self[name] = Data.json_value(self[name])
26
+ rescue ArgumentError => exception
27
+ errors.add(name, exception.message)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ class CaseResult < ApplicationRecord
6
+ self.table_name = "ruby_llm_evaluations_case_results"
7
+
8
+ belongs_to :run, inverse_of: :case_results, class_name: "RubyLLM::Evaluations::Run"
9
+ has_many :metric_results, -> { order(:position) }, inverse_of: :case_result,
10
+ class_name: "RubyLLM::Evaluations::MetricResult", dependent: :restrict_with_exception
11
+ accepts_nested_attributes_for :metric_results
12
+
13
+ validates :case_id, :name, presence: true
14
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
15
+ validates :status, inclusion: { in: %w[passed failed error not_evaluated] }
16
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }
17
+ validate :validate_json
18
+
19
+ def snapshot
20
+ Data.json_value(
21
+ id: case_id, name: name, question: question, expected_answer: expected_answer,
22
+ expected_actions: expected_actions, minimum_scores: minimum_scores,
23
+ metadata: metadata, output: output, status: status, duration_ms: duration_ms,
24
+ input_tokens: input_tokens, output_tokens: output_tokens, cost: cost, actions: actions,
25
+ response_metadata: response_metadata, error: error_details,
26
+ metrics: metric_results.map(&:snapshot)
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def validate_json
33
+ normalize_json_attributes(:question, :expected_answer, :expected_actions, :minimum_scores, :metadata,
34
+ :output, :actions, :response_metadata, :error_details)
35
+ errors.add(:metadata, "must be a hash") unless metadata.is_a?(Hash)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ class MetricResult < ApplicationRecord
6
+ self.table_name = "ruby_llm_evaluations_metric_results"
7
+
8
+ belongs_to :case_result, inverse_of: :metric_results, class_name: "RubyLLM::Evaluations::CaseResult"
9
+ validates :name, presence: true
10
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
11
+ validates :threshold, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
12
+ validates :score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
13
+ validates :passed, inclusion: { in: [true, false] }
14
+ validate { normalize_json_attributes(:error_details) }
15
+
16
+ def snapshot
17
+ Data.json_value(name: name, threshold: threshold, score: score,
18
+ passed: passed, reason: reason, error: error_details)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module RubyLLM
7
+ module Evaluations
8
+ class Run < ApplicationRecord
9
+ self.table_name = "ruby_llm_evaluations_runs"
10
+
11
+ has_many :case_results, -> { order(:position) }, inverse_of: :run,
12
+ class_name: "RubyLLM::Evaluations::CaseResult", dependent: :restrict_with_exception
13
+ accepts_nested_attributes_for :case_results
14
+
15
+ scope :recent, -> { order(started_at: :desc, sequence: :desc) }
16
+ scope :for_suite, ->(name) { name.nil? ? all : where(suite: name) }
17
+ scope :for_agent, ->(name) { name.nil? ? all : where(agent: name) }
18
+ scope :with_results, -> { includes(case_results: :metric_results) }
19
+
20
+ before_validation :assign_identifier, on: :create
21
+ validates :suite, :agent, :version, :suite_digest, :started_at, :finished_at, presence: true
22
+ validates :status, inclusion: { in: ["completed"] }
23
+ validate :validate_json
24
+
25
+ def previous
26
+ return unless persisted?
27
+
28
+ self.class.where(suite: suite, agent: agent, suite_digest: suite_digest)
29
+ .where("started_at < :time OR (started_at = :time AND sequence < :sequence)", time: started_at, sequence: sequence)
30
+ .recent.with_results.first
31
+ end
32
+
33
+ def snapshot
34
+ Data.json_value(
35
+ id: id, suite: suite, agent: agent, version: version, suite_digest: suite_digest,
36
+ started_at: started_at.utc.iso8601(6), finished_at: finished_at.utc.iso8601(6),
37
+ status: status, metadata: metadata, summary: summary,
38
+ cases: case_results.map(&:snapshot)
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def assign_identifier
45
+ self.id ||= SecureRandom.uuid
46
+ end
47
+
48
+ def validate_json
49
+ normalize_json_attributes(:metadata, :summary)
50
+ errors.add(:metadata, "must be a hash") unless metadata.is_a?(Hash)
51
+ errors.add(:summary, "must be a hash") unless summary.is_a?(Hash)
52
+ end
53
+ end
54
+ end
55
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ RubyLLM::Evaluations::Engine.routes.draw do
4
+ mount RubyLLM::Evaluations::Dashboard.new => "/"
5
+ end