dspy 0.10.1 → 0.12.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: b721c6c31be4c7a18cb488a7646ff356d658618a5a64ff36f50be9f731d9755a
4
- data.tar.gz: 2e13ea6bf39e6a89872d604f22c7b197bf50924058824ff2a3483d859f1ba744
3
+ metadata.gz: d40d9681a87073c6c4f5531025891bf871bee086434ee131e15a4391cba7b893
4
+ data.tar.gz: 1a1a4ce935e7cdf4ce9c06617f124ed8858d7074a3f6bec4484d6db24dbfa232
5
5
  SHA512:
6
- metadata.gz: b6966d789e0ebad1e6ee6c65deaef2dec1d67d483908e7c0847df84998b4f167ad346d20de974af7194db890fcccabe38f3b38ab33dde95b430fdcd3b86a6045
7
- data.tar.gz: 373e489777b036968ad5152aabc01141212c0409c849d89337075f1b2000de7504b8e2eaccca9755c4e52195b1d05d0667f9e4a8fe54c621cd90ba37b18837b0
6
+ metadata.gz: 2cdd3ee7e867344b4e45dee7fd0d73dc875ec2c730a2cf75e5f620a19c535f0aac50cc6c88dfb7beb3e075b147e7467ccd4e55a7724f49f3ac111eb080fd88e0
7
+ data.tar.gz: f5c5ed5dd3b6aa5401c20e31fbbd9638eb1d8c0f3b7fe5adb2e2b69bbb3b051d0ecd277d6e2f3b4d4896fd19f39a89345c9503d97106a3b8e005b1f55a3e5b2b
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # DSPy.rb
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/dspy)](https://rubygems.org/gems/dspy)
4
+ [![Total Downloads](https://img.shields.io/gem/dt/dspy)](https://rubygems.org/gems/dspy)
5
+
3
6
  **Build reliable LLM applications in Ruby using composable, type-safe modules.**
4
7
 
5
8
  DSPy.rb brings structured LLM programming to Ruby developers. Instead of wrestling with prompt strings and parsing responses, you define typed signatures and compose them into pipelines that just work.
@@ -43,7 +46,7 @@ The result? LLM applications that actually scale and don't break when you sneeze
43
46
 
44
47
  ## Development Status
45
48
 
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.
49
+ DSPy.rb is actively developed and approaching stability at **v0.10.1**. 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
50
 
48
51
  Real-world usage feedback is invaluable - if you encounter issues or have suggestions, please open a GitHub issue!
49
52
 
@@ -174,7 +177,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
174
177
 
175
178
  ## Roadmap - Battle-Testing Toward v1.0
176
179
 
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.
180
+ DSPy.rb is currently at **v0.10.1** and approaching stability. I'm focusing on real-world usage and refinement through the 0.11, 0.12+ series before committing to a stable v1.0 API.
178
181
 
179
182
  **Current Focus Areas:**
180
183
  - 🚧 **Ollama Support** - Local model integration
data/lib/dspy/errors.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  module DSPy
4
4
  class Error < StandardError; end
5
5
 
6
+ class ValidationError < Error; end
7
+
8
+ class DeserializationError < Error; end
9
+
6
10
  class ConfigurationError < Error
7
11
  def self.missing_lm(module_name)
8
12
  new(<<~MESSAGE)
data/lib/dspy/example.rb CHANGED
@@ -77,6 +77,17 @@ module DSPy
77
77
  expected_hash
78
78
  end
79
79
 
80
+ # Custom equality comparison
81
+ sig { params(other: T.untyped).returns(T::Boolean) }
82
+ def ==(other)
83
+ return false unless other.is_a?(Example)
84
+
85
+ @signature_class == other.signature_class &&
86
+ input_values == other.input_values &&
87
+ expected_values == other.expected_values
88
+ end
89
+
90
+
80
91
  # Check if prediction matches expected output using struct comparison
81
92
  sig { params(prediction: T.untyped).returns(T::Boolean) }
82
93
  def matches_prediction?(prediction)
@@ -179,15 +190,6 @@ module DSPy
179
190
  examples
180
191
  end
181
192
 
182
- # Equality comparison
183
- sig { params(other: T.untyped).returns(T::Boolean) }
184
- def ==(other)
185
- return false unless other.is_a?(Example)
186
-
187
- @signature_class == other.signature_class &&
188
- input_values == other.input_values &&
189
- expected_values == other.expected_values
190
- end
191
193
 
192
194
  # String representation for debugging
193
195
  sig { returns(String) }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ class LM
5
+ class MessageBuilder
6
+ attr_reader :messages
7
+
8
+ def initialize
9
+ @messages = []
10
+ end
11
+
12
+ def system(content)
13
+ @messages << { role: 'system', content: content.to_s }
14
+ self
15
+ end
16
+
17
+ def user(content)
18
+ @messages << { role: 'user', content: content.to_s }
19
+ self
20
+ end
21
+
22
+ def assistant(content)
23
+ @messages << { role: 'assistant', content: content.to_s }
24
+ self
25
+ end
26
+ end
27
+ end
28
+ end
data/lib/dspy/lm.rb CHANGED
@@ -18,6 +18,9 @@ require_relative 'lm/adapters/anthropic_adapter'
18
18
  require_relative 'lm/strategy_selector'
19
19
  require_relative 'lm/retry_handler'
20
20
 
21
+ # Load message builder
22
+ require_relative 'lm/message_builder'
23
+
21
24
  module DSPy
22
25
  class LM
23
26
  attr_reader :model_id, :api_key, :model, :provider, :adapter
@@ -39,41 +42,13 @@ module DSPy
39
42
  # Build messages from inference module
40
43
  messages = build_messages(inference_module, input_values)
41
44
 
42
- # Calculate input size for monitoring
43
- input_text = messages.map { |m| m[:content] }.join(' ')
44
- input_size = input_text.length
45
-
46
- # Use smart consolidation: emit LM events only when not in nested context
47
- response = nil
48
- token_usage = {}
45
+ # Execute with instrumentation
46
+ response = instrument_lm_request(messages, signature_class.name) do
47
+ chat_with_strategy(messages, signature_class, &block)
48
+ end
49
49
 
50
+ # Instrument response parsing
50
51
  if should_emit_lm_events?
51
- # Emit all LM events when not in nested context
52
- response = Instrumentation.instrument('dspy.lm.request', {
53
- gen_ai_operation_name: 'chat',
54
- gen_ai_system: provider,
55
- gen_ai_request_model: model,
56
- signature_class: signature_class.name,
57
- provider: provider,
58
- adapter_class: adapter.class.name,
59
- input_size: input_size
60
- }) do
61
- chat_with_strategy(messages, signature_class, &block)
62
- end
63
-
64
- # Extract actual token usage from response (more accurate than estimation)
65
- token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
66
-
67
- # Emit token usage event if available
68
- if token_usage.any?
69
- Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
70
- gen_ai_system: provider,
71
- gen_ai_request_model: model,
72
- signature_class: signature_class.name
73
- }))
74
- end
75
-
76
- # Instrument response parsing
77
52
  parsed_result = Instrumentation.instrument('dspy.lm.response.parsed', {
78
53
  signature_class: signature_class.name,
79
54
  provider: provider,
@@ -82,15 +57,33 @@ module DSPy
82
57
  parse_response(response, input_values, signature_class)
83
58
  end
84
59
  else
85
- # Consolidated mode: execute without nested instrumentation
86
- response = chat_with_strategy(messages, signature_class, &block)
87
- token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
88
60
  parsed_result = parse_response(response, input_values, signature_class)
89
61
  end
90
62
 
91
63
  parsed_result
92
64
  end
93
65
 
66
+ def raw_chat(messages = nil, &block)
67
+ # Support both array format and builder DSL
68
+ if block_given? && messages.nil?
69
+ # DSL mode - block is for building messages
70
+ builder = MessageBuilder.new
71
+ yield builder
72
+ messages = builder.messages
73
+ streaming_block = nil
74
+ else
75
+ # Array mode - block is for streaming
76
+ messages ||= []
77
+ streaming_block = block
78
+ end
79
+
80
+ # Validate messages format
81
+ validate_messages!(messages)
82
+
83
+ # Execute with instrumentation
84
+ execute_raw_chat(messages, &streaming_block)
85
+ end
86
+
94
87
  private
95
88
 
96
89
  def chat_with_strategy(messages, signature_class, &block)
@@ -208,5 +201,77 @@ module DSPy
208
201
  raise "Failed to parse LLM response as JSON: #{e.message}. Original content length: #{response.content&.length || 0} chars"
209
202
  end
210
203
  end
204
+
205
+ # Common instrumentation method for LM requests
206
+ def instrument_lm_request(messages, signature_class_name, &execution_block)
207
+ input_text = messages.map { |m| m[:content] }.join(' ')
208
+ input_size = input_text.length
209
+
210
+ response = nil
211
+
212
+ if should_emit_lm_events?
213
+ # Emit dspy.lm.request event
214
+ response = Instrumentation.instrument('dspy.lm.request', {
215
+ gen_ai_operation_name: 'chat',
216
+ gen_ai_system: provider,
217
+ gen_ai_request_model: model,
218
+ signature_class: signature_class_name,
219
+ provider: provider,
220
+ adapter_class: adapter.class.name,
221
+ input_size: input_size
222
+ }, &execution_block)
223
+
224
+ # Extract and emit token usage
225
+ emit_token_usage(response, signature_class_name)
226
+ else
227
+ # Consolidated mode: execute without instrumentation
228
+ response = execution_block.call
229
+ end
230
+
231
+ response
232
+ end
233
+
234
+ # Common method to emit token usage events
235
+ def emit_token_usage(response, signature_class_name)
236
+ token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
237
+
238
+ if token_usage.any?
239
+ Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
240
+ gen_ai_system: provider,
241
+ gen_ai_request_model: model,
242
+ signature_class: signature_class_name
243
+ }))
244
+ end
245
+
246
+ token_usage
247
+ end
248
+
249
+ def validate_messages!(messages)
250
+ unless messages.is_a?(Array)
251
+ raise ArgumentError, "messages must be an array"
252
+ end
253
+
254
+ valid_roles = %w[system user assistant]
255
+
256
+ messages.each do |message|
257
+ unless message.is_a?(Hash) && message.key?(:role) && message.key?(:content)
258
+ raise ArgumentError, "Each message must have :role and :content"
259
+ end
260
+
261
+ unless valid_roles.include?(message[:role])
262
+ raise ArgumentError, "Invalid role: #{message[:role]}. Must be one of: #{valid_roles.join(', ')}"
263
+ end
264
+ end
265
+ end
266
+
267
+ def execute_raw_chat(messages, &streaming_block)
268
+ response = instrument_lm_request(messages, 'RawPrompt') do
269
+ # Direct adapter call, no strategies or JSON parsing
270
+ adapter.chat(messages: messages, signature: nil, &streaming_block)
271
+ end
272
+
273
+ # Return raw response content, not parsed JSON
274
+ response.content
275
+ end
211
276
  end
