Skip to content

Commit ec09057

Browse files
committed
Address Rails engine review feedback
1 parent 9120389 commit ec09057

9 files changed

Lines changed: 120 additions & 44 deletions

File tree

README.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,11 @@ See examples: [eval.rb](./examples/eval.rb), [dataset.rb](./examples/eval/datase
348348

349349
### Dev Server
350350

351-
Run evaluations from the Braintrust web UI against code in your own application. Define evaluators, pass them to the dev server, and start serving:
351+
Run evaluations from the Braintrust web UI against code in your own application.
352+
353+
**Run as a Rack app**
354+
355+
Define evaluators, pass them to the dev server, and start serving:
352356

353357
```ruby
354358
# eval_server.ru
@@ -378,6 +382,44 @@ run Braintrust::Server::Rack.app(
378382
bundle exec rackup eval_server.ru -p 8300 -o 0.0.0.0
379383
```
380384

385+
**Run as a Rails engine**
386+
387+
Use the Rails engine when your evaluators live inside an existing Rails app and you want to mount the Braintrust endpoints into that application.
388+
389+
```ruby
390+
# config/initializers/braintrust_server.rb
391+
require "braintrust/server/rails"
392+
393+
Braintrust::Contrib::Rails::Engine.configure do |config|
394+
config.evaluators = {
395+
"food-classifier" => Braintrust::Eval::Evaluator.new(
396+
task: ->(input:) { FoodClassifier.classify(input) },
397+
scorers: [
398+
Braintrust::Scorer.new("exact_match") { |expected:, output:| output == expected ? 1.0 : 0.0 }
399+
]
400+
)
401+
}
402+
403+
# Default is :clerk_token. Use :none for local development.
404+
config.auth = :none
405+
end
406+
```
407+
408+
```ruby
409+
# config/routes.rb
410+
Rails.application.routes.draw do
411+
mount Braintrust::Contrib::Rails::Engine, at: "/braintrust"
412+
end
413+
```
414+
415+
Mounted at `/braintrust`, the engine exposes:
416+
417+
- `GET /braintrust/` for the health check
418+
- `GET /braintrust/list` and `POST /braintrust/list` to enumerate evaluators
419+
- `POST /braintrust/eval` to run an evaluation and stream SSE results
420+
421+
See example: [contrib/rails/eval.rb](./examples/contrib/rails/eval.rb)
422+
381423
**Custom evaluators**
382424

383425
Evaluators can also be defined as subclasses:
@@ -412,7 +454,7 @@ gem "rack"
412454
gem "puma" # recommended
413455
```
414456

415-
See example: [server/eval.ru](./examples/server/eval.ru)
457+
See examples: [server/eval.ru](./examples/server/eval.ru), [contrib/rails/eval.rb](./examples/contrib/rails/eval.rb)
416458

417459
## Documentation
418460

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ BRAINTRUST_DEBUG=true ruby examples/login/login_basic.rb
3333
### Dev Server Examples
3434

3535
- **`server/eval.ru`**: Set up a dev server for remote evals — define evaluators (subclass or inline) and serve them via a Rack app. Start with: `bundle exec appraisal server rackup examples/server/eval.ru -p 8300 -o 0.0.0.0`
36+
- **`contrib/rails/eval.rb`**: Mount the dev server as a Rails engine and configure evaluators via `Braintrust::Contrib::Rails::Engine.configure`
3637

3738
## Coming Soon
3839

lib/braintrust/contrib/rails/application_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ def authenticate!
1818
request.env["braintrust.auth"] = auth_result
1919
@braintrust_auth = auth_result
2020
end
21+
22+
def parse_json_body
23+
body = request.body.read
24+
return nil if body.nil? || body.empty?
25+
JSON.parse(body)
26+
rescue JSON::ParserError
27+
nil
28+
end
2129
end
2230
end
2331
end

lib/braintrust/contrib/rails/engine.rb

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,23 @@ def self.evaluators
2323
end
2424

2525
def self.auth_strategy
26-
@auth_strategy ||= resolve_auth(config.auth)
26+
resolve_auth(config.auth)
2727
end
2828

2929
def self.list_service
30-
@list_service ||= Server::Services::List.new(config.evaluators)
30+
Server::Services::List.new(-> { config.evaluators })
3131
end
3232

3333
# Long-lived so the state cache persists across requests.
3434
def self.eval_service
35-
@eval_service ||= Server::Services::Eval.new(config.evaluators)
35+
@eval_service ||= Server::Services::Eval.new(-> { config.evaluators })
3636
end
3737

38-
# Reset memoized services (useful in tests when config changes).
39-
def self.reset_services!
40-
@auth_strategy = nil
41-
@list_service = nil
42-
@eval_service = nil
43-
end
44-
45-
def self.configure
46-
yield config
47-
reset_services!
38+
# Support the explicit `|config|` style used by this integration while
39+
# still delegating zero-arity DSL blocks to Rails' native implementation.
40+
def self.configure(&block)
41+
return super(&block) if block&.arity == 0
42+
yield config if block
4843
end
4944

5045
def self.resolve_auth(auth)

lib/braintrust/contrib/rails/eval_controller.rb

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class EvalController < ApplicationController
77
include ActionController::Live
88

99
def create
10-
body = parse_body
10+
body = parse_json_body
1111
unless body
1212
render json: {"error" => "Invalid JSON body"}, status: :bad_request
1313
return
@@ -28,16 +28,6 @@ def create
2828
ensure
2929
response.stream.close
3030
end
31-
32-
private
33-
34-
def parse_body
35-
body = request.body.read
36-
return nil if body.nil? || body.empty?
37-
JSON.parse(body)
38-
rescue JSON::ParserError
39-
nil
40-
end
4131
end
4232
end
4333
end

lib/braintrust/server/services/eval_service.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def validate(body)
2121
name = body["name"]
2222
return {error: "Missing required field: name", status: 400} unless name
2323

24-
evaluator = @evaluators[name]
24+
evaluator = current_evaluators[name]
2525
return {error: "Evaluator '#{name}' not found", status: 404} unless evaluator
2626

2727
data = body["data"]
@@ -156,6 +156,11 @@ def build_state(auth)
156156

157157
private
158158

159+
def current_evaluators
160+
return @evaluators.call if @evaluators.respond_to?(:call)
161+
@evaluators
162+
end
163+
159164
# Resolve data source from the data field.
160165
# Returns [cases, dataset] where exactly one is non-nil.
161166
def resolve_data_source(data)

lib/braintrust/server/services/list_service.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def initialize(evaluators)
1414

1515
def call
1616
result = {}
17-
@evaluators.each do |name, evaluator|
17+
current_evaluators.each do |name, evaluator|
1818
scores = (evaluator.scorers || []).each_with_index.map do |scorer, i|
1919
scorer_name = scorer.respond_to?(:name) ? scorer.name : "score_#{i}"
2020
{"name" => scorer_name}
@@ -29,6 +29,11 @@ def call
2929

3030
private
3131

32+
def current_evaluators
33+
return @evaluators.call if @evaluators.respond_to?(:call)
34+
@evaluators
35+
end
36+
3237
# Convert user-defined parameters to the dev server protocol format.
3338
# Wraps in a staticParameters container with "data" typed entries.
3439
def serialize_parameters(parameters)

test/braintrust/contrib/rails/engine_test.rb

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,62 @@ def test_evaluators_returns_config_value
1919

2020
def test_auth_strategy_returns_no_auth_for_none
2121
Engine.config.auth = :none
22-
Engine.reset_services!
2322
assert_instance_of Braintrust::Server::Auth::NoAuth, Engine.auth_strategy
2423
end
2524

2625
def test_auth_strategy_returns_clerk_token_by_default
2726
Engine.config.auth = :clerk_token
28-
Engine.reset_services!
2927
assert_instance_of Braintrust::Server::Auth::ClerkToken, Engine.auth_strategy
3028
end
3129

3230
def test_auth_strategy_accepts_custom_object
3331
custom = Braintrust::Server::Auth::NoAuth.new
3432
Engine.config.auth = custom
35-
Engine.reset_services!
3633
assert_same custom, Engine.auth_strategy
3734
end
3835

3936
def test_auth_strategy_raises_for_unknown_symbol
4037
Engine.config.auth = :jwt
41-
Engine.reset_services!
4238
assert_raises(ArgumentError) { Engine.auth_strategy }
4339
end
4440

41+
def test_auth_strategy_raises_for_unknown_string
42+
Engine.config.auth = "jwt"
43+
assert_raises(ArgumentError) { Engine.auth_strategy }
44+
end
45+
46+
def test_auth_strategy_reflects_config_changes_without_manual_reset
47+
Engine.config.auth = :none
48+
assert_instance_of Braintrust::Server::Auth::NoAuth, Engine.auth_strategy
49+
50+
Engine.config.auth = :clerk_token
51+
assert_instance_of Braintrust::Server::Auth::ClerkToken, Engine.auth_strategy
52+
end
53+
54+
def test_list_service_uses_latest_evaluators_without_manual_reset
55+
first = Braintrust::Eval::Evaluator.new(task: ->(input) { input })
56+
second = Braintrust::Eval::Evaluator.new(task: ->(input) { input })
57+
58+
Engine.config.evaluators = {"first" => first}
59+
assert_equal ["first"], Engine.list_service.call.keys
60+
61+
Engine.config.evaluators = {"second" => second}
62+
assert_equal ["second"], Engine.list_service.call.keys
63+
end
64+
65+
def test_eval_service_uses_latest_evaluators_without_manual_reset
66+
first = Braintrust::Eval::Evaluator.new(task: ->(input) { input })
67+
second = Braintrust::Eval::Evaluator.new(task: ->(input) { input })
68+
payload = {"data" => {"data" => [{"input" => "hello"}]}}
69+
70+
Engine.config.evaluators = {"first" => first}
71+
service = Engine.eval_service
72+
assert_same first, service.validate(payload.merge("name" => "first"))[:evaluator]
73+
74+
Engine.config.evaluators = {"second" => second}
75+
assert_same second, service.validate(payload.merge("name" => "second"))[:evaluator]
76+
end
77+
4578
def test_eval_service_returns_eval_instance
4679
assert_instance_of Braintrust::Server::Services::Eval, Engine.eval_service
4780
end
@@ -56,16 +89,10 @@ def test_eval_service_is_memoized
5689
assert_same svc1, svc2
5790
end
5891

59-
def test_reset_services_clears_memoized_instances
60-
svc1 = Engine.eval_service
61-
Engine.reset_services!
62-
svc2 = Engine.eval_service
63-
refute_same svc1, svc2
64-
end
65-
66-
def test_configure_yields_config_and_resets_services
92+
def test_configure_yields_config_without_resetting_eval_service
6793
svc_before = Engine.eval_service
6894
evaluator = Braintrust::Eval::Evaluator.new(task: ->(input) { input })
95+
payload = {"name" => "configured-eval", "data" => {"data" => [{"input" => "hello"}]}}
6996

7097
Engine.configure do |config|
7198
config.evaluators = {"configured-eval" => evaluator}
@@ -74,7 +101,8 @@ def test_configure_yields_config_and_resets_services
74101

75102
assert_same evaluator, Engine.evaluators["configured-eval"]
76103
assert_instance_of Braintrust::Server::Auth::NoAuth, Engine.auth_strategy
77-
refute_same svc_before, Engine.eval_service
104+
assert_same svc_before, Engine.eval_service
105+
assert_same evaluator, Engine.eval_service.validate(payload)[:evaluator]
78106
end
79107

80108
def test_cors_middleware_is_in_middleware_stack

test/support/rails_server_helper.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ def rails_app
5252
end
5353

5454
def reset_engine!(evaluators: {}, auth: :none)
55-
Braintrust::Contrib::Rails::Engine.config.evaluators = evaluators
56-
Braintrust::Contrib::Rails::Engine.config.auth = auth
57-
Braintrust::Contrib::Rails::Engine.reset_services!
55+
engine = Braintrust::Contrib::Rails::Engine
56+
engine.config.evaluators = evaluators
57+
engine.config.auth = auth
58+
# Clear the long-lived eval service so cached state does not leak across tests.
59+
engine.instance_variable_set(:@eval_service, nil)
5860
end
5961
end
6062
end

0 commit comments

Comments
 (0)