dspy 0.20.1 → 0.22.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: 3324f833b373e826df1dcdf3f2c3f46011e192e9a30fdf38d7db1b9cc51a7950
4
- data.tar.gz: f6159081bc6429d0c57e6275111d2672bf984f8500be7756afbf8bb17599dd19
3
+ metadata.gz: e7897711c81ee7a4b72dd86a8fbeee9faf4cdf3e398b9878435e0da5750b0fc1
4
+ data.tar.gz: '079cfdea700a852a94d08415bbdbbe2ff4c4e7d61e4dfae5e739fb88dec29c79'
5
5
  SHA512:
6
- metadata.gz: cd81596af2ea0d734550b0827e18b71d4abdd1e3a4c92efb014af665d17bb2a37fb36b747757eb3ad4377d89a435b81bbaa4477ce0fd22b8ed88319ebc8b1f5b
7
- data.tar.gz: d65e68af35f7ced5e97524dd85f1c590900f33b720038f25bbc22995031d10cc5115bdb1b160b153e5fbaa5bf2cb3809dde0520f6bd99a7ac348c0231bf88afa
6
+ metadata.gz: b81f7fefb5727bbdbc5f94b6618ba7de9cbea26de78324d2ed5f29f6aa1722f5a4e0191d99e3a1393962f5fe224198e25fe3b73fe34b59e99bd486f08bab1189
7
+ data.tar.gz: 89515f64f64e681ae9cee83ffdad853b7e29409381351528324047b9a60627ed4721b79dd1943cee1ab3300996521aeb595e7a3ce57c90ad09cda47489acca77
data/README.md CHANGED
@@ -14,6 +14,10 @@ Traditional prompting is like writing code with string concatenation: it works u
14
14
  the programming approach pioneered by [dspy.ai](https://dspy.ai/): instead of crafting fragile prompts, you define modular
15
15
  signatures and let the framework handle the messy details.
16
16
 
17
+ DSPy.rb is an idiomatic Ruby port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). While implementing
18
+ the core concepts of signatures, predictors, and optimization from the original Python library, DSPy.rb embraces Ruby
19
+ conventions and adds Ruby-specific innovations like CodeAct agents and enhanced production instrumentation.
20
+
17
21
  The result? LLM applications that actually scale and don't break when you sneeze.
18
22
 
