dspy 0.34.3 → 1.0.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.
@@ -59,6 +59,8 @@ module DSPy
59
59
  { type: 'text', text: item[:text] }
60
60
  when 'image'
61
61
  item[:image].to_openai_format
62
+ when 'document'
63
+ item[:document].to_openai_format
62
64
  else
63
65
  item
64
66
  end
@@ -83,6 +85,8 @@ module DSPy
83
85
  { type: 'text', text: item[:text] }
84
86
  when 'image'
85
87
  item[:image].to_anthropic_format
88
+ when 'document'
89
+ item[:document].to_anthropic_format
86
90
  else
87
91
  item
88
92
  end
@@ -160,4 +164,4 @@ module DSPy
160
164
  end
161
165
  end
162
166
  end
163
- end
167
+ end
@@ -68,6 +68,20 @@ module DSPy
68
68
  )
69
69
  self
70
70
  end
71
+
72
+ sig { params(text: String, document: DSPy::Document).returns(MessageBuilder) }
73
+ def user_with_document(text, document)
74
+ content_array = [
75
+ { type: 'text', text: text },
76
+ { type: 'document', document: document }
77
+ ]
78
+
79
+ @messages << Message.new(
80
+ role: Message::Role::User,
81
+ content: content_array
82
+ )
83
+ self
84
+ end
71
85
 
72
86
  # For backward compatibility, allow conversion to hash array
73
87
  sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
@@ -76,4 +90,4 @@ module DSPy
76
90
  end
77
91
  end
78
92
  end
79
- end
93
+ end
@@ -118,7 +118,7 @@ module DSPy
118
118
  extend T::Sig
119
119
 
120
120
  const :content, String
121
- const :usage, T.nilable(T.any(Usage, OpenAIUsage)), default: nil
121
+ const :usage, T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage)), default: nil
122
122
  const :metadata, T.any(ResponseMetadata, OpenAIResponseMetadata, AnthropicResponseMetadata, GeminiResponseMetadata, T::Hash[Symbol, T.untyped])
123
123
 
124
124
  sig { returns(String) }
data/lib/dspy/lm/usage.rb CHANGED
@@ -45,11 +45,34 @@ module DSPy
45
45
  end
46
46
  end
47
47
 
48
+ # Anthropic-specific usage information with cache token fields
49
+ class AnthropicUsage < T::Struct
50
+ extend T::Sig
51
+
52
+ const :input_tokens, Integer
53
+ const :output_tokens, Integer
54
+ const :total_tokens, Integer
55
+ const :cache_creation_input_tokens, T.nilable(Integer), default: nil
56
+ const :cache_read_input_tokens, T.nilable(Integer), default: nil
57
+
58
+ sig { returns(Hash) }
59
+ def to_h
60
+ base = {
61
+ input_tokens: input_tokens,
62
+ output_tokens: output_tokens,
63
+ total_tokens: total_tokens
64
+ }
65
+ base[:cache_creation_input_tokens] = cache_creation_input_tokens unless cache_creation_input_tokens.nil?
66
+ base[:cache_read_input_tokens] = cache_read_input_tokens unless cache_read_input_tokens.nil?
67
+ base
68
+ end
69
+ end
70
+
48
71
  # Factory for creating appropriate usage objects
49
72
  module UsageFactory
50
73
  extend T::Sig
51
74
 
52
- sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage))) }
75
+ sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage))) }
53
76
  def self.create(provider, usage_data)
54
77
  return nil if usage_data.nil?
55
78
 
@@ -121,17 +144,19 @@ module DSPy
121
144
  nil
122
145
  end
123
146
 
124
- sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(Usage)) }
147
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(AnthropicUsage)) }
125
148
  def self.create_anthropic_usage(data)
126
149
  # Anthropic uses input_tokens/output_tokens
127
150
  input_tokens = data[:input_tokens] || 0
128
151
  output_tokens = data[:output_tokens] || 0
129
152
  total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
