langfuse-rb 0.1.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 +60 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/lib/langfuse/api_client.rb +330 -0
- data/lib/langfuse/cache_warmer.rb +219 -0
- data/lib/langfuse/chat_prompt_client.rb +98 -0
- data/lib/langfuse/client.rb +338 -0
- data/lib/langfuse/config.rb +135 -0
- data/lib/langfuse/observations.rb +615 -0
- data/lib/langfuse/otel_attributes.rb +275 -0
- data/lib/langfuse/otel_setup.rb +123 -0
- data/lib/langfuse/prompt_cache.rb +131 -0
- data/lib/langfuse/propagation.rb +471 -0
- data/lib/langfuse/rails_cache_adapter.rb +200 -0
- data/lib/langfuse/score_client.rb +321 -0
- data/lib/langfuse/span_processor.rb +61 -0
- data/lib/langfuse/text_prompt_client.rb +67 -0
- data/lib/langfuse/types.rb +353 -0
- data/lib/langfuse/version.rb +5 -0
- data/lib/langfuse.rb +457 -0
- metadata +177 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "opentelemetry/trace"
|
|
5
|
+
|
|
6
|
+
module Langfuse
|
|
7
|
+
# Client for creating and batching Langfuse scores
|
|
8
|
+
#
|
|
9
|
+
# Handles thread-safe queuing, batching, and sending of score events
|
|
10
|
+
# to the Langfuse ingestion API. Scores are batched and sent automatically
|
|
11
|
+
# based on batch_size and flush_interval configuration.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# score_client = ScoreClient.new(api_client: api_client, config: config)
|
|
15
|
+
# score_client.create(name: "quality", value: 0.85, trace_id: "abc123...")
|
|
16
|
+
#
|
|
17
|
+
# @example With OTel integration
|
|
18
|
+
# Langfuse.observe("operation") do |obs|
|
|
19
|
+
# score_client.score_active_observation(name: "accuracy", value: 0.92)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
# rubocop:disable Metrics/ClassLength
|
|
24
|
+
class ScoreClient
|
|
25
|
+
attr_reader :api_client, :config, :logger
|
|
26
|
+
|
|
27
|
+
# Initialize a new ScoreClient
|
|
28
|
+
#
|
|
29
|
+
# @param api_client [ApiClient] The API client for sending batches
|
|
30
|
+
# @param config [Config] Configuration object with batch_size and flush_interval
|
|
31
|
+
def initialize(api_client:, config:)
|
|
32
|
+
@api_client = api_client
|
|
33
|
+
@config = config
|
|
34
|
+
@logger = config.logger
|
|
35
|
+
@queue = Queue.new
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@flush_thread = nil
|
|
38
|
+
@shutdown = false
|
|
39
|
+
|
|
40
|
+
start_flush_timer
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create a score event and queue it for batching
|
|
44
|
+
#
|
|
45
|
+
# @param name [String] Score name (required)
|
|
46
|
+
# @param value [Numeric, Integer, String] Score value (type depends on data_type)
|
|
47
|
+
# @param trace_id [String, nil] Trace ID to associate with the score
|
|
48
|
+
# @param observation_id [String, nil] Observation ID to associate with the score
|
|
49
|
+
# @param comment [String, nil] Optional comment
|
|
50
|
+
# @param metadata [Hash, nil] Optional metadata hash
|
|
51
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
52
|
+
# @return [void]
|
|
53
|
+
# @raise [ArgumentError] if validation fails
|
|
54
|
+
#
|
|
55
|
+
# @example Numeric score
|
|
56
|
+
# create(name: "quality", value: 0.85, trace_id: "abc123", data_type: :numeric)
|
|
57
|
+
#
|
|
58
|
+
# @example Boolean score
|
|
59
|
+
# create(name: "passed", value: true, trace_id: "abc123", data_type: :boolean)
|
|
60
|
+
#
|
|
61
|
+
# @example Categorical score
|
|
62
|
+
# create(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
|
|
63
|
+
# rubocop:disable Metrics/ParameterLists
|
|
64
|
+
def create(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
|
|
65
|
+
data_type: :numeric)
|
|
66
|
+
validate_name(name)
|
|
67
|
+
normalized_value = normalize_value(value, data_type)
|
|
68
|
+
data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
|
|
69
|
+
|
|
70
|
+
event = build_score_event(
|
|
71
|
+
name: name,
|
|
72
|
+
value: normalized_value,
|
|
73
|
+
trace_id: trace_id,
|
|
74
|
+
observation_id: observation_id,
|
|
75
|
+
comment: comment,
|
|
76
|
+
metadata: metadata,
|
|
77
|
+
data_type: data_type_str
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@queue << event
|
|
81
|
+
|
|
82
|
+
# Trigger flush if batch size reached
|
|
83
|
+
flush if @queue.size >= config.batch_size
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
logger.error("Langfuse score creation failed: #{e.message}")
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
# rubocop:enable Metrics/ParameterLists
|
|
89
|
+
|
|
90
|
+
# Create a score for the currently active observation (from OTel span)
|
|
91
|
+
#
|
|
92
|
+
# Extracts observation_id and trace_id from the active OpenTelemetry span.
|
|
93
|
+
#
|
|
94
|
+
# @param name [String] Score name (required)
|
|
95
|
+
# @param value [Numeric, Integer, String] Score value
|
|
96
|
+
# @param comment [String, nil] Optional comment
|
|
97
|
+
# @param metadata [Hash, nil] Optional metadata hash
|
|
98
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [ArgumentError] if no active span or validation fails
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# Langfuse.observe("operation") do |obs|
|
|
104
|
+
# score_client.score_active_observation(name: "accuracy", value: 0.92)
|
|
105
|
+
# end
|
|
106
|
+
def score_active_observation(name:, value:, comment: nil, metadata: nil, data_type: :numeric)
|
|
107
|
+
ids = extract_ids_from_active_span
|
|
108
|
+
raise ArgumentError, "No active OpenTelemetry span found" unless ids[:observation_id]
|
|
109
|
+
|
|
110
|
+
create(
|
|
111
|
+
name: name,
|
|
112
|
+
value: value,
|
|
113
|
+
trace_id: ids[:trace_id],
|
|
114
|
+
observation_id: ids[:observation_id],
|
|
115
|
+
comment: comment,
|
|
116
|
+
metadata: metadata,
|
|
117
|
+
data_type: data_type
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Create a score for the currently active trace (from OTel span)
|
|
122
|
+
#
|
|
123
|
+
# Extracts trace_id from the active OpenTelemetry span.
|
|
124
|
+
#
|
|
125
|
+
# @param name [String] Score name (required)
|
|
126
|
+
# @param value [Numeric, Integer, String] Score value
|
|
127
|
+
# @param comment [String, nil] Optional comment
|
|
128
|
+
# @param metadata [Hash, nil] Optional metadata hash
|
|
129
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
130
|
+
# @return [void]
|
|
131
|
+
# @raise [ArgumentError] if no active span or validation fails
|
|
132
|
+
#
|
|
133
|
+
# @example
|
|
134
|
+
# Langfuse.observe("operation") do |obs|
|
|
135
|
+
# score_client.score_active_trace(name: "overall_quality", value: 5)
|
|
136
|
+
# end
|
|
137
|
+
def score_active_trace(name:, value:, comment: nil, metadata: nil, data_type: :numeric)
|
|
138
|
+
ids = extract_ids_from_active_span
|
|
139
|
+
raise ArgumentError, "No active OpenTelemetry span found" unless ids[:trace_id]
|
|
140
|
+
|
|
141
|
+
create(
|
|
142
|
+
name: name,
|
|
143
|
+
value: value,
|
|
144
|
+
trace_id: ids[:trace_id],
|
|
145
|
+
comment: comment,
|
|
146
|
+
metadata: metadata,
|
|
147
|
+
data_type: data_type
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Force flush all queued score events
|
|
152
|
+
#
|
|
153
|
+
# Sends all queued events to the API immediately.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
def flush
|
|
157
|
+
return if @queue.empty?
|
|
158
|
+
|
|
159
|
+
events = []
|
|
160
|
+
@queue.size.times do
|
|
161
|
+
events << @queue.pop(true)
|
|
162
|
+
rescue StandardError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
events.compact!
|
|
166
|
+
|
|
167
|
+
return if events.empty?
|
|
168
|
+
|
|
169
|
+
send_batch(events)
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
logger.error("Langfuse score flush failed: #{e.message}")
|
|
172
|
+
# Don't raise - silent error handling for batch operations
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Shutdown the score client and flush remaining events
|
|
176
|
+
#
|
|
177
|
+
# Stops the flush timer thread and sends any remaining queued events.
|
|
178
|
+
#
|
|
179
|
+
# @return [void]
|
|
180
|
+
def shutdown
|
|
181
|
+
@mutex.synchronize do
|
|
182
|
+
return if @shutdown
|
|
183
|
+
|
|
184
|
+
@shutdown = true
|
|
185
|
+
stop_flush_timer
|
|
186
|
+
flush
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# Build a score event hash for ingestion API
|
|
193
|
+
#
|
|
194
|
+
# @param name [String] Score name
|
|
195
|
+
# @param value [Object] Normalized score value
|
|
196
|
+
# @param trace_id [String, nil] Trace ID
|
|
197
|
+
# @param observation_id [String, nil] Observation ID
|
|
198
|
+
# @param comment [String, nil] Comment
|
|
199
|
+
# @param metadata [Hash, nil] Metadata
|
|
200
|
+
# @param data_type [String] Data type string (NUMERIC, BOOLEAN, CATEGORICAL)
|
|
201
|
+
# @return [Hash] Event hash
|
|
202
|
+
# rubocop:disable Metrics/ParameterLists
|
|
203
|
+
def build_score_event(name:, value:, trace_id:, observation_id:, comment:, metadata:, data_type:)
|
|
204
|
+
body = {
|
|
205
|
+
id: SecureRandom.uuid,
|
|
206
|
+
name: name,
|
|
207
|
+
value: value,
|
|
208
|
+
dataType: data_type
|
|
209
|
+
}
|
|
210
|
+
body[:traceId] = trace_id if trace_id
|
|
211
|
+
body[:observationId] = observation_id if observation_id
|
|
212
|
+
body[:comment] = comment if comment
|
|
213
|
+
body[:metadata] = metadata if metadata
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
id: SecureRandom.uuid,
|
|
217
|
+
type: "score-create",
|
|
218
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
219
|
+
body: body
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
# rubocop:enable Metrics/ParameterLists
|
|
223
|
+
|
|
224
|
+
# Normalize and validate score value based on data type
|
|
225
|
+
#
|
|
226
|
+
# @param value [Object] Raw score value
|
|
227
|
+
# @param data_type [Symbol] Data type symbol
|
|
228
|
+
# @return [Object] Normalized value
|
|
229
|
+
# @raise [ArgumentError] if value doesn't match data type
|
|
230
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
231
|
+
def normalize_value(value, data_type)
|
|
232
|
+
case data_type
|
|
233
|
+
when :numeric
|
|
234
|
+
raise ArgumentError, "Numeric value must be Numeric, got #{value.class}" unless value.is_a?(Numeric)
|
|
235
|
+
|
|
236
|
+
value
|
|
237
|
+
when :boolean
|
|
238
|
+
case value
|
|
239
|
+
when true, 1
|
|
240
|
+
1
|
|
241
|
+
when false, 0
|
|
242
|
+
0
|
|
243
|
+
else
|
|
244
|
+
raise ArgumentError, "Boolean value must be true/false or 0/1, got #{value.inspect}"
|
|
245
|
+
end
|
|
246
|
+
when :categorical
|
|
247
|
+
raise ArgumentError, "Categorical value must be a String, got #{value.class}" unless value.is_a?(String)
|
|
248
|
+
|
|
249
|
+
value
|
|
250
|
+
else
|
|
251
|
+
raise ArgumentError, "Invalid data_type: #{data_type}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
255
|
+
|
|
256
|
+
# Validate score name
|
|
257
|
+
#
|
|
258
|
+
# @param name [String] Score name
|
|
259
|
+
# @raise [ArgumentError] if name is invalid
|
|
260
|
+
def validate_name(name)
|
|
261
|
+
raise ArgumentError, "name is required" if name.nil?
|
|
262
|
+
raise ArgumentError, "name must be a String" unless name.is_a?(String)
|
|
263
|
+
raise ArgumentError, "name is required" if name.empty?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Extract trace_id and observation_id from active OTel span
|
|
267
|
+
#
|
|
268
|
+
# @return [Hash] Hash with :trace_id and :observation_id (may be nil)
|
|
269
|
+
def extract_ids_from_active_span
|
|
270
|
+
span = OpenTelemetry::Trace.current_span
|
|
271
|
+
return { trace_id: nil, observation_id: nil } unless span&.recording?
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
trace_id: span.context.trace_id.unpack1("H*"),
|
|
275
|
+
observation_id: span.context.span_id.unpack1("H*")
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Send a batch of events to the API
|
|
280
|
+
#
|
|
281
|
+
# @param events [Array<Hash>] Array of event hashes
|
|
282
|
+
# @return [void]
|
|
283
|
+
def send_batch(events)
|
|
284
|
+
api_client.send_batch(events)
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
logger.error("Langfuse score batch send failed: #{e.message}")
|
|
287
|
+
# Don't raise - silent error handling
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Start the background flush timer thread
|
|
291
|
+
#
|
|
292
|
+
# @return [void]
|
|
293
|
+
def start_flush_timer
|
|
294
|
+
return if config.flush_interval.nil? || config.flush_interval <= 0
|
|
295
|
+
|
|
296
|
+
@flush_thread = Thread.new do
|
|
297
|
+
loop do
|
|
298
|
+
sleep(config.flush_interval)
|
|
299
|
+
break if @shutdown
|
|
300
|
+
|
|
301
|
+
flush
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
logger.error("Langfuse score flush timer error: #{e.message}")
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
@flush_thread.abort_on_exception = false
|
|
307
|
+
@flush_thread.name = "langfuse-score-flush"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Stop the flush timer thread
|
|
311
|
+
#
|
|
312
|
+
# @return [void]
|
|
313
|
+
def stop_flush_timer
|
|
314
|
+
return unless @flush_thread&.alive?
|
|
315
|
+
|
|
316
|
+
@flush_thread.kill
|
|
317
|
+
@flush_thread.join(1) # Wait up to 1 second for thread to finish
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
# rubocop:enable Metrics/ClassLength
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Span processor that automatically sets propagated attributes on new spans.
|
|
7
|
+
#
|
|
8
|
+
# This processor reads propagated attributes from OpenTelemetry context and
|
|
9
|
+
# sets them on spans when they are created. This ensures that attributes set
|
|
10
|
+
# via `propagate_attributes` are automatically applied to all child spans.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class SpanProcessor < OpenTelemetry::SDK::Trace::SpanProcessor
|
|
14
|
+
# Called when a span starts
|
|
15
|
+
#
|
|
16
|
+
# @param span [OpenTelemetry::SDK::Trace::Span] The span that started
|
|
17
|
+
# @param parent_context [OpenTelemetry::Context] The parent context
|
|
18
|
+
# @return [void]
|
|
19
|
+
def on_start(span, parent_context)
|
|
20
|
+
return unless span.recording?
|
|
21
|
+
|
|
22
|
+
# Get propagated attributes from context
|
|
23
|
+
propagated_attrs = Propagation.get_propagated_attributes_from_context(parent_context)
|
|
24
|
+
|
|
25
|
+
# Set attributes on span
|
|
26
|
+
propagated_attrs.each do |key, value|
|
|
27
|
+
span.set_attribute(key, value)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Called when a span ends
|
|
32
|
+
#
|
|
33
|
+
# @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
|
|
34
|
+
# @return [void]
|
|
35
|
+
def on_finish(span)
|
|
36
|
+
# No-op - we don't need to do anything when spans finish
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shutdown the processor
|
|
40
|
+
#
|
|
41
|
+
# @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
|
|
42
|
+
# @return [Integer] Always returns 0 (no timeout needed for no-op)
|
|
43
|
+
def shutdown(timeout: nil)
|
|
44
|
+
# No-op - nothing to clean up
|
|
45
|
+
# Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
|
|
46
|
+
_ = timeout # Suppress unused argument warning
|
|
47
|
+
0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Force flush (no-op for this processor)
|
|
51
|
+
#
|
|
52
|
+
# @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
|
|
53
|
+
# @return [Integer] Always returns 0 (no timeout needed for no-op)
|
|
54
|
+
def force_flush(timeout: nil)
|
|
55
|
+
# No-op - nothing to flush
|
|
56
|
+
# Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
|
|
57
|
+
_ = timeout # Suppress unused argument warning
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mustache"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Text prompt client for compiling text prompts with variable substitution
|
|
7
|
+
#
|
|
8
|
+
# Handles text-based prompts from Langfuse, providing Mustache templating
|
|
9
|
+
# for variable substitution.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# prompt_data = api_client.get_prompt("greeting")
|
|
13
|
+
# text_prompt = Langfuse::TextPromptClient.new(prompt_data)
|
|
14
|
+
# text_prompt.compile(variables: { name: "Alice" })
|
|
15
|
+
# # => "Hello Alice!"
|
|
16
|
+
#
|
|
17
|
+
# @example Accessing metadata
|
|
18
|
+
# text_prompt.name # => "greeting"
|
|
19
|
+
# text_prompt.version # => 1
|
|
20
|
+
# text_prompt.labels # => ["production"]
|
|
21
|
+
#
|
|
22
|
+
class TextPromptClient
|
|
23
|
+
attr_reader :name, :version, :labels, :tags, :config, :prompt
|
|
24
|
+
|
|
25
|
+
# Initialize a new text prompt client
|
|
26
|
+
#
|
|
27
|
+
# @param prompt_data [Hash] The prompt data from the API
|
|
28
|
+
# @raise [ArgumentError] if prompt data is invalid
|
|
29
|
+
def initialize(prompt_data)
|
|
30
|
+
validate_prompt_data!(prompt_data)
|
|
31
|
+
|
|
32
|
+
@name = prompt_data["name"]
|
|
33
|
+
@version = prompt_data["version"]
|
|
34
|
+
@prompt = prompt_data["prompt"]
|
|
35
|
+
@labels = prompt_data["labels"] || []
|
|
36
|
+
@tags = prompt_data["tags"] || []
|
|
37
|
+
@config = prompt_data["config"] || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Compile the prompt with variable substitution
|
|
41
|
+
#
|
|
42
|
+
# @param kwargs [Hash] Variables to substitute in the template (as keyword arguments)
|
|
43
|
+
# @return [String] The compiled prompt text
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# text_prompt.compile(name: "Alice", greeting: "Hi")
|
|
47
|
+
# # => "Hi Alice! Welcome."
|
|
48
|
+
def compile(**kwargs)
|
|
49
|
+
return prompt if kwargs.empty?
|
|
50
|
+
|
|
51
|
+
Mustache.render(prompt, kwargs)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Validate prompt data structure
|
|
57
|
+
#
|
|
58
|
+
# @param prompt_data [Hash] The prompt data to validate
|
|
59
|
+
# @raise [ArgumentError] if validation fails
|
|
60
|
+
def validate_prompt_data!(prompt_data)
|
|
61
|
+
raise ArgumentError, "prompt_data must be a Hash" unless prompt_data.is_a?(Hash)
|
|
62
|
+
raise ArgumentError, "prompt_data must include 'prompt' field" unless prompt_data.key?("prompt")
|
|
63
|
+
raise ArgumentError, "prompt_data must include 'name' field" unless prompt_data.key?("name")
|
|
64
|
+
raise ArgumentError, "prompt_data must include 'version' field" unless prompt_data.key?("version")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|