braintrust 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1047e872fe3a0acf40117bd9a0fcbf4e5024edbe3c1eb3d3bd1e32f60e5ffb18
4
- data.tar.gz: e4c412abf3cf748ff157248feb821724ba4ffb76b67aecd8d1735179572afa93
3
+ metadata.gz: 38aa89e0aba8b3df517d42a95d016cb09cbfbaeac91d16d4caed00f0779415eb
4
+ data.tar.gz: fd800de8e09a8ac7ca93342d65abd6c19dcdc5bffe2421dd5cf921e1b6b06645
5
5
  SHA512:
6
- metadata.gz: 41f183ac2bb20f64bca5c576f0decabdea9b7a4bcb46cbcf0c66292eb34f7e75a3b91516cd5520b4a3e207669eff0fa212afd1f75c1d6b172a44ea27bbc91e4b
7
- data.tar.gz: 8a98b9ea710b69ac0b4e22cbdbed2b55bfab0d25db5fda2afc872931ce27a4eb9183433db03be6cedcb295dfca2f1fa43583620d848c55e9fa8b19629ecbfb8e
6
+ metadata.gz: 82c01479c75cbb51bc79a67af1b91c46af0fdefc2895c05a54837fb38a2bd501a5e462e1cff953fc83e189ad9e1da891e39814077bb0cd03fd66970eccb285a9
7
+ data.tar.gz: '0757861a2df4962b7cdcff628c5638fe361e33b78ba76417dd3c1087c557fffc7920a857a3916f95bc65e3d6f8bb676afb848a27a6fef28dc987b0d7e237c922'
@@ -0,0 +1,19 @@
1
+ require "braintrust/contrib/rails/server"
2
+
3
+ Braintrust::Contrib::Rails::Server::Engine.configure do |config|
4
+ config.evaluators = {
5
+ <% if @evaluators.empty? -%>
6
+ # Add evaluator instances here, for example:
7
+ # "food-classifier" => FoodClassifier.new
8
+ <% else -%>
9
+ <% @evaluators.each_with_index do |evaluator, index| -%>
10
+ "<%= evaluator[:slug] %>" => <%= evaluator[:class_name] %>.new<%= "," unless index == @evaluators.length - 1 %>
11
+ <% end -%>
12
+ <% end -%>
13
+ }
14
+
15
+ # Default is :clerk_token. Use :none only when developing locally without
16
+ # incoming request authentication; outgoing Braintrust API calls still need
17
+ # a valid Braintrust API key.
18
+ config.auth = :clerk_token
19
+ end
@@ -186,17 +186,17 @@ module Braintrust
186
186
  ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
187
187
  elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
188
188
  ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
189
- elsif tool.respond_to?(:params_schema) && tool.params_schema
189
+ elsif tool_params_schema(tool)
190
190
  build_basic_tool_schema(tool)
191
191
  else
192
192
  build_minimal_tool_schema(tool)
193
193
  end
194
194
  rescue NameError, ArgumentError => e
195
195
  Braintrust::Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
196
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
196
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
197
197
  end
198
198
  else
199
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
199
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
200
200
  end
201
201
 
202
202
  # Strip RubyLLM-specific fields to match native OpenAI format
@@ -217,6 +217,19 @@ module Braintrust
217
217
  tool_schema
218
218
  end
219
219
 
220
+ # A tool's JSON Schema, across ruby_llm versions.
221
+ # 1.x exposes it as params_schema; 2.0 renamed it to parameters_schema.
222
+ # @param tool [Object] the RubyLLM tool
223
+ # @return [Hash, nil] the schema, or nil when the tool declares none
224
+ def tool_params_schema(tool)
225
+ %i[params_schema parameters_schema].each do |name|
226
+ next unless tool.respond_to?(name)
227
+ schema = tool.public_send(name)
228
+ return schema if schema
229
+ end
230
+ nil
231
+ end
232
+
220
233
  # Build a basic tool schema with parameters
221
234
  def build_basic_tool_schema(tool)
222
235
  {
@@ -224,7 +237,7 @@ module Braintrust
224
237
  "function" => {
225
238
  "name" => tool.name.to_s,
226
239
  "description" => tool.description,
227
- "parameters" => tool.params_schema
240
+ "parameters" => tool_params_schema(tool)
228
241
  }
229
242
  }
230
243
  end
@@ -255,10 +268,11 @@ module Braintrust
255
268
  # Handle content
