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.
- checksums.yaml +4 -4
- data/README.md +9 -16
- data/lib/dspy/context.rb +53 -20
- data/lib/dspy/document.rb +153 -0
- data/lib/dspy/lm/adapter.rb +23 -0
- data/lib/dspy/lm/errors.rb +7 -2
- data/lib/dspy/lm/json_strategy.rb +87 -124
- data/lib/dspy/lm/message.rb +5 -1
- data/lib/dspy/lm/message_builder.rb +15 -1
- data/lib/dspy/lm/response.rb +1 -1
- data/lib/dspy/lm/usage.rb +31 -6
- data/lib/dspy/lm.rb +81 -8
- data/lib/dspy/mixins/type_coercion.rb +76 -14
- data/lib/dspy/module.rb +133 -6
- data/lib/dspy/predict.rb +1 -1
- data/lib/dspy/prediction.rb +10 -1
- data/lib/dspy/prompt.rb +2 -46
- data/lib/dspy/re_act.rb +159 -34
- data/lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb +13 -3
- data/lib/dspy/ruby_llm/version.rb +1 -1
- data/lib/dspy/ruby_llm.rb +0 -3
- data/lib/dspy/signature.rb +4 -5
- data/lib/dspy/structured_outputs_prompt.rb +1 -1
- data/lib/dspy/support/openai_sdk_warning.rb +32 -0
- data/lib/dspy/utils/serialization.rb +2 -6
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +52 -17
- metadata +9 -2
- data/lib/dspy/ruby_llm/guardrails.rb +0 -24
data/lib/dspy/lm/message.rb
CHANGED
|
@@ -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
|
data/lib/dspy/lm/response.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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) && !
|
|
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
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
-
#
|
|
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' =>
|
|
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:
|
|
285
|
+
operation: operation_name,
|
|
273
286
|
**span_attributes
|
|
274
287
|
) do |span|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
span
|
|
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
|
-
|
|
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? &&
|
|
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]
|
data/lib/dspy/prediction.rb
CHANGED
|
@@ -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(
|
|
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
|