212
277
  end
@@ -319,6 +319,9 @@ module DSPy
319
319
  end
320
320
 
321
321
  value.each do |k, v|
322
+ # Skip _type field from being added to the struct (it's not a real field)
323
+ next if k == :_type || k == "_type"
324
+
322
325
  prop_info = struct_class.props[k]
323
326
  if prop_info
324
327
  prop_type = prop_info[:type_object] || prop_info[:type]
@@ -343,7 +346,27 @@ module DSPy
343
346
  value
344
347
  end
345
348
  when T::Types::Union
346
- # For unions without discriminator, try each type
349
+ # Check if value has a _type field for automatic type detection
350
+ type_name = value[:_type] || value["_type"]
351
+
352
+ if type_name
353
+ # Use _type field to determine which struct to instantiate
354
+ type.types.each do |t|
355
+ next if t == T::Utils.coerce(NilClass)
356
+
357
+ if t.is_a?(T::Types::Simple) && t.raw_type < T::Struct
358
+ struct_name = t.raw_type.name.split('::').last
359
+ if struct_name == type_name
360
+ return convert_to_struct(value, t)
361
+ end
362
+ end
363
+ end
364
+
365
+ # If no matching type found, raise an error
366
+ raise DSPy::DeserializationError, "Unknown type: #{type_name}. Expected one of: #{type.types.map { |t| t.is_a?(T::Types::Simple) && t.raw_type < T::Struct ? t.raw_type.name.split('::').last : nil }.compact.join(', ')}"
367
+ end
368
+
369
+ # Fallback to trying each type if no _type field
347
370
  type.types.each do |t|
