dspy 0.28.0 → 0.28.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df0e7cf901df85567e1553a3d7df8659a71f254fa5671803ea98f0573de0c39a
4
- data.tar.gz: b2d5d37cb05678f97143a1463410d92848ba10178bd50d60ee384827e9efe9b1
3
+ metadata.gz: f1cc0ac1e2e1dc27f6255b11ca70ff0ad0eb37b5ae50ff38b97ae7d35b7b69b8
4
+ data.tar.gz: 2399987757b4f037080632e646714e785328fbe3c1fd39138fe750336cdd7710
5
5
  SHA512:
6
- metadata.gz: 0d870f2d338fcdce0540143decd3674e94a9c24e5fad796536772e6c8064af75dcafc36533bf39a0a1eeefac30777de1d6541a3ab57eb2568a007260d7a5d7a3
7
- data.tar.gz: 13f92278a81ca870f90b662fa40972e41aa6203d27a8792d3d03c13b97d971d7684a0f661dfb86bf03ef5c58e4a8f2f57c2ac48414ed9b8f8e5f0bce7628f231
6
+ metadata.gz: ea48762186d3de89a005e8eac27f57ca90b294c4ab096ec1c7985d06396216d719ca3a2144406d1f43af472a560670e502329a785d33b8923b5c9c4c0f83dfd1
7
+ data.tar.gz: 98fee1468f2692a0f7cc87622722f945dbc344fb299e71935ef592bc1d59e68e48e1390208981b6263998627dacb38dc325727cc191f81e1db79fff57c1537a4
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Provides Rails-style callback hooks for DSPy modules
7
+ #
8
+ # @example Define callbacks in base class
9
+ # class DSPy::Module
10
+ # include DSPy::Callbacks
11
+ #
12
+ # create_before_callback :forward
13
+ # create_after_callback :forward
14
+ # create_around_callback :forward
15
+ # end
16
+ #
17
+ # @example Use callbacks in subclasses
18
+ # class MyAgent < DSPy::Module
19
+ # before :setup_context
20
+ # after :log_metrics
21
+ # around :manage_memory
22
+ #
23
+ # private
24
+ #
25
+ # def setup_context
26
+ # @start_time = Time.now
27
+ # end
28
+ #
29
+ # def log_metrics
30
+ # puts "Duration: #{Time.now - @start_time}"
31
+ # end
32
+ #
33
+ # def manage_memory
34
+ # load_context
35
+ # yield
36
+ # save_context
37
+ # end
38
+ # end
39
+ module Callbacks
40
+ def self.included(base)
41
+ base.extend(ClassMethods)
42
+ end
43
+
44
+ module ClassMethods
45
+ # Creates a before callback hook for the specified method
46
+ #
47
+ # @param method_name [Symbol] the method to add callback support to
48
+ def create_before_callback(method_name)
49
+ mark_method_has_callbacks(method_name)
50
+ ensure_callback_method_defined(:before, method_name)
51
+ wrap_method_with_callbacks(method_name)
52
+ end
53
+
54
+ # Creates an after callback hook for the specified method
55
+ #
56
+ # @param method_name [Symbol] the method to add callback support to
57
+ def create_after_callback(method_name)
58
+ mark_method_has_callbacks(method_name)
59
+ ensure_callback_method_defined(:after, method_name)
60
+ wrap_method_with_callbacks(method_name)
61
+ end
62
+
63
+ # Creates an around callback hook for the specified method
64
+ #
65
+ # @param method_name [Symbol] the method to add callback support to
66
+ def create_around_callback(method_name)
67
+ mark_method_has_callbacks(method_name)
68
+ ensure_callback_method_defined(:around, method_name)
69
+ wrap_method_with_callbacks(method_name)
70
+ end
71
+
72
+ private
73
+
74
+ # Ensures the callback registration method exists
75
+ def ensure_callback_method_defined(type, target_method_name)
76
+ return if singleton_class.method_defined?(type)
77
+
78
+ define_singleton_method(type) do |callback_method|
79
+ register_callback(type, target_method_name, callback_method)
80
+ end
81
+ end
82
+
83
+ # Registers a callback for execution
84
+ def register_callback(type, method_name, callback_method)
85
+ own_callbacks_for(method_name)[type] ||= []
86
+ own_callbacks_for(method_name)[type] << callback_method
87
+ end
88
+
89
+ # Returns own callbacks (not including parent)
90
+ def own_callbacks_for(method_name)
91
+ @callbacks ||= {}
92
+ @callbacks[method_name] ||= {}
93
+ end
94
+
95
+ # Marks that a method has callback support (even if no callbacks registered yet)
96
+ def mark_method_has_callbacks(method_name)
97
+ own_callbacks_for(method_name)
98
+ end
99
+
100
+ # Returns the callback registry for a method
101
+ # Includes callbacks from parent classes
102
+ def callbacks_for(method_name)
103
+ own_callbacks = own_callbacks_for(method_name)
104
+
105
+ # Merge parent callbacks if this is a subclass
106
+ if superclass.respond_to?(:callbacks_for, true)
107
+ parent_callbacks = superclass.send(:callbacks_for, method_name)
108
+
109
+ # Merge each callback type, with own callbacks coming after parent callbacks
110
+ merged_callbacks = {}
111
+ [:before, :after, :around].each do |type|
112
+ parent_list = parent_callbacks[type] || []
113
+ own_list = own_callbacks[type] || []
114
+ merged_callbacks[type] = parent_list + own_list if parent_list.any? || own_list.any?
115
+ end
116
+
117
+ merged_callbacks
118
+ else
119
+ own_callbacks
120
+ end
121
+ end
122
+
123
+ # Wraps a method with callback execution logic
124
+ def wrap_method_with_callbacks(method_name)
125
+ return if method_wrapped?(method_name)
126
+
127
+ # Defer wrapping if method doesn't exist yet
128
+ return unless method_defined?(method_name)
129
+
130
+ # Mark as wrapped BEFORE define_method to prevent infinite recursion
131
+ mark_method_wrapped(method_name)
132
+
133
+ original_method = instance_method(method_name)
134
+
135
+ define_method(method_name) do |*args, **kwargs, &block|
136
+ # Execute before callbacks
137
+ run_callbacks(:before, method_name)
138
+
139
+ # Execute around callbacks or original method
140
+ result = if self.class.send(:has_around_callbacks?, method_name)
141
+ execute_with_around_callbacks(method_name, original_method, *args, **kwargs, &block)
142
+ else
143
+ original_method.bind(self).call(*args, **kwargs, &block)
144
+ end
145
+
146
+ # Execute after callbacks
147
+ run_callbacks(:after, method_name)
148
+
149
+ result
150
+ end
151
+ end
152
+
153
+ # Checks if method has around callbacks
154
+ def has_around_callbacks?(method_name)
155
+ callbacks_for(method_name)[:around]&.any?
156
+ end
157
+
158
+ # Hook into method_added to wrap methods when they're defined
159
+ def method_added(method_name)
160
+ super
161
+
162
+ # Check if this method or any parent has callback support (even if no callbacks registered yet)
163
+ has_callback_support = method_has_callback_support?(method_name)
164
+
165
+ return unless has_callback_support
166
+ return if method_wrapped?(method_name)
167
+
168
+ wrap_method_with_callbacks(method_name)
169
+ end
170
+
171
+ # Checks if a method has callback support in this class or parents
172
+ def method_has_callback_support?(method_name)
173
+ # Check own callbacks registry
174
+ return true if @callbacks&.key?(method_name)
175
+
176
+ # Check parent class
177
+ if superclass.respond_to?(:method_has_callback_support?, true)
178
+ superclass.send(:method_has_callback_support?, method_name)
179
+ else
180
+ false
181
+ end
182
+ end
183
+
184
+ # Marks a method as wrapped
185
+ def mark_method_wrapped(method_name)
186
+ @wrapped_methods ||= []
187
+ @wrapped_methods << method_name
188
+ end
189
+
190
+ # Checks if method is already wrapped
191
+ def method_wrapped?(method_name)
192
+ @wrapped_methods&.include?(method_name)
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ # Executes callbacks of a specific type
199
+ def run_callbacks(type, method_name)
200
+ callbacks = self.class.send(:callbacks_for, method_name)[type]
201
+ return unless callbacks
202
+
203
+ callbacks.each do |callback_method|
204
+ send(callback_method)
205
+ end
206
+ end
207
+
208
+ # Executes method with around callbacks
209
+ def execute_with_around_callbacks(method_name, original_method, *args, **kwargs, &block)
210
+ callbacks = self.class.send(:callbacks_for, method_name)[:around]
211
+
212
+ # Build callback chain from innermost (original method) to outermost
213
+ chain = callbacks.reverse.inject(
214
+ -> { original_method.bind(self).call(*args, **kwargs, &block) }
215
+ ) do |inner, callback_method|
216
+ -> { send(callback_method) { inner.call } }
217
+ end
218
+
219
+ chain.call
220
+ end
221
+ end
222
+ end
@@ -46,7 +46,8 @@ module DSPy
46
46
  input_schema: @signature_class.input_json_schema,
47
47
  output_schema: @signature_class.output_json_schema,
48
48
  few_shot_examples: new_prompt.few_shot_examples,
49
- signature_class_name: @signature_class.name
49
+ signature_class_name: @signature_class.name,
50
+ schema_format: new_prompt.schema_format
50
51
  )
51
52
 
52
53
  instance.instance_variable_set(:@prompt, enhanced_prompt)
@@ -11,29 +11,32 @@ module DSPy
11
11
  extend T::Sig
12
12
 
13
13
  # Models that support structured outputs (JSON + Schema)
14
- # Based on official Google documentation (Sept 2025)
14
+ # Based on official Google documentation: https://ai.google.dev/gemini-api/docs/models/gemini
15
+ # Last updated: Oct 2025
16
+ # Note: Gemini 1.5 series deprecated Oct 2025
15
17
  STRUCTURED_OUTPUT_MODELS = T.let([
16
- # Gemini 1.5 series
17
- "gemini-1.5-pro",
18
- "gemini-1.5-pro-preview-0514",
19
- "gemini-1.5-pro-preview-0409",
20
- "gemini-1.5-flash", # ✅ Now supports structured outputs
21
- "gemini-1.5-flash-8b",
22
18
  # Gemini 2.0 series
23
19
  "gemini-2.0-flash",
24
- "gemini-2.0-flash-001",
25
- # Gemini 2.5 series
20
+ "gemini-2.0-flash-lite",
21
+ # Gemini 2.5 series (current)
26
22
  "gemini-2.5-pro",
27
- "gemini-2.5-flash",
28
- "gemini-2.5-flash-lite"
23
+ "gemini-2.5-flash",
24
+ "gemini-2.5-flash-lite",
25
+ "gemini-2.5-flash-image"
29
26
  ].freeze, T::Array[String])
30
27
 
31
- # Models that do not support structured outputs (legacy only)
28
+ # Models that do not support structured outputs or are deprecated
32
29
  UNSUPPORTED_MODELS = T.let([
33
- # Legacy Gemini 1.0 series only
34
- "gemini-pro",
30
+ # Legacy Gemini 1.0 series
31
+ "gemini-pro",
35
32
  "gemini-1.0-pro-002",
36
- "gemini-1.0-pro"
33
+ "gemini-1.0-pro",
34
+ # Deprecated Gemini 1.5 series (removed Oct 2025)
35
+ "gemini-1.5-pro",
36
+ "gemini-1.5-pro-preview-0514",
37
+ "gemini-1.5-pro-preview-0409",
38
+ "gemini-1.5-flash",
39
+ "gemini-1.5-flash-8b"
37
40
  ].freeze, T::Array[String])
38
41
 
39
42
  sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T::Hash[Symbol, T.untyped]) }
@@ -111,7 +114,13 @@ module DSPy
111
114
  case property_schema[:type]
112
115
  when "string"
113
116
  result = { type: "string" }
114
- result[:enum] = property_schema[:enum] if property_schema[:enum]
117
+ # Gemini responseJsonSchema doesn't support const, so convert to single-value enum
118
+ # See: https://ai.google.dev/api/generate-content#FIELDS.response_json_schema
119
+ if property_schema[:const]
120
+ result[:enum] = [property_schema[:const]]
121
+ elsif property_schema[:enum]
122
+ result[:enum] = property_schema[:enum]
123
+ end
115
124
  result
116
125
  when "integer"
117
126
  { type: "integer" }
@@ -102,11 +102,6 @@ module DSPy
102
102
  type: "tool",
103
103
  name: "json_output"
104
104
  }
105
-
106
- # Update last user message
107
- if messages.any? && messages.last[:role] == "user"
108
- messages.last[:content] += "\n\nPlease use the json_output tool to provide your response."
109
- end
110
105
  end
111
106
 
112
107
  # Gemini preparation
data/lib/dspy/lm.rb CHANGED
@@ -26,19 +26,21 @@ require_relative 'lm/json_strategy'
26
26
  # Load message builder and message types
27
27
  require_relative 'lm/message'
28
28
  require_relative 'lm/message_builder'
29
+ require_relative 'structured_outputs_prompt'
29
30
 
30
31
  module DSPy
31
32
  class LM
32
33
  extend T::Sig
33
- attr_reader :model_id, :api_key, :model, :provider, :adapter
34
+ attr_reader :model_id, :api_key, :model, :provider, :adapter, :schema_format
34
35
 
35
- def initialize(model_id, api_key: nil, **options)
36
+ def initialize(model_id, api_key: nil, schema_format: :json, **options)
36
37
  @model_id = model_id
37
38
  @api_key = api_key
38
-
39
+ @schema_format = schema_format
40
+
39
41
  # Parse provider and model from model_id
40
42
  @provider, @model = parse_model_id(model_id)
41
-
43
+
42
44
  # Create appropriate adapter with options
43
45
  @adapter = AdapterFactory.create(model_id, api_key: api_key, **options)
44
46
  end
@@ -176,26 +178,53 @@ module DSPy
176
178
 
177
179
  def build_messages(inference_module, input_values)
178
180
  messages = []
179
-
181
+
182
+ # Determine if structured outputs will be used and wrap prompt if so
183
+ base_prompt = inference_module.prompt
184
+ prompt = if will_use_structured_outputs?(inference_module.signature_class)
185
+ StructuredOutputsPrompt.new(**base_prompt.to_h)
186
+ else
187
+ base_prompt
188
+ end
189
+
180
190
  # Add system message
181
- system_prompt = inference_module.system_signature
191
+ system_prompt = prompt.render_system_prompt
182
192
  if system_prompt
183
193
  messages << Message.new(
184
194
  role: Message::Role::System,
185
195
  content: system_prompt
186
196
  )
187
197
  end
188
-
198
+
189
199
  # Add user message
190
- user_prompt = inference_module.user_signature(input_values)
200
+ user_prompt = prompt.render_user_prompt(input_values)
191
201
  messages << Message.new(
192
202
  role: Message::Role::User,
193
203
  content: user_prompt
194
204
  )
195
-
205
+
196
206
  messages
197
207
  end
198
208
 
209
+ def will_use_structured_outputs?(signature_class)
210
+ return false unless signature_class
211
+
212
+ adapter_class_name = adapter.class.name
213
+
214
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
215
+ adapter.instance_variable_get(:@structured_outputs_enabled) &&
216
+ DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(adapter.model)
217
+ elsif adapter_class_name.include?('GeminiAdapter')
218
+ adapter.instance_variable_get(:@structured_outputs_enabled) &&
219
+ DSPy::LM::Adapters::Gemini::SchemaConverter.supports_structured_outputs?(adapter.model)
220
+ elsif adapter_class_name.include?('AnthropicAdapter')
221
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
222
+ structured_outputs_enabled.nil? ? true : structured_outputs_enabled
223
+ else
224
+ false
225
+ end
226
+ end
227
+
199
228
  def parse_response(response, input_values, signature_class)
200
229
  # Try to parse the response as JSON
201
230
  content = response.content
@@ -208,26 +208,26 @@ module DSPy
208
208
  sig { params(value: T.untyped, union_type: T.untyped).returns(T.untyped) }
209
209
  def coerce_union_value(value, union_type)
210
210
  return value unless value.is_a?(Hash)
211
-
211
+
212
212
  # Check for _type discriminator field
213
213
  type_name = value[:_type] || value["_type"]
214
214
  return value unless type_name
215
-
215
+
216
216
  # Find matching struct type in the union
217
217
  union_type.types.each do |type|
218
218
  next if type == T::Utils.coerce(NilClass)
219
-
219
+
220
220
  if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
221
221
  struct_name = type.raw_type.name.split('::').last
222
222
  if struct_name == type_name
223
223
  # Convert string keys to symbols and remove _type
224
224
  symbolized_hash = value.transform_keys(&:to_sym)
225
225
  symbolized_hash.delete(:_type)
226
-
226
+
227
227
  # Coerce struct field values based on their types
228
228
  struct_class = type.raw_type
229
229
  struct_props = struct_class.props
230
-
230
+
231
231
  # ONLY include fields that exist in the struct
232
232
  coerced_hash = {}
233
233
  struct_props.each_key do |key|
@@ -236,13 +236,13 @@ module DSPy
236
236
  coerced_hash[key] = coerce_value_to_type(symbolized_hash[key], prop_type)
237
237
  end
238
238
  end
239
-
239
+
240
240
  # Create the struct instance with coerced values
241
241
  return struct_class.new(**coerced_hash)
242
242
  end
243
243
  end
244
244
  end
245
-
245
+
246
246
  # If no matching type found, return original value
247
247
  value
248
248
  rescue ArgumentError => e
data/lib/dspy/module.rb CHANGED
@@ -3,16 +3,23 @@
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
5
  require_relative 'context'
6
+ require_relative 'callbacks'
6
7
 
7
8
  module DSPy
8
9
  class Module
9
10
  extend T::Sig
10
11
  extend T::Generic
11
12
  include Dry::Configurable
13
+ include DSPy::Callbacks
12
14
 
13
15
  # Per-instance LM configuration
14
16
  setting :lm, default: nil
15
17
 
18
+ # Define callback hooks for forward method
19
+ create_before_callback :forward
20
+ create_after_callback :forward
21
+ create_around_callback :forward
22
+
16
23
  # The main forward method that users will call is generic and type parameterized
17
24
  sig do
18
25
  type_parameters(:I, :O)
@@ -72,5 +79,31 @@ module DSPy
72
79
  def lm
73
80
  config.lm || DSPy.current_lm
74
81
  end
82
+
83
+ # Save the module state to a JSON file
84
+ # Lightweight serialization for intermediate optimization trials
85
+ #
86
+ # @param path [String] Path to save the module state (JSON format)
87
+ sig { params(path: String).void }
88
+ def save(path)
89
+ require 'json'
90
+ require 'fileutils'
91
+
92
+ # Ensure parent directory exists
93
+ dir = File.dirname(path)
94
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
95
+
96
+ # Serialize module to JSON
97
+ File.write(path, JSON.pretty_generate(to_h))
98
+ end
99
+
100
+ # Default serialization method - subclasses can override
101
+ sig { returns(T::Hash[Symbol, T.untyped]) }
102
+ def to_h
103
+ {
104
+ class_name: self.class.name,
105
+ state: {}
106
+ }
107
+ end
75
108
  end
76
109
  end
data/lib/dspy/predict.rb CHANGED
@@ -53,11 +53,18 @@ module DSPy
53
53
  sig { returns(Prompt) }
54
54
  attr_reader :prompt
55
55
 
56
+ # Mutable demos attribute for MIPROv2 compatibility
57
+ sig { returns(T.nilable(T::Array[FewShotExample])) }
58
+ attr_accessor :demos
59
+
56
60
  sig { params(signature_class: T.class_of(Signature)).void }
57
61
  def initialize(signature_class)
58
62
  super()
59
63
  @signature_class = signature_class
64
+
65
+ # Prompt will read schema_format from config automatically
60
66
  @prompt = Prompt.from_signature(signature_class)
67
+ @demos = nil
61
68
  end
62
69
 
63
70
  # Reconstruct program from serialized hash