130
-
131
- Usage.new(
153
+
154
+ AnthropicUsage.new(
132
155
  input_tokens: input_tokens,
133
156
  output_tokens: output_tokens,
134
- total_tokens: total_tokens
157
+ total_tokens: total_tokens,
158
+ cache_creation_input_tokens: data[:cache_creation_input_tokens],
159
+ cache_read_input_tokens: data[:cache_read_input_tokens]
135
160
  )
136
161
  rescue StandardError => e
137
162
  DSPy.logger.debug("Failed to create Anthropic usage: #{e.message}")
@@ -173,4 +198,4 @@ module DSPy
173
198
  end
174
199
  end
175
200
  end
176
- end
201
+ end
data/lib/dspy/lm.rb CHANGED
@@ -161,16 +161,78 @@ module DSPy
161
161
  )
162
162
  end
163
163
 
164
- # Add user message
165
- user_prompt = prompt.render_user_prompt(input_values)
166
- messages << Message.new(
167
- role: Message::Role::User,
168
- content: user_prompt
169
- )
164
+ document_inputs = extract_document_inputs(input_values)
165
+
166
+ if document_inputs.empty?
167
+ user_prompt = prompt.render_user_prompt(input_values)
168
+ messages << Message.new(
169
+ role: Message::Role::User,
170
+ content: user_prompt
171
+ )
172
+ else
173
+ validate_document_predict_support!(input_values, document_inputs)
174
+
175
+ placeholder_inputs = input_values.transform_values do |value|
176
+ value.is_a?(DSPy::Document) ? "[attached pdf document]" : value
177
+ end
178
+
179
+ user_prompt = prompt.render_user_prompt(placeholder_inputs)
180
+ content_array = [
181
+ { type: 'text', text: user_prompt },
182
+ { type: 'document', document: document_inputs.first.last }
183
+ ]
184
+
185
+ messages << Message.new(
186
+ role: Message::Role::User,
187
+ content: content_array
188
+ )
189
+ end
170
190
 
171
191
  messages
172
192
  end
173
193
 
194
+ def extract_document_inputs(input_values)
195
+ input_values.each_with_object([]) do |(key, value), inputs|
196
+ if value.is_a?(DSPy::Document)
197
+ inputs << [key, value]
198
+ elsif nested_document_input?(value)
199
+ raise DSPy::LM::IncompatibleDocumentFeatureError,
200
+ "Only one top-level DSPy::Document input is currently supported in Predict."
201
+ end
202
+ end
203
+ end
204
+
205
+ def nested_document_input?(value)
206
+ case value
207
+ when T::Struct
208
+ value.class.props.any? { |name, _| nested_document_input?(value.public_send(name)) }
209
+ when Array
210
+ value.any? { |item| nested_document_input?(item) }
211
+ when Hash
212
+ value.values.any? { |item| nested_document_input?(item) }
213
+ else
214
+ value.is_a?(DSPy::Document)
215
+ end
216
+ end
217
+
218
+ def validate_document_predict_support!(input_values, document_inputs)
219
+ if document_inputs.length > 1
220
+ raise DSPy::LM::IncompatibleDocumentFeatureError,
221
+ "Only one top-level DSPy::Document input is currently supported in Predict."
222
+ end
223
+
224
+ if input_values.values.any? { |value| value.is_a?(DSPy::Image) }
225
+ raise DSPy::LM::IncompatibleDocumentFeatureError,
226
+ "Predict does not support mixing DSPy::Document and DSPy::Image inputs in this release."
227
+ end
228
+
229
+ return if provider == 'anthropic'
230
+ return if adapter.class.name.include?('RubyLLMAdapter') && adapter.provider == 'anthropic'
231
+
232
+ raise DSPy::LM::IncompatibleDocumentFeatureError,
233
+ "Document inputs are currently supported only for Anthropic models and Anthropic via RubyLLM."
234
+ end
235
+
174
236
  def will_use_structured_outputs?(signature_class, data_format: nil)
175
237
  return false unless signature_class
176
238
  return false if data_format == :toon
@@ -305,6 +367,12 @@ module DSPy
305
367
  span.set_attribute('gen_ai.usage.prompt_tokens', usage.input_tokens) if usage.input_tokens
