braintrust 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +53 -2
- data/lib/braintrust/config.rb +21 -4
- data/lib/braintrust/eval.rb +164 -0
- data/lib/braintrust/state.rb +14 -6
- data/lib/braintrust/trace/attachment.rb +138 -0
- data/lib/braintrust/trace/contrib/anthropic.rb +82 -156
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +141 -0
- data/lib/braintrust/trace/contrib/openai.rb +118 -5
- data/lib/braintrust/trace/span_filter.rb +59 -0
- data/lib/braintrust/trace/span_processor.rb +29 -3
- data/lib/braintrust/trace.rb +60 -10
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +8 -2
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6321acf7b780922ed97ea3cc57dde47a52947a10650a082dcfd9af780056d99a
|
|
4
|
+
data.tar.gz: 67c181e53537829931de704c7503cc056646652f9c1a61d914bc1ee0b7af69a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb8546fdbf0a448016a1d31ceb8729a40be59e0d8d081ef275f763a11dbb2f5df0134ec52fc3b1c15c41d9dcdf42fbbe6becaf00ab7ac882c8f2f7e173a9a61f
|
|
7
|
+
data.tar.gz: 41e6d13504302a3b3ec26697cb50ce4736040d910c8e293d37088311922daa77274fc4214c86543ca21209bf56e82b2f6f00d5fbc2d7d0d0baad6f1e77cc48ff
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Braintrust Ruby SDK
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/braintrust)
|
|
4
|
-
[](https://gemdocs.org/gems/braintrust/)
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## Overview
|
|
@@ -171,6 +171,56 @@ puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
|
|
|
171
171
|
OpenTelemetry.tracer_provider.shutdown
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
### Attachments
|
|
175
|
+
|
|
176
|
+
Attachments allow you to log binary data (images, PDFs, audio, etc.) as part of your traces. This is particularly useful for multimodal AI applications like vision models.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
require "braintrust"
|
|
180
|
+
require "braintrust/trace/attachment"
|
|
181
|
+
|
|
182
|
+
Braintrust.init
|
|
183
|
+
|
|
184
|
+
tracer = OpenTelemetry.tracer_provider.tracer("vision-app")
|
|
185
|
+
|
|
186
|
+
tracer.in_span("analyze-image") do |span|
|
|
187
|
+
# Create attachment from file
|
|
188
|
+
att = Braintrust::Trace::Attachment.from_file(
|
|
189
|
+
Braintrust::Trace::Attachment::IMAGE_PNG,
|
|
190
|
+
"./photo.png"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Build message with attachment (OpenAI/Anthropic format)
|
|
194
|
+
messages = [
|
|
195
|
+
{
|
|
196
|
+
role: "user",
|
|
197
|
+
content: [
|
|
198
|
+
{type: "text", text: "What's in this image?"},
|
|
199
|
+
att.to_h # Converts to {"type" => "base64_attachment", "content" => "data:..."}
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
# Log to trace
|
|
205
|
+
span.set_attribute("braintrust.input_json", JSON.generate(messages))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
OpenTelemetry.tracer_provider.shutdown
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
You can create attachments from bytes, files, or URLs:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# From bytes
|
|
215
|
+
att = Braintrust::Trace::Attachment.from_bytes("image/jpeg", image_data)
|
|
216
|
+
|
|
217
|
+
# From file
|
|
218
|
+
att = Braintrust::Trace::Attachment.from_file("application/pdf", "./doc.pdf")
|
|
219
|
+
|
|
220
|
+
# From URL
|
|
221
|
+
att = Braintrust::Trace::Attachment.from_url("https://example.com/image.png")
|
|
222
|
+
```
|
|
223
|
+
|
|
174
224
|
## Features
|
|
175
225
|
|
|
176
226
|
- **Evaluations**: Run systematic evaluations of your AI systems with custom scoring functions
|
|
@@ -187,13 +237,14 @@ Check out the [`examples/`](./examples/) directory for complete working examples
|
|
|
187
237
|
- [trace.rb](./examples/trace.rb) - Manual span creation and tracing
|
|
188
238
|
- [openai.rb](./examples/openai.rb) - Automatically trace OpenAI API calls
|
|
189
239
|
- [anthropic.rb](./examples/anthropic.rb) - Automatically trace Anthropic API calls
|
|
240
|
+
- [trace/trace_attachments.rb](./examples/trace/trace_attachments.rb) - Log attachments (images, PDFs) in traces
|
|
190
241
|
- [eval/dataset.rb](./examples/eval/dataset.rb) - Run evaluations using datasets stored in Braintrust
|
|
191
242
|
- [eval/remote_functions.rb](./examples/eval/remote_functions.rb) - Use remote scoring functions
|
|
192
243
|
|
|
193
244
|
## Documentation
|
|
194
245
|
|
|
195
246
|
- [Braintrust Documentation](https://www.braintrust.dev/docs)
|
|
196
|
-
- [API Documentation](https://
|
|
247
|
+
- [API Documentation](https://gemdocs.org/gems/braintrust/)
|
|
197
248
|
|
|
198
249
|
## Contributing
|
|
199
250
|
|
data/lib/braintrust/config.rb
CHANGED
|
@@ -4,14 +4,18 @@ module Braintrust
|
|
|
4
4
|
# Configuration object that reads from environment variables
|
|
5
5
|
# and allows overriding with explicit options
|
|
6
6
|
class Config
|
|
7
|
-
attr_reader :api_key, :org_name, :default_project, :app_url, :api_url
|
|
7
|
+
attr_reader :api_key, :org_name, :default_project, :app_url, :api_url,
|
|
8
|
+
:filter_ai_spans, :span_filter_funcs
|
|
8
9
|
|
|
9
|
-
def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil
|
|
10
|
+
def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
|
|
11
|
+
filter_ai_spans: nil, span_filter_funcs: nil)
|
|
10
12
|
@api_key = api_key
|
|
11
13
|
@org_name = org_name
|
|
12
14
|
@default_project = default_project
|
|
13
15
|
@app_url = app_url
|
|
14
16
|
@api_url = api_url
|
|
17
|
+
@filter_ai_spans = filter_ai_spans
|
|
18
|
+
@span_filter_funcs = span_filter_funcs || []
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Create a Config from environment variables, with option overrides
|
|
@@ -21,14 +25,27 @@ module Braintrust
|
|
|
21
25
|
# @param default_project [String, nil] Default project (overrides BRAINTRUST_DEFAULT_PROJECT env var)
|
|
22
26
|
# @param app_url [String, nil] App URL (overrides BRAINTRUST_APP_URL env var)
|
|
23
27
|
# @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var)
|
|
28
|
+
# @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
|
|
29
|
+
# @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
|
|
24
30
|
# @return [Config] the created config
|
|
25
|
-
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil
|
|
31
|
+
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
|
|
32
|
+
filter_ai_spans: nil, span_filter_funcs: nil)
|
|
33
|
+
# Parse filter_ai_spans from ENV if not explicitly provided
|
|
34
|
+
env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"]
|
|
35
|
+
filter_ai_spans_value = if filter_ai_spans.nil?
|
|
36
|
+
env_filter_ai_spans&.downcase == "true"
|
|
37
|
+
else
|
|
38
|
+
filter_ai_spans
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
new(
|
|
27
42
|
api_key: api_key || ENV["BRAINTRUST_API_KEY"],
|
|
28
43
|
org_name: org_name || ENV["BRAINTRUST_ORG_NAME"],
|
|
29
44
|
default_project: default_project || ENV["BRAINTRUST_DEFAULT_PROJECT"],
|
|
30
45
|
app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
|
|
31
|
-
api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev"
|
|
46
|
+
api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev",
|
|
47
|
+
filter_ai_spans: filter_ai_spans_value,
|
|
48
|
+
span_filter_funcs: span_filter_funcs
|
|
32
49
|
)
|
|
33
50
|
end
|
|
34
51
|
end
|
data/lib/braintrust/eval.rb
CHANGED
|
@@ -9,6 +9,170 @@ require "opentelemetry/sdk"
|
|
|
9
9
|
require "json"
|
|
10
10
|
|
|
11
11
|
module Braintrust
|
|
12
|
+
# Evaluation framework for testing AI systems with custom test cases and scoring functions.
|
|
13
|
+
#
|
|
14
|
+
# The Eval module provides tools for running systematic evaluations of your AI systems. An
|
|
15
|
+
# evaluation consists of:
|
|
16
|
+
# - **Cases**: Test inputs with optional expected outputs
|
|
17
|
+
# - **Task**: The code/model being evaluated
|
|
18
|
+
# - **Scorers**: Functions that judge the quality of outputs
|
|
19
|
+
#
|
|
20
|
+
# @example Basic evaluation with inline cases
|
|
21
|
+
# require "braintrust"
|
|
22
|
+
#
|
|
23
|
+
# Braintrust.init
|
|
24
|
+
#
|
|
25
|
+
# # Define a simple task (the code being evaluated)
|
|
26
|
+
# task = ->(input) { input.include?("a") ? "fruit" : "vegetable" }
|
|
27
|
+
#
|
|
28
|
+
# # Run evaluation with inline cases
|
|
29
|
+
# Braintrust::Eval.run(
|
|
30
|
+
# project: "my-project",
|
|
31
|
+
# experiment: "food-classifier",
|
|
32
|
+
# cases: [
|
|
33
|
+
# {input: "apple", expected: "fruit"},
|
|
34
|
+
# {input: "carrot", expected: "vegetable"},
|
|
35
|
+
# {input: "banana", expected: "fruit"}
|
|
36
|
+
# ],
|
|
37
|
+
# task: task,
|
|
38
|
+
# scorers: [
|
|
39
|
+
# # Named scorer with Eval.scorer
|
|
40
|
+
# Braintrust::Eval.scorer("exact_match") do |input, expected, output|
|
|
41
|
+
# output == expected ? 1.0 : 0.0
|
|
42
|
+
# end
|
|
43
|
+
# ]
|
|
44
|
+
# )
|
|
45
|
+
#
|
|
46
|
+
# @example Different ways to define scorers (recommended patterns)
|
|
47
|
+
# # Method reference (auto-uses method name as scorer name)
|
|
48
|
+
# def exact_match(input, expected, output)
|
|
49
|
+
# output == expected ? 1.0 : 0.0
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# # Named scorer with Eval.scorer
|
|
53
|
+
# case_insensitive = Braintrust::Eval.scorer("case_insensitive") do |input, expected, output|
|
|
54
|
+
# output.downcase == expected.downcase ? 1.0 : 0.0
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# # Callable class with name method
|
|
58
|
+
# class FuzzyMatch
|
|
59
|
+
# def name
|
|
60
|
+
# "fuzzy_match"
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# def call(input, expected, output, metadata = {})
|
|
64
|
+
# threshold = metadata[:threshold] || 0.8
|
|
65
|
+
# # scoring logic here
|
|
66
|
+
# 1.0
|
|
67
|
+
# end
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# # Anonymous lambda that returns named score object
|
|
71
|
+
# multi_score = ->(input, expected, output) {
|
|
72
|
+
# [
|
|
73
|
+
# {name: "exact_match", score: output == expected ? 1.0 : 0.0},
|
|
74
|
+
# {name: "length_match", score: output.length == expected.length ? 1.0 : 0.0}
|
|
75
|
+
# ]
|
|
76
|
+
# }
|
|
77
|
+
#
|
|
78
|
+
# # All can be used together
|
|
79
|
+
# Braintrust::Eval.run(
|
|
80
|
+
# project: "my-project",
|
|
81
|
+
# experiment: "scorer-examples",
|
|
82
|
+
# cases: [{input: "test", expected: "test"}],
|
|
83
|
+
# task: ->(input) { input },
|
|
84
|
+
# scorers: [method(:exact_match), case_insensitive, FuzzyMatch.new, multi_score]
|
|
85
|
+
# )
|
|
86
|
+
#
|
|
87
|
+
# @example Different ways to define tasks
|
|
88
|
+
# # Lambda
|
|
89
|
+
# task_lambda = ->(input) { "result" }
|
|
90
|
+
#
|
|
91
|
+
# # Proc
|
|
92
|
+
# task_proc = proc { |input| "result" }
|
|
93
|
+
#
|
|
94
|
+
# # Method reference
|
|
95
|
+
# def my_task(input)
|
|
96
|
+
# "result"
|
|
97
|
+
# end
|
|
98
|
+
# task_method = method(:my_task)
|
|
99
|
+
#
|
|
100
|
+
# # Callable class
|
|
101
|
+
# class MyTask
|
|
102
|
+
# def call(input)
|
|
103
|
+
# "result"
|
|
104
|
+
# end
|
|
105
|
+
# end
|
|
106
|
+
# task_class = MyTask.new
|
|
107
|
+
#
|
|
108
|
+
# # All of these can be used as the task parameter
|
|
109
|
+
# Braintrust::Eval.run(
|
|
110
|
+
# project: "my-project",
|
|
111
|
+
# experiment: "task-examples",
|
|
112
|
+
# cases: [{input: "test"}],
|
|
113
|
+
# task: task_lambda, # or task_proc, task_method, task_class
|
|
114
|
+
# scorers: [
|
|
115
|
+
# Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
|
|
116
|
+
# ]
|
|
117
|
+
# )
|
|
118
|
+
#
|
|
119
|
+
# @example Using datasets instead of inline cases
|
|
120
|
+
# # Fetch cases from a dataset stored in Braintrust
|
|
121
|
+
# Braintrust::Eval.run(
|
|
122
|
+
# project: "my-project",
|
|
123
|
+
# experiment: "with-dataset",
|
|
124
|
+
# dataset: "my-dataset-name", # fetches from same project
|
|
125
|
+
# task: ->(input) { "result" },
|
|
126
|
+
# scorers: [
|
|
127
|
+
# Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
|
|
128
|
+
# ]
|
|
129
|
+
# )
|
|
130
|
+
#
|
|
131
|
+
# # Or with more options
|
|
132
|
+
# Braintrust::Eval.run(
|
|
133
|
+
# project: "my-project",
|
|
134
|
+
# experiment: "with-dataset-options",
|
|
135
|
+
# dataset: {
|
|
136
|
+
# name: "my-dataset",
|
|
137
|
+
# project: "other-project",
|
|
138
|
+
# version: "1.0",
|
|
139
|
+
# limit: 100
|
|
140
|
+
# },
|
|
141
|
+
# task: ->(input) { "result" },
|
|
142
|
+
# scorers: [
|
|
143
|
+
# Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
|
|
144
|
+
# ]
|
|
145
|
+
# )
|
|
146
|
+
#
|
|
147
|
+
# @example Using metadata and tags
|
|
148
|
+
# Braintrust::Eval.run(
|
|
149
|
+
# project: "my-project",
|
|
150
|
+
# experiment: "with-metadata",
|
|
151
|
+
# cases: [
|
|
152
|
+
# {
|
|
153
|
+
# input: "apple",
|
|
154
|
+
# expected: "fruit",
|
|
155
|
+
# tags: ["tropical", "sweet"],
|
|
156
|
+
# metadata: {threshold: 0.9, category: "produce"}
|
|
157
|
+
# }
|
|
158
|
+
# ],
|
|
159
|
+
# task: ->(input) { "fruit" },
|
|
160
|
+
# scorers: [
|
|
161
|
+
# # Scorer can access case metadata
|
|
162
|
+
# Braintrust::Eval.scorer("threshold_match") do |input, expected, output, metadata|
|
|
163
|
+
# threshold = metadata[:threshold] || 0.5
|
|
164
|
+
# # scoring logic using threshold
|
|
165
|
+
# 1.0
|
|
166
|
+
# end
|
|
167
|
+
# ],
|
|
168
|
+
# # Experiment-level tags and metadata
|
|
169
|
+
# tags: ["v1", "production"],
|
|
170
|
+
# metadata: {
|
|
171
|
+
# model: "gpt-4",
|
|
172
|
+
# temperature: 0.7,
|
|
173
|
+
# version: "1.0.0"
|
|
174
|
+
# }
|
|
175
|
+
# )
|
|
12
176
|
module Eval
|
|
13
177
|
class << self
|
|
14
178
|
# Create a scorer with a name and callable
|
data/lib/braintrust/state.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Braintrust
|
|
|
6
6
|
# State object that holds Braintrust configuration
|
|
7
7
|
# Thread-safe global state management
|
|
8
8
|
class State
|
|
9
|
-
attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in
|
|
9
|
+
attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config
|
|
10
10
|
|
|
11
11
|
@mutex = Mutex.new
|
|
12
12
|
@global_state = nil
|
|
@@ -20,15 +20,20 @@ module Braintrust
|
|
|
20
20
|
# @param blocking_login [Boolean] whether to block and login synchronously (default: false)
|
|
21
21
|
# @param enable_tracing [Boolean] whether to enable OpenTelemetry tracing (default: true)
|
|
22
22
|
# @param tracer_provider [TracerProvider, nil] Optional tracer provider to use
|
|
23
|
+
# @param filter_ai_spans [Boolean, nil] Enable AI span filtering
|
|
24
|
+
# @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
|
|
25
|
+
# @param exporter [Exporter, nil] Optional exporter override (for testing)
|
|
23
26
|
# @return [State] the created state
|
|
24
|
-
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil)
|
|
27
|
+
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil)
|
|
25
28
|
require_relative "config"
|
|
26
29
|
config = Config.from_env(
|
|
27
30
|
api_key: api_key,
|
|
28
31
|
org_name: org_name,
|
|
29
32
|
default_project: default_project,
|
|
30
33
|
app_url: app_url,
|
|
31
|
-
api_url: api_url
|
|
34
|
+
api_url: api_url,
|
|
35
|
+
filter_ai_spans: filter_ai_spans,
|
|
36
|
+
span_filter_funcs: span_filter_funcs
|
|
32
37
|
)
|
|
33
38
|
new(
|
|
34
39
|
api_key: config.api_key,
|
|
@@ -38,11 +43,13 @@ module Braintrust
|
|
|
38
43
|
api_url: config.api_url,
|
|
39
44
|
blocking_login: blocking_login,
|
|
40
45
|
enable_tracing: enable_tracing,
|
|
41
|
-
tracer_provider: tracer_provider
|
|
46
|
+
tracer_provider: tracer_provider,
|
|
47
|
+
config: config,
|
|
48
|
+
exporter: exporter
|
|
42
49
|
)
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil)
|
|
52
|
+
def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, config: nil, exporter: nil)
|
|
46
53
|
# Instance-level mutex for thread-safe login
|
|
47
54
|
@login_mutex = Mutex.new
|
|
48
55
|
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
@@ -55,6 +62,7 @@ module Braintrust
|
|
|
55
62
|
@api_url = api_url
|
|
56
63
|
@proxy_url = proxy_url
|
|
57
64
|
@logged_in = false
|
|
65
|
+
@config = config
|
|
58
66
|
|
|
59
67
|
# Perform login after state setup
|
|
60
68
|
if blocking_login
|
|
@@ -66,7 +74,7 @@ module Braintrust
|
|
|
66
74
|
# Setup tracing if requested
|
|
67
75
|
if enable_tracing
|
|
68
76
|
require_relative "trace"
|
|
69
|
-
Trace.setup(self, tracer_provider)
|
|
77
|
+
Trace.setup(self, tracer_provider, exporter: exporter)
|
|
70
78
|
end
|
|
71
79
|
end
|
|
72
80
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Braintrust
|
|
8
|
+
module Trace
|
|
9
|
+
# Attachment represents binary data (images, audio, PDFs, etc.) that can be logged
|
|
10
|
+
# as part of traces in Braintrust. Attachments are stored securely and can be viewed
|
|
11
|
+
# in the Braintrust UI.
|
|
12
|
+
#
|
|
13
|
+
# Attachments are particularly useful for multimodal AI applications, such as vision
|
|
14
|
+
# models that process images.
|
|
15
|
+
#
|
|
16
|
+
# @example Create attachment from file
|
|
17
|
+
# att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
|
|
18
|
+
# data_url = att.to_data_url
|
|
19
|
+
# # => "data:image/png;base64,iVBORw0KGgo..."
|
|
20
|
+
#
|
|
21
|
+
# @example Create attachment from bytes
|
|
22
|
+
# att = Braintrust::Trace::Attachment.from_bytes("image/jpeg", image_bytes)
|
|
23
|
+
# message = att.to_message
|
|
24
|
+
# # => {"type" => "base64_attachment", "content" => "data:image/jpeg;base64,..."}
|
|
25
|
+
#
|
|
26
|
+
# @example Use in a trace span
|
|
27
|
+
# att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
|
|
28
|
+
# messages = [
|
|
29
|
+
# {
|
|
30
|
+
# role: "user",
|
|
31
|
+
# content: [
|
|
32
|
+
# {type: "text", text: "What's in this image?"},
|
|
33
|
+
# att.to_h # Converts to {"type" => "base64_attachment", "content" => "..."}
|
|
34
|
+
# ]
|
|
35
|
+
# }
|
|
36
|
+
# ]
|
|
37
|
+
# span.set_attribute("braintrust.input_json", JSON.generate(messages))
|
|
38
|
+
class Attachment
|
|
39
|
+
# Common MIME type constants for convenience
|
|
40
|
+
IMAGE_PNG = "image/png"
|
|
41
|
+
IMAGE_JPEG = "image/jpeg"
|
|
42
|
+
IMAGE_JPG = "image/jpg"
|
|
43
|
+
IMAGE_GIF = "image/gif"
|
|
44
|
+
IMAGE_WEBP = "image/webp"
|
|
45
|
+
TEXT_PLAIN = "text/plain"
|
|
46
|
+
APPLICATION_PDF = "application/pdf"
|
|
47
|
+
|
|
48
|
+
# @!visibility private
|
|
49
|
+
def initialize(content_type, data)
|
|
50
|
+
@content_type = content_type
|
|
51
|
+
@data = data
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Creates an attachment from raw bytes.
|
|
55
|
+
#
|
|
56
|
+
# @param content_type [String] MIME type of the data (e.g., "image/png")
|
|
57
|
+
# @param data [String] Binary data as a string
|
|
58
|
+
# @return [Attachment] New attachment instance
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# image_data = File.binread("photo.png")
|
|
62
|
+
# att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
|
|
63
|
+
def self.from_bytes(content_type, data)
|
|
64
|
+
new(content_type, data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Creates an attachment by reading from a file.
|
|
68
|
+
#
|
|
69
|
+
# @param content_type [String] MIME type of the file (e.g., "image/png")
|
|
70
|
+
# @param path [String] Path to the file to read
|
|
71
|
+
# @return [Attachment] New attachment instance
|
|
72
|
+
# @raise [Errno::ENOENT] If the file does not exist
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
|
|
76
|
+
def self.from_file(content_type, path)
|
|
77
|
+
data = File.binread(path)
|
|
78
|
+
new(content_type, data)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Creates an attachment by fetching data from a URL.
|
|
82
|
+
#
|
|
83
|
+
# The content type is inferred from the Content-Type header in the HTTP response.
|
|
84
|
+
# If the header is not present, it falls back to "application/octet-stream".
|
|
85
|
+
#
|
|
86
|
+
# @param url [String] URL to fetch
|
|
87
|
+
# @return [Attachment] New attachment instance
|
|
88
|
+
# @raise [StandardError] If the HTTP request fails
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# att = Braintrust::Trace::Attachment.from_url("https://example.com/image.png")
|
|
92
|
+
def self.from_url(url)
|
|
93
|
+
uri = URI.parse(url)
|
|
94
|
+
response = Net::HTTP.get_response(uri)
|
|
95
|
+
|
|
96
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
97
|
+
raise StandardError, "Failed to fetch URL: #{response.code} #{response.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
content_type = response.content_type || "application/octet-stream"
|
|
101
|
+
new(content_type, response.body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Converts the attachment to a data URL format.
|
|
105
|
+
#
|
|
106
|
+
# @return [String] Data URL in the format "data:<content-type>;base64,<encoded-data>"
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
|
|
110
|
+
# att.to_data_url
|
|
111
|
+
# # => "data:image/png;base64,iVBORw0KGgo..."
|
|
112
|
+
def to_data_url
|
|
113
|
+
encoded = Base64.strict_encode64(@data)
|
|
114
|
+
"data:#{@content_type};base64,#{encoded}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts the attachment to a message format suitable for LLM APIs.
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash] Message hash with "type" and "content" keys
|
|
120
|
+
#
|
|
121
|
+
# @example
|
|
122
|
+
# att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
|
|
123
|
+
# att.to_message
|
|
124
|
+
# # => {"type" => "base64_attachment", "content" => "data:image/png;base64,..."}
|
|
125
|
+
def to_message
|
|
126
|
+
{
|
|
127
|
+
"type" => "base64_attachment",
|
|
128
|
+
"content" => to_data_url
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Alias for {#to_message}. Converts the attachment to a hash representation.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] Same as {#to_message}
|
|
135
|
+
alias_method :to_h, :to_message
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|