19
23
  ## Your First DSPy Program
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module Events
5
+ # Base subscriber class for event-driven patterns
6
+ # This provides the foundation for creating custom event subscribers
7
+ #
8
+ # Example usage:
9
+ # class MySubscriber < DSPy::Events::BaseSubscriber
10
+ # def subscribe
11
+ # add_subscription('llm.*') do |event_name, attributes|
12
+ # # Handle LLM events
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # subscriber = MySubscriber.new
18
+ # # subscriber will start receiving events
19
+ # subscriber.unsubscribe # Clean up when done
20
+ class BaseSubscriber
21
+ def initialize
22
+ @subscriptions = []
23
+ end
24
+
25
+ def subscribe
26
+ raise NotImplementedError, "Subclasses must implement #subscribe"
27
+ end
28
+
29
+ def unsubscribe
30
+ @subscriptions.each { |id| DSPy.events.unsubscribe(id) }
31
+ @subscriptions.clear
32
+ end
33
+
34
+ protected
35
+
36
+ def add_subscription(pattern, &block)
37
+ subscription_id = DSPy.events.subscribe(pattern, &block)
38
+ @subscriptions << subscription_id
39
+ subscription_id
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ module Events
7
+ # Base event structure using Sorbet T::Struct
8
+ class Event < T::Struct
9
+ const :name, String
10
+ const :timestamp, Time
11
+ const :attributes, T::Hash[T.any(String, Symbol), T.untyped], default: {}
12
+
13
+ def initialize(name:, timestamp: Time.now, attributes: {})
14
+ super(name: name, timestamp: timestamp, attributes: attributes)
15
+ end
16
+
17
+ def to_attributes
18
+ result = attributes.dup
19
+ result[:timestamp] = timestamp
20
+ result
21
+ end
22
+ end
23
+
24
+ # Token usage structure for LLM events
25
+ class TokenUsage < T::Struct
26
+ const :prompt_tokens, Integer
27
+ const :completion_tokens, Integer
28
+
29
+ def total_tokens
30
+ prompt_tokens + completion_tokens
31
+ end
32
+ end
33
+
34
+ # LLM operation events with semantic conventions
35
+ class LLMEvent < T::Struct
36
+ VALID_PROVIDERS = T.let(
37
+ ['openai', 'anthropic', 'google', 'azure', 'ollama', 'together', 'groq', 'cohere'].freeze,
38
+ T::Array[String]
39
+ )
40
+
41
+ # Common event fields
42
+ const :name, String
43
+ const :timestamp, Time
44
+
45
+ # LLM-specific fields
46
+ const :provider, String
47
+ const :model, String
48
+ const :usage, T.nilable(TokenUsage), default: nil
49
+ const :duration_ms, T.nilable(Numeric), default: nil
50
+ const :temperature, T.nilable(Float), default: nil
51
+ const :max_tokens, T.nilable(Integer), default: nil
52
+ const :stream, T.nilable(T::Boolean), default: nil
53
+
54
+ def initialize(name:, provider:, model:, timestamp: Time.now, usage: nil, duration_ms: nil, temperature: nil, max_tokens: nil, stream: nil)
55
+ unless VALID_PROVIDERS.include?(provider.downcase)
56
+ raise ArgumentError, "Invalid provider '#{provider}'. Must be one of: #{VALID_PROVIDERS.join(', ')}"
57
+ end
58
+ super(
59
+ name: name,
60
+ timestamp: timestamp,
61
+ provider: provider.downcase,
62
+ model: model,
63
+ usage: usage,
64
+ duration_ms: duration_ms,
65
+ temperature: temperature,
66
+ max_tokens: max_tokens,
67
+ stream: stream
68
+ )
69
+ end
70
+
71
+ def to_otel_attributes
72
+ attrs = {
73
+ 'gen_ai.system' => provider,
74
+ 'gen_ai.request.model' => model
75
+ }
76
+
77
+ if usage
78
+ attrs['gen_ai.usage.prompt_tokens'] = usage.prompt_tokens
79
+ attrs['gen_ai.usage.completion_tokens'] = usage.completion_tokens
80
+ attrs['gen_ai.usage.total_tokens'] = usage.total_tokens
81
+ end
82
+
83
+ attrs['gen_ai.request.temperature'] = temperature if temperature
84
+ attrs['gen_ai.request.max_tokens'] = max_tokens if max_tokens
85
+ attrs['gen_ai.request.stream'] = stream if stream
86
+ attrs['duration_ms'] = duration_ms if duration_ms
87
+
88
+ attrs
89
+ end
90
+
91
+ def to_attributes
92
+ result = to_otel_attributes.dup
93
+ result[:timestamp] = timestamp
94
+ result[:provider] = provider
95
+ result[:model] = model
96
+ result[:duration_ms] = duration_ms if duration_ms
97
+ result
98
+ end
99
+ end
100
+
101
+ # DSPy module execution events
102
+ class ModuleEvent < T::Struct
103
+ # Common event fields
104
+ const :name, String
105
+ const :timestamp, Time
106
+
107
+ # Module-specific fields
108
+ const :module_name, String
109
+ const :signature_name, T.nilable(String), default: nil
110
+ const :input_fields, T.nilable(T::Array[String]), default: nil
111
+ const :output_fields, T.nilable(T::Array[String]), default: nil
112
+ const :duration_ms, T.nilable(Numeric), default: nil
113
+ const :success, T.nilable(T::Boolean), default: nil
114
+
115
+ def initialize(name:, module_name:, timestamp: Time.now, signature_name: nil, input_fields: nil, output_fields: nil, duration_ms: nil, success: nil)
116
+ super(
117
+ name: name,
118
+ timestamp: timestamp,
119
+ module_name: module_name,
120
+ signature_name: signature_name,
121
+ input_fields: input_fields,
122
+ output_fields: output_fields,
123
+ duration_ms: duration_ms,
124
+ success: success
125
+ )
126
+ end
127
+
128
+ def to_attributes
129
+ result = { timestamp: timestamp }
130
+ result[:module_name] = module_name
131
+ result[:signature_name] = signature_name if signature_name
132
+ result[:input_fields] = input_fields if input_fields
133
+ result[:output_fields] = output_fields if output_fields
134
+ result[:duration_ms] = duration_ms if duration_ms
135
+ result[:success] = success if success
136
+ result
137
+ end
138
+ end
139
+
140
+ # Optimization and training events
141
+ class OptimizationEvent < T::Struct
142
+ # Common event fields
143
+ const :name, String
144
+ const :timestamp, Time
145
+
146
+ # Optimization-specific fields
147
+ const :optimizer_name, String
148
+ const :trial_number, T.nilable(Integer), default: nil
149
+ const :score, T.nilable(Float), default: nil
150
+ const :best_score, T.nilable(Float), default: nil
151
+ const :parameters, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
152
+ const :duration_ms, T.nilable(Numeric), default: nil
153
+
154
+ def initialize(name:, optimizer_name:, timestamp: Time.now, trial_number: nil, score: nil, best_score: nil, parameters: nil, duration_ms: nil)
155
+ super(
156
+ name: name,
157
+ timestamp: timestamp,
158
+ optimizer_name: optimizer_name,
159
+ trial_number: trial_number,
160
+ score: score,
161
+ best_score: best_score,
162
+ parameters: parameters,
163
+ duration_ms: duration_ms
164
+ )
165
+ end
166
+
167
+ def to_attributes
168
+ result = { timestamp: timestamp }
169
+ result[:optimizer_name] = optimizer_name
170
+ result[:trial_number] = trial_number if trial_number
171
+ result[:score] = score if score
172
+ result[:best_score] = best_score if best_score
173
+ result[:parameters] = parameters if parameters
174
+ result[:duration_ms] = duration_ms if duration_ms
175
+ result
176
+ end
177
+ end
178
+
179
+ # Evaluation events
180
+ class EvaluationEvent < T::Struct
181
+ # Common event fields
182
+ const :name, String
183
+ const :timestamp, Time
184
+
185
+ # Evaluation-specific fields
186
+ const :evaluator_name, String
187
+ const :metric_name, T.nilable(String), default: nil
188
+ const :score, T.nilable(Float), default: nil
189
+ const :total_examples, T.nilable(Integer), default: nil
190
+ const :passed_examples, T.nilable(Integer), default: nil
191
+ const :duration_ms, T.nilable(Numeric), default: nil
192
+
193
+ def initialize(name:, evaluator_name:, timestamp: Time.now, metric_name: nil, score: nil, total_examples: nil, passed_examples: nil, duration_ms: nil)
194
+ super(
195
+ name: name,
196
+ timestamp: timestamp,
197
+ evaluator_name: evaluator_name,
198
+ metric_name: metric_name,
199
+ score: score,
200
+ total_examples: total_examples,
201
+ passed_examples: passed_examples,
202
+ duration_ms: duration_ms
203
+ )
204
+ end
205
+
206
+ def to_attributes
207
+ result = { timestamp: timestamp }
208
+ result[:evaluator_name] = evaluator_name
209
+ result[:metric_name] = metric_name if metric_name
210
+ result[:score] = score if score
211
+ result[:total_examples] = total_examples if total_examples
212
+ result[:passed_examples] = passed_examples if passed_examples
213
+ result[:duration_ms] = duration_ms if duration_ms
214
+ result
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module DSPy
6
+ # Events module to hold typed event structures
7
+ module Events
8
+ # Will be defined in events/types.rb
9
+ end
10
+
11
+ class EventRegistry
12
+ def initialize
13
+ @listeners = {}
14
+ @subscription_counter = 0
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def subscribe(pattern, &block)
19
+ return unless block_given?
20
+
21
+ subscription_id = SecureRandom.uuid
22
+ @mutex.synchronize do
23
+ @listeners[subscription_id] = {
24
+ pattern: pattern,
25
+ block: block
26
+ }
27
+ end
28
+
29
+ subscription_id
30
+ end
31
+
32
+ def unsubscribe(subscription_id)
33
+ @mutex.synchronize do
34
+ @listeners.delete(subscription_id)
35
+ end
36
+ end
37
+
38
+ def clear_listeners
39
+ @mutex.synchronize do
40
+ @listeners.clear
41
+ end
42
+ end
43
+
44
+ def notify(event_name, attributes)
45
+ # Take a snapshot of current listeners to avoid holding the mutex during execution
46
+ # This allows listeners to be modified while others are executing
47
+ matching_listeners = @mutex.synchronize do
48
+ @listeners.select do |id, listener|
49
+ pattern_matches?(listener[:pattern], event_name)
50
+ end.dup # Create a copy to avoid shared state
51
+ end
52
+
53
+ matching_listeners.each do |id, listener|
54
+ begin
55
+ listener[:block].call(event_name, attributes)
56
+ rescue => e
57
+ # Log the error but continue processing other listeners
58
+ # Use emit_log directly to avoid infinite recursion
59
+ DSPy.send(:emit_log, 'event.listener.error', {
60
+ subscription_id: id,
61
+ error_class: e.class.name,
62
+ error_message: e.message,
63
+ event_name: event_name
64
+ })
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def pattern_matches?(pattern, event_name)
72
+ if pattern.include?('*')
73
+ # Convert wildcard pattern to regex
74
+ # llm.* becomes ^llm\..*$
75
+ regex_pattern = "^#{Regexp.escape(pattern).gsub('\\*', '.*')}$"
76
+ Regexp.new(regex_pattern).match?(event_name)
77
+ else
78
+ # Exact match
79
+ pattern == event_name
80
+ end
81
+ end
82
+ end
83
+ end
@@ -114,24 +114,55 @@ module DSPy
114
114
 