256
269
  if msg.respond_to?(:content) && msg.content
257
270
  raw_content = msg.content
271
+ attachments = extract_attachments(msg)
258
272
 
259
- # Check if content is a Content object with attachments (issue #71)
260
- formatted["content"] = if raw_content.respond_to?(:text) && raw_content.respond_to?(:attachments) && raw_content.attachments&.any?
261
- format_multipart_content(raw_content)
273
+ # Include attachments alongside the text when present (issue #71)
274
+ formatted["content"] = if attachments.any?
275
+ format_multipart_content(content_text(raw_content), attachments)
262
276
  else
263
277
  format_simple_content(raw_content, msg.role.to_s)
264
278
  end
@@ -278,17 +292,43 @@ module Braintrust
278
292
  formatted
279
293
  end
280
294
 
295
+ # Collect a message's attachments.
296
+ #
297
+ # ruby_llm 1.x wraps text and attachments in a Content object hanging off
298
+ # the message; 2.0 removed Content, leaving content a plain String and
299
+ # exposing attachments on the message itself. Support both.
300
+ #
301
+ # @param msg [Object] the RubyLLM message
302
+ # @return [Array] the message's attachments, empty when there are none
303
+ def extract_attachments(msg)
304
+ content = msg.content if msg.respond_to?(:content)
305
+ if content.respond_to?(:attachments)
306
+ from_content = Array(content.attachments)
307
+ return from_content if from_content.any?
308
+ end
309
+
310
+ msg.respond_to?(:attachments) ? Array(msg.attachments) : []
311
+ end
312
+
313
+ # Extract the plain text of a message's content.
314
+ # @param raw_content [Object] String, or a 1.x Content object
315
+ # @return [Object] the text
316
+ def content_text(raw_content)
317
+ raw_content.respond_to?(:text) ? raw_content.text : raw_content
318
+ end
319
+
281
320
  # Format multipart content with text and attachments
282
- # @param content_obj [Object] Content object with text and attachments
321
+ # @param text [Object] the message text
322
+ # @param attachments [Array] the message's attachments
283
323
  # @return [Array<Hash>] array of content parts
284
- def format_multipart_content(content_obj)
324
+ def format_multipart_content(text, attachments)
285
325
  content_parts = []
286
326
 
287
327
  # Add text part
288
- content_parts << {"type" => "text", "text" => content_obj.text} if content_obj.text
328
+ content_parts << {"type" => "text", "text" => text} if text
289
329
 
290
330
  # Add attachment parts (convert to Braintrust format)
291
- content_obj.attachments.each do |attachment|
331
+ attachments.each do |attachment|
292
332
  content_parts << format_attachment_for_input(attachment)
293
333
  end
294
334
 
@@ -166,14 +166,14 @@ module Braintrust
166
166
  record
167
167
  end
168
168
 
169
- # Build origin JSON for tracing/linking
169
+ # Build origin pointer for tracing/linking
170
170
  # @param raw [Hash] Raw record from API
171
171
  # @param dataset_id [String] Dataset ID (fallback if not in record)
172
- # @return [String, nil] JSON-serialized origin, or nil if record lacks required fields
172
+ # @return [Hash, nil] Origin pointer, or nil if record lacks required fields
173
173
  def build_origin(raw, dataset_id)
174
174
  return nil unless raw["id"] && raw["_xact_id"]
175
175
 
