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 +4 -4
- data/README.md +68 -26
- data/lib/dspy/example.rb +21 -1
- data/lib/dspy/prediction.rb +464 -0
- data/lib/dspy/re_act.rb +86 -43
- data/lib/dspy/signature.rb +10 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +1 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7efcb1d5900858477fe538790a6d132a8e289260bb326cee5f30316358fa80f0
|
4
|
+
data.tar.gz: c4116d5f2e01e34e23b6bd3baefd5f7480af214284410c77255d4f37fc099743
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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** -
|
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** -
|
32
|
+
- **File-based Storage** - Optimization result persistence with versioning
|
32
33
|
- **Multi-Platform Observability** - OpenTelemetry, New Relic, and Langfuse integration
|
33
|
-
- **
|
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
|
-
##
|
44
|
+
## Development Status
|
44
45
|
|
45
|
-
|
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
|
-
|
54
|
+
```ruby
|
55
|
+
gem 'dspy', '~> 0.9'
|
56
|
+
```
|
57
|
+
|
58
|
+
Or add to your Gemfile:
|
52
59
|
|
53
60
|
```ruby
|
54
|
-
gem 'dspy'
|
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
|
-
##
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
- ✅
|
132
|
-
- ✅
|
133
|
-
- ✅
|
134
|
-
- ✅
|
135
|
-
- ✅
|
136
|
-
- ✅
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
-
|
141
|
-
|
142
|
-
|
143
|
-
-
|
144
|
-
-
|
145
|
-
-
|
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
|
-
#
|
34
|
-
class
|
35
|
-
description "Generate a thought about what to do next to
|
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
|
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
|
-
#
|
64
|
-
class
|
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(
|
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(
|
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
|
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(
|
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(
|
169
|
-
def execute_react_reasoning_loop(
|
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
|
-
|
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(
|
206
|
-
def execute_single_iteration(
|
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
|
-
|
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
|
-
|
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(
|
341
|
-
def process_observation_and_decide_next_step(
|
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
|
-
|
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
|
-
|
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(
|
360
|
-
def generate_forced_final_answer(
|
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
|
-
|
405
|
+
input_context: input_struct.serialize.to_json,
|
363
406
|
history: history,
|
364
407
|
available_tools: available_tools_desc
|
365
408
|
)
|
data/lib/dspy/signature.rb
CHANGED
@@ -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
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.
|
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
|
+
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.
|
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: []
|