348
371
  next if t == T::Utils.coerce(NilClass)
349
372
 
@@ -398,7 +421,27 @@ module DSPy
398
421
 
399
422
  sig { params(hash: T::Hash[Symbol, T.untyped], union_type: T::Types::Union).returns(T.untyped) }
400
423
  def convert_hash_to_union_struct(hash, union_type)
401
- # Try to match the hash structure to one of the union types
424
+ # First check if hash has a _type field for automatic type detection
425
+ type_name = hash[:_type] || hash["_type"]
426
+
427
+ if type_name
428
+ # Use _type field to determine which struct to instantiate
429
+ union_type.types.each do |type|
430
+ next if type == T::Utils.coerce(NilClass)
431
+
432
+ if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
433
+ struct_name = type.raw_type.name.split('::').last
434
+ if struct_name == type_name
435
+ return convert_to_struct(hash, type)
436
+ end
437
+ end
438
+ end
439
+
440
+ # If no matching type found, raise an error
441
+ raise DSPy::DeserializationError, "Unknown type: #{type_name}. Expected one of: #{union_type.types.map { |t| t.is_a?(T::Types::Simple) && t.raw_type < T::Struct ? t.raw_type.name.split('::').last : nil }.compact.join(', ')}"
442
+ end
443
+
444
+ # Fallback: Try to match the hash structure to one of the union types
402
445
  union_type.types.each do |type|
403
446
  next if type == T::Utils.coerce(NilClass)
404
447
 
@@ -412,6 +455,9 @@ module DSPy
412
455
  # Need to convert nested values too
413
456
  converted_hash = {}
414
457
  hash.each do |k, v|
458
+ # Skip _type field
459
+ next if k == :_type || k == "_type"
460
+
415
461
  prop_info = struct_class.props[k]
416
462
  if prop_info
417
463
  prop_type = prop_info[:type_object] || prop_info[:type]
data/lib/dspy/re_act.rb CHANGED
@@ -256,7 +256,7 @@ module DSPy
256
256
  }) do
257
257
  # Generate thought and action
258
258
  thought_obj = @thought_generator.forward(
259
- input_context: input_struct.serialize.to_json,
259
+ input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
260
260
  history: history,
261
261
  available_tools: available_tools_desc
262
262
  )
@@ -385,7 +385,7 @@ module DSPy
385
385
  return { should_finish: false } if observation.include?("Unknown action")
386
386
 
387
387
  observation_result = @observation_processor.forward(
388
- input_context: input_struct.serialize.to_json,
388
+ input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
389
389
  history: history,
390
390
  observation: observation
391
391
  )
@@ -402,7 +402,7 @@ module DSPy
402
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
403
  def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
404
404
  final_thought = @thought_generator.forward(
405
- input_context: input_struct.serialize.to_json,
405
+ input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
406
406
  history: history,
407
407
  available_tools: available_tools_desc
408
408
  )