176
- Internal::Origin.to_json(
176
+ Internal::Origin.build(
177
177
  object_type: "dataset",
178
178
  object_id: raw["dataset_id"] || dataset_id,
179
179
  id: raw["id"],
@@ -27,6 +27,11 @@ module Braintrust
27
27
  @eval_context = eval_context
28
28
  @tracer = eval_context.tracer_provider.tracer("braintrust-eval")
29
29
 
30
+ # Whether any scorer/classifier can receive `trace:`. Computed once: the
31
+ # per-case flush that makes traces queryable over BTQL is a real network
32
+ # wait, so it's only worth paying when something will actually consume it.
33
+ @needs_trace = (eval_context.scorers + eval_context.classifiers).any? { |c| wants_trace?(c) }
34
+
30
35
  # Mutexes for thread-safe result collection
31
36
  @score_mutex = Mutex.new
32
37
  @classification_mutex = Mutex.new
@@ -55,6 +60,11 @@ module Braintrust
55
60
  # Convert Queue to Array after all threads complete
56
61
  error_array = [].tap { |a| a << errors.pop until errors.empty? }
57
62
 
63
+ # Deliver any spans still buffered. Callers that supply their own
64
+ # tracer_provider get no at_exit hook (see Trace.setup), so without this
65
+ # the tail of the run would wait on the processor's schedule delay.
66
+ flush_spans
67
+
58
68
  # Calculate duration
59
69
  duration = Time.now - start_time
60
70
 
@@ -95,7 +105,7 @@ module Braintrust
95
105
  set_json_attr(eval_span, "braintrust.expected_json", kase.expected) unless kase.expected.nil?
96
106
  set_json_attr(eval_span, "braintrust.metadata", kase.metadata) if kase.metadata
97
107
  eval_span.set_attribute("braintrust.tags", kase.tags) if kase.tags
98
- eval_span.set_attribute("braintrust.origin", kase.origin) if kase.origin
108
+ set_json_attr(eval_span, "braintrust.origin", kase.origin) if kase.origin
99
109
 
100
110
  # Run task
101
111
  begin
@@ -108,9 +118,11 @@ module Braintrust
108
118
  next
109
119
  end
110
120
 
111
- # Flush spans so they're queryable via BTQL, then build trace
112
- eval_context.tracer_provider.force_flush if eval_context.tracer_provider.respond_to?(:force_flush)
113
- kase.trace = build_trace(eval_span)
121
+ # Build the trace, then flush spans so they're queryable via BTQL.
122
+ # Both are skipped unless a scorer/classifier declared `trace:` and the
123
+ # trace is actually resolvable (build_trace returns nil in local-only mode).
124
+ kase.trace = build_trace(eval_span) if @needs_trace
125
+ flush_spans if kase.trace
114
126
 
115
127
  # Run scorers
116
128
  begin
@@ -229,6 +241,23 @@ module Braintrust
229
241
  end
230
242
  end
231
243
 
244
+ # Whether a callable can receive `trace:`: it either declares the keyword
245
+ # or accepts arbitrary kwargs and may forward it. Reuses the #call_parameters
246
+ # introspection Internal::Callable::KeywordFilter already depends on.
247
+ # @param callable [Scorer, Classifier]
248
+ # @return [Boolean]
249
+ def wants_trace?(callable)
250
+ return true unless callable.respond_to?(:call_parameters)
251
+
252
+ callable.call_parameters.any? { |type, name| name == :trace || type == :keyrest }
253
+ end
254
+
255
+ # Force the tracer provider to export buffered spans, if it supports it.
256
+ def flush_spans
257
+ provider = eval_context.tracer_provider
258
+ provider.force_flush if provider.respond_to?(:force_flush)
259
+ end
260
+
232
261
  # Build a lazy Trace for a case, backed by BTQL.
233
262
  # Returns nil when state or experiment_id are unavailable (local-only mode).
234
263
  # @param eval_span [OpenTelemetry::Trace::Span] The eval span for this case
@@ -275,9 +304,7 @@ module Braintrust
275
304
  def report_progress(eval_span, kase, **fields)
276
305
  return unless eval_context.on_progress
277
306
  progress = {"id" => eval_span.context.hex_span_id}.merge(fields.transform_keys(&:to_s))
278
- if kase.origin
279
- progress["origin"] = kase.origin.is_a?(String) ? JSON.parse(kase.origin) : kase.origin
280
- end
307
+ progress["origin"] = kase.origin if kase.origin
281
308
  eval_context.on_progress.call(progress)
282
309
  rescue => e
283
310
  Braintrust.logger.warn("on_progress callback error: #{e.message}")
@@ -336,7 +336,7 @@ module Braintrust
336
336
  # Use pinned version if available, otherwise compute from max(_xact_id)
337
337
  version = dataset_obj.version
338
338
  version ||= cases
339
- .filter_map { |c| c[:origin] && JSON.parse(c[:origin])["_xact_id"] }
339
+ .filter_map { |c| c[:origin]&.dig("_xact_id") }
340
340
  .max
341
341
 
342
342
  {cases: cases, dataset_id: dataset_obj.id, dataset_version: version}
@@ -1,27 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Braintrust
6
4
  module Internal
7
- # Origin provides serialization for source object pointers in Braintrust.
8
- # Used internally to link spans back to their source records (e.g., dataset rows).
5
+ # Origin builds source object pointers, which link spans back to the record
6
+ # they came from (e.g., a dataset row). Pointers stay Hashes internally and
7
+ # are serialized once at the boundary that needs them, so that inbound
8
+ # pointers from the wire and ones we build here have the same shape.
9
9
  module Origin
10
- # Serialize an origin pointer to JSON
10
+ # Build an origin pointer
11
11
  # @param object_type [String] Type of source object (e.g., "dataset", "playground_logs")
12
12
  # @param object_id [String] ID of the source object
13
13
  # @param id [String] ID of the specific record within the source
14
14
  # @param xact_id [String] Transaction ID
15
15
  # @param created [String, nil] Creation timestamp
16
- # @return [String] JSON-serialized origin
17
- def self.to_json(object_type:, object_id:, id:, xact_id:, created:)
18
- JSON.dump({
19
- object_type: object_type,
20
- object_id: object_id,
21
- id: id,
22
- _xact_id: xact_id,
23
- created: created
24
- })
16
+ # @return [Hash] Origin pointer with string keys, matching the wire format
17
+ def self.build(object_type:, object_id:, id:, xact_id:, created:)
18
+ {
19
+ "object_type" => object_type,
20
+ "object_id" => object_id,
21
+ "id" => id,
22
+ "_xact_id" => xact_id,
23
+ "created" => created
24
+ }
25
25
  end
26
26
  end
27
27
  end
@@ -152,10 +152,10 @@ module Braintrust
152
152
  case block.arity
153
153
  when 3
154
154
  Log.warn_once(:scorer_positional_3, "Scorer with positional params (input, expected, output) is deprecated. Use keyword args: |input:, expected:, output:| instead.")
155
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output]) }
155
+ ->(input: nil, expected: nil, output: nil) { block.call(input, expected, output) }
156
156
  when 4, -4, -1