115
115
  example = {}
116
116
  schema[:properties].each do |field_name, field_schema|
117
- example[field_name.to_s] = case field_schema[:type]
118
- when "string"
119
- field_schema[:description] || "example string"
120
- when "integer"
121
- 42
122
- when "number"
123
- 3.14
124
- when "boolean"
125
- true
126
- when "array"
117
+ example[field_name.to_s] = generate_example_value(field_schema)
118
+ end
119
+ example
120
+ end
121
+
122
+ sig { params(field_schema: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
123
+ def generate_example_value(field_schema)
124
+ case field_schema[:type]
125
+ when "string"
126
+ field_schema[:description] || "example string"
127
+ when "integer"
128
+ 42
129
+ when "number"
130
+ 3.14
131
+ when "boolean"
132
+ true
133
+ when "array"
134
+ if field_schema[:items]
135
+ [generate_example_value(field_schema[:items])]
136
+ else
127
137
  ["example item"]
128
- when "object"
138
+ end
139
+ when "object"
140
+ if field_schema[:properties]
141
+ # Generate proper nested object example
142
+ nested_example = {}
143
+ field_schema[:properties].each do |prop_name, prop_schema|
144
+ nested_example[prop_name.to_s] = generate_example_value(prop_schema)
145
+ end
146
+ nested_example
147
+ else
129
148
  { "nested" => "object" }
149
+ end
150
+ when Array
151
+ # Handle union types like ["object", "null"]
152
+ if field_schema[:type].include?("object") && field_schema[:properties]
153
+ nested_example = {}
154
+ field_schema[:properties].each do |prop_name, prop_schema|
155
+ nested_example[prop_name.to_s] = generate_example_value(prop_schema)
156
+ end
157
+ nested_example
158
+ elsif field_schema[:type].include?("string")
159
+ "example string"
130
160
  else
131
161
  "example value"
132
162
  end
163
+ else
164
+ "example value"
133
165
  end
134
- example
135
166
  end
136
167
 
137
168
  sig { params(content: String).returns(T::Boolean) }
@@ -188,6 +188,11 @@ module DSPy
188
188
  return { type: "boolean" }
189
189
  end
190
190
 
191
+ # Handle type aliases by resolving to their underlying type
192
+ if type.is_a?(T::Private::Types::TypeAlias)
193
+ return type_to_json_schema(type.aliased_type)
194
+ end
195
+
191
196
  # Handle raw class types first
192
197
  if type.is_a?(Class)
193
198
  if type < T::Enum
@@ -257,6 +262,22 @@ module DSPy
257
262
  # Add a more explicit description of the expected structure
258
263
  description: "A mapping where keys are #{key_schema[:type]}s and values are #{value_schema[:description] || value_schema[:type]}s"
259
264
  }
265
+ elsif type.is_a?(T::Types::FixedHash)
266
+ # Handle fixed hashes (from type aliases like { "key" => Type })
267
+ properties = {}
268
+ required = []
269
+
270
+ type.types.each do |key, value_type|
271
+ properties[key] = type_to_json_schema(value_type)
272
+ required << key
273
+ end
274
+
275
+ {
276
+ type: "object",
277
+ properties: properties,
278
+ required: required,
279
+ additionalProperties: false
280
+ }
260
281
  elsif type.class.name == "T::Private::Types::SimplePairUnion"
261
282
  # Handle T.nilable types (T::Private::Types::SimplePairUnion)
262
283
  # This is the actual implementation of T.nilable(SomeType)
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.20.1"
4
+ VERSION = "0.22.0"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -9,6 +9,8 @@ require_relative 'dspy/errors'
9
9
  require_relative 'dspy/type_serializer'
10
10
  require_relative 'dspy/observability'
11
11
  require_relative 'dspy/context'
12
+ require_relative 'dspy/events'
13
+ require_relative 'dspy/events/types'
12
14
 
13
15
  module DSPy
14
16
  extend Dry::Configurable
@@ -34,18 +36,105 @@ module DSPy
34
36
  end
35
37
 
36
38
  def self.log(event, **attributes)
39
+ # Return nil early if logger is not configured (backward compatibility)
40
+ return nil unless logger
41
+
42
+ # Forward to event system - this maintains backward compatibility
43
+ # while providing all new event system benefits
44
+ event(event, attributes)
45
+
46
+ # Return nil to maintain backward compatibility
47
+ nil
48
+ end
49
+
50
+ def self.event(event_name_or_object, attributes = {})
51
+ # Handle typed event objects
52
+ if event_name_or_object.respond_to?(:name) && event_name_or_object.respond_to?(:to_attributes)
53
+ event_obj = event_name_or_object
54
+ event_name = event_obj.name
55
+ attributes = event_obj.to_attributes
56
+
57
+ # For LLM events, use OpenTelemetry semantic conventions for spans
58
+ if event_obj.is_a?(DSPy::Events::LLMEvent)
59
+ otel_attributes = event_obj.to_otel_attributes
60
+ create_event_span(event_name, otel_attributes)
61
+ else
62
+ create_event_span(event_name, attributes)
63
+ end
64
+ else
65
+ # Handle string event names (backward compatibility)
66
+ event_name = event_name_or_object
67
+ raise ArgumentError, "Event name cannot be nil" if event_name.nil?
68
+
69
+ # Handle nil attributes
70
+ attributes = {} if attributes.nil?
71
+
72
+ # Create OpenTelemetry span for the event if observability is enabled
73
+ create_event_span(event_name, attributes)
74
+ end
75
+
76
+ # Perform the actual logging (original DSPy.log behavior)
77
+ emit_log(event_name, attributes)
78
+
79
+ # Notify event listeners
80
+ events.notify(event_name, attributes)
81
+ end
82
+
83
+ def self.events
84
+ @event_registry ||= DSPy::EventRegistry.new
85
+ end
86
+
87
+ private
88
+
89
+ def self.emit_log(event_name, attributes)
37
90
  return unless logger
38
91
 
39
92
  # Merge context automatically (but don't include span_stack)
40
93
  context = Context.current.dup
41
94
  context.delete(:span_stack)
42
95
  attributes = context.merge(attributes)
43
- attributes[:event] = event
96
+ attributes[:event] = event_name
44
97
 
45
98
  # Use Dry::Logger's structured logging
46
99
  logger.info(attributes)
47
100
  end
48
101
 
102
+ def self.create_event_span(event_name, attributes)
103
+ return unless DSPy::Observability.enabled?
104
+
105
+ begin
106
+ # Flatten nested hashes for OpenTelemetry span attributes
107
+ flattened_attributes = flatten_attributes(attributes)
108
+
109
+ # Create and immediately finish a span for this event
110
+ # Events are instant moments in time, not ongoing operations
111
+ span = DSPy::Observability.start_span(event_name, flattened_attributes)
112
+ DSPy::Observability.finish_span(span) if span
113
+ rescue => e
114
+ # Log error but don't let it break the event system
115
+ # Use emit_log directly to avoid infinite recursion
116
+ emit_log('event.span_creation_error', {
117
+ error_class: e.class.name,
118
+ error_message: e.message,
119
+ event_name: event_name
120
+ })
121
+ end
122
+ end
123
+
124
+ def self.flatten_attributes(attributes, parent_key = '', result = {})
125
+ attributes.each do |key, value|
126
+ new_key = parent_key.empty? ? key.to_s : "#{parent_key}.#{key}"
127
+
128
+ if value.is_a?(Hash)
129
+ flatten_attributes(value, new_key, result)
130
+ else
131
+ result[new_key] = value
132
+ end
133
+ end
134
+
135
+ result
136
+ end
137
+
49
138
  def self.create_logger
50
139
  env = ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'
51
140
  log_output = ENV['DSPY_LOG'] # Allow override
@@ -101,6 +190,7 @@ require_relative 'dspy/image'
101
190
  require_relative 'dspy/strategy'
102
191
  require_relative 'dspy/prediction'
103
192
  require_relative 'dspy/predict'
193
+ require_relative 'dspy/events/subscribers'
104
194
  require_relative 'dspy/chain_of_thought'
105
195
  require_relative 'dspy/re_act'
106
196
  require_relative 'dspy/code_act'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.1
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-27 00:00:00.000000000 Z
10
+ date: 2025-09-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-configurable
@@ -192,6 +192,9 @@ files:
192
192
  - lib/dspy/error_formatter.rb
193
193
  - lib/dspy/errors.rb
194
194
  - lib/dspy/evaluate.rb
195
+ - lib/dspy/events.rb
196
+ - lib/dspy/events/subscribers.rb
197
+ - lib/dspy/events/types.rb
195
198
  - lib/dspy/example.rb
196
199
  - lib/dspy/few_shot_example.rb
197
200
  - lib/dspy/field.rb