dspy 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 564abfdf924b5e3aa3c002a9210a3e1ef73f2d418e80bf58a4038eecee65219a
4
- data.tar.gz: c7c8c1b2338d09636a5cca0b9bab7b12affe91d22d5edfb25031b9b4740ba68c
3
+ metadata.gz: 7efcb1d5900858477fe538790a6d132a8e289260bb326cee5f30316358fa80f0
4
+ data.tar.gz: c4116d5f2e01e34e23b6bd3baefd5f7480af214284410c77255d4f37fc099743
5
5
  SHA512:
6
- metadata.gz: d81c9af7ac29706395e20db912af207cfc02f24d5777e247615ad94ebaa07a5f352df7ce87cac0947acb09a9f9d1e1094950c835a47fda5d2441114bdedabbd2
7
- data.tar.gz: 9366f67dffe4baa183f40705e8fc5f8f781b5f87e7aca3f0f77f954bb1009edefd773ce68d704a5e3663a0518f940e2ba51cc9b8a6c5c591e4309a7b325d4d51
6
+ metadata.gz: 487725527cfbe3b91d5694999aecdd6fe916940bb9ed42c2a222346b0f1b09ec7b9cb0bdf0f6509a542d72a07bfd4dab66dc879b6bf76d2dc1ebb7e61064dfa7
7
+ data.tar.gz: d3674ea0fe89d498d31b5be92e1a24458bfec0c79f55aef061869cf3a7014f2b5ab67ffac24226c6783f3ea0e485e16bdc16a1dafeef906f602241d8fd7077b7
data/README.md CHANGED
@@ -25,12 +25,13 @@ The result? LLM applications that actually scale and don't break when you sneeze
25
25
  - **Basic Optimization** - Simple prompt optimization techniques
26
26
 
27
27
  **Production Features:**
28
- - **Reliable JSON Extraction** - Automatic strategy selection for OpenAI structured outputs, Anthropic patterns, and fallback modes
28
+ - **Reliable JSON Extraction** - Native OpenAI structured outputs, Anthropic extraction patterns, and automatic strategy selection with fallback
29
+ - **Type-Safe Configuration** - Strategy enums with automatic provider optimization (Strict/Compatible modes)
29
30
  - **Smart Retry Logic** - Progressive fallback with exponential backoff for handling transient failures
30
31
  - **Performance Caching** - Schema and capability caching for faster repeated operations
31
- - **File-based Storage** - Basic optimization result persistence
32
+ - **File-based Storage** - Optimization result persistence with versioning
32
33
  - **Multi-Platform Observability** - OpenTelemetry, New Relic, and Langfuse integration
33
- - **Basic Instrumentation** - Event tracking and logging
34
+ - **Comprehensive Instrumentation** - Event tracking, performance monitoring, and detailed logging
34
35
 
35
36
  **Developer Experience:**
36
37
  - LLM provider support using official Ruby clients:
@@ -40,20 +41,56 @@ The result? LLM applications that actually scale and don't break when you sneeze
40
41
  - Type-safe tool definitions for ReAct agents
41
42
  - Comprehensive instrumentation and observability
42
43
 
43
- ## Fair Warning
44
+ ## Development Status
44
45
 
45
- This is fresh off the oven and evolving fast. I'm actively building this as a Ruby port of the [DSPy library](https://dspy.ai/). If you hit bugs or want to contribute, just email me directly!
46
+ DSPy.rb is actively developed and approaching stability at **v0.9.0**. The core framework is production-ready with comprehensive documentation, but I'm battle-testing features through the 0.x series before committing to a stable v1.0 API.
47
+
48
+ Real-world usage feedback is invaluable - if you encounter issues or have suggestions, please open a GitHub issue!
46
49
 
47
50
  ## Quick Start
48
51
 
49
52
  ### Installation
50
53
 
51
- Skip the gem for now - install straight from this repo while I prep the first release:
54
+ ```ruby
55
+ gem 'dspy', '~> 0.9'
56
+ ```
57
+
58
+ Or add to your Gemfile:
52
59
 
53
60
  ```ruby
54
- gem 'dspy', github: 'vicentereig/dspy.rb'
61
+ gem 'dspy'
62
+ ```
63
+
64
+ Then run:
65
+
66
+ ```bash
67
+ bundle install
68
+ ```
69
+
70
+ #### System Dependencies for Ubuntu/Pop!_OS
71
+
72
+ If you need to compile the `polars-df` dependency from source (used for data processing in evaluations), install these system packages:
73
+
74
+ ```bash
75
+ # Update package list
76
+ sudo apt-get update
77
+
78
+ # Install Ruby development files (if not already installed)
79
+ sudo apt-get install ruby-full ruby-dev
80
+
81
+ # Install essential build tools
82
+ sudo apt-get install build-essential
83
+
84
+ # Install Rust and Cargo (required for polars-df compilation)
85
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
86
+ source $HOME/.cargo/env
87
+
88
+ # Install CMake (often needed for Rust projects)
89
+ sudo apt-get install cmake
55
90
  ```
56
91
 
92
+ **Note**: The `polars-df` gem compilation can take 15-20 minutes. Pre-built binaries are available for most platforms, so compilation is only needed if a pre-built binary isn't available for your system.
93
+
57
94
  ### Your First DSPy Program
58
95
 
59
96
  ```ruby
@@ -124,25 +161,30 @@ puts result.confidence # => 0.85
124
161
  - **[RAG Patterns](docs/src/advanced/rag.md)** - Manual RAG implementation with external services
125
162
  - **[Custom Metrics](docs/src/advanced/custom-metrics.md)** - Proc-based evaluation logic
126
163
 
127
- ## What's Next
128
-
129
- These are my goals to release v1.0.
130
-
131
- - ✅ Prompt objects foundation - *Done*
132
- - ✅ Evaluation framework - *Done*
133
- - ✅ Teleprompter base classes - *Done*
134
- - ✅ MIPROv2 optimization algorithm - *Done*
135
- - ✅ Storage & persistence system - *Done*
136
- - ✅ Registry & version management - *Done*
137
- - ✅ OpenTelemetry integration - *Done*
138
- - New Relic integration - *Done*
139
- - ✅ Langfuse integration - *Done*
140
- - 🚧 Ollama support
141
- - Context Engineering (see recent research: [How Contexts Fail](https://www.dbreunig.com/2025/06/22/how-contexts-fail-and-how-to-fix-them.html), [How to Fix Your Context](https://www.dbreunig.com/2025/06/26/how-to-fix-your-context.html), [Context Engineering](https://simonwillison.net/2025/Jun/27/context-engineering/))
142
- - Agentic Memory support
143
- - MCP Support
144
- - Documentation website
145
- - Performance benchmarks
164
+ ## Recent Achievements
165
+
166
+ DSPy.rb has rapidly evolved from experimental to production-ready:
167
+
168
+ - ✅ **JSON Parsing Reliability** (v0.8.0) - Native OpenAI structured outputs, strategy selection, retry logic
169
+ - ✅ **Type-Safe Strategy Configuration** (v0.9.0) - Provider-optimized automatic strategy selection
170
+ - ✅ **Documentation Website** (v0.6.4) - Comprehensive docs at [vicentereig.github.io/dspy.rb](https://vicentereig.github.io/dspy.rb)
171
+ - ✅ **Production Observability** - OpenTelemetry, New Relic, and Langfuse integration
172
+ - ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
173
+ - ✅ **Core Module System** - Predict, ChainOfThought, ReAct, CodeAct with type safety
174
+
175
+ ## Roadmap - Battle-Testing Toward v1.0
176
+
177
+ DSPy.rb is currently at **v0.9.0** and approaching stability. I'm focusing on real-world usage and refinement through the 0.10, 0.11, 0.12+ series before committing to a stable v1.0 API.
178
+
179
+ **Current Focus Areas:**
180
+ - 🚧 **Ollama Support** - Local model integration
181
+ - 🚧 **Context Engineering** - Advanced prompt optimization techniques
182
+ - 🚧 **MCP Support** - Model Context Protocol integration
183
+ - 🚧 **Agentic Memory** - Persistent agent state management
184
+ - 🚧 **Performance Optimization** - Based on production usage patterns
185
+
186
+ **v1.0 Philosophy:**
187
+ v1.0 will be released after extensive production battle-testing, not after checking off features. This ensures a stable, reliable API backed by real-world validation.
146
188
 
147
189
  ## License
148
190
 
data/lib/dspy/example.rb CHANGED
@@ -192,8 +192,28 @@ module DSPy
192
192
  # String representation for debugging
193
193
  sig { returns(String) }
194
194
  def to_s
195
- "DSPy::Example(#{@signature_class.name}) input=#{input_values} expected=#{expected_values}"
195
+ "DSPy::Example(#{@signature_class.name}) input=#{format_hash(input_values)} expected=#{format_hash(expected_values)}"
196
196
  end
197
+
198
+ private
199
+
200
+ # Format hash without escaping Unicode characters
201
+ sig { params(hash: T::Hash[Symbol, T.untyped]).returns(String) }
202
+ def format_hash(hash)
203
+ pairs = hash.map do |k, v|
204
+ value_str = case v
205
+ when String
206
+ # Don't escape Unicode characters
207
+ "\"#{v}\""
208
+ else
209
+ v.inspect
210
+ end
211
+ ":#{k} => #{value_str}"
212
+ end
213
+ "{#{pairs.join(", ")}}"
214
+ end
215
+
216
+ public
197
217
 
198
218
  sig { returns(String) }
199
219
  def inspect
@@ -0,0 +1,464 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module DSPy
5
+ class Prediction
6
+ extend T::Sig
7
+ extend T::Generic
8
+ include T::Props
9
+ include T::Props::Serializable
10
+
11
+ # The underlying struct that holds the actual data
12
+ sig { returns(T.untyped) }
13
+ attr_reader :_struct
14
+
15
+ # Schema information for type conversion
16
+ sig { returns(T.nilable(T::Class[T::Struct])) }
17
+ attr_reader :_schema
18
+
19
+ sig do
20
+ params(
21
+ schema: T.nilable(T.any(T::Class[T::Struct], T::Types::Base)),
22
+ attributes: T.untyped
23
+ ).void
24
+ end
25
+ def initialize(schema = nil, **attributes)
26
+ @_schema = extract_struct_class(schema)
27
+
28
+ # Convert attributes based on schema if provided
29
+ converted_attributes = if @_schema
30
+ convert_attributes_with_schema(attributes)
31
+ else
32
+ attributes
33
+ end
34
+
35
+ # Create a dynamic struct to hold the data
36
+ struct_class = create_dynamic_struct(converted_attributes)
37
+ @_struct = struct_class.new(**converted_attributes)
38
+ end
39
+
40
+ # Delegate all method calls to the underlying struct
41
+ sig { params(method: Symbol, args: T.untyped, block: T.untyped).returns(T.untyped) }
42
+ def method_missing(method, *args, &block)
43
+ if @_struct.respond_to?(method)
44
+ @_struct.send(method, *args, &block)
45
+ else
46
+ super
47
+ end
48
+ end
49
+
50
+ sig { params(method: Symbol, include_all: T::Boolean).returns(T::Boolean) }
51
+ def respond_to_missing?(method, include_all = false)
52
+ @_struct.respond_to?(method, include_all) || super
53
+ end
54
+
55
+ sig { returns(T::Hash[Symbol, T.untyped]) }
56
+ def to_h
57
+ @_struct.to_h
58
+ end
59
+
60
+ private
61
+
62
+ sig { params(schema: T.untyped).returns(T.nilable(T::Class[T::Struct])) }
63
+ def extract_struct_class(schema)
64
+ case schema
65
+ when Class
66
+ schema if schema < T::Struct
67
+ when T::Types::Simple
68
+ schema.raw_type if schema.raw_type < T::Struct
69
+ else
70
+ nil
71
+ end
72
+ end
73
+
74
+ sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
75
+ def convert_attributes_with_schema(attributes)
76
+ return attributes unless @_schema
77
+
78
+ converted = {}
79
+
80
+ # Get discriminator mappings for T.any() fields
81
+ discriminator_mappings = detect_discriminator_fields(@_schema)
82
+
83
+ # First, add all the fields from the schema with defaults if not provided
84
+ @_schema.props.each do |field_name, prop_info|
85
+ # Skip if attribute was provided
86
+ next if attributes.key?(field_name)
87
+
88
+ # Apply default value if available
89
+ default_value = prop_info[:default]
90
+ if !default_value.nil?
91
+ if default_value.is_a?(Proc)
92
+ converted[field_name] = default_value.call
93
+ else
94
+ converted[field_name] = default_value
95
+ end
96
+ elsif prop_info[:fully_optional]
97
+ # For optional fields without defaults, set to nil
98
+ converted[field_name] = nil
99
+ end
100
+ end
101
+
102
+ attributes.each do |key, value|
103
+ prop_info = @_schema.props[key]
104
+
105
+ if prop_info && discriminator_mappings[key]
106
+ # This is a T.any() field with a discriminator
107
+ discriminator_field, type_mapping = discriminator_mappings[key]
108
+ discriminator_value = attributes[discriminator_field]
109
+ prop_type = prop_info[:type_object] || prop_info[:type]
110
+
111
+ converted[key] = convert_union_type(value, discriminator_value, type_mapping, prop_type)
112
+ elsif prop_info
113
+ prop_type = prop_info[:type_object] || prop_info[:type]
114
+
115
+ # Handle nil values explicitly
116
+ if value.nil?
117
+ # Check if there's a default value
118
+ default_value = prop_info[:default]
119
+ if !default_value.nil?
120
+ converted[key] = default_value.is_a?(Proc) ? default_value.call : default_value
121
+ else
122
+ converted[key] = nil
123
+ end
124
+ elsif is_enum_type?(prop_type) && value.is_a?(String)
125
+ # Convert string to enum
126
+ converted[key] = prop_type.raw_type.deserialize(value)
127
+ elsif value.is_a?(Hash) && needs_struct_conversion?(prop_type)
128
+ # Regular struct field that needs conversion
129
+ converted[key] = convert_to_struct(value, prop_type)
130
+ elsif value.is_a?(Array) && needs_array_conversion?(prop_type)
131
+ # Array field that might contain structs
132
+ converted[key] = convert_array_elements(value, prop_type)
133
+ else
134
+ converted[key] = value
135
+ end
136
+ else
137
+ converted[key] = value
138
+ end
139
+ end
140
+
141
+ converted
142
+ end
143
+
144
+ sig { params(schema: T::Class[T::Struct]).returns(T::Hash[Symbol, [Symbol, T::Hash[String, T.untyped]]]) }
145
+ def detect_discriminator_fields(schema)
146
+ discriminator_mappings = {}
147
+ props = schema.props.to_a
148
+
149
+ props.each_with_index do |(prop_name, prop_info), index|
150
+ prop_type = prop_info[:type_object] || prop_info[:type]
151
+ next unless is_union_type?(prop_type)
152
+
153
+ # Look for preceding String or Enum field as potential discriminator
154
+ if index > 0
155
+ prev_prop_name, prev_prop_info = props[index - 1]
156
+ prev_prop_type = prev_prop_info[:type_object] || prev_prop_info[:type]
157
+ if prev_prop_type && (is_string_type?(prev_prop_type) || is_enum_type?(prev_prop_type))
158
+ # This String/Enum field might be a discriminator
159
+ type_mapping = build_type_mapping_from_union(prop_type, prev_prop_type)
160
+ discriminator_mappings[prop_name] = [prev_prop_name, type_mapping]
161
+ end
162
+ end
163
+ end
164
+
165
+ discriminator_mappings
166
+ end
167
+
168
+ sig { params(type: T.untyped).returns(T::Boolean) }
169
+ def is_union_type?(type)
170
+ type.is_a?(T::Types::Union) && !is_nilable_type?(type)
171
+ end
172
+
173
+ sig { params(type: T.untyped).returns(T::Boolean) }
174
+ def is_nilable_type?(type)
175
+ type.is_a?(T::Types::Union) && type.types.any? { |t| t == T::Utils.coerce(NilClass) }
176
+ end
177
+
178
+ sig { params(type: T.untyped).returns(T::Boolean) }
179
+ def is_string_type?(type)
180
+ case type
181
+ when T::Types::Simple
182
+ type.raw_type == String
183
+ else
184
+ false
185
+ end
186
+ end
187
+
188
+ sig { params(type: T.untyped).returns(T::Boolean) }
189
+ def is_enum_type?(type)
190
+ return false if type.nil?
191
+ return false unless type.is_a?(T::Types::Simple)
192
+
193
+ begin
194
+ raw_type = type.raw_type
195
+ return false unless raw_type.is_a?(Class)
196
+ result = raw_type < T::Enum
197
+ return result == true # Force conversion to boolean
198
+ rescue StandardError
199
+ return false
200
+ end
201
+ end
202
+
203
+ sig { params(union_type: T::Types::Union, discriminator_type: T.untyped).returns(T::Hash[String, T.untyped]) }
204
+ def build_type_mapping_from_union(union_type, discriminator_type)
205
+ mapping = {}
206
+
207
+ if is_enum_type?(discriminator_type)
208
+ # For enum discriminators, try to map enum values to struct types
209
+ enum_class = discriminator_type.raw_type
210
+ union_type.types.each do |type|
211
+ next if type == T::Utils.coerce(NilClass)
212
+
213
+ if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
214
+ struct_class = type.raw_type
215
+ struct_name = struct_class.name.split("::").last
216
+
217
+ # Try to find matching enum value by name
218
+ enum_class.values.each do |enum_value|
219
+ enum_name = enum_value.instance_variable_get(:@const_name).to_s
220
+ if enum_name == struct_name
221
+ # Exact match
222
+ mapping[enum_value.serialize] = struct_class
223
+ elsif enum_name.downcase == struct_name.downcase
224
+ # Case-insensitive match
225
+ mapping[enum_value.serialize] = struct_class
226
+ end
227
+ end
228
+
229
+ # Also add snake_case mapping as fallback
230
+ discriminator_value = struct_name
231
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
232
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
233
+ .downcase
234
+ mapping[discriminator_value] = struct_class
235
+ end
236
+ end
237
+ else
238
+ # String discriminators use snake_case convention
239
+ union_type.types.each do |type|
240
+ next if type == T::Utils.coerce(NilClass)
241
+
242
+ if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
243
+ struct_class = type.raw_type
244
+ # Convert class name to snake_case for discriminator value
245
+ discriminator_value = struct_class.name
246
+ .split("::").last
247
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
248
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
249
+ .downcase
250
+
251
+ mapping[discriminator_value] = struct_class
252
+ end
253
+ end
254
+ end
255
+
256
+ mapping
257
+ end
258
+
259
+ sig do
260
+ params(
261
+ value: T.untyped,
262
+ discriminator_value: T.untyped,
263
+ type_mapping: T::Hash[String, T.untyped],
264
+ union_type: T.untyped
265
+ ).returns(T.untyped)
266
+ end
267
+ def convert_union_type(value, discriminator_value, type_mapping, union_type)
268
+ return value unless value.is_a?(Hash)
269
+
270
+ # Handle enum discriminators
271
+ discriminator_str = case discriminator_value
272
+ when T::Enum
273
+ discriminator_value.serialize
274
+ when String
275
+ discriminator_value
276
+ else
277
+ return value
278
+ end
279
+
280
+ struct_class = type_mapping[discriminator_str]
281
+ return value unless struct_class
282
+
283
+ # Convert the Hash to the appropriate struct type
284
+ struct_class.new(**value)
285
+ rescue TypeError, ArgumentError
286
+ # If conversion fails, return the original value
287
+ value
288
+ end
289
+
290
+ sig { params(type: T.untyped).returns(T::Boolean) }
291
+ def needs_struct_conversion?(type)
292
+ case type
293
+ when T::Types::Simple
294
+ type.raw_type < T::Struct
295
+ when T::Types::Union
296
+ # Check if any type in the union is a struct
297
+ type.types.any? { |t| needs_struct_conversion?(t) }
298
+ else
299
+ false
300
+ end
301
+ end
302
+
303
+ sig { params(value: T::Hash[Symbol, T.untyped], type: T.untyped).returns(T.untyped) }
304
+ def convert_to_struct(value, type)
305
+ case type
306
+ when T::Types::Simple
307
+ struct_class = type.raw_type
308
+ # Convert nested hash values to structs if needed
309
+ converted_hash = {}
310
+
311
+ # First, apply defaults for missing fields
312
+ struct_class.props.each do |field_name, prop_info|
313
+ next if value.key?(field_name)
314
+
315
+ default_value = prop_info[:default]
316
+ if !default_value.nil?
317
+ converted_hash[field_name] = default_value.is_a?(Proc) ? default_value.call : default_value
318
+ end
319
+ end
320
+
321
+ value.each do |k, v|
322
+ prop_info = struct_class.props[k]
323
+ if prop_info
324
+ prop_type = prop_info[:type_object] || prop_info[:type]
325
+ if v.is_a?(String) && is_enum_type?(prop_type)
326
+ # Convert string to enum
327
+ converted_hash[k] = prop_type.raw_type.deserialize(v)
328
+ elsif v.is_a?(Hash) && needs_struct_conversion?(prop_type)
329
+ converted_hash[k] = convert_to_struct(v, prop_type)
330
+ elsif v.is_a?(Array) && needs_array_conversion?(prop_type)
331
+ converted_hash[k] = convert_array_elements(v, prop_type)
332
+ else
333
+ converted_hash[k] = v
334
+ end
335
+ else
336
+ converted_hash[k] = v
337
+ end
338
+ end
339
+ begin
340
+ struct_class.new(**converted_hash)
341
+ rescue => e
342
+ # Return original value if conversion fails
343
+ value
344
+ end
345
+ when T::Types::Union
346
+ # For unions without discriminator, try each type
347
+ type.types.each do |t|
348
+ next if t == T::Utils.coerce(NilClass)
349
+
350
+ begin
351
+ return convert_to_struct(value, t) if needs_struct_conversion?(t)
352
+ rescue TypeError, ArgumentError
353
+ # Try next type
354
+ end
355
+ end
356
+ value
357
+ else
358
+ value
359
+ end
360
+ rescue TypeError, ArgumentError
361
+ value
362
+ end
363
+
364
+ sig { params(type: T.untyped).returns(T::Boolean) }
365
+ def needs_array_conversion?(type)
366
+ case type
367
+ when T::Types::TypedArray
368
+ needs_struct_conversion?(type.type)
369
+ else
370
+ false
371
+ end
372
+ end
373
+
374
+ sig { params(array: T::Array[T.untyped], type: T.untyped).returns(T::Array[T.untyped]) }
375
+ def convert_array_elements(array, type)
376
+ return array unless type.is_a?(T::Types::TypedArray)
377
+
378
+ element_type = type.type
379
+ # Check if elements need any conversion (structs or enums)
380
+ return array unless needs_struct_conversion?(element_type) || is_enum_type?(element_type)
381
+
382
+ array.map do |element|
383
+ if element.is_a?(Hash)
384
+ # For union types, we need to infer which struct type based on the hash structure
385
+ if is_union_type?(element_type) && !is_nilable_type?(element_type)
386
+ convert_hash_to_union_struct(element, element_type)
387
+ else
388
+ convert_to_struct(element, element_type)
389
+ end
390
+ elsif element.is_a?(String) && is_enum_type?(element_type)
391
+ # Convert string to enum
392
+ element_type.raw_type.deserialize(element)
393
+ else
394
+ element
395
+ end
396
+ end
397
+ end
398
+
399
+ sig { params(hash: T::Hash[Symbol, T.untyped], union_type: T::Types::Union).returns(T.untyped) }
400
+ def convert_hash_to_union_struct(hash, union_type)
401
+ # Try to match the hash structure to one of the union types
402
+ union_type.types.each do |type|
403
+ next if type == T::Utils.coerce(NilClass)
404
+
405
+ if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
406
+ struct_class = type.raw_type
407
+
408
+ # Check if all required fields of this struct are present in the hash
409
+ required_fields = struct_class.props.reject { |_, info| info[:fully_optional] }.keys
410
+ if required_fields.all? { |field| hash.key?(field) }
411
+ begin
412
+ # Need to convert nested values too
413
+ converted_hash = {}
414
+ hash.each do |k, v|
415
+ prop_info = struct_class.props[k]
416
+ if prop_info
417
+ prop_type = prop_info[:type_object] || prop_info[:type]
418
+ if v.is_a?(String) && is_enum_type?(prop_type)
419
+ converted_hash[k] = prop_type.raw_type.deserialize(v)
420
+ elsif v.is_a?(Hash) && needs_struct_conversion?(prop_type)
421
+ converted_hash[k] = convert_to_struct(v, prop_type)
422
+ elsif v.is_a?(Array) && needs_array_conversion?(prop_type)
423
+ converted_hash[k] = convert_array_elements(v, prop_type)
424
+ else
425
+ converted_hash[k] = v
426
+ end
427
+ else
428
+ converted_hash[k] = v
429
+ end
430
+ end
431
+ return struct_class.new(**converted_hash)
432
+ rescue TypeError, ArgumentError
433
+ # This struct didn't match, try the next one
434
+ end
435
+ end
436
+ end
437
+ end
438
+
439
+ # If no struct matched, return the original hash
440
+ hash
441
+ end
442
+
443
+ sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(T::Class[T::Struct]) }
444
+ def create_dynamic_struct(attributes)
445
+ # If we have a schema, include all fields from it in the dynamic struct
446
+ all_fields = if @_schema
447
+ # Merge schema fields with provided attributes
448
+ schema_fields = @_schema.props.keys.to_h { |k| [k, nil] }
449
+ schema_fields.merge(attributes)
450
+ else
451
+ attributes
452
+ end
453
+
454
+ Class.new(T::Struct) do
455
+ const :_prediction_marker, T::Boolean, default: true
456
+
457
+ all_fields.each do |key, value|
458
+ # Use T.untyped for dynamic properties
459
+ const key, T.untyped
460
+ end
461
+ end
462
+ end
463
+ end
464
+ end
data/lib/dspy/re_act.rb CHANGED
@@ -30,18 +30,9 @@ module DSPy
30
30
  }.compact
31
31
  end
32
32
  end
33
- # Defines the signature for ReAct reasoning using Sorbet signatures
34
- class Thought < DSPy::Signature
35
- description "Generate a thought about what to do next to answer the question."
36
-
37
- input do
38
- const :question, String,
39
- description: "The question to answer"
40
- const :history, T::Array[HistoryEntry],
41
- description: "Previous thoughts and actions, including observations from tools. The agent MUST use information from the history to inform its actions and final answer. Each entry is a hash representing a step in the reasoning process."
42
- const :available_tools, T::Array[T::Hash[String, T.untyped]],
43
- description: "Array of available tools with their JSON schemas. The agent MUST choose an action from the tool names in this list or use \"finish\". For each tool, use the name exactly as specified and provide action_input as a JSON object matching the tool's schema."
44
- end
33
+ # Base class for ReAct thought generation - will be customized per input type
34
+ class ThoughtBase < DSPy::Signature
35
+ description "Generate a thought about what to do next to process the given inputs."
45
36
 
46
37
  output do
47
38
  const :thought, String,
@@ -49,7 +40,7 @@ module DSPy
49
40
  const :action, String,
50
41
  description: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
51
42
  const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
52
- description: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final answer to the original question. This answer MUST be directly taken from the relevant Observation in the history if available. For example, if an observation showed \"Observation: 100.0\", and you are finishing, this field MUST be \"100.0\". Do not leave empty if finishing with an observed answer."
43
+ description: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data. This result MUST be directly taken from the relevant Observation in the history if available."
53
44
  end
54
45
  end
55
46
 
@@ -60,19 +51,10 @@ module DSPy
60
51
  end
61
52
  end
62
53
 
63
- # Defines the signature for processing observations and deciding next steps
64
- class ReActObservation < DSPy::Signature
54
+ # Base class for observation processing - will be customized per input type
55
+ class ReActObservationBase < DSPy::Signature
65
56
  description "Process the observation from a tool and decide what to do next."
66
57
 
67
- input do
68
- const :question, String,
69
- description: "The original question"
70
- const :history, T::Array[HistoryEntry],
71
- description: "Previous thoughts, actions, and observations. Each entry is a hash representing a step in the reasoning process."
72
- const :observation, String,
73
- description: "The result from the last action"
74
- end
75
-
76
58
  output do
77
59
  const :interpretation, String,
78
60
  description: "Interpretation of the observation"
@@ -108,11 +90,15 @@ module DSPy
108
90
  tools.each { |tool| @tools[tool.name.downcase] = tool }
109
91
  @max_iterations = max_iterations
110
92
 
93
+ # Create dynamic signature classes that include the original input fields
94
+ thought_signature = create_thought_signature(signature_class)
95
+ observation_signature = create_observation_signature(signature_class)
96
+
111
97
  # Create thought generator using Predict to preserve field descriptions
112
- @thought_generator = T.let(DSPy::Predict.new(Thought), DSPy::Predict)
98
+ @thought_generator = T.let(DSPy::Predict.new(thought_signature), DSPy::Predict)
113
99
 
114
100
  # Create observation processor using Predict to preserve field descriptions
115
- @observation_processor = T.let(DSPy::Predict.new(ReActObservation), DSPy::Predict)
101
+ @observation_processor = T.let(DSPy::Predict.new(observation_signature), DSPy::Predict)
116
102
 
117
103
  # Create enhanced output struct with ReAct fields
118
104
  @enhanced_output_struct = create_enhanced_output_struct(signature_class)
@@ -148,12 +134,11 @@ module DSPy
148
134
  max_iterations: @max_iterations,
149
135
  available_tools: available_tools
150
136
  }) do
151
- # Validate input and extract question
137
+ # Validate input
152
138
  input_struct = @original_signature_class.input_struct_class.new(**kwargs)
153
- question = T.cast(input_struct.serialize.values.first, String)
154
139
 
155
140
  # Execute ReAct reasoning loop
156
- reasoning_result = execute_react_reasoning_loop(question)
141
+ reasoning_result = execute_react_reasoning_loop(input_struct)
157
142
 
158
143
  # Create enhanced output with all ReAct data
159
144
  create_enhanced_result(kwargs, reasoning_result)
@@ -164,9 +149,67 @@ module DSPy
164
149
 
165
150
  private
166
151
 
152
+ # Creates a dynamic Thought signature that includes the original input fields
153
+ sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
154
+ def create_thought_signature(signature_class)
155
+ # Create new class that inherits from DSPy::Signature
156
+ Class.new(DSPy::Signature) do
157
+ # Set description
158
+ description "Generate a thought about what to do next to process the given inputs."
159
+
160
+ # Define input fields
161
+ input do
162
+ const :input_context, String,
163
+ desc: "Serialized representation of all input fields"
164
+ const :history, T::Array[HistoryEntry],
165
+ desc: "Previous thoughts and actions, including observations from tools."
166
+ const :available_tools, T::Array[T::Hash[String, T.untyped]],
167
+ desc: "Array of available tools with their JSON schemas."
168
+ end
169
+
170
+ # Define output fields (same as ThoughtBase)
171
+ output do
172
+ const :thought, String,
173
+ desc: "Reasoning about what to do next, considering the history and observations."
174
+ const :action, String,
175
+ desc: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
176
+ const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
177
+ desc: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data."
178
+ end
179
+ end
180
+ end
181
+
182
+ # Creates a dynamic observation signature that includes the original input fields
183
+ sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
184
+ def create_observation_signature(signature_class)
185
+ # Create new class that inherits from DSPy::Signature
186
+ Class.new(DSPy::Signature) do
187
+ # Set description
188
+ description "Process the observation from a tool and decide what to do next."
189
+
190
+ # Define input fields
191
+ input do
192
+ const :input_context, String,
193
+ desc: "Serialized representation of all input fields"
194
+ const :history, T::Array[HistoryEntry],
195
+ desc: "Previous thoughts, actions, and observations."
196
+ const :observation, String,
197
+ desc: "The result from the last action"
198
+ end
199
+
200
+ # Define output fields (same as ReActObservationBase)
201
+ output do
202
+ const :interpretation, String,
203
+ desc: "Interpretation of the observation"
204
+ const :next_step, NextStep,
205
+ desc: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
206
+ end
207
+ end
208
+ end
209
+
167
210
  # Executes the main ReAct reasoning loop
168
- sig { params(question: String).returns(T::Hash[Symbol, T.untyped]) }
169
- def execute_react_reasoning_loop(question)
211
+ sig { params(input_struct: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
212
+ def execute_react_reasoning_loop(input_struct)
170
213
  history = T.let([], T::Array[HistoryEntry])
171
214
  available_tools_desc = @tools.map { |name, tool| JSON.parse(tool.schema) }
172
215
  final_answer = T.let(nil, T.nilable(String))
@@ -178,7 +221,7 @@ module DSPy
178
221
  iterations_count += 1
179
222
 
180
223
  iteration_result = execute_single_iteration(
181
- question, history, available_tools_desc, iterations_count, tools_used, last_observation
224
+ input_struct, history, available_tools_desc, iterations_count, tools_used, last_observation
182
225
  )
183
226
 
184
227
  if iteration_result[:should_finish]
@@ -202,8 +245,8 @@ module DSPy
202
245
  end
203
246
 
204
247
  # Executes a single iteration of the ReAct loop
205
- sig { params(question: String, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
206
- def execute_single_iteration(question, history, available_tools_desc, iteration, tools_used, last_observation)
248
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
249
+ def execute_single_iteration(input_struct, history, available_tools_desc, iteration, tools_used, last_observation)
207
250
  # Instrument each iteration
208
251
  Instrumentation.instrument('dspy.react.iteration', {
209
252
  iteration: iteration,
@@ -213,7 +256,7 @@ module DSPy
213
256
  }) do
214
257
  # Generate thought and action
215
258
  thought_obj = @thought_generator.forward(
216
- question: question,
259
+ input_context: input_struct.serialize.to_json,
217
260
  history: history,
218
261
  available_tools: available_tools_desc
219
262
  )
@@ -243,7 +286,7 @@ module DSPy
243
286
 
244
287
  # Process observation and decide next step
245
288
  observation_decision = process_observation_and_decide_next_step(
246
- question, history, observation, available_tools_desc, iteration
289
+ input_struct, history, observation, available_tools_desc, iteration
247
290
  )
248
291
 
249
292
  if observation_decision[:should_finish]
@@ -337,12 +380,12 @@ module DSPy
337
380
  )
338
381
  end
339
382
 
340
- sig { params(question: String, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
341
- def process_observation_and_decide_next_step(question, history, observation, available_tools_desc, iteration)
383
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
384
+ def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
342
385
  return { should_finish: false } if observation.include?("Unknown action")
343
386
 
344
387
  observation_result = @observation_processor.forward(
345
- question: question,
388
+ input_context: input_struct.serialize.to_json,
346
389
  history: history,
347
390
  observation: observation
348
391
  )
@@ -350,16 +393,16 @@ module DSPy
350
393
  return { should_finish: false } unless observation_result.next_step == NextStep::Finish
351
394
 
352
395
  final_answer = generate_forced_final_answer(
353
- question, history, available_tools_desc, observation_result, iteration
396
+ input_struct, history, available_tools_desc, observation_result, iteration
354
397
  )
355
398
 
356
399
  { should_finish: true, final_answer: final_answer }
357
400
  end
358
401
 
359
- sig { params(question: String, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], observation_result: T.untyped, iteration: Integer).returns(String) }
360
- def generate_forced_final_answer(question, history, available_tools_desc, observation_result, iteration)
402
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], observation_result: T.untyped, iteration: Integer).returns(String) }
403
+ def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
361
404
  final_thought = @thought_generator.forward(
362
- question: question,
405
+ input_context: input_struct.serialize.to_json,
363
406
  history: history,
364
407
  available_tools: available_tools_desc
365
408
  )
@@ -147,6 +147,11 @@ module DSPy
147
147
  }
148
148
  end
149
149
 
150
+ sig { returns(T.nilable(T.class_of(T::Struct))) }
151
+ def input_schema
152
+ @input_struct_class
153
+ end
154
+
150
155
  sig { returns(T::Hash[Symbol, T.untyped]) }
151
156
  def output_json_schema
152
157
  return {} unless @output_struct_class
@@ -169,6 +174,11 @@ module DSPy
169
174
  }
170
175
  end
171
176
 
177
+ sig { returns(T.nilable(T.class_of(T::Struct))) }
178
+ def output_schema
179
+ @output_struct_class
180
+ end
181
+
172
182
  private
173
183
 
174
184
  sig { params(type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -123,6 +123,7 @@ require_relative 'dspy/prompt'
123
123
  require_relative 'dspy/example'
124
124
  require_relative 'dspy/lm'
125
125
  require_relative 'dspy/strategy'
126
+ require_relative 'dspy/prediction'
126
127
  require_relative 'dspy/predict'
127
128
  require_relative 'dspy/chain_of_thought'
128
129
  require_relative 'dspy/re_act'
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-07-11 00:00:00.000000000 Z
11
+ date: 2025-07-20 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: dry-configurable
@@ -196,6 +197,7 @@ files:
196
197
  - lib/dspy/mixins/type_coercion.rb
197
198
  - lib/dspy/module.rb
198
199
  - lib/dspy/predict.rb
200
+ - lib/dspy/prediction.rb
199
201
  - lib/dspy/prompt.rb
200
202
  - lib/dspy/propose/grounded_proposer.rb
201
203
  - lib/dspy/re_act.rb
@@ -225,6 +227,7 @@ homepage: https://github.com/vicentereig/dspy.rb
225
227
  licenses:
226
228
  - MIT
227
229
  metadata: {}
230
+ post_install_message:
228
231
  rdoc_options: []
229
232
  require_paths:
230
233
  - lib
@@ -239,7 +242,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
239
242
  - !ruby/object:Gem::Version
240
243
  version: '0'
241
244
  requirements: []
242
- rubygems_version: 3.6.5
245
+ rubygems_version: 3.5.22
246
+ signing_key:
243
247
  specification_version: 4
244
248
  summary: Ruby port of DSPy 2.6
245
249
  test_files: []