157
157
  Log.warn_once(:scorer_positional_4, "Scorer with positional params (input, expected, output, metadata) is deprecated. Use keyword args: |input:, expected:, output:, metadata:| instead.")
158
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output], kw[:metadata]) }
158
+ ->(input: nil, expected: nil, output: nil, metadata: nil) { block.call(input, expected, output, metadata) }
159
159
  else
160
160
  raise ArgumentError, "Scorer must accept keyword args or 3-4 positional params (got arity #{block.arity})"
161
161
  end
@@ -2,12 +2,19 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../../eval/case"
6
+
5
7
  module Braintrust
6
8
  module Server
7
9
  module Services
8
10
  # Framework-agnostic service for running evaluations and streaming SSE results.
9
11
  # Must be long-lived (not per-request) to preserve the @state_cache across requests.
10
12
  class Eval
13
+ # Row fields this SDK understands, derived from the Case contract so that
14
+ # adding a field there carries it through here. Anything else on an
15
+ # inbound row is a field this version has no code for, and is ignored.
16
+ CASE_FIELDS = Braintrust::Eval::Case.members.map(&:to_s).freeze
17
+
11
18
  def initialize(evaluators)
12
19
  @evaluators = evaluators
13
20
  @state_mutex = Mutex.new
@@ -177,9 +184,10 @@ module Braintrust
177
184
  # Returns [cases, dataset] where exactly one is non-nil.
178
185
  def resolve_data_source(data)
179
186
  if data.key?("data")
180
- cases = data["data"].map do |d|
181
- {input: d["input"], expected: d["expected"]}
182
- end
187
+ # Rows arrive inline from the Playground carrying tags, metadata and
188
+ # an origin pointer back to the row they came from. Carry them all:
189
+ # the Playground matches streamed results to its grid by origin.
190
+ cases = data["data"].map { |row| row.slice(*CASE_FIELDS).transform_keys(&:to_sym) }
183
191
  [cases, nil]
184
192
  elsif data.key?("dataset_id")
185
193
  [nil, Braintrust::Dataset::ID.new(id: data["dataset_id"])]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: braintrust
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -118,6 +118,7 @@ files:
118
118
  - lib/braintrust/contrib/rails/server/health_controller.rb
119
119
  - lib/braintrust/contrib/rails/server/list_controller.rb
120
120
  - lib/braintrust/contrib/rails/server/routes.rb
121
+ - lib/braintrust/contrib/rails/server/templates/initializer.rb.tt
121
122
  - lib/braintrust/contrib/registry.rb
122
123
  - lib/braintrust/contrib/ruby_llm/deprecated.rb
123
124
  - lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb