ruby_llm-agent-evaluations 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +332 -0
- data/app/models/ruby_llm/evaluations/application_record.rb +32 -0
- data/app/models/ruby_llm/evaluations/case_result.rb +39 -0
- data/app/models/ruby_llm/evaluations/metric_result.rb +22 -0
- data/app/models/ruby_llm/evaluations/run.rb +55 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261011000000_create_agent_evaluation_history.rb +145 -0
- data/db/migrate/20261011000100_rename_case_criteria_to_minimum_scores.rb +7 -0
- data/docs/images/dashboard-case.png +0 -0
- data/docs/images/dashboard-history.png +0 -0
- data/docs/images/dashboard-mobile.png +0 -0
- data/docs/images/dashboard-regression.png +0 -0
- data/docs/images/dashboard-without-judge.png +0 -0
- data/docs/images/html-report.png +0 -0
- data/examples/api_replay_eval.rb +41 -0
- data/examples/context_bloat_eval.rb +77 -0
- data/examples/fixtures/large_return_policy.json +8006 -0
- data/examples/fixtures/order-42.json +8 -0
- data/examples/ruby_llm_eval.rb +26 -0
- data/examples/semantic_actions_eval.rb +43 -0
- data/examples/support_eval.rb +52 -0
- data/exe/ruby_llm-agent-evaluations +5 -0
- data/lib/ruby_llm/evaluations/action_group.rb +21 -0
- data/lib/ruby_llm/evaluations/actions.rb +30 -0
- data/lib/ruby_llm/evaluations/adapters.rb +40 -0
- data/lib/ruby_llm/evaluations/api_replay.rb +426 -0
- data/lib/ruby_llm/evaluations/cli.rb +209 -0
- data/lib/ruby_llm/evaluations/context.rb +19 -0
- data/lib/ruby_llm/evaluations/dashboard/dashboard.css +939 -0
- data/lib/ruby_llm/evaluations/dashboard/error.erb +1 -0
- data/lib/ruby_llm/evaluations/dashboard/index.erb +46 -0
- data/lib/ruby_llm/evaluations/dashboard/layout.erb +18 -0
- data/lib/ruby_llm/evaluations/dashboard/run.erb +54 -0
- data/lib/ruby_llm/evaluations/dashboard.rb +270 -0
- data/lib/ruby_llm/evaluations/data.rb +55 -0
- data/lib/ruby_llm/evaluations/engine.rb +18 -0
- data/lib/ruby_llm/evaluations/evaluators.rb +65 -0
- data/lib/ruby_llm/evaluations/judge_metric.rb +42 -0
- data/lib/ruby_llm/evaluations/report.rb +248 -0
- data/lib/ruby_llm/evaluations/response.rb +50 -0
- data/lib/ruby_llm/evaluations/runner.rb +139 -0
- data/lib/ruby_llm/evaluations/suite.rb +171 -0
- data/lib/ruby_llm/evaluations/version.rb +7 -0
- data/lib/ruby_llm/evaluations.rb +48 -0
- metadata +156 -0
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
|
+

|
|
281
|
+

|
|
282
|
+

|
|
283
|
+

|
|
284
|
+

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

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