306
368
  span.set_attribute('gen_ai.usage.completion_tokens', usage.output_tokens) if usage.output_tokens
307
369
  span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
370
+ if usage.respond_to?(:cache_creation_input_tokens) && !usage.cache_creation_input_tokens.nil?
371
+ span.set_attribute('gen_ai.usage.cache_creation_input_tokens', usage.cache_creation_input_tokens)
372
+ end
373
+ if usage.respond_to?(:cache_read_input_tokens) && !usage.cache_read_input_tokens.nil?
374
+ span.set_attribute('gen_ai.usage.cache_read_input_tokens', usage.cache_read_input_tokens)
375
+ end
308
376
  end
309
377
  end
310
378
 
@@ -356,11 +424,16 @@ module DSPy
356
424
 
357
425
  # Handle Usage struct objects
358
426
  if response.usage.respond_to?(:input_tokens)
359
- return {
427
+ result = {
360
428
  input_tokens: response.usage.input_tokens,
361
429
  output_tokens: response.usage.output_tokens,
362
430
  total_tokens: response.usage.total_tokens
363
- }.compact
431
+ }
432
+ if response.usage.respond_to?(:cache_creation_input_tokens)
433
+ result[:cache_creation_input_tokens] = response.usage.cache_creation_input_tokens
434
+ result[:cache_read_input_tokens] = response.usage.cache_read_input_tokens
435
+ end
436
+ return result.compact
364
437
  end
365
438
 
366
439
  # Handle hash-based usage (for VCR compatibility)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'sorbet-runtime'
5
+ require 'yaml'
5
6
 
6
7
  module DSPy
7
8
  module Mixins
@@ -88,6 +89,15 @@ module DSPy
88
89
  case prop_type
89
90
  when ->(type) { union_type?(type) }
90
91
  coerce_union_value(value, prop_type)
92
+ when ->(type) { nilable_type?(type) }
93
+ # Unwrap T.nilable(X) to coerce as X (nil already handled above)
94
+ non_nil_types = prop_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
95
+ if non_nil_types.size == 1
96
+ coerce_value_to_type(value, non_nil_types.first)
97
+ else
98
+ # T.any(A, B, NilClass) — rebuild as T.any(A, B) and coerce as union
99
+ coerce_union_value(value, T::Types::Union.new(non_nil_types))
100
+ end
91
101
  when ->(type) { array_type?(type) }
92
102
  coerce_array_value(value, prop_type)
93
103
  when ->(type) { hash_type?(type) }
@@ -161,15 +171,31 @@ module DSPy
161
171
  # Checks if a type is a union type (T.any)
162
172
  sig { params(type: T.untyped).returns(T::Boolean) }
163
173
  def union_type?(type)
164
- type.is_a?(T::Types::Union) && !is_nilable_type?(type)
174
+ type.is_a?(T::Types::Union) && !nilable_type?(type)
165
175
  end
166
176
 
167
177
  # Checks if a type is nilable (contains NilClass)
168
178
  sig { params(type: T.untyped).returns(T::Boolean) }
169
- def is_nilable_type?(type)
179
+ def nilable_type?(type)
170
180
  type.is_a?(T::Types::Union) && type.types.any? { |t| t == T::Utils.coerce(NilClass) }
171
181
  end
172
182
 
183
+ # Checks if a union type is a simple nilable struct (T.nilable(SomeStruct))
184
+ # Returns true only if the union has exactly 2 types: NilClass and a Struct
185
+ sig { params(union_type: T.untyped).returns(T::Boolean) }
186
+ def nilable_struct_union?(union_type)
187
+ return false unless union_type.is_a?(T::Types::Union)
188
+
189
+ types = union_type.types
190
+ return false unless types.size == 2
191
+
192
+ # One type must be NilClass, the other must be a struct
193
+ has_nil = types.any? { |t| t == T::Utils.coerce(NilClass) }
194
+ struct_type = types.find { |t| t != T::Utils.coerce(NilClass) && struct_type?(t) }
195
+
196
+ has_nil && !struct_type.nil?
197
+ end
198
+
173
199
  # Checks if a type is a scalar (primitives that don't need special serialization)