@@ -292,6 +292,19 @@ module DSPy
292
292
  properties = {}
293
293
  required = []
294
294
 
295
+ # Check if struct already has a _type field
296
+ if struct_class.props.key?(:_type)
297
+ raise DSPy::ValidationError, "_type field conflict: #{struct_class.name} already has a _type field defined. " \
298
+ "DSPy uses _type for automatic type detection in union types."
299
+ end
300
+
301
+ # Add automatic _type field for type detection
302
+ properties[:_type] = {
303
+ type: "string",
304
+ const: struct_class.name.split('::').last # Use the simple class name
305
+ }
306
+ required << "_type"
307
+
295
308
  struct_class.props.each do |prop_name, prop_info|
296
309
  prop_type = prop_info[:type_object] || prop_info[:type]
297
310
  properties[prop_name] = type_to_json_schema(prop_type)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+
5
+ module DSPy
6
+ class TypeSerializer
7
+ extend T::Sig
8
+
9
+ # Serialize a value, injecting _type fields for T::Struct instances
10
+ sig { params(value: T.untyped).returns(T.untyped) }
11
+ def self.serialize(value)
12
+ case value
13
+ when T::Struct
14
+ serialize_struct(value)
15
+ when Array
16
+ value.map { |item| serialize(item) }
17
+ when Hash
18
+ value.transform_values { |v| serialize(v) }
19
+ else
20
+ value
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ sig { params(struct: T::Struct).returns(T::Hash[String, T.untyped]) }
27
+ def self.serialize_struct(struct)
28
+ # Handle anonymous structs that don't have a name
29
+ class_name = struct.class.name
30
+ type_name = if class_name.nil? || class_name.empty?
31
+ # For anonymous structs, use a generic identifier
32
+ "AnonymousStruct"
33
+ else
34
+ class_name.split('::').last
35
+ end
36
+
37
+ result = {
38
+ "_type" => type_name
39
+ }
40
+
41
+ # Get all props and serialize their values
42
+ struct.class.props.each do |prop_name, prop_info|
43
+ prop_value = struct.send(prop_name)
44
+
45
+ # Skip nil values for optional fields
46
+ next if prop_value.nil? && prop_info[:fully_optional]
47
+
48
+ # Recursively serialize nested values
49
+ result[prop_name.to_s] = serialize(prop_value)
50
+ end
51
+
52
+ result
53
+ end
54
+ end
55
+ end
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.10.1"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -6,6 +6,7 @@ require 'securerandom'
6
6
 
7
7
  require_relative 'dspy/version'
8
8
  require_relative 'dspy/errors'
9
+ require_relative 'dspy/type_serializer'
9
10
 
10
11
  module DSPy
11
12
  extend Dry::Configurable
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-20 00:00:00.000000000 Z
11
+ date: 2025-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-configurable
@@ -177,6 +177,7 @@ files:
177
177
  - lib/dspy/lm/adapters/openai_adapter.rb
178
178
  - lib/dspy/lm/cache_manager.rb
179
179
  - lib/dspy/lm/errors.rb
180
+ - lib/dspy/lm/message_builder.rb
180
181
  - lib/dspy/lm/response.rb
181
182
  - lib/dspy/lm/retry_handler.rb
182
183
  - lib/dspy/lm/strategies/anthropic_extraction_strategy.rb
@@ -223,6 +224,7 @@ files:
223
224
  - lib/dspy/tools/memory_toolset.rb
224
225
  - lib/dspy/tools/text_processing_toolset.rb
225
226
  - lib/dspy/tools/toolset.rb
227
+ - lib/dspy/type_serializer.rb
226
228
  - lib/dspy/version.rb
227
229
  homepage: https://github.com/vicentereig/dspy.rb
228
230
  licenses: