dspy 0.2.0 → 0.3.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.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-schema'
4
+
5
+ module DSPy
6
+ # Schema adapters for integrating sorbet-schema with T::Struct
7
+ module SchemaAdapters
8
+ # Handles sorbet-schema integration for serialization/deserialization
9
+ class SorbetSchemaAdapter
10
+ extend T::Sig
11
+
12
+ # Serialize a hash to a T::Struct using sorbet-schema
13
+ #
14
+ # @param struct_class [Class] T::Struct class
15
+ # @param hash_data [Hash] Data to serialize
16
+ # @return [T::Struct] Validated struct instance
17
+ sig { params(struct_class: T.class_of(T::Struct), hash_data: T::Hash[T.untyped, T.untyped]).returns(T::Struct) }
18
+ def self.from_hash(struct_class, hash_data)
19
+ # TODO: Implement using sorbet-schema
20
+ # For now, fall back to direct struct creation
21
+ struct_class.new(**hash_data.transform_keys(&:to_sym))
22
+ end
23
+
24
+ # Deserialize a T::Struct to a hash using sorbet-schema
25
+ #
26
+ # @param struct_instance [T::Struct] Struct instance to serialize
27
+ # @return [Hash] Serialized hash
28
+ sig { params(struct_instance: T::Struct).returns(T::Hash[T.untyped, T.untyped]) }
29
+ def self.to_hash(struct_instance)
30
+ # TODO: Implement using sorbet-schema
31
+ # For now, fall back to simple serialization
32
+ result = {}
33
+ struct_instance.class.props.each do |name, _prop_info|
34
+ result[name] = struct_instance.send(name)
35
+ end
36
+ result
37
+ end
38
+
39
+ # Validate data against a T::Struct schema using sorbet-schema
40
+ #
41
+ # @param struct_class [Class] T::Struct class
42
+ # @param hash_data [Hash] Data to validate
43
+ # @return [Array] [success_boolean, result_or_errors]
44
+ sig { params(struct_class: T.class_of(T::Struct), hash_data: T::Hash[T.untyped, T.untyped]).returns([T::Boolean, T.untyped]) }
45
+ def self.validate(struct_class, hash_data)
46
+ begin
47
+ result = from_hash(struct_class, hash_data)
48
+ [true, result]
49
+ rescue => e
50
+ [false, [e.message]]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,25 +1,297 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'sorbet-runtime'
4
+ require_relative 'schema_adapters'
5
+
3
6
  module DSPy
4
7
  class Signature
8
+ extend T::Sig
9
+
10
+ # Container for field type and description
11
+ class FieldDescriptor
12
+ extend T::Sig
13
+
14
+ sig { returns(T.untyped) }
15
+ attr_reader :type
16
+
17
+ sig { returns(T.nilable(String)) }
18
+ attr_reader :description
19
+
20
+ sig { returns(T::Boolean) }
21
+ attr_reader :has_default
22
+
23
+ sig { params(type: T.untyped, description: T.nilable(String), has_default: T::Boolean).void }
24
+ def initialize(type, description = nil, has_default = false)
25
+ @type = type
26
+ @description = description
27
+ @has_default = has_default
28
+ end
29
+ end
30
+
31
+ # DSL helper for building struct classes with field descriptions
32
+ class StructBuilder
33
+ extend T::Sig
34
+
35
+ sig { returns(T::Hash[Symbol, FieldDescriptor]) }
36
+ attr_reader :field_descriptors
37
+
38
+ sig { void }
39
+ def initialize
40
+ @field_descriptors = {}
41
+ end
42
+
43
+ sig { params(name: Symbol, type: T.untyped, kwargs: T.untyped).void }
44
+ def const(name, type, **kwargs)
45
+ description = kwargs[:description]
46
+ has_default = kwargs.key?(:default)
47
+ @field_descriptors[name] = FieldDescriptor.new(type, description, has_default)
48
+ # Store default for future use if needed
49
+ end
50
+
51
+ sig { returns(T.class_of(T::Struct)) }
52
+ def build_struct_class
53
+ descriptors = @field_descriptors
54
+ Class.new(T::Struct) do
55
+ extend T::Sig
56
+ descriptors.each do |name, descriptor|
57
+ const name, descriptor.type
58
+ end
59
+ end
60
+ end
61
+ end
62
+
5
63
  class << self
6
- attr_reader :input_schema
7
- attr_accessor :output_schema
64
+ extend T::Sig
65
+
66
+ sig { returns(T.nilable(String)) }
67
+ attr_reader :desc
68
+
69
+ sig { returns(T.nilable(T.class_of(T::Struct))) }
70
+ attr_reader :input_struct_class
71
+
72
+ sig { returns(T.nilable(T.class_of(T::Struct))) }
73
+ attr_reader :output_struct_class
74
+
75
+ sig { returns(T::Hash[Symbol, FieldDescriptor]) }
76
+ attr_reader :input_field_descriptors
77
+
78
+ sig { returns(T::Hash[Symbol, FieldDescriptor]) }
79
+ attr_reader :output_field_descriptors
80
+
81
+ sig { params(desc: T.nilable(String)).returns(T.nilable(String)) }
82
+ def description(desc = nil)
83
+ if desc.nil?
84
+ @desc
85
+ else
86
+ @desc = desc
87
+ end
88
+ end
89
+
90
+ sig { params(block: T.proc.void).void }
91
+ def input(&block)
92
+ builder = StructBuilder.new
8
93
 
9
- def description(text = nil)
10
- if text
11
- @description = text
94
+ if block.arity > 0
95
+ block.call(builder)
12
96
  else
13
- @description
97
+ # Preferred format
98
+ builder.instance_eval(&block)
14
99
  end
100
+
101
+ @input_field_descriptors = builder.field_descriptors
102
+ @input_struct_class = builder.build_struct_class
15
103
  end
16
104
 
17
- def input(&)
18
- @input_schema= Dry::Schema::JSON(&)
105
+ sig { params(block: T.proc.void).void }
106
+ def output(&block)
107
+ builder = StructBuilder.new
108
+
109
+ if block.arity > 0
110
+ block.call(builder)
111
+ else
112
+ # Preferred format
113
+ builder.instance_eval(&block)
114
+ end
115
+
116
+ @output_field_descriptors = builder.field_descriptors
117
+ @output_struct_class = builder.build_struct_class
19
118
  end
20
119
 
21
- def output(&)
22
- @output_schema = Dry::Schema::JSON(&)
120
+ sig { returns(T::Hash[Symbol, T.untyped]) }
121
+ def input_json_schema
122
+ return {} unless @input_struct_class
123
+
124
+ properties = {}
125
+ required = []
126
+
127
+ @input_field_descriptors&.each do |name, descriptor|
128
+ schema = type_to_json_schema(descriptor.type)
129
+ schema[:description] = descriptor.description if descriptor.description
130
+ properties[name] = schema
131
+ required << name.to_s unless descriptor.has_default
132
+ end
133
+
134
+ {
135
+ "$schema": "http://json-schema.org/draft-06/schema#",
136
+ type: "object",
137
+ properties: properties,
138
+ required: required
139
+ }
140
+ end
141
+
142
+ sig { returns(T::Hash[Symbol, T.untyped]) }
143
+ def output_json_schema
144
+ return {} unless @output_struct_class
145
+
146
+ properties = {}
147
+ required = []
148
+
149
+ @output_field_descriptors&.each do |name, descriptor|
150
+ schema = type_to_json_schema(descriptor.type)
151
+ schema[:description] = descriptor.description if descriptor.description
152
+ properties[name] = schema
153
+ required << name.to_s unless descriptor.has_default
154
+ end
155
+
156
+ {
157
+ "$schema": "http://json-schema.org/draft-06/schema#",
158
+ type: "object",
159
+ properties: properties,
160
+ required: required
161
+ }
162
+ end
163
+
164
+ private
165
+
166
+ sig { params(type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
167
+ def type_to_json_schema(type)
168
+ # Handle T::Boolean type alias first
169
+ if type == T::Boolean
170
+ return { type: "boolean" }
171
+ end
172
+
173
+ # Handle raw class types first
174
+ if type.is_a?(Class)
175
+ if type < T::Enum
176
+ # Get all enum values
177
+ values = type.values.map(&:serialize)
178
+ { type: "string", enum: values }
179
+ elsif type == String
180
+ { type: "string" }
181
+ elsif type == Integer
182
+ { type: "integer" }
183
+ elsif type == Float
184
+ { type: "number" }
185
+ elsif type == Numeric
186
+ { type: "number" }
187
+ elsif [TrueClass, FalseClass].include?(type)
188
+ { type: "boolean" }
189
+ elsif type < T::Struct
190
+ # Handle custom T::Struct classes by generating nested object schema
191
+ generate_struct_schema(type)
192
+ else
193
+ { type: "string" } # Default fallback
194
+ end
195
+ elsif type.is_a?(T::Types::Simple)
196
+ case type.raw_type.to_s
197
+ when "String"
198
+ { type: "string" }
199
+ when "Integer"
200
+ { type: "integer" }
201
+ when "Float"
202
+ { type: "number" }
203
+ when "Numeric"
204
+ { type: "number" }
205
+ when "TrueClass", "FalseClass"
206
+ { type: "boolean" }
207
+ when "T::Boolean"
208
+ { type: "boolean" }
209
+ else
210
+ # Check if it's an enum
211
+ if type.raw_type < T::Enum
212
+ # Get all enum values
213
+ values = type.raw_type.values.map(&:serialize)
214
+ { type: "string", enum: values }
215
+ elsif type.raw_type < T::Struct
216
+ # Handle custom T::Struct classes
217
+ generate_struct_schema(type.raw_type)
218
+ else
219
+ { type: "string" } # Default fallback
220
+ end
221
+ end
222
+ elsif type.is_a?(T::Types::TypedArray)
223
+ # Handle arrays properly with nested item type
224
+ {
225
+ type: "array",
226
+ items: type_to_json_schema(type.type)
227
+ }
228
+ elsif type.is_a?(T::Types::TypedHash)
229
+ # Handle hashes as objects with additionalProperties
230
+ # TypedHash has keys and values methods to access its key and value types
231
+ key_schema = type_to_json_schema(type.keys)
232
+ value_schema = type_to_json_schema(type.values)
233
+
234
+ # Create a more descriptive schema for nested structures
235
+ {
236
+ type: "object",
237
+ propertyNames: key_schema, # Describe key constraints
238
+ additionalProperties: value_schema,
239
+ # Add a more explicit description of the expected structure
240
+ description: "A mapping where keys are #{key_schema[:type]}s and values are #{value_schema[:description] || value_schema[:type]}s"
241
+ }
242
+ elsif type.is_a?(T::Types::Union)
243
+ # For optional types (T.nilable), just use the non-nil type
244
+ non_nil_types = type.types.reject { |t| t == T::Utils.coerce(NilClass) }
245
+ if non_nil_types.size == 1
246
+ type_to_json_schema(non_nil_types.first)
247
+ elsif non_nil_types.size > 1
248
+ # Handle complex unions with oneOf for better JSON schema compliance
249
+ {
250
+ oneOf: non_nil_types.map { |t| type_to_json_schema(t) },
251
+ description: "Union of multiple types"
252
+ }
253
+ else
254
+ { type: "string" } # Fallback for complex unions
255
+ end
256
+ elsif type.is_a?(T::Types::ClassOf)
257
+ # Handle T.class_of() types
258
+ {
259
+ type: "string",
260
+ description: "Class name (T.class_of type)"
261
+ }
262
+ else
263
+ { type: "string" } # Default fallback
264
+ end
265
+ end
266
+
267
+ private
268
+
269
+ # Generate JSON schema for custom T::Struct classes
270
+ sig { params(struct_class: T.class_of(T::Struct)).returns(T::Hash[Symbol, T.untyped]) }
271
+ def generate_struct_schema(struct_class)
272
+ return { type: "string", description: "Struct (schema introspection not available)" } unless struct_class.respond_to?(:props)
273
+
274
+ properties = {}
275
+ required = []
276
+
277
+ struct_class.props.each do |prop_name, prop_info|
278
+ prop_type = prop_info[:type_object] || prop_info[:type]
279
+ properties[prop_name] = type_to_json_schema(prop_type)
280
+
281
+ # A field is required if it's not fully optional
282
+ # fully_optional is true for nilable prop fields
283
+ # immutable const fields are required unless nilable
284
+ unless prop_info[:fully_optional]
285
+ required << prop_name.to_s
286
+ end
287
+ end
288
+
289
+ {
290
+ type: "object",
291
+ properties: properties,
292
+ required: required,
293
+ description: "#{struct_class.name} struct"
294
+ }
23
295
  end
24
296
  end
25
297
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module Subscribers
5
+ # Logger subscriber that provides detailed logging based on instrumentation events
6
+ # Subscribes to DSPy events and logs relevant information for debugging and monitoring
7
+ class LoggerSubscriber
8
+ extend T::Sig
9
+
10
+ sig { params(logger: T.nilable(Logger)).void }
11
+ def initialize(logger: nil)
12
+ @logger = T.let(logger || DSPy.config.logger, Logger)
13
+ setup_event_subscriptions
14
+ end
15
+
16
+ private
17
+
18
+ sig { void }
19
+ def setup_event_subscriptions
20
+ # Subscribe to DSPy instrumentation events
21
+ DSPy::Instrumentation.subscribe('dspy.lm.request') do |event|
22
+ log_lm_request(event)
23
+ end
24
+
25
+ DSPy::Instrumentation.subscribe('dspy.predict') do |event|
26
+ log_prediction(event)
27
+ end
28
+
29
+ DSPy::Instrumentation.subscribe('dspy.chain_of_thought') do |event|
30
+ log_chain_of_thought(event)
31
+ end
32
+
33
+ DSPy::Instrumentation.subscribe('dspy.react') do |event|
34
+ log_react(event)
35
+ end
36
+
37
+ DSPy::Instrumentation.subscribe('dspy.react.iteration_complete') do |event|
38
+ log_react_iteration_complete(event)
39
+ end
40
+
41
+ DSPy::Instrumentation.subscribe('dspy.react.tool_call') do |event|
42
+ log_react_tool_call(event)
43
+ end
44
+ end
45
+
46
+ # Callback methods for different event types
47
+ sig { params(event: T.untyped).void }
48
+ def on_lm_request(event)
49
+ log_lm_request(event)
50
+ end
51
+
52
+ sig { params(event: T.untyped).void }
53
+ def on_predict(event)
54
+ log_prediction(event)
55
+ end
56
+
57
+ sig { params(event: T.untyped).void }
58
+ def on_chain_of_thought(event)
59
+ log_chain_of_thought(event)
60
+ end
61
+
62
+ sig { params(event: T.untyped).void }
63
+ def on_react(event)
64
+ log_react(event)
65
+ end
66
+
67
+ sig { params(event: T.untyped).void }
68
+ def on_react_iteration_complete(event)
69
+ log_react_iteration_complete(event)
70
+ end
71
+
72
+ sig { params(event: T.untyped).void }
73
+ def on_react_tool_call(event)
74
+ log_react_tool_call(event)
75
+ end
76
+
77
+ # Event logging methods
78
+ sig { params(event: T.untyped).void }
79
+ def log_lm_request(event)
80
+ payload = event.payload
81
+ provider = payload[:provider]
82
+ model = payload[:gen_ai_request_model] || payload[:model]
83
+ duration = payload[:duration_ms]&.round(2)
84
+ status = payload[:status]
85
+ tokens = if payload[:tokens_total]
86
+ " (#{payload[:tokens_total]} tokens)"
87
+ else
88
+ ""
89
+ end
90
+
91
+ status_emoji = status == 'success' ? '✅' : '❌'
92
+ @logger.info("#{status_emoji} LM Request [#{provider}/#{model}] - #{status} (#{duration}ms)#{tokens}")
93
+
94
+ if status == 'error' && payload[:error_message]
95
+ @logger.error(" Error: #{payload[:error_message]}")
96
+ end
97
+ end
98
+
99
+ sig { params(event: T.untyped).void }
100
+ def log_prediction(event)
101
+ payload = event.payload
102
+ signature = payload[:signature_class]
103
+ duration = payload[:duration_ms]&.round(2)
104
+ status = payload[:status]
105
+ input_size = payload[:input_size]
106
+
107
+ status_emoji = status == 'success' ? '🔮' : '❌'
108
+ @logger.info("#{status_emoji} Prediction [#{signature}] - #{status} (#{duration}ms)")
109
+ @logger.info(" Input size: #{input_size} chars") if input_size
110
+
111
+ if status == 'error' && payload[:error_message]
112
+ @logger.error(" Error: #{payload[:error_message]}")
113
+ end
114
+ end
115
+
116
+ sig { params(event: T.untyped).void }
117
+ def log_chain_of_thought(event)
118
+ payload = event.payload
119
+ signature = payload[:signature_class]
120
+ duration = payload[:duration_ms]&.round(2)
121
+ status = payload[:status]
122
+ reasoning_steps = payload[:reasoning_steps]
123
+ reasoning_length = payload[:reasoning_length]
124
+
125
+ status_emoji = status == 'success' ? '🧠' : '❌'
126
+ @logger.info("#{status_emoji} Chain of Thought [#{signature}] - #{status} (#{duration}ms)")
127
+ @logger.info(" Reasoning steps: #{reasoning_steps}") if reasoning_steps
128
+ @logger.info(" Reasoning length: #{reasoning_length} chars") if reasoning_length
129
+
130
+ if status == 'error' && payload[:error_message]
131
+ @logger.error(" Error: #{payload[:error_message]}")
132
+ end
133
+ end
134
+
135
+ sig { params(event: T.untyped).void }
136
+ def log_react(event)
137
+ payload = event.payload
138
+ signature = payload[:signature_class]
139
+ duration = payload[:duration_ms]&.round(2)
140
+ status = payload[:status]
141
+ iteration_count = payload[:iteration_count]
142
+ tools_used = payload[:tools_used]
143
+ final_answer = payload[:final_answer]
144
+
145
+ status_emoji = case status
146
+ when 'success' then '🤖'
147
+ when 'max_iterations' then '⏰'
148
+ else '❌'
149
+ end
150
+
151
+ @logger.info("#{status_emoji} ReAct Agent [#{signature}] - #{status} (#{duration}ms)")
152
+ @logger.info(" Iterations: #{iteration_count}") if iteration_count
153
+ @logger.info(" Tools used: #{tools_used.join(', ')}") if tools_used&.any?
154
+ @logger.info(" Final answer: #{final_answer}") if final_answer
155
+
156
+ if status == 'error' && payload[:error_message]
157
+ @logger.error(" Error: #{payload[:error_message]}")
158
+ end
159
+ end
160
+
161
+ sig { params(event: T.untyped).void }
162
+ def log_react_iteration_complete(event)
163
+ payload = event.payload
164
+ iteration = payload[:iteration]
165
+ thought = payload[:thought]
166
+ action = payload[:action]
167
+ duration = payload[:duration_ms]&.round(2)
168
+ status = payload[:status]
169
+
170
+ status_emoji = status == 'success' ? '🔄' : '❌'
171
+ @logger.info("#{status_emoji} ReAct Iteration #{iteration} - #{status} (#{duration}ms)")
172
+ @logger.info(" Thought: #{thought.truncate(100)}") if thought
173
+ @logger.info(" Action: #{action}") if action
174
+
175
+ if status == 'error' && payload[:error_message]
176
+ @logger.error(" Error: #{payload[:error_message]}")
177
+ end
178
+ end
179
+
180
+ sig { params(event: T.untyped).void }
181
+ def log_react_tool_call(event)
182
+ payload = event.payload
183
+ iteration = payload[:iteration]
184
+ tool_name = payload[:tool_name]
185
+ duration = payload[:duration_ms]&.round(2)
186
+ status = payload[:status]
187
+
188
+ status_emoji = status == 'success' ? '🔧' : '❌'
189
+ @logger.info("#{status_emoji} Tool Call [#{tool_name}] (Iteration #{iteration}) - #{status} (#{duration}ms)")
190
+
191
+ if status == 'error' && payload[:error_message]
192
+ @logger.error(" Error: #{payload[:error_message]}")
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end