174
200
  sig { params(type_object: T.untyped).returns(T::Boolean) }
175
201
  def scalar_type?(type_object)
@@ -283,9 +309,11 @@ module DSPy
283
309
  # Coerces a hash value, converting keys and values as needed
284
310
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
285
311
  def coerce_hash_value(value, prop_type)
286
- return value unless value.is_a?(Hash)
287
312
  return value unless prop_type.is_a?(T::Types::TypedHash)
288
-
313
+
314
+ value = try_parse_string_to_hash(value)
315
+ return value unless value.is_a?(Hash)
316
+
289
317
  key_type = prop_type.keys
290
318
  value_type = prop_type.values
291
319
 
@@ -302,9 +330,41 @@ module DSPy
302
330
  result.transform_values { |v| coerce_value_to_type(v, value_type) }
303
331
  end
304
332
 
333
+ # Attempts to parse a string into a Hash.
334
+ # Returns the parsed Hash on success, or the original value otherwise.
335
+ sig { params(value: T.untyped).returns(T.untyped) }
336
+ def try_parse_string_to_hash(value)
337
+ return value unless value.is_a?(String)
338
+
339
+ parsed = begin
340
+ JSON.parse(value)
341
+ rescue JSON::ParserError
342
+ YAML.safe_load(value, permitted_classes: [Symbol, Date, Time])
343
+ end
344
+
345
+ parsed.is_a?(Hash) ? parsed : value
346
+ rescue Psych::SyntaxError
347
+ value
348
+ end
349
+
350
+ # Attempts to parse a JSON string into a Hash.
351
+ # Returns the parsed Hash on success, or the original value otherwise.
352
+ sig { params(value: T.untyped).returns(T.untyped) }
353
+ def try_parse_json_to_hash(value)
354
+ return value unless value.is_a?(String)
355
+
356
+ parsed = JSON.parse(value)
357
+ parsed.is_a?(Hash) ? parsed : value
358
+ rescue JSON::ParserError
359
+ value
360
+ end
361
+
305
362
  # Coerces a struct value from a hash
306
363
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
307
364
  def coerce_struct_value(value, prop_type)
365
+ # Anthropic tool use may return struct fields as JSON strings
366
+ value = try_parse_json_to_hash(value)
367
+
308
368
  return value unless value.is_a?(Hash)
309
369
 
310
370
  struct_class = if prop_type.is_a?(Class)
@@ -347,7 +407,7 @@ module DSPy
347
407
  next false unless prop_info
348
408
  prop_type = prop_info[:type_object] || prop_info[:type]
349
409
  has_default = prop_info.key?(:default) || prop_info[:fully_optional]
350
- !is_nilable_type?(prop_type) && has_default
410
+ !nilable_type?(prop_type) && has_default
351
411
  end
352
412
 
353
413
  # Create the struct instance
@@ -363,18 +423,20 @@ module DSPy
363
423
  def coerce_union_value(value, union_type)
364
424
  # Anthropic tool use may return complex oneOf union fields as JSON strings
365
425
  # instead of nested objects. Parse them back into Hashes for coercion.
366
- if value.is_a?(String)
367
- begin
368
- parsed = JSON.parse(value)
369
- value = parsed if parsed.is_a?(Hash)
370
- rescue JSON::ParserError
371
- # Not JSON — fall through
372
- end
373
- end
426
+ value = try_parse_json_to_hash(value)
374
427
 
375
428
  return value unless value.is_a?(Hash)
376
429
 
377
- # Check for _type discriminator field
430
+ # Handle nilable struct unions (T.nilable(SomeStruct)) without _type discriminator
431
+ # LLMs don't provide _type for simple nilable structs, so we can directly coerce
432
+ if nilable_struct_union?(union_type)
433
+ struct_type = union_type.types.find { |t|
434
+ t != T::Utils.coerce(NilClass) && struct_type?(t)
435
+ }
436
+ return coerce_struct_value(value, struct_type) if struct_type
437
+ end
438
+
439
+ # Check for _type discriminator field (required for true multi-type unions)
378
440
  type_name = value[:_type] || value["_type"]
379
441
  return value unless type_name
380
442
 
data/lib/dspy/module.rb CHANGED
@@ -261,21 +261,64 @@ module DSPy
261
261
  def instrument_forward_call(call_args, call_kwargs)
262
262
  ensure_module_subscriptions!
263
263
 
264
+ input_json = serialize_module_input(call_args, call_kwargs)
265
+ root_call = DSPy::Context.current[:span_stack].empty?
266
+
264
267
  DSPy::Context.with_module(self) do
265
268
  observation_type = DSPy::ObservationType.for_module_class(self.class)
266
269
  span_attributes = observation_type.langfuse_attributes.merge(
267
- 'langfuse.observation.input' => serialize_module_input(call_args, call_kwargs),
270
+ 'langfuse.observation.input' => input_json,
268
271
  'dspy.module' => self.class.name
269
272
  )
273
+ operation_name = "#{self.class.name}.forward"
274
+ span_attributes.merge!(root_trace_attributes(call_args, call_kwargs, input_json)) if root_call
275
+
276
+ if self.class.name == 'DSPy::Predict' && respond_to?(:signature_class)
277
+ signature_name = signature_class&.name
278
+ span_attributes['dspy.signature'] = signature_name || 'anonymous'
279
+ span_attributes['dspy.signature_kind'] = infer_signature_kind(signature_name)
280
+ span_attributes['dspy.predictor_label'] = module_scope_label if module_scope_label
281
+ operation_name = "DSPy::Predict(#{signature_name}).forward" if signature_name
282
+ end
270
283
 
271
284
  DSPy::Context.with_span(
272
- operation: "#{self.class.name}.forward",
285
+ operation: operation_name,
273
286
  **span_attributes
274
287
  ) do |span|
275
- yield.tap do |result|
276
- if span && result
277
- span.set_attribute('langfuse.observation.output', serialize_module_output(result))
288
+ begin
289
+ yield.tap do |result|
290
+ if span && !result.nil?
291
+ span.set_attribute('langfuse.observation.output', serialize_module_output(result))
292
+ span.set_attribute('langfuse.observation.status', 'completed')
293
+ span.set_attribute('dspy.status', 'completed')
294
+ if root_call
295
+ span.set_attribute('langfuse.trace.output', serialize_module_output(result))
296
+ span.set_attribute('langfuse.trace.status', 'completed')
297
+ end
298
+ end
299
+ end
300
+ rescue StandardError => e
301
+ if span
302
+ span.set_attribute('langfuse.observation.output', serialize_module_error_output(e))
303
+ span.set_attribute('langfuse.observation.status', 'error')
304
+ span.set_attribute('dspy.error.class', e.class.name)
305
+ span.set_attribute('dspy.error.message', e.message.to_s[0, 2000]) if e.message
306
+ span.set_attribute('dspy.status', 'error')
307
+ if root_call
308
+ span.set_attribute('langfuse.trace.output', serialize_module_error_output(e))
309
+ span.set_attribute('langfuse.trace.status', 'error')
310
+ end
311
+ if e.respond_to?(:iterations)
312
+ span.set_attribute('dspy.error.iterations', e.iterations.to_i) unless e.iterations.nil?
313
+ end
314
+ if e.respond_to?(:max_iterations)
315
+ span.set_attribute('dspy.error.max_iterations', e.max_iterations.to_i) unless e.max_iterations.nil?
316
+ end
317
+ if e.respond_to?(:tools_used)
318
+ span.set_attribute('dspy.error.tools_used', Array(e.tools_used).map(&:to_s))
319
+ end
278
320
  end
321
+ raise
279
322
  end
280
323
  end
281
324
  end
@@ -303,7 +346,91 @@ module DSPy
303
346
  result.to_s
304
347
  end
305
348
 
306
- private :instrument_forward_call, :serialize_module_input, :serialize_module_output
349
+ def serialize_module_error_output(error)
350
+ payload = {
351
+ error: {
352
+ class: error.class.name,
353
+ message: error.message.to_s
354
+ }
355
+ }
356
+
357
+ if error.respond_to?(:iterations) || error.respond_to?(:max_iterations) || error.respond_to?(:tools_used)
358
+ payload[:react] = {}
359
+ payload[:react][:iterations] = error.iterations if error.respond_to?(:iterations)
360
+ payload[:react][:max_iterations] = error.max_iterations if error.respond_to?(:max_iterations)
361
+ payload[:react][:tools_used] = Array(error.tools_used) if error.respond_to?(:tools_used)
362
+ end
363
+
364
+ serialized = DSPy::TypeSerializer.serialize(payload)
365
+ JSON.generate(serialized)
366
+ rescue StandardError
367
+ "#{error.class}: #{error.message}"
368
+ end
369
+
370
+ def root_trace_attributes(call_args, call_kwargs, input_json)
371
+ metadata = {
372
+ module: self.class.name,
373
+ signature: (respond_to?(:signature_class) ? signature_class&.name : nil),
374
+ signature_kind: (respond_to?(:signature_class) ? infer_signature_kind(signature_class&.name) : nil),
375
+ predictor_label: module_scope_label
376
+ }.compact
377
+ conversation_id, conversation_id_source = resolve_conversation_id(call_args, call_kwargs)
378
+ metadata[:conversation_id_source] = conversation_id_source if conversation_id_source
379
+
380
+ {
381
+ 'langfuse.trace.name' => "#{self.class.name}.forward",
382
+ 'langfuse.trace.input' => input_json,
383
+ 'langfuse.trace.metadata' => JSON.generate(metadata),
384
+ 'langfuse.trace.output' => '{"status":"in_progress"}',
385
+ 'conversation_id' => conversation_id,
386
+ 'dspy.conversation_id' => conversation_id
387
+ }
388
+ rescue StandardError
389
+ {}
390
+ end
391
+
392
+ # Conversation ID precedence is deterministic:
393
+ # 1. top-level kwargs[:conversation_id]
394
+ # 2. first positional hash arg[:conversation_id]
395
+ # 3. kwargs[:input_context][:conversation_id]
396
+ # 4. DSPy::Context.current[:conversation_id]
397
+ def resolve_conversation_id(call_args, call_kwargs)
398
+ direct = fetch_hash_value(call_kwargs, :conversation_id)
399
+ return [direct.to_s, 'kwargs.conversation_id'] if present_value?(direct)
400
+
401
+ first_arg = call_args.first if call_args.is_a?(Array) && call_args.first.is_a?(Hash)
402
+ arg_value = fetch_hash_value(first_arg, :conversation_id)
403
+ return [arg_value.to_s, 'args[0].conversation_id'] if present_value?(arg_value)
404
+
405
+ input_context = fetch_hash_value(call_kwargs, :input_context)
406
+ nested = fetch_hash_value(input_context, :conversation_id)
407
+ return [nested.to_s, 'kwargs.input_context.conversation_id'] if present_value?(nested)
408
+
409
+ context_value = fetch_hash_value(DSPy::Context.current, :conversation_id)
410
+ return [context_value.to_s, 'context.conversation_id'] if present_value?(context_value)
411
+
412
+ [nil, nil]
413
+ end
414
+
415
+ def fetch_hash_value(hash, key)
416
+ return nil unless hash.is_a?(Hash)
417
+
418
+ hash[key] || hash[key.to_s]
419
+ end
420
+
421
+ def present_value?(value)
422
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
423
+ end
424
+
425
+ def infer_signature_kind(signature_name)
426
+ return 'custom' unless signature_name
427
+ return 'thought' if signature_name.match?(/thought/i)
428
+ return 'observation' if signature_name.match?(/observation/i)
429
+
430
+ 'custom'
431
+ end
432
+
433
+ private :instrument_forward_call, :serialize_module_input, :serialize_module_output, :serialize_module_error_output, :root_trace_attributes, :resolve_conversation_id, :fetch_hash_value, :present_value?, :infer_signature_kind
307
434
 
308
435
  sig { returns(String) }
309
436
  def module_scope_id
data/lib/dspy/predict.rb CHANGED
@@ -304,7 +304,7 @@ module DSPy
304
304
  next unless prop_type
305
305
 
306
306
  # For nilable fields with nil values, ensure proper handling
307
- if value.nil? && is_nilable_type?(prop_type)
307
+ if value.nil? && nilable_type?(prop_type)
308
308
  # For nilable fields, nil is valid - keep it as is
309
309
  next
310
310
  elsif value.nil? && prop_info[:fully_optional]
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'utils/serialization'
5
+
4
6
  module DSPy
5
7
  class Prediction
6
8
  extend T::Sig
@@ -54,7 +56,14 @@ module DSPy
54
56
 
55
57
  sig { returns(T::Hash[Symbol, T.untyped]) }
56
58
  def to_h
57
- @_struct.serialize
59
+ hash = DSPy::Utils::Serialization.deep_serialize(@_struct.serialize)
60
+ hash.delete('_prediction_marker')
61
+ hash
62
+ end
63
+
64
+ sig { params(args: T.untyped).returns(String) }
65
+ def to_json(*args)
66
+ to_h.to_json(*args)
58
67
  end
59
68
 
60
69
  private
data/lib/dspy/prompt.rb CHANGED
@@ -5,6 +5,7 @@ require 'sorbet-runtime'
5
5
  require 'sorbet/toon'
6
6
 
7
7
  require_relative 'few_shot_example'
8
+ require_relative 'utils/serialization'
8
9
  require_relative 'schema/sorbet_toon_adapter'
9
10
 
10
11
  module DSPy
@@ -241,7 +242,7 @@ module DSPy
241
242
  else
242
243
  sections << "## Input Values"
243
244
  sections << "```json"
244
- sections << JSON.pretty_generate(serialize_for_json(input_values))
245
+ sections << JSON.pretty_generate(DSPy::Utils::Serialization.deep_serialize(input_values))
245
246
  sections << "```"
246
247
  sections << ""
247
248
  sections << "Respond with the corresponding output schema fields wrapped in a ```json ``` block,"
@@ -376,51 +377,6 @@ module DSPy
376
377
  "# Please install: gem install sorbet-baml"
377
378
  end
378
379
 
379
- # Recursively serialize complex objects for JSON representation
380
- sig { params(obj: T.untyped).returns(T.untyped) }
381
- def serialize_for_json(obj)
382
- case obj
383
- when T::Struct
384
- # Convert T::Struct to hash using to_h method if available
385
- if obj.respond_to?(:to_h)
386
- serialize_for_json(obj.to_h)
387
- else
388
- # Fallback: serialize using struct properties
389
- serialize_struct_to_hash(obj)
390
- end
391
- when Hash
392
- # Recursively serialize hash values
393
- obj.transform_values { |v| serialize_for_json(v) }
394
- when Array
395
- # Recursively serialize array elements
396
- obj.map { |item| serialize_for_json(item) }
397
- when T::Enum
398
- # Serialize enums to their string representation
399
- obj.serialize
400
- else
401
- # For basic types (String, Integer, Float, Boolean, etc.), return as-is
402
- obj
403
- end
404
- end
405
-
406
- # Fallback method to serialize T::Struct to hash when to_h is not available
407
- sig { params(struct_obj: T::Struct).returns(T::Hash[Symbol, T.untyped]) }
408
- def serialize_struct_to_hash(struct_obj)
409
- result = {}
410
-
411
- # Use struct's props method to get all properties
412
- if struct_obj.class.respond_to?(:props)
413
- struct_obj.class.props.each do |prop_name, _prop_info|
414
- if struct_obj.respond_to?(prop_name)
415
- value = struct_obj.public_send(prop_name)
416
- result[prop_name] = serialize_for_json(value)
417
- end
418
- end
419
- end
420
-
421
- result
422
- end
423
-
424
380
  def toon_data_format_enabled?
425
381
  data_format == :toon && @signature_class